mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
add is_billable support for create / update project and in timetracker, fixes ST-217
This commit is contained in:
committed by
Constantin Graf
parent
d9244d1ab4
commit
7fb58ea341
@@ -73,3 +73,5 @@ NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
|||||||
|
|
||||||
FORWARD_DB_PORT=5432
|
FORWARD_DB_PORT=5432
|
||||||
FORWARD_WEB_PORT=8083
|
FORWARD_WEB_PORT=8083
|
||||||
|
|
||||||
|
PAGINATION_PER_PAGE_DEFAULT=500
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class OrganizationController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get organization
|
* Get organization
|
||||||
*
|
*
|
||||||
|
* @operationId getOrganization
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
public function show(Organization $organization): OrganizationResource
|
public function show(Organization $organization): OrganizationResource
|
||||||
@@ -26,6 +27,7 @@ class OrganizationController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Update organization
|
* Update organization
|
||||||
*
|
*
|
||||||
|
* @operationId updateOrganization
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
public function update(Organization $organization, OrganizationUpdateRequest $request): OrganizationResource
|
public function update(Organization $organization, OrganizationUpdateRequest $request): OrganizationResource
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class OrganizationResource extends BaseResource
|
|||||||
'id' => $this->resource->id,
|
'id' => $this->resource->id,
|
||||||
/** @var string $name Name */
|
/** @var string $name Name */
|
||||||
'name' => $this->resource->name,
|
'name' => $this->resource->name,
|
||||||
/** @var string $color Personal organizations automatically created after registration */
|
/** @var bool $color Personal organizations automatically created after registration */
|
||||||
'is_personal' => $this->resource->personal_team,
|
'is_personal' => $this->resource->personal_team,
|
||||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||||
'billable_rate' => $this->resource->billable_rate,
|
'billable_rate' => $this->resource->billable_rate,
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ class ProjectResource extends BaseResource
|
|||||||
'client_id' => $this->resource->client_id,
|
'client_id' => $this->resource->client_id,
|
||||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||||
'billable_rate' => $this->resource->billable_rate,
|
'billable_rate' => $this->resource->billable_rate,
|
||||||
|
/** @var bool $is_billable Project time entries billable default */
|
||||||
|
'is_billable' => $this->resource->is_billable,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,11 +52,11 @@ const OrganizationResource = z
|
|||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
is_personal: z.string(),
|
is_personal: z.boolean(),
|
||||||
billable_rate: z.union([z.number(), z.null()]),
|
billable_rate: z.union([z.number(), z.null()]),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const v1_organizations_update_Body = z
|
const updateOrganization_Body = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
billable_rate: z.union([z.number(), z.null()]).optional(),
|
billable_rate: z.union([z.number(), z.null()]).optional(),
|
||||||
@@ -69,12 +69,14 @@ const ProjectResource = z
|
|||||||
color: z.string(),
|
color: z.string(),
|
||||||
client_id: z.union([z.string(), z.null()]),
|
client_id: z.union([z.string(), z.null()]),
|
||||||
billable_rate: z.union([z.number(), z.null()]),
|
billable_rate: z.union([z.number(), z.null()]),
|
||||||
|
is_billable: z.boolean(),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
const createProject_Body = z
|
const createProject_Body = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
color: z.string(),
|
color: z.string(),
|
||||||
|
is_billable: z.boolean(),
|
||||||
billable_rate: z.union([z.number(), z.null()]).optional(),
|
billable_rate: z.union([z.number(), z.null()]).optional(),
|
||||||
client_id: z.union([z.string(), z.null()]).optional(),
|
client_id: z.union([z.string(), z.null()]).optional(),
|
||||||
})
|
})
|
||||||
@@ -188,7 +190,7 @@ export const schemas = {
|
|||||||
updateMember_Body,
|
updateMember_Body,
|
||||||
MemberResource,
|
MemberResource,
|
||||||
OrganizationResource,
|
OrganizationResource,
|
||||||
v1_organizations_update_Body,
|
updateOrganization_Body,
|
||||||
ProjectResource,
|
ProjectResource,
|
||||||
createProject_Body,
|
createProject_Body,
|
||||||
ProjectMemberResource,
|
ProjectMemberResource,
|
||||||
@@ -210,7 +212,7 @@ const endpoints = makeApi([
|
|||||||
{
|
{
|
||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/v1/organizations/:organization',
|
path: '/v1/organizations/:organization',
|
||||||
alias: 'v1.organizations.show',
|
alias: 'getOrganization',
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
@@ -236,13 +238,13 @@ const endpoints = makeApi([
|
|||||||
{
|
{
|
||||||
method: 'put',
|
method: 'put',
|
||||||
path: '/v1/organizations/:organization',
|
path: '/v1/organizations/:organization',
|
||||||
alias: 'v1.organizations.update',
|
alias: 'updateOrganization',
|
||||||
requestFormat: 'json',
|
requestFormat: 'json',
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
name: 'body',
|
name: 'body',
|
||||||
type: 'Body',
|
type: 'Body',
|
||||||
schema: v1_organizations_update_Body,
|
schema: updateOrganization_Body,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'organization',
|
name: 'organization',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const test = baseTest.extend<object, { workerStorageState: string }>({
|
|||||||
await page.getByLabel('Name').fill('John Doe');
|
await page.getByLabel('Name').fill('John Doe');
|
||||||
await page
|
await page
|
||||||
.getByLabel('Email')
|
.getByLabel('Email')
|
||||||
.fill(`john+${Math.round(Math.random() * 10000)}@doe.com`);
|
.fill(`john+${Math.round(Math.random() * 1000000)}@doe.com`);
|
||||||
await page
|
await page
|
||||||
.getByLabel('Password', { exact: true })
|
.getByLabel('Password', { exact: true })
|
||||||
.fill('amazingpassword123');
|
.fill('amazingpassword123');
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ const inputValue = ref(formatValue(model.value));
|
|||||||
ref="billableRateInput"
|
ref="billableRateInput"
|
||||||
v-model="inputValue"
|
v-model="inputValue"
|
||||||
@blur="updateRate($event.target.value)"
|
@blur="updateRate($event.target.value)"
|
||||||
|
@keydown.enter="updateRate($event.target.value)"
|
||||||
type="text"
|
type="text"
|
||||||
:name="name"
|
:name="name"
|
||||||
placeholder="Billable Rate"
|
placeholder="Billable Rate"
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SelectDropdown from '@/Components/Common/SelectDropdown.vue';
|
||||||
|
import type { BillableKey } from '@/utils/useProjects';
|
||||||
|
import Badge from '@/Components/Common/Badge.vue';
|
||||||
|
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
|
||||||
|
|
||||||
|
const model = defineModel<BillableKey>({
|
||||||
|
default: 'non-billable',
|
||||||
|
});
|
||||||
|
|
||||||
|
type Option = { key: BillableKey; name: string };
|
||||||
|
|
||||||
|
const options: Option[] = [
|
||||||
|
{
|
||||||
|
key: 'non-billable',
|
||||||
|
name: 'Non-billable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'default-rate',
|
||||||
|
name: 'Default Rate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'custom-rate',
|
||||||
|
name: 'Custom Rate',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function getKeyFromItem(item: Option) {
|
||||||
|
return item.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNameFromItem(item: Option) {
|
||||||
|
return item.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNameForKey(key: BillableKey | undefined) {
|
||||||
|
const item = options.find((item) => getKeyFromItem(item) === key);
|
||||||
|
if (item) {
|
||||||
|
return getNameFromItem(item);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectDropdown
|
||||||
|
v-model="model"
|
||||||
|
:get-key-from-item="getKeyFromItem"
|
||||||
|
:get-name-for-item="getNameFromItem"
|
||||||
|
:items="options">
|
||||||
|
<template #trigger>
|
||||||
|
<Badge size="xlarge" class="bg-input-background cursor-pointer">
|
||||||
|
<span>
|
||||||
|
{{ getNameForKey(model) }}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon class="text-muted w-5"></ChevronDownIcon>
|
||||||
|
</Badge>
|
||||||
|
</template>
|
||||||
|
</SelectDropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -13,9 +13,9 @@ import Badge from '@/Components/Common/Badge.vue';
|
|||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelector.vue';
|
import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelector.vue';
|
||||||
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
|
|
||||||
import { UserCircleIcon } from '@heroicons/vue/20/solid';
|
import { UserCircleIcon } from '@heroicons/vue/20/solid';
|
||||||
import InputLabel from '@/Components/InputLabel.vue';
|
import InputLabel from '@/Components/InputLabel.vue';
|
||||||
|
import ProjectEditBillableSection from '@/Components/Common/Project/ProjectEditBillableSection.vue';
|
||||||
|
|
||||||
const { createProject } = useProjectsStore();
|
const { createProject } = useProjectsStore();
|
||||||
const { clients } = storeToRefs(useClientsStore());
|
const { clients } = storeToRefs(useClientsStore());
|
||||||
@@ -27,6 +27,7 @@ const project = ref<CreateProjectBody>({
|
|||||||
color: getRandomColor(),
|
color: getRandomColor(),
|
||||||
client_id: null,
|
client_id: null,
|
||||||
billable_rate: null,
|
billable_rate: null,
|
||||||
|
is_billable: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@@ -37,6 +38,7 @@ async function submit() {
|
|||||||
color: getRandomColor(),
|
color: getRandomColor(),
|
||||||
client_id: null,
|
client_id: null,
|
||||||
billable_rate: null,
|
billable_rate: null,
|
||||||
|
is_billable: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,12 +89,6 @@ const currentClientName = computed(() => {
|
|||||||
autocomplete="projectName" />
|
autocomplete="projectName" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sm:max-w-[120px]">
|
|
||||||
<InputLabel for="billableRate" value="Billable Rate" />
|
|
||||||
<BillableRateInput
|
|
||||||
v-model="project.billable_rate"
|
|
||||||
name="billableRate" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<InputLabel for="client" value="Client" />
|
<InputLabel for="client" value="Client" />
|
||||||
<ClientDropdown class="mt-2" v-model="project.client_id">
|
<ClientDropdown class="mt-2" v-model="project.client_id">
|
||||||
@@ -112,10 +108,14 @@ const currentClientName = computed(() => {
|
|||||||
</ClientDropdown>
|
</ClientDropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ProjectEditBillableSection
|
||||||
|
v-model:isBillable="project.is_billable"
|
||||||
|
v-model:billableRate="
|
||||||
|
project.billable_rate
|
||||||
|
"></ProjectEditBillableSection>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
||||||
|
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
class="ms-3"
|
class="ms-3"
|
||||||
:class="{ 'opacity-25': saving }"
|
:class="{ 'opacity-25': saving }"
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ async function addProjectIfNoneExists() {
|
|||||||
{
|
{
|
||||||
name: searchValue.value,
|
name: searchValue.value,
|
||||||
color: getRandomColor(),
|
color: getRandomColor(),
|
||||||
|
is_billable: false,
|
||||||
},
|
},
|
||||||
{ params: { organization: page.props.auth.user.current_team_id } }
|
{ params: { organization: page.props.auth.user.current_team_id } }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import InputLabel from '@/Components/InputLabel.vue';
|
||||||
|
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
|
||||||
|
import ProjectBillableSelect from '@/Components/Common/Project/ProjectBillableSelect.vue';
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import type { BillableKey } from '@/utils/useProjects';
|
||||||
|
|
||||||
|
const billableRateSelect = ref<BillableKey>('non-billable');
|
||||||
|
|
||||||
|
const billableRate = defineModel<number | null>('billableRate');
|
||||||
|
const isBillable = defineModel<boolean>('isBillable');
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (isBillable.value === true) {
|
||||||
|
if (billableRate.value) {
|
||||||
|
billableRateSelect.value = 'custom-rate';
|
||||||
|
} else {
|
||||||
|
billableRateSelect.value = 'default-rate';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(billableRateSelect, () => {
|
||||||
|
if (billableRateSelect.value === 'non-billable') {
|
||||||
|
isBillable.value = false;
|
||||||
|
billableRate.value = null;
|
||||||
|
} else if (billableRateSelect.value === 'default-rate') {
|
||||||
|
isBillable.value = true;
|
||||||
|
billableRate.value = null;
|
||||||
|
} else {
|
||||||
|
isBillable.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
billableRateSelect.value = 'non-billable';
|
||||||
|
|
||||||
|
const billableOptionInfoTexts: { [key in BillableKey]: string } = {
|
||||||
|
'non-billable':
|
||||||
|
'New time entries for this project not be marked billable by default.',
|
||||||
|
'default-rate':
|
||||||
|
'New time entries for this project will be billable at the default rate by default.',
|
||||||
|
'custom-rate':
|
||||||
|
'New time entries for this project will be billable at a custom rate by default.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const billableOptionInfoText = computed(() => {
|
||||||
|
return billableOptionInfoTexts[billableRateSelect.value];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4 pt-6">
|
||||||
|
<div>
|
||||||
|
<InputLabel for="billable" value="Billable Default" />
|
||||||
|
<ProjectBillableSelect
|
||||||
|
v-model="billableRateSelect"
|
||||||
|
class="mt-2"></ProjectBillableSelect>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="sm:max-w-[120px]"
|
||||||
|
v-if="billableRateSelect === 'custom-rate'">
|
||||||
|
<InputLabel for="billableRate" value="Billable Rate" />
|
||||||
|
<BillableRateInput v-model="billableRate" name="billableRate" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-muted pt-2 pl-1">
|
||||||
|
<span>
|
||||||
|
<span class="font-semibold"> Info: </span>
|
||||||
|
{{ billableOptionInfoText }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -12,8 +12,8 @@ import { twMerge } from 'tailwind-merge';
|
|||||||
import Badge from '@/Components/Common/Badge.vue';
|
import Badge from '@/Components/Common/Badge.vue';
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
|
|
||||||
import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelector.vue';
|
import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelector.vue';
|
||||||
|
import ProjectEditBillableSection from '@/Components/Common/Project/ProjectEditBillableSection.vue';
|
||||||
|
|
||||||
const { updateProject } = useProjectsStore();
|
const { updateProject } = useProjectsStore();
|
||||||
const { clients } = storeToRefs(useClientsStore());
|
const { clients } = storeToRefs(useClientsStore());
|
||||||
@@ -29,6 +29,7 @@ const project = ref<CreateProjectBody>({
|
|||||||
color: props.originalProject.color,
|
color: props.originalProject.color,
|
||||||
client_id: props.originalProject.client_id,
|
client_id: props.originalProject.client_id,
|
||||||
billable_rate: props.originalProject.billable_rate,
|
billable_rate: props.originalProject.billable_rate,
|
||||||
|
is_billable: props.originalProject.is_billable,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@@ -77,11 +78,6 @@ const currentClientName = computed(() => {
|
|||||||
required
|
required
|
||||||
autocomplete="projectName" />
|
autocomplete="projectName" />
|
||||||
</div>
|
</div>
|
||||||
<div class="sm:max-w-[120px]">
|
|
||||||
<BillableRateInput
|
|
||||||
v-model="project.billable_rate"
|
|
||||||
name="billable_rate" />
|
|
||||||
</div>
|
|
||||||
<div class="">
|
<div class="">
|
||||||
<ClientDropdown v-model="project.client_id">
|
<ClientDropdown v-model="project.client_id">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
@@ -98,6 +94,11 @@ const currentClientName = computed(() => {
|
|||||||
</ClientDropdown>
|
</ClientDropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ProjectEditBillableSection
|
||||||
|
v-model:isBillable="project.is_billable"
|
||||||
|
v-model:billableRate="
|
||||||
|
project.billable_rate
|
||||||
|
"></ProjectEditBillableSection>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ function deleteProject() {
|
|||||||
useProjectsStore().deleteProject(props.project.id);
|
useProjectsStore().deleteProject(props.project.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const billableRateInfo = computed(() => {
|
||||||
|
if (props.project.is_billable) {
|
||||||
|
if (props.project.billable_rate) {
|
||||||
|
return formatCents(props.project.billable_rate);
|
||||||
|
} else {
|
||||||
|
return 'Default Rate';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '--';
|
||||||
|
});
|
||||||
|
|
||||||
const showEditProjectModal = ref(false);
|
const showEditProjectModal = ref(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -61,11 +72,7 @@ const showEditProjectModal = ref(false);
|
|||||||
<div v-else>No client</div>
|
<div v-else>No client</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
|
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
|
||||||
{{
|
{{ billableRateInfo }}
|
||||||
project.billable_rate
|
|
||||||
? formatCents(project.billable_rate)
|
|
||||||
: '--'
|
|
||||||
}}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap px-3 py-4 text-sm text-muted flex space-x-1 items-center font-medium">
|
class="whitespace-nowrap px-3 py-4 text-sm text-muted flex space-x-1 items-center font-medium">
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ watch(open, () => {
|
|||||||
<template>
|
<template>
|
||||||
<Dropdown v-model="open" align="bottom-start" :closeOnContentClick="false">
|
<Dropdown v-model="open" align="bottom-start" :closeOnContentClick="false">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<slot name="trigger"></slot>
|
<slot name="trigger"> </slot>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div ref="dropdownViewport" class="w-60">
|
<div ref="dropdownViewport" class="w-60">
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ const props = defineProps<{
|
|||||||
<div
|
<div
|
||||||
:class="
|
:class="
|
||||||
twMerge(
|
twMerge(
|
||||||
'flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out',
|
'flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out cursor-pointer ',
|
||||||
|
props.selected
|
||||||
props.selected ? 'bg-accent-300/20' : 'hover:bg-card-background'
|
? 'bg-accent-300/20'
|
||||||
|
: 'hover:bg-card-background-active'
|
||||||
)
|
)
|
||||||
">
|
">
|
||||||
<span>{{ name }}</span>
|
<span>{{ name }}</span>
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ const filteredProjects = computed(() => {
|
|||||||
value: '',
|
value: '',
|
||||||
client_id: null,
|
client_id: null,
|
||||||
billable_rate: null,
|
billable_rate: null,
|
||||||
|
is_billable: false,
|
||||||
},
|
},
|
||||||
tasks: [],
|
tasks: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { getCurrentOrganizationId } from '@/utils/useUser';
|
|||||||
import { switchOrganization } from '@/utils/useOrganization';
|
import { switchOrganization } from '@/utils/useOrganization';
|
||||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||||
import TimeTrackerRangeSelector from '@/Components/Common/TimeTracker/TimeTrackerRangeSelector.vue';
|
import TimeTrackerRangeSelector from '@/Components/Common/TimeTracker/TimeTrackerRangeSelector.vue';
|
||||||
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
|
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
auth: {
|
auth: {
|
||||||
@@ -48,6 +49,22 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function setBillableDefaultForProject() {
|
||||||
|
const projectssStore = useProjectsStore();
|
||||||
|
const { projects } = storeToRefs(projectssStore);
|
||||||
|
const project = projects.value.find(
|
||||||
|
(project) => project.id === currentTimeEntry.value.project_id
|
||||||
|
);
|
||||||
|
if (project) {
|
||||||
|
currentTimeEntry.value.billable = project.is_billable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProject() {
|
||||||
|
setBillableDefaultForProject();
|
||||||
|
updateTimeEntry();
|
||||||
|
}
|
||||||
|
|
||||||
function updateTimeEntry() {
|
function updateTimeEntry() {
|
||||||
if (currentTimeEntry.value.id) {
|
if (currentTimeEntry.value.id) {
|
||||||
useCurrentTimeEntryStore().updateTimer();
|
useCurrentTimeEntryStore().updateTimer();
|
||||||
@@ -108,7 +125,7 @@ function switchToTimeEntryOrganization() {
|
|||||||
<div class="flex items-center justify-between pl-2">
|
<div class="flex items-center justify-between pl-2">
|
||||||
<div class="flex items-center w-[130px] sm:w-auto">
|
<div class="flex items-center w-[130px] sm:w-auto">
|
||||||
<TimeTrackerProjectTaskDropdown
|
<TimeTrackerProjectTaskDropdown
|
||||||
@changed="updateTimeEntry"
|
@changed="updateProject"
|
||||||
v-model:project="currentTimeEntry.project_id"
|
v-model:project="currentTimeEntry.project_id"
|
||||||
v-model:task="
|
v-model:task="
|
||||||
currentTimeEntry.task_id
|
currentTimeEntry.task_id
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import FormSection from '@/Components/FormSection.vue';
|
||||||
|
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import InputLabel from '@/Components/InputLabel.vue';
|
||||||
|
import type { UpdateOrganizationBody } from '@/utils/api';
|
||||||
|
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
|
||||||
|
import { useOrganizationStore } from '@/utils/useOrganization';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
|
const store = useOrganizationStore();
|
||||||
|
const { fetchOrganization, updateOrganization } = store;
|
||||||
|
const { organization } = storeToRefs(store);
|
||||||
|
|
||||||
|
const organizationBody = ref<UpdateOrganizationBody>({
|
||||||
|
name: '',
|
||||||
|
billable_rate: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchOrganization();
|
||||||
|
organizationBody.value = {
|
||||||
|
name: organization.value?.name ?? '',
|
||||||
|
billable_rate: organization.value?.billable_rate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
updateOrganization(organizationBody.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormSection>
|
||||||
|
<template #title> Billable Rate</template>
|
||||||
|
|
||||||
|
<template #description>
|
||||||
|
Configure the default billable rate for the organization.
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #form>
|
||||||
|
<!-- Organization Owner Information -->
|
||||||
|
<div class="col-span-6">
|
||||||
|
<div class="col-span-6 sm:col-span-4">
|
||||||
|
<InputLabel
|
||||||
|
for="organizationBillableRate"
|
||||||
|
value="Organization Billable Rate" />
|
||||||
|
<BillableRateInput
|
||||||
|
v-if="organization"
|
||||||
|
v-model="organizationBody.billable_rate"
|
||||||
|
name="organizationBillableRate"></BillableRateInput>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<PrimaryButton @click="submit">Save</PrimaryButton>
|
||||||
|
</template>
|
||||||
|
</FormSection>
|
||||||
|
</template>
|
||||||
@@ -8,6 +8,7 @@ import type { Organization } from '@/types/models';
|
|||||||
import type { Permissions, Role } from '@/types/jetstream';
|
import type { Permissions, Role } from '@/types/jetstream';
|
||||||
import ImportData from '@/Pages/Teams/Partials/ImportData.vue';
|
import ImportData from '@/Pages/Teams/Partials/ImportData.vue';
|
||||||
import { canUpdateOrganization } from '@/utils/permissions';
|
import { canUpdateOrganization } from '@/utils/permissions';
|
||||||
|
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
team: Organization;
|
team: Organization;
|
||||||
@@ -28,6 +29,11 @@ defineProps<{
|
|||||||
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
|
||||||
<UpdateTeamNameForm :team="team" :permissions="permissions" />
|
<UpdateTeamNameForm :team="team" :permissions="permissions" />
|
||||||
|
|
||||||
|
<SectionBorder />
|
||||||
|
<OrganizationBillableRate
|
||||||
|
v-if="canUpdateOrganization()"
|
||||||
|
:team="team" />
|
||||||
|
|
||||||
<TeamMemberManager
|
<TeamMemberManager
|
||||||
class="mt-10 sm:mt-0"
|
class="mt-10 sm:mt-0"
|
||||||
:team="team"
|
:team="team"
|
||||||
|
|||||||
@@ -108,3 +108,18 @@ export type AggregatedTimeEntriesQueryParams = ZodiosQueryParamsByAlias<
|
|||||||
SolidTimeApi,
|
SolidTimeApi,
|
||||||
'getAggregatedTimeEntries'
|
'getAggregatedTimeEntries'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type OrganizationResponse = ZodiosResponseByAlias<
|
||||||
|
SolidTimeApi,
|
||||||
|
'getOrganization'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type Organization = ZodiosResponseByAlias<
|
||||||
|
SolidTimeApi,
|
||||||
|
'getOrganization'
|
||||||
|
>['data'];
|
||||||
|
|
||||||
|
export type UpdateOrganizationBody = ZodiosBodyByAlias<
|
||||||
|
SolidTimeApi,
|
||||||
|
'updateOrganization'
|
||||||
|
>;
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { router } from '@inertiajs/vue3';
|
import { router } from '@inertiajs/vue3';
|
||||||
import { initializeStores } from '@/utils/init';
|
import { initializeStores } from '@/utils/init';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import type {
|
||||||
|
Organization,
|
||||||
|
OrganizationResponse,
|
||||||
|
UpdateOrganizationBody,
|
||||||
|
} from '@/utils/api';
|
||||||
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
|
import { api } from '../../../openapi.json.client';
|
||||||
|
|
||||||
export function switchOrganization(organizationId: string) {
|
export function switchOrganization(organizationId: string) {
|
||||||
router.put(
|
router.put(
|
||||||
@@ -15,3 +25,49 @@ export function switchOrganization(organizationId: string) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useOrganizationStore = defineStore('organization', () => {
|
||||||
|
const organizationResponse = ref<OrganizationResponse | null>(null);
|
||||||
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||||
|
|
||||||
|
async function fetchOrganization() {
|
||||||
|
const organization = getCurrentOrganizationId();
|
||||||
|
if (organization) {
|
||||||
|
organizationResponse.value = await handleApiRequestNotifications(
|
||||||
|
() =>
|
||||||
|
api.getOrganization({
|
||||||
|
params: {
|
||||||
|
organization: organization,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
'Failed to fetch organization'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateOrganization(
|
||||||
|
organizationBody: UpdateOrganizationBody
|
||||||
|
) {
|
||||||
|
const organization = getCurrentOrganizationId();
|
||||||
|
if (organization) {
|
||||||
|
await handleApiRequestNotifications(
|
||||||
|
() =>
|
||||||
|
api.updateOrganization(organizationBody, {
|
||||||
|
params: {
|
||||||
|
organization: organization,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'Organization updated successfully',
|
||||||
|
'Failed to update organization'
|
||||||
|
);
|
||||||
|
await fetchOrganization();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const organization = computed<Organization | null>(() => {
|
||||||
|
return organizationResponse.value?.data || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { organization, fetchOrganization, updateOrganization };
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import type {
|
|||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import { useNotificationsStore } from '@/utils/notification';
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
|
|
||||||
|
export type BillableKey = 'non-billable' | 'default-rate' | 'custom-rate';
|
||||||
|
|
||||||
export const useProjectsStore = defineStore('projects', () => {
|
export const useProjectsStore = defineStore('projects', () => {
|
||||||
const projectResponse = ref<ProjectResponse | null>(null);
|
const projectResponse = ref<ProjectResponse | null>(null);
|
||||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||||
|
|||||||
Reference in New Issue
Block a user