mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-15 11:42:15 +01:00
add break time entries and simplified time tracker ui
This commit is contained in:
121
resources/js/packages/ui/src/TimeEntry/BreakCreateModal.vue
Normal file
121
resources/js/packages/ui/src/TimeEntry/BreakCreateModal.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { Field, FieldLabel } from '../field';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import type { CreateTimeEntryBody } from '@/packages/api/src';
|
||||
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
|
||||
import { Coffee } from '@lucide/vue';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => Promise<void>;
|
||||
start?: string;
|
||||
end?: string;
|
||||
}>();
|
||||
|
||||
function defaultStart() {
|
||||
return getDayJsInstance().utc().subtract(30, 'm').second(0).format();
|
||||
}
|
||||
|
||||
function defaultEnd() {
|
||||
return getDayJsInstance().utc().second(0).format();
|
||||
}
|
||||
|
||||
const note = ref('');
|
||||
const localStart = ref(getLocalizedDayJs(defaultStart()).format());
|
||||
const localEnd = ref(getLocalizedDayJs(defaultEnd()).format());
|
||||
|
||||
// Prefill start/end when the modal is opened with a given range (e.g. from the calendar)
|
||||
watch(
|
||||
() => props.start,
|
||||
(value) => {
|
||||
if (value) {
|
||||
localStart.value = getLocalizedDayJs(value).format();
|
||||
}
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => props.end,
|
||||
(value) => {
|
||||
if (value) {
|
||||
localEnd.value = getLocalizedDayJs(value).format();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const durationSeconds = computed(() =>
|
||||
getLocalizedDayJs(localEnd.value).diff(getLocalizedDayJs(localStart.value), 'second')
|
||||
);
|
||||
|
||||
async function submit() {
|
||||
if (durationSeconds.value <= 0) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await props.createTimeEntry({
|
||||
description: note.value,
|
||||
project_id: null,
|
||||
task_id: null,
|
||||
tags: [],
|
||||
billable: false,
|
||||
type: 'break',
|
||||
start: getLocalizedDayJs(localStart.value).utc().format(),
|
||||
end: getLocalizedDayJs(localEnd.value).utc().format(),
|
||||
});
|
||||
note.value = '';
|
||||
localStart.value = getLocalizedDayJs(defaultStart()).format();
|
||||
localEnd.value = getLocalizedDayJs(defaultEnd()).format();
|
||||
show.value = false;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex items-center space-x-2 text-amber-600 dark:text-amber-400">
|
||||
<Coffee class="w-5 h-5" />
|
||||
<span> Add break </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="space-y-4">
|
||||
<TimeRangeFields
|
||||
v-model:start="localStart"
|
||||
v-model:end="localEnd"
|
||||
date-picker-size="sm"></TimeRangeFields>
|
||||
<Field>
|
||||
<FieldLabel for="break_note">Note (optional)</FieldLabel>
|
||||
<TextInput
|
||||
id="break_note"
|
||||
v-model="note"
|
||||
placeholder="e.g. Lunch"
|
||||
type="text"
|
||||
class="block w-full"
|
||||
@keydown.enter="submit" />
|
||||
</Field>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
tabindex="2"
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': saving }"
|
||||
:disabled="saving || durationSeconds <= 0"
|
||||
@click="submit">
|
||||
Add Break
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
14
resources/js/packages/ui/src/TimeEntry/BreakLabel.vue
Normal file
14
resources/js/packages/ui/src/TimeEntry/BreakLabel.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { Coffee } from '@lucide/vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-testid="break_badge"
|
||||
class="flex items-center space-x-1.5 text-sm font-medium text-text-secondary">
|
||||
<Coffee class="w-4 h-4 text-text-tertiary" />
|
||||
<span>Break</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { ExclamationTriangleIcon, ArrowRightIcon } from '@heroicons/vue/20/solid';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/packages/ui/src';
|
||||
|
||||
// Warning affordance for a misplaced break: an amber triangle that opens a small
|
||||
// popover offering to jump to the break's day in the calendar. Rendered by the
|
||||
// caller only when the break is actually misplaced (see `showPlacementHint`).
|
||||
defineProps<{
|
||||
// Local day (YYYY-MM-DD) the calendar should navigate to.
|
||||
fixDate: string;
|
||||
// Delegated navigation — packages/ui stays router-agnostic.
|
||||
fixInCalendar?: (date: string) => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="break_placement_hint"
|
||||
title="This break does not align with your work entries"
|
||||
class="flex items-center justify-center shrink-0 rounded-full p-0.5 text-amber-500 hover:bg-amber-500/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<ExclamationTriangleIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="min-w-[260px]" align="start">
|
||||
<div class="px-3 py-2 space-y-1.5">
|
||||
<p class="text-xs text-text-secondary">
|
||||
This break is not directly between work entries.
|
||||
</p>
|
||||
<button
|
||||
v-if="fixInCalendar"
|
||||
type="button"
|
||||
data-testid="break_fix_in_calendar"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-accent-400 hover:underline"
|
||||
@click="fixInCalendar(fixDate)">
|
||||
Fix in calendar
|
||||
<ArrowRightIcon class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -16,11 +16,19 @@ import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTag
|
||||
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
|
||||
import { ref, inject, type ComputedRef } from 'vue';
|
||||
import { formatHumanReadableDuration, formatStartEnd } from '@/packages/ui/src/utils/time';
|
||||
import { ref, inject, computed, type ComputedRef } from 'vue';
|
||||
import {
|
||||
formatHumanReadableDuration,
|
||||
formatStartEnd,
|
||||
getLocalizedDayJs,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
|
||||
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
|
||||
import type { TimeEntriesGroupedByType } from '@/types/time-entries';
|
||||
import {
|
||||
findMisplacedBreak,
|
||||
type BreakPlacementHint,
|
||||
} from '@/packages/ui/src/utils/breakPlacement';
|
||||
import {
|
||||
Checkbox,
|
||||
ContextMenu,
|
||||
@@ -30,6 +38,9 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from '@/packages/ui/src';
|
||||
import { PlayIcon, TrashIcon } from '@heroicons/vue/20/solid';
|
||||
import BreakLabel from '@/packages/ui/src/TimeEntry/BreakLabel.vue';
|
||||
import BreakPlacementHintButton from '@/packages/ui/src/TimeEntry/BreakPlacementHintButton.vue';
|
||||
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
const props = defineProps<{
|
||||
timeEntry: TimeEntriesGroupedByType;
|
||||
@@ -50,6 +61,8 @@ const props = defineProps<{
|
||||
selectedTimeEntries: TimeEntry[];
|
||||
enableEstimatedTime: boolean;
|
||||
canCreateProject: boolean;
|
||||
breakPlacementHints?: Record<string, BreakPlacementHint | null>;
|
||||
fixInCalendar?: (date: string) => void;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
selected: [TimeEntry[]];
|
||||
@@ -57,6 +70,26 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
const breaksEnabled = useBreaksEnabled();
|
||||
|
||||
// Continue creates a new entry of the same type, which the server rejects
|
||||
// for breaks when breaks are disabled for the organization
|
||||
const canRecreate = computed(() => props.timeEntry.type !== 'break' || breaksEnabled.value);
|
||||
|
||||
// Grouped breaks collapse into a single summary row, so surface the placement
|
||||
// warning if any entry in the group is misplaced. All grouped entries share the
|
||||
// same day, so the first misplaced one supplies the calendar navigation date.
|
||||
const misplacedBreakEntry = computed<TimeEntry | null>(() =>
|
||||
findMisplacedBreak(props.timeEntry.timeEntries, props.breakPlacementHints ?? {})
|
||||
);
|
||||
const showPlacementHint = computed(
|
||||
() => props.timeEntry.type === 'break' && misplacedBreakEntry.value !== null
|
||||
);
|
||||
const breakFixDate = computed(() =>
|
||||
misplacedBreakEntry.value
|
||||
? getLocalizedDayJs(misplacedBreakEntry.value.start).format('YYYY-MM-DD')
|
||||
: ''
|
||||
);
|
||||
|
||||
function updateTimeEntryDescription(description: string) {
|
||||
props.updateTimeEntries(
|
||||
@@ -121,12 +154,26 @@ function onSelectChange(checked: boolean) {
|
||||
{{ timeEntry?.timeEntries?.length }}
|
||||
</GroupedItemsCountButton>
|
||||
<TimeEntryDescriptionInput
|
||||
v-if="timeEntry.type !== 'break'"
|
||||
class="min-w-0 mr-4 shrink"
|
||||
:model-value="timeEntry.description"
|
||||
@changed="
|
||||
updateTimeEntryDescription
|
||||
"></TimeEntryDescriptionInput>
|
||||
<BreakLabel
|
||||
v-if="timeEntry.type === 'break'"
|
||||
class="px-2 shrink-0" />
|
||||
<span
|
||||
v-if="timeEntry.type === 'break' && timeEntry.description"
|
||||
class="min-w-0 mr-4 shrink truncate text-sm text-text-secondary">
|
||||
{{ timeEntry.description }}
|
||||
</span>
|
||||
<BreakPlacementHintButton
|
||||
v-if="showPlacementHint"
|
||||
:fix-date="breakFixDate"
|
||||
:fix-in-calendar="fixInCalendar" />
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-if="timeEntry.type !== 'break'"
|
||||
class="min-w-0 shrink"
|
||||
:clients
|
||||
:create-project
|
||||
@@ -147,11 +194,13 @@ function onSelectChange(checked: boolean) {
|
||||
<div
|
||||
class="hidden @lg:flex items-center font-medium space-x-1 @lg:space-x-2 shrink-0">
|
||||
<TimeEntryRowTagDropdown
|
||||
v-if="timeEntry.type !== 'break'"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
:model-value="timeEntry.tags"
|
||||
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
|
||||
<BillableToggleButton
|
||||
v-if="timeEntry.type !== 'break'"
|
||||
:model-value="timeEntry.billable"
|
||||
size="small"
|
||||
faded
|
||||
@@ -189,6 +238,7 @@ function onSelectChange(checked: boolean) {
|
||||
</button>
|
||||
|
||||
<TimeTrackerStartStop
|
||||
v-if="canRecreate"
|
||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||
variant="secondary"
|
||||
class="opacity-60 flex group-hover:opacity-100 focus-visible:opacity-100"
|
||||
@@ -231,7 +281,17 @@ function onSelectChange(checked: boolean) {
|
||||
</div>
|
||||
<!-- Second row: project/task - tags - billable - start - more -->
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<div
|
||||
v-if="timeEntry.type === 'break'"
|
||||
class="flex items-center min-w-0">
|
||||
<BreakLabel class="px-2 min-w-0" />
|
||||
<BreakPlacementHintButton
|
||||
v-if="showPlacementHint"
|
||||
:fix-date="breakFixDate"
|
||||
:fix-in-calendar="fixInCalendar" />
|
||||
</div>
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-else
|
||||
class="min-w-0"
|
||||
:clients
|
||||
:create-project
|
||||
@@ -249,16 +309,19 @@ function onSelectChange(checked: boolean) {
|
||||
"></TimeTrackerProjectTaskDropdown>
|
||||
<div class="flex items-center shrink-0">
|
||||
<TimeEntryRowTagDropdown
|
||||
v-if="timeEntry.type !== 'break'"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
:model-value="timeEntry.tags"
|
||||
compact
|
||||
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
|
||||
<BillableToggleButton
|
||||
v-if="timeEntry.type !== 'break'"
|
||||
:model-value="timeEntry.billable"
|
||||
size="small"
|
||||
@changed="updateTimeEntryBillable"></BillableToggleButton>
|
||||
<TimeTrackerStartStop
|
||||
v-if="canRecreate"
|
||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||
variant="secondary"
|
||||
class="ml-2"
|
||||
@@ -278,7 +341,7 @@ function onSelectChange(checked: boolean) {
|
||||
</MainContainer>
|
||||
<div
|
||||
v-if="expanded"
|
||||
class="w-full border-t border-default-background-separator bg-black/15">
|
||||
class="w-full border-t border-default-background-separator bg-black/5 dark:bg-black/15">
|
||||
<TimeEntryRow
|
||||
v-for="subEntry in timeEntry.timeEntries"
|
||||
:key="subEntry.id"
|
||||
@@ -303,6 +366,8 @@ function onSelectChange(checked: boolean) {
|
||||
:duplicate-time-entry="() => duplicateTimeEntry(subEntry)"
|
||||
:currency="currency"
|
||||
:create-tag
|
||||
:placement-hint="breakPlacementHints?.[subEntry.id] ?? null"
|
||||
:fix-in-calendar="fixInCalendar"
|
||||
:time-entry="subEntry"
|
||||
@selected="emit('selected', [subEntry])"
|
||||
@unselected="emit('unselected', [subEntry])"></TimeEntryRow>
|
||||
@@ -311,12 +376,13 @@ function onSelectChange(checked: boolean) {
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent class="min-w-[160px]">
|
||||
<ContextMenuItem
|
||||
v-if="canRecreate"
|
||||
class="space-x-3"
|
||||
@select="onStartStopClick(timeEntry.timeEntries[0]!)">
|
||||
<PlayIcon class="w-4 h-4 text-icon-default" />
|
||||
<span>Continue</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSeparator v-if="canRecreate" />
|
||||
<ContextMenuItem
|
||||
class="space-x-3 text-destructive"
|
||||
@select="deleteTimeEntries(timeEntry?.timeEntries ?? [])">
|
||||
|
||||
@@ -5,7 +5,6 @@ import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import { Field, FieldLabel } from '../field';
|
||||
import { TagIcon } from '@heroicons/vue/20/solid';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import type {
|
||||
@@ -19,12 +18,8 @@ import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||
import { Button } from '@/packages/ui/src/Buttons';
|
||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
|
||||
|
||||
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
|
||||
import type { Tag, Task } from '@/packages/api/src';
|
||||
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = ref(false);
|
||||
@@ -62,6 +57,7 @@ const timeEntryDefaultValues = {
|
||||
task_id: null,
|
||||
tags: [],
|
||||
billable: false,
|
||||
type: 'work' as CreateTimeEntryBody['type'],
|
||||
start: getDayJsInstance().utc().subtract(1, 'h').second(0).format(),
|
||||
end: getDayJsInstance().utc().second(0).format(),
|
||||
};
|
||||
@@ -107,9 +103,6 @@ const localEnd = ref(getLocalizedDayJs(timeEntryDefaultValues.end).format());
|
||||
|
||||
watch(localStart, (value) => {
|
||||
timeEntry.value.start = getLocalizedDayJs(value).utc().format();
|
||||
if (getLocalizedDayJs(localEnd.value).isBefore(getLocalizedDayJs(value))) {
|
||||
localEnd.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
watch(localEnd, (value) => {
|
||||
@@ -202,39 +195,11 @@ const billableProxy = computed({
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-5 gap-4 pt-4">
|
||||
<Field class="col-span-2 sm:col-span-3">
|
||||
<FieldLabel>Duration</FieldLabel>
|
||||
<div class="space-y-2 flex flex-col">
|
||||
<DurationHumanInput
|
||||
v-model:start="localStart"
|
||||
v-model:end="localEnd"
|
||||
name="Duration"></DurationHumanInput>
|
||||
<div class="text-sm flex space-x-1">
|
||||
<InformationCircleIcon
|
||||
class="w-4 shrink-0 text-text-quaternary"></InformationCircleIcon>
|
||||
<span class="text-text-secondary text-xs">
|
||||
You can type natural language like
|
||||
<span class="font-semibold"> 2h 30m</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Start</FieldLabel>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TimePickerSimple v-model="localStart" class="w-full"></TimePickerSimple>
|
||||
<DatePicker v-model="localStart" class="w-full" tabindex="1"></DatePicker>
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>End</FieldLabel>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TimePickerSimple v-model="localEnd" class="w-full"></TimePickerSimple>
|
||||
<DatePicker v-model="localEnd" class="w-full" tabindex="1"></DatePicker>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<TimeRangeFields
|
||||
v-model:start="localStart"
|
||||
v-model:end="localEnd"
|
||||
show-hint
|
||||
class="pt-4"></TimeRangeFields>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>
|
||||
|
||||
@@ -23,8 +23,14 @@ import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
|
||||
|
||||
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import { Coffee } from '@lucide/vue';
|
||||
import type { Tag, Task } from '@/packages/api/src';
|
||||
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
||||
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
|
||||
|
||||
// Breaks may have been disabled after this entry was created, so an existing break can still be
|
||||
// edited (and converted back), but a work entry may only offer the break option when enabled.
|
||||
const breaksEnabled = useBreaksEnabled();
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = ref(false);
|
||||
@@ -137,6 +143,24 @@ const billableProxy = computed({
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const isBreak = computed(() => editableTimeEntry.value?.type === 'break');
|
||||
|
||||
const typeProxy = computed({
|
||||
get: () => editableTimeEntry.value?.type ?? 'work',
|
||||
set: (value: string) => {
|
||||
if (editableTimeEntry.value) {
|
||||
editableTimeEntry.value.type = value as TimeEntry['type'];
|
||||
if (value === 'break') {
|
||||
// Breaks can not be billable, have tags or belong to a project/task
|
||||
editableTimeEntry.value.project_id = null;
|
||||
editableTimeEntry.value.task_id = null;
|
||||
editableTimeEntry.value.billable = false;
|
||||
editableTimeEntry.value.tags = [];
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -162,7 +186,7 @@ const billableProxy = computed({
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-end gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div v-if="!isBreak" class="flex-1 min-w-0">
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="editableTimeEntry.project_id"
|
||||
v-model:task="editableTimeEntry.task_id"
|
||||
@@ -178,8 +202,24 @@ const billableProxy = computed({
|
||||
:tasks="tasks"
|
||||
:enable-estimated-time="enableEstimatedTime" />
|
||||
</div>
|
||||
<div v-else class="flex-1 min-w-0"></div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Select v-if="breaksEnabled || isBreak" v-model="typeProxy">
|
||||
<SelectTrigger :show-chevron="false">
|
||||
<SelectValue class="flex items-center gap-2">
|
||||
<Coffee
|
||||
class="h-4 w-4"
|
||||
:class="isBreak ? 'text-amber-500' : 'text-icon-default'" />
|
||||
<span>{{ isBreak ? 'Break' : 'Work time' }}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="work">Work time</SelectItem>
|
||||
<SelectItem value="break">Break</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TagDropdown
|
||||
v-if="!isBreak"
|
||||
v-model="editableTimeEntry.tags"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
@@ -195,7 +235,7 @@ const billableProxy = computed({
|
||||
</Button>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
<Select v-model="billableProxy">
|
||||
<Select v-if="!isBreak" v-model="billableProxy">
|
||||
<SelectTrigger :show-chevron="false">
|
||||
<SelectValue class="flex items-center gap-2">
|
||||
<BillableIcon class="h-4 text-icon-default" />
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
Client,
|
||||
} from '@/packages/api/src';
|
||||
import { getDayJsInstance, getLocalizedDateFromTimestamp } from '@/packages/ui/src/utils/time';
|
||||
import {
|
||||
getBreakPlacementHint,
|
||||
type BreakPlacementHint,
|
||||
} from '@/packages/ui/src/utils/breakPlacement';
|
||||
import TimeEntryAggregateRow from '@/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue';
|
||||
import TimeEntryRowHeading from '@/packages/ui/src/TimeEntry/TimeEntryRowHeading.vue';
|
||||
import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
|
||||
@@ -39,12 +43,24 @@ const props = withDefaults(
|
||||
enableEstimatedTime: boolean;
|
||||
canCreateProject: boolean;
|
||||
groupSimilarTimeEntries?: boolean;
|
||||
// Host-provided navigation to the calendar for a break's day (YYYY-MM-DD)
|
||||
fixInCalendar?: (date: string) => void;
|
||||
}>(),
|
||||
{
|
||||
groupSimilarTimeEntries: true,
|
||||
}
|
||||
);
|
||||
|
||||
const breakPlacementHints = computed<Record<string, BreakPlacementHint | null>>(() => {
|
||||
const hints: Record<string, BreakPlacementHint | null> = {};
|
||||
for (const entry of props.timeEntries) {
|
||||
if (entry.type === 'break') {
|
||||
hints[entry.id] = getBreakPlacementHint(entry, props.timeEntries);
|
||||
}
|
||||
}
|
||||
return hints;
|
||||
});
|
||||
|
||||
const groupedTimeEntries = computed(() => {
|
||||
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
|
||||
for (const entry of props.timeEntries) {
|
||||
@@ -75,6 +91,7 @@ const groupedTimeEntries = computed(() => {
|
||||
e.project_id === entry.project_id &&
|
||||
e.task_id === entry.task_id &&
|
||||
e.billable === entry.billable &&
|
||||
e.type === entry.type &&
|
||||
e.description === entry.description
|
||||
);
|
||||
if (oldEntriesIndex !== -1 && newDailyEntries[oldEntriesIndex]) {
|
||||
@@ -113,13 +130,24 @@ function startTimeEntryFromExisting(entry: TimeEntry) {
|
||||
start: getDayJsInstance().utc().format(),
|
||||
end: null,
|
||||
billable: entry.billable,
|
||||
type: entry.type,
|
||||
description: entry.description,
|
||||
tags: [...entry.tags],
|
||||
});
|
||||
}
|
||||
|
||||
function sumDuration(timeEntries: TimeEntry[]) {
|
||||
return timeEntries.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
|
||||
// Breaks are not working time: the day total only sums work entries,
|
||||
// the break portion is shown separately in the heading
|
||||
return timeEntries
|
||||
.filter((entry) => entry.type !== 'break')
|
||||
.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
|
||||
}
|
||||
|
||||
function sumBreakDuration(timeEntries: TimeEntry[]) {
|
||||
return timeEntries
|
||||
.filter((entry) => entry.type === 'break')
|
||||
.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
|
||||
}
|
||||
function selectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
|
||||
for (const timeEntry of value) {
|
||||
@@ -151,6 +179,7 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
|
||||
<TimeEntryRowHeading
|
||||
:date="String(key)"
|
||||
:duration="sumDuration(value)"
|
||||
:break-duration="sumBreakDuration(value)"
|
||||
:checked="
|
||||
value.every((timeEntry: TimeEntry) => selectedTimeEntries.includes(timeEntry))
|
||||
"
|
||||
@@ -176,6 +205,8 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
|
||||
:create-tag
|
||||
:currency="currency"
|
||||
:organization-billable-rate="organizationBillableRate"
|
||||
:break-placement-hints="breakPlacementHints"
|
||||
:fix-in-calendar="fixInCalendar"
|
||||
:time-entry="entry"
|
||||
@selected="
|
||||
(timeEntries: TimeEntry[]) => {
|
||||
@@ -213,6 +244,9 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
|
||||
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
|
||||
:delete-time-entry="() => deleteTimeEntries([entry])"
|
||||
:duplicate-time-entry="() => createTimeEntry(entry)"
|
||||
:create-time-entry="createTimeEntry"
|
||||
:placement-hint="breakPlacementHints[entry.timeEntries[0]!.id] ?? null"
|
||||
:fix-in-calendar="fixInCalendar"
|
||||
:currency="currency"
|
||||
:time-entry="entry.timeEntries[0]!"
|
||||
@selected="selectedTimeEntries.push(entry)"
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type UpdateMultipleTimeEntriesChangeset,
|
||||
} from '@/packages/api/src';
|
||||
import { Checkbox } from '@/packages/ui/src';
|
||||
import { TagIcon } from '@heroicons/vue/20/solid';
|
||||
import { TagIcon, ExclamationTriangleIcon } from '@heroicons/vue/20/solid';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||
import { Button } from '@/packages/ui/src/Buttons';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
@@ -129,6 +129,21 @@ watch(removeAllTags, () => {
|
||||
selectedTags.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
const selectedBreaksCount = computed(
|
||||
() => props.timeEntries.filter((entry) => entry.type === 'break').length
|
||||
);
|
||||
|
||||
// Mirrors the server-side skip in TimeEntryController::updateMultiple: a break
|
||||
// entry is skipped entirely when the changeset assigns a project, makes it
|
||||
// billable, or adds tags (clearing tags via removeAllTags is fine).
|
||||
const showBreakWarning = computed(
|
||||
() =>
|
||||
selectedBreaksCount.value > 0 &&
|
||||
((projectId.value !== null && projectId.value !== '') ||
|
||||
billable.value === true ||
|
||||
selectedTags.value.length > 0)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -141,6 +156,20 @@ watch(removeAllTags, () => {
|
||||
|
||||
<template #content>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="showBreakWarning"
|
||||
data-testid="mass_update_break_warning"
|
||||
class="flex items-start space-x-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:text-amber-400">
|
||||
<ExclamationTriangleIcon class="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
{{ selectedBreaksCount }}
|
||||
{{ selectedBreaksCount === 1 ? 'break is' : 'breaks are' }} selected —
|
||||
breaks can not have a project or tags, or be billable, so
|
||||
{{ selectedBreaksCount === 1 ? 'this entry' : 'these entries' }} will be
|
||||
skipped entirely and none of the changes (including the description) will be
|
||||
applied to {{ selectedBreaksCount === 1 ? 'it' : 'them' }}.
|
||||
</span>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel for="description">Description</FieldLabel>
|
||||
<TextInput
|
||||
|
||||
@@ -18,6 +18,8 @@ import TimeEntryRowDurationInput from '@/packages/ui/src/TimeEntry/TimeEntryRowD
|
||||
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
|
||||
import { TimeEntryEditModal } from '@/packages/ui/src';
|
||||
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
|
||||
import { getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
|
||||
import { computed, ref } from 'vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import {
|
||||
@@ -29,6 +31,10 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from '@/packages/ui/src';
|
||||
import { PlayIcon, PencilIcon, DocumentDuplicateIcon, TrashIcon } from '@heroicons/vue/20/solid';
|
||||
import BreakLabel from '@/packages/ui/src/TimeEntry/BreakLabel.vue';
|
||||
import BreakPlacementHintButton from '@/packages/ui/src/TimeEntry/BreakPlacementHintButton.vue';
|
||||
import type { BreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
|
||||
import type { CreateTimeEntryBody } from '@/packages/api/src';
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntry: TimeEntry;
|
||||
@@ -45,6 +51,9 @@ const props = defineProps<{
|
||||
deleteTimeEntry: () => void;
|
||||
duplicateTimeEntry?: () => void;
|
||||
updateTimeEntry: (timeEntry: TimeEntry) => void;
|
||||
createTimeEntry?: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;
|
||||
placementHint?: BreakPlacementHint | null;
|
||||
fixInCalendar?: (date: string) => void;
|
||||
currency: string;
|
||||
organizationBillableRate: number | null;
|
||||
showMember?: boolean;
|
||||
@@ -59,6 +68,20 @@ const emit = defineEmits<{ selected: []; unselected: [] }>();
|
||||
|
||||
const showEditModal = ref(false);
|
||||
|
||||
const breaksEnabled = useBreaksEnabled();
|
||||
|
||||
const isBreak = computed(() => props.timeEntry.type === 'break');
|
||||
|
||||
// Continue/Duplicate create a new entry of the same type, which the server
|
||||
// rejects for breaks when breaks are disabled for the organization
|
||||
const canRecreate = computed(() => !isBreak.value || breaksEnabled.value);
|
||||
|
||||
const showPlacementHint = computed(
|
||||
() => isBreak.value && props.placementHint != null && props.placementHint.misplaced
|
||||
);
|
||||
|
||||
const breakFixDate = computed(() => getLocalizedDayJs(props.timeEntry.start).format('YYYY-MM-DD'));
|
||||
|
||||
function updateTimeEntryDescription(description: string) {
|
||||
props.updateTimeEntry({ ...props.timeEntry, description });
|
||||
}
|
||||
@@ -131,10 +154,22 @@ async function handleDeleteTimeEntry() {
|
||||
<Checkbox :checked="selected" @update:checked="onSelectChange" />
|
||||
<div v-if="indent === true" class="w-10 h-7"></div>
|
||||
<TimeEntryDescriptionInput
|
||||
v-if="!isBreak"
|
||||
class="min-w-0 mr-4 shrink"
|
||||
:model-value="timeEntry.description"
|
||||
@changed="updateTimeEntryDescription"></TimeEntryDescriptionInput>
|
||||
<BreakLabel v-if="isBreak" class="pl-1.5 @lg:pl-3 pr-2 shrink-0" />
|
||||
<span
|
||||
v-if="isBreak && timeEntry.description"
|
||||
class="min-w-0 mr-4 shrink truncate text-sm text-text-secondary">
|
||||
{{ timeEntry.description }}
|
||||
</span>
|
||||
<BreakPlacementHintButton
|
||||
v-if="showPlacementHint"
|
||||
:fix-date="breakFixDate"
|
||||
:fix-in-calendar="fixInCalendar" />
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-if="!isBreak"
|
||||
class="min-w-0 shrink"
|
||||
:create-project
|
||||
:create-client
|
||||
@@ -154,11 +189,13 @@ async function handleDeleteTimeEntry() {
|
||||
{{ memberName }}
|
||||
</div>
|
||||
<TimeEntryRowTagDropdown
|
||||
v-if="!isBreak"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
:model-value="timeEntry.tags"
|
||||
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
|
||||
<BillableToggleButton
|
||||
v-if="!isBreak"
|
||||
:model-value="timeEntry.billable"
|
||||
size="small"
|
||||
faded
|
||||
@@ -176,11 +213,13 @@ async function handleDeleteTimeEntry() {
|
||||
:is-report="props.isReport"
|
||||
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
|
||||
<TimeTrackerStartStop
|
||||
v-if="canRecreate"
|
||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||
variant="secondary"
|
||||
class="opacity-60 flex focus-visible:opacity-100 group-hover:opacity-100"
|
||||
@changed="onStartStopClick"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
:show-duplicate="canRecreate"
|
||||
@edit="handleEdit"
|
||||
@duplicate="duplicateTimeEntry"
|
||||
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
|
||||
@@ -190,11 +229,17 @@ async function handleDeleteTimeEntry() {
|
||||
<!-- First row: description + duration -->
|
||||
<div class="flex items-center justify-between min-w-0">
|
||||
<TimeEntryDescriptionInput
|
||||
v-if="!isBreak"
|
||||
class="min-w-0 flex-1"
|
||||
:model-value="timeEntry.description"
|
||||
@changed="
|
||||
updateTimeEntryDescription
|
||||
"></TimeEntryDescriptionInput>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 flex-1 truncate text-sm text-text-secondary pl-1.5">
|
||||
{{ timeEntry.description }}
|
||||
</span>
|
||||
<TimeEntryRowDurationInput
|
||||
:start="timeEntry.start"
|
||||
:end="timeEntry.end"
|
||||
@@ -203,7 +248,9 @@ async function handleDeleteTimeEntry() {
|
||||
</div>
|
||||
<!-- Second row: project/task - tags - billable - start - more -->
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<BreakLabel v-if="isBreak" class="pl-1.5 pr-2 min-w-0" />
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-else
|
||||
class="min-w-0"
|
||||
:create-project
|
||||
:create-client
|
||||
@@ -221,21 +268,25 @@ async function handleDeleteTimeEntry() {
|
||||
"></TimeTrackerProjectTaskDropdown>
|
||||
<div class="flex items-center shrink-0">
|
||||
<TimeEntryRowTagDropdown
|
||||
v-if="!isBreak"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
:model-value="timeEntry.tags"
|
||||
compact
|
||||
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
|
||||
<BillableToggleButton
|
||||
v-if="!isBreak"
|
||||
:model-value="timeEntry.billable"
|
||||
size="small"
|
||||
@changed="updateTimeEntryBillable"></BillableToggleButton>
|
||||
<TimeTrackerStartStop
|
||||
v-if="canRecreate"
|
||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||
variant="secondary"
|
||||
class="ml-2"
|
||||
@changed="onStartStopClick"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
:show-duplicate="canRecreate"
|
||||
@edit="handleEdit"
|
||||
@duplicate="duplicateTimeEntry"
|
||||
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
|
||||
@@ -247,7 +298,7 @@ async function handleDeleteTimeEntry() {
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent class="min-w-[160px]">
|
||||
<ContextMenuItem class="space-x-3" @select="onStartStopClick()">
|
||||
<ContextMenuItem v-if="canRecreate" class="space-x-3" @select="onStartStopClick()">
|
||||
<PlayIcon class="w-4 h-4 text-icon-default" />
|
||||
<span>Continue</span>
|
||||
</ContextMenuItem>
|
||||
@@ -255,7 +306,7 @@ async function handleDeleteTimeEntry() {
|
||||
<PencilIcon class="w-4 h-4 text-icon-default" />
|
||||
<span>Edit</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem class="space-x-3" @select="duplicateTimeEntry?.()">
|
||||
<ContextMenuItem v-if="canRecreate" class="space-x-3" @select="duplicateTimeEntry?.()">
|
||||
<DocumentDuplicateIcon class="w-4 h-4 text-icon-default" />
|
||||
<span>Duplicate</span>
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -12,11 +12,17 @@ import { CalendarIcon } from '@heroicons/vue/20/solid';
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
defineProps<{
|
||||
date: string;
|
||||
duration: number;
|
||||
checked: boolean;
|
||||
}>();
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
date: string;
|
||||
duration: number;
|
||||
checked: boolean;
|
||||
breakDuration?: number;
|
||||
}>(),
|
||||
{
|
||||
breakDuration: 0,
|
||||
}
|
||||
);
|
||||
const emit = defineEmits<{
|
||||
selectAll: [];
|
||||
unselectAll: [];
|
||||
@@ -55,6 +61,19 @@ function selectUnselectAll(value: boolean) {
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-text-primary pr-2 @lg:pr-[92px]">
|
||||
<span
|
||||
v-if="breakDuration > 0"
|
||||
data-testid="day_break_duration"
|
||||
class="text-text-secondary font-normal mr-2">
|
||||
{{
|
||||
formatHumanReadableDuration(
|
||||
breakDuration,
|
||||
organization?.interval_format,
|
||||
organization?.number_format
|
||||
)
|
||||
}}
|
||||
break ·
|
||||
</span>
|
||||
<span class="font-medium">
|
||||
{{
|
||||
formatHumanReadableDuration(
|
||||
|
||||
73
resources/js/packages/ui/src/TimeEntry/TimeRangeFields.vue
Normal file
73
resources/js/packages/ui/src/TimeEntry/TimeRangeFields.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue';
|
||||
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import { Field, FieldLabel } from '../field';
|
||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
|
||||
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
||||
import { getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
|
||||
// Local (user timezone) ISO strings, as produced by getLocalizedDayJs(...).format()
|
||||
const start = defineModel<string>('start', { required: true });
|
||||
const end = defineModel<string>('end', { required: true });
|
||||
|
||||
defineProps<{
|
||||
showHint?: boolean;
|
||||
datePickerSize?: 'sm';
|
||||
}>();
|
||||
|
||||
// Moving the start to or past the end drags the end along, preserving the
|
||||
// range's previous duration, so the range never collapses or inverts.
|
||||
watch(start, (value, oldValue) => {
|
||||
if (getLocalizedDayJs(end.value).isAfter(getLocalizedDayJs(value))) return;
|
||||
const previousDuration = Math.max(
|
||||
0,
|
||||
getLocalizedDayJs(end.value).diff(getLocalizedDayJs(oldValue), 'second')
|
||||
);
|
||||
end.value = getLocalizedDayJs(value).add(previousDuration, 'second').format();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
||||
<Field class="col-span-2 sm:col-span-3">
|
||||
<FieldLabel>Duration</FieldLabel>
|
||||
<div class="space-y-2 flex flex-col">
|
||||
<DurationHumanInput
|
||||
v-model:start="start"
|
||||
v-model:end="end"
|
||||
name="Duration"></DurationHumanInput>
|
||||
<div v-if="showHint" class="text-sm flex space-x-1">
|
||||
<InformationCircleIcon
|
||||
class="w-4 shrink-0 text-text-quaternary"></InformationCircleIcon>
|
||||
<span class="text-text-secondary text-xs">
|
||||
You can type natural language like
|
||||
<span class="font-semibold"> 2h 30m</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Start</FieldLabel>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TimePickerSimple v-model="start" class="w-full"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="start"
|
||||
:size="datePickerSize"
|
||||
class="w-full"
|
||||
tabindex="1"></DatePicker>
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>End</FieldLabel>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TimePickerSimple v-model="end" class="w-full"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="end"
|
||||
:size="datePickerSize"
|
||||
class="w-full"
|
||||
tabindex="1"></DatePicker>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user