@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,89 @@
1
+ <script setup lang="ts">
2
+ import type { SubmissionHistoryEventDto } from '@nuxt-customer-portal/timesheets/shared/types/timesheet'
3
+
4
+ defineProps<{ events: readonly Readonly<SubmissionHistoryEventDto>[] }>()
5
+ const { t, locale } = useI18n()
6
+ const userStore = useUserStore()
7
+ const isOwnEvent = (event: Readonly<SubmissionHistoryEventDto>) =>
8
+ !!event.actorUserId && event.actorUserId === userStore.currentUser?.id
9
+ const formatHistoryDate = (date: string) =>
10
+ new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(date))
11
+ </script>
12
+
13
+ <template>
14
+ <section v-if="events?.length" class="border-t border-default pt-3">
15
+ <ol
16
+ class="relative w-full space-y-4 py-2 text-sm before:absolute before:inset-y-0 before:left-2 before:w-px before:bg-accented sm:before:left-1/2"
17
+ >
18
+ <li
19
+ v-for="event in events"
20
+ :key="event.id"
21
+ class="relative grid grid-cols-[1rem_minmax(0,1fr)] gap-x-4 sm:grid-cols-[minmax(0,1fr)_1rem_minmax(0,1fr)]"
22
+ >
23
+ <span
24
+ class="z-10 col-start-1 row-start-1 mt-4 size-4 rounded-full border-4 border-default sm:col-start-2"
25
+ :class="
26
+ ['APPROVED', 'AUTO_APPROVED'].includes(event.action)
27
+ ? 'bg-success'
28
+ : ['REJECTED', 'DISPUTED'].includes(event.action)
29
+ ? 'bg-error'
30
+ : ['SUBMITTED', 'CLIENT_SUBMITTED'].includes(event.action)
31
+ ? 'bg-primary'
32
+ : 'bg-warning'
33
+ "
34
+ aria-hidden="true"
35
+ />
36
+ <div
37
+ class="col-start-2 row-start-1 rounded-lg border p-3"
38
+ :class="
39
+ isOwnEvent(event)
40
+ ? 'border-primary/25 bg-primary/10 sm:col-start-3'
41
+ : 'border-default bg-elevated sm:col-start-1 sm:text-right'
42
+ "
43
+ >
44
+ <div
45
+ class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs"
46
+ :class="!isOwnEvent(event) ? 'sm:justify-end' : ''"
47
+ >
48
+ <p
49
+ class="shrink-0 font-medium"
50
+ :class="
51
+ ['APPROVED', 'AUTO_APPROVED'].includes(event.action)
52
+ ? 'text-success'
53
+ : ['REJECTED', 'DISPUTED'].includes(event.action)
54
+ ? 'text-error'
55
+ : 'text-default'
56
+ "
57
+ >
58
+ {{ t(`features.timesheets.submissions.events.${event.action}`) }}
59
+ </p>
60
+ <div class="flex items-center gap-1.5 text-default">
61
+ <UAvatar :src="event.actorImage ?? undefined" :alt="event.actorName ?? undefined" size="xs" />
62
+ <span
63
+ >{{ event.actorName }}<template v-if="event.clientName"> · {{ event.clientName }}</template></span
64
+ >
65
+ </div>
66
+ <time :datetime="event.createdAt" class="shrink-0 text-xs text-muted">{{
67
+ formatHistoryDate(event.createdAt)
68
+ }}</time>
69
+ </div>
70
+ <blockquote
71
+ v-if="event.comment"
72
+ class="mt-3 rounded-r-md border-l-2 bg-default/50 px-3 py-2 text-left italic text-default"
73
+ :class="
74
+ ['APPROVED', 'AUTO_APPROVED'].includes(event.action)
75
+ ? 'border-success/50'
76
+ : ['REJECTED', 'DISPUTED'].includes(event.action)
77
+ ? 'border-error/50'
78
+ : ['SUBMITTED', 'CLIENT_SUBMITTED'].includes(event.action)
79
+ ? 'border-primary/50'
80
+ : 'border-warning/50'
81
+ "
82
+ >
83
+ <p class="whitespace-pre-line break-words leading-relaxed">{{ event.comment }}</p>
84
+ </blockquote>
85
+ </div>
86
+ </li>
87
+ </ol>
88
+ </section>
89
+ </template>
@@ -0,0 +1,80 @@
1
+ <script setup lang="ts">
2
+ import { z } from 'zod'
3
+ import type { TimesheetsAdminBootstrap } from '@nuxt-customer-portal/timesheets/app/composables/useTimesheets'
4
+
5
+ const props = defineProps<{
6
+ settings: TimesheetsAdminBootstrap['settings']
7
+ refresh: () => Promise<unknown>
8
+ }>()
9
+
10
+ const { t } = useI18n()
11
+ const toast = useToast()
12
+ const timesheets = useTimesheets()
13
+ const busy = ref(false)
14
+ const draft = reactive({
15
+ timerRoundingMinutes: 1,
16
+ currency: 'EUR',
17
+ timezone: 'Europe/Amsterdam'
18
+ })
19
+ const timezoneOptions = computed(() =>
20
+ [...new Set(['UTC', ...Intl.supportedValuesOf('timeZone'), draft.timezone])].sort()
21
+ )
22
+ const schema = computed(() =>
23
+ z.object({
24
+ timerRoundingMinutes: z.number().int().min(1).max(60),
25
+ currency: z.string().trim().length(3, t('features.timesheets.validation.currencyLength')),
26
+ timezone: z.string().trim().min(3, t('features.timesheets.validation.required')).max(100)
27
+ })
28
+ )
29
+
30
+ watch(
31
+ () => props.settings,
32
+ (settings) => {
33
+ draft.timerRoundingMinutes = settings.timerRoundingMinutes ?? 1
34
+ draft.currency = settings.currency
35
+ draft.timezone = settings.timezone
36
+ },
37
+ { immediate: true }
38
+ )
39
+
40
+ const save = async () => {
41
+ busy.value = true
42
+ try {
43
+ await timesheets.updateSettings(draft)
44
+ await props.refresh()
45
+ toast.add({ title: t('features.timesheets.messages.settingsSaved'), color: 'success' })
46
+ } catch (error) {
47
+ toast.add({
48
+ title: t('features.timesheets.messages.saveError'),
49
+ description: String(error),
50
+ color: 'error'
51
+ })
52
+ } finally {
53
+ busy.value = false
54
+ }
55
+ }
56
+ </script>
57
+
58
+ <template>
59
+ <UCard>
60
+ <UForm novalidate :state="draft" :schema="schema" class="max-w-xl space-y-4" @submit="save">
61
+ <UFormField name="currency" :label="t('features.timesheets.admin.currency')" required>
62
+ <UInput v-model="draft.currency" maxlength="3" class="w-full uppercase sm:w-28" />
63
+ </UFormField>
64
+ <UFormField name="timezone" :label="t('features.timesheets.admin.timezone')" required>
65
+ <USelectMenu v-model="draft.timezone" :items="timezoneOptions" class="w-full sm:w-80" />
66
+ </UFormField>
67
+ <UFormField
68
+ name="timerRoundingMinutes"
69
+ :label="t('features.timesheets.admin.timerRounding')"
70
+ :description="t('features.timesheets.admin.timerRoundingHelp')"
71
+ required
72
+ >
73
+ <UInput v-model.number="draft.timerRoundingMinutes" type="number" :min="1" :max="60" class="w-full sm:w-28" />
74
+ </UFormField>
75
+ <UButton type="submit" class="w-full justify-center sm:w-auto" icon="i-lucide-save" :loading="busy">
76
+ {{ t('features.timesheets.save') }}
77
+ </UButton>
78
+ </UForm>
79
+ </UCard>
80
+ </template>
@@ -0,0 +1,69 @@
1
+ export const useTimesheetMenu = () => {
2
+ const { activeOrganizationRole, isSystemAdmin } = usePortalSession()
3
+ const { t } = useI18n()
4
+ const route = useRoute()
5
+
6
+ const isOrganizationAdmin = computed(() => {
7
+ const role = activeOrganizationRole.value
8
+ return isSystemAdmin.value || role === 'owner' || role === 'admin'
9
+ })
10
+
11
+ const menuItems = computed(() => {
12
+ const items = [
13
+ {
14
+ label: t('features.timesheets.navigation.myTimesheet'),
15
+ to: '/timesheets',
16
+ icon: 'i-lucide-clock-3',
17
+ active: route.path === '/timesheets'
18
+ }
19
+ ]
20
+
21
+ if (isOrganizationAdmin.value) {
22
+ items.push(
23
+ {
24
+ label: t('features.timesheets.internalApprovals.manageTitle'),
25
+ to: '/admin/timesheets/internal-approvals',
26
+ icon: 'i-lucide-user-round-check',
27
+ active: route.path === '/admin/timesheets/internal-approvals'
28
+ },
29
+ {
30
+ label: t('features.timesheets.admin.projects'),
31
+ to: '/admin/timesheets/projects',
32
+ icon: 'i-lucide-folder-kanban',
33
+ active: route.path === '/admin/timesheets/projects'
34
+ },
35
+ {
36
+ label: t('features.timesheets.admin.activities'),
37
+ to: '/admin/timesheets/activities',
38
+ icon: 'i-lucide-tags',
39
+ active: route.path === '/admin/timesheets/activities'
40
+ },
41
+ {
42
+ label: t('features.timesheets.admin.teamRates'),
43
+ to: '/admin/timesheets/rates',
44
+ icon: 'i-lucide-badge-euro',
45
+ active: route.path === '/admin/timesheets/rates'
46
+ },
47
+ {
48
+ label: t('features.timesheets.admin.workspaceSettings'),
49
+ to: '/admin/timesheets/settings',
50
+ icon: 'i-lucide-settings-2',
51
+ active: route.path === '/admin/timesheets/settings'
52
+ },
53
+ {
54
+ label: t('features.timesheets.admin.reports'),
55
+ to: '/admin/timesheets/reports',
56
+ icon: 'i-lucide-chart-no-axes-combined',
57
+ active: route.path === '/admin/timesheets/reports'
58
+ }
59
+ )
60
+ }
61
+
62
+ return items
63
+ })
64
+
65
+ return {
66
+ menuItems: readonly(menuItems),
67
+ isOrganizationAdmin: readonly(isOrganizationAdmin)
68
+ }
69
+ }
@@ -0,0 +1,35 @@
1
+ import { TIMESHEET_ERROR_CODES } from '@nuxt-customer-portal/timesheets/shared/timesheet-errors'
2
+
3
+ type ApiErrorShape = {
4
+ data?: { code?: string; data?: { code?: string } }
5
+ message?: string
6
+ }
7
+
8
+ export const useTimesheetMutationError = () => {
9
+ const { t } = useI18n()
10
+ const toast = useToast()
11
+
12
+ const codeFor = (error: unknown) => {
13
+ const value = error as ApiErrorShape
14
+ return value?.data?.code ?? value?.data?.data?.code
15
+ }
16
+
17
+ const show = (error: unknown, titleKey = 'features.timesheets.messages.saveError') => {
18
+ const code = codeFor(error)
19
+ const descriptionKey =
20
+ code === 'TIMESHEET_PERIOD_LOCKED'
21
+ ? 'features.timesheets.validation.periodLocked'
22
+ : code === TIMESHEET_ERROR_CODES.tariffRequired
23
+ ? 'features.timesheets.errors.tariffRequired'
24
+ : code === TIMESHEET_ERROR_CODES.entryDisabled
25
+ ? 'features.timesheets.errors.entryDisabled'
26
+ : code === TIMESHEET_ERROR_CODES.runningTimer
27
+ ? 'features.timesheets.errors.runningTimer'
28
+ : code === TIMESHEET_ERROR_CODES.internalApproverRequired
29
+ ? 'features.timesheets.errors.internalApproverRequired'
30
+ : 'features.timesheets.errors.generic'
31
+ toast.add({ title: t(titleKey), description: t(descriptionKey), color: 'error' })
32
+ }
33
+
34
+ return { codeFor, show }
35
+ }
@@ -0,0 +1,248 @@
1
+ import type {
2
+ ActivityTypeDto,
3
+ ApprovalQueueItemDto,
4
+ ClientOrganizationOptionDto,
5
+ ClientDto,
6
+ ProjectDto,
7
+ TeamMemberDto,
8
+ TimesheetBootstrapDto,
9
+ TimesheetReportDto,
10
+ TimeEntryDto,
11
+ ClientAccessMode,
12
+ ClientTimesheetsDto,
13
+ ClientWorkspaceDto,
14
+ ClientReviewerDto,
15
+ ClientReviewerSupplierDto,
16
+ ClientApprovalSupplierOptionDto,
17
+ TimesheetsDashboardDto,
18
+ TimesheetsSetupStatusDto,
19
+ InternalApprovalConfigurationDto,
20
+ InternalApprovalQueueDto
21
+ } from '@nuxt-customer-portal/timesheets/shared/types/timesheet'
22
+
23
+ export interface TimesheetsAdminBootstrap {
24
+ providerOrganization: { organizationId: string; name: string }
25
+ settings: {
26
+ timerRoundingMinutes?: number
27
+ currency: string
28
+ timezone: string
29
+ weekStartsOn: number
30
+ internalApprovalsEnabled: boolean
31
+ }
32
+ clients: ClientDto[]
33
+ availableClientOrganizations: ClientOrganizationOptionDto[]
34
+ projects: ProjectDto[]
35
+ activities: ActivityTypeDto[]
36
+ team: TeamMemberDto[]
37
+ approvals: ApprovalQueueItemDto[]
38
+ setupStatus: TimesheetsSetupStatusDto
39
+ }
40
+ export interface OrganizationTimesheetCapabilities {
41
+ workspaceEnabled: boolean
42
+ clientOf: Array<{ workspaceOrganizationId: string; workspaceName: string; accessMode: ClientAccessMode }>
43
+ }
44
+
45
+ export const useTimesheets = () => {
46
+ const refreshApprovalState = async () => {
47
+ if (import.meta.client) {
48
+ window.dispatchEvent(new Event('timesheets:capabilities-refresh'))
49
+ }
50
+ // Invalidate cached dashboard data even when its widgets are not mounted.
51
+ clearNuxtData('timesheets-dashboard')
52
+ await refreshNuxtData('timesheets-dashboard')
53
+ }
54
+ const bootstrap = (week?: string) => $fetch<TimesheetBootstrapDto>('/api/timesheets/bootstrap', { query: { week } })
55
+ const dashboard = () => $fetch<TimesheetsDashboardDto>('/api/timesheets/dashboard')
56
+ const internalApprovalQueue = (contextOnly = false) =>
57
+ $fetch<InternalApprovalQueueDto>('/api/timesheets/internal-approvals', { query: { contextOnly } })
58
+ const internalApprovalConfiguration = () =>
59
+ $fetch<InternalApprovalConfigurationDto>('/api/timesheets/admin/internal-approvals')
60
+ const updateInternalApprovalWorkspace = (enabled: boolean) =>
61
+ $fetch('/api/timesheets/admin/internal-approvals', { method: 'PATCH', body: { enabled } })
62
+ const updateInternalApprovalMember = (userId: string, input: { required: boolean; approverUserIds: string[] }) =>
63
+ $fetch(`/api/timesheets/admin/internal-approvals/${userId}`, { method: 'PUT', body: input })
64
+ const setupStatus = () => $fetch<TimesheetsSetupStatusDto>('/api/timesheets/setup-status')
65
+
66
+ const createEntry = (input: {
67
+ projectId: string
68
+ activityTypeId: string
69
+ entryDate: string
70
+ durationMinutes: number
71
+ note?: string | null
72
+ }) => $fetch<TimeEntryDto>('/api/timesheets/entries', { method: 'POST', body: input })
73
+
74
+ const updateEntry = (
75
+ id: string,
76
+ input: Partial<{
77
+ projectId: string
78
+ activityTypeId: string
79
+ entryDate: string
80
+ durationMinutes: number
81
+ note: string | null
82
+ }>
83
+ ) => $fetch<TimeEntryDto>(`/api/timesheets/entries/${id}`, { method: 'PATCH', body: input })
84
+
85
+ const deleteEntry = (id: string) => $fetch(`/api/timesheets/entries/${id}`, { method: 'DELETE' as never })
86
+
87
+ const startTimer = (input: { projectId: string; activityTypeId: string; entryDate: string; note?: string | null }) =>
88
+ $fetch<TimeEntryDto>('/api/timesheets/timer', { method: 'POST', body: input })
89
+
90
+ const stopTimer = () => $fetch<TimeEntryDto>('/api/timesheets/timer', { method: 'DELETE' as never })
91
+
92
+ const submitWeek = (id: string, cutoffDate: string, comment?: string) =>
93
+ $fetch(`/api/timesheets/weeks/${id}/submissions`, { method: 'POST', body: { cutoffDate, comment } })
94
+ const replyClientSubmission = (id: string, clientOrganizationId: string, expectedVersion: number, reply?: string) =>
95
+ $fetch(`/api/timesheets/submissions/${id}/client-reply`, {
96
+ method: 'POST',
97
+ body: { clientOrganizationId, expectedVersion, reply }
98
+ })
99
+ const resubmitSubmission = (id: string, reply?: string) =>
100
+ $fetch(`/api/timesheets/submissions/${id}/resubmit`, { method: 'POST', body: { reply } })
101
+
102
+ const adminBootstrap = (section?: string) =>
103
+ $fetch<TimesheetsAdminBootstrap>('/api/timesheets/admin/bootstrap', { query: section ? { section } : undefined })
104
+ const createClient = (
105
+ input: { mode: 'link'; organizationId: string } | { mode: 'create'; name: string; slug: string }
106
+ ) => $fetch('/api/timesheets/admin/clients', { method: 'POST', body: input })
107
+ const getClientDeletionEligibility = (id: string) =>
108
+ $fetch<{ clientId: string; clientName: string; canDelete: boolean }>(`/api/timesheets/admin/clients/${id}/deletion`)
109
+ const deleteClient = (id: string, clientName: string) =>
110
+ $fetch(`/api/timesheets/admin/clients/${id}`, {
111
+ method: 'DELETE' as never,
112
+ body: { clientName }
113
+ })
114
+ const updateClientAccess = (id: string, accessMode: ClientAccessMode) =>
115
+ $fetch(`/api/timesheets/admin/clients/${id}`, { method: 'PATCH', body: { accessMode } })
116
+ const getOrganizationTimesheetCapabilities = (organizationId: string) =>
117
+ $fetch<OrganizationTimesheetCapabilities>(`/api/timesheets/admin/organization-capabilities/${organizationId}`)
118
+ const updateOrganizationTimesheetCapabilities = (organizationId: string, input: { workspaceEnabled: boolean }) =>
119
+ $fetch(`/api/timesheets/admin/organization-capabilities/${organizationId}`, { method: 'PATCH', body: input })
120
+ const clientWorkspaces = () => $fetch<ClientWorkspaceDto[]>('/api/timesheets/client/workspaces')
121
+ const clientApprovalMembers = () =>
122
+ $fetch<Array<{ id: string; name: string }>>('/api/timesheets/client/approval-members')
123
+ const clientApprovalSuppliers = () =>
124
+ $fetch<ClientApprovalSupplierOptionDto[]>('/api/timesheets/client/approval-suppliers')
125
+ const clientSupplierOptions = () =>
126
+ $fetch<ClientApprovalSupplierOptionDto[]>('/api/timesheets/client/supplier-options')
127
+ const clientReviewerSuppliers = () => $fetch<ClientReviewerSupplierDto[]>('/api/timesheets/client/reviewer-suppliers')
128
+ const clientTimesheets = (workspaceClientId: string) =>
129
+ $fetch<ClientTimesheetsDto>(`/api/timesheets/client/${workspaceClientId}`)
130
+ const reviewClientSlice = async (
131
+ workspaceClientId: string,
132
+ submissionId: string,
133
+ input: { action: 'APPROVE' | 'DISPUTE'; expectedVersion: number; comment?: string | null }
134
+ ) => {
135
+ const result = await $fetch(`/api/timesheets/client/${workspaceClientId}/reviews/${submissionId}`, {
136
+ method: 'POST',
137
+ body: input
138
+ })
139
+ await refreshApprovalState()
140
+ return result
141
+ }
142
+ const clientReviewers = (workspaceClientId: string) =>
143
+ $fetch<ClientReviewerDto[]>(`/api/timesheets/client/${workspaceClientId}/reviewers`)
144
+ const setClientReviewer = (workspaceClientId: string, userId: string, assigned: boolean) =>
145
+ $fetch(`/api/timesheets/client/${workspaceClientId}/reviewers`, { method: 'PUT', body: { userId, assigned } })
146
+ const createActivity = (input: { name: string; billable: boolean }) =>
147
+ $fetch('/api/timesheets/admin/activities', { method: 'POST', body: input })
148
+ const updateActivity = (id: string, input: Partial<{ name: string; billable: boolean; active: boolean }>) =>
149
+ $fetch(`/api/timesheets/admin/activities/${id}`, { method: 'PATCH', body: input })
150
+ const getActivityDeletionEligibility = (id: string) =>
151
+ $fetch<{ activityId: string; activityName: string; canDelete: boolean }>(
152
+ `/api/timesheets/admin/activities/${id}/deletion`
153
+ )
154
+ const deleteActivity = (id: string, activityName: string) =>
155
+ $fetch(`/api/timesheets/admin/activities/${id}`, {
156
+ method: 'DELETE' as never,
157
+ body: { activityName }
158
+ })
159
+ const createProject = (input: {
160
+ clientOrganizationId: string
161
+ name: string
162
+ code?: string | null
163
+ budgetMinutes?: number | null
164
+ budgetMinor?: number | null
165
+ activityTypeIds: string[]
166
+ }) => $fetch<{ id: string }>('/api/timesheets/admin/projects', { method: 'POST', body: input })
167
+ const getProject = (id: string) => $fetch<ProjectDto>(`/api/timesheets/admin/projects/${id}`)
168
+ const updateProject = (id: string, input: Record<string, unknown>) =>
169
+ $fetch(`/api/timesheets/admin/projects/${id}`, { method: 'PATCH', body: input })
170
+ const getProjectDeletionEligibility = (id: string) =>
171
+ $fetch<{ projectId: string; projectName: string; canDelete: boolean }>(
172
+ `/api/timesheets/admin/projects/${id}/deletion`
173
+ )
174
+ const deleteProject = (id: string, projectName: string) =>
175
+ $fetch(`/api/timesheets/admin/projects/${id}`, {
176
+ method: 'DELETE' as never,
177
+ body: { projectName }
178
+ })
179
+ const setTeamTariff = (userId: string, hourlyRateMinor: number) =>
180
+ $fetch('/api/timesheets/admin/tariffs', {
181
+ method: 'PUT',
182
+ body: { userId, hourlyRateMinor }
183
+ })
184
+ const updateTeamMemberSettings = (
185
+ userId: string,
186
+ input: { canEnterTime: boolean; defaultHourlyRateMinor: number | null }
187
+ ) => $fetch(`/api/timesheets/admin/team/${userId}`, { method: 'PUT', body: input })
188
+ const updateSettings = (input: { currency?: string; timezone?: string; timerRoundingMinutes?: number }) =>
189
+ $fetch('/api/timesheets/admin/settings', { method: 'PATCH', body: input })
190
+ const reviewSubmission = async (id: string, action: 'APPROVE' | 'REJECT' | 'REOPEN', comment?: string | null) => {
191
+ const result = await $fetch(`/api/timesheets/internal-approvals/${id}`, {
192
+ method: 'POST',
193
+ body: { action, comment }
194
+ })
195
+ await refreshApprovalState()
196
+ return result
197
+ }
198
+ const getReport = (query: Record<string, string | undefined>) =>
199
+ $fetch<TimesheetReportDto>('/api/timesheets/admin/report', { query })
200
+
201
+ return {
202
+ bootstrap,
203
+ dashboard,
204
+ internalApprovalQueue,
205
+ internalApprovalConfiguration,
206
+ updateInternalApprovalWorkspace,
207
+ updateInternalApprovalMember,
208
+ setupStatus,
209
+ createEntry,
210
+ updateEntry,
211
+ deleteEntry,
212
+ startTimer,
213
+ stopTimer,
214
+ submitWeek,
215
+ resubmitSubmission,
216
+ replyClientSubmission,
217
+ adminBootstrap,
218
+ createClient,
219
+ getClientDeletionEligibility,
220
+ deleteClient,
221
+ updateClientAccess,
222
+ getOrganizationTimesheetCapabilities,
223
+ updateOrganizationTimesheetCapabilities,
224
+ clientWorkspaces,
225
+ clientApprovalSuppliers,
226
+ clientApprovalMembers,
227
+ clientSupplierOptions,
228
+ clientReviewerSuppliers,
229
+ clientTimesheets,
230
+ reviewClientSlice,
231
+ clientReviewers,
232
+ setClientReviewer,
233
+ createActivity,
234
+ updateActivity,
235
+ getActivityDeletionEligibility,
236
+ deleteActivity,
237
+ createProject,
238
+ getProject,
239
+ updateProject,
240
+ getProjectDeletionEligibility,
241
+ deleteProject,
242
+ setTeamTariff,
243
+ updateTeamMemberSettings,
244
+ updateSettings,
245
+ reviewSubmission,
246
+ getReport
247
+ }
248
+ }
@@ -0,0 +1,165 @@
1
+ import type { TimesheetsListResponse } from '@nuxt-customer-portal/timesheets/shared/types/timesheet'
2
+ import { usePaginatedResource } from '@nuxt-customer-portal/core/app/composables/usePaginatedResource'
3
+
4
+ export const useTimesheetsAdminList = <Item>(options: {
5
+ endpoint: string
6
+ filterKeys?: string[]
7
+ defaultFilters?: Record<string, string>
8
+ defaultSort: string
9
+ defaultSortDir?: 'asc' | 'desc'
10
+ }) => {
11
+ const route = useRoute()
12
+ const router = useRouter()
13
+ const pageSize = 20
14
+ const defaultSortDir = options.defaultSortDir ?? 'asc'
15
+ const search = ref(String(route.query.search ?? ''))
16
+ const sortBy = ref(String(route.query.sortBy ?? options.defaultSort))
17
+ const sortDir = ref<'asc' | 'desc'>(
18
+ route.query.sortDir === 'asc' || route.query.sortDir === 'desc' ? route.query.sortDir : defaultSortDir
19
+ )
20
+ const currentPage = ref(Math.max(1, Number(route.query.page) || 1))
21
+ const filters = reactive<Record<string, string | undefined>>(
22
+ Object.fromEntries(
23
+ (options.filterKeys ?? []).map((key) => [
24
+ key,
25
+ route.query[key] ? String(route.query[key]) : options.defaultFilters?.[key]
26
+ ])
27
+ )
28
+ )
29
+ const resource = usePaginatedResource<Item, Record<string, string | undefined>>({
30
+ pageSize,
31
+ getKey: (item) => (item as { id: string }).id,
32
+ fetchPage: ({ filters: query, page, pageSize, signal }) =>
33
+ $fetch<TimesheetsListResponse<Item>>(options.endpoint, { query: { ...query, page, pageSize }, signal })
34
+ })
35
+ const query = () => ({
36
+ ...(search.value.trim() ? { search: search.value.trim() } : {}),
37
+ ...Object.fromEntries(Object.entries(filters).filter(([, value]) => value)),
38
+ ...(sortBy.value !== options.defaultSort ? { sortBy: sortBy.value } : {}),
39
+ ...(sortDir.value !== defaultSortDir ? { sortDir: sortDir.value } : {})
40
+ })
41
+ const routeQuery = () => ({
42
+ ...(search.value.trim() ? { search: search.value.trim() } : {}),
43
+ ...Object.fromEntries(
44
+ Object.entries(filters).filter(([key, value]) => value && value !== options.defaultFilters?.[key])
45
+ ),
46
+ ...(sortBy.value !== options.defaultSort ? { sortBy: sortBy.value } : {}),
47
+ ...(sortDir.value !== defaultSortDir ? { sortDir: sortDir.value } : {})
48
+ })
49
+ const syncRoute = (page = currentPage.value) =>
50
+ router.replace({ path: route.path, query: { ...routeQuery(), ...(page > 1 ? { page: String(page) } : {}) } })
51
+ const load = async (page = currentPage.value) => {
52
+ const result = await resource.loadPage(query(), { page, pageSize })
53
+ if (result) {
54
+ currentPage.value = result.pagination.page
55
+ }
56
+ return result
57
+ }
58
+ const resetAndLoad = async () => {
59
+ currentPage.value = 1
60
+ await syncRoute(1)
61
+ await load(1)
62
+ }
63
+ const loadNext = async () => {
64
+ const result = await resource.loadNextPage(query())
65
+ if (result) {
66
+ currentPage.value = result.pagination.page
67
+ await syncRoute(currentPage.value)
68
+ }
69
+ }
70
+ const loadPrevious = async () => {
71
+ const result = await resource.loadPreviousPage(query())
72
+ if (result) {
73
+ currentPage.value = result.pagination.page
74
+ await syncRoute(currentPage.value)
75
+ }
76
+ return result
77
+ }
78
+ const goToPage = async (page: number) => {
79
+ currentPage.value = page
80
+ await syncRoute(page)
81
+ await load(page)
82
+ }
83
+ const setFilter = (key: string, value: string | undefined) => {
84
+ filters[key] = value
85
+ }
86
+ const toggleSortDir = () => {
87
+ sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
88
+ }
89
+ let searchTimer: ReturnType<typeof setTimeout> | undefined
90
+ let applyingRoute = false
91
+ watch(
92
+ search,
93
+ () => {
94
+ if (applyingRoute) {
95
+ return
96
+ }
97
+ clearTimeout(searchTimer)
98
+ searchTimer = setTimeout(() => {
99
+ void resetAndLoad()
100
+ }, 300)
101
+ },
102
+ { flush: 'sync' }
103
+ )
104
+ watch(
105
+ [sortBy, sortDir, ...Object.keys(filters).map((key) => () => filters[key])],
106
+ () => {
107
+ if (applyingRoute) {
108
+ return
109
+ }
110
+ clearTimeout(searchTimer)
111
+ void resetAndLoad()
112
+ },
113
+ { flush: 'sync' }
114
+ )
115
+ watch(
116
+ () => route.query,
117
+ async (routeQuery) => {
118
+ const nextSearch = String(routeQuery.search ?? '')
119
+ const nextSort = String(routeQuery.sortBy ?? options.defaultSort)
120
+ const nextDir =
121
+ routeQuery.sortDir === 'asc' || routeQuery.sortDir === 'desc' ? routeQuery.sortDir : defaultSortDir
122
+ const nextPage = Math.max(1, Number(routeQuery.page) || 1)
123
+ const nextFilters = Object.fromEntries(
124
+ (options.filterKeys ?? []).map((key) => [
125
+ key,
126
+ routeQuery[key] ? String(routeQuery[key]) : options.defaultFilters?.[key]
127
+ ])
128
+ )
129
+ if (
130
+ nextSearch === search.value.trim() &&
131
+ nextSort === sortBy.value &&
132
+ nextDir === sortDir.value &&
133
+ nextPage === currentPage.value &&
134
+ Object.keys(nextFilters).every((key) => nextFilters[key] === filters[key])
135
+ ) {
136
+ return
137
+ }
138
+ clearTimeout(searchTimer)
139
+ applyingRoute = true
140
+ search.value = nextSearch
141
+ sortBy.value = nextSort
142
+ sortDir.value = nextDir
143
+ currentPage.value = nextPage
144
+ Object.assign(filters, nextFilters)
145
+ applyingRoute = false
146
+ await load(nextPage)
147
+ }
148
+ )
149
+ onScopeDispose(() => clearTimeout(searchTimer))
150
+ return {
151
+ ...resource,
152
+ search,
153
+ filters,
154
+ sortBy,
155
+ sortDir,
156
+ currentPage,
157
+ load,
158
+ refresh: load,
159
+ loadNext,
160
+ loadPrevious,
161
+ goToPage,
162
+ setFilter,
163
+ toggleSortDir
164
+ }
165
+ }