@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,449 @@
1
+ <script setup lang="ts">
2
+ import { formatTimesheetPeriod } from '@nuxt-customer-portal/timesheets/shared/timesheet-dates'
3
+ import type { DeepReadonly } from 'vue'
4
+ import { z } from 'zod'
5
+ import type {
6
+ ApprovalQueueItemDto,
7
+ InternalApprovalQueueDto
8
+ } from '@nuxt-customer-portal/timesheets/shared/types/timesheet'
9
+
10
+ const props = defineProps<{
11
+ data: DeepReadonly<InternalApprovalQueueDto>
12
+ refresh: () => Promise<unknown>
13
+ clientMode?: boolean
14
+ }>()
15
+ const { t, locale } = useI18n()
16
+ const toast = useToast()
17
+ const timesheets = useTimesheets()
18
+ const busy = ref(false)
19
+ const rejectionOpen = ref(false)
20
+ const rejectionSubmissionId = ref('')
21
+ const rejectionComment = ref('')
22
+ const rejectionState = computed(() => ({ comment: rejectionComment.value }))
23
+ const rejectionSchema = computed(() =>
24
+ z.object({
25
+ comment: z.string().trim().min(1, t('features.timesheets.validation.rejectionReason')).max(2000)
26
+ })
27
+ )
28
+
29
+ const clientReplyOpen = ref(false)
30
+ const clientReplyTarget = ref<{ submissionId: string; clientOrganizationId: string; version: number } | null>(null)
31
+ const clientReplyState = reactive({ reply: '' })
32
+ const clientReplySchema = z.object({ reply: z.string().trim().min(1).max(5000) })
33
+ const openClientReply = (submissionId: string, review: DeepReadonly<ApprovalQueueItemDto['clientReviews'][number]>) => {
34
+ clientReplyTarget.value = { submissionId, clientOrganizationId: review.clientOrganizationId, version: review.version }
35
+ clientReplyState.reply = ''
36
+ clientReplyOpen.value = true
37
+ }
38
+ const sendClientReply = () =>
39
+ run(async () => {
40
+ const target = clientReplyTarget.value!
41
+ await timesheets.replyClientSubmission(
42
+ target.submissionId,
43
+ target.clientOrganizationId,
44
+ target.version,
45
+ clientReplyState.reply
46
+ )
47
+ clientReplyOpen.value = false
48
+ })
49
+
50
+ const approvalOpen = ref(false)
51
+ const approvalId = ref('')
52
+ const approvalState = reactive({ comment: '' })
53
+ const approvalSchema = z.object({ comment: z.string().trim().max(2000) })
54
+ const openApprove = (id: string) => {
55
+ approvalId.value = id
56
+ approvalState.comment = ''
57
+ approvalOpen.value = true
58
+ }
59
+ const approve = () =>
60
+ run(async () => {
61
+ await timesheets.reviewSubmission(approvalId.value, 'APPROVE', approvalState.comment)
62
+ approvalOpen.value = false
63
+ })
64
+
65
+ const formatHours = (minutes: number) => `${(minutes / 60).toFixed(2)} h`
66
+ const formatMoney = (minor: number) =>
67
+ new Intl.NumberFormat(locale.value, {
68
+ style: 'currency',
69
+ currency: props.data.settings.currency
70
+ }).format(minor / 100)
71
+ const formatEntryMoney = (minor: number, currency: string) =>
72
+ new Intl.NumberFormat(locale.value, {
73
+ style: 'currency',
74
+ currency
75
+ }).format(minor / 100)
76
+ const formatEntryDate = (date: string) =>
77
+ new Intl.DateTimeFormat(locale.value, {
78
+ weekday: 'short',
79
+ day: 'numeric',
80
+ month: 'short'
81
+ }).format(new Date(`${date}T12:00:00`))
82
+ const formatPeriod = (from: string, to: string) => formatTimesheetPeriod(from, to, locale.value)
83
+ const projectFor = (projectId: string) => props.data.projects.find((project) => project.id === projectId)
84
+ const activityFor = (activityTypeId: string) => props.data.activities.find((activity) => activity.id === activityTypeId)
85
+ const weekdayTotals = (item: DeepReadonly<ApprovalQueueItemDto>) => {
86
+ const weekStart = new Date(`${item.weekStartsOn}T12:00:00`)
87
+ return Array.from({ length: 7 }, (_, index) => {
88
+ const date = new Date(weekStart)
89
+ date.setDate(weekStart.getDate() + index)
90
+ const value = date.toISOString().slice(0, 10)
91
+ return {
92
+ value,
93
+ label: new Intl.DateTimeFormat(locale.value, { weekday: 'short' }).format(date),
94
+ minutes: item.entries
95
+ .filter((entry) => entry.entryDate === value)
96
+ .reduce((sum, entry) => sum + entry.durationMinutes, 0)
97
+ }
98
+ })
99
+ }
100
+
101
+ const run = async (operation: () => Promise<unknown>) => {
102
+ busy.value = true
103
+ try {
104
+ await operation()
105
+ await props.refresh()
106
+ } catch (error) {
107
+ toast.add({
108
+ title: t('features.timesheets.messages.saveError'),
109
+ description: String(error),
110
+ color: 'error'
111
+ })
112
+ } finally {
113
+ busy.value = false
114
+ }
115
+ }
116
+
117
+ const requestOpen = ref(false)
118
+ const requestTarget = ref<{ submissionId: string; clientOrganizationId: string } | null>(null)
119
+ const requestState = reactive({ message: '' })
120
+ const requestSchema = z.object({ message: z.string().trim().max(5000) })
121
+ const openRequestApproval = (submissionId: string, clientOrganizationId: string) => {
122
+ requestTarget.value = { submissionId, clientOrganizationId }
123
+ requestState.message = ''
124
+ requestOpen.value = true
125
+ }
126
+ const confirmRequestApproval = () => {
127
+ if (requestTarget.value) {
128
+ return requestApproval(requestTarget.value.submissionId, requestTarget.value.clientOrganizationId)
129
+ }
130
+ }
131
+ const requestApproval = async (submissionId: string, clientOrganizationId: string) => {
132
+ if (busy.value) {
133
+ return
134
+ }
135
+ busy.value = true
136
+ try {
137
+ await $fetch(`/api/timesheets/submissions/${submissionId}/request-client-approval`, {
138
+ method: 'POST',
139
+ body: { clientOrganizationId, message: requestState.message }
140
+ })
141
+ requestOpen.value = false
142
+ await props.refresh()
143
+ } catch (error) {
144
+ const response = error as { data?: { code?: string; data?: { code?: string } }; statusCode?: number }
145
+ const code = response.data?.data?.code ?? response.data?.code
146
+ const reasons: Record<string, string> = {
147
+ CLIENT_APPROVAL_NOT_READY: 'notReady',
148
+ CLIENT_APPROVAL_ACCESS_REQUIRED: 'accessRequired',
149
+ CLIENT_APPROVAL_EMPTY: 'empty',
150
+ CLIENT_APPROVAL_EXISTS: 'exists',
151
+ TIMESHEET_INVOICED: 'invoiced'
152
+ }
153
+ toast.add({
154
+ title: t('features.timesheets.clientSubmissionErrors.title'),
155
+ description: t(
156
+ `features.timesheets.clientSubmissionErrors.${reasons[code ?? ''] ?? (response.statusCode === 403 ? 'unauthorized' : 'generic')}`
157
+ ),
158
+ color: 'error'
159
+ })
160
+ } finally {
161
+ busy.value = false
162
+ }
163
+ }
164
+ const review = (id: string, action: 'APPROVE' | 'REOPEN') => run(() => timesheets.reviewSubmission(id, action))
165
+
166
+ const openReject = (id: string) => {
167
+ rejectionSubmissionId.value = id
168
+ rejectionComment.value = ''
169
+ rejectionOpen.value = true
170
+ }
171
+
172
+ const reject = () =>
173
+ run(async () => {
174
+ await timesheets.reviewSubmission(rejectionSubmissionId.value, 'REJECT', rejectionComment.value)
175
+ rejectionOpen.value = false
176
+ })
177
+ </script>
178
+
179
+ <template>
180
+ <section class="space-y-3">
181
+ <UCard v-if="!data.approvals.length">
182
+ <div class="py-8 text-center">
183
+ <UIcon name="i-lucide-circle-check-big" class="size-8 text-success" />
184
+ <p class="mt-2 font-medium">
185
+ {{ t('features.timesheets.admin.noApprovals') }}
186
+ </p>
187
+ </div>
188
+ </UCard>
189
+ <details
190
+ v-for="item in data.approvals"
191
+ :key="clientMode ? item.id + item.clientReviews[0]?.clientOrganizationId : item.id"
192
+ :open="item.status === 'SUBMITTED' || item.clientReviews.some((review) => review.status === 'DISPUTED')"
193
+ class="group rounded-lg border border-default bg-default"
194
+ >
195
+ <summary
196
+ class="relative cursor-pointer list-none rounded-lg p-4 pr-12 focus-visible:outline-2 focus-visible:outline-primary [&::-webkit-details-marker]:hidden"
197
+ >
198
+ <div class="flex flex-col gap-4 lg:flex-row lg:items-center">
199
+ <div class="min-w-0 flex-1">
200
+ <div class="flex flex-wrap items-center gap-2">
201
+ <p class="font-medium">
202
+ <template v-if="clientMode"
203
+ >{{
204
+ data.clients.find((client) => client.organizationId === item.clientReviews[0]?.clientOrganizationId)
205
+ ?.name
206
+ }}
207
+ · </template
208
+ >{{ item.userName }}
209
+ </p>
210
+ <UBadge
211
+ v-if="!clientMode"
212
+ :color="item.status === 'APPROVED' ? 'success' : item.status === 'REJECTED' ? 'error' : 'warning'"
213
+ variant="subtle"
214
+ >
215
+ {{ t(`features.timesheets.status.${item.status.toLowerCase()}`) }}
216
+ </UBadge>
217
+ </div>
218
+ <p class="mt-1 text-sm text-muted">
219
+ {{ formatPeriod(item.periodStartsOn, item.periodEndsOn) }} · {{ formatHours(item.totalMinutes) }} ·
220
+ {{ formatMoney(item.billableAmountMinor) }}
221
+ </p>
222
+ <div v-if="item.clientReviews.length" class="mt-2 flex flex-wrap gap-2">
223
+ <UBadge
224
+ v-for="clientReview in item.clientReviews"
225
+ :key="clientReview.clientOrganizationId"
226
+ :color="
227
+ clientReview.status === 'DISPUTED'
228
+ ? 'error'
229
+ : ['APPROVED', 'AUTO_APPROVED'].includes(clientReview.status)
230
+ ? 'success'
231
+ : 'warning'
232
+ "
233
+ variant="subtle"
234
+ >
235
+ <template v-if="!clientMode">
236
+ {{ data.clients.find((client) => client.organizationId === clientReview.clientOrganizationId)?.name }}
237
+ ·
238
+ </template>
239
+ {{ t(`features.timesheets.clientPortal.${clientReview.status.toLowerCase()}`) }}
240
+ </UBadge>
241
+ </div>
242
+ <ul class="mt-3 flex max-w-2xl overflow-hidden rounded-md border border-default">
243
+ <li
244
+ v-for="day in weekdayTotals(item)"
245
+ :key="day.value"
246
+ class="flex min-w-0 flex-1 items-baseline justify-center gap-1 border-r border-default px-2 py-2 last:border-r-0"
247
+ >
248
+ <span class="text-xs text-muted">{{ day.label }}</span>
249
+ <span class="text-sm font-semibold" :class="day.minutes ? 'text-highlighted' : 'text-dimmed'">
250
+ {{ formatHours(day.minutes) }}
251
+ </span>
252
+ </li>
253
+ </ul>
254
+ </div>
255
+ <div class="flex flex-wrap items-center gap-2">
256
+ <template v-if="!clientMode && item.status === 'SUBMITTED'">
257
+ <UButton color="error" variant="outline" icon="i-lucide-undo-2" @click.stop.prevent="openReject(item.id)">
258
+ {{ t('features.timesheets.admin.reject') }}
259
+ </UButton>
260
+ <UButton color="success" icon="i-lucide-check" :loading="busy" @click.stop.prevent="openApprove(item.id)">
261
+ {{ t('features.timesheets.admin.approve') }}
262
+ </UButton>
263
+ </template>
264
+ <UButton
265
+ v-else-if="!clientMode && item.status === 'APPROVED'"
266
+ color="neutral"
267
+ variant="outline"
268
+ icon="i-lucide-lock-open"
269
+ @click.stop.prevent="review(item.id, 'REOPEN')"
270
+ >
271
+ {{ t('features.timesheets.admin.reopen') }}
272
+ </UButton>
273
+ <UIcon
274
+ name="i-lucide-chevron-down"
275
+ class="absolute top-4 right-4 size-4 text-muted transition-transform group-open:rotate-180"
276
+ />
277
+ </div>
278
+ </div>
279
+ </summary>
280
+ <div class="px-4 pb-4">
281
+ <div class="divide-y divide-default border-t border-default">
282
+ <article
283
+ v-for="entry in item.entries"
284
+ :key="entry.id"
285
+ class="grid gap-3 py-4 sm:grid-cols-[7rem_minmax(0,1fr)_auto] sm:items-start"
286
+ >
287
+ <time :datetime="entry.entryDate" class="text-sm font-medium">
288
+ {{ formatEntryDate(entry.entryDate) }}
289
+ </time>
290
+ <div class="min-w-0">
291
+ <p class="font-medium text-highlighted">
292
+ {{ projectFor(entry.projectId)?.clientName }} · {{ projectFor(entry.projectId)?.name }}
293
+ </p>
294
+ <div class="mt-1 flex flex-wrap items-center gap-2 text-sm">
295
+ <span class="text-primary">{{ activityFor(entry.activityTypeId)?.name }}</span>
296
+ <UBadge :color="entry.billable ? 'success' : 'neutral'" variant="subtle" size="sm">
297
+ {{ t(entry.billable ? 'features.timesheets.billable' : 'features.timesheets.nonBillable') }}
298
+ </UBadge>
299
+ </div>
300
+ <p v-if="entry.note?.trim()" class="mt-2 whitespace-pre-line text-sm text-muted">
301
+ {{ entry.note }}
302
+ </p>
303
+ </div>
304
+ <div class="flex items-baseline gap-1.5 whitespace-nowrap text-sm sm:justify-end sm:text-right">
305
+ <span class="font-semibold text-highlighted">{{ formatHours(entry.durationMinutes) }}</span>
306
+ <span class="text-dimmed">×</span>
307
+ <span class="text-muted">{{ formatEntryMoney(entry.hourlyRateMinor, entry.currency) }}/h</span>
308
+ <span class="text-dimmed">=</span>
309
+ <span class="font-semibold text-highlighted">
310
+ {{ formatEntryMoney(Math.round((entry.durationMinutes * entry.hourlyRateMinor) / 60), entry.currency) }}
311
+ </span>
312
+ </div>
313
+ </article>
314
+ </div>
315
+ <TimesheetsSubmissionTimeline :events="item.history ?? []" />
316
+ <section
317
+ v-if="
318
+ item.clientReviews.length &&
319
+ (!clientMode ||
320
+ item.clientReviews.some(
321
+ (decision) =>
322
+ decision.status === 'AUTO_APPROVED' || (decision.canReply && decision.status === 'DISPUTED')
323
+ ))
324
+ "
325
+ :class="clientMode ? 'mt-4 flex justify-end gap-3' : 'mt-4 space-y-3 border-t border-default pt-4'"
326
+ >
327
+ <h3 v-if="!clientMode" class="font-semibold">{{ t('features.timesheets.admin.clientApprovals') }}</h3>
328
+ <div
329
+ v-for="clientDecision in item.clientReviews"
330
+ :key="clientDecision.clientOrganizationId"
331
+ :class="clientMode ? 'flex justify-end gap-3' : 'rounded-md border border-default p-3'"
332
+ >
333
+ <div v-if="!clientMode" class="flex flex-wrap items-center justify-between gap-3">
334
+ <span class="font-medium">{{
335
+ data.clients.find((client) => client.organizationId === clientDecision.clientOrganizationId)?.name
336
+ }}</span>
337
+ <UBadge
338
+ :color="
339
+ clientDecision.status === 'DISPUTED'
340
+ ? 'error'
341
+ : ['APPROVED', 'AUTO_APPROVED'].includes(clientDecision.status)
342
+ ? 'success'
343
+ : 'warning'
344
+ "
345
+ variant="subtle"
346
+ >
347
+ {{ t(`features.timesheets.clientPortal.${clientDecision.status.toLowerCase()}`) }}
348
+ </UBadge>
349
+ </div>
350
+ <p v-if="!clientMode && clientDecision.comment" class="mt-2 whitespace-pre-line text-sm text-muted">
351
+ {{ clientDecision.comment }}
352
+ </p>
353
+ <UButton
354
+ v-if="clientMode && clientDecision.status === 'AUTO_APPROVED'"
355
+ class="mt-3"
356
+ :loading="busy"
357
+ icon="i-lucide-send"
358
+ @click="openRequestApproval(item.id, clientDecision.clientOrganizationId)"
359
+ >
360
+ {{ t('features.timesheets.submissions.title') }}
361
+ </UButton>
362
+ <UButton
363
+ v-if="clientDecision.canReply && clientDecision.status === 'DISPUTED'"
364
+ class="mt-3"
365
+ icon="i-lucide-reply"
366
+ @click="openClientReply(item.id, clientDecision)"
367
+ >
368
+ {{ t('features.timesheets.submissions.resubmit') }}
369
+ </UButton>
370
+ </div>
371
+ </section>
372
+ </div>
373
+ </details>
374
+
375
+ <UModal v-model:open="requestOpen" :title="t('features.timesheets.submissions.title')">
376
+ <template #body>
377
+ <UForm
378
+ :schema="requestSchema"
379
+ :state="requestState"
380
+ novalidate
381
+ class="space-y-4"
382
+ @submit="confirmRequestApproval"
383
+ >
384
+ <UFormField name="message" :label="t('features.timesheets.submissions.remark')">
385
+ <UTextarea v-model="requestState.message" :rows="4" class="w-full" />
386
+ </UFormField>
387
+ <div class="flex justify-end gap-2">
388
+ <UButton color="neutral" variant="outline" :disabled="busy" @click="requestOpen = false">{{
389
+ t('features.timesheets.cancel')
390
+ }}</UButton>
391
+ <UButton type="submit" icon="i-lucide-send" :loading="busy">{{
392
+ t('features.timesheets.submissions.title')
393
+ }}</UButton>
394
+ </div>
395
+ </UForm>
396
+ </template>
397
+ </UModal>
398
+ <UModal v-model:open="clientReplyOpen" :title="t('features.timesheets.submissions.reply')">
399
+ <template #body>
400
+ <UForm
401
+ :schema="clientReplySchema"
402
+ :state="clientReplyState"
403
+ novalidate
404
+ class="space-y-4"
405
+ @submit="sendClientReply"
406
+ >
407
+ <UFormField name="reply" :label="t('features.timesheets.submissions.reply')">
408
+ <UTextarea v-model="clientReplyState.reply" class="w-full" :rows="4" />
409
+ </UFormField>
410
+ <UButton type="submit" :loading="busy">{{ t('features.timesheets.submissions.resubmit') }}</UButton>
411
+ </UForm>
412
+ </template>
413
+ </UModal>
414
+ <UModal v-model:open="approvalOpen" :title="t('features.timesheets.admin.approve')">
415
+ <template #body>
416
+ <UForm :state="approvalState" :schema="approvalSchema" novalidate class="space-y-4" @submit="approve">
417
+ <UFormField name="comment" :label="t('features.timesheets.submissions.remark')">
418
+ <UTextarea v-model="approvalState.comment" :rows="3" class="w-full" />
419
+ </UFormField>
420
+ <div class="flex justify-end gap-2">
421
+ <UButton color="neutral" variant="outline" @click="approvalOpen = false">{{
422
+ t('features.timesheets.cancel')
423
+ }}</UButton>
424
+ <UButton type="submit" color="success" :loading="busy">{{
425
+ t('features.timesheets.admin.approve')
426
+ }}</UButton>
427
+ </div>
428
+ </UForm>
429
+ </template>
430
+ </UModal>
431
+ <UModal v-model:open="rejectionOpen" :title="t('features.timesheets.admin.rejectTimesheet')">
432
+ <template #body>
433
+ <UForm novalidate :state="rejectionState" :schema="rejectionSchema" class="space-y-4" @submit="reject">
434
+ <UFormField name="comment" :label="t('features.timesheets.admin.rejectionReason')" required>
435
+ <UTextarea v-model="rejectionComment" :rows="4" class="w-full" />
436
+ </UFormField>
437
+ <div class="flex justify-end gap-2">
438
+ <UButton type="button" color="neutral" variant="outline" @click="rejectionOpen = false">
439
+ {{ t('features.timesheets.cancel') }}
440
+ </UButton>
441
+ <UButton type="submit" color="error" :loading="busy">
442
+ {{ t('features.timesheets.admin.reject') }}
443
+ </UButton>
444
+ </div>
445
+ </UForm>
446
+ </template>
447
+ </UModal>
448
+ </section>
449
+ </template>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ withDefaults(
3
+ defineProps<{
4
+ icon: string
5
+ title: string
6
+ description: string
7
+ actionLabel: string
8
+ actionIcon?: string
9
+ actionDisabled?: boolean
10
+ }>(),
11
+ {
12
+ actionIcon: 'i-lucide-plus',
13
+ actionDisabled: false
14
+ }
15
+ )
16
+
17
+ defineEmits<{ action: [] }>()
18
+ </script>
19
+
20
+ <template>
21
+ <UEmpty :icon="icon" :title="title" :description="description" variant="outline" size="lg" class="min-h-72">
22
+ <template #actions>
23
+ <UButton size="lg" :icon="actionIcon" :disabled="actionDisabled" @click="$emit('action')">
24
+ {{ actionLabel }}
25
+ </UButton>
26
+ </template>
27
+ </UEmpty>
28
+ </template>
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ defineOptions({ inheritAttrs: false })
3
+ const props = defineProps<{
4
+ filters: Array<{ key: string; placeholder: string; items: Array<{ label: string; value: string | undefined }> }>
5
+ filterValues: Record<string, string | undefined>
6
+ sortOptions: Array<{ label: string; value: string }>
7
+ sortBy: string
8
+ sortDir: 'asc' | 'desc'
9
+ }>()
10
+ const search = defineModel<string>('search', { required: true })
11
+ </script>
12
+
13
+ <template>
14
+ <PortalListToolbar
15
+ v-bind="{ ...props, ...$attrs }"
16
+ v-model:search="search"
17
+ :search-placeholder="$t('features.timesheets.admin.list.search')"
18
+ />
19
+ </template>
@@ -0,0 +1,95 @@
1
+ <script setup lang="ts">
2
+ import type { TimesheetsListPagination } from '@nuxt-customer-portal/timesheets/shared/types/timesheet'
3
+
4
+ const props = defineProps<{
5
+ pagination: TimesheetsListPagination
6
+ pending: boolean
7
+ loadingNext: boolean
8
+ loadingPrevious: boolean
9
+ hasNext: boolean
10
+ hasPrevious: boolean
11
+ initialScrollTop?: number
12
+ }>()
13
+ const emit = defineEmits<{ next: []; previous: []; page: [page: number]; scroll: [scrollTop: number] }>()
14
+ const scrollContainer = ref<HTMLElement | null>(null)
15
+ const previousSentinel = ref<HTMLElement | null>(null)
16
+ const nextSentinel = ref<HTMLElement | null>(null)
17
+ let previousScrollHeight = 0
18
+ const loadPrevious = () => {
19
+ previousScrollHeight = scrollContainer.value?.scrollHeight ?? 0
20
+ emit('previous')
21
+ }
22
+ watch(
23
+ () => props.loadingPrevious,
24
+ async (loading, wasLoading) => {
25
+ if (loading || !wasLoading || !scrollContainer.value) {
26
+ return
27
+ }
28
+ await nextTick()
29
+ scrollContainer.value.scrollTop += scrollContainer.value.scrollHeight - previousScrollHeight
30
+ }
31
+ )
32
+ useAutoPagination({
33
+ sentinel: previousSentinel,
34
+ scrollContainer,
35
+ canLoadMore: () => props.hasPrevious,
36
+ loading: () => props.pending,
37
+ loadMore: loadPrevious,
38
+ rootMargin: '240px 0px 0px 0px'
39
+ })
40
+ useAutoPagination({
41
+ sentinel: nextSentinel,
42
+ scrollContainer,
43
+ canLoadMore: () => props.hasNext,
44
+ loading: () => props.pending,
45
+ loadMore: () => emit('next')
46
+ })
47
+ onMounted(async () => {
48
+ if (!props.initialScrollTop) {
49
+ return
50
+ }
51
+ await nextTick()
52
+ if (scrollContainer.value) {
53
+ scrollContainer.value.scrollTop = props.initialScrollTop
54
+ }
55
+ })
56
+ </script>
57
+
58
+ <template>
59
+ <div class="flex h-full min-h-0 flex-col">
60
+ <div
61
+ ref="scrollContainer"
62
+ class="timesheets-list-scroll min-h-0 flex-1 overflow-y-auto overflow-x-hidden"
63
+ @scroll="emit('scroll', scrollContainer?.scrollTop ?? 0)"
64
+ >
65
+ <div v-if="hasPrevious" ref="previousSentinel" class="h-px" aria-hidden="true" />
66
+ <div v-if="loadingPrevious" class="space-y-2 pb-3">
67
+ <USkeleton v-for="index in 2" :key="index" class="h-24 w-full" />
68
+ </div>
69
+ <slot />
70
+ <div v-if="hasNext" ref="nextSentinel" class="h-px" aria-hidden="true" />
71
+ <div v-if="loadingNext" class="space-y-2 pt-3">
72
+ <USkeleton v-for="index in 2" :key="index" class="h-24 w-full" />
73
+ </div>
74
+ </div>
75
+ <footer
76
+ class="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-3 border-t border-default px-1 pt-3 text-sm text-muted"
77
+ >
78
+ <span>{{ $t('features.timesheets.admin.list.totalRecords', pagination.total) }}</span>
79
+ <UPagination
80
+ v-if="pagination.pageCount > 1"
81
+ :page="pagination.page"
82
+ :total="pagination.total"
83
+ :items-per-page="pagination.pageSize"
84
+ :disabled="pending"
85
+ @update:page="emit('page', $event)"
86
+ />
87
+ </footer>
88
+ </div>
89
+ </template>
90
+
91
+ <style scoped>
92
+ .timesheets-list-scroll {
93
+ padding: 2px 2px 16px;
94
+ }
95
+ </style>