@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,234 @@
1
+ <script setup lang="ts">
2
+ import type { ComponentPublicInstance } from 'vue'
3
+ import type { TimesheetsAdminBootstrap } from '@nuxt-customer-portal/timesheets/app/composables/useTimesheets'
4
+ import type { ProjectDto } from '@nuxt-customer-portal/timesheets/shared/types/timesheet'
5
+
6
+ const props = defineProps<{ data: TimesheetsAdminBootstrap; refresh: () => Promise<unknown> }>()
7
+ const route = useRoute()
8
+ const { t } = useI18n()
9
+ const toast = useToast()
10
+ const timesheets = useTimesheets()
11
+ const busy = ref(false)
12
+ const formOpen = ref(false)
13
+ const projectFormRef = ref<ComponentPublicInstance | null>(null)
14
+ const listScrollTop = ref(Math.max(0, Number(route.query.scroll) || 0))
15
+ const form = reactive({
16
+ clientOrganizationId: '',
17
+ name: '',
18
+ code: '',
19
+ budgetHours: null as number | null,
20
+ budgetAmount: null as number | null,
21
+ activityTypeIds: [] as string[]
22
+ })
23
+ const listing = useTimesheetsAdminList<ProjectDto>({
24
+ endpoint: '/api/timesheets/admin/projects',
25
+ filterKeys: ['clientOrganizationId', 'status'],
26
+ defaultFilters: { status: 'ACTIVE' },
27
+ defaultSort: 'name'
28
+ })
29
+ const activeProjects = listing.items
30
+ const internalOrganizationLabel = computed(() =>
31
+ t('features.timesheets.admin.internalOrganization', { name: props.data.providerOrganization.name })
32
+ )
33
+ const projectClientLabel = (project: Pick<ProjectDto, 'internal' | 'clientName'>) =>
34
+ project.internal
35
+ ? t('features.timesheets.admin.internalOrganization', { name: project.clientName })
36
+ : project.clientName
37
+ const projectFilters = computed(() => [
38
+ {
39
+ key: 'clientOrganizationId',
40
+ placeholder: t('features.timesheets.admin.list.clientFilter'),
41
+ items: [
42
+ { label: t('features.timesheets.admin.list.allClients'), value: undefined },
43
+ { label: internalOrganizationLabel.value, value: props.data.providerOrganization.organizationId },
44
+ ...props.data.clients.map((client) => ({ label: client.name, value: client.organizationId }))
45
+ ]
46
+ },
47
+ {
48
+ key: 'status',
49
+ placeholder: t('features.timesheets.admin.list.projectStatusFilter'),
50
+ items: [
51
+ { label: t('features.timesheets.admin.list.allProjectStatuses'), value: 'ALL' },
52
+ { label: t('features.timesheets.admin.projectStatus.active'), value: 'ACTIVE' },
53
+ { label: t('features.timesheets.admin.projectStatus.archived'), value: 'ARCHIVED' }
54
+ ]
55
+ }
56
+ ])
57
+ const sortOptions = computed(() => [
58
+ { label: t('features.timesheets.admin.list.sortName'), value: 'name' },
59
+ { label: t('features.timesheets.admin.list.sortClient'), value: 'clientName' },
60
+ { label: t('features.timesheets.admin.list.sortStartDate'), value: 'startsOn' }
61
+ ])
62
+ const formatHours = (minutes: number) => `${(minutes / 60).toFixed(2)} h`
63
+ const formatMoney = (minor: number) =>
64
+ new Intl.NumberFormat(undefined, { style: 'currency', currency: props.data.settings.currency }).format(minor / 100)
65
+ const statusColor = (status: ProjectDto['status']) => (status === 'ACTIVE' ? 'success' : 'neutral')
66
+ const statusLabel = (status: ProjectDto['status']) =>
67
+ t(`features.timesheets.admin.projectStatus.${status.toLowerCase()}`)
68
+
69
+ const returnPath = computed(() => {
70
+ const query = new URLSearchParams()
71
+ for (const [key, value] of Object.entries(route.query)) {
72
+ const selected = Array.isArray(value) ? value[0] : value
73
+ if (selected != null && key !== 'scroll') {
74
+ query.set(key, String(selected))
75
+ }
76
+ }
77
+ if (listScrollTop.value > 0) {
78
+ query.set('scroll', String(Math.round(listScrollTop.value)))
79
+ }
80
+ const suffix = query.toString()
81
+ return suffix ? `/admin/timesheets/projects?${suffix}` : '/admin/timesheets/projects'
82
+ })
83
+ const projectDetailTo = (project: { id: string }) => ({
84
+ path: `/admin/timesheets/projects/${project.id}`,
85
+ query: { returnTo: returnPath.value }
86
+ })
87
+ const resetForm = () => {
88
+ Object.assign(form, {
89
+ clientOrganizationId: '',
90
+ name: '',
91
+ code: '',
92
+ budgetHours: null,
93
+ budgetAmount: null,
94
+ activityTypeIds: []
95
+ })
96
+ formOpen.value = false
97
+ }
98
+ const openNew = async () => {
99
+ resetForm()
100
+ formOpen.value = true
101
+ await nextTick()
102
+ const formElement = projectFormRef.value?.$el as HTMLElement | undefined
103
+ formElement?.scrollIntoView({ behavior: 'smooth', block: 'start' })
104
+ }
105
+ const save = async () => {
106
+ busy.value = true
107
+ try {
108
+ const created = await timesheets.createProject({
109
+ clientOrganizationId: form.clientOrganizationId,
110
+ name: form.name,
111
+ code: form.code || null,
112
+ budgetMinutes: form.budgetHours ? Math.round(form.budgetHours * 60) : null,
113
+ budgetMinor: form.budgetAmount ? Math.round(form.budgetAmount * 100) : null,
114
+ activityTypeIds: form.activityTypeIds
115
+ })
116
+ resetForm()
117
+ await Promise.all([props.refresh(), listing.refresh()])
118
+ toast.add({ title: t('features.timesheets.messages.projectCreated'), color: 'success' })
119
+ await navigateTo({ path: `/admin/timesheets/projects/${created.id}`, query: { returnTo: returnPath.value } })
120
+ } catch (error) {
121
+ toast.add({ title: t('features.timesheets.messages.saveError'), description: String(error), color: 'error' })
122
+ } finally {
123
+ busy.value = false
124
+ }
125
+ }
126
+ const addActivity = async (input: { name: string; billable: boolean }) => {
127
+ busy.value = true
128
+ try {
129
+ await timesheets.createActivity(input)
130
+ await props.refresh()
131
+ return props.data.activities.find((activity) => activity.name === input.name)?.id
132
+ } finally {
133
+ busy.value = false
134
+ }
135
+ }
136
+ onKeyStroke('Escape', () => {
137
+ if (formOpen.value) {
138
+ resetForm()
139
+ }
140
+ })
141
+ defineExpose({
142
+ canCreate: computed(() => true),
143
+ openCreate: openNew,
144
+ refreshList: () => listing.refresh(),
145
+ showCreate: computed(() => Boolean(activeProjects.value.length) && !formOpen.value)
146
+ })
147
+ await listing.load()
148
+ </script>
149
+
150
+ <template>
151
+ <section class="flex h-full min-h-0 flex-col gap-5">
152
+ <TimesheetsAdminListToolbar
153
+ v-model:search="listing.search.value"
154
+ :filters="projectFilters"
155
+ :filter-values="listing.filters"
156
+ :sort-options="sortOptions"
157
+ :sort-by="listing.sortBy.value"
158
+ :sort-dir="listing.sortDir.value"
159
+ @filter="listing.setFilter"
160
+ @sort="listing.sortBy.value = $event"
161
+ @toggle-direction="listing.toggleSortDir"
162
+ />
163
+ <TimesheetsAdminPaginatedList
164
+ class="min-h-0 flex-1"
165
+ :pagination="listing.pagination.value"
166
+ :pending="listing.pending.value"
167
+ :loading-next="listing.loadingNextPage.value"
168
+ :loading-previous="listing.loadingPreviousPage.value"
169
+ :has-next="listing.hasNextPage.value"
170
+ :has-previous="listing.hasPreviousPage.value"
171
+ :initial-scroll-top="listScrollTop"
172
+ @scroll="listScrollTop = $event"
173
+ @next="listing.loadNext"
174
+ @previous="listing.loadPrevious"
175
+ @page="listing.goToPage"
176
+ >
177
+ <TimesheetsAdminEmptyState
178
+ v-if="!activeProjects.length && !formOpen && !listing.pending.value"
179
+ icon="i-lucide-folder-kanban"
180
+ :title="t('features.timesheets.admin.noProjectsTitle')"
181
+ :description="t('features.timesheets.admin.noProjectsDescription')"
182
+ :action-label="t('features.timesheets.admin.createFirstProject')"
183
+ @action="openNew"
184
+ />
185
+ <div class="grid gap-3">
186
+ <TimesheetsProjectForm
187
+ v-if="formOpen"
188
+ ref="projectFormRef"
189
+ v-model="form"
190
+ :data="data"
191
+ :editing="false"
192
+ :busy="busy"
193
+ :show-cancel="true"
194
+ :add-activity="addActivity"
195
+ @submit="save"
196
+ @cancel="resetForm"
197
+ />
198
+ <UCard
199
+ v-for="project in activeProjects"
200
+ :key="project.id"
201
+ class="transition-colors hover:ring-1 hover:ring-primary/50"
202
+ >
203
+ <div class="grid grid-cols-[minmax(0,1fr)_2.75rem] items-center gap-3">
204
+ <NuxtLink
205
+ :to="projectDetailTo(project)"
206
+ class="group min-w-0 rounded-lg focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary"
207
+ >
208
+ <div class="flex flex-wrap items-center gap-2">
209
+ <p class="font-semibold">{{ project.name }}</p>
210
+ <UBadge :color="statusColor(project.status)" variant="subtle">{{ statusLabel(project.status) }}</UBadge>
211
+ </div>
212
+ <p class="mt-1 text-sm text-muted">
213
+ {{ projectClientLabel(project) }}{{ project.code ? ` · ${project.code}` : '' }}
214
+ </p>
215
+ <div class="mt-3 flex flex-wrap gap-2 text-xs text-muted">
216
+ <span
217
+ >{{ project.activityTypeIds.length }}
218
+ {{ t('features.timesheets.admin.activities').toLowerCase() }}</span
219
+ ><span v-if="project.budgetMinutes">· {{ formatHours(project.budgetMinutes) }}</span
220
+ ><span v-if="project.budgetMinor">· {{ formatMoney(project.budgetMinor) }}</span>
221
+ </div>
222
+ </NuxtLink>
223
+ <NuxtLink
224
+ :to="projectDetailTo(project)"
225
+ :aria-label="t('features.timesheets.admin.openProject', { name: project.name })"
226
+ class="grid size-11 place-items-center justify-self-end rounded focus-visible:outline-2 focus-visible:outline-primary"
227
+ ><UIcon name="i-lucide-chevron-right" class="size-5 text-muted"
228
+ /></NuxtLink>
229
+ </div>
230
+ </UCard>
231
+ </div>
232
+ </TimesheetsAdminPaginatedList>
233
+ </section>
234
+ </template>
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ import type { TimesheetsAdminBootstrap } from '@nuxt-customer-portal/timesheets/app/composables/useTimesheets'
3
+
4
+ const props = defineProps<{ data: TimesheetsAdminBootstrap; refresh: () => Promise<unknown> }>()
5
+ const { t } = useI18n()
6
+ const toast = useToast()
7
+ const timesheets = useTimesheets()
8
+ const busy = ref(false)
9
+ const tariffDrafts = reactive<Record<string, number | undefined>>({})
10
+ const entryDrafts = reactive<Record<string, boolean>>({})
11
+
12
+ const run = async (operation: () => Promise<unknown>) => {
13
+ busy.value = true
14
+ try {
15
+ await operation()
16
+ await props.refresh()
17
+ } catch (error) {
18
+ toast.add({ title: t('features.timesheets.messages.saveError'), description: String(error), color: 'error' })
19
+ await props.refresh()
20
+ } finally {
21
+ busy.value = false
22
+ }
23
+ }
24
+
25
+ const saveMember = (userId: string) => {
26
+ const value = tariffDrafts[userId]
27
+ return run(async () => {
28
+ await timesheets.updateTeamMemberSettings(userId, {
29
+ canEnterTime: entryDrafts[userId] ?? true,
30
+ defaultHourlyRateMinor: value === null || value === undefined ? null : Math.round(value * 100)
31
+ })
32
+ if (import.meta.client) {
33
+ window.dispatchEvent(new Event('timesheets:capabilities-refresh'))
34
+ }
35
+ })
36
+ }
37
+ const updateEntryEligibility = (userId: string, canEnterTime: boolean) => {
38
+ entryDrafts[userId] = canEnterTime
39
+ return saveMember(userId)
40
+ }
41
+ watch(
42
+ () => props.data,
43
+ (data) => {
44
+ for (const member of data.team) {
45
+ tariffDrafts[member.id] = member.defaultHourlyRateMinor === null ? undefined : member.defaultHourlyRateMinor / 100
46
+ entryDrafts[member.id] = member.canEnterTime
47
+ }
48
+ },
49
+ { immediate: true }
50
+ )
51
+ </script>
52
+
53
+ <template>
54
+ <section class="space-y-3">
55
+ <UCard v-for="member in data.team" :key="member.id">
56
+ <div class="flex flex-col gap-3 sm:flex-row sm:items-center">
57
+ <UAvatar :src="member.image ?? undefined" :alt="member.name" />
58
+ <div class="min-w-0 flex-1">
59
+ <p class="truncate font-medium">{{ member.name }}</p>
60
+ <p class="truncate text-sm text-muted">{{ member.email }}</p>
61
+ </div>
62
+ <div class="flex flex-wrap items-end gap-4">
63
+ <UFormField :label="t('features.timesheets.admin.canEnterTime')">
64
+ <USwitch
65
+ :model-value="entryDrafts[member.id]"
66
+ :aria-label="t('features.timesheets.admin.canEnterTimeFor', { name: member.name })"
67
+ @update:model-value="updateEntryEligibility(member.id, $event)"
68
+ />
69
+ </UFormField>
70
+ <UFormField :label="`${data.settings.currency} / h`">
71
+ <UInput v-model.number="tariffDrafts[member.id]" type="number" min="0" step="0.01" class="w-32" />
72
+ </UFormField>
73
+ <UButton
74
+ icon="i-lucide-save"
75
+ :aria-label="t('features.timesheets.save')"
76
+ :loading="busy"
77
+ @click="saveMember(member.id)"
78
+ />
79
+ </div>
80
+ </div>
81
+ </UCard>
82
+ </section>
83
+ </template>
@@ -0,0 +1,123 @@
1
+ <script setup lang="ts">
2
+ import type { TimesheetStatus } from '@nuxt-customer-portal/timesheets/shared/types/timesheet'
3
+ import type { TimesheetsAdminBootstrap } from '@nuxt-customer-portal/timesheets/app/composables/useTimesheets'
4
+
5
+ const props = defineProps<{
6
+ data: TimesheetsAdminBootstrap
7
+ refresh?: () => Promise<unknown>
8
+ }>()
9
+ const { t } = useI18n()
10
+ const timesheets = useTimesheets()
11
+ const allFilterValue = '__all__'
12
+ const filters = reactive({
13
+ from: '',
14
+ to: '',
15
+ clientOrganizationId: allFilterValue,
16
+ projectId: allFilterValue,
17
+ userId: '',
18
+ activityTypeId: '',
19
+ status: '' as '' | TimesheetStatus
20
+ })
21
+ const report = ref<Awaited<ReturnType<typeof timesheets.getReport>> | null>(null)
22
+ const query = () => Object.fromEntries(Object.entries(filters).filter(([, value]) => value && value !== allFilterValue))
23
+ const loadReport = async () => {
24
+ report.value = await timesheets.getReport(query())
25
+ }
26
+ const downloadCsv = () => {
27
+ const params = new URLSearchParams(query())
28
+ params.set('format', 'csv')
29
+ window.location.assign(`/api/timesheets/admin/report?${params}`)
30
+ }
31
+ const formatHours = (minutes: number) => `${(minutes / 60).toFixed(2)} h`
32
+ const formatMoney = (minor: number) =>
33
+ new Intl.NumberFormat(undefined, { style: 'currency', currency: props.data.settings.currency }).format(minor / 100)
34
+ </script>
35
+
36
+ <template>
37
+ <section class="space-y-5">
38
+ <UCard>
39
+ <div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
40
+ <UFormField :label="t('features.timesheets.admin.from')">
41
+ <UInput v-model="filters.from" type="date" class="w-full" />
42
+ </UFormField>
43
+ <UFormField :label="t('features.timesheets.admin.to')">
44
+ <UInput v-model="filters.to" type="date" class="w-full" />
45
+ </UFormField>
46
+ <UFormField :label="t('features.timesheets.admin.client')">
47
+ <USelect
48
+ v-model="filters.clientOrganizationId"
49
+ :items="[
50
+ { label: t('features.timesheets.admin.all'), value: allFilterValue },
51
+ ...data.clients.map((item) => ({ label: item.name, value: item.organizationId }))
52
+ ]"
53
+ value-key="value"
54
+ class="w-full"
55
+ />
56
+ </UFormField>
57
+ <UFormField :label="t('features.timesheets.fields.project')">
58
+ <USelect
59
+ v-model="filters.projectId"
60
+ :items="[
61
+ { label: t('features.timesheets.admin.all'), value: allFilterValue },
62
+ ...data.projects.map((item) => ({ label: item.name, value: item.id }))
63
+ ]"
64
+ value-key="value"
65
+ class="w-full"
66
+ />
67
+ </UFormField>
68
+ </div>
69
+ <div class="mt-4 flex flex-wrap justify-end gap-2">
70
+ <UButton color="neutral" variant="outline" icon="i-lucide-download" @click="downloadCsv">
71
+ {{ t('features.timesheets.admin.exportCsv') }}
72
+ </UButton>
73
+ <UButton icon="i-lucide-search" @click="loadReport">{{ t('features.timesheets.admin.runReport') }}</UButton>
74
+ </div>
75
+ </UCard>
76
+ <div v-if="report" class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
77
+ <UCard>
78
+ <p class="text-sm text-muted">{{ t('features.timesheets.admin.totalHours') }}</p>
79
+ <p class="mt-1 text-2xl font-semibold">{{ formatHours(report.totals.minutes) }}</p>
80
+ </UCard>
81
+ <UCard>
82
+ <p class="text-sm text-muted">{{ t('features.timesheets.admin.billableHours') }}</p>
83
+ <p class="mt-1 text-2xl font-semibold">{{ formatHours(report.totals.billableMinutes) }}</p>
84
+ </UCard>
85
+ <UCard>
86
+ <p class="text-sm text-muted">{{ t('features.timesheets.admin.nonBillableHours') }}</p>
87
+ <p class="mt-1 text-2xl font-semibold">{{ formatHours(report.totals.nonBillableMinutes) }}</p>
88
+ </UCard>
89
+ <UCard>
90
+ <p class="text-sm text-muted">{{ t('features.timesheets.admin.billableAmount') }}</p>
91
+ <p class="mt-1 text-2xl font-semibold">{{ formatMoney(report.totals.billableAmountMinor) }}</p>
92
+ </UCard>
93
+ </div>
94
+ <UCard v-if="report" :ui="{ body: '!p-0' }">
95
+ <div class="overflow-x-auto">
96
+ <table class="w-full min-w-[52rem] text-sm">
97
+ <thead class="border-b border-default bg-muted text-left text-muted">
98
+ <tr>
99
+ <th class="p-3">{{ t('features.timesheets.fields.date') }}</th>
100
+ <th class="p-3">{{ t('features.timesheets.admin.client') }}</th>
101
+ <th class="p-3">{{ t('features.timesheets.fields.project') }}</th>
102
+ <th class="p-3">{{ t('features.timesheets.admin.person') }}</th>
103
+ <th class="p-3">{{ t('features.timesheets.fields.activity') }}</th>
104
+ <th class="p-3 text-right">{{ t('features.timesheets.admin.hours') }}</th>
105
+ <th class="p-3 text-right">{{ t('features.timesheets.admin.amount') }}</th>
106
+ </tr>
107
+ </thead>
108
+ <tbody>
109
+ <tr v-for="row in report.rows" :key="row.entryId" class="border-b border-muted">
110
+ <td class="p-3">{{ row.date }}</td>
111
+ <td class="p-3">{{ row.client }}</td>
112
+ <td class="p-3">{{ row.project }}</td>
113
+ <td class="p-3">{{ row.person }}</td>
114
+ <td class="p-3">{{ row.activity }}</td>
115
+ <td class="p-3 text-right tabular-nums">{{ formatHours(row.minutes) }}</td>
116
+ <td class="p-3 text-right tabular-nums">{{ formatMoney(row.amountMinor) }}</td>
117
+ </tr>
118
+ </tbody>
119
+ </table>
120
+ </div>
121
+ </UCard>
122
+ </section>
123
+ </template>
@@ -0,0 +1,135 @@
1
+ <script setup lang="ts">
2
+ import type { Component } from 'vue'
3
+ import TimesheetsAdminActivities from './TimesheetsAdminActivities.vue'
4
+ import TimesheetsAdminApprovals from './TimesheetsAdminApprovals.vue'
5
+ import TimesheetsAdminProjects from './TimesheetsAdminProjects.vue'
6
+ import TimesheetsAdminRates from './TimesheetsAdminRates.vue'
7
+ import TimesheetsAdminReports from './TimesheetsAdminReports.vue'
8
+ import TimesheetsAdminSettings from './TimesheetsAdminSettings.vue'
9
+
10
+ type AdminSection = 'approvals' | 'projects' | 'activities' | 'rates' | 'settings' | 'reports'
11
+ type CrudSectionView = {
12
+ canCreate: boolean
13
+ openCreate: () => void
14
+ refreshList: () => Promise<unknown>
15
+ showCreate: boolean
16
+ }
17
+
18
+ const props = defineProps<{ section: AdminSection }>()
19
+ const sectionComponents = {
20
+ approvals: TimesheetsAdminApprovals,
21
+ projects: TimesheetsAdminProjects,
22
+ activities: TimesheetsAdminActivities,
23
+ rates: TimesheetsAdminRates,
24
+ settings: TimesheetsAdminSettings,
25
+ reports: TimesheetsAdminReports
26
+ } satisfies Record<AdminSection, Component>
27
+ const sectionTitleKeys = {
28
+ approvals: 'approvals',
29
+ projects: 'projects',
30
+ activities: 'activities',
31
+ rates: 'teamRates',
32
+ settings: 'workspaceSettings',
33
+ reports: 'reports'
34
+ } satisfies Record<AdminSection, string>
35
+ const sectionIcons: Partial<Record<AdminSection, string>> = {
36
+ projects: 'i-lucide-folder-kanban',
37
+ activities: 'i-lucide-tags'
38
+ }
39
+ const createLabelKeys: Partial<Record<AdminSection, string>> = {
40
+ projects: 'newProject',
41
+ activities: 'newActivity'
42
+ }
43
+ const { t } = useI18n()
44
+ const timesheets = useTimesheets()
45
+ const sectionViewRef = ref<CrudSectionView | null>(null)
46
+ const refreshing = ref(false)
47
+ const sectionComponent = computed(() => sectionComponents[props.section])
48
+ const titleKey = computed(() => sectionTitleKeys[props.section])
49
+ const sectionTitle = computed(() => t(`features.timesheets.admin.${titleKey.value}`))
50
+ const sectionSubtitle = computed(() => t(`features.timesheets.admin.sectionSubtitles.${props.section}`))
51
+ const hasConstrainedList = computed(() => ['projects', 'activities'].includes(props.section))
52
+ const createLabel = computed(() =>
53
+ createLabelKeys[props.section] ? t(`features.timesheets.admin.${createLabelKeys[props.section]}`) : ''
54
+ )
55
+ const { data, pending, refresh } = await useAsyncData(`timesheets-admin-${props.section}`, () =>
56
+ timesheets.adminBootstrap(props.section)
57
+ )
58
+ const openCreate = () => sectionViewRef.value?.openCreate()
59
+ const refreshSection = async () => {
60
+ if (refreshing.value) {
61
+ return
62
+ }
63
+ refreshing.value = true
64
+ try {
65
+ await Promise.all([refresh(), sectionViewRef.value?.refreshList()])
66
+ } finally {
67
+ refreshing.value = false
68
+ }
69
+ }
70
+
71
+ useSeoMeta({ title: sectionTitle })
72
+ </script>
73
+
74
+ <template>
75
+ <TimesheetsPageShell
76
+ class="timesheet-admin h-full min-h-0"
77
+ :setup-status="data?.setupStatus"
78
+ :class="hasConstrainedList ? 'flex flex-col overflow-hidden' : 'space-y-6 overflow-y-auto'"
79
+ >
80
+ <header :class="hasConstrainedList ? 'mb-6 shrink-0' : ''">
81
+ <div class="flex items-end justify-between gap-4">
82
+ <div class="min-w-0">
83
+ <div class="flex items-center gap-2">
84
+ <UIcon v-if="sectionIcons[section]" :name="sectionIcons[section]" class="size-6 shrink-0" />
85
+ <h1 class="text-2xl font-semibold text-highlighted">{{ sectionTitle }}</h1>
86
+ </div>
87
+ <p class="mt-1 hidden text-sm text-muted sm:block">{{ sectionSubtitle }}</p>
88
+ </div>
89
+ <div v-if="hasConstrainedList" class="flex shrink-0 items-center gap-1">
90
+ <UButton
91
+ v-if="sectionViewRef?.showCreate"
92
+ class="rounded-full sm:hidden"
93
+ icon="i-lucide-plus"
94
+ :aria-label="createLabel"
95
+ :disabled="!sectionViewRef.canCreate"
96
+ @click="openCreate"
97
+ />
98
+ <UButton
99
+ v-if="sectionViewRef?.showCreate"
100
+ icon="i-lucide-plus"
101
+ size="sm"
102
+ variant="outline"
103
+ class="hidden h-8 sm:inline-flex"
104
+ :disabled="!sectionViewRef.canCreate"
105
+ @click="openCreate"
106
+ >
107
+ {{ createLabel }}
108
+ </UButton>
109
+ <UButton
110
+ icon="i-lucide-refresh-cw"
111
+ color="neutral"
112
+ variant="ghost"
113
+ size="sm"
114
+ :loading="refreshing"
115
+ :aria-label="t('features.timesheets.admin.refresh')"
116
+ @click="refreshSection"
117
+ />
118
+ </div>
119
+ </div>
120
+ </header>
121
+ <div v-if="pending && !data" class="flex justify-center py-12 text-muted" role="status">
122
+ <UIcon name="i-lucide-loader-circle" class="size-6 animate-spin" />
123
+ <span class="sr-only">{{ t('features.timesheets.loading') }}</span>
124
+ </div>
125
+ <component
126
+ :is="sectionComponent"
127
+ v-if="data"
128
+ ref="sectionViewRef"
129
+ :key="props.section"
130
+ :data="data"
131
+ :refresh="refresh"
132
+ :class="hasConstrainedList ? 'min-h-0 flex-1' : ''"
133
+ />
134
+ </TimesheetsPageShell>
135
+ </template>
@@ -0,0 +1,15 @@
1
+ <script setup lang="ts">
2
+ import type { TimesheetsAdminBootstrap } from '@nuxt-customer-portal/timesheets/app/composables/useTimesheets'
3
+ import TimesheetsWorkspaceSettingsForm from './TimesheetsWorkspaceSettingsForm.vue'
4
+
5
+ defineProps<{
6
+ data: TimesheetsAdminBootstrap
7
+ refresh: () => Promise<unknown>
8
+ }>()
9
+ </script>
10
+
11
+ <template>
12
+ <section class="grid gap-4">
13
+ <TimesheetsWorkspaceSettingsForm :settings="data.settings" :refresh="refresh" />
14
+ </section>
15
+ </template>
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ import type { OrganizationTimesheetCapabilities } from '../composables/useTimesheets'
3
+
4
+ const props = defineProps<{ organizationId: string }>()
5
+ const timesheets = useTimesheets()
6
+ const capabilities = ref<OrganizationTimesheetCapabilities | null>(null)
7
+ const pending = ref(true)
8
+
9
+ const load = async () => {
10
+ pending.value = true
11
+ try {
12
+ capabilities.value = await timesheets.getOrganizationTimesheetCapabilities(props.organizationId)
13
+ } finally {
14
+ pending.value = false
15
+ }
16
+ }
17
+
18
+ await load()
19
+ </script>
20
+
21
+ <template>
22
+ <USkeleton v-if="pending" class="h-32 w-full" />
23
+ <template v-else-if="capabilities">
24
+ <TimesheetsOrganizationFeaturesCard v-model="capabilities" :organization-id="organizationId" />
25
+ </template>
26
+ </template>
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ import { z } from 'zod'
3
+ import type { GenericClientDto } from '@nuxt-customer-portal/clients/types'
4
+
5
+ const props = defineProps<{ client: GenericClientDto }>()
6
+ const { t } = useI18n()
7
+ const toast = useToast()
8
+ const busy = ref(false)
9
+ const state = reactive({ accessMode: 'DISABLED' as 'DISABLED' | 'VIEW' | 'REVIEW' })
10
+ const schema = z.object({ accessMode: z.enum(['DISABLED', 'VIEW', 'REVIEW']) })
11
+ const { data } = await useFetch(`/api/timesheets/admin/client-settings/${props.client.organizationId}`)
12
+ if (data.value) {
13
+ Object.assign(state, data.value)
14
+ }
15
+ const save = async () => {
16
+ busy.value = true
17
+ try {
18
+ await $fetch(`/api/timesheets/admin/client-settings/${props.client.organizationId}`, {
19
+ method: 'PUT',
20
+ body: state
21
+ })
22
+ toast.add({ title: t('features.timesheets.messages.saved'), color: 'success' })
23
+ } finally {
24
+ busy.value = false
25
+ }
26
+ }
27
+ </script>
28
+
29
+ <template>
30
+ <UCard>
31
+ <template #header>
32
+ <h2 class="font-semibold">{{ t('features.timesheets.admin.clientSettings') }}</h2>
33
+ </template>
34
+ <UForm :state="state" :schema="schema" class="space-y-4" @submit="save">
35
+ <UFormField name="accessMode" :label="t('features.timesheets.timesheetAccess')">
36
+ <USelect
37
+ v-model="state.accessMode"
38
+ :items="[
39
+ { label: t('features.timesheets.clientAccess.disabled'), value: 'DISABLED' },
40
+ { label: t('features.timesheets.clientAccess.view'), value: 'VIEW' },
41
+ { label: t('features.timesheets.clientAccess.review'), value: 'REVIEW' }
42
+ ]"
43
+ value-key="value"
44
+ class="w-full"
45
+ />
46
+ </UFormField>
47
+ <div class="flex justify-end">
48
+ <UButton type="submit" icon="i-lucide-save" :loading="busy">{{ t('features.timesheets.save') }}</UButton>
49
+ </div>
50
+ </UForm>
51
+ </UCard>
52
+ </template>