add is_billable support for create / update project and in timetracker, fixes ST-217

This commit is contained in:
Gregor Vostrak
2024-06-06 16:04:37 +02:00
committed by Constantin Graf
parent d9244d1ab4
commit 7fb58ea341
22 changed files with 343 additions and 32 deletions

View File

@@ -73,3 +73,5 @@ NETWORK_NAME=reverse-proxy-docker-traefik_routing
FORWARD_DB_PORT=5432
FORWARD_WEB_PORT=8083
PAGINATION_PER_PAGE_DEFAULT=500

View File

@@ -14,6 +14,7 @@ class OrganizationController extends Controller
/**
* Get organization
*
* @operationId getOrganization
* @throws AuthorizationException
*/
public function show(Organization $organization): OrganizationResource
@@ -26,6 +27,7 @@ class OrganizationController extends Controller
/**
* Update organization
*
* @operationId updateOrganization
* @throws AuthorizationException
*/
public function update(Organization $organization, OrganizationUpdateRequest $request): OrganizationResource

View File

@@ -25,7 +25,7 @@ class OrganizationResource extends BaseResource
'id' => $this->resource->id,
/** @var string $name 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,
/** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate,

View File

@@ -31,6 +31,8 @@ class ProjectResource extends BaseResource
'client_id' => $this->resource->client_id,
/** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate,
/** @var bool $is_billable Project time entries billable default */
'is_billable' => $this->resource->is_billable,
];
}
}

View File

@@ -52,11 +52,11 @@ const OrganizationResource = z
.object({
id: z.string(),
name: z.string(),
is_personal: z.string(),
is_personal: z.boolean(),
billable_rate: z.union([z.number(), z.null()]),
})
.passthrough();
const v1_organizations_update_Body = z
const updateOrganization_Body = z
.object({
name: z.string(),
billable_rate: z.union([z.number(), z.null()]).optional(),
@@ -69,12 +69,14 @@ const ProjectResource = z
color: z.string(),
client_id: z.union([z.string(), z.null()]),
billable_rate: z.union([z.number(), z.null()]),
is_billable: z.boolean(),
})
.passthrough();
const createProject_Body = z
.object({
name: z.string(),
color: z.string(),
is_billable: z.boolean(),
billable_rate: z.union([z.number(), z.null()]).optional(),
client_id: z.union([z.string(), z.null()]).optional(),
})
@@ -188,7 +190,7 @@ export const schemas = {
updateMember_Body,
MemberResource,
OrganizationResource,
v1_organizations_update_Body,
updateOrganization_Body,
ProjectResource,
createProject_Body,
ProjectMemberResource,
@@ -210,7 +212,7 @@ const endpoints = makeApi([
{
method: 'get',
path: '/v1/organizations/:organization',
alias: 'v1.organizations.show',
alias: 'getOrganization',
requestFormat: 'json',
parameters: [
{
@@ -236,13 +238,13 @@ const endpoints = makeApi([
{
method: 'put',
path: '/v1/organizations/:organization',
alias: 'v1.organizations.update',
alias: 'updateOrganization',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: v1_organizations_update_Body,
schema: updateOrganization_Body,
},
{
name: 'organization',

View File

@@ -10,7 +10,7 @@ export const test = baseTest.extend<object, { workerStorageState: string }>({
await page.getByLabel('Name').fill('John Doe');
await page
.getByLabel('Email')
.fill(`john+${Math.round(Math.random() * 10000)}@doe.com`);
.fill(`john+${Math.round(Math.random() * 1000000)}@doe.com`);
await page
.getByLabel('Password', { exact: true })
.fill('amazingpassword123');

View File

@@ -75,6 +75,7 @@ const inputValue = ref(formatValue(model.value));
ref="billableRateInput"
v-model="inputValue"
@blur="updateRate($event.target.value)"
@keydown.enter="updateRate($event.target.value)"
type="text"
:name="name"
placeholder="Billable Rate"

View File

@@ -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>

View File

@@ -13,9 +13,9 @@ import Badge from '@/Components/Common/Badge.vue';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelector.vue';
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
import { UserCircleIcon } from '@heroicons/vue/20/solid';
import InputLabel from '@/Components/InputLabel.vue';
import ProjectEditBillableSection from '@/Components/Common/Project/ProjectEditBillableSection.vue';
const { createProject } = useProjectsStore();
const { clients } = storeToRefs(useClientsStore());
@@ -27,6 +27,7 @@ const project = ref<CreateProjectBody>({
color: getRandomColor(),
client_id: null,
billable_rate: null,
is_billable: false,
});
async function submit() {
@@ -37,6 +38,7 @@ async function submit() {
color: getRandomColor(),
client_id: null,
billable_rate: null,
is_billable: false,
};
}
@@ -87,12 +89,6 @@ const currentClientName = computed(() => {
autocomplete="projectName" />
</div>
</div>
<div class="sm:max-w-[120px]">
<InputLabel for="billableRate" value="Billable Rate" />
<BillableRateInput
v-model="project.billable_rate"
name="billableRate" />
</div>
<div>
<InputLabel for="client" value="Client" />
<ClientDropdown class="mt-2" v-model="project.client_id">
@@ -112,10 +108,14 @@ const currentClientName = computed(() => {
</ClientDropdown>
</div>
</div>
<ProjectEditBillableSection
v-model:isBillable="project.is_billable"
v-model:billableRate="
project.billable_rate
"></ProjectEditBillableSection>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"

View File

@@ -61,6 +61,7 @@ async function addProjectIfNoneExists() {
{
name: searchValue.value,
color: getRandomColor(),
is_billable: false,
},
{ params: { organization: page.props.auth.user.current_team_id } }
);

View File

@@ -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>

View File

@@ -12,8 +12,8 @@ import { twMerge } from 'tailwind-merge';
import Badge from '@/Components/Common/Badge.vue';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import BillableRateInput from '@/Components/Common/BillableRateInput.vue';
import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelector.vue';
import ProjectEditBillableSection from '@/Components/Common/Project/ProjectEditBillableSection.vue';
const { updateProject } = useProjectsStore();
const { clients } = storeToRefs(useClientsStore());
@@ -29,6 +29,7 @@ const project = ref<CreateProjectBody>({
color: props.originalProject.color,
client_id: props.originalProject.client_id,
billable_rate: props.originalProject.billable_rate,
is_billable: props.originalProject.is_billable,
});
async function submit() {
@@ -77,11 +78,6 @@ const currentClientName = computed(() => {
required
autocomplete="projectName" />
</div>
<div class="sm:max-w-[120px]">
<BillableRateInput
v-model="project.billable_rate"
name="billable_rate" />
</div>
<div class="">
<ClientDropdown v-model="project.client_id">
<template #trigger>
@@ -98,6 +94,11 @@ const currentClientName = computed(() => {
</ClientDropdown>
</div>
</div>
<ProjectEditBillableSection
v-model:isBillable="project.is_billable"
v-model:billableRate="
project.billable_rate
"></ProjectEditBillableSection>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>

View File

@@ -33,6 +33,17 @@ function deleteProject() {
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);
</script>
@@ -61,11 +72,7 @@ const showEditProjectModal = ref(false);
<div v-else>No client</div>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
{{
project.billable_rate
? formatCents(project.billable_rate)
: '--'
}}
{{ billableRateInfo }}
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-muted flex space-x-1 items-center font-medium">

View File

@@ -115,7 +115,7 @@ watch(open, () => {
<template>
<Dropdown v-model="open" align="bottom-start" :closeOnContentClick="false">
<template #trigger>
<slot name="trigger"></slot>
<slot name="trigger"> </slot>
</template>
<template #content>
<div ref="dropdownViewport" class="w-60">

View File

@@ -11,9 +11,10 @@ const props = defineProps<{
<div
:class="
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',
props.selected ? 'bg-accent-300/20' : 'hover:bg-card-background'
'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
? 'bg-accent-300/20'
: 'hover:bg-card-background-active'
)
">
<span>{{ name }}</span>

View File

@@ -89,6 +89,7 @@ const filteredProjects = computed(() => {
value: '',
client_id: null,
billable_rate: null,
is_billable: false,
},
tasks: [],
},

View File

@@ -18,6 +18,7 @@ import { getCurrentOrganizationId } from '@/utils/useUser';
import { switchOrganization } from '@/utils/useOrganization';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import TimeTrackerRangeSelector from '@/Components/Common/TimeTracker/TimeTrackerRangeSelector.vue';
import { useProjectsStore } from '@/utils/useProjects';
const page = usePage<{
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() {
if (currentTimeEntry.value.id) {
useCurrentTimeEntryStore().updateTimer();
@@ -108,7 +125,7 @@ function switchToTimeEntryOrganization() {
<div class="flex items-center justify-between pl-2">
<div class="flex items-center w-[130px] sm:w-auto">
<TimeTrackerProjectTaskDropdown
@changed="updateTimeEntry"
@changed="updateProject"
v-model:project="currentTimeEntry.project_id"
v-model:task="
currentTimeEntry.task_id

View File

@@ -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>

View File

@@ -8,6 +8,7 @@ import type { Organization } from '@/types/models';
import type { Permissions, Role } from '@/types/jetstream';
import ImportData from '@/Pages/Teams/Partials/ImportData.vue';
import { canUpdateOrganization } from '@/utils/permissions';
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
defineProps<{
team: Organization;
@@ -28,6 +29,11 @@ defineProps<{
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
<UpdateTeamNameForm :team="team" :permissions="permissions" />
<SectionBorder />
<OrganizationBillableRate
v-if="canUpdateOrganization()"
:team="team" />
<TeamMemberManager
class="mt-10 sm:mt-0"
:team="team"

View File

@@ -108,3 +108,18 @@ export type AggregatedTimeEntriesQueryParams = ZodiosQueryParamsByAlias<
SolidTimeApi,
'getAggregatedTimeEntries'
>;
export type OrganizationResponse = ZodiosResponseByAlias<
SolidTimeApi,
'getOrganization'
>;
export type Organization = ZodiosResponseByAlias<
SolidTimeApi,
'getOrganization'
>['data'];
export type UpdateOrganizationBody = ZodiosBodyByAlias<
SolidTimeApi,
'updateOrganization'
>;

View File

@@ -1,5 +1,15 @@
import { router } from '@inertiajs/vue3';
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) {
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 };
});

View File

@@ -10,6 +10,8 @@ import type {
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
export type BillableKey = 'non-billable' | 'default-rate' | 'custom-rate';
export const useProjectsStore = defineStore('projects', () => {
const projectResponse = ref<ProjectResponse | null>(null);
const { handleApiRequestNotifications } = useNotificationsStore();