Conditionally show cost column in report tables; Task/Project Modal

Field cleanup; improve estimated time UX
This commit is contained in:
Gregor Vostrak
2026-02-11 17:09:32 +01:00
parent 2d9f33387e
commit c78c681ec4
18 changed files with 664 additions and 266 deletions

View File

@@ -1,7 +1,5 @@
<script setup lang="ts">
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import { computed, nextTick, ref, watch } from 'vue';
import ClientDropdownItem from '@/packages/ui/src/Client/ClientDropdownItem.vue';
import type { CreateClientBody, Client } from '@/packages/api/src';
import {
ComboboxAnchor,
@@ -13,6 +11,7 @@ import {
} from 'radix-vue';
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { Check, Plus } from 'lucide-vue-next';
const model = defineModel<string | null>({
default: null,
@@ -77,7 +76,7 @@ function updateValue(client: { id: string | null; name: string }) {
</script>
<template>
<Dropdown v-model="open" align="start" width="60">
<Dropdown v-model="open" align="start">
<template #trigger>
<slot name="trigger"></slot>
</template>
@@ -92,35 +91,41 @@ function updateValue(client: { id: string | null; name: string }) {
<ComboboxAnchor>
<ComboboxInput
ref="searchInput"
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full"
placeholder="Search for a client..." />
</ComboboxAnchor>
<ComboboxContent>
<ComboboxViewport class="w-60 max-h-60 overflow-y-scroll">
<ComboboxViewport
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
<ComboboxItem
:value="{ id: null, name: 'No Client' }"
class="data-[highlighted]:bg-card-background-active">
<ClientDropdownItem :selected="model === null" name="No Client" />
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground">
<span>No Client</span>
<span
v-if="model === null"
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<Check class="h-4 w-4" />
</span>
</ComboboxItem>
<ComboboxItem
v-for="client in filteredClients"
:key="client.id"
:value="client"
class="data-[highlighted]:bg-card-background-active"
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
:data-client-id="client.id">
<ClientDropdownItem
:selected="isClientSelected(client.id)"
:name="client.name" />
<span>{{ client.name }}</span>
<span
v-if="isClientSelected(client.id)"
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<Check class="h-4 w-4" />
</span>
</ComboboxItem>
<div
v-if="searchValue.length > 0 && filteredClients.length === 0"
class="bg-card-background-active">
<div
class="flex space-x-3 items-center px-4 py-3 text-xs text-text-primary font-medium border-t rounded-b-lg border-card-background-separator"
@click="addClientIfNoneExists">
<PlusCircleIcon class="w-5 flex-shrink-0" />
<span>Add "{{ searchValue }}" as a new Client</span>
</div>
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
@click="addClientIfNoneExists">
<Plus class="h-4 w-4 shrink-0" />
<span>Add "{{ searchValue }}" as a new Client</span>
</div>
</ComboboxViewport>
</ComboboxContent>

View File

@@ -1,26 +1,23 @@
<script setup lang="ts">
import EstimatedTimeInput from '@/packages/ui/src/Input/EstimatedTimeInput.vue';
import { ClockIcon } from '@heroicons/vue/20/solid';
import { Field, FieldLabel } from './field';
import { Field, FieldDescription, FieldLabel } from './field';
const model = defineModel<number | null>();
const emit = defineEmits(['submit']);
</script>
<template>
<Field class="pt-6">
<div class="flex items-center space-x-1">
<ClockIcon class="text-text-quaternary w-4"></ClockIcon>
<FieldLabel for="billable">Time Estimated</FieldLabel>
</div>
<EstimatedTimeInput v-model="model" @submit="emit('submit')"></EstimatedTimeInput>
<div class="flex items-center text-text-secondary text-xs pt-2 pl-1">
<span>
<span class="font-semibold">Info:</span>
You can type natural language like
<span class="font-semibold">2h 30m</span>
</span>
</div>
<Field>
<FieldLabel for="time_estimated" :icon="ClockIcon">Time Estimated</FieldLabel>
<EstimatedTimeInput
id="time_estimated"
v-model="model"
@submit="emit('submit')"></EstimatedTimeInput>
<FieldDescription>
You can type natural language like
<span class="font-semibold">2h 30m</span>
</FieldDescription>
</Field>
</template>

View File

@@ -13,6 +13,7 @@ const props = defineProps<{
name: string;
focus?: boolean;
currency: string;
disabled?: boolean;
}>();
const model = defineModel<number | null>({
@@ -33,6 +34,7 @@ function formatValue(modelValue: number | null) {
:id="name"
ref="billableRateInput"
:model-value="formatValue(model)"
:disabled="disabled"
:step-snapping="false"
class="block w-full"
:format-options="{

View File

@@ -10,12 +10,12 @@ const model = defineModel<string>({ default: '' });
<Dropdown align="center">
<template #trigger>
<button
class="p-2 bg-input-background hover:bg-tertiary transition rounded-full border border-input-border">
class="h-9 w-9 flex items-center justify-center bg-input-background hover:bg-tertiary transition rounded-full border border-input-border">
<div
:style="{
backgroundColor: model,
}"
class="w-6 h-6 rounded-full cursor-pointer"></div>
class="w-5 h-5 rounded-full cursor-pointer"></div>
</button>
</template>
<template #content>

View File

@@ -8,11 +8,12 @@ import { getRandomColor } from '@/packages/ui/src/utils/color';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import ClientDropdown from '@/packages/ui/src/Client/ClientDropdown.vue';
import Badge from '@/packages/ui/src/Badge.vue';
import ProjectColorSelector from '@/packages/ui/src/Project/ProjectColorSelector.vue';
import { Button } from '@/packages/ui/src/Buttons';
import { ChevronDown } from 'lucide-vue-next';
import { UserCircleIcon } from '@heroicons/vue/20/solid';
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
import { Field, FieldLabel } from '../field';
import { Field, FieldGroup, FieldLabel } from '../field';
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
import type { Client } from '@/packages/api/src';
@@ -74,9 +75,9 @@ const currentClientName = computed(() => {
</template>
<template #content>
<div class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4">
<div class="flex-1 flex items-center">
<Field class="text-center pr-5">
<FieldGroup>
<FieldGroup class="flex-row items-end">
<Field class="w-auto text-center">
<FieldLabel for="color">Color</FieldLabel>
<ProjectColorSelector v-model="project.color"></ProjectColorSelector>
</Field>
@@ -94,43 +95,30 @@ const currentClientName = computed(() => {
autocomplete="projectName"
@keydown.enter="submit()" />
</Field>
</div>
</FieldGroup>
<Field>
<FieldLabel for="client">Client</FieldLabel>
<FieldLabel for="client" :icon="UserCircleIcon">Client</FieldLabel>
<ClientDropdown
v-model="project.client_id"
:create-client="createClient"
:clients="activeClients">
<template #trigger>
<Badge
tag="button"
class="bg-input-background cursor-pointer hover:bg-tertiary"
size="xlarge">
<div class="flex items-center space-x-2">
<UserCircleIcon class="w-5 text-icon-default"></UserCircleIcon>
<span>
{{ currentClientName }}
</span>
</div>
</Badge>
<Button variant="input" class="w-full justify-between">
<span class="truncate">{{ currentClientName }}</span>
<ChevronDown class="w-4 h-4 text-icon-default" />
</Button>
</template>
</ClientDropdown>
</Field>
</div>
<div>
<div>
<ProjectEditBillableSection
v-model:is-billable="project.is_billable"
v-model:billable-rate="project.billable_rate"
:currency="currency"></ProjectEditBillableSection>
</div>
<div>
<EstimatedTimeSection
v-if="enableEstimatedTime"
v-model="project.estimated_time"
@submit="submit()"></EstimatedTimeSection>
</div>
</div>
<ProjectEditBillableSection
v-model:is-billable="project.is_billable"
v-model:billable-rate="project.billable_rate"
:currency="currency"></ProjectEditBillableSection>
<EstimatedTimeSection
v-if="enableEstimatedTime"
v-model="project.estimated_time"
@submit="submit()"></EstimatedTimeSection>
</FieldGroup>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>

View File

@@ -1,82 +1,137 @@
<script setup lang="ts">
import { Field, FieldLabel } from '../field';
import { Field, FieldDescription, FieldLabel } from '../field';
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
import ProjectBillableSelect from '@/packages/ui/src/Project/ProjectBillableSelect.vue';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/packages/ui/src/tooltip';
import { computed, onMounted, ref, watch } from 'vue';
import type { BillableKey } from '@/types/projects';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
defineProps<{
currency: string;
}>();
const billableRateSelect = ref<BillableKey>('non-billable');
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
type RateType = 'default-rate' | 'custom-rate';
const billableDefault = ref<'billable' | 'non-billable'>('non-billable');
const rateType = ref<RateType>('default-rate');
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';
}
billableDefault.value = 'billable';
rateType.value = billableRate.value ? 'custom-rate' : 'default-rate';
}
});
watch(billableRateSelect, () => {
if (billableRateSelect.value === 'non-billable') {
watch(billableDefault, () => {
if (billableDefault.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 will 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];
watch(rateType, () => {
if (rateType.value === 'default-rate') {
billableRate.value = null;
} else if (rateType.value === 'custom-rate') {
billableDefault.value = 'billable';
isBillable.value = true;
if (!billableRate.value) {
billableRate.value = organization.value?.billable_rate ?? null;
}
}
});
const displayedRate = computed({
get() {
if (rateType.value === 'default-rate') {
return organization.value?.billable_rate ?? null;
}
return billableRate.value;
},
set(value: number | null) {
if (rateType.value === 'custom-rate') {
billableRate.value = value;
}
},
});
const billableDescription = computed(() => {
if (billableDefault.value === 'non-billable') {
return 'New time entries for this project will not be marked billable by default.';
}
return 'New time entries for this project will be marked billable by default.';
});
const emit = defineEmits(['submit']);
</script>
<template>
<div class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-4 pt-6">
<Field>
<div class="flex items-center space-x-1">
<BillableIcon class="text-text-quaternary h-4 ml-1 mr-0.5"></BillableIcon>
<FieldLabel for="billable">Billable Default</FieldLabel>
</div>
<ProjectBillableSelect v-model="billableRateSelect"></ProjectBillableSelect>
</Field>
<Field v-if="billableRateSelect === 'custom-rate'">
<FieldLabel for="billableRate">Billable Rate</FieldLabel>
<Field>
<FieldLabel for="billable" :icon="BillableIcon">Billable Default</FieldLabel>
<Select v-model="billableDefault">
<SelectTrigger id="billable">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="non-billable">Non-billable</SelectItem>
<SelectItem value="billable">Billable</SelectItem>
</SelectContent>
</Select>
<FieldDescription>{{ billableDescription }}</FieldDescription>
</Field>
<Field>
<FieldLabel :icon="BillableIcon" for="billableRateType">Billable Rate</FieldLabel>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Select v-model="rateType">
<SelectTrigger id="billableRateType">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default-rate">Default Rate</SelectItem>
<SelectItem value="custom-rate">Custom Rate</SelectItem>
</SelectContent>
</Select>
<TooltipProvider v-if="rateType === 'default-rate'">
<Tooltip>
<TooltipTrigger as-child>
<div>
<BillableRateInput
v-model="displayedRate"
:currency="currency"
disabled
name="billableRate" />
</div>
</TooltipTrigger>
<TooltipContent> Uses the default rate of the organization </TooltipContent>
</Tooltip>
</TooltipProvider>
<BillableRateInput
v-model="billableRate"
v-else
v-model="displayedRate"
:currency="currency"
name="billableRate"
@keydown.enter="emit('submit')" />
</Field>
</div>
<div class="flex items-center text-text-secondary text-xs pt-2 pl-1">
<span>
<span class="font-semibold"> Info: </span>
{{ billableOptionInfoText }}
</span>
</div>
</div>
</Field>
</template>
<style scoped></style>

View File

@@ -166,7 +166,7 @@ const billableProxy = computed({
@keydown.enter="submit" />
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-end gap-2 sm:gap-4 pt-4">
<div class="flex flex-col sm:flex-row sm:items-end gap-2 sm:gap-4">
<div class="flex-1 min-w-0">
<TimeTrackerProjectTaskDropdown
v-model:project="editableTimeEntry.project_id"

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import type { Component, HTMLAttributes } from 'vue';
import { cn } from '@/lib/utils';
import { Label } from '@/Components/ui/label';
const props = defineProps<{
class?: HTMLAttributes['class'];
icon?: Component;
}>();
</script>
@@ -13,12 +14,14 @@ const props = defineProps<{
data-slot="field-label"
:class="
cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'group/field-label peer/field-label flex w-fit gap-1.5 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&_>[data-slot=field]]:p-3',
'has-[[data-state=checked]]:bg-primary/5 has-[[data-state=checked]]:border-primary dark:has-[[data-state=checked]]:bg-primary/10',
icon ? 'items-center' : '',
props.class
)
">
<component :is="icon" v-if="icon" class="h-4 w-4 text-text-quaternary shrink-0" />
<slot />
</Label>
</template>