@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,338 @@
1
+ <script setup lang="ts">
2
+ import type { TimesheetsAdminBootstrap } from '@nuxt-customer-portal/timesheets/app/composables/useTimesheets'
3
+ import type { ProjectDto } from '@nuxt-customer-portal/timesheets/shared/types/timesheet'
4
+
5
+ const props = defineProps<{
6
+ project: ProjectDto
7
+ data: TimesheetsAdminBootstrap
8
+ refreshProject: () => Promise<unknown>
9
+ refreshData: () => Promise<unknown>
10
+ }>()
11
+ const emit = defineEmits<{ deleted: [] }>()
12
+ const { t } = useI18n()
13
+ const toast = useToast()
14
+ const timesheets = useTimesheets()
15
+ const busy = ref(false)
16
+ const editing = ref(false)
17
+ const deleting = ref(false)
18
+ const deletionName = ref('')
19
+ const deletionPending = ref(false)
20
+ const eligibility = ref<{ projectId: string; projectName: string; canDelete: boolean } | null>(null)
21
+ const form = reactive({
22
+ clientOrganizationId: '',
23
+ name: '',
24
+ code: '',
25
+ budgetHours: null as number | null,
26
+ budgetAmount: null as number | null,
27
+ activityTypeIds: [] as string[]
28
+ })
29
+ const rateDrafts = reactive<Record<string, number | null>>({})
30
+ const formatHours = (minutes: number) => `${(minutes / 60).toFixed(2)} h`
31
+ const formatMoney = (minor: number) =>
32
+ new Intl.NumberFormat(undefined, { style: 'currency', currency: props.data.settings.currency }).format(minor / 100)
33
+ const activityNames = computed(() =>
34
+ props.project.activityTypeIds
35
+ .map((id) => props.data.activities.find((activity) => activity.id === id)?.name)
36
+ .filter(Boolean)
37
+ )
38
+ const statusColor = computed(() => (props.project.status === 'ACTIVE' ? 'success' : 'neutral'))
39
+ const statusLabel = computed(() => t(`features.timesheets.admin.projectStatus.${props.project.status.toLowerCase()}`))
40
+ const clientLabel = computed(() =>
41
+ props.project.internal
42
+ ? t('features.timesheets.admin.internalOrganization', { name: props.project.clientName })
43
+ : props.project.clientName
44
+ )
45
+
46
+ const resetForm = () =>
47
+ Object.assign(form, {
48
+ clientOrganizationId: props.project.clientOrganizationId,
49
+ name: props.project.name,
50
+ code: props.project.code ?? '',
51
+ budgetHours: props.project.budgetMinutes === null ? null : props.project.budgetMinutes / 60,
52
+ budgetAmount: props.project.budgetMinor === null ? null : props.project.budgetMinor / 100,
53
+ activityTypeIds: [...props.project.activityTypeIds]
54
+ })
55
+ const toggleEditing = () => {
56
+ editing.value = !editing.value
57
+ deleting.value = false
58
+ resetForm()
59
+ }
60
+ const cancelEditing = () => {
61
+ editing.value = false
62
+ resetForm()
63
+ }
64
+ const resetRates = () => {
65
+ for (const key of Object.keys(rateDrafts)) {
66
+ Reflect.deleteProperty(rateDrafts, key)
67
+ }
68
+ for (const member of props.data.team) {
69
+ rateDrafts[member.id] =
70
+ props.project.personRates[member.id] === undefined ? null : props.project.personRates[member.id]! / 100
71
+ }
72
+ }
73
+ watch(
74
+ () => props.project,
75
+ () => {
76
+ resetForm()
77
+ resetRates()
78
+ },
79
+ { immediate: true }
80
+ )
81
+ watch(() => props.data.team, resetRates)
82
+
83
+ const run = async (operation: () => Promise<unknown>) => {
84
+ busy.value = true
85
+ try {
86
+ await operation()
87
+ await Promise.all([props.refreshProject(), props.refreshData()])
88
+ } catch (error) {
89
+ toast.add({ title: t('features.timesheets.messages.saveError'), description: String(error), color: 'error' })
90
+ } finally {
91
+ busy.value = false
92
+ }
93
+ }
94
+ const save = () =>
95
+ run(async () => {
96
+ await timesheets.updateProject(props.project.id, {
97
+ clientOrganizationId: form.clientOrganizationId,
98
+ name: form.name,
99
+ code: form.code || null,
100
+ budgetMinutes: form.budgetHours ? Math.round(form.budgetHours * 60) : null,
101
+ budgetMinor: form.budgetAmount ? Math.round(form.budgetAmount * 100) : null,
102
+ activityTypeIds: form.activityTypeIds
103
+ })
104
+ editing.value = false
105
+ toast.add({ title: t('features.timesheets.messages.projectUpdated'), color: 'success' })
106
+ })
107
+ const addActivity = async (input: { name: string; billable: boolean }) => {
108
+ await run(() => timesheets.createActivity(input))
109
+ return props.data.activities.find((activity) => activity.name === input.name)?.id
110
+ }
111
+ const setRateDraft = (userId: string, value: unknown) => {
112
+ rateDrafts[userId] = value === '' || value === null ? null : Number(value)
113
+ }
114
+ const saveRates = () =>
115
+ run(async () => {
116
+ await timesheets.updateProject(props.project.id, {
117
+ personRates: Object.fromEntries(
118
+ Object.entries(rateDrafts)
119
+ .filter(([, value]) => value !== null && value !== undefined)
120
+ .map(([id, value]) => [id, Math.round(Number(value) * 100)])
121
+ )
122
+ })
123
+ toast.add({ title: t('features.timesheets.messages.rateSaved'), color: 'success' })
124
+ })
125
+ const toggleArchived = () =>
126
+ run(async () => {
127
+ const status = props.project.status === 'ACTIVE' ? 'ARCHIVED' : 'ACTIVE'
128
+ await timesheets.updateProject(props.project.id, { status })
129
+ toast.add({
130
+ title: t(
131
+ status === 'ARCHIVED'
132
+ ? 'features.timesheets.messages.projectArchived'
133
+ : 'features.timesheets.messages.projectRestored'
134
+ ),
135
+ color: 'success'
136
+ })
137
+ })
138
+ const requestDeletion = async () => {
139
+ editing.value = false
140
+ deleting.value = !deleting.value
141
+ deletionName.value = ''
142
+ eligibility.value = null
143
+ if (!deleting.value) {
144
+ return
145
+ }
146
+ deletionPending.value = true
147
+ try {
148
+ eligibility.value = await timesheets.getProjectDeletionEligibility(props.project.id)
149
+ } catch (error) {
150
+ deleting.value = false
151
+ toast.add({ title: t('features.timesheets.messages.saveError'), description: String(error), color: 'error' })
152
+ } finally {
153
+ deletionPending.value = false
154
+ }
155
+ }
156
+ const confirmDeletion = async () => {
157
+ if (!eligibility.value?.canDelete || deletionName.value !== eligibility.value.projectName) {
158
+ return
159
+ }
160
+ busy.value = true
161
+ try {
162
+ await timesheets.deleteProject(props.project.id, deletionName.value)
163
+ toast.add({ title: t('features.timesheets.messages.projectDeleted'), color: 'success' })
164
+ emit('deleted')
165
+ } finally {
166
+ busy.value = false
167
+ }
168
+ }
169
+ onKeyStroke('Escape', () => {
170
+ editing.value = false
171
+ deleting.value = false
172
+ resetForm()
173
+ })
174
+ </script>
175
+
176
+ <template>
177
+ <div class="space-y-6">
178
+ <header class="flex flex-col gap-3 border-b border-default pb-5 sm:flex-row sm:items-end sm:justify-between">
179
+ <div class="min-w-0">
180
+ <div class="flex flex-wrap items-center gap-2">
181
+ <UIcon name="i-lucide-folder-kanban" class="size-6 shrink-0" />
182
+ <h1 class="truncate text-2xl font-semibold">{{ project.name }}</h1>
183
+ <UBadge :color="statusColor" variant="subtle">{{ statusLabel }}</UBadge>
184
+ </div>
185
+ <p class="mt-1 text-sm text-muted">{{ clientLabel }}{{ project.code ? ` · ${project.code}` : '' }}</p>
186
+ </div>
187
+ <UButton size="sm" variant="outline" icon="i-lucide-pencil" @click="toggleEditing">
188
+ {{ t('features.timesheets.admin.editProject') }}
189
+ </UButton>
190
+ </header>
191
+
192
+ <TimesheetsProjectForm
193
+ v-if="editing"
194
+ v-model="form"
195
+ :data="data"
196
+ :editing="true"
197
+ :busy="busy"
198
+ :show-cancel="true"
199
+ :add-activity="addActivity"
200
+ @submit="save"
201
+ @cancel="cancelEditing"
202
+ />
203
+
204
+ <UCard v-else>
205
+ <template #header>
206
+ <h2 class="font-semibold">{{ t('features.timesheets.admin.projectDetails') }}</h2>
207
+ </template>
208
+ <dl class="grid gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
209
+ <div>
210
+ <dt class="text-sm text-muted">{{ t('features.timesheets.fields.project') }}</dt>
211
+ <dd class="font-medium">{{ project.name }}</dd>
212
+ </div>
213
+ <div>
214
+ <dt class="text-sm text-muted">{{ t('features.timesheets.admin.client') }}</dt>
215
+ <dd>{{ clientLabel }}</dd>
216
+ </div>
217
+ <div>
218
+ <dt class="text-sm text-muted">{{ t('features.timesheets.admin.code') }}</dt>
219
+ <dd class="font-mono text-sm">{{ project.code || '—' }}</dd>
220
+ </div>
221
+ <div>
222
+ <dt class="text-sm text-muted">{{ t('features.timesheets.admin.hoursBudget') }}</dt>
223
+ <dd>{{ project.budgetMinutes === null ? '—' : formatHours(project.budgetMinutes) }}</dd>
224
+ </div>
225
+ <div>
226
+ <dt class="text-sm text-muted">{{ t('features.timesheets.admin.moneyBudget') }}</dt>
227
+ <dd>{{ project.budgetMinor === null ? '—' : formatMoney(project.budgetMinor) }}</dd>
228
+ </div>
229
+ <div>
230
+ <dt class="text-sm text-muted">{{ t('features.timesheets.admin.activities') }}</dt>
231
+ <dd>{{ activityNames.join(', ') || '—' }}</dd>
232
+ </div>
233
+ </dl>
234
+ </UCard>
235
+
236
+ <UCard>
237
+ <template #header>
238
+ <h2 class="font-semibold">{{ t('features.timesheets.admin.projectRates') }}</h2>
239
+ </template>
240
+ <div class="space-y-3">
241
+ <div v-for="member in data.team" :key="member.id" class="flex items-center gap-3">
242
+ <span class="min-w-0 flex-1 truncate text-sm">{{ member.name }}</span
243
+ ><UInput
244
+ :model-value="rateDrafts[member.id] ?? undefined"
245
+ type="number"
246
+ min="0"
247
+ step="0.01"
248
+ :placeholder="member.defaultHourlyRateMinor === null ? '—' : String(member.defaultHourlyRateMinor / 100)"
249
+ class="w-32"
250
+ @update:model-value="setRateDraft(member.id, $event)"
251
+ />
252
+ </div>
253
+ <div class="flex justify-end">
254
+ <UButton size="sm" variant="outline" icon="i-lucide-save" :loading="busy" @click="saveRates">
255
+ {{ t('features.timesheets.admin.saveOverrides') }}
256
+ </UButton>
257
+ </div>
258
+ </div>
259
+ </UCard>
260
+
261
+ <UCard>
262
+ <template #header>
263
+ <h2 class="font-semibold">{{ t('features.timesheets.admin.projectLifecycle') }}</h2>
264
+ </template>
265
+ <div class="flex flex-wrap justify-between gap-3">
266
+ <UButton
267
+ color="neutral"
268
+ variant="outline"
269
+ :icon="project.status === 'ACTIVE' ? 'i-lucide-archive' : 'i-lucide-archive-restore'"
270
+ :loading="busy"
271
+ @click="toggleArchived"
272
+ >
273
+ {{
274
+ t(
275
+ project.status === 'ACTIVE'
276
+ ? 'features.timesheets.admin.archiveProject'
277
+ : 'features.timesheets.admin.restoreProject'
278
+ )
279
+ }}
280
+ </UButton>
281
+ <UButton color="error" variant="outline" icon="i-lucide-trash-2" @click="requestDeletion">
282
+ {{ t('features.timesheets.admin.deleteProject') }}
283
+ </UButton>
284
+ </div>
285
+ </UCard>
286
+
287
+ <UCard v-if="deleting" class="border-error">
288
+ <template #header>
289
+ <h2 class="font-semibold text-error">{{ t('features.timesheets.admin.deleteProject') }}</h2>
290
+ </template>
291
+ <div v-if="deletionPending" class="flex items-center gap-2 text-sm text-muted">
292
+ <UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />{{
293
+ t('features.timesheets.admin.checkingProjectDeletion')
294
+ }}
295
+ </div>
296
+ <template v-else-if="eligibility">
297
+ <div class="flex items-center gap-2 text-sm" :class="eligibility.canDelete ? 'text-success' : 'text-error'">
298
+ <UIcon
299
+ :name="eligibility.canDelete ? 'i-lucide-circle-check' : 'i-lucide-circle-x'"
300
+ class="size-5 shrink-0"
301
+ />{{
302
+ t(
303
+ eligibility.canDelete
304
+ ? 'features.timesheets.admin.projectHasNoTimeEntries'
305
+ : 'features.timesheets.admin.projectHasTimeEntries'
306
+ )
307
+ }}
308
+ </div>
309
+ <div v-if="eligibility.canDelete" class="mt-4 space-y-4">
310
+ <p class="text-sm text-muted">
311
+ {{ t('features.timesheets.admin.typeProjectNameToDelete', { name: project.name }) }}
312
+ </p>
313
+ <UFormField :label="t('features.timesheets.admin.projectNameConfirmation')">
314
+ <UInput v-model="deletionName" :placeholder="project.name" autocomplete="off" class="w-full" />
315
+ </UFormField>
316
+ <div class="flex justify-end gap-2">
317
+ <UButton color="neutral" variant="outline" @click="deleting = false">
318
+ {{ t('features.timesheets.cancel') }} </UButton
319
+ ><UButton
320
+ color="error"
321
+ icon="i-lucide-trash-2"
322
+ :loading="busy"
323
+ :disabled="deletionName !== project.name"
324
+ @click="confirmDeletion"
325
+ >
326
+ {{ t('features.timesheets.admin.deleteProject') }}
327
+ </UButton>
328
+ </div>
329
+ </div>
330
+ <div v-else class="mt-4 flex justify-end">
331
+ <UButton color="neutral" variant="outline" @click="deleting = false">
332
+ {{ t('features.timesheets.cancel') }}
333
+ </UButton>
334
+ </div>
335
+ </template>
336
+ </UCard>
337
+ </div>
338
+ </template>
@@ -0,0 +1,215 @@
1
+ <script setup lang="ts">
2
+ import { z } from 'zod'
3
+ import type { TimesheetsAdminBootstrap } from '@nuxt-customer-portal/timesheets/app/composables/useTimesheets'
4
+
5
+ export interface TimesheetsProjectFormModel {
6
+ clientOrganizationId: string
7
+ name: string
8
+ code: string
9
+ budgetHours: number | null
10
+ budgetAmount: number | null
11
+ activityTypeIds: string[]
12
+ }
13
+
14
+ const props = defineProps<{
15
+ data: TimesheetsAdminBootstrap
16
+ editing: boolean
17
+ busy: boolean
18
+ showCancel: boolean
19
+ addActivity: (input: { name: string; billable: boolean }) => Promise<string | undefined>
20
+ }>()
21
+ const emit = defineEmits<{
22
+ submit: []
23
+ cancel: []
24
+ }>()
25
+ const form = defineModel<TimesheetsProjectFormModel>({ required: true })
26
+ const { t } = useI18n()
27
+ const activityFormOpen = ref(false)
28
+ const activityForm = reactive({ name: '', billable: true })
29
+ const currencyFormat = computed<Intl.NumberFormatOptions>(() => ({
30
+ style: 'currency',
31
+ currency: props.data.settings.currency,
32
+ minimumFractionDigits: 2,
33
+ maximumFractionDigits: 2
34
+ }))
35
+ const wholeNumberFormat: Intl.NumberFormatOptions = {
36
+ minimumFractionDigits: 0,
37
+ maximumFractionDigits: 0
38
+ }
39
+ const availableActivities = computed(() =>
40
+ props.data.activities
41
+ .filter((activity) => activity.active || form.value.activityTypeIds.includes(activity.id))
42
+ .map((activity) => ({ label: activity.name, value: activity.id }))
43
+ )
44
+ const clientOptions = computed(() => [
45
+ {
46
+ label: t('features.timesheets.admin.internalOrganization', { name: props.data.providerOrganization.name }),
47
+ value: props.data.providerOrganization.organizationId
48
+ },
49
+ ...props.data.clients.map((item) => ({ label: item.name, value: item.organizationId }))
50
+ ])
51
+ const schema = computed(() =>
52
+ z.object({
53
+ clientOrganizationId: z.string().min(1, t('features.timesheets.validation.required')),
54
+ name: z.string().trim().min(2, t('features.timesheets.validation.projectNameLength')).max(160),
55
+ code: z.string().trim().max(40),
56
+ budgetHours: z.number().min(0).nullable(),
57
+ budgetAmount: z.number().min(0).nullable(),
58
+ activityTypeIds: z.array(z.string()).min(1, t('features.timesheets.validation.selectActivity'))
59
+ })
60
+ )
61
+
62
+ const createActivity = async () => {
63
+ const activityId = await props.addActivity({
64
+ name: activityForm.name.trim(),
65
+ billable: activityForm.billable
66
+ })
67
+ if (!activityId) {
68
+ return
69
+ }
70
+ if (!form.value.activityTypeIds.includes(activityId)) {
71
+ form.value.activityTypeIds.push(activityId)
72
+ }
73
+ Object.assign(activityForm, { name: '', billable: true })
74
+ activityFormOpen.value = false
75
+ }
76
+ </script>
77
+
78
+ <template>
79
+ <UCard>
80
+ <template #header>
81
+ <div class="flex items-center justify-between gap-3">
82
+ <h2 class="font-semibold">
83
+ {{ editing ? t('features.timesheets.admin.editProject') : t('features.timesheets.admin.newProject') }}
84
+ </h2>
85
+ <UButton
86
+ v-if="editing"
87
+ type="button"
88
+ color="neutral"
89
+ variant="ghost"
90
+ icon="i-lucide-x"
91
+ :aria-label="t('features.timesheets.admin.close')"
92
+ @click="emit('cancel')"
93
+ />
94
+ </div>
95
+ </template>
96
+ <UForm novalidate :state="form" :schema="schema" class="space-y-4" @submit="emit('submit')">
97
+ <div>
98
+ <UFormField name="clientOrganizationId" :label="t('features.timesheets.admin.client')" required>
99
+ <USelect v-model="form.clientOrganizationId" :items="clientOptions" value-key="value" class="w-full" />
100
+ </UFormField>
101
+ <p v-if="!data.clients.length" class="mt-2 text-sm text-muted">
102
+ {{ t('features.timesheets.admin.internalProjectHelp') }}
103
+ <ULink to="/clients" class="text-primary">{{ t('features.timesheets.admin.manageClients') }}</ULink>
104
+ </p>
105
+ </div>
106
+ <UFormField name="name" :label="t('features.timesheets.fields.project')" required>
107
+ <UInput v-model="form.name" class="w-full" />
108
+ </UFormField>
109
+ <UFormField name="code" :label="t('features.timesheets.admin.code')">
110
+ <UInput v-model="form.code" class="w-full" />
111
+ </UFormField>
112
+ <div class="flex items-end gap-2">
113
+ <UFormField
114
+ name="activityTypeIds"
115
+ :label="t('features.timesheets.admin.activities')"
116
+ required
117
+ class="min-w-0 flex-1"
118
+ >
119
+ <USelectMenu
120
+ v-model="form.activityTypeIds"
121
+ multiple
122
+ :items="availableActivities"
123
+ value-key="value"
124
+ class="w-full"
125
+ />
126
+ </UFormField>
127
+ <UButton
128
+ v-if="data.activities.some((item) => item.active)"
129
+ type="button"
130
+ size="sm"
131
+ variant="outline"
132
+ icon="i-lucide-plus"
133
+ :aria-expanded="activityFormOpen"
134
+ @click="activityFormOpen = !activityFormOpen"
135
+ >
136
+ {{ t('features.timesheets.admin.createActivity') }}
137
+ </UButton>
138
+ </div>
139
+ <div
140
+ v-if="!data.activities.some((item) => item.active) || activityFormOpen"
141
+ class="rounded-lg border border-default p-3"
142
+ >
143
+ <UInput v-model="activityForm.name" :placeholder="t('features.timesheets.fields.activity')" class="w-full" />
144
+ <div class="mt-3 flex items-center justify-between gap-3">
145
+ <UCheckbox v-model="activityForm.billable" :label="t('features.timesheets.billable')" />
146
+ <UButton
147
+ type="button"
148
+ size="sm"
149
+ :loading="busy"
150
+ :disabled="!activityForm.name.trim()"
151
+ @click="createActivity"
152
+ >
153
+ {{ t('features.timesheets.admin.createActivity') }}
154
+ </UButton>
155
+ </div>
156
+ </div>
157
+ <div class="grid grid-cols-2 gap-3">
158
+ <UFormField name="budgetHours" :label="t('features.timesheets.admin.hoursBudget')">
159
+ <UInputNumber
160
+ :model-value="form.budgetHours"
161
+ :placeholder="t('features.timesheets.admin.noBudget')"
162
+ :min="0"
163
+ :step="1"
164
+ :format-options="wholeNumberFormat"
165
+ class="w-full"
166
+ @update:model-value="
167
+ form.budgetHours = typeof $event === 'number' && Number.isFinite($event) ? $event : null
168
+ "
169
+ />
170
+ <UButton
171
+ v-if="form.budgetHours !== null"
172
+ type="button"
173
+ color="neutral"
174
+ variant="link"
175
+ size="xs"
176
+ @click="form.budgetHours = null"
177
+ >
178
+ {{ t('features.timesheets.admin.clearBudget') }}
179
+ </UButton>
180
+ </UFormField>
181
+ <UFormField name="budgetAmount" :label="t('features.timesheets.admin.moneyBudget')">
182
+ <UInputNumber
183
+ :model-value="form.budgetAmount"
184
+ :placeholder="t('features.timesheets.admin.noBudget')"
185
+ :min="0"
186
+ :step="1"
187
+ :format-options="currencyFormat"
188
+ class="w-full"
189
+ @update:model-value="
190
+ form.budgetAmount = typeof $event === 'number' && Number.isFinite($event) ? $event : null
191
+ "
192
+ />
193
+ <UButton
194
+ v-if="form.budgetAmount !== null"
195
+ type="button"
196
+ color="neutral"
197
+ variant="link"
198
+ size="xs"
199
+ @click="form.budgetAmount = null"
200
+ >
201
+ {{ t('features.timesheets.admin.clearBudget') }}
202
+ </UButton>
203
+ </UFormField>
204
+ </div>
205
+ <div class="flex justify-end gap-2">
206
+ <UButton v-if="showCancel" type="button" color="neutral" variant="outline" @click="emit('cancel')">
207
+ {{ t('features.timesheets.cancel') }}
208
+ </UButton>
209
+ <UButton type="submit" :loading="busy">
210
+ {{ editing ? t('features.timesheets.save') : t('features.timesheets.admin.createProject') }}
211
+ </UButton>
212
+ </div>
213
+ </UForm>
214
+ </UCard>
215
+ </template>
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ import type { TimesheetsSetupStatusDto } from '@nuxt-customer-portal/timesheets/shared/types/timesheet'
3
+
4
+ const props = defineProps<{ status: TimesheetsSetupStatusDto }>()
5
+ const { t } = useI18n()
6
+ const items = computed(() => [
7
+ { complete: props.status.hasClient, key: 'client', to: '/clients' },
8
+ { complete: props.status.hasActiveActivity, key: 'activity', to: '/admin/timesheets/activities' },
9
+ { complete: props.status.hasConfiguredProject, key: 'project', to: '/admin/timesheets/projects' },
10
+ {
11
+ complete: props.status.missingDefaultTariffCount === 0,
12
+ key: 'rates',
13
+ to: '/admin/timesheets/rates',
14
+ count: props.status.missingDefaultTariffCount
15
+ }
16
+ ])
17
+ </script>
18
+
19
+ <template>
20
+ <UAlert
21
+ color="warning"
22
+ icon="i-lucide-list-checks"
23
+ :title="t('features.timesheets.setup.title')"
24
+ :description="t('features.timesheets.setup.description')"
25
+ variant="outline"
26
+ >
27
+ <template #actions>
28
+ <ul class="grid w-full gap-2 sm:grid-cols-2">
29
+ <li v-for="item in items" :key="item.key">
30
+ <UButton
31
+ :to="item.complete ? undefined : item.to"
32
+ :disabled="item.complete"
33
+ color="neutral"
34
+ variant="soft"
35
+ size="sm"
36
+ class="w-full justify-start"
37
+ >
38
+ <UIcon
39
+ :name="item.complete ? 'i-lucide-circle-check' : 'i-lucide-circle-dashed'"
40
+ :class="item.complete ? 'text-success' : 'text-warning'"
41
+ />
42
+ {{ t(`features.timesheets.setup.${item.key}`, { count: item.count ?? 0 }) }}
43
+ </UButton>
44
+ </li>
45
+ </ul>
46
+ </template>
47
+ </UAlert>
48
+ </template>
@@ -0,0 +1,30 @@
1
+ <script setup lang="ts">
2
+ import type { SubmissionMessageDto } from '@nuxt-customer-portal/timesheets/shared/types/timesheet'
3
+
4
+ defineProps<{ messages: readonly Readonly<SubmissionMessageDto>[] }>()
5
+ const { locale } = useI18n()
6
+ const userStore = useUserStore()
7
+ const timestamp = (value: string) =>
8
+ new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
9
+ </script>
10
+
11
+ <template>
12
+ <div v-if="messages.length" class="my-3 flex w-full max-w-4xl flex-col gap-3">
13
+ <article
14
+ v-for="message in messages"
15
+ :key="message.id"
16
+ class="w-fit min-w-0 max-w-[92%] rounded-2xl border p-3 text-default sm:max-w-[80%]"
17
+ :class="
18
+ message.authorUserId && message.authorUserId === userStore.currentUser?.id
19
+ ? 'self-end rounded-br-sm border-primary/25 bg-primary/10'
20
+ : 'self-start rounded-bl-sm border-default bg-elevated'
21
+ "
22
+ >
23
+ <div class="mb-2 flex flex-wrap items-center gap-2">
24
+ <span v-if="message.authorName" class="text-sm font-medium">{{ message.authorName }}</span>
25
+ <time :datetime="message.createdAt" class="text-xs text-muted">{{ timestamp(message.createdAt) }}</time>
26
+ </div>
27
+ <p class="whitespace-pre-line break-words text-sm leading-relaxed">{{ message.comment }}</p>
28
+ </article>
29
+ </div>
30
+ </template>