@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,8 @@
1
+ export const useTimesheetsDashboard = () => {
2
+ const { activeOrganizationId } = usePortalSession()
3
+ const { dashboard } = useTimesheets()
4
+ return useAsyncData('timesheets-dashboard', dashboard, {
5
+ watch: [activeOrganizationId],
6
+ dedupe: 'defer'
7
+ })
8
+ }
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <TimesheetsAdminSectionPage section="activities" />
3
+ </template>
@@ -0,0 +1,3 @@
1
+ <script setup lang="ts">
2
+ await navigateTo('/timesheets/internal-approvals', { redirectCode: 301 })
3
+ </script>
@@ -0,0 +1,7 @@
1
+ <script setup lang="ts">
2
+ await navigateTo('/admin/timesheets/internal-approvals', { redirectCode: 301, replace: true })
3
+ </script>
4
+
5
+ <template>
6
+ <div />
7
+ </template>
@@ -0,0 +1,131 @@
1
+ <script setup lang="ts">
2
+ const { t } = useI18n()
3
+ const toast = useToast()
4
+ const timesheets = useTimesheets()
5
+ const search = ref('')
6
+ const busyId = ref<string | null>(null)
7
+ const { data, pending, refresh } = await useAsyncData(
8
+ 'timesheets-internal-approval-configuration',
9
+ timesheets.internalApprovalConfiguration
10
+ )
11
+ const members = computed(() =>
12
+ (data.value?.members ?? []).filter((member) => {
13
+ const query = search.value.trim().toLocaleLowerCase()
14
+ return !query || member.name.toLocaleLowerCase().includes(query) || member.email.toLocaleLowerCase().includes(query)
15
+ })
16
+ )
17
+ const optionsFor = (userId: string) =>
18
+ (data.value?.members ?? [])
19
+ .filter((member) => member.id !== userId)
20
+ .map((member) => ({ label: `${member.name} · ${member.email}`, value: member.id }))
21
+ const saveWorkspace = async (enabled: boolean) => {
22
+ busyId.value = 'workspace'
23
+ try {
24
+ await timesheets.updateInternalApprovalWorkspace(enabled)
25
+ await refresh()
26
+ if (import.meta.client) {
27
+ window.dispatchEvent(new Event('timesheets:capabilities-refresh'))
28
+ }
29
+ } catch (error) {
30
+ toast.add({ title: t('features.timesheets.messages.saveError'), description: String(error), color: 'error' })
31
+ } finally {
32
+ busyId.value = null
33
+ }
34
+ }
35
+ const saveMember = async (userId: string, required: boolean, approverUserIds: string[]) => {
36
+ busyId.value = userId
37
+ try {
38
+ await timesheets.updateInternalApprovalMember(userId, { required, approverUserIds })
39
+ await refresh()
40
+ if (import.meta.client) {
41
+ window.dispatchEvent(new Event('timesheets:capabilities-refresh'))
42
+ }
43
+ } catch (error) {
44
+ toast.add({ title: t('features.timesheets.messages.saveError'), description: String(error), color: 'error' })
45
+ await refresh()
46
+ } finally {
47
+ busyId.value = null
48
+ }
49
+ }
50
+ useSeoMeta({ title: () => t('features.timesheets.internalApprovals.manageTitle') })
51
+ </script>
52
+
53
+ <template>
54
+ <TimesheetsPageShell class="h-full min-h-0 space-y-6 overflow-y-auto">
55
+ <header>
56
+ <div class="flex items-center gap-2">
57
+ <UIcon name="i-lucide-user-round-check" class="size-6" />
58
+ <h1 class="text-2xl font-semibold text-highlighted">
59
+ {{ t('features.timesheets.internalApprovals.manageTitle') }}
60
+ </h1>
61
+ </div>
62
+ <p class="mt-1 hidden text-sm text-muted sm:block">
63
+ {{ t('features.timesheets.internalApprovals.manageSubtitle') }}
64
+ </p>
65
+ </header>
66
+ <div v-if="pending && !data" class="flex justify-center py-12 text-muted">
67
+ <UIcon name="i-lucide-loader-circle" class="size-6 animate-spin" />
68
+ </div>
69
+ <template v-else-if="data">
70
+ <UCard>
71
+ <div class="flex items-center justify-between gap-4">
72
+ <div>
73
+ <p class="font-medium">{{ t('features.timesheets.internalApprovals.enabled') }}</p>
74
+ <p class="text-sm text-muted">{{ t('features.timesheets.internalApprovals.enabledHelp') }}</p>
75
+ </div>
76
+ <USwitch
77
+ :model-value="data.enabled"
78
+ :loading="busyId === 'workspace'"
79
+ :aria-label="t('features.timesheets.internalApprovals.enabled')"
80
+ @update:model-value="saveWorkspace"
81
+ />
82
+ </div>
83
+ </UCard>
84
+ <UInput
85
+ v-model="search"
86
+ icon="i-lucide-search"
87
+ :placeholder="t('features.timesheets.internalApprovals.search')"
88
+ class="w-full"
89
+ />
90
+ <section class="grid gap-3">
91
+ <UCard v-for="member in members" :key="member.id">
92
+ <div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto_minmax(18rem,1fr)] lg:items-center">
93
+ <div class="flex min-w-0 items-center gap-3">
94
+ <UAvatar :src="member.image ?? undefined" :alt="member.name" />
95
+ <div class="min-w-0">
96
+ <p class="truncate font-medium">{{ member.name }}</p>
97
+ <p class="truncate text-sm text-muted">{{ member.email }}</p>
98
+ </div>
99
+ </div>
100
+ <UFormField :label="t('features.timesheets.internalApprovals.required')">
101
+ <USwitch
102
+ :model-value="member.internalApprovalRequired"
103
+ :disabled="!data.enabled || busyId === member.id"
104
+ @update:model-value="saveMember(member.id, $event, $event ? member.approverUserIds : [])"
105
+ />
106
+ </UFormField>
107
+ <UFormField
108
+ :label="t('features.timesheets.internalApprovals.approvers')"
109
+ :description="t('features.timesheets.internalApprovals.approversHelp')"
110
+ >
111
+ <USelectMenu
112
+ :model-value="member.approverUserIds"
113
+ multiple
114
+ :items="optionsFor(member.id)"
115
+ value-key="value"
116
+ class="w-full"
117
+ :disabled="!data.enabled || !member.internalApprovalRequired || busyId === member.id"
118
+ @update:model-value="saveMember(member.id, true, $event)"
119
+ />
120
+ </UFormField>
121
+ </div>
122
+ </UCard>
123
+ <UCard v-if="!members.length">
124
+ <p class="py-8 text-center text-sm text-muted">
125
+ {{ t('features.timesheets.internalApprovals.emptySearch') }}
126
+ </p>
127
+ </UCard>
128
+ </section>
129
+ </template>
130
+ </TimesheetsPageShell>
131
+ </template>
@@ -0,0 +1,57 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute()
3
+ const { t } = useI18n()
4
+ const timesheets = useTimesheets()
5
+ const projectId = computed(() => String(route.params.id))
6
+ const returnTo = computed(() => {
7
+ const value = Array.isArray(route.query.returnTo) ? route.query.returnTo[0] : route.query.returnTo
8
+ return typeof value === 'string' &&
9
+ (value === '/admin/timesheets/projects' || value.startsWith('/admin/timesheets/projects?'))
10
+ ? value
11
+ : '/admin/timesheets/projects'
12
+ })
13
+ const {
14
+ data: project,
15
+ pending: projectPending,
16
+ error,
17
+ refresh: refreshProject
18
+ } = await useAsyncData(`timesheets-project-${projectId.value}`, () => timesheets.getProject(projectId.value))
19
+ const {
20
+ data,
21
+ pending: dataPending,
22
+ refresh: refreshData
23
+ } = await useAsyncData('timesheets-admin-project-detail', () => timesheets.adminBootstrap('projects'))
24
+ const pending = computed(() => projectPending.value || dataPending.value)
25
+ useSeoMeta({ title: computed(() => project.value?.name ?? t('features.timesheets.admin.projects')) })
26
+ const handleDeleted = async () => {
27
+ await navigateTo(returnTo.value)
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <TimesheetsPageShell class="timesheet-admin h-full min-h-0 overflow-y-auto" :setup-status="data?.setupStatus">
33
+ <UButton :to="returnTo" variant="link" color="neutral" icon="i-lucide-arrow-left" class="mb-4 w-fit px-0">
34
+ {{ t('features.timesheets.admin.backToProjects') }}
35
+ </UButton>
36
+ <div v-if="pending" class="flex justify-center py-12" role="status">
37
+ <UIcon name="i-lucide-loader-circle" class="size-6 animate-spin" /><span class="sr-only">{{
38
+ t('features.timesheets.admin.projectLoading')
39
+ }}</span>
40
+ </div>
41
+ <UAlert
42
+ v-else-if="error"
43
+ color="error"
44
+ icon="i-lucide-circle-alert"
45
+ :title="t('features.timesheets.admin.projectNotFound')"
46
+ variant="outline"
47
+ />
48
+ <TimesheetsProjectDetail
49
+ v-else-if="project && data"
50
+ :project="project"
51
+ :data="data"
52
+ :refresh-project="refreshProject"
53
+ :refresh-data="refreshData"
54
+ @deleted="handleDeleted"
55
+ />
56
+ </TimesheetsPageShell>
57
+ </template>
@@ -0,0 +1,8 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute()
3
+ </script>
4
+
5
+ <template>
6
+ <NuxtPage v-if="route.params.id" />
7
+ <TimesheetsAdminSectionPage v-else section="projects" />
8
+ </template>
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <TimesheetsAdminSectionPage section="rates" />
3
+ </template>
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <TimesheetsAdminSectionPage section="reports" />
3
+ </template>
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <TimesheetsAdminSectionPage section="settings" />
3
+ </template>
@@ -0,0 +1,96 @@
1
+ <!-- Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 -->
2
+ <script setup lang="ts">
3
+ const { t } = useI18n()
4
+ const api = useTimesheets()
5
+ const toast = useToast()
6
+ const { data: suppliers, refresh: refreshSuppliers } = await useAsyncData(
7
+ 'timesheet-reviewer-suppliers',
8
+ api.clientReviewerSuppliers
9
+ )
10
+ const supplier = computed(() => suppliers.value?.[0] ?? null)
11
+ const { data: reviewers, refresh } = await useAsyncData('timesheet-approval-reviewers', async () =>
12
+ supplier.value ? api.clientReviewers(supplier.value.id) : []
13
+ )
14
+ const saving = ref(false)
15
+ const configurableReviewers = computed(() => reviewers.value ?? [])
16
+ const reviewerCount = computed(() => reviewers.value?.filter((person) => person.assigned).length ?? 0)
17
+ const toggle = async (userId: string, assigned: boolean) => {
18
+ if (!supplier.value || saving.value) {
19
+ return
20
+ }
21
+ saving.value = true
22
+ try {
23
+ await api.setClientReviewer(supplier.value.id, userId, assigned)
24
+ await Promise.all([refresh(), refreshSuppliers()])
25
+ window.dispatchEvent(new CustomEvent('timesheets:capabilities-refresh'))
26
+ } catch {
27
+ toast.add({
28
+ title: t('features.timesheets.messages.saveError'),
29
+ color: 'error'
30
+ })
31
+ } finally {
32
+ saving.value = false
33
+ }
34
+ }
35
+ useSeoMeta({ title: () => t('features.timesheets.approvals.reviewersTitle') })
36
+ </script>
37
+
38
+ <template>
39
+ <TimesheetsPageShell width="narrow" class="h-full overflow-y-auto">
40
+ <header class="border-b border-default pb-5">
41
+ <h1 class="text-2xl font-semibold">
42
+ {{ t('features.timesheets.approvals.reviewersTitle') }}
43
+ </h1>
44
+ <p class="mt-1 text-sm text-muted">
45
+ {{ t('features.timesheets.approvals.reviewersSubtitle') }}
46
+ </p>
47
+ </header>
48
+ <UAlert
49
+ v-if="!supplier"
50
+ class="mt-6"
51
+ icon="i-lucide-users-round"
52
+ :title="t('features.timesheets.approvals.approvalUnavailable')"
53
+ variant="outline"
54
+ />
55
+ <section v-else class="my-6 rounded-lg border border-default bg-default">
56
+ <header class="border-b border-default p-5">
57
+ <div class="flex flex-wrap items-start justify-between gap-3">
58
+ <div>
59
+ <h2 class="text-lg font-semibold">
60
+ {{ t('features.timesheets.approvals.chooseReviewers') }}
61
+ </h2>
62
+ <p class="mt-1 text-sm text-muted">
63
+ {{ t('features.timesheets.clientPortal.reviewerHelp') }}
64
+ </p>
65
+ </div>
66
+ <UBadge color="neutral" variant="subtle">
67
+ {{ t('features.timesheets.approvals.reviewerCount', reviewerCount) }}
68
+ </UBadge>
69
+ </div>
70
+ </header>
71
+ <div v-if="configurableReviewers.length">
72
+ <div class="divide-y divide-default">
73
+ <label
74
+ v-for="person in configurableReviewers"
75
+ :key="person.id"
76
+ class="flex items-center gap-3 p-4 hover:bg-elevated/40"
77
+ ><USwitch
78
+ :model-value="person.assigned"
79
+ :disabled="saving || (person.assigned && reviewerCount <= 1)"
80
+ @update:model-value="toggle(person.id, $event)"
81
+ /><span class="min-w-0"
82
+ ><strong class="block text-sm">{{ person.name }}</strong
83
+ ><span class="block truncate text-xs text-muted">{{ person.email }}</span></span
84
+ ></label
85
+ >
86
+ </div>
87
+ </div>
88
+ <p v-if="!configurableReviewers.length" class="p-5 text-sm text-muted">
89
+ {{ t('features.timesheets.approvals.noEligibleReviewers') }}
90
+ </p>
91
+ <footer class="border-t border-default p-4 text-xs text-muted">
92
+ {{ t('features.timesheets.clientPortal.saveReviewers') }}
93
+ </footer>
94
+ </section>
95
+ </TimesheetsPageShell>
96
+ </template>
@@ -0,0 +1,11 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute()
3
+ const { t } = useI18n()
4
+
5
+ useSeoMeta({ title: () => t('features.timesheets.approvals.title') })
6
+ </script>
7
+
8
+ <template>
9
+ <NuxtPage v-if="route.path !== '/timesheets/approvals'" />
10
+ <TimesheetsClientTimesheetBrowser v-else mode="review" />
11
+ </template>
@@ -0,0 +1,108 @@
1
+ <script setup lang="ts">
2
+ import type { ApprovalQueueItemDto } from '@nuxt-customer-portal/timesheets/shared/types/timesheet'
3
+
4
+ const { t } = useI18n()
5
+ const timesheets = useTimesheets()
6
+ const { data, error } = await useAsyncData('timesheets-client-approval-context', () =>
7
+ timesheets.internalApprovalQueue(true)
8
+ )
9
+ const listing = useTimesheetsAdminList<ApprovalQueueItemDto>({
10
+ endpoint: '/api/timesheets/client-approvals',
11
+ filterKeys: ['status', 'userId', 'clientOrganizationId'],
12
+ defaultSort: 'weekStartsOn',
13
+ defaultSortDir: 'desc'
14
+ })
15
+ const filters = computed(() => [
16
+ {
17
+ key: 'clientOrganizationId',
18
+ placeholder: t('features.timesheets.admin.list.clientFilter'),
19
+ items: [
20
+ { label: t('features.timesheets.admin.list.allClients'), value: undefined },
21
+ ...(data.value?.clients ?? []).map((client) => ({ label: client.name, value: client.organizationId }))
22
+ ]
23
+ },
24
+ {
25
+ key: 'userId',
26
+ placeholder: t('features.timesheets.internalApprovals.memberFilter'),
27
+ items: [
28
+ { label: t('features.timesheets.internalApprovals.allMembers'), value: undefined },
29
+ ...(data.value?.teamMembers ?? []).map((member) => ({ label: member.name, value: member.id }))
30
+ ]
31
+ },
32
+ {
33
+ key: 'status',
34
+ placeholder: t('features.timesheets.approvals.statusFilter'),
35
+ items: [
36
+ { label: t('features.timesheets.approvals.allStatuses'), value: undefined },
37
+ ...['PENDING', 'APPROVED', 'DISPUTED', 'AUTO_APPROVED'].map((status) => ({
38
+ label: t(`features.timesheets.clientPortal.${status.toLowerCase()}`),
39
+ value: status
40
+ }))
41
+ ]
42
+ }
43
+ ])
44
+ const sortOptions = computed(() => [
45
+ { label: t('features.timesheets.approvals.sortNewest'), value: 'weekStartsOn' },
46
+ { label: t('features.timesheets.approvals.sortPerson'), value: 'userName' },
47
+ { label: t('features.timesheets.approvals.sortHours'), value: 'totalMinutes' },
48
+ { label: t('features.timesheets.approvals.sortStatus'), value: 'status' }
49
+ ])
50
+ const approvalData = computed(() => (data.value ? { ...data.value, approvals: [...listing.items.value] } : null))
51
+ await listing.load()
52
+ useSeoMeta({ title: () => t('features.timesheets.admin.clientApprovals') })
53
+ </script>
54
+
55
+ <template>
56
+ <TimesheetsPageShell class="flex h-full min-h-0 flex-col gap-4 overflow-hidden">
57
+ <header class="flex shrink-0 items-center justify-between gap-3 border-b border-default pb-4 sm:items-end">
58
+ <div class="flex min-w-0 gap-3">
59
+ <UIcon name="i-lucide-stamp" class="mt-1 size-6 shrink-0 text-primary" />
60
+ <div class="min-w-0">
61
+ <h1 class="text-2xl font-semibold text-highlighted">
62
+ {{ t('features.timesheets.admin.clientApprovals') }}
63
+ </h1>
64
+ <p class="hidden text-sm text-muted sm:block">
65
+ {{ t('features.timesheets.admin.clientApprovalsSubtitle') }}
66
+ </p>
67
+ </div>
68
+ </div>
69
+ </header>
70
+ <TimesheetsAdminListToolbar
71
+ v-model:search="listing.search.value"
72
+ class="shrink-0"
73
+ :filters="filters"
74
+ :filter-values="listing.filters"
75
+ :sort-options="sortOptions"
76
+ :sort-by="listing.sortBy.value"
77
+ :sort-dir="listing.sortDir.value"
78
+ @filter="listing.setFilter"
79
+ @sort="listing.sortBy.value = $event"
80
+ @toggle-direction="listing.toggleSortDir"
81
+ />
82
+ <TimesheetsAdminPaginatedList
83
+ class="min-h-0 flex-1"
84
+ :pagination="listing.pagination.value"
85
+ :pending="listing.pending.value"
86
+ :loading-next="listing.loadingNextPage.value"
87
+ :loading-previous="listing.loadingPreviousPage.value"
88
+ :has-next="listing.hasNextPage.value"
89
+ :has-previous="listing.hasPreviousPage.value"
90
+ @next="listing.loadNext"
91
+ @previous="listing.loadPrevious"
92
+ @page="listing.goToPage"
93
+ >
94
+ <UAlert v-if="error || listing.error.value" color="error" :title="t('features.timesheets.dashboard.error')" />
95
+ <div v-else-if="listing.pending.value && !listing.items.value.length" class="space-y-3">
96
+ <USkeleton v-for="index in 3" :key="index" class="h-32 w-full" />
97
+ </div>
98
+ <UAlert
99
+ v-else-if="!listing.items.value.length"
100
+ icon="i-lucide-search-x"
101
+ :title="t('features.timesheets.approvals.emptyFiltered')"
102
+ :description="t('features.timesheets.approvals.emptyFilteredDescription')"
103
+ variant="outline"
104
+ />
105
+ <TimesheetsAdminApprovals v-else-if="approvalData" client-mode :data="approvalData" :refresh="listing.refresh" />
106
+ </TimesheetsAdminPaginatedList>
107
+ </TimesheetsPageShell>
108
+ </template>
@@ -0,0 +1,17 @@
1
+ <script setup lang="ts">
2
+ const capabilities = await $fetch<{ canAccessApprovals: boolean; canEnterTime: boolean; canViewSupplierTime: boolean }>(
3
+ '/api/timesheets/capabilities'
4
+ )
5
+ const destination = capabilities.canAccessApprovals
6
+ ? '/timesheets/approvals'
7
+ : capabilities.canEnterTime
8
+ ? '/timesheets'
9
+ : capabilities.canViewSupplierTime
10
+ ? '/timesheets/suppliers'
11
+ : '/dashboard'
12
+ await navigateTo(destination, { replace: true })
13
+ </script>
14
+
15
+ <template>
16
+ <div />
17
+ </template>