@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,323 @@
1
+ <script setup lang="ts">
2
+ import type { ComponentPublicInstance } from 'vue'
3
+ import type { TimesheetsAdminBootstrap } from '@nuxt-customer-portal/timesheets/app/composables/useTimesheets'
4
+
5
+ const props = defineProps<{ data: TimesheetsAdminBootstrap; refresh: () => Promise<unknown> }>()
6
+ const { t } = useI18n()
7
+ const toast = useToast()
8
+ const timesheets = useTimesheets()
9
+ const busy = ref(false)
10
+ const formOpen = ref(false)
11
+ const editingId = ref('')
12
+ const deletionId = ref('')
13
+ const deletionName = ref('')
14
+ const deletionPending = ref(false)
15
+ const eligibility = ref<{ activityId: string; activityName: string; canDelete: boolean } | null>(null)
16
+ const activityFormRef = ref<ComponentPublicInstance | null>(null)
17
+ const form = reactive({ name: '', billable: true })
18
+ const listing = useTimesheetsAdminList<TimesheetsAdminBootstrap['activities'][number]>({
19
+ endpoint: '/api/timesheets/admin/activities',
20
+ filterKeys: ['active', 'billable'],
21
+ defaultSort: 'name'
22
+ })
23
+ const activityFilters = computed(() => [
24
+ {
25
+ key: 'active',
26
+ placeholder: t('features.timesheets.admin.list.statusFilter'),
27
+ items: [
28
+ { label: t('features.timesheets.admin.list.allStatuses'), value: undefined },
29
+ { label: t('features.timesheets.admin.active'), value: 'true' },
30
+ { label: t('features.timesheets.admin.inactive'), value: 'false' }
31
+ ]
32
+ },
33
+ {
34
+ key: 'billable',
35
+ placeholder: t('features.timesheets.admin.list.billingFilter'),
36
+ items: [
37
+ { label: t('features.timesheets.admin.list.allBilling'), value: undefined },
38
+ { label: t('features.timesheets.admin.list.billable'), value: 'true' },
39
+ { label: t('features.timesheets.admin.list.nonBillable'), value: 'false' }
40
+ ]
41
+ }
42
+ ])
43
+ const sortOptions = computed(() => [
44
+ { label: t('features.timesheets.admin.list.sortName'), value: 'name' },
45
+ { label: t('features.timesheets.admin.list.sortActive'), value: 'active' },
46
+ { label: t('features.timesheets.admin.list.sortBilling'), value: 'billable' }
47
+ ])
48
+
49
+ const run = async (operation: () => Promise<unknown>) => {
50
+ busy.value = true
51
+ try {
52
+ await operation()
53
+ await props.refresh()
54
+ await listing.refresh()
55
+ } catch (error) {
56
+ toast.add({ title: t('features.timesheets.messages.saveError'), description: String(error), color: 'error' })
57
+ } finally {
58
+ busy.value = false
59
+ }
60
+ }
61
+ const resetForm = () => {
62
+ Object.assign(form, { name: '', billable: true })
63
+ editingId.value = ''
64
+ formOpen.value = false
65
+ }
66
+ const resetDeletion = () => {
67
+ deletionId.value = ''
68
+ deletionName.value = ''
69
+ deletionPending.value = false
70
+ eligibility.value = null
71
+ }
72
+ const openNew = () => {
73
+ resetDeletion()
74
+ resetForm()
75
+ formOpen.value = true
76
+ }
77
+ const scrollToActivityForm = async () => {
78
+ if (!import.meta.client) {
79
+ return
80
+ }
81
+ await nextTick()
82
+ const formElement = activityFormRef.value?.$el as HTMLElement | undefined
83
+ if (!formElement) {
84
+ return
85
+ }
86
+ const headerHeight = document.querySelector('header')?.getBoundingClientRect().height || 72
87
+ const availableHeight = window.innerHeight - headerHeight - 32
88
+ formElement.scrollIntoView({
89
+ behavior: 'smooth',
90
+ block: formElement.offsetHeight <= availableHeight ? 'center' : 'start'
91
+ })
92
+ formElement
93
+ .querySelector<HTMLElement>(
94
+ 'textarea:not([disabled]), input:not([disabled]), select:not([disabled]), button[role="combobox"]:not([disabled])'
95
+ )
96
+ ?.focus({ preventScroll: true })
97
+ }
98
+ const openEditor = (activity: TimesheetsAdminBootstrap['activities'][number]) => {
99
+ if (formOpen.value && editingId.value === activity.id) {
100
+ return resetForm()
101
+ }
102
+ resetDeletion()
103
+ editingId.value = activity.id
104
+ Object.assign(form, { name: activity.name, billable: activity.billable })
105
+ formOpen.value = true
106
+ void scrollToActivityForm()
107
+ }
108
+ const save = () =>
109
+ run(async () => {
110
+ if (editingId.value) {
111
+ await timesheets.updateActivity(editingId.value, form)
112
+ } else {
113
+ await timesheets.createActivity(form)
114
+ }
115
+ resetForm()
116
+ })
117
+ const openDeletion = async (id: string) => {
118
+ if (deletionId.value === id) {
119
+ return resetDeletion()
120
+ }
121
+ resetForm()
122
+ deletionId.value = id
123
+ deletionName.value = ''
124
+ eligibility.value = null
125
+ deletionPending.value = true
126
+ try {
127
+ const result = await timesheets.getActivityDeletionEligibility(id)
128
+ if (deletionId.value === id) {
129
+ eligibility.value = result
130
+ }
131
+ } catch (error) {
132
+ toast.add({ title: t('features.timesheets.messages.saveError'), description: String(error), color: 'error' })
133
+ resetDeletion()
134
+ } finally {
135
+ if (deletionId.value === id) {
136
+ deletionPending.value = false
137
+ }
138
+ }
139
+ }
140
+ const confirmDeletion = () => {
141
+ if (!eligibility.value?.canDelete || deletionName.value !== eligibility.value.activityName) {
142
+ return
143
+ }
144
+ return run(async () => {
145
+ await timesheets.deleteActivity(eligibility.value!.activityId, deletionName.value)
146
+ resetDeletion()
147
+ })
148
+ }
149
+ const toggleActive = (id: string, active: boolean) => run(() => timesheets.updateActivity(id, { active }))
150
+ onKeyStroke('Escape', () => {
151
+ if (formOpen.value) {
152
+ resetForm()
153
+ }
154
+ })
155
+ defineExpose({
156
+ canCreate: computed(() => true),
157
+ openCreate: openNew,
158
+ refreshList: () => listing.refresh(),
159
+ showCreate: computed(() => Boolean(listing.items.value.length) && !formOpen.value)
160
+ })
161
+ await listing.load()
162
+ </script>
163
+
164
+ <template>
165
+ <section class="flex h-full min-h-0 flex-col gap-5">
166
+ <TimesheetsAdminListToolbar
167
+ v-model:search="listing.search.value"
168
+ :filters="activityFilters"
169
+ :filter-values="listing.filters"
170
+ :sort-options="sortOptions"
171
+ :sort-by="listing.sortBy.value"
172
+ :sort-dir="listing.sortDir.value"
173
+ @filter="listing.setFilter"
174
+ @sort="listing.sortBy.value = $event"
175
+ @toggle-direction="listing.toggleSortDir"
176
+ />
177
+ <TimesheetsAdminPaginatedList
178
+ class="min-h-0 flex-1"
179
+ :pagination="listing.pagination.value"
180
+ :pending="listing.pending.value"
181
+ :loading-next="listing.loadingNextPage.value"
182
+ :loading-previous="listing.loadingPreviousPage.value"
183
+ :has-next="listing.hasNextPage.value"
184
+ :has-previous="listing.hasPreviousPage.value"
185
+ @next="listing.loadNext"
186
+ @previous="listing.loadPrevious"
187
+ @page="listing.goToPage"
188
+ >
189
+ <TimesheetsAdminEmptyState
190
+ v-if="!listing.items.value.length && !formOpen && !listing.pending.value"
191
+ icon="i-lucide-tags"
192
+ :title="t('features.timesheets.admin.noActivitiesTitle')"
193
+ :description="t('features.timesheets.admin.noActivitiesDescription')"
194
+ :action-label="t('features.timesheets.admin.createFirstActivity')"
195
+ @action="openNew"
196
+ />
197
+ <div class="grid gap-3">
198
+ <template v-for="(activity, index) in listing.items.value" :key="activity.id">
199
+ <UCard
200
+ :style="{ order: index * 2 }"
201
+ class="cursor-pointer transition-colors hover:ring-primary/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
202
+ :class="{ 'ring-2 ring-primary': formOpen && editingId === activity.id }"
203
+ role="button"
204
+ tabindex="0"
205
+ @click="openEditor(activity)"
206
+ @keydown.enter="openEditor(activity)"
207
+ @keydown.space.prevent="openEditor(activity)"
208
+ >
209
+ <div class="flex items-center justify-between gap-3">
210
+ <div>
211
+ <p class="font-medium">{{ activity.name }}</p>
212
+ <p class="text-sm text-muted">
213
+ {{ activity.billable ? t('features.timesheets.billable') : t('features.timesheets.nonBillable') }}
214
+ </p>
215
+ </div>
216
+ <div class="flex items-center gap-1">
217
+ <UButton
218
+ size="xs"
219
+ color="neutral"
220
+ variant="ghost"
221
+ icon="i-lucide-pencil"
222
+ :aria-label="t('features.timesheets.admin.editActivity')"
223
+ :aria-expanded="formOpen && editingId === activity.id"
224
+ @click.stop="openEditor(activity)"
225
+ @keydown.stop
226
+ />
227
+ <UButton
228
+ size="xs"
229
+ color="error"
230
+ variant="ghost"
231
+ icon="i-lucide-trash-2"
232
+ :aria-label="t('features.timesheets.admin.deleteActivity')"
233
+ :aria-expanded="deletionId === activity.id"
234
+ @click.stop="openDeletion(activity.id)"
235
+ @keydown.stop
236
+ />
237
+ <span @click.stop @keydown.stop
238
+ ><USwitch
239
+ :model-value="activity.active"
240
+ :aria-label="t('features.timesheets.admin.active')"
241
+ @update:model-value="toggleActive(activity.id, $event)"
242
+ /></span>
243
+ </div>
244
+ </div>
245
+ </UCard>
246
+ <UCard v-if="deletionId === activity.id" :style="{ order: index * 2 + 1 }" class="border-error">
247
+ <template #header>
248
+ <h2 class="font-semibold text-error">{{ t('features.timesheets.admin.deleteActivity') }}</h2>
249
+ </template>
250
+ <div v-if="deletionPending" class="flex items-center gap-2 text-sm text-muted">
251
+ <UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />{{
252
+ t('features.timesheets.admin.checkingActivityDeletion')
253
+ }}
254
+ </div>
255
+ <template v-else-if="eligibility">
256
+ <div
257
+ class="flex items-center gap-2 text-sm"
258
+ :class="eligibility.canDelete ? 'text-success' : 'text-error'"
259
+ >
260
+ <UIcon
261
+ :name="eligibility.canDelete ? 'i-lucide-circle-check' : 'i-lucide-circle-x'"
262
+ class="size-5 shrink-0"
263
+ />{{
264
+ t(
265
+ eligibility.canDelete
266
+ ? 'features.timesheets.admin.activityHasNoTimeEntries'
267
+ : 'features.timesheets.admin.activityHasTimeEntries'
268
+ )
269
+ }}
270
+ </div>
271
+ <div v-if="eligibility.canDelete" class="mt-4 space-y-4">
272
+ <p class="text-sm text-muted">
273
+ {{ t('features.timesheets.admin.typeActivityNameToDelete', { name: activity.name }) }}
274
+ </p>
275
+ <UFormField :label="t('features.timesheets.admin.activityNameConfirmation')">
276
+ <UInput v-model="deletionName" :placeholder="activity.name" autocomplete="off" class="w-full" />
277
+ </UFormField>
278
+ <div class="flex justify-end gap-2">
279
+ <UButton color="neutral" variant="ghost" @click="resetDeletion">
280
+ {{ t('features.timesheets.cancel') }} </UButton
281
+ ><UButton
282
+ color="error"
283
+ icon="i-lucide-trash-2"
284
+ :loading="busy"
285
+ :disabled="deletionName !== activity.name"
286
+ @click="confirmDeletion"
287
+ >
288
+ {{ t('features.timesheets.admin.deleteActivity') }}
289
+ </UButton>
290
+ </div>
291
+ </div>
292
+ <div v-else class="mt-4 flex justify-end">
293
+ <UButton color="neutral" variant="ghost" @click="resetDeletion">
294
+ {{ t('features.timesheets.cancel') }}
295
+ </UButton>
296
+ </div>
297
+ </template>
298
+ </UCard>
299
+ </template>
300
+ <TimesheetsActivityForm
301
+ v-if="formOpen"
302
+ ref="activityFormRef"
303
+ v-model="form"
304
+ class="entity-editor"
305
+ :editing="Boolean(editingId)"
306
+ :busy="busy"
307
+ :show-cancel="true"
308
+ :style="{
309
+ order: editingId ? listing.items.value.findIndex((activity) => activity.id === editingId) * 2 + 1 : -1
310
+ }"
311
+ @submit="save"
312
+ @cancel="resetForm"
313
+ />
314
+ </div>
315
+ </TimesheetsAdminPaginatedList>
316
+ </section>
317
+ </template>
318
+
319
+ <style scoped>
320
+ .entity-editor {
321
+ scroll-margin-top: calc(var(--ui-header-height) + 1rem);
322
+ }
323
+ </style>