improve time estimate input, responsive time entry create modal fixes,

fixes #460, #800
This commit is contained in:
Gregor Vostrak
2026-02-06 14:35:52 +01:00
parent d2644112c5
commit bbe05ca0d8
16 changed files with 760 additions and 301 deletions

View File

@@ -201,6 +201,7 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
<TimeTrackerProjectTaskDropdown
v-model:project="currentTimeEntry.project_id"
v-model:task="currentTimeEntry.task_id"
variant="input"
:projects="projects"
:tasks="tasks"
:clients="activeClients"
@@ -209,7 +210,6 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
:can-create-project="canCreateProjects()"
:currency="getOrganizationCurrencyString()"
:enable-estimated-time="isAllowedToPerformPremiumAction()"
size="xlarge"
class="w-full" />
</template>
<template #footer>
@@ -225,6 +225,7 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
<TimeTrackerProjectTaskDropdown
v-model:project="currentTimeEntry.project_id"
v-model:task="currentTimeEntry.task_id"
variant="input"
:projects="projects"
:tasks="tasks"
:clients="activeClients"
@@ -233,7 +234,6 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
:can-create-project="canCreateProjects()"
:currency="getOrganizationCurrencyString()"
:enable-estimated-time="isAllowedToPerformPremiumAction()"
size="xlarge"
class="w-full" />
</template>
<template #footer>

View File

@@ -21,7 +21,7 @@ const props = withDefaults(
<button
:type="type"
:disabled="loading"
class="inline-flex items-center px-2 sm:px-3 py-1 sm:py-2 bg-button-primary-background border border-button-primary-border rounded-md font-medium text-xs sm:text-sm text-button-primary-text hover:bg-button-primary-background-hover active:bg-button-primary-background-hover focus:outline-none focus-visible:ring-2 focus-visible:border-transparent focus-visible:ring-ring transition ease-in-out duration-150">
class="inline-flex items-center px-3 py-2 bg-button-primary-background border border-button-primary-border rounded-md font-medium text-xs sm:text-sm text-button-primary-text hover:bg-button-primary-background-hover active:bg-button-primary-background-hover focus:outline-none focus-visible:ring-2 focus-visible:border-transparent focus-visible:ring-ring transition ease-in-out duration-150">
<span :class="twMerge('flex items-center ', props.icon ? 'space-x-1.5' : '')">
<LoadingSpinner v-if="loading"></LoadingSpinner>
<component

View File

@@ -21,8 +21,8 @@ const props = withDefaults(
);
const sizeClasses = {
small: 'text-xs px-2 sm:px-2.5 py-1 sm:py-1.5',
base: 'text-xs sm:text-sm px-2 sm:px-3 py-1 sm:py-2',
small: 'text-xs px-2.5 py-1.5',
base: 'text-xs sm:text-sm px-3 py-2',
};
</script>

View File

@@ -25,7 +25,7 @@ const close = () => {
<template>
<Modal :show="show" :max-width="maxWidth" :closeable="closeable" @close="close">
<div class="px-6 py-4">
<div class="px-4 lg:px-6 py-4">
<div class="text-lg font-medium text-text-primary" role="heading">
<slot name="title" />
</div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import DurationInput from '@/packages/ui/src/Input/DurationInput.vue';
import EstimatedTimeInput from '@/packages/ui/src/Input/EstimatedTimeInput.vue';
import { ClockIcon } from '@heroicons/vue/20/solid';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
@@ -13,10 +13,14 @@ const emit = defineEmits(['submit']);
<ClockIcon class="text-text-quaternary w-4"></ClockIcon>
<InputLabel for="billable" value="Time Estimated" />
</div>
<DurationInput
v-model="model"
class="max-w-[150px]"
@submit="emit('submit')"></DurationInput>
<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>
</div>
</template>

View File

@@ -1,70 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { TextInput } from '@/packages/ui/src';
const model = defineModel<number | null>({
default: null,
});
const emit = defineEmits<{
submit: [];
}>();
const temporaryCustomTimerEntry = ref<string>('');
function updateDuration() {
const hours = parseInt(temporaryCustomTimerEntry.value);
if (!isNaN(hours)) {
model.value = hours * 60 * 60;
}
temporaryCustomTimerEntry.value = '';
}
const currentTime = computed({
get() {
if (temporaryCustomTimerEntry.value !== '') {
return temporaryCustomTimerEntry.value;
}
if (model.value === null) {
return '';
}
return Math.round(model.value / 60 / 60).toString();
},
// setter
set(newValue) {
if (newValue) {
temporaryCustomTimerEntry.value = newValue;
} else {
temporaryCustomTimerEntry.value = '';
}
},
});
function selectInput(event: Event) {
const target = event.target as HTMLInputElement;
target.select();
}
function updateAndSubmit() {
updateDuration();
emit('submit');
}
</script>
<template>
<div class="relative">
<TextInput
v-model="currentTime"
class="w-full overflow-hidden pr-14"
placeholder="0"
@focus="selectInput"
@blur="updateDuration"
@keydown.enter="updateAndSubmit">
</TextInput>
<div class="absolute top-0 right-0 h-full flex items-center px-4 font-medium">
<span> hours </span>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { onMounted, ref, watch, inject } from 'vue';
import { formatHumanReadableDuration, parseTimeInput } from '@/packages/ui/src/utils/time';
import { twMerge } from 'tailwind-merge';
import { TextInput } from '@/packages/ui/src';
import type { Organization } from '@/packages/api/src';
import { type ComputedRef } from 'vue';
const temporaryInput = ref<string>('');
const model = defineModel<number | null>({
default: null,
});
const emit = defineEmits<{
submit: [];
}>();
const organization = inject<ComputedRef<Organization>>('organization');
function updateDuration() {
const input = temporaryInput.value.trim();
if (input === '') {
model.value = null;
return;
}
// Use parseTimeInput with 'hours' as default unit for estimated time
const seconds = parseTimeInput(input, 'hours');
if (seconds !== null && seconds > 0) {
model.value = seconds;
}
updateInputDisplay();
}
const props = defineProps<{
class?: string;
}>();
watch(model, updateInputDisplay);
onMounted(() => updateInputDisplay());
function updateInputDisplay() {
if (model.value !== null && model.value > 0) {
temporaryInput.value = formatHumanReadableDuration(
model.value,
organization?.value?.interval_format,
organization?.value?.number_format
);
} else {
temporaryInput.value = '';
}
}
function selectInput(event: Event) {
const target = event.target as HTMLInputElement;
target.select();
}
function updateAndSubmit() {
updateDuration();
emit('submit');
}
</script>
<template>
<TextInput
ref="inputField"
v-model="temporaryInput"
:class="twMerge('text-text-secondary', props.class)"
type="text"
placeholder="e.g. 2h 30m or 1.5"
@focus="selectInput"
@blur="updateDuration"
@keydown.enter="updateAndSubmit" />
</template>
<style scoped></style>

View File

@@ -119,7 +119,6 @@ function onSelectChange(checked: boolean) {
:can-create-project
:projects="projects"
:tasks="tasks"
:show-badge-border="false"
:project="timeEntry.project_id"
:enable-estimated-time
:currency="currency"
@@ -136,8 +135,8 @@ function onSelectChange(checked: boolean) {
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
<BillableToggleButton
:model-value="timeEntry.billable"
class="opacity-50 focus-visible:opacity-100 group-hover:opacity-100"
size="small"
class="opacity-50 focus-visible:opacity-100 group-hover:opacity-100"
@changed="updateTimeEntryBillable"></BillableToggleButton>
<div class="flex-1">
<button
@@ -220,7 +219,6 @@ function onSelectChange(checked: boolean) {
:can-create-project
:projects="projects"
:tasks="tasks"
:show-badge-border="false"
:project="timeEntry.project_id"
:enable-estimated-time
:currency="currency"

View File

@@ -16,7 +16,6 @@ import type {
CreateTimeEntryBody,
} from '@/packages/api/src';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { Badge } from '@/packages/ui/src';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import {
Select,
@@ -25,6 +24,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Button } from '@/Components/ui/button';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
@@ -159,63 +159,53 @@ const billableProxy = computed({
@keydown.enter="submit" />
</div>
</div>
<div class="sm:flex justify-between items-end space-y-2 sm:space-y-0 pt-4 sm:space-x-4">
<div class="flex w-full items-center space-x-2 justify-between">
<div class="flex-1 min-w-0">
<TimeTrackerProjectTaskDropdown
v-model:project="timeEntry.project_id"
v-model:task="timeEntry.task_id"
:clients
:create-project
:create-client
:can-create-project
:currency
size="xlarge"
class="bg-input-background"
:projects="projects"
:tasks="tasks"
:enable-estimated-time="
enableEstimatedTime
"></TimeTrackerProjectTaskDropdown>
</div>
<div class="flex items-center space-x-2">
<div class="flex-col">
<TagDropdown v-model="timeEntry.tags" :create-tag :tags="tags">
<template #trigger>
<Badge class="bg-input-background" tag="button" size="xlarge">
<TagIcon
v-if="timeEntry.tags.length === 0"
class="w-4"></TagIcon>
<div
v-else
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
{{ timeEntry.tags.length }}
</div>
<span>Tags</span>
</Badge>
</template>
</TagDropdown>
</div>
<div class="flex-col">
<Select v-model="billableProxy">
<SelectTrigger size="small" :show-chevron="false">
<SelectValue class="flex items-center gap-2">
<BillableIcon class="h-4 text-icon-default" />
<span>{{
timeEntry.billable ? 'Billable' : 'Non-Billable'
}}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Billable</SelectItem>
<SelectItem value="false">Non Billable</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-end gap-2 sm:gap-4 pt-4">
<div class="flex-1 min-w-0">
<TimeTrackerProjectTaskDropdown
v-model:project="timeEntry.project_id"
v-model:task="timeEntry.task_id"
variant="input"
:clients
:create-project
:create-client
:can-create-project
:currency
:projects="projects"
:tasks="tasks"
:enable-estimated-time="enableEstimatedTime" />
</div>
<div class="flex items-center gap-2 shrink-0">
<TagDropdown
v-model="timeEntry.tags"
:create-tag
:tags="tags"
:show-no-tag-option="false">
<template #trigger>
<Button variant="input" size="sm">
<TagIcon class="h-4 text-icon-default" />
<span>{{
timeEntry.tags.length === 0
? 'Tags'
: `${timeEntry.tags.length} Tag${timeEntry.tags.length > 1 ? 's' : ''}`
}}</span>
</Button>
</template>
</TagDropdown>
<Select v-model="billableProxy">
<SelectTrigger size="small" :show-chevron="false">
<SelectValue class="flex items-center gap-2">
<BillableIcon class="h-4 text-icon-default" />
<span>{{ timeEntry.billable ? 'Billable' : 'Non-Billable' }}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Billable</SelectItem>
<SelectItem value="false">Non Billable</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="flex pt-4 space-x-4">
<div class="flex flex-col sm:flex-row gap-4 pt-4">
<div class="flex-1">
<InputLabel>Duration</InputLabel>
<div class="space-y-2 mt-1 flex flex-col">
@@ -225,32 +215,37 @@ const billableProxy = computed({
name="Duration"></DurationHumanInput>
<div class="text-sm flex space-x-1">
<InformationCircleIcon
class="w-4 text-text-quaternary"></InformationCircleIcon>
class="w-4 shrink-0 text-text-quaternary"></InformationCircleIcon>
<span class="text-text-secondary text-xs">
You can type natural language here f.e.
You can type natural language like
<span class="font-semibold"> 2h 30m</span>
</span>
</div>
</div>
</div>
<div class="">
<InputLabel>Start</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
<TimePickerSimple
v-model="localStart"
class="w-full"
size="large"></TimePickerSimple>
<DatePicker v-model="localStart" class="w-full" tabindex="1"></DatePicker>
<div class="grid grid-cols-2 sm:flex gap-4">
<div>
<InputLabel>Start</InputLabel>
<div class="flex flex-col gap-2 mt-1 sm:w-28">
<TimePickerSimple
v-model="localStart"
class="w-full"
size="large"></TimePickerSimple>
<DatePicker
v-model="localStart"
class="w-full"
tabindex="1"></DatePicker>
</div>
</div>
</div>
<div class="">
<InputLabel>End</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
<TimePickerSimple
v-model="localEnd"
class="w-full"
size="large"></TimePickerSimple>
<DatePicker v-model="localEnd" class="w-full" tabindex="1"></DatePicker>
<div>
<InputLabel>End</InputLabel>
<div class="flex flex-col gap-2 mt-1 sm:w-28">
<TimePickerSimple
v-model="localEnd"
class="w-full"
size="large"></TimePickerSimple>
<DatePicker v-model="localEnd" class="w-full" tabindex="1"></DatePicker>
</div>
</div>
</div>
</div>

View File

@@ -16,7 +16,6 @@ import type {
TimeEntry,
} from '@/packages/api/src';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { Badge } from '@/packages/ui/src';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import {
Select,
@@ -25,6 +24,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Button } from '@/Components/ui/button';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
@@ -166,72 +166,55 @@ const billableProxy = computed({
@keydown.enter="submit" />
</div>
</div>
<div
class="sm:flex justify-between items-end space-y-2 sm:space-y-0 pt-4 sm:space-x-4">
<div class="flex w-full items-center space-x-2 justify-between">
<div class="flex-1 min-w-0">
<TimeTrackerProjectTaskDropdown
v-model:project="editableTimeEntry.project_id"
v-model:task="editableTimeEntry.task_id"
:clients
:create-project
:create-client
:can-create-project="canCreateProject"
:currency="currency"
size="xlarge"
class="bg-input-background"
:projects="projects"
:tasks="tasks"
:enable-estimated-time="
enableEstimatedTime
"></TimeTrackerProjectTaskDropdown>
</div>
<div class="flex items-center space-x-2">
<div class="flex-col">
<TagDropdown
v-model="editableTimeEntry.tags"
:create-tag
:tags="tags">
<template #trigger>
<Badge
class="bg-input-background"
tag="button"
size="xlarge">
<TagIcon
v-if="editableTimeEntry.tags.length === 0"
class="w-4"></TagIcon>
<div
v-else
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
{{ editableTimeEntry.tags.length }}
</div>
<span>Tags</span>
</Badge>
</template>
</TagDropdown>
</div>
<div class="flex-col">
<Select v-model="billableProxy">
<SelectTrigger size="small" :show-chevron="false">
<SelectValue class="flex items-center gap-2">
<BillableIcon class="h-4 text-icon-default" />
<span>{{
editableTimeEntry.billable
? 'Billable'
: 'Non-Billable'
}}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Billable</SelectItem>
<SelectItem value="false">Non Billable</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-end gap-2 sm:gap-4 pt-4">
<div class="flex-1 min-w-0">
<TimeTrackerProjectTaskDropdown
v-model:project="editableTimeEntry.project_id"
v-model:task="editableTimeEntry.task_id"
variant="input"
:clients
:create-project
:create-client
:can-create-project="canCreateProject"
:currency="currency"
:projects="projects"
:tasks="tasks"
:enable-estimated-time="enableEstimatedTime" />
</div>
<div class="flex items-center gap-2 shrink-0">
<TagDropdown
v-model="editableTimeEntry.tags"
:create-tag
:tags="tags"
:show-no-tag-option="false">
<template #trigger>
<Button variant="input" size="sm">
<TagIcon class="h-4 text-icon-default" />
<span>{{
editableTimeEntry.tags.length === 0
? 'Tags'
: `${editableTimeEntry.tags.length} Tag${editableTimeEntry.tags.length > 1 ? 's' : ''}`
}}</span>
</Button>
</template>
</TagDropdown>
<Select v-model="billableProxy">
<SelectTrigger size="small" :show-chevron="false">
<SelectValue class="flex items-center gap-2">
<BillableIcon class="h-4 text-icon-default" />
<span>{{
editableTimeEntry.billable ? 'Billable' : 'Non-Billable'
}}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Billable</SelectItem>
<SelectItem value="false">Non Billable</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="flex pt-4 space-x-4">
<div class="flex flex-col sm:flex-row gap-4 pt-4">
<div class="flex-1">
<InputLabel>Duration</InputLabel>
<div class="space-y-2 mt-1 flex flex-col">
@@ -241,35 +224,40 @@ const billableProxy = computed({
name="Duration"></DurationHumanInput>
<div class="text-sm flex space-x-1">
<InformationCircleIcon
class="w-4 text-text-quaternary"></InformationCircleIcon>
class="w-4 shrink-0 text-text-quaternary"></InformationCircleIcon>
<span class="text-text-secondary text-xs">
You can type natural language here f.e.
You can type natural language like
<span class="font-semibold"> 2h 30m</span>
</span>
</div>
</div>
</div>
<div class="">
<InputLabel>Start</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
<TimePickerSimple
v-model="localStart"
class="w-full"
size="large"></TimePickerSimple>
<DatePicker
v-model="localStart"
class="w-full"
tabindex="1"></DatePicker>
<div class="grid grid-cols-2 sm:flex gap-4">
<div>
<InputLabel>Start</InputLabel>
<div class="flex flex-col gap-2 mt-1 sm:w-28">
<TimePickerSimple
v-model="localStart"
class="w-full"
size="large"></TimePickerSimple>
<DatePicker
v-model="localStart"
class="w-full"
tabindex="1"></DatePicker>
</div>
</div>
</div>
<div class="">
<InputLabel>End</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
<TimePickerSimple
v-model="localEnd"
class="w-full"
size="large"></TimePickerSimple>
<DatePicker v-model="localEnd" class="w-full" tabindex="1"></DatePicker>
<div>
<InputLabel>End</InputLabel>
<div class="flex flex-col gap-2 mt-1 sm:w-28">
<TimePickerSimple
v-model="localEnd"
class="w-full"
size="large"></TimePickerSimple>
<DatePicker
v-model="localEnd"
class="w-full"
tabindex="1"></DatePicker>
</div>
</div>
</div>
</div>

View File

@@ -14,7 +14,8 @@ import {
type TimeEntry,
type UpdateMultipleTimeEntriesChangeset,
} from '@/packages/api/src';
import { Badge, Checkbox } from '@/packages/ui/src';
import { Checkbox } from '@/packages/ui/src';
import { TagIcon } from '@heroicons/vue/20/solid';
import {
Select,
SelectContent,
@@ -22,6 +23,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { Button } from '@/Components/ui/button';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import type { Tag, Task } from '@/packages/api/src';
@@ -159,6 +161,9 @@ watch(removeAllTags, () => {
<TimeTrackerProjectTaskDropdown
v-model:project="projectId"
v-model:task="taskId"
variant="input"
align="start"
size="default"
:clients
:create-project
:create-client
@@ -167,7 +172,6 @@ watch(removeAllTags, () => {
class="mt-1"
empty-placeholder="Select project..."
allow-reset
size="xlarge"
:enable-estimated-time
:projects="projects"
:tasks="tasks"></TimeTrackerProjectTaskDropdown>
@@ -175,14 +179,19 @@ watch(removeAllTags, () => {
<div class="space-y-2">
<InputLabel for="project" value="Tag" />
<div class="flex space-x-5">
<TagDropdown v-model="selectedTags" :create-tag :tags="tags">
<TagDropdown
v-model="selectedTags"
:create-tag
:tags="tags"
:show-no-tag-option="false">
<template #trigger>
<Badge :disabled="removeAllTags" tag="button" size="xlarge">
<Button variant="input" :disabled="removeAllTags">
<TagIcon class="h-4 text-icon-default" />
<span v-if="selectedTags.length > 0">
Set {{ selectedTags.length }} tags
</span>
<span v-else> Select Tags... </span>
</Badge>
<span v-else>Select Tags...</span>
</Button>
</template>
</TagDropdown>
<div class="flex items-center space-x-2">
@@ -195,7 +204,7 @@ watch(removeAllTags, () => {
<InputLabel for="project" value="Billable" />
<div class="flex">
<Select v-model="timeEntryBillable">
<SelectTrigger size="small" :show-chevron="false">
<SelectTrigger>
<SelectValue>
<span v-if="billable === undefined">Set billable status</span>
<span v-else-if="billable === true">Billable</span>

View File

@@ -131,7 +131,6 @@ async function handleDeleteTimeEntry() {
:clients
:projects="projects"
:tasks="tasks"
:show-badge-border="false"
:project="timeEntry.project_id"
:currency="currency"
:enable-estimated-time
@@ -150,10 +149,10 @@ async function handleDeleteTimeEntry() {
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
<BillableToggleButton
:model-value="timeEntry.billable"
size="small"
:class="
twMerge('opacity-50 group-hover:opacity-100 focus-visible:opacity-100')
"
size="small"
@changed="updateTimeEntryBillable"></BillableToggleButton>
<div class="flex-1">
<TimeEntryRangeSelector
@@ -199,7 +198,6 @@ async function handleDeleteTimeEntry() {
:clients
:projects="projects"
:tasks="tasks"
:show-badge-border="false"
:project="timeEntry.project_id"
:currency="currency"
:enable-estimated-time

View File

@@ -251,6 +251,7 @@ useSelectEvents(
<TimeTrackerProjectTaskDropdown
v-model:project="currentTimeEntry.project_id"
v-model:task="currentTimeEntry.task_id"
variant="outline"
:create-client
:can-create-project
:clients

View File

@@ -10,11 +10,11 @@ import type {
Task,
Client,
} from '@/packages/api/src';
import ProjectBadge from '@/packages/ui/src/Project/ProjectBadge.vue';
import Badge from '@/packages/ui/src/Badge.vue';
import { PlusIcon, PlusCircleIcon, MinusIcon, XMarkIcon } from '@heroicons/vue/16/solid';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import { twMerge } from 'tailwind-merge';
import { Button } from '@/Components/ui/button';
const task = defineModel<string | null>('task', {
default: null,
@@ -48,8 +48,6 @@ type ClientsWithProjectsWithTasks = ClientWithProjectsWithTasks[];
const props = withDefaults(
defineProps<{
showBadgeBorder?: boolean;
size?: 'base' | 'large' | 'xlarge';
projects: Project[];
tasks: Task[];
clients: Client[];
@@ -61,12 +59,16 @@ const props = withDefaults(
enableEstimatedTime: boolean;
canCreateProject: boolean;
class?: string;
variant?: 'input' | 'ghost' | 'outline';
align?: 'center' | 'end' | 'start';
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'input';
}>(),
{
showBadgeBorder: true,
size: 'large',
emptyPlaceholder: 'No Project',
allowReset: false,
variant: 'ghost',
align: 'center',
size: 'sm',
}
);
@@ -486,56 +488,51 @@ function selectProject(projectId: string) {
emit('changed', project.value, task.value);
}
function resetProject() {
project.value = null;
task.value = null;
emit('changed', project.value, task.value);
}
const showCreateProject = ref(false);
</script>
<template>
<div v-if="projects.length === 0 && canCreateProject">
<Badge
size="large"
tag="button"
class="cursor-pointer hover:bg-tertiary"
<Button
:variant="props.variant"
:size="props.size"
:class="twMerge('w-full justify-start', props.class)"
@click="showCreateProject = true">
<PlusIcon class="-ml-1 w-5"></PlusIcon>
<PlusIcon class="w-4" />
<span>Add new project</span>
</Badge>
</Button>
</div>
<Dropdown v-else v-model="open" :close-on-content-click="false" align="center">
<Dropdown v-else v-model="open" :close-on-content-click="false" :align="props.align">
<template #trigger>
<ProjectBadge
ref="projectDropdownTrigger"
:color="selectedProjectColor"
:size="size"
:border="showBadgeBorder"
tag="button"
:name="selectedProjectName"
:class="
twMerge(
'focus:border-border-tertiary w-full focus:outline-0 focus:bg-card-background-separator min-w-0 relative',
props.class
)
">
<div class="flex items-center lg:space-x-1 min-w-0 overflow-hidden">
<span class="text-xs lg:text-sm shrink-0">
{{ selectedProjectName }}
</span>
<ChevronRightIcon
v-if="currentTask"
class="w-4 lg:w-5 text-text-secondary shrink-0"></ChevronRightIcon>
<div v-if="currentTask" class="min-w-0 text-xs lg:text-sm truncate shrink">
{{ currentTask.name }}
</div>
</div>
<div class="flex items-center gap-1">
<Button
:variant="props.variant"
:size="props.size"
:class="twMerge('w-full justify-start', props.class)">
<div
class="w-3 h-3 rounded-full shrink-0"
:style="{ backgroundColor: selectedProjectColor }"></div>
<span class="truncate">{{ selectedProjectName }}</span>
<template v-if="currentTask">
<ChevronRightIcon class="w-4 h-4 text-text-tertiary shrink-0" />
<span class="truncate">{{ currentTask.name }}</span>
</template>
</Button>
<button
v-if="project !== null && allowReset"
class="absolute right-0 top-0 h-full flex items-center pr-3 text-text-quaternary hover:text-text-secondary"
@click.stop="
project = null;
task = null;
">
<XMarkIcon class="w-5"></XMarkIcon>
v-if="allowReset && project !== null"
type="button"
data-testid="project_reset_button"
class="p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary"
@click.stop="resetProject">
<XMarkIcon class="w-4 h-4" />
</button>
</ProjectBadge>
</div>
</template>
<template #content>
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">