@nuxt-customer-portal/timesheets 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +5 -0
  4. package/app/assets/timesheets.css +368 -0
  5. package/app/components/TimesheetsActivityForm.vue +61 -0
  6. package/app/components/TimesheetsAdminActivities.vue +323 -0
  7. package/app/components/TimesheetsAdminApprovals.vue +449 -0
  8. package/app/components/TimesheetsAdminEmptyState.vue +28 -0
  9. package/app/components/TimesheetsAdminListToolbar.vue +19 -0
  10. package/app/components/TimesheetsAdminPaginatedList.vue +95 -0
  11. package/app/components/TimesheetsAdminProjects.vue +234 -0
  12. package/app/components/TimesheetsAdminRates.vue +83 -0
  13. package/app/components/TimesheetsAdminReports.vue +123 -0
  14. package/app/components/TimesheetsAdminSectionPage.vue +135 -0
  15. package/app/components/TimesheetsAdminSettings.vue +15 -0
  16. package/app/components/TimesheetsAdministrationOrganizationPanel.vue +26 -0
  17. package/app/components/TimesheetsClientSettingsPanel.vue +52 -0
  18. package/app/components/TimesheetsClientTimesheetBrowser.vue +439 -0
  19. package/app/components/TimesheetsDashboardCard.vue +260 -0
  20. package/app/components/TimesheetsDashboardClientApprovals.vue +1 -0
  21. package/app/components/TimesheetsDashboardInternalApprovals.vue +1 -0
  22. package/app/components/TimesheetsDashboardMyTimesheets.vue +1 -0
  23. package/app/components/TimesheetsDashboardMyWeek.vue +1 -0
  24. package/app/components/TimesheetsDashboardSupplierTimesheets.vue +47 -0
  25. package/app/components/TimesheetsOrganizationFeaturesCard.vue +66 -0
  26. package/app/components/TimesheetsPageShell.vue +48 -0
  27. package/app/components/TimesheetsProjectDetail.vue +338 -0
  28. package/app/components/TimesheetsProjectForm.vue +215 -0
  29. package/app/components/TimesheetsSetupChecklist.vue +48 -0
  30. package/app/components/TimesheetsSubmissionMessages.vue +30 -0
  31. package/app/components/TimesheetsSubmissionTimeline.vue +89 -0
  32. package/app/components/TimesheetsWorkspaceSettingsForm.vue +80 -0
  33. package/app/composables/useTimesheetMenu.ts +69 -0
  34. package/app/composables/useTimesheetMutationError.ts +35 -0
  35. package/app/composables/useTimesheets.ts +248 -0
  36. package/app/composables/useTimesheetsAdminList.ts +165 -0
  37. package/app/composables/useTimesheetsDashboard.ts +8 -0
  38. package/app/pages/admin/timesheets/activities.vue +3 -0
  39. package/app/pages/admin/timesheets/approvals.vue +3 -0
  40. package/app/pages/admin/timesheets/index.vue +7 -0
  41. package/app/pages/admin/timesheets/internal-approvals.vue +131 -0
  42. package/app/pages/admin/timesheets/projects/[id].vue +57 -0
  43. package/app/pages/admin/timesheets/projects.vue +8 -0
  44. package/app/pages/admin/timesheets/rates.vue +3 -0
  45. package/app/pages/admin/timesheets/reports.vue +3 -0
  46. package/app/pages/admin/timesheets/settings.vue +3 -0
  47. package/app/pages/timesheets/approvals/reviewers.vue +96 -0
  48. package/app/pages/timesheets/approvals.vue +11 -0
  49. package/app/pages/timesheets/client-approvals.vue +108 -0
  50. package/app/pages/timesheets/client.vue +17 -0
  51. package/app/pages/timesheets/index.vue +1458 -0
  52. package/app/pages/timesheets/internal-approvals.vue +100 -0
  53. package/app/pages/timesheets/suppliers.vue +23 -0
  54. package/app/plugins/timesheets-feature.ts +122 -0
  55. package/i18n/locales/en.json +455 -0
  56. package/i18n/locales/nl.json +455 -0
  57. package/migrations/0000_baseline.sql +409 -0
  58. package/migrations/0001_remove_unused_legacy_invoice_schema.sql +31 -0
  59. package/migrations/0002_submission_batches.sql +47 -0
  60. package/migrations/0003_sync_provider_clients.sql +26 -0
  61. package/migrations/0004_client_reply.sql +1 -0
  62. package/migrations/0005_timer_rounding.sql +1 -0
  63. package/migrations/meta/0000_snapshot.json +3178 -0
  64. package/migrations/meta/_journal.json +13 -0
  65. package/nuxt.config.ts +20 -0
  66. package/package.json +61 -0
  67. package/portal.manifest.mjs +10 -0
  68. package/server/api/timesheets/admin/activities/[id]/deletion.get.ts +18 -0
  69. package/server/api/timesheets/admin/activities/[id].delete.ts +20 -0
  70. package/server/api/timesheets/admin/activities/[id].patch.ts +19 -0
  71. package/server/api/timesheets/admin/activities/index.get.ts +19 -0
  72. package/server/api/timesheets/admin/activities/index.post.ts +19 -0
  73. package/server/api/timesheets/admin/approvals/[id].post.ts +19 -0
  74. package/server/api/timesheets/admin/bootstrap.get.ts +73 -0
  75. package/server/api/timesheets/admin/client-settings/[clientId]/index.get.ts +25 -0
  76. package/server/api/timesheets/admin/client-settings/[clientId]/index.put.ts +28 -0
  77. package/server/api/timesheets/admin/clients/[id]/deletion.get.ts +18 -0
  78. package/server/api/timesheets/admin/clients/[id].delete.ts +20 -0
  79. package/server/api/timesheets/admin/clients/[id].patch.ts +14 -0
  80. package/server/api/timesheets/admin/clients/index.get.ts +18 -0
  81. package/server/api/timesheets/admin/clients/index.post.ts +25 -0
  82. package/server/api/timesheets/admin/internal-approvals/[userId].put.ts +11 -0
  83. package/server/api/timesheets/admin/internal-approvals/index.get.ts +8 -0
  84. package/server/api/timesheets/admin/internal-approvals/index.patch.ts +11 -0
  85. package/server/api/timesheets/admin/organization-capabilities/[organizationId].get.ts +19 -0
  86. package/server/api/timesheets/admin/organization-capabilities/[organizationId].patch.ts +23 -0
  87. package/server/api/timesheets/admin/projects/[id]/deletion.get.ts +18 -0
  88. package/server/api/timesheets/admin/projects/[id].delete.ts +20 -0
  89. package/server/api/timesheets/admin/projects/[id].get.ts +22 -0
  90. package/server/api/timesheets/admin/projects/[id].patch.ts +19 -0
  91. package/server/api/timesheets/admin/projects/index.get.ts +18 -0
  92. package/server/api/timesheets/admin/projects/index.post.ts +19 -0
  93. package/server/api/timesheets/admin/report.get.ts +26 -0
  94. package/server/api/timesheets/admin/settings.patch.ts +19 -0
  95. package/server/api/timesheets/admin/tariffs/index.put.ts +20 -0
  96. package/server/api/timesheets/admin/team/[userId].put.ts +24 -0
  97. package/server/api/timesheets/bootstrap.get.ts +24 -0
  98. package/server/api/timesheets/capabilities.get.ts +58 -0
  99. package/server/api/timesheets/client/[workspaceClientId]/index.get.ts +12 -0
  100. package/server/api/timesheets/client/[workspaceClientId]/reviewers.get.ts +10 -0
  101. package/server/api/timesheets/client/[workspaceClientId]/reviewers.put.ts +18 -0
  102. package/server/api/timesheets/client/[workspaceClientId]/reviews/[submissionId].post.ts +18 -0
  103. package/server/api/timesheets/client/approval-members.get.ts +11 -0
  104. package/server/api/timesheets/client/approval-suppliers.get.ts +8 -0
  105. package/server/api/timesheets/client/approvals.get.ts +14 -0
  106. package/server/api/timesheets/client/reviewer-suppliers.get.ts +10 -0
  107. package/server/api/timesheets/client/supplier-options.get.ts +14 -0
  108. package/server/api/timesheets/client/supplier-timesheets.get.ts +17 -0
  109. package/server/api/timesheets/client/workspaces.get.ts +11 -0
  110. package/server/api/timesheets/client-approvals.get.ts +18 -0
  111. package/server/api/timesheets/dashboard.get.ts +191 -0
  112. package/server/api/timesheets/entries/[id].delete.ts +18 -0
  113. package/server/api/timesheets/entries/[id].patch.ts +23 -0
  114. package/server/api/timesheets/entries/index.post.ts +22 -0
  115. package/server/api/timesheets/internal-approvals/[id].post.ts +9 -0
  116. package/server/api/timesheets/internal-approvals/index.get.ts +37 -0
  117. package/server/api/timesheets/internal-approvals/list.get.ts +12 -0
  118. package/server/api/timesheets/setup-status.get.ts +21 -0
  119. package/server/api/timesheets/submissions/[id]/client-reply.post.ts +20 -0
  120. package/server/api/timesheets/submissions/[id]/request-client-approval.post.ts +21 -0
  121. package/server/api/timesheets/submissions/[id]/resubmit.post.ts +10 -0
  122. package/server/api/timesheets/timer/index.delete.ts +18 -0
  123. package/server/api/timesheets/timer/index.post.ts +23 -0
  124. package/server/api/timesheets/weeks/[id]/submissions.post.ts +19 -0
  125. package/server/db/schema/timesheets.ts +383 -0
  126. package/server/plugins/client-sync.ts +30 -0
  127. package/server/plugins/openapi-contracts.ts +57 -0
  128. package/server/utils/timesheet-admin-listing.ts +281 -0
  129. package/server/utils/timesheet-email.ts +221 -0
  130. package/server/utils/timesheet-repository.ts +3096 -0
  131. package/server/utils/timesheet-validation.ts +231 -0
  132. package/shared/client-approval-dashboard.ts +17 -0
  133. package/shared/emails.ts +403 -0
  134. package/shared/feature.ts +184 -0
  135. package/shared/period-lock.ts +8 -0
  136. package/shared/submission-reply.ts +3 -0
  137. package/shared/timer-rounding.ts +2 -0
  138. package/shared/timesheet-dates.ts +23 -0
  139. package/shared/timesheet-errors.ts +13 -0
  140. package/shared/types/timesheet.ts +424 -0
  141. package/tokens.css +25 -0
@@ -0,0 +1,1458 @@
1
+ <script setup lang="ts">
2
+ import { formatTimesheetPeriod } from '@nuxt-customer-portal/timesheets/shared/timesheet-dates'
3
+ import { addDays, addWeeks, format, getISOWeek, parseISO } from 'date-fns'
4
+ import { z } from 'zod'
5
+ import { submissionReplySchema } from '@nuxt-customer-portal/timesheets/shared/submission-reply'
6
+ import { isTimesheetDateLocked } from '@nuxt-customer-portal/timesheets/shared/period-lock'
7
+ import type { TimeEntryDto, TimesheetSubmissionDto } from '@nuxt-customer-portal/timesheets/shared/types/timesheet'
8
+
9
+ defineOptions({ name: 'TimesheetsWorkbenchPage' })
10
+
11
+ definePageMeta({
12
+ middleware: async () => {
13
+ const capabilities = await $fetch<{
14
+ canEnterTime: boolean
15
+ canReviewClientTimesheets: boolean
16
+ canApproveInternalTimesheets: boolean
17
+ canViewSupplierTime: boolean
18
+ }>('/api/timesheets/capabilities')
19
+ if (capabilities.canEnterTime) {
20
+ return
21
+ }
22
+ return navigateTo(
23
+ capabilities.canReviewClientTimesheets
24
+ ? '/timesheets/approvals'
25
+ : capabilities.canApproveInternalTimesheets
26
+ ? '/timesheets/internal-approvals'
27
+ : capabilities.canViewSupplierTime
28
+ ? '/timesheets/suppliers'
29
+ : '/dashboard',
30
+ { replace: true }
31
+ )
32
+ }
33
+ })
34
+
35
+ const { t, locale } = useI18n()
36
+ const timesheets = useTimesheets()
37
+ const mutationError = useTimesheetMutationError()
38
+ const toast = useToast()
39
+ const { isOrganizationAdmin } = useTimesheetMenu()
40
+
41
+ useSeoMeta({
42
+ title: () => t('features.timesheets.title')
43
+ })
44
+
45
+ const route = useRoute()
46
+ const selectedWeek = ref<string | undefined>(
47
+ typeof route.query.week === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(route.query.week) ? route.query.week : undefined
48
+ )
49
+ const selectedDay = ref('')
50
+ const modalOpen = ref(false)
51
+ const cellEntriesOpen = ref(false)
52
+ const timerModalOpen = ref(false)
53
+ const submissionModalOpen = ref(false)
54
+ const submissionCutoff = ref('')
55
+ const submissionComment = ref('')
56
+ const submissionState = computed(() => ({ cutoffDate: submissionCutoff.value, comment: submissionComment.value }))
57
+ const submissionSchema = z.object({
58
+ cutoffDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
59
+ comment: z.string().trim().max(2000)
60
+ })
61
+ const saving = ref(false)
62
+ const now = ref(Date.now())
63
+ const currentDate = computed(() => format(new Date(now.value), 'yyyy-MM-dd'))
64
+ let timerInterval: number | undefined
65
+
66
+ const { data, pending, refresh } = await useAsyncData(
67
+ 'timesheet-week',
68
+ () => timesheets.bootstrap(selectedWeek.value),
69
+ { watch: [selectedWeek] }
70
+ )
71
+
72
+ const week = computed(() => data.value?.week)
73
+ const projects = computed(() => data.value?.projects.filter((project) => project.status === 'ACTIVE') ?? [])
74
+ const activities = computed(() => data.value?.activities.filter((activity) => activity.active) ?? [])
75
+ const rememberedEntryContext = ref<{ projectId: string; activityTypeId: string } | null>(null)
76
+ const lastReusableEntryContext = computed(() => {
77
+ if (rememberedEntryContext.value) {
78
+ return rememberedEntryContext.value
79
+ }
80
+
81
+ const entry = [...(week.value?.entries ?? [])].reverse().find((candidate) => {
82
+ const project = projects.value.find((item) => item.id === candidate.projectId)
83
+ return (
84
+ project?.activityTypeIds.includes(candidate.activityTypeId) &&
85
+ activities.value.some((item) => item.id === candidate.activityTypeId)
86
+ )
87
+ })
88
+
89
+ return entry ? { projectId: entry.projectId, activityTypeId: entry.activityTypeId } : null
90
+ })
91
+ const runningEntry = computed(() => data.value?.runningTimer ?? null)
92
+ const editable = computed(() => Boolean(data.value?.canEnterTime))
93
+ const dateLocked = (date: string) => isTimesheetDateLocked(date, week.value?.submissions ?? [])
94
+ const dateEditable = (date: string) => editable.value && !dateLocked(date)
95
+ const entryEditable = (entry: TimeEntryDto) =>
96
+ dateEditable(entry.entryDate) && (!entry.submissionId || ['DRAFT', 'REJECTED'].includes(entry.submissionStatus ?? ''))
97
+ const currentMember = computed(() => data.value?.team.find((member) => member.id === week.value?.userId))
98
+ const submissionNeedsApprover = computed(
99
+ () =>
100
+ Boolean(data.value?.settings.internalApprovalsEnabled) &&
101
+ Boolean(currentMember.value?.internalApprovalRequired) &&
102
+ !currentMember.value?.approverUserIds.length
103
+ )
104
+ const selectedActivity = computed(() => activities.value.find((activity) => activity.id === form.activityTypeId))
105
+ const selectedProject = computed(() => projects.value.find((project) => project.id === form.projectId))
106
+ const tariffMissing = computed(() =>
107
+ Boolean(
108
+ selectedActivity.value?.billable &&
109
+ currentMember.value?.defaultHourlyRateMinor === null &&
110
+ selectedProject.value?.personRates[week.value?.userId ?? ''] === undefined
111
+ )
112
+ )
113
+ const selectedProjectNeedsTariff = computed(() =>
114
+ Boolean(
115
+ selectedProject.value &&
116
+ currentMember.value?.defaultHourlyRateMinor === null &&
117
+ selectedProject.value.personRates[week.value?.userId ?? ''] === undefined &&
118
+ activities.value.some(
119
+ (activity) => activity.active && activity.billable && selectedProject.value?.activityTypeIds.includes(activity.id)
120
+ )
121
+ )
122
+ )
123
+ const workspaceStructureIncomplete = computed(() =>
124
+ data.value
125
+ ? !(
126
+ data.value.setupStatus.hasClient &&
127
+ data.value.setupStatus.hasActiveActivity &&
128
+ data.value.setupStatus.hasConfiguredProject
129
+ )
130
+ : false
131
+ )
132
+ const memberDefaultTariffMissing = computed(() =>
133
+ Boolean(
134
+ !isOrganizationAdmin.value &&
135
+ data.value?.canEnterTime &&
136
+ !workspaceStructureIncomplete.value &&
137
+ currentMember.value?.defaultHourlyRateMinor === null &&
138
+ data.value.setupStatus.billableWorkExists
139
+ )
140
+ )
141
+ const weekDays = computed(() => {
142
+ if (!week.value) {
143
+ return []
144
+ }
145
+ const weekdayFormatter = new Intl.DateTimeFormat(locale.value, { weekday: 'short' })
146
+ return Array.from({ length: 7 }, (_, index) => {
147
+ const date = addDays(parseISO(week.value!.weekStartsOn), index)
148
+ return { value: format(date, 'yyyy-MM-dd'), label: weekdayFormatter.format(date), day: format(date, 'd') }
149
+ })
150
+ })
151
+ const selectedDayEntries = computed(() =>
152
+ (week.value?.entries ?? [])
153
+ .filter((entry) => entry.entryDate === selectedDay.value)
154
+ .map((entry) => {
155
+ const project = projects.value.find((item) => item.id === entry.projectId)
156
+ const activity = activities.value.find((item) => item.id === entry.activityTypeId)
157
+ return {
158
+ ...entry,
159
+ clientName: project?.clientName ?? '',
160
+ projectName: project?.name ?? '',
161
+ activityName: activity?.name ?? ''
162
+ }
163
+ })
164
+ )
165
+ const selectedDayTotal = computed(() => totalForDate(selectedDay.value))
166
+
167
+ watch(
168
+ weekDays,
169
+ (days) => {
170
+ if (!days.length || days.some((day) => day.value === selectedDay.value)) {
171
+ return
172
+ }
173
+ selectedDay.value = days.find((day) => day.value === currentDate.value)?.value ?? days[0]!.value
174
+ },
175
+ { immediate: true }
176
+ )
177
+
178
+ const form = reactive({
179
+ id: null as string | null,
180
+ projectId: '',
181
+ activityTypeId: '',
182
+ entryDate: '',
183
+ hours: 0,
184
+ minutes: 0,
185
+ note: ''
186
+ })
187
+ const entrySchema = computed(() =>
188
+ z
189
+ .object({
190
+ id: z.string().nullable(),
191
+ projectId: z.string().min(1, t('features.timesheets.validation.required')),
192
+ activityTypeId: z.string().min(1, t('features.timesheets.validation.required')),
193
+ entryDate: z
194
+ .string()
195
+ .regex(/^\d{4}-\d{2}-\d{2}$/, t('features.timesheets.validation.validDate'))
196
+ .refine(dateEditable, t('features.timesheets.validation.periodLocked')),
197
+ hours: z.number().int().min(0).max(24, t('features.timesheets.validation.hoursRange')),
198
+ minutes: z.number().int().min(0).max(59, t('features.timesheets.validation.minutesRange')),
199
+ note: z.string().trim().max(2000)
200
+ })
201
+ .refine((value) => value.hours * 60 + value.minutes > 0, {
202
+ message: t('features.timesheets.validation.positiveDuration'),
203
+ path: ['hours']
204
+ })
205
+ .refine((value) => value.hours * 60 + value.minutes <= 24 * 60, {
206
+ message: t('features.timesheets.validation.maximumDuration'),
207
+ path: ['hours']
208
+ })
209
+ )
210
+ const timerSchema = computed(() =>
211
+ z.object({
212
+ id: z.string().nullable(),
213
+ projectId: z.string().min(1, t('features.timesheets.validation.required')),
214
+ activityTypeId: z.string().min(1, t('features.timesheets.validation.required')),
215
+ entryDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, t('features.timesheets.validation.validDate')),
216
+ note: z.string().trim().max(2000)
217
+ })
218
+ )
219
+
220
+ const activityOptions = computed(() => {
221
+ const selected = projects.value.find((project) => project.id === form.projectId)
222
+ return activities.value
223
+ .filter((activity) => selected?.activityTypeIds.includes(activity.id))
224
+ .map((activity) => {
225
+ const lacksTariff =
226
+ activity.billable &&
227
+ currentMember.value?.defaultHourlyRateMinor === null &&
228
+ selected?.personRates[week.value?.userId ?? ''] === undefined
229
+ return {
230
+ label: `${activity.name}${activity.billable ? (lacksTariff ? ` · ${t('features.timesheets.tariffMissingShort')}` : '') : ` · ${t('features.timesheets.nonBillable')}`}`,
231
+ value: activity.id,
232
+ disabled: lacksTariff
233
+ }
234
+ })
235
+ })
236
+
237
+ const projectOptions = computed(() =>
238
+ projects.value.map((project) => ({
239
+ label: `${project.clientName} · ${project.name}`,
240
+ value: project.id
241
+ }))
242
+ )
243
+
244
+ const totalForDate = (date: string) =>
245
+ week.value?.entries
246
+ .filter((entry) => entry.entryDate === date)
247
+ .reduce((sum, entry) => sum + entry.durationMinutes, 0) ?? 0
248
+
249
+ const totalMinutes = computed(() => week.value?.entries.reduce((sum, entry) => sum + entry.durationMinutes, 0) ?? 0)
250
+ const maximumSubmissionDate = computed(() => {
251
+ if (!week.value) {
252
+ return currentDate.value
253
+ }
254
+ const weekEnd = format(addDays(parseISO(week.value.weekStartsOn), 6), 'yyyy-MM-dd')
255
+ return weekEnd < currentDate.value ? weekEnd : currentDate.value
256
+ })
257
+ const coversWorkweek = (submission: TimesheetSubmissionDto) => {
258
+ if (!week.value) {
259
+ return false
260
+ }
261
+ const monday = week.value.weekStartsOn
262
+ const friday = format(addDays(parseISO(monday), 4), 'yyyy-MM-dd')
263
+ return submission.periodStartsOn <= monday && submission.periodEndsOn >= friday
264
+ }
265
+ const formatSubmissionPeriod = (from: string, to: string) => formatTimesheetPeriod(from, to, locale.value)
266
+ const formatWeekPeriod = (startsOn: string) =>
267
+ new Intl.DateTimeFormat(locale.value, { day: 'numeric', month: 'short', year: 'numeric' }).formatRange(
268
+ parseISO(startsOn),
269
+ addDays(parseISO(startsOn), 6)
270
+ )
271
+ const hasOtherUnsubmittedHours = (submission: TimesheetSubmissionDto) =>
272
+ (week.value?.entries ?? []).some(
273
+ (entry) =>
274
+ !entry.submissionId &&
275
+ entry.durationMinutes > 0 &&
276
+ (entry.entryDate < submission.periodStartsOn || entry.entryDate > submission.periodEndsOn)
277
+ )
278
+ const eligibleSubmissionEntries = computed(() =>
279
+ (week.value?.entries ?? []).filter(
280
+ (entry) => !entry.submissionId && entry.entryDate <= submissionCutoff.value && !entry.timerStartedAt
281
+ )
282
+ )
283
+ const submissionMinutes = computed(() =>
284
+ eligibleSubmissionEntries.value.reduce((sum, entry) => sum + entry.durationMinutes, 0)
285
+ )
286
+ const submissionEntryCount = computed(() =>
287
+ t('features.timesheets.submissions.entryCount', eligibleSubmissionEntries.value.length)
288
+ )
289
+ const submissionRange = computed(() => {
290
+ const dates = eligibleSubmissionEntries.value.map((entry) => entry.entryDate).sort()
291
+ return dates.length ? formatSubmissionPeriod(dates[0]!, submissionCutoff.value) : '—'
292
+ })
293
+ const openSubmission = () => {
294
+ submissionComment.value = ''
295
+ const eligible = (week.value?.entries ?? []).filter(
296
+ (entry) => !entry.submissionId && entry.entryDate <= maximumSubmissionDate.value
297
+ )
298
+ submissionCutoff.value =
299
+ eligible
300
+ .map((entry) => entry.entryDate)
301
+ .sort()
302
+ .at(-1) ?? maximumSubmissionDate.value
303
+ submissionModalOpen.value = true
304
+ }
305
+
306
+ const groupedRows = computed(() => {
307
+ const groups = new Map<
308
+ string,
309
+ {
310
+ projectId: string
311
+ activityTypeId: string
312
+ clientName: string
313
+ projectName: string
314
+ activityName: string
315
+ label: string
316
+ entries: TimeEntryDto[]
317
+ }
318
+ >()
319
+ for (const entry of week.value?.entries ?? []) {
320
+ const key = `${entry.projectId}:${entry.activityTypeId}`
321
+ const selectedProject = projects.value.find((project) => project.id === entry.projectId)
322
+ const activity = activities.value.find((item) => item.id === entry.activityTypeId)
323
+ if (!groups.has(key)) {
324
+ groups.set(key, {
325
+ projectId: entry.projectId,
326
+ activityTypeId: entry.activityTypeId,
327
+ clientName: selectedProject?.clientName ?? '',
328
+ projectName: selectedProject?.name ?? '',
329
+ activityName: activity?.name ?? '',
330
+ label: `${selectedProject?.clientName ?? ''} · ${selectedProject?.name ?? ''} / ${activity?.name ?? ''}`,
331
+ entries: []
332
+ })
333
+ }
334
+ groups.get(key)!.entries.push(entry)
335
+ }
336
+ return [...groups.values()]
337
+ })
338
+ const selectedCell = ref<{
339
+ row?: (typeof groupedRows.value)[number]
340
+ date: string
341
+ entries: TimeEntryDto[]
342
+ } | null>(null)
343
+
344
+ const formatDuration = (minutes: number) => {
345
+ if (!minutes) {
346
+ return '—'
347
+ }
348
+ return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`
349
+ }
350
+
351
+ const hoursInput = ref<{ inputRef: HTMLInputElement | null } | null>(null)
352
+ const focusPrefilledHours = () => {
353
+ if (form.id || !form.projectId || !form.activityTypeId) {
354
+ return
355
+ }
356
+ const input = hoursInput.value?.inputRef
357
+ input?.focus()
358
+ input?.select()
359
+ }
360
+
361
+ const openCreate = (date = format(new Date(), 'yyyy-MM-dd'), projectId?: string, activityTypeId?: string) => {
362
+ if (!dateEditable(date)) {
363
+ return
364
+ }
365
+ const remembered = lastReusableEntryContext.value
366
+ Object.assign(form, {
367
+ id: null,
368
+ projectId: projectId ?? remembered?.projectId ?? projects.value[0]?.id ?? '',
369
+ activityTypeId: activityTypeId ?? remembered?.activityTypeId ?? '',
370
+ entryDate: date,
371
+ hours: 0,
372
+ minutes: 0,
373
+ note: ''
374
+ })
375
+ modalOpen.value = true
376
+ }
377
+
378
+ const openEdit = (entry: TimeEntryDto) => {
379
+ if (!entryEditable(entry)) {
380
+ return
381
+ }
382
+ Object.assign(form, {
383
+ id: entry.id,
384
+ projectId: entry.projectId,
385
+ activityTypeId: entry.activityTypeId,
386
+ entryDate: entry.entryDate,
387
+ hours: Math.floor(entry.durationMinutes / 60),
388
+ minutes: entry.durationMinutes % 60,
389
+ note: entry.note ?? ''
390
+ })
391
+ cellEntriesOpen.value = false
392
+ modalOpen.value = true
393
+ }
394
+
395
+ const detailFilter = ref<{ date?: string; projectId?: string } | null>(null)
396
+ const detailPanel = ref<HTMLElement | null>(null)
397
+ const detailEntries = computed(() =>
398
+ (week.value?.entries ?? [])
399
+ .filter(
400
+ (entry) =>
401
+ detailFilter.value &&
402
+ (!detailFilter.value.date || entry.entryDate === detailFilter.value.date) &&
403
+ (!detailFilter.value.projectId || entry.projectId === detailFilter.value.projectId)
404
+ )
405
+ .map((entry) => ({
406
+ ...entry,
407
+ clientName: projects.value.find((project) => project.id === entry.projectId)?.clientName ?? '',
408
+ projectName: projects.value.find((project) => project.id === entry.projectId)?.name ?? '',
409
+ activityName: activities.value.find((activity) => activity.id === entry.activityTypeId)?.name ?? ''
410
+ }))
411
+ .sort((a, b) => a.entryDate.localeCompare(b.entryDate))
412
+ )
413
+ const showDetails = async (filter: { date?: string; projectId?: string }) => {
414
+ detailFilter.value = filter
415
+ await nextTick()
416
+ detailPanel.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
417
+ }
418
+ watch(
419
+ () => week.value?.id,
420
+ () => {
421
+ detailFilter.value = null
422
+ }
423
+ )
424
+ const openRowCell = (row: (typeof groupedRows.value)[number], date: string) => {
425
+ if (week.value?.entries.some((entry) => entry.projectId === row.projectId && entry.entryDate === date)) {
426
+ void showDetails({ projectId: row.projectId, date })
427
+ } else {
428
+ openCreate(date, row.projectId, row.activityTypeId)
429
+ }
430
+ }
431
+ const openDayCell = (date: string) => {
432
+ if ((week.value?.entries ?? []).some((entry) => entry.entryDate === date)) {
433
+ void showDetails({ date })
434
+ } else {
435
+ openCreate(date)
436
+ }
437
+ }
438
+
439
+ const addToSelectedCell = () => {
440
+ if (!selectedCell.value) {
441
+ return
442
+ }
443
+ const { row, date } = selectedCell.value
444
+ cellEntriesOpen.value = false
445
+ openCreate(date, row?.projectId, row?.activityTypeId)
446
+ }
447
+
448
+ const entryProject = (entry: TimeEntryDto) => projects.value.find((project) => project.id === entry.projectId)
449
+ const entryActivity = (entry: TimeEntryDto) => activities.value.find((activity) => activity.id === entry.activityTypeId)
450
+
451
+ const saveEntry = async () => {
452
+ saving.value = true
453
+ try {
454
+ const payload = {
455
+ projectId: form.projectId,
456
+ activityTypeId: form.activityTypeId,
457
+ entryDate: form.entryDate,
458
+ durationMinutes: Number(form.hours) * 60 + Number(form.minutes),
459
+ note: form.note || null
460
+ }
461
+ if (form.id) {
462
+ await timesheets.updateEntry(form.id, payload)
463
+ } else {
464
+ await timesheets.createEntry(payload)
465
+ }
466
+ rememberedEntryContext.value = {
467
+ projectId: form.projectId,
468
+ activityTypeId: form.activityTypeId
469
+ }
470
+ modalOpen.value = false
471
+ await refresh()
472
+ } catch (error) {
473
+ mutationError.show(error)
474
+ } finally {
475
+ saving.value = false
476
+ }
477
+ }
478
+
479
+ const removeEntry = async () => {
480
+ if (!form.id) {
481
+ return
482
+ }
483
+ saving.value = true
484
+ try {
485
+ await timesheets.deleteEntry(form.id)
486
+ modalOpen.value = false
487
+ await refresh()
488
+ } catch (error) {
489
+ mutationError.show(error)
490
+ } finally {
491
+ saving.value = false
492
+ }
493
+ }
494
+
495
+ const changeWeek = (amount: number) => {
496
+ if (pending.value) {
497
+ return
498
+ }
499
+ const anchor = week.value?.weekStartsOn ?? format(new Date(), 'yyyy-MM-dd')
500
+ selectedWeek.value = format(addWeeks(parseISO(anchor), amount), 'yyyy-MM-dd')
501
+ }
502
+
503
+ const startTimer = async () => {
504
+ saving.value = true
505
+ try {
506
+ await timesheets.startTimer({
507
+ projectId: form.projectId,
508
+ activityTypeId: form.activityTypeId,
509
+ entryDate: format(new Date(), 'yyyy-MM-dd'),
510
+ note: form.note || null
511
+ })
512
+ timerModalOpen.value = false
513
+ await refresh()
514
+ } catch (error) {
515
+ mutationError.show(error, 'features.timesheets.errors.timerStartTitle')
516
+ } finally {
517
+ saving.value = false
518
+ }
519
+ }
520
+
521
+ const openTimer = () => {
522
+ if (data.value?.canEnterTime && data.value.canStartTimer === false && !runningEntry.value) {
523
+ toast.add({
524
+ title: t('features.timesheets.errors.timerStartTitle'),
525
+ description: t('features.timesheets.timer.todayLocked'),
526
+ color: 'error'
527
+ })
528
+ return
529
+ }
530
+ Object.assign(form, {
531
+ id: null,
532
+ projectId: projects.value[0]?.id ?? '',
533
+ activityTypeId: '',
534
+ entryDate: format(new Date(), 'yyyy-MM-dd'),
535
+ hours: 0,
536
+ minutes: 0,
537
+ note: ''
538
+ })
539
+ timerModalOpen.value = true
540
+ }
541
+
542
+ const stopTimer = async () => {
543
+ try {
544
+ await timesheets.stopTimer()
545
+ await refresh()
546
+ } catch (error) {
547
+ mutationError.show(error)
548
+ }
549
+ }
550
+
551
+ const submit = async () => {
552
+ if (!week.value) {
553
+ return
554
+ }
555
+ try {
556
+ await timesheets.submitWeek(week.value.id, submissionCutoff.value, submissionComment.value)
557
+ submissionModalOpen.value = false
558
+ await refresh()
559
+ } catch (error) {
560
+ mutationError.show(error)
561
+ }
562
+ }
563
+
564
+ const replyOpen = ref(false)
565
+ const resubmitting = ref(false)
566
+ const replySubmissionId = ref('')
567
+ const replyClient = ref<{ id: string; version: number } | null>(null)
568
+ const replyState = reactive({ reply: '' })
569
+ const openReply = (id: string, client?: { id: string; version: number }) => {
570
+ replyClient.value = client ?? null
571
+ replySubmissionId.value = id
572
+ replyState.reply = ''
573
+ replyOpen.value = true
574
+ }
575
+ const resubmit = async () => {
576
+ if (resubmitting.value) {
577
+ return
578
+ }
579
+ resubmitting.value = true
580
+ try {
581
+ if (replyClient.value) {
582
+ await timesheets.replyClientSubmission(
583
+ replySubmissionId.value,
584
+ replyClient.value.id,
585
+ replyClient.value.version,
586
+ replyState.reply
587
+ )
588
+ } else {
589
+ await timesheets.resubmitSubmission(replySubmissionId.value, replyState.reply)
590
+ }
591
+ replyOpen.value = false
592
+ } catch (error) {
593
+ mutationError.show(error)
594
+ return
595
+ } finally {
596
+ resubmitting.value = false
597
+ }
598
+ await refresh()
599
+ }
600
+
601
+ onMounted(() => {
602
+ timerInterval = window.setInterval(() => {
603
+ now.value = Date.now()
604
+ }, 1000)
605
+ })
606
+
607
+ onBeforeUnmount(() => {
608
+ if (timerInterval !== undefined) {
609
+ window.clearInterval(timerInterval)
610
+ }
611
+ })
612
+
613
+ const runningDuration = computed(() => {
614
+ if (!runningEntry.value?.timerStartedAt) {
615
+ return ''
616
+ }
617
+ const seconds = Math.floor((now.value - new Date(runningEntry.value.timerStartedAt).getTime()) / 1000)
618
+ return `${String(Math.floor(seconds / 3600)).padStart(2, '0')}:${String(Math.floor(seconds / 60) % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
619
+ })
620
+ </script>
621
+
622
+ <template>
623
+ <TimesheetsPageShell
624
+ class="timesheet-workbench h-full min-h-0 space-y-5 overflow-y-auto"
625
+ :setup-status="data?.setupStatus"
626
+ :aria-busy="pending"
627
+ :inert="pending && !!data"
628
+ >
629
+ <header class="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
630
+ <div>
631
+ <h1 class="text-2xl font-semibold text-highlighted">
632
+ {{ t('features.timesheets.title') }}
633
+ </h1>
634
+ <p class="mt-1 hidden text-sm text-muted sm:block">
635
+ {{ t('features.timesheets.subtitle') }}
636
+ </p>
637
+ </div>
638
+ <div class="flex flex-wrap items-center gap-2">
639
+ <UButton
640
+ icon="i-lucide-chevron-left"
641
+ color="neutral"
642
+ variant="outline"
643
+ :aria-label="t('features.timesheets.previousWeek')"
644
+ @click="changeWeek(-1)"
645
+ />
646
+ <UBadge color="neutral" variant="subtle" size="lg" class="relative">
647
+ <span
648
+ v-if="pending && data"
649
+ class="absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-full"
650
+ role="status"
651
+ :aria-label="t('features.timesheets.loading')"
652
+ >
653
+ <span class="block h-full w-full animate-pulse bg-primary" />
654
+ </span>
655
+ {{
656
+ week
657
+ ? `${t('features.timesheets.weekNumber', { number: getISOWeek(parseISO(week.weekStartsOn)) })} · ${formatWeekPeriod(week.weekStartsOn)}`
658
+ : '—'
659
+ }}
660
+ </UBadge>
661
+ <UButton
662
+ icon="i-lucide-chevron-right"
663
+ color="neutral"
664
+ variant="outline"
665
+ :aria-label="t('features.timesheets.nextWeek')"
666
+ @click="changeWeek(1)"
667
+ />
668
+ <UTooltip v-if="!runningEntry" class="ml-auto sm:hidden" :text="t('features.timesheets.timer.start')">
669
+ <UButton
670
+ class="size-11 justify-center rounded-full p-0"
671
+ icon="i-lucide-timer"
672
+ color="success"
673
+ size="lg"
674
+ square
675
+ :aria-label="t('features.timesheets.timer.start')"
676
+ :disabled="!data?.canEnterTime || pending"
677
+ @click="openTimer"
678
+ />
679
+ </UTooltip>
680
+ <UButton
681
+ v-if="!runningEntry"
682
+ class="hidden sm:ml-auto sm:inline-flex"
683
+ icon="i-lucide-timer"
684
+ color="success"
685
+ :disabled="!data?.canEnterTime || pending"
686
+ @click="openTimer"
687
+ >
688
+ {{ t('features.timesheets.timer.start') }}
689
+ </UButton>
690
+ </div>
691
+ </header>
692
+
693
+ <UAlert
694
+ v-if="data && !data.canEnterTime"
695
+ color="warning"
696
+ icon="i-lucide-clock-alert"
697
+ :title="t('features.timesheets.errors.entryDisabledTitle')"
698
+ :description="t('features.timesheets.errors.entryDisabled')"
699
+ variant="outline"
700
+ />
701
+ <UAlert
702
+ v-else-if="memberDefaultTariffMissing"
703
+ color="warning"
704
+ icon="i-lucide-badge-alert"
705
+ :title="t('features.timesheets.errors.memberTariffMissingTitle')"
706
+ :description="t('features.timesheets.errors.memberTariffMissingDescription')"
707
+ variant="outline"
708
+ />
709
+
710
+ <UCard v-if="runningEntry" class="timesheet-timer">
711
+ <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
712
+ <div class="min-w-0">
713
+ <p class="text-xs font-medium text-muted">
714
+ {{ t('features.timesheets.timer.running') }}
715
+ </p>
716
+ <p class="truncate font-medium">
717
+ {{ data?.projects.find((item) => item.id === runningEntry?.projectId)?.name }} ·
718
+ {{ data?.activities.find((item) => item.id === runningEntry?.activityTypeId)?.name }}
719
+ </p>
720
+ </div>
721
+ <div class="flex items-center gap-3">
722
+ <span class="font-mono text-lg tabular-nums">{{ runningDuration }}</span>
723
+ <UButton icon="i-lucide-square" color="error" @click="stopTimer">
724
+ {{ t('features.timesheets.timer.stop') }}
725
+ </UButton>
726
+ </div>
727
+ </div>
728
+ </UCard>
729
+
730
+ <UCard class="relative hidden xl:block" :ui="{ body: '!p-0' }">
731
+ <div
732
+ v-if="pending && data"
733
+ class="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-default/60"
734
+ role="status"
735
+ :aria-label="t('features.timesheets.loading')"
736
+ >
737
+ <div class="flex items-center gap-2 rounded-full border border-default bg-default px-4 py-2 shadow-sm">
738
+ <UIcon name="i-lucide-loader-circle" class="size-5 animate-spin text-primary" aria-hidden="true" />
739
+ <span class="text-sm text-muted">{{ t('features.timesheets.loading') }}</span>
740
+ </div>
741
+ </div>
742
+
743
+ <div v-if="pending && !data" class="p-8 text-center text-muted">
744
+ {{ t('features.timesheets.loading') }}
745
+ </div>
746
+ <div v-else class="overflow-x-clip">
747
+ <div class="timesheet-grid text-sm">
748
+ <div class="timesheet-grid__cell font-medium text-muted">
749
+ {{ t('features.timesheets.projectActivity') }}
750
+ </div>
751
+ <button
752
+ v-for="(day, index) in weekDays"
753
+ :key="day.value"
754
+ type="button"
755
+ :disabled="!totalForDate(day.value)"
756
+ class="timesheet-grid__cell text-center"
757
+ :class="{
758
+ 'timesheet-grid__cell--today': day.value === currentDate,
759
+ 'timesheet-grid__cell--weekend': index > 4
760
+ }"
761
+ :aria-current="day.value === currentDate ? 'date' : undefined"
762
+ @click="showDetails({ date: day.value })"
763
+ >
764
+ <span class="block text-xs text-muted">{{ day.label }}</span>
765
+ <span class="inline-flex items-center justify-center gap-1 font-semibold">
766
+ {{ day.day }}
767
+ <UIcon
768
+ v-if="dateLocked(day.value)"
769
+ name="i-lucide-lock-keyhole"
770
+ class="size-3 shrink-0"
771
+ role="img"
772
+ :aria-label="t('features.timesheets.validation.periodLocked')"
773
+ :title="t('features.timesheets.validation.periodLocked')"
774
+ />
775
+ </span>
776
+ </button>
777
+ <div class="timesheet-grid__cell text-right font-medium">
778
+ {{ t('features.timesheets.total') }}
779
+ </div>
780
+
781
+ <template v-for="row in groupedRows" :key="`${row.projectId}:${row.activityTypeId}`">
782
+ <button
783
+ type="button"
784
+ class="timesheet-grid__cell timesheet-grid__identity text-left"
785
+ @click="showDetails({ projectId: row.projectId })"
786
+ >
787
+ <span class="timesheet-grid__client">{{ row.clientName }}</span>
788
+ <span class="timesheet-grid__project">{{ row.projectName }}</span>
789
+ <span class="timesheet-grid__activity">{{ row.activityName }}</span>
790
+ </button>
791
+ <div
792
+ v-for="(day, index) in weekDays"
793
+ :key="day.value"
794
+ class="timesheet-grid__cell group/cell relative !p-0 text-center"
795
+ :class="{
796
+ 'timesheet-grid__cell--weekend': index > 4,
797
+ 'timesheet-grid__cell--locked': !dateEditable(day.value)
798
+ }"
799
+ >
800
+ <button
801
+ type="button"
802
+ class="timesheet-row-action h-full w-full p-3 hover:bg-elevated"
803
+ :disabled="
804
+ !editable || (!dateEditable(day.value) && !row.entries.some((entry) => entry.entryDate === day.value))
805
+ "
806
+ :aria-label="`${t('features.timesheets.addEntry')}: ${row.label}, ${day.label} ${day.day}`"
807
+ @click="openRowCell(row, day.value)"
808
+ >
809
+ <span>{{
810
+ formatDuration(
811
+ row.entries
812
+ .filter((entry) => entry.entryDate === day.value)
813
+ .reduce((sum, entry) => sum + entry.durationMinutes, 0)
814
+ )
815
+ }}</span>
816
+ <UBadge
817
+ v-if="row.entries.filter((entry) => entry.entryDate === day.value).length > 1"
818
+ class="ml-1"
819
+ color="neutral"
820
+ variant="subtle"
821
+ size="sm"
822
+ >
823
+ {{ row.entries.filter((entry) => entry.entryDate === day.value).length }}
824
+ </UBadge>
825
+ </button>
826
+ <UButton
827
+ v-if="dateEditable(day.value)"
828
+ class="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/cell:opacity-100 group-has-[:focus-visible]/cell:opacity-100 focus-visible:opacity-100"
829
+ size="xs"
830
+ variant="soft"
831
+ icon="i-lucide-plus"
832
+ :aria-label="`${t('features.timesheets.addEntry')}: ${row.label}, ${day.label} ${day.day}`"
833
+ @click="openCreate(day.value, row.projectId, row.activityTypeId)"
834
+ />
835
+ </div>
836
+ <div class="timesheet-grid__cell text-right font-semibold">
837
+ {{ formatDuration(row.entries.reduce((sum, entry) => sum + entry.durationMinutes, 0)) }}
838
+ </div>
839
+ </template>
840
+
841
+ <div class="timesheet-grid__cell">
842
+ <UButton
843
+ v-if="dateEditable(currentDate)"
844
+ size="sm"
845
+ variant="ghost"
846
+ icon="i-lucide-plus"
847
+ @click="openCreate()"
848
+ >
849
+ {{ t('features.timesheets.addEntry') }}
850
+ </UButton>
851
+ </div>
852
+ <button
853
+ v-for="(day, index) in weekDays"
854
+ :key="`total-${day.value}`"
855
+ type="button"
856
+ class="timesheet-grid__cell timesheet-row-action text-center font-semibold hover:bg-elevated"
857
+ :class="{
858
+ 'timesheet-grid__cell--weekend': index > 4,
859
+ 'timesheet-grid__cell--locked': !dateEditable(day.value)
860
+ }"
861
+ :disabled="!editable || (!dateEditable(day.value) && !totalForDate(day.value))"
862
+ :aria-label="`${totalForDate(day.value) ? t('features.timesheets.cellEntries.title') : t('features.timesheets.addEntry')}: ${day.label} ${day.day}`"
863
+ @click="openDayCell(day.value)"
864
+ >
865
+ {{ formatDuration(totalForDate(day.value)) }}
866
+ </button>
867
+ <div class="timesheet-grid__cell text-right text-base font-semibold">
868
+ {{ formatDuration(totalMinutes) }}
869
+ </div>
870
+ </div>
871
+ </div>
872
+ </UCard>
873
+
874
+ <section v-if="detailFilter" ref="detailPanel" class="timesheet-mobile hidden xl:block">
875
+ <div class="flex items-center justify-between gap-3 p-4">
876
+ <h2 class="font-semibold">
877
+ {{ t('features.timesheets.cellEntries.title') }} ·
878
+ {{
879
+ detailFilter.date
880
+ ? formatSubmissionPeriod(detailFilter.date, detailFilter.date)
881
+ : formatWeekPeriod(week!.weekStartsOn)
882
+ }}<template v-if="detailFilter.projectId">
883
+ · {{ projects.find((project) => project.id === detailFilter?.projectId)?.name }}</template
884
+ >
885
+ </h2>
886
+ <UButton
887
+ icon="i-lucide-x"
888
+ color="neutral"
889
+ variant="ghost"
890
+ :aria-label="t('features.timesheets.cancel')"
891
+ @click="detailFilter = null"
892
+ />
893
+ </div>
894
+ <div class="timesheet-mobile__entries">
895
+ <component
896
+ :is="entryEditable(entry) ? 'button' : 'div'"
897
+ v-for="entry in detailEntries"
898
+ :key="entry.id"
899
+ :type="entryEditable(entry) ? 'button' : undefined"
900
+ class="timesheet-mobile__entry"
901
+ :class="{ 'timesheet-row-action': entryEditable(entry) }"
902
+ :aria-label="
903
+ entryEditable(entry)
904
+ ? `${t('features.timesheets.editEntry')}: ${entry.projectName}, ${entry.activityName}${entry.note ? `, ${entry.note}` : ''}`
905
+ : undefined
906
+ "
907
+ @click="openEdit(entry)"
908
+ >
909
+ <span class="timesheet-mobile__entry-copy">
910
+ <small>{{ formatSubmissionPeriod(entry.entryDate, entry.entryDate) }} · {{ entry.clientName }}</small>
911
+ <strong>{{ entry.projectName }}</strong>
912
+ <span class="timesheet-mobile__entry-activity">{{ entry.activityName }}</span>
913
+ <span v-if="entry.note" class="timesheet-mobile__entry-note">{{ entry.note }}</span>
914
+ </span>
915
+ <span class="timesheet-mobile__entry-meta">
916
+ <span class="flex items-center gap-2">
917
+ <UIcon
918
+ v-if="entryEditable(entry)"
919
+ name="i-lucide-pencil"
920
+ class="timesheet-mobile__edit-cue"
921
+ aria-hidden="true"
922
+ />
923
+ <span class="timesheet-mobile__duration">{{ formatDuration(entry.durationMinutes) }}</span>
924
+ </span>
925
+ <UBadge
926
+ v-if="entry.submissionStatus"
927
+ :color="
928
+ entry.submissionStatus === 'APPROVED'
929
+ ? 'success'
930
+ : entry.submissionStatus === 'SUBMITTED'
931
+ ? 'warning'
932
+ : entry.submissionStatus === 'REJECTED'
933
+ ? 'error'
934
+ : 'neutral'
935
+ "
936
+ :icon="dateLocked(entry.entryDate) ? 'i-lucide-lock-keyhole' : undefined"
937
+ variant="subtle"
938
+ size="sm"
939
+ >
940
+ {{ t(`features.timesheets.status.${entry.submissionStatus.toLowerCase()}`) }}
941
+ </UBadge>
942
+ </span>
943
+ </component>
944
+ </div>
945
+ </section>
946
+
947
+ <section class="timesheet-mobile relative xl:hidden" :aria-label="t('features.timesheets.projectActivity')">
948
+ <div
949
+ v-if="pending && data"
950
+ class="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-default/60"
951
+ role="status"
952
+ :aria-label="t('features.timesheets.loading')"
953
+ >
954
+ <div class="flex items-center gap-2 rounded-full border border-default bg-default px-4 py-2 shadow-sm">
955
+ <UIcon name="i-lucide-loader-circle" class="size-5 animate-spin text-primary" aria-hidden="true" />
956
+ <span class="text-sm text-muted">{{ t('features.timesheets.loading') }}</span>
957
+ </div>
958
+ </div>
959
+
960
+ <div v-if="pending && !data" class="timesheet-mobile__loading text-muted">
961
+ {{ t('features.timesheets.loading') }}
962
+ </div>
963
+ <template v-else>
964
+ <div class="timesheet-mobile__days" role="tablist" :aria-label="t('features.timesheets.projectActivity')">
965
+ <button
966
+ v-for="(day, index) in weekDays"
967
+ :key="day.value"
968
+ type="button"
969
+ role="tab"
970
+ class="timesheet-mobile__day"
971
+ :class="{
972
+ 'timesheet-mobile__day--active': selectedDay === day.value,
973
+ 'timesheet-mobile__day--weekend': index > 4
974
+ }"
975
+ :aria-selected="selectedDay === day.value"
976
+ @click="selectedDay = day.value"
977
+ >
978
+ <span>{{ day.label }}</span>
979
+ <strong class="inline-flex items-center justify-center gap-1">
980
+ {{ day.day }}
981
+ <UIcon
982
+ v-if="dateLocked(day.value)"
983
+ name="i-lucide-lock-keyhole"
984
+ class="size-3 shrink-0"
985
+ role="img"
986
+ :aria-label="t('features.timesheets.validation.periodLocked')"
987
+ :title="t('features.timesheets.validation.periodLocked')"
988
+ />
989
+ </strong>
990
+ <small>{{ formatDuration(totalForDate(day.value)) }}</small>
991
+ </button>
992
+ </div>
993
+
994
+ <div class="timesheet-mobile__summary">
995
+ <div>
996
+ <p class="timesheet-mobile__date">
997
+ {{ weekDays.find((day) => day.value === selectedDay)?.label }}
998
+ {{ weekDays.find((day) => day.value === selectedDay)?.day }}
999
+ </p>
1000
+ <p class="text-sm text-muted">
1001
+ {{ t('features.timesheets.mobile.entryCount', selectedDayEntries.length) }}
1002
+ </p>
1003
+ </div>
1004
+ <div class="timesheet-mobile__day-total">
1005
+ <span>{{ t('features.timesheets.mobile.dayTotal') }}</span>
1006
+ <strong class="timesheet-mobile__total">{{ formatDuration(selectedDayTotal) }}</strong>
1007
+ </div>
1008
+ </div>
1009
+
1010
+ <div v-if="selectedDayEntries.length" class="timesheet-mobile__entries">
1011
+ <component
1012
+ :is="entryEditable(entry) ? 'button' : 'div'"
1013
+ v-for="entry in selectedDayEntries"
1014
+ :key="entry.id"
1015
+ :type="entryEditable(entry) ? 'button' : undefined"
1016
+ class="timesheet-mobile__entry"
1017
+ :class="{ 'timesheet-row-action': entryEditable(entry) }"
1018
+ :aria-label="
1019
+ entryEditable(entry)
1020
+ ? `${t('features.timesheets.editEntry')}: ${entry.projectName}, ${entry.activityName}${entry.note ? `, ${entry.note}` : ''}`
1021
+ : undefined
1022
+ "
1023
+ @click="openEdit(entry)"
1024
+ >
1025
+ <span class="timesheet-mobile__entry-copy">
1026
+ <small>{{ entry.clientName }}</small>
1027
+ <strong>{{ entry.projectName }}</strong>
1028
+ <span class="timesheet-mobile__entry-activity">{{ entry.activityName }}</span>
1029
+ <span v-if="entry.note" class="timesheet-mobile__entry-note">{{ entry.note }}</span>
1030
+ </span>
1031
+ <span class="timesheet-mobile__entry-meta">
1032
+ <span class="flex items-center gap-2">
1033
+ <UIcon
1034
+ v-if="entryEditable(entry)"
1035
+ name="i-lucide-pencil"
1036
+ class="timesheet-mobile__edit-cue"
1037
+ aria-hidden="true"
1038
+ />
1039
+ <span class="timesheet-mobile__duration">{{ formatDuration(entry.durationMinutes) }}</span>
1040
+ </span>
1041
+ <UBadge
1042
+ v-if="entry.submissionStatus"
1043
+ :color="
1044
+ entry.submissionStatus === 'APPROVED'
1045
+ ? 'success'
1046
+ : entry.submissionStatus === 'SUBMITTED'
1047
+ ? 'warning'
1048
+ : entry.submissionStatus === 'REJECTED'
1049
+ ? 'error'
1050
+ : 'neutral'
1051
+ "
1052
+ :icon="dateLocked(entry.entryDate) ? 'i-lucide-lock-keyhole' : undefined"
1053
+ variant="subtle"
1054
+ size="sm"
1055
+ >
1056
+ {{ t(`features.timesheets.status.${entry.submissionStatus.toLowerCase()}`) }}
1057
+ </UBadge>
1058
+ </span>
1059
+ </component>
1060
+ </div>
1061
+ <div v-else class="timesheet-mobile__empty">
1062
+ <p>{{ t('features.timesheets.mobile.empty') }}</p>
1063
+ <span>{{ t('features.timesheets.mobile.emptyDescription') }}</span>
1064
+ </div>
1065
+
1066
+ <UButton
1067
+ v-if="dateEditable(selectedDay)"
1068
+ class="timesheet-mobile__add"
1069
+ color="primary"
1070
+ variant="outline"
1071
+ icon="i-lucide-plus"
1072
+ @click="openCreate(selectedDay)"
1073
+ >
1074
+ {{ t('features.timesheets.addEntry') }}
1075
+ </UButton>
1076
+ </template>
1077
+ </section>
1078
+
1079
+ <footer class="flex items-center gap-3">
1080
+ <div class="ml-auto flex items-center gap-3">
1081
+ <UButton
1082
+ v-if="editable"
1083
+ icon="i-lucide-send"
1084
+ size="sm"
1085
+ :disabled="
1086
+ !week?.entries.some((entry) => !entry.submissionId && entry.entryDate <= maximumSubmissionDate) ||
1087
+ !!runningEntry
1088
+ "
1089
+ @click="openSubmission"
1090
+ >
1091
+ {{ t('features.timesheets.submit') }}
1092
+ </UButton>
1093
+ <div class="text-right">
1094
+ <p class="text-xs font-medium text-muted">
1095
+ {{ t('features.timesheets.weekTotal') }}
1096
+ </p>
1097
+ <p class="text-xl font-semibold tabular-nums text-primary">
1098
+ {{ formatDuration(totalMinutes) }}
1099
+ </p>
1100
+ </div>
1101
+ </div>
1102
+ </footer>
1103
+
1104
+ <details
1105
+ v-for="submission in week?.submissions"
1106
+ :key="submission.id"
1107
+ :open="
1108
+ ['DRAFT', 'REJECTED'].includes(submission.status) ||
1109
+ (submission.status === 'APPROVED' && submission.clientThreads?.some((thread) => thread.status === 'DISPUTED'))
1110
+ "
1111
+ class="group rounded-lg border"
1112
+ :class="
1113
+ submission.status === 'APPROVED'
1114
+ ? 'border-success/40'
1115
+ : submission.status === 'REJECTED'
1116
+ ? 'border-error/40'
1117
+ : submission.status === 'SUBMITTED'
1118
+ ? 'border-warning/40'
1119
+ : 'border-default'
1120
+ "
1121
+ >
1122
+ <summary
1123
+ class="flex cursor-pointer list-none items-center justify-between gap-3 rounded-lg p-4 text-sm font-medium focus-visible:outline-2 focus-visible:outline-primary [&::-webkit-details-marker]:hidden"
1124
+ >
1125
+ <span
1126
+ :class="
1127
+ submission.status === 'APPROVED'
1128
+ ? 'text-success'
1129
+ : submission.status === 'REJECTED'
1130
+ ? 'text-error'
1131
+ : submission.status === 'SUBMITTED'
1132
+ ? 'text-warning'
1133
+ : 'text-default'
1134
+ "
1135
+ >
1136
+ {{
1137
+ t(
1138
+ coversWorkweek(submission)
1139
+ ? 'features.timesheets.submissions.fullStatus'
1140
+ : 'features.timesheets.submissions.partialStatus',
1141
+ {
1142
+ period: formatSubmissionPeriod(submission.periodStartsOn, submission.periodEndsOn),
1143
+ status: t(`features.timesheets.status.${submission.status.toLowerCase()}`)
1144
+ }
1145
+ )
1146
+ }}
1147
+ </span>
1148
+ <UIcon
1149
+ name="i-lucide-chevron-down"
1150
+ class="size-4 shrink-0 text-muted transition-transform group-open:rotate-180"
1151
+ />
1152
+ </summary>
1153
+ <div class="space-y-3 px-4 pb-4">
1154
+ <TimesheetsSubmissionTimeline :events="submission.history ?? []" />
1155
+ <div class="space-y-3">
1156
+ <section
1157
+ v-for="thread in submission.clientThreads ?? []"
1158
+ :key="thread.clientOrganizationId"
1159
+ class="rounded-lg border border-default p-3 text-default"
1160
+ >
1161
+ <p class="text-sm font-medium">
1162
+ {{ thread.clientName }} ·
1163
+ {{
1164
+ t(
1165
+ `features.timesheets.clientPortal.${thread.status === 'DISPUTED' ? 'disputed' : thread.status === 'APPROVED' ? 'approved' : 'pending'}`
1166
+ )
1167
+ }}
1168
+ </p>
1169
+ <UButton
1170
+ v-if="thread.status === 'DISPUTED' && submission.status === 'APPROVED'"
1171
+ variant="outline"
1172
+ @click="openReply(submission.id, { id: thread.clientOrganizationId, version: thread.version })"
1173
+ >{{ t('features.timesheets.submissions.resubmit') }}</UButton
1174
+ >
1175
+ </section>
1176
+ <div
1177
+ v-if="hasOtherUnsubmittedHours(submission)"
1178
+ class="flex items-start gap-2 border-t border-default pt-3 text-muted"
1179
+ >
1180
+ <UIcon name="i-lucide-info" class="mt-0.5 size-4 shrink-0" />
1181
+ <p>{{ t('features.timesheets.submissions.remainingWeekUnsubmitted') }}</p>
1182
+ </div>
1183
+ </div>
1184
+ <UButton
1185
+ v-if="['DRAFT', 'REJECTED'].includes(submission.status)"
1186
+ size="sm"
1187
+ color="error"
1188
+ variant="outline"
1189
+ icon="i-lucide-send"
1190
+ @click="openReply(submission.id)"
1191
+ >
1192
+ {{ t('features.timesheets.submissions.resubmit') }}
1193
+ </UButton>
1194
+ </div>
1195
+ </details>
1196
+
1197
+ <UModal v-model:open="replyOpen" :title="t('features.timesheets.submissions.resubmit')">
1198
+ <template #body>
1199
+ <UForm :schema="submissionReplySchema" :state="replyState" novalidate class="space-y-4" @submit="resubmit">
1200
+ <UFormField name="reply" :label="t('features.timesheets.submissions.reply')">
1201
+ <UTextarea v-model="replyState.reply" :rows="4" class="w-full" />
1202
+ </UFormField>
1203
+ <UButton type="submit" icon="i-lucide-send" :loading="resubmitting" :disabled="resubmitting">
1204
+ {{ t('features.timesheets.submissions.resubmit') }}
1205
+ </UButton>
1206
+ </UForm>
1207
+ </template>
1208
+ </UModal>
1209
+
1210
+ <UModal
1211
+ v-model:open="modalOpen"
1212
+ :title="form.id ? t('features.timesheets.editEntry') : t('features.timesheets.addEntry')"
1213
+ @after:enter="focusPrefilledHours"
1214
+ >
1215
+ <template #body>
1216
+ <UAlert
1217
+ v-if="!isOrganizationAdmin && workspaceStructureIncomplete"
1218
+ color="warning"
1219
+ icon="i-lucide-hourglass"
1220
+ :title="t('features.timesheets.setup.waitingTitle')"
1221
+ :description="t('features.timesheets.setup.waitingDescription')"
1222
+ variant="outline"
1223
+ />
1224
+ <UForm v-else novalidate :state="form" :schema="entrySchema" class="space-y-4" @submit="saveEntry">
1225
+ <UFormField name="projectId" :label="t('features.timesheets.fields.project')" required>
1226
+ <USelect
1227
+ v-model="form.projectId"
1228
+ :items="projectOptions"
1229
+ value-key="value"
1230
+ class="w-full"
1231
+ @update:model-value="form.activityTypeId = ''"
1232
+ />
1233
+ </UFormField>
1234
+ <UFormField name="activityTypeId" :label="t('features.timesheets.fields.activity')" required>
1235
+ <USelect v-model="form.activityTypeId" :items="activityOptions" value-key="value" class="w-full" />
1236
+ </UFormField>
1237
+ <UAlert
1238
+ v-if="selectedProjectNeedsTariff"
1239
+ color="warning"
1240
+ icon="i-lucide-badge-alert"
1241
+ :title="t('features.timesheets.errors.tariffRequiredTitle')"
1242
+ :description="
1243
+ t(
1244
+ isOrganizationAdmin
1245
+ ? 'features.timesheets.errors.tariffRequiredAdmin'
1246
+ : 'features.timesheets.errors.tariffRequiredMember'
1247
+ )
1248
+ "
1249
+ variant="outline"
1250
+ >
1251
+ <template v-if="isOrganizationAdmin" #actions>
1252
+ <UButton to="/admin/timesheets/rates" size="sm" color="warning">
1253
+ {{ t('features.timesheets.errors.configureRates') }}
1254
+ </UButton>
1255
+ </template>
1256
+ </UAlert>
1257
+ <UFormField name="entryDate" :label="t('features.timesheets.fields.date')" required>
1258
+ <UInput v-model="form.entryDate" type="date" class="w-full" />
1259
+ </UFormField>
1260
+ <div class="grid grid-cols-2 gap-3">
1261
+ <UFormField name="hours" :label="t('features.timesheets.fields.hours')" required>
1262
+ <UInput ref="hoursInput" v-model.number="form.hours" type="number" min="0" max="24" class="w-full" />
1263
+ </UFormField>
1264
+ <UFormField name="minutes" :label="t('features.timesheets.fields.minutes')" required>
1265
+ <UInput v-model.number="form.minutes" type="number" min="0" max="59" class="w-full" />
1266
+ </UFormField>
1267
+ </div>
1268
+ <UFormField name="note" :label="t('features.timesheets.fields.note')">
1269
+ <UTextarea v-model="form.note" :rows="3" class="w-full" />
1270
+ </UFormField>
1271
+ <div class="flex justify-between gap-3">
1272
+ <UButton
1273
+ v-if="form.id"
1274
+ type="button"
1275
+ color="error"
1276
+ variant="ghost"
1277
+ icon="i-lucide-trash-2"
1278
+ @click="removeEntry"
1279
+ >
1280
+ {{ t('features.timesheets.delete') }}
1281
+ </UButton>
1282
+ <div class="ml-auto flex gap-2">
1283
+ <UButton type="button" color="neutral" variant="outline" @click="modalOpen = false">
1284
+ {{ t('features.timesheets.cancel') }}
1285
+ </UButton>
1286
+ <UButton type="submit" :loading="saving" :disabled="tariffMissing">
1287
+ {{ t('features.timesheets.save') }}
1288
+ </UButton>
1289
+ </div>
1290
+ </div>
1291
+ </UForm>
1292
+ </template>
1293
+ </UModal>
1294
+
1295
+ <UModal v-model:open="cellEntriesOpen" :title="t('features.timesheets.cellEntries.title')">
1296
+ <template #body>
1297
+ <div v-if="selectedCell" class="space-y-4">
1298
+ <div>
1299
+ <p v-if="selectedCell.row" class="font-medium text-highlighted">
1300
+ {{ selectedCell.row.clientName }} · {{ selectedCell.row.projectName }}
1301
+ </p>
1302
+ <p class="text-sm text-muted">
1303
+ {{ selectedCell.row ? `${selectedCell.row.activityName} · ` : '' }}{{ selectedCell.date }}
1304
+ </p>
1305
+ </div>
1306
+ <div class="divide-y divide-default overflow-hidden rounded-lg border border-default">
1307
+ <button
1308
+ v-for="entry in selectedCell.entries"
1309
+ :key="entry.id"
1310
+ type="button"
1311
+ class="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-elevated"
1312
+ :disabled="!entryEditable(entry)"
1313
+ @click="openEdit(entry)"
1314
+ >
1315
+ <span class="min-w-0 flex-1">
1316
+ <strong v-if="!selectedCell.row" class="block truncate text-sm">
1317
+ {{ entryProject(entry)?.name }} · {{ entryActivity(entry)?.name }}
1318
+ </strong>
1319
+ <span class="block truncate text-sm text-muted">
1320
+ {{ entry.note || t('features.timesheets.cellEntries.noNote') }}
1321
+ </span>
1322
+ <UBadge v-if="entry.submissionStatus" class="mt-1" color="neutral" variant="subtle" size="sm">
1323
+ {{ t(`features.timesheets.status.${entry.submissionStatus.toLowerCase()}`) }}
1324
+ </UBadge>
1325
+ </span>
1326
+ <strong class="tabular-nums">{{ formatDuration(entry.durationMinutes) }}</strong>
1327
+ <UIcon v-if="entryEditable(entry)" name="i-lucide-pencil" class="size-4 text-muted" aria-hidden="true" />
1328
+ </button>
1329
+ </div>
1330
+ <div class="flex justify-end">
1331
+ <UButton
1332
+ v-if="selectedCell && dateEditable(selectedCell.date)"
1333
+ icon="i-lucide-plus"
1334
+ variant="soft"
1335
+ @click="addToSelectedCell"
1336
+ >
1337
+ {{ t('features.timesheets.cellEntries.addAnother') }}
1338
+ </UButton>
1339
+ </div>
1340
+ </div>
1341
+ </template>
1342
+ </UModal>
1343
+
1344
+ <UModal v-model:open="submissionModalOpen" :title="t('features.timesheets.submissions.title')">
1345
+ <template #body>
1346
+ <UForm :state="submissionState" :schema="submissionSchema" novalidate class="space-y-4" @submit="submit">
1347
+ <p class="text-sm text-muted">{{ t('features.timesheets.submissions.description') }}</p>
1348
+ <UFormField name="cutoffDate" :label="t('features.timesheets.submissions.cutoff')" required>
1349
+ <UInput
1350
+ v-model="submissionCutoff"
1351
+ type="date"
1352
+ :min="week?.weekStartsOn"
1353
+ :max="maximumSubmissionDate"
1354
+ class="w-full"
1355
+ />
1356
+ </UFormField>
1357
+ <UFormField name="comment" :label="t('features.timesheets.submissions.remark')">
1358
+ <UTextarea v-model="submissionComment" :rows="3" class="w-full" />
1359
+ </UFormField>
1360
+ <UAlert
1361
+ color="neutral"
1362
+ variant="subtle"
1363
+ :title="
1364
+ t('features.timesheets.submissions.summary', {
1365
+ entries: submissionEntryCount,
1366
+ hours: formatDuration(submissionMinutes),
1367
+ range: submissionRange
1368
+ })
1369
+ "
1370
+ />
1371
+ <UAlert
1372
+ v-if="submissionNeedsApprover"
1373
+ color="warning"
1374
+ icon="i-lucide-user-round-x"
1375
+ :title="t('features.timesheets.errors.internalApproverRequiredTitle')"
1376
+ :description="t('features.timesheets.errors.internalApproverRequired')"
1377
+ >
1378
+ <template v-if="isOrganizationAdmin" #actions>
1379
+ <UButton to="/admin/timesheets/internal-approvals" color="warning" variant="outline" size="sm">
1380
+ {{ t('features.timesheets.errors.configureApprovers') }}
1381
+ </UButton>
1382
+ </template>
1383
+ </UAlert>
1384
+ <div class="flex justify-end gap-2">
1385
+ <UButton type="button" color="neutral" variant="outline" @click="submissionModalOpen = false">
1386
+ {{ t('features.timesheets.cancel') }}
1387
+ </UButton>
1388
+ <UButton
1389
+ type="submit"
1390
+ icon="i-lucide-send"
1391
+ :disabled="!eligibleSubmissionEntries.length || submissionNeedsApprover"
1392
+ >
1393
+ {{ t('features.timesheets.submissions.confirm') }}
1394
+ </UButton>
1395
+ </div>
1396
+ </UForm>
1397
+ </template>
1398
+ </UModal>
1399
+
1400
+ <UModal v-model:open="timerModalOpen" :title="t('features.timesheets.timer.start')">
1401
+ <template #body>
1402
+ <UAlert
1403
+ v-if="!isOrganizationAdmin && workspaceStructureIncomplete"
1404
+ color="warning"
1405
+ icon="i-lucide-hourglass"
1406
+ :title="t('features.timesheets.setup.waitingTitle')"
1407
+ :description="t('features.timesheets.setup.waitingDescription')"
1408
+ variant="outline"
1409
+ />
1410
+ <UForm v-else :state="form" :schema="timerSchema" class="space-y-4" @submit="startTimer">
1411
+ <UFormField name="projectId" :label="t('features.timesheets.fields.project')" required>
1412
+ <USelect
1413
+ v-model="form.projectId"
1414
+ :items="projectOptions"
1415
+ value-key="value"
1416
+ class="w-full"
1417
+ @update:model-value="form.activityTypeId = ''"
1418
+ />
1419
+ </UFormField>
1420
+ <UFormField name="activityTypeId" :label="t('features.timesheets.fields.activity')" required>
1421
+ <USelect v-model="form.activityTypeId" :items="activityOptions" value-key="value" class="w-full" />
1422
+ </UFormField>
1423
+ <UAlert
1424
+ v-if="selectedProjectNeedsTariff"
1425
+ color="warning"
1426
+ icon="i-lucide-badge-alert"
1427
+ :title="t('features.timesheets.errors.tariffRequiredTitle')"
1428
+ :description="
1429
+ t(
1430
+ isOrganizationAdmin
1431
+ ? 'features.timesheets.errors.tariffRequiredAdmin'
1432
+ : 'features.timesheets.errors.tariffRequiredMember'
1433
+ )
1434
+ "
1435
+ variant="outline"
1436
+ >
1437
+ <template v-if="isOrganizationAdmin" #actions>
1438
+ <UButton to="/admin/timesheets/rates" size="sm" color="warning">
1439
+ {{ t('features.timesheets.errors.configureRates') }}
1440
+ </UButton>
1441
+ </template>
1442
+ </UAlert>
1443
+ <UFormField name="note" :label="t('features.timesheets.fields.note')">
1444
+ <UInput v-model="form.note" class="w-full" />
1445
+ </UFormField>
1446
+ <div class="flex justify-end gap-2">
1447
+ <UButton type="button" color="neutral" variant="outline" @click="timerModalOpen = false">
1448
+ {{ t('features.timesheets.cancel') }}
1449
+ </UButton>
1450
+ <UButton type="submit" color="success" icon="i-lucide-play" :loading="saving" :disabled="tariffMissing">
1451
+ {{ t('features.timesheets.timer.start') }}
1452
+ </UButton>
1453
+ </div>
1454
+ </UForm>
1455
+ </template>
1456
+ </UModal>
1457
+ </TimesheetsPageShell>
1458
+ </template>