@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,439 @@
1
+ <!-- Hallmark · shared client timesheet collection and detail browser -->
2
+ <script setup lang="ts">
3
+ import { z } from 'zod'
4
+ import { formatTimesheetPeriod } from '@nuxt-customer-portal/timesheets/shared/timesheet-dates'
5
+ import type { DeepReadonly } from 'vue'
6
+ import type {
7
+ ClientApprovalItemDto,
8
+ ClientReviewStatus,
9
+ ClientSupplierTimesheetItemDto
10
+ } from '@nuxt-customer-portal/timesheets/shared/types/timesheet'
11
+
12
+ const props = defineProps<{ mode: 'review' | 'view' }>()
13
+ type Item = ClientApprovalItemDto | ClientSupplierTimesheetItemDto
14
+ type ReadonlyItem = DeepReadonly<Item>
15
+ type ReadonlyApprovalItem = DeepReadonly<ClientApprovalItemDto>
16
+ type BadgeColor = 'success' | 'error' | 'warning' | 'neutral'
17
+ const { t, locale } = useI18n()
18
+ const api = useTimesheets()
19
+ const toast = useToast()
20
+ const route = useRoute()
21
+ const router = useRouter()
22
+ const disputeOpen = ref(false)
23
+ const disputeComment = ref('')
24
+ const reviewAction = ref<'APPROVE' | 'DISPUTE'>('DISPUTE')
25
+ const reviewState = computed(() => ({ comment: disputeComment.value }))
26
+ const reviewSchema = computed(() =>
27
+ z.object({
28
+ comment: reviewAction.value === 'DISPUTE' ? z.string().trim().min(1).max(2000) : z.string().trim().max(2000)
29
+ })
30
+ )
31
+ const openReview = (action: 'APPROVE' | 'DISPUTE') => {
32
+ reviewAction.value = action
33
+ disputeComment.value = ''
34
+ disputeOpen.value = true
35
+ }
36
+ const saving = ref(false)
37
+ const isReview = computed(() => props.mode === 'review')
38
+ const detailQueryKey = computed(() => (isReview.value ? 'review' : 'detail'))
39
+ const endpoint = computed(() =>
40
+ isReview.value ? '/api/timesheets/client/approvals' : '/api/timesheets/client/supplier-timesheets'
41
+ )
42
+ const filterKeys = computed(() => (isReview.value ? ['userId', 'status'] : ['workspaceClientId']))
43
+ const listing = useTimesheetsAdminList<Item>({
44
+ endpoint: endpoint.value,
45
+ filterKeys: filterKeys.value,
46
+ defaultSort: 'weekStartsOn',
47
+ defaultSortDir: 'desc'
48
+ })
49
+ const { data: filterOptions } = await useAsyncData(`timesheet-client-${props.mode}-filter-options`, () =>
50
+ isReview.value ? api.clientApprovalMembers() : api.clientSupplierOptions()
51
+ )
52
+ const selectedId = computed(() =>
53
+ typeof route.query[detailQueryKey.value] === 'string' ? String(route.query[detailQueryKey.value]) : ''
54
+ )
55
+ const selected = computed(() => listing.items.value.find((item) => item.id === selectedId.value) ?? null)
56
+ const entryGroups = computed(() => {
57
+ type Entry = ReadonlyItem['entries'][number]
58
+ const groups = new Map<
59
+ string,
60
+ { key: string; date: string; project: string; activity: string; minutes: number; entries: Entry[] }
61
+ >()
62
+ for (const entry of selected.value?.entries ?? []) {
63
+ const key = JSON.stringify([entry.date, entry.project, entry.activity, entry.person])
64
+ const group = groups.get(key) ?? {
65
+ key,
66
+ date: entry.date,
67
+ project: entry.project,
68
+ activity: entry.activity,
69
+ minutes: 0,
70
+ entries: []
71
+ }
72
+ group.minutes += entry.minutes
73
+ group.entries.push(entry)
74
+ groups.set(key, group)
75
+ }
76
+ return [...groups.values()]
77
+ })
78
+ const filters = computed(() => [
79
+ ...(isReview.value
80
+ ? [
81
+ {
82
+ key: 'status',
83
+ placeholder: t('features.timesheets.approvals.statusFilter'),
84
+ items: [
85
+ { label: t('features.timesheets.approvals.allStatuses'), value: undefined },
86
+ { label: t('features.timesheets.approvals.pending'), value: 'PENDING' },
87
+ { label: t('features.timesheets.clientPortal.approved'), value: 'APPROVED' },
88
+ { label: t('features.timesheets.clientPortal.disputed'), value: 'DISPUTED' }
89
+ ]
90
+ }
91
+ ]
92
+ : []),
93
+ {
94
+ key: isReview.value ? 'userId' : 'workspaceClientId',
95
+ placeholder: t(
96
+ isReview.value
97
+ ? 'features.timesheets.internalApprovals.memberFilter'
98
+ : 'features.timesheets.approvals.supplierFilter'
99
+ ),
100
+ items: [
101
+ {
102
+ label: t(
103
+ isReview.value
104
+ ? 'features.timesheets.internalApprovals.allMembers'
105
+ : 'features.timesheets.approvals.allSuppliers'
106
+ ),
107
+ value: undefined
108
+ },
109
+ ...(filterOptions.value ?? []).map((item) => ({ label: item.name, value: item.id }))
110
+ ]
111
+ }
112
+ ])
113
+ const sortOptions = computed(() => [
114
+ { label: t('features.timesheets.approvals.sortNewest'), value: 'weekStartsOn' },
115
+ ...(!isReview.value ? [{ label: t('features.timesheets.approvals.sortSupplier'), value: 'supplierName' }] : []),
116
+ { label: t('features.timesheets.approvals.sortPerson'), value: 'person' },
117
+ { label: t('features.timesheets.approvals.sortHours'), value: 'totalMinutes' },
118
+ ...(isReview.value ? [{ label: t('features.timesheets.approvals.sortStatus'), value: 'status' }] : [])
119
+ ])
120
+ const hours = (minutes: number) => `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`
121
+ const formatDate = (value: string) =>
122
+ new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium' }).format(new Date(`${value}T12:00:00`))
123
+ const formatDateTime = (value: string) =>
124
+ new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
125
+ const batchPeriod = (from: string, to: string) => formatTimesheetPeriod(from, to, locale.value)
126
+ const reviewStatusColor = (value: ClientReviewStatus): BadgeColor =>
127
+ value === 'APPROVED' ? 'success' : value === 'DISPUTED' ? 'error' : 'warning'
128
+ const isApprovalItem = (item: ReadonlyItem): item is ReadonlyApprovalItem => 'canAct' in item
129
+ const itemStatus = (item: ReadonlyItem): { label: string; color: BadgeColor } => ({
130
+ label: t(
131
+ item.status === 'PENDING'
132
+ ? 'features.timesheets.approvals.pending'
133
+ : `features.timesheets.clientPortal.${item.status.toLowerCase()}`
134
+ ),
135
+ color: reviewStatusColor(item.status)
136
+ })
137
+ const historyKey = (action: string) =>
138
+ ({
139
+ SUBMITTED: 'submitted',
140
+ APPROVED_INTERNAL: 'approvedInternal',
141
+ REOPENED: 'reopened',
142
+ APPROVED_CLIENT: 'approvedClient',
143
+ DISPUTED_CLIENT: 'disputedClient'
144
+ })[action] ?? 'submitted'
145
+ const openDetail = (item: ReadonlyItem) => router.push({ query: { ...route.query, [detailQueryKey.value]: item.id } })
146
+ const closeDetail = () => router.push({ query: { ...route.query, [detailQueryKey.value]: undefined } })
147
+ const act = async (item: ReadonlyApprovalItem, action: 'APPROVE' | 'DISPUTE') => {
148
+ saving.value = true
149
+ try {
150
+ await api.reviewClientSlice(item.workspaceClientId, item.submissionId, {
151
+ action,
152
+ expectedVersion: item.version,
153
+ comment: disputeComment.value
154
+ })
155
+ disputeOpen.value = false
156
+ disputeComment.value = ''
157
+ await listing.refresh()
158
+ toast.add({
159
+ title: t(
160
+ action === 'APPROVE'
161
+ ? 'features.timesheets.approvals.approvedMessage'
162
+ : 'features.timesheets.approvals.disputedMessage'
163
+ ),
164
+ color: 'success'
165
+ })
166
+ } catch {
167
+ await listing.refresh()
168
+ toast.add({
169
+ title: t('features.timesheets.approvals.conflictTitle'),
170
+ description: t('features.timesheets.approvals.conflictDescription'),
171
+ color: 'error'
172
+ })
173
+ } finally {
174
+ saving.value = false
175
+ }
176
+ }
177
+ await listing.load()
178
+ </script>
179
+
180
+ <template>
181
+ <TimesheetsPageShell class="flex h-full min-h-0 flex-col overflow-hidden">
182
+ <header class="shrink-0 border-b border-default pb-5">
183
+ <div class="flex items-center gap-2">
184
+ <UIcon :name="isReview ? 'i-lucide-stamp' : 'i-lucide-building-2'" class="size-6 shrink-0" />
185
+ <h1 class="text-2xl font-semibold text-highlighted">
186
+ {{ t(isReview ? 'features.timesheets.approvals.title' : 'features.timesheets.suppliers.title') }}
187
+ </h1>
188
+ </div>
189
+ <p class="mt-1 hidden text-sm text-muted sm:block">
190
+ {{ t(isReview ? 'features.timesheets.approvals.subtitle' : 'features.timesheets.suppliers.subtitle') }}
191
+ </p>
192
+ </header>
193
+ <div v-if="selected" class="min-h-0 flex-1 overflow-y-auto">
194
+ <div class="py-5">
195
+ <UButton icon="i-lucide-arrow-left" color="neutral" variant="ghost" @click="closeDetail">
196
+ {{ t(isReview ? 'features.timesheets.approvals.back' : 'features.timesheets.suppliers.back') }}
197
+ </UButton>
198
+ </div>
199
+ <article class="overflow-hidden rounded-lg border border-default bg-default">
200
+ <header class="flex flex-wrap items-start justify-between gap-4 border-b border-default p-5">
201
+ <div>
202
+ <p class="text-sm font-medium text-primary">{{ selected.supplierName }}</p>
203
+ <h2 class="mt-1 text-xl font-semibold">
204
+ {{ batchPeriod(selected.periodStartsOn, selected.periodEndsOn) }}
205
+ </h2>
206
+ <p class="mt-1 text-sm text-muted">
207
+ {{ selected.person }} ·
208
+ {{ t('features.timesheets.approvals.totalHours', { hours: hours(selected.totalMinutes) }) }}
209
+ </p>
210
+ </div>
211
+ <UBadge :color="itemStatus(selected).color" variant="subtle">{{ itemStatus(selected).label }}</UBadge>
212
+ </header>
213
+ <UAlert
214
+ v-if="
215
+ isReview &&
216
+ isApprovalItem(selected) &&
217
+ selected.status === 'PENDING' &&
218
+ !selected.canAct &&
219
+ selected.canManageReviewers &&
220
+ !selected.hasReviewers
221
+ "
222
+ color="warning"
223
+ icon="i-lucide-triangle-alert"
224
+ :title="t('features.timesheets.approvals.noReviewers')"
225
+ :description="t('features.timesheets.approvals.noReviewersDescription')"
226
+ class="m-5"
227
+ variant="outline"
228
+ >
229
+ <template #actions>
230
+ <UButton
231
+ :to="`/timesheets/approvals/reviewers?supplier=${selected.workspaceClientId}`"
232
+ size="sm"
233
+ color="warning"
234
+ >
235
+ {{ t('features.timesheets.approvals.assignReviewers') }}
236
+ </UButton>
237
+ </template>
238
+ </UAlert>
239
+ <div class="divide-y divide-default px-5">
240
+ <div
241
+ v-for="group in entryGroups"
242
+ :key="group.key"
243
+ class="grid gap-2 py-4 sm:grid-cols-[7rem_minmax(0,1fr)_5rem]"
244
+ >
245
+ <span class="text-sm text-muted">{{ formatDate(group.date) }}</span>
246
+ <p class="text-sm font-medium">{{ group.project }} · {{ group.activity }}</p>
247
+ <strong class="text-sm sm:text-right">{{ hours(group.minutes) }}</strong>
248
+ <ul
249
+ v-if="group.entries.length > 1 || group.entries.some((entry) => entry.note?.trim())"
250
+ class="space-y-2 border-l-2 border-default pl-3 sm:col-start-2 sm:col-span-2"
251
+ >
252
+ <li v-for="entry in group.entries" :key="entry.id" class="flex items-start justify-between gap-4 text-sm">
253
+ <p class="whitespace-pre-line break-words text-muted">{{ entry.note }}</p>
254
+ <span class="shrink-0 tabular-nums">{{ hours(entry.minutes) }}</span>
255
+ </li>
256
+ </ul>
257
+ </div>
258
+ </div>
259
+ <TimesheetsSubmissionTimeline v-if="isApprovalItem(selected)" class="p-5" :events="selected.timeline ?? []" />
260
+ <section v-if="!isReview && selected.history.length" class="border-t border-default p-5">
261
+ <h3 class="text-sm font-semibold">{{ t('features.timesheets.clientPortal.history') }}</h3>
262
+ <ol class="mt-4 space-y-4">
263
+ <li
264
+ v-for="item in [...selected.history].reverse()"
265
+ :key="item.id"
266
+ class="grid grid-cols-[1rem_minmax(0,1fr)] gap-3 text-sm"
267
+ >
268
+ <UIcon name="i-lucide-history" class="mt-0.5 size-4 text-muted" />
269
+ <div>
270
+ <p>
271
+ {{
272
+ t(`features.timesheets.clientPortal.historyActions.${historyKey(item.action)}`, {
273
+ actor: item.actorName
274
+ })
275
+ }}
276
+ </p>
277
+ <p class="text-xs text-muted">{{ formatDateTime(item.createdAt) }}</p>
278
+ <p v-if="!isReview && item.comment" class="mt-1 text-muted">{{ item.comment }}</p>
279
+ </div>
280
+ </li>
281
+ </ol>
282
+ </section>
283
+ <footer
284
+ v-if="isReview && isApprovalItem(selected) && selected.canAct"
285
+ class="flex justify-end gap-2 border-t border-default bg-elevated/40 p-5"
286
+ >
287
+ <UButton color="neutral" variant="outline" :disabled="saving" @click="openReview('DISPUTE')">
288
+ {{ t('features.timesheets.clientPortal.dispute') }} </UButton
289
+ ><UButton icon="i-lucide-check" :loading="saving" @click="openReview('APPROVE')">
290
+ {{ t('features.timesheets.clientPortal.approve') }}
291
+ </UButton>
292
+ </footer>
293
+ </article>
294
+ <UModal
295
+ v-if="isReview && selected && isApprovalItem(selected)"
296
+ v-model:open="disputeOpen"
297
+ :title="t(`features.timesheets.clientPortal.${reviewAction === 'APPROVE' ? 'approve' : 'dispute'}`)"
298
+ >
299
+ <template #body>
300
+ <UForm
301
+ :schema="reviewSchema"
302
+ :state="reviewState"
303
+ novalidate
304
+ class="space-y-4"
305
+ @submit="act(selected, reviewAction)"
306
+ >
307
+ <UFormField
308
+ name="comment"
309
+ :label="
310
+ t(
311
+ reviewAction === 'APPROVE'
312
+ ? 'features.timesheets.submissions.remark'
313
+ : 'features.timesheets.clientPortal.comment'
314
+ )
315
+ "
316
+ :required="reviewAction === 'DISPUTE'"
317
+ >
318
+ <UTextarea v-model="disputeComment" :rows="4" class="w-full" />
319
+ </UFormField>
320
+ <UButton type="submit" :loading="saving" :color="reviewAction === 'APPROVE' ? 'success' : 'error'">{{
321
+ t(`features.timesheets.clientPortal.${reviewAction === 'APPROVE' ? 'approve' : 'dispute'}`)
322
+ }}</UButton>
323
+ </UForm>
324
+ </template>
325
+ </UModal>
326
+ </div>
327
+ <section v-else class="flex min-h-0 flex-1 flex-col gap-5 pt-5">
328
+ <TimesheetsAdminListToolbar
329
+ v-model:search="listing.search.value"
330
+ class="shrink-0"
331
+ :filters="filters"
332
+ :filter-values="listing.filters"
333
+ :sort-options="sortOptions"
334
+ :sort-by="listing.sortBy.value"
335
+ :sort-dir="listing.sortDir.value"
336
+ @filter="listing.setFilter"
337
+ @sort="listing.sortBy.value = $event"
338
+ @toggle-direction="listing.toggleSortDir"
339
+ />
340
+ <TimesheetsAdminPaginatedList
341
+ class="min-h-0 flex-1"
342
+ :pagination="listing.pagination.value"
343
+ :pending="listing.pending.value"
344
+ :loading-next="listing.loadingNextPage.value"
345
+ :loading-previous="listing.loadingPreviousPage.value"
346
+ :has-next="listing.hasNextPage.value"
347
+ :has-previous="listing.hasPreviousPage.value"
348
+ @next="listing.loadNext"
349
+ @previous="listing.loadPrevious"
350
+ @page="listing.goToPage"
351
+ >
352
+ <UAlert v-if="listing.error.value" color="error" :title="t('features.timesheets.dashboard.error')" />
353
+ <div v-else-if="listing.pending.value && !listing.items.value.length" class="space-y-3">
354
+ <USkeleton v-for="index in 3" :key="index" class="h-24 w-full" />
355
+ </div>
356
+ <UAlert
357
+ v-else-if="!listing.items.value.length"
358
+ icon="i-lucide-search-x"
359
+ :title="
360
+ t(isReview ? 'features.timesheets.approvals.emptyFiltered' : 'features.timesheets.suppliers.emptyFiltered')
361
+ "
362
+ :description="
363
+ t(
364
+ isReview
365
+ ? 'features.timesheets.approvals.emptyFilteredDescription'
366
+ : 'features.timesheets.suppliers.emptyFilteredDescription'
367
+ )
368
+ "
369
+ variant="outline"
370
+ />
371
+ <div v-else class="grid gap-3">
372
+ <UCard
373
+ v-for="item in listing.items.value"
374
+ :key="item.id"
375
+ class="cursor-pointer transition-colors hover:ring-primary/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
376
+ role="button"
377
+ tabindex="0"
378
+ @click="openDetail(item)"
379
+ @keydown.enter="openDetail(item)"
380
+ @keydown.space.prevent="openDetail(item)"
381
+ >
382
+ <div
383
+ class="grid gap-4 sm:grid-cols-2 sm:items-center xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_max-content_auto]"
384
+ >
385
+ <div class="min-w-0">
386
+ <p class="flex items-center gap-2 font-medium">
387
+ <UIcon name="i-lucide-building-2" class="size-4 shrink-0 text-muted" /><span class="truncate">{{
388
+ item.supplierName
389
+ }}</span>
390
+ </p>
391
+ <p class="mt-1 flex items-center gap-2 text-sm text-muted">
392
+ <UIcon name="i-lucide-user-round" class="size-4 shrink-0" /><span class="truncate">{{
393
+ item.person
394
+ }}</span>
395
+ </p>
396
+ </div>
397
+ <div>
398
+ <p class="flex items-center gap-2 text-sm font-medium">
399
+ <UIcon name="i-lucide-calendar-days" class="size-4 shrink-0 text-muted" />{{
400
+ batchPeriod(item.periodStartsOn, item.periodEndsOn)
401
+ }}
402
+ </p>
403
+ <p class="mt-1 flex items-center gap-2 text-xs text-muted">
404
+ <UIcon name="i-lucide-clock-3" class="size-4 shrink-0" />{{
405
+ t('features.timesheets.approvals.totalHours', { hours: hours(item.totalMinutes) })
406
+ }}
407
+ </p>
408
+ </div>
409
+ <div>
410
+ <UBadge :color="itemStatus(item).color" variant="subtle" class="whitespace-nowrap">{{
411
+ itemStatus(item).label
412
+ }}</UBadge>
413
+ <p
414
+ v-if="isApprovalItem(item) && item.reviewerName"
415
+ class="mt-2 flex items-center gap-1 text-xs text-muted"
416
+ >
417
+ <UIcon name="i-lucide-user-check" class="size-3.5 shrink-0" />{{ item.reviewerName }}
418
+ </p>
419
+ </div>
420
+ <UButton
421
+ color="neutral"
422
+ variant="ghost"
423
+ trailing-icon="i-lucide-chevron-right"
424
+ tabindex="-1"
425
+ @click.stop="openDetail(item)"
426
+ >
427
+ {{
428
+ isReview && isApprovalItem(item) && item.canAct
429
+ ? t('features.timesheets.approvals.reviewTimesheet')
430
+ : t('features.timesheets.approvals.viewDetails')
431
+ }}
432
+ </UButton>
433
+ </div>
434
+ </UCard>
435
+ </div>
436
+ </TimesheetsAdminPaginatedList>
437
+ </section>
438
+ </TimesheetsPageShell>
439
+ </template>