mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-15 03:32:15 +01:00
add break time entries and simplified time tracker ui
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
|
||||
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||
import { FolderIcon } from '@heroicons/vue/16/solid';
|
||||
import { Check } from '@lucide/vue';
|
||||
import { Check, Coffee } from '@lucide/vue';
|
||||
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
|
||||
@@ -27,6 +28,7 @@ const selectedClients = defineModel<string[]>('selectedClients', { required: tru
|
||||
const selectedTags = defineModel<string[]>('selectedTags', { required: true });
|
||||
const tagMatchType = defineModel<TagMatchType>('tagMatchType', { required: true });
|
||||
const billable = defineModel<'true' | 'false' | null>('billable', { required: true });
|
||||
const entryType = defineModel<'work' | 'break' | null>('entryType', { required: true });
|
||||
const roundingEnabled = defineModel<boolean>('roundingEnabled', { required: true });
|
||||
const roundingType = defineModel<TimeEntryRoundingType>('roundingType', { required: true });
|
||||
const roundingMinutes = defineModel<number>('roundingMinutes', { required: true });
|
||||
@@ -37,6 +39,8 @@ const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
|
||||
const breaksEnabled = useBreaksEnabled();
|
||||
|
||||
const { tags } = useTagsQuery();
|
||||
|
||||
const tagMatchOptions: { value: TagMatchType; label: string }[] = [
|
||||
@@ -162,6 +166,38 @@ async function createTag(name: string) {
|
||||
<SelectItem value="false">Non Billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
v-if="breaksEnabled"
|
||||
v-model="entryType"
|
||||
@update:model-value="emit('submit')">
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:active="entryType !== null"
|
||||
:show-chevron="false">
|
||||
<SelectValue class="flex items-center gap-2">
|
||||
<Coffee
|
||||
class="h-4 w-4"
|
||||
:class="
|
||||
entryType !== null
|
||||
? 'dark:text-accent-300/80 text-accent-400/80'
|
||||
: 'text-text-quaternary'
|
||||
" />
|
||||
<span class="text-text-secondary">{{
|
||||
entryType === null
|
||||
? 'Type'
|
||||
: entryType === 'break'
|
||||
? 'Breaks'
|
||||
: 'Work time'
|
||||
}}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">Both</SelectItem>
|
||||
<SelectItem value="work">Work time</SelectItem>
|
||||
<SelectItem value="break">Breaks</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ReportingRoundingControls
|
||||
v-model:enabled="roundingEnabled"
|
||||
v-model:type="roundingType"
|
||||
|
||||
@@ -71,6 +71,7 @@ const selectedClients = ref<string[]>([]);
|
||||
const tagMatchType = ref<TagMatchType>('contains');
|
||||
|
||||
const billable = ref<'true' | 'false' | null>(null);
|
||||
const entryType = ref<'work' | 'break' | null>('work');
|
||||
const roundingEnabled = ref<boolean>(false);
|
||||
const roundingType = ref<TimeEntryRoundingType>('nearest');
|
||||
const roundingMinutes = ref<number>(15);
|
||||
@@ -126,6 +127,7 @@ const filterParams = computed<AggregatedTimeEntriesQueryParams>(() => {
|
||||
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||
tag_match_type: selectedTags.value.length > 0 ? tagMatchType.value : undefined,
|
||||
billable: billable.value !== null ? billable.value : undefined,
|
||||
type: entryType.value !== null ? entryType.value : undefined,
|
||||
member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
|
||||
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
|
||||
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
|
||||
@@ -160,7 +162,7 @@ const aggregatedTableTimeEntries = computed<AggregatedTimeEntries | undefined>((
|
||||
});
|
||||
|
||||
const reportProperties = computed(() => {
|
||||
const { billable: billableFilter, ...rest } = filterParams.value;
|
||||
const { billable: billableFilter, type: typeFilter, ...rest } = filterParams.value;
|
||||
|
||||
let billableValue: boolean | null = null;
|
||||
if (billableFilter === 'true') {
|
||||
@@ -172,6 +174,7 @@ const reportProperties = computed(() => {
|
||||
return {
|
||||
...rest,
|
||||
billable: billableValue,
|
||||
time_entry_type: typeFilter ?? null,
|
||||
group: group.value,
|
||||
sub_group: subGroup.value,
|
||||
history_group: getOptimalGroupingOption(startDate.value, endDate.value),
|
||||
@@ -371,6 +374,7 @@ const tableData = computed(() => {
|
||||
v-model:selected-tags="selectedTags"
|
||||
v-model:tag-match-type="tagMatchType"
|
||||
v-model:billable="billable"
|
||||
v-model:entry-type="entryType"
|
||||
v-model:rounding-enabled="roundingEnabled"
|
||||
v-model:rounding-type="roundingType"
|
||||
v-model:rounding-minutes="roundingMinutes"
|
||||
|
||||
@@ -28,6 +28,7 @@ const {
|
||||
},
|
||||
queries: {
|
||||
member_id: getCurrentMembershipId(),
|
||||
type: 'work',
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -62,6 +62,8 @@ const queryParams = computed<AggregatedTimeEntriesQueryParams>(() => {
|
||||
group: group.value,
|
||||
sub_group: subGroup.value,
|
||||
member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
|
||||
// Breaks are excluded from all dashboard stats (see DashboardService workTime())
|
||||
type: 'work',
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ import CardTitle from '@/packages/ui/src/CardTitle.vue';
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { type User } from '@/types/models';
|
||||
import { computed, onMounted, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
|
||||
|
||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||
import { getLastWorkTimeEntry, useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||
import { switchOrganization } from '@/utils/useOrganization';
|
||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||
@@ -20,6 +20,7 @@ import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import TimeTrackerControls from '@/packages/ui/src/TimeTracker/TimeTrackerControls.vue';
|
||||
import type { TimeTrackerMode } from '@/packages/ui/src/TimeTracker/types';
|
||||
import type {
|
||||
CreateClientBody,
|
||||
CreateProjectBody,
|
||||
@@ -44,15 +45,15 @@ const page = usePage<{
|
||||
user: User;
|
||||
};
|
||||
}>();
|
||||
dayjs.extend(duration);
|
||||
|
||||
dayjs.extend(utc);
|
||||
const dayjs = getDayJsInstance();
|
||||
|
||||
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
||||
const breaksEnabled = useBreaksEnabled(organization);
|
||||
|
||||
const currentTimeEntryStore = useCurrentTimeEntryStore();
|
||||
const { currentTimeEntry, isActive, now } = storeToRefs(currentTimeEntryStore);
|
||||
const { startLiveTimer, stopLiveTimer, setActiveState } = currentTimeEntryStore;
|
||||
const { currentTimeEntry, isActive, isOnBreak, now } = storeToRefs(currentTimeEntryStore);
|
||||
const { startLiveTimer, stopLiveTimer, setActiveState, startBreak, resumeWorkAfterBreak } =
|
||||
currentTimeEntryStore;
|
||||
|
||||
const { projects } = useProjectsQuery();
|
||||
const { tasks } = useTasksQuery();
|
||||
@@ -67,6 +68,8 @@ const showManualTimeEntryModal = ref(false);
|
||||
const { createTimeEntry: createTimeEntryMutation, deleteTimeEntry } = useTimeEntriesMutations();
|
||||
const { data: timeEntriesData } = useTimeEntriesInfiniteQuery();
|
||||
const timeEntries = computed(() => timeEntriesData.value?.pages.flatMap((page) => page.data) || []);
|
||||
const lastWorkTimeEntry = computed(() => getLastWorkTimeEntry(timeEntries.value));
|
||||
const canResumeAfterBreak = computed(() => lastWorkTimeEntry.value !== null);
|
||||
|
||||
watch(isActive, () => {
|
||||
if (isActive.value) {
|
||||
@@ -123,6 +126,14 @@ async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>
|
||||
showManualTimeEntryModal.value = false;
|
||||
}
|
||||
|
||||
async function resumePreviousWorkAfterBreak() {
|
||||
const timeEntry = lastWorkTimeEntry.value;
|
||||
if (!timeEntry) {
|
||||
return;
|
||||
}
|
||||
await resumeWorkAfterBreak(timeEntry);
|
||||
}
|
||||
|
||||
async function createTimeEntryFromCurrentEntry() {
|
||||
const { start, end, description, project_id, task_id, billable, tags } = currentTimeEntry.value;
|
||||
await createTimeEntry({ start, end, description, project_id, task_id, billable, tags });
|
||||
@@ -142,6 +153,16 @@ async function discardCurrentTimeEntry() {
|
||||
}
|
||||
}
|
||||
|
||||
// Time tracker UI mode is a per-device UI preference, stored client-side and keyed by organization
|
||||
const timeTrackerMode = useLocalStorage<TimeTrackerMode>(
|
||||
`solidtime/time-tracker-mode/${getCurrentOrganizationId()}`,
|
||||
'project'
|
||||
);
|
||||
|
||||
function toggleTimeTrackerMode() {
|
||||
timeTrackerMode.value = timeTrackerMode.value === 'simple' ? 'project' : 'simple';
|
||||
}
|
||||
|
||||
const { tags } = useTagsQuery();
|
||||
</script>
|
||||
|
||||
@@ -186,17 +207,29 @@ const { tags } = useTagsQuery();
|
||||
:time-entries
|
||||
:create-tag
|
||||
:is-active
|
||||
:is-on-break="isOnBreak"
|
||||
:breaks-enabled="breaksEnabled"
|
||||
:can-resume-after-break="canResumeAfterBreak"
|
||||
:resume-description="lastWorkTimeEntry?.description ?? null"
|
||||
:time-tracker-mode="timeTrackerMode"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
@start-live-timer="startLiveTimer"
|
||||
@stop-live-timer="stopLiveTimer"
|
||||
@start-timer="setActiveState(true)"
|
||||
@stop-timer="setActiveState(false)"
|
||||
@start-break="startBreak"
|
||||
@resume-after-break="resumePreviousWorkAfterBreak"
|
||||
@update-time-entry="updateTimeEntry"
|
||||
@create-time-entry="createTimeEntryFromCurrentEntry"></TimeTrackerControls>
|
||||
</div>
|
||||
<TimeTrackerMoreOptionsDropdown
|
||||
:has-active-timer="isActive"
|
||||
:time-tracker-mode="timeTrackerMode"
|
||||
:is-on-break="isOnBreak"
|
||||
:breaks-enabled="breaksEnabled"
|
||||
@manual-entry="showManualTimeEntryModal = true"
|
||||
@start-break="startBreak"
|
||||
@toggle-time-tracker-mode="toggleTimeTrackerMode"
|
||||
@discard="discardCurrentTimeEntry"></TimeTrackerMoreOptionsDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
225
resources/js/Components/Timesheet/BreakPlacementModal.vue
Normal file
225
resources/js/Components/Timesheet/BreakPlacementModal.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
|
||||
import { formatTime, getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import { Coffee } from '@lucide/vue';
|
||||
import { computed, inject, ref, watch, type ComputedRef } from 'vue';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
import {
|
||||
BREAK_GAP_TOLERANCE_SECONDS,
|
||||
placementMode,
|
||||
planMoveInsert,
|
||||
planSplitEntry,
|
||||
type BreakPlacementRequest,
|
||||
} from '@/utils/timesheet/breakPlacementMath';
|
||||
import { BREAK_GAP_TOLERANCE_MINUTES } from '@/packages/ui/src/utils/breakPlacement';
|
||||
|
||||
const props = defineProps<{
|
||||
request: BreakPlacementRequest | null;
|
||||
apply: (breakStart: string, durationSeconds: number) => Promise<void>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ cancel: [] }>();
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
const show = computed(() => props.request !== null);
|
||||
const mode = computed(() => (props.request ? placementMode(props.request) : null));
|
||||
const saving = ref(false);
|
||||
|
||||
const localStart = ref('');
|
||||
const localEnd = ref('');
|
||||
|
||||
// Seed the pickers from the suggested placement whenever a new request arrives.
|
||||
watch(
|
||||
() => props.request,
|
||||
(request) => {
|
||||
if (!request) return;
|
||||
localStart.value = getLocalizedDayJs(request.defaultBreakStart).format();
|
||||
localEnd.value = getLocalizedDayJs(request.defaultBreakStart)
|
||||
.add(request.durationSeconds, 'second')
|
||||
.format();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const utcStart = computed(() => getLocalizedDayJs(localStart.value).utc().format());
|
||||
const durationSeconds = computed(() =>
|
||||
getLocalizedDayJs(localEnd.value)
|
||||
.utc()
|
||||
.diff(getLocalizedDayJs(localStart.value).utc(), 'second')
|
||||
);
|
||||
|
||||
const splitPlan = computed(() => {
|
||||
if (!props.request || mode.value !== 'split' || durationSeconds.value <= 0) return null;
|
||||
return planSplitEntry(props.request.workEntries[0]!, durationSeconds.value, utcStart.value);
|
||||
});
|
||||
|
||||
const movePlan = computed(() => {
|
||||
if (!props.request || mode.value !== 'move' || durationSeconds.value <= 0) return null;
|
||||
return planMoveInsert(
|
||||
[...props.request.workEntries, ...props.request.otherEntries],
|
||||
props.request.dayStart,
|
||||
props.request.dayEnd,
|
||||
utcStart.value,
|
||||
durationSeconds.value
|
||||
);
|
||||
});
|
||||
|
||||
// Non-blocking heads-up: the placement is feasible but the break would end up
|
||||
// further than the tolerance from work on either side, so it would carry the
|
||||
// misaligned warning right after being created. Mirrors getBreakPlacementHint,
|
||||
// but computed against the planned (post-shift) layout.
|
||||
const resultMisaligned = computed<boolean>(() => {
|
||||
const req = props.request;
|
||||
const plan = movePlan.value;
|
||||
if (!req || mode.value !== 'move' || !plan) return false;
|
||||
const dayjs = getDayJsInstance();
|
||||
const toMs = (iso: string) => dayjs.utc(iso).valueOf();
|
||||
const breakStartMs = toMs(plan.breakSlot.start);
|
||||
const breakEndMs = toMs(plan.breakSlot.end);
|
||||
const shiftedById = new Map(plan.shifted.map((s) => [s.id, s]));
|
||||
|
||||
let prevWorkEndMs: number | null = null;
|
||||
let nextWorkStartMs: number | null = null;
|
||||
for (const entry of req.workEntries) {
|
||||
const planned = shiftedById.get(entry.id) ?? entry;
|
||||
const startMs = toMs(planned.start);
|
||||
const endMs = toMs(planned.end);
|
||||
if (endMs <= breakStartMs && (prevWorkEndMs === null || endMs > prevWorkEndMs)) {
|
||||
prevWorkEndMs = endMs;
|
||||
}
|
||||
if (startMs >= breakEndMs && (nextWorkStartMs === null || startMs < nextWorkStartMs)) {
|
||||
nextWorkStartMs = startMs;
|
||||
}
|
||||
}
|
||||
|
||||
const toleranceMs = BREAK_GAP_TOLERANCE_SECONDS * 1000;
|
||||
return (
|
||||
prevWorkEndMs === null ||
|
||||
breakStartMs - prevWorkEndMs > toleranceMs ||
|
||||
nextWorkStartMs === null ||
|
||||
nextWorkStartMs - breakEndMs > toleranceMs
|
||||
);
|
||||
});
|
||||
|
||||
const feasible = computed(() =>
|
||||
mode.value === 'split' ? splitPlan.value !== null : movePlan.value !== null
|
||||
);
|
||||
|
||||
function fmt(iso: string): string {
|
||||
return formatTime(iso, organization?.value?.time_format);
|
||||
}
|
||||
|
||||
const explanation = computed(() => {
|
||||
if (!props.request) return '';
|
||||
return mode.value === 'split'
|
||||
? "There's no free gap that fits this break, so the work entry will be split and the break placed inside it."
|
||||
: "There's no free gap that fits this break, so the surrounding entries will be shifted to make room.";
|
||||
});
|
||||
|
||||
// Human-readable summary of what will change, so the user can confirm the edit.
|
||||
const changeSummary = computed<string[]>(() => {
|
||||
if (mode.value === 'split') {
|
||||
const plan = splitPlan.value;
|
||||
if (!plan) return [];
|
||||
return [
|
||||
`${fmt(plan.firstHalf.start)}–${fmt(plan.firstHalf.end)} (work)`,
|
||||
`${fmt(plan.breakSlot.start)}–${fmt(plan.breakSlot.end)} (break)`,
|
||||
`${fmt(plan.secondHalf.start)}–${fmt(plan.secondHalf.end)} (work)`,
|
||||
];
|
||||
}
|
||||
const plan = movePlan.value;
|
||||
if (!plan) return [];
|
||||
if (plan.shifted.length === 0) return ['No entries need to move.'];
|
||||
return plan.shifted.map((shift) => {
|
||||
const isBreak = props.request!.otherEntries.some((e) => e.id === shift.id);
|
||||
const original =
|
||||
props.request!.workEntries.find((e) => e.id === shift.id) ??
|
||||
props.request!.otherEntries.find((e) => e.id === shift.id)!;
|
||||
const label = `${fmt(original.start)}–${fmt(original.end)} → ${fmt(shift.start)}–${fmt(shift.end)}`;
|
||||
return isBreak ? `${label} (break)` : label;
|
||||
});
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (!feasible.value || durationSeconds.value <= 0) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await props.apply(utcStart.value, durationSeconds.value);
|
||||
} catch {
|
||||
// apply surfaces its own error toast; keep the modal open so the user can retry
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="emit('cancel')">
|
||||
<template #title>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Coffee class="w-5 h-5 text-text-secondary" />
|
||||
<span>Add break</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-text-secondary">{{ explanation }}</p>
|
||||
|
||||
<TimeRangeFields
|
||||
v-model:start="localStart"
|
||||
v-model:end="localEnd"
|
||||
date-picker-size="sm"></TimeRangeFields>
|
||||
|
||||
<div
|
||||
v-if="feasible"
|
||||
data-testid="break_placement_summary"
|
||||
class="rounded-lg border border-card-border bg-secondary/40 px-3 py-2 text-sm text-text-secondary space-y-1">
|
||||
<div class="text-xs uppercase tracking-wide text-text-tertiary">
|
||||
{{ mode === 'split' ? 'Result' : 'Entries that move' }}
|
||||
</div>
|
||||
<div v-for="(line, index) in changeSummary" :key="index" class="tabular-nums">
|
||||
{{ line }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="feasible && resultMisaligned"
|
||||
data-testid="break_placement_misaligned_warning"
|
||||
class="rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
At this time the break would sit more than
|
||||
{{ BREAK_GAP_TOLERANCE_MINUTES }} minutes away from your work entries and will
|
||||
be flagged as misaligned.
|
||||
</div>
|
||||
<!-- `request` guard (not just !feasible): when the request is cleared on save,
|
||||
the dialog fades out with content still mounted — don't flash the error then -->
|
||||
<div
|
||||
v-if="!feasible && request"
|
||||
data-testid="break_placement_infeasible"
|
||||
class="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
|
||||
{{
|
||||
mode === 'split'
|
||||
? "This break doesn't fit there — it must lie inside the work entry, leaving at least a minute of work on each side."
|
||||
: "This break doesn't fit at that time without pushing an entry outside the day. Try a shorter break or a different time."
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="emit('cancel')">Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': saving || !feasible }"
|
||||
:disabled="saving || !feasible"
|
||||
@click="submit">
|
||||
Add break
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -93,4 +93,26 @@ describe('TimesheetCell', () => {
|
||||
|
||||
expect((wrapper.get('input').element as HTMLInputElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('renders read-only and emits nothing when the row is read-only', async () => {
|
||||
const wrapper = mount(TimesheetCell, {
|
||||
props: {
|
||||
cell: buildCell(2 * 3600),
|
||||
dayIndex: 0,
|
||||
date: '2026-04-13',
|
||||
isToday: false,
|
||||
hasRunningEntry: false,
|
||||
readonly: true,
|
||||
},
|
||||
});
|
||||
|
||||
const input = wrapper.get('input');
|
||||
expect((input.element as HTMLInputElement).disabled).toBe(true);
|
||||
|
||||
await input.trigger('focus');
|
||||
await input.setValue('4h');
|
||||
await input.trigger('blur');
|
||||
|
||||
expect(wrapper.emitted('update')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ const props = defineProps<{
|
||||
date: string;
|
||||
isToday: boolean;
|
||||
hasRunningEntry: boolean;
|
||||
readonly?: boolean;
|
||||
saveStatus?: CellSaveStatus;
|
||||
pendingSeconds?: number;
|
||||
}>();
|
||||
@@ -30,6 +31,16 @@ const emit = defineEmits<{
|
||||
const displaySeconds = computed(() => props.pendingSeconds ?? props.cell?.totalSeconds ?? 0);
|
||||
const isSaving = computed(() => props.saveStatus === 'saving');
|
||||
|
||||
// A cell is non-editable while its entry is running or when the row itself is
|
||||
// read-only (e.g. a leftover break row after breaks were disabled). Both render
|
||||
// the same disabled input, differing only in the tooltip explanation.
|
||||
const isReadonly = computed(() => props.hasRunningEntry || props.readonly === true);
|
||||
const readonlyTooltip = computed(() =>
|
||||
props.hasRunningEntry
|
||||
? 'Stop the running time entry to edit the timesheet'
|
||||
: 'Breaks are disabled for this organization'
|
||||
);
|
||||
|
||||
// Swap the border color (don't layer) to avoid same-specificity fights.
|
||||
const inputClass = computed(() => {
|
||||
const border = props.saveStatus === 'error' ? 'border-red-500/70' : 'border-input-border';
|
||||
@@ -51,7 +62,7 @@ const inputClass = computed(() => {
|
||||
data-testid="timesheet_cell"
|
||||
class="flex items-center justify-center border-t border-default-background-separator"
|
||||
:class="{ 'bg-default-background': isToday }">
|
||||
<TooltipProvider v-if="hasRunningEntry" :delay-duration="100">
|
||||
<TooltipProvider v-if="isReadonly" :delay-duration="100">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span class="inline-block cursor-not-allowed">
|
||||
@@ -68,7 +79,7 @@ const inputClass = computed(() => {
|
||||
disabled:opacity-50 disabled:cursor-not-allowed" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent> Stop the running time entry to edit the timesheet </TooltipContent>
|
||||
<TooltipContent>{{ readonlyTooltip }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<template v-else>
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
import { inject, type ComputedRef } from 'vue';
|
||||
import { Button } from '@/packages/ui/src/Buttons';
|
||||
import { PlusIcon } from '@heroicons/vue/20/solid';
|
||||
import { ExclamationTriangleIcon, ArrowRightIcon } from '@heroicons/vue/16/solid';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/packages/ui/src';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import TimesheetRow from '@/Components/Timesheet/TimesheetRow.vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||
@@ -26,6 +29,8 @@ defineProps<{
|
||||
todayDate: string;
|
||||
dayTotals: number[];
|
||||
weekTotalFormatted: string;
|
||||
breakDayTotals: number[];
|
||||
breakGrandTotal: number;
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
clients: Client[];
|
||||
@@ -39,6 +44,7 @@ defineProps<{
|
||||
formatDuration: (seconds: number) => string;
|
||||
cellStatuses: Record<string, CellSaveStatus>;
|
||||
cellPendingSeconds: Record<string, number>;
|
||||
misplacedBreakDates?: Set<string>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -74,9 +80,34 @@ const emit = defineEmits<{
|
||||
<div
|
||||
v-for="day in weekDays"
|
||||
:key="day"
|
||||
data-testid="timesheet_day_header"
|
||||
class="bg-background dark:bg-secondary px-2 py-1 text-center">
|
||||
<div class="text-xs font-medium text-text-secondary">
|
||||
{{ dayjs(day).format('ddd D') }}
|
||||
<div
|
||||
class="flex items-center justify-center gap-1 text-xs font-medium text-text-secondary">
|
||||
<span>{{ dayjs(day).format('ddd D') }}</span>
|
||||
<DropdownMenu v-if="misplacedBreakDates?.has(day)">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
title="A break on this day 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-3.5 h-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="min-w-[240px]" align="start">
|
||||
<div class="px-3 py-2 space-y-1.5">
|
||||
<p class="text-xs text-text-secondary">
|
||||
A break on this day is not directly between work entries.
|
||||
</p>
|
||||
<Link
|
||||
:href="`/calendar?date=${day}`"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-accent-400 hover:underline">
|
||||
Fix in calendar
|
||||
<ArrowRightIcon class="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -85,7 +116,7 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
<div class="bg-background dark:bg-secondary"></div>
|
||||
|
||||
<!-- Data rows -->
|
||||
<!-- Data rows (break row is pinned last) -->
|
||||
<TimesheetRow
|
||||
v-for="row in rows"
|
||||
:key="row.key"
|
||||
@@ -140,9 +171,9 @@ const emit = defineEmits<{
|
||||
</TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
|
||||
<!-- Totals row -->
|
||||
<!-- Totals row: worked time, with break time annotated below (calendar-style) -->
|
||||
<div
|
||||
class="border-t border-default-background-separator bg-background dark:bg-secondary pl-7 pr-3 py-1 text-xs text-text-tertiary md:sticky md:left-0 md:z-10">
|
||||
class="flex items-center border-t border-default-background-separator bg-background dark:bg-secondary pl-7 pr-3 py-1 text-xs text-text-tertiary md:sticky md:left-0 md:z-10">
|
||||
Total
|
||||
</div>
|
||||
<div
|
||||
@@ -150,18 +181,24 @@ const emit = defineEmits<{
|
||||
:key="dayIndex"
|
||||
data-testid="timesheet_day_total"
|
||||
:class="[
|
||||
'flex items-center justify-center border-t border-default-background-separator bg-background dark:bg-secondary px-2 py-1 text-xs font-medium',
|
||||
'flex flex-col items-center justify-center border-t border-default-background-separator bg-background dark:bg-secondary px-2 py-1 text-xs font-medium leading-tight',
|
||||
weekDays[dayIndex] === todayDate
|
||||
? 'text-text-primary'
|
||||
: 'text-text-secondary',
|
||||
]">
|
||||
<span class="w-[80px] text-center">
|
||||
{{ total > 0 ? formatDuration(total) : '-' }}
|
||||
<span>{{ total > 0 ? formatDuration(total) : '-' }}</span>
|
||||
<span
|
||||
v-if="(breakDayTotals[dayIndex] ?? 0) > 0"
|
||||
class="font-normal text-text-tertiary">
|
||||
+{{ formatDuration(breakDayTotals[dayIndex] ?? 0) }} break
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-end border-t border-default-background-separator bg-background dark:bg-secondary pl-3 pr-3 py-1 text-xs font-semibold text-text-primary">
|
||||
{{ weekTotalFormatted }}
|
||||
class="flex flex-col items-end justify-center border-t border-default-background-separator bg-background dark:bg-secondary pl-3 pr-3 py-1 text-xs font-semibold text-text-primary leading-tight">
|
||||
<span>{{ weekTotalFormatted }}</span>
|
||||
<span v-if="breakGrandTotal > 0" class="font-normal text-text-tertiary">
|
||||
+{{ formatDuration(breakGrandTotal) }} break
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="border-t border-default-background-separator bg-background dark:bg-secondary"></div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, type ComputedRef } from 'vue';
|
||||
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
|
||||
import { XMarkIcon } from '@heroicons/vue/16/solid';
|
||||
import { Coffee } from '@lucide/vue';
|
||||
import TimesheetCell from './TimesheetCell.vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
import { Button } from '@/packages/ui/src/Buttons';
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
const breaksEnabled = useBreaksEnabled();
|
||||
|
||||
const props = defineProps<{
|
||||
row: TimesheetRow;
|
||||
@@ -62,6 +65,11 @@ const selectedTask = computed({
|
||||
|
||||
const rowTotalFormatted = computed(() => props.formatDuration(props.row.totalSeconds));
|
||||
|
||||
// A break row can survive after breaks are disabled (its entries are
|
||||
// grandfathered). Those cells become read-only — creating/editing break time is
|
||||
// rejected server-side — leaving the remove button as the only action.
|
||||
const cellsReadonly = computed(() => props.row.type === 'break' && !breaksEnabled.value);
|
||||
|
||||
function hasRunningEntry(dayIndex: number): boolean {
|
||||
const cell = props.row.cells.get(dayIndex);
|
||||
if (!cell) return false;
|
||||
@@ -74,7 +82,13 @@ function hasRunningEntry(dayIndex: number): boolean {
|
||||
<!-- Project/Task column -->
|
||||
<div
|
||||
class="flex items-center gap-1 border-t border-default-background-separator bg-default-background pl-4 pr-3 py-2 md:sticky md:left-0 md:z-10">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
v-if="row.type === 'break'"
|
||||
class="flex flex-1 items-center gap-1.5 min-w-0 px-2 py-1 text-sm text-text-secondary">
|
||||
<Coffee class="w-4 h-4" />
|
||||
<span>Break</span>
|
||||
</div>
|
||||
<div v-else class="flex-1 min-w-0">
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="selectedProject"
|
||||
v-model:task="selectedTask"
|
||||
@@ -94,11 +108,13 @@ function hasRunningEntry(dayIndex: number): boolean {
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-shrink-0 ml-auto">
|
||||
<TimeEntryRowTagDropdown
|
||||
v-if="row.type !== 'break'"
|
||||
:create-tag="createTag"
|
||||
:tags="tags"
|
||||
:model-value="row.tags"
|
||||
@changed="emit('tagsChange', $event)" />
|
||||
<BillableToggleButton
|
||||
v-if="row.type !== 'break'"
|
||||
:model-value="row.billable"
|
||||
size="small"
|
||||
faded
|
||||
@@ -115,6 +131,7 @@ function hasRunningEntry(dayIndex: number): boolean {
|
||||
:date="day"
|
||||
:is-today="day === todayDate"
|
||||
:has-running-entry="hasRunningEntry(dayIndex)"
|
||||
:readonly="cellsReadonly"
|
||||
:save-status="cellStatuses[makeCellStatusKey(row.key, dayIndex)]"
|
||||
:pending-seconds="cellPendingSeconds[makeCellStatusKey(row.key, dayIndex)]"
|
||||
@update="(seconds) => emit('cellUpdate', dayIndex, seconds)" />
|
||||
@@ -126,10 +143,11 @@ function hasRunningEntry(dayIndex: number): boolean {
|
||||
{{ rowTotalFormatted }}
|
||||
</div>
|
||||
|
||||
<!-- Remove action -->
|
||||
<!-- Remove action (the break row is permanent while breaks are enabled) -->
|
||||
<div
|
||||
class="flex items-center justify-center border-t border-default-background-separator pr-4 py-3">
|
||||
<Button
|
||||
v-if="!(row.type === 'break' && breaksEnabled)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Remove row"
|
||||
|
||||
Reference in New Issue
Block a user