@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,57 @@
1
+ import { z } from 'zod'
2
+ import { registerPortalOpenApiContracts } from '@nuxt-customer-portal/core/server/utils/openapi-contracts'
3
+ import {
4
+ activityCreateSchema,
5
+ activityDeleteSchema,
6
+ activityListQuerySchema,
7
+ activityUpdateSchema,
8
+ clientCreateSchema,
9
+ clientDeleteSchema,
10
+ clientListQuerySchema,
11
+ entryCreateSchema,
12
+ entryUpdateSchema,
13
+ projectCreateSchema,
14
+ projectDeleteSchema,
15
+ projectListQuerySchema,
16
+ projectUpdateSchema,
17
+ reportQuerySchema,
18
+ reviewSchema,
19
+ settingsUpdateSchema,
20
+ tariffUpdateSchema,
21
+ teamMemberSettingsUpdateSchema,
22
+ timerStartSchema,
23
+ weekQuerySchema
24
+ } from '../utils/timesheet-validation'
25
+
26
+ export default defineNitroPlugin(() => {
27
+ registerPortalOpenApiContracts({
28
+ owner: 'timesheets',
29
+ query: {
30
+ timesheetsBootstrapGet: weekQuerySchema,
31
+ timesheetsAdminBootstrapGet: z.object({
32
+ section: z.enum(['approvals', 'clients', 'projects', 'activities', 'rates', 'settings', 'reports']).optional()
33
+ }),
34
+ timesheetsAdminActivitiesGet: activityListQuerySchema,
35
+ timesheetsAdminClientsGet: clientListQuerySchema,
36
+ timesheetsAdminProjectsGet: projectListQuerySchema,
37
+ timesheetsAdminReportGet: reportQuerySchema
38
+ },
39
+ body: {
40
+ timesheetsEntriesPost: entryCreateSchema,
41
+ timesheetsEntriesByIdPatch: entryUpdateSchema,
42
+ timesheetsTimerPost: timerStartSchema,
43
+ timesheetsAdminClientsPost: clientCreateSchema,
44
+ timesheetsAdminClientsByIdDelete: clientDeleteSchema,
45
+ timesheetsAdminActivitiesPost: activityCreateSchema,
46
+ timesheetsAdminActivitiesByIdPatch: activityUpdateSchema,
47
+ timesheetsAdminActivitiesByIdDelete: activityDeleteSchema,
48
+ timesheetsAdminProjectsPost: projectCreateSchema,
49
+ timesheetsAdminProjectsByIdPatch: projectUpdateSchema,
50
+ timesheetsAdminProjectsByIdDelete: projectDeleteSchema,
51
+ timesheetsAdminTariffsPut: tariffUpdateSchema,
52
+ timesheetsAdminTeamMemberPut: teamMemberSettingsUpdateSchema,
53
+ timesheetsAdminSettingsPatch: settingsUpdateSchema,
54
+ timesheetsAdminApprovalsByIdPost: reviewSchema
55
+ }
56
+ })
57
+ })
@@ -0,0 +1,281 @@
1
+ import type {
2
+ ActivityTypeDto,
3
+ ClientApprovalItemDto,
4
+ ClientDto,
5
+ ClientSupplierTimesheetItemDto,
6
+ ProjectDto,
7
+ TimesheetsListResponse
8
+ } from '@nuxt-customer-portal/timesheets/shared/types/timesheet'
9
+ import type {
10
+ ActivityListQuery,
11
+ InternalApprovalListQuery,
12
+ ClientApprovalListQuery,
13
+ ClientListQuery,
14
+ ClientSupplierTimesheetListQuery,
15
+ ProjectListQuery
16
+ } from './timesheet-validation'
17
+ import {
18
+ listActivities,
19
+ listApprovalQueue,
20
+ listClientApprovals,
21
+ listClientSupplierTimesheets,
22
+ listClients,
23
+ listProjects
24
+ } from './timesheet-repository'
25
+
26
+ const compareText = (left: string | null | undefined, right: string | null | undefined) =>
27
+ (left ?? '').localeCompare(right ?? '', undefined, { sensitivity: 'base', numeric: true })
28
+ const direction = (value: number, sortDir: 'asc' | 'desc') => (sortDir === 'asc' ? value : -value)
29
+ const includes = (value: string | null | undefined, search: string) =>
30
+ (value ?? '').toLocaleLowerCase().includes(search)
31
+ const paginate = <T>(items: T[], page: number, pageSize: number): TimesheetsListResponse<T> => {
32
+ const total = items.length
33
+ const pageCount = Math.ceil(total / pageSize)
34
+ const safePage = pageCount ? Math.min(page, pageCount) : 1
35
+ return {
36
+ items: items.slice((safePage - 1) * pageSize, safePage * pageSize),
37
+ pagination: { total, page: safePage, pageSize, pageCount }
38
+ }
39
+ }
40
+
41
+ export const listInternalApprovalsPage = async (
42
+ organizationId: string,
43
+ actorUserId: string,
44
+ query: InternalApprovalListQuery
45
+ ) => {
46
+ const search = query.search?.toLocaleLowerCase() ?? ''
47
+ const rows = (await listApprovalQueue(organizationId, actorUserId))
48
+ .filter((item) => !query.userId || item.userId === query.userId)
49
+ .filter((item) => !search || includes(item.userName, search))
50
+ .filter((item) => !query.status || item.status === query.status)
51
+ .sort(
52
+ (a, b) =>
53
+ direction(
54
+ query.sortBy === 'totalMinutes'
55
+ ? a.totalMinutes - b.totalMinutes
56
+ : compareText(a[query.sortBy], b[query.sortBy]),
57
+ query.sortDir
58
+ ) || compareText(a.id, b.id)
59
+ )
60
+ return paginate(rows, query.page, query.pageSize)
61
+ }
62
+
63
+ export const listProviderClientApprovalsPage = async (
64
+ organizationId: string,
65
+ actorUserId: string,
66
+ query: Omit<InternalApprovalListQuery, 'status'> & {
67
+ status?: 'PENDING' | 'APPROVED' | 'DISPUTED' | 'AUTO_APPROVED'
68
+ clientOrganizationId?: string
69
+ }
70
+ ) => {
71
+ const search = query.search?.toLocaleLowerCase() ?? ''
72
+ const [projects, clients] = await Promise.all([listProjects(organizationId), listClients(organizationId)])
73
+ const rows = (await listApprovalQueue(organizationId, actorUserId, 'provider'))
74
+ .flatMap((item) =>
75
+ [
76
+ ...item.clientReviews,
77
+ ...(item.status === 'APPROVED'
78
+ ? clients
79
+ .filter(
80
+ (client) =>
81
+ !item.clientReviews.some((review) => review.clientOrganizationId === client.organizationId) &&
82
+ item.entries.some((entry) =>
83
+ projects.some(
84
+ (project) =>
85
+ project.id === entry.projectId && project.clientOrganizationId === client.organizationId
86
+ )
87
+ )
88
+ )
89
+ .map((client) => ({
90
+ clientOrganizationId: client.organizationId,
91
+ status: 'AUTO_APPROVED' as const,
92
+ version: 0,
93
+ canReply: false,
94
+ comment: null,
95
+ createdAt:
96
+ [...(item.history ?? [])]
97
+ .reverse()
98
+ .find((event) => event.action === 'APPROVED' && !event.clientOrganizationId)?.createdAt ??
99
+ item.submittedAt!
100
+ }))
101
+ : [])
102
+ ].map((review) => {
103
+ const entries = item.entries.filter((entry) =>
104
+ projects.some(
105
+ (project) => project.id === entry.projectId && project.clientOrganizationId === review.clientOrganizationId
106
+ )
107
+ )
108
+ return {
109
+ ...item,
110
+ clientReviews: [review],
111
+ messages: [],
112
+ history: [
113
+ {
114
+ id: `client-review-created:${item.id}:${review.clientOrganizationId}`,
115
+ action: review.status === 'AUTO_APPROVED' ? 'AUTO_APPROVED' : 'CLIENT_SUBMITTED',
116
+ actorUserId: item.userId,
117
+ actorName: item.userName,
118
+ actorImage: null,
119
+ clientName: null,
120
+ clientOrganizationId: review.clientOrganizationId,
121
+ comment: null,
122
+ createdAt: review.createdAt
123
+ },
124
+ ...(item.history ?? []).filter((event) => event.clientOrganizationId === review.clientOrganizationId)
125
+ ],
126
+ entries,
127
+ totalMinutes: entries.reduce((sum, entry) => sum + entry.durationMinutes, 0),
128
+ billableMinutes: entries
129
+ .filter((entry) => entry.billable)
130
+ .reduce((sum, entry) => sum + entry.durationMinutes, 0),
131
+ billableAmountMinor: entries.reduce(
132
+ (sum, entry) => sum + Math.round((entry.durationMinutes * entry.hourlyRateMinor) / 60),
133
+ 0
134
+ )
135
+ }
136
+ })
137
+ )
138
+ .filter((item) => item.entries.length > 0)
139
+ .filter(
140
+ (item) =>
141
+ !query.clientOrganizationId ||
142
+ item.clientReviews.some((review) => review.clientOrganizationId === query.clientOrganizationId)
143
+ )
144
+ .filter((item) => !query.userId || item.userId === query.userId)
145
+ .filter((item) => !search || includes(item.userName, search))
146
+ .filter((item) => !query.status || item.clientReviews.some((review) => review.status === query.status))
147
+ .sort(
148
+ (a, b) =>
149
+ direction(
150
+ query.sortBy === 'totalMinutes'
151
+ ? a.totalMinutes - b.totalMinutes
152
+ : compareText(a[query.sortBy], b[query.sortBy]),
153
+ query.sortDir
154
+ ) || compareText(a.id, b.id)
155
+ )
156
+ return paginate(rows, query.page, query.pageSize)
157
+ }
158
+
159
+ export const listProjectsPage = async (organizationId: string, query: ProjectListQuery) => {
160
+ const search = query.search?.toLocaleLowerCase() ?? ''
161
+ const rows = (await listProjects(organizationId))
162
+ .filter((item) => !search || [item.name, item.code, item.clientName].some((value) => includes(value, search)))
163
+ .filter((item) => !query.clientOrganizationId || item.clientOrganizationId === query.clientOrganizationId)
164
+ .filter((item) => !query.status || query.status === 'ALL' || item.status === query.status)
165
+ .sort(
166
+ (a, b) =>
167
+ direction(
168
+ query.sortBy === 'clientName'
169
+ ? compareText(a.clientName, b.clientName)
170
+ : query.sortBy === 'startsOn'
171
+ ? compareText(a.startsOn, b.startsOn)
172
+ : compareText(a.name, b.name),
173
+ query.sortDir
174
+ ) || compareText(a.id, b.id)
175
+ )
176
+ return paginate<ProjectDto>(rows, query.page, query.pageSize)
177
+ }
178
+
179
+ export const listClientsPage = async (organizationId: string, query: ClientListQuery) => {
180
+ const search = query.search?.toLocaleLowerCase() ?? ''
181
+ const configured = (item: ClientDto) => item.accessMode !== 'DISABLED'
182
+ const rows = (await listClients(organizationId))
183
+ .filter((item) => !search || [item.name, item.officialName, item.slug].some((value) => includes(value, search)))
184
+ .filter((item) => !query.configured || configured(item) === (query.configured === 'configured'))
185
+ .sort((a, b) => direction(compareText(a.name, b.name), query.sortDir) || compareText(a.id, b.id))
186
+ return paginate<ClientDto>(rows, query.page, query.pageSize)
187
+ }
188
+
189
+ export const listActivitiesPage = async (organizationId: string, query: ActivityListQuery) => {
190
+ const search = query.search?.toLocaleLowerCase() ?? ''
191
+ const rows = (await listActivities(organizationId))
192
+ .filter((item) => !search || includes(item.name, search))
193
+ .filter((item) => query.active === undefined || item.active === (query.active === 'true'))
194
+ .filter((item) => query.billable === undefined || item.billable === (query.billable === 'true'))
195
+ .sort(
196
+ (a, b) =>
197
+ direction(
198
+ query.sortBy === 'active'
199
+ ? Number(a.active) - Number(b.active)
200
+ : query.sortBy === 'billable'
201
+ ? Number(a.billable) - Number(b.billable)
202
+ : compareText(a.name, b.name),
203
+ query.sortDir
204
+ ) || compareText(a.id, b.id)
205
+ )
206
+ return paginate<ActivityTypeDto>(rows, query.page, query.pageSize)
207
+ }
208
+
209
+ export const listClientApprovalsPage = async (
210
+ clientOrganizationId: string,
211
+ actorUserId: string,
212
+ isAdmin: boolean,
213
+ query: ClientApprovalListQuery
214
+ ) => {
215
+ const search = query.search?.toLocaleLowerCase() ?? ''
216
+ const rows = (await listClientApprovals(clientOrganizationId, actorUserId, isAdmin)).items
217
+ .filter((item) => !query.userId || item.userId === query.userId)
218
+ .filter(
219
+ (item) =>
220
+ !search ||
221
+ [
222
+ item.supplierName,
223
+ item.person,
224
+ item.reviewerName,
225
+ item.comment,
226
+ ...item.entries.flatMap((entry) => [entry.project, entry.activity, entry.note])
227
+ ].some((value) => includes(value, search))
228
+ )
229
+ .filter((item) => !query.workspaceClientId || item.workspaceClientId === query.workspaceClientId)
230
+ .filter((item) => !query.status || item.status === query.status)
231
+ .sort(
232
+ (a, b) =>
233
+ direction(
234
+ query.sortBy === 'supplierName'
235
+ ? compareText(a.supplierName, b.supplierName)
236
+ : query.sortBy === 'person'
237
+ ? compareText(a.person, b.person)
238
+ : query.sortBy === 'status'
239
+ ? compareText(a.status, b.status)
240
+ : query.sortBy === 'totalMinutes'
241
+ ? a.totalMinutes - b.totalMinutes
242
+ : compareText(a.weekStartsOn, b.weekStartsOn),
243
+ query.sortDir
244
+ ) || compareText(a.id, b.id)
245
+ )
246
+ return paginate<ClientApprovalItemDto>(rows, query.page, query.pageSize)
247
+ }
248
+
249
+ export const listClientSupplierTimesheetsPage = async (
250
+ clientOrganizationId: string,
251
+ actorUserId: string,
252
+ isAdmin: boolean,
253
+ query: ClientSupplierTimesheetListQuery
254
+ ) => {
255
+ const search = query.search?.toLocaleLowerCase() ?? ''
256
+ const rows = (await listClientSupplierTimesheets(clientOrganizationId, actorUserId, isAdmin))
257
+ .filter(
258
+ (item) =>
259
+ !search ||
260
+ [
261
+ item.supplierName,
262
+ item.person,
263
+ ...item.entries.flatMap((entry) => [entry.project, entry.activity, entry.note])
264
+ ].some((value) => includes(value, search))
265
+ )
266
+ .filter((item) => !query.workspaceClientId || item.workspaceClientId === query.workspaceClientId)
267
+ .sort(
268
+ (a, b) =>
269
+ direction(
270
+ query.sortBy === 'supplierName'
271
+ ? compareText(a.supplierName, b.supplierName)
272
+ : query.sortBy === 'person'
273
+ ? compareText(a.person, b.person)
274
+ : query.sortBy === 'totalMinutes'
275
+ ? a.totalMinutes - b.totalMinutes
276
+ : compareText(a.weekStartsOn, b.weekStartsOn),
277
+ query.sortDir
278
+ ) || compareText(a.id, b.id)
279
+ )
280
+ return paginate<ClientSupplierTimesheetItemDto>(rows, query.page, query.pageSize)
281
+ }
@@ -0,0 +1,221 @@
1
+ import { formatTimesheetPeriod } from '../../shared/timesheet-dates'
2
+ import { getClientEmailLocale } from '@nuxt-customer-portal/clients/server/utils/client-email-locale'
3
+ import { and, eq, isNotNull } from 'drizzle-orm'
4
+ import { db } from '@nuxt-customer-portal/core/server/portal'
5
+ import { member, organization, user } from '@nuxt-customer-portal/core/schema'
6
+ import { getPortalEmailSettings, sendPortalEmail } from '@nuxt-customer-portal/core/server/utils/portal-email'
7
+ import { timesheetEmails } from '../../shared/emails'
8
+ import {
9
+ internalApproverAssignment,
10
+ timesheetSubmission,
11
+ timesheetClientReview,
12
+ workspaceClient,
13
+ workspaceClientReviewer,
14
+ type TimesheetSubmissionRecord
15
+ } from '../db/schema/timesheets'
16
+
17
+ const escapeHtml = (value: string) =>
18
+ value.replace(
19
+ /[&<>"']/g,
20
+ (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!
21
+ )
22
+
23
+ type NotificationEvent = 'submitted' | 'approved' | 'rejected' | 'reopened'
24
+
25
+ // Delivery is deliberately outside the transaction. An unavailable provider must not
26
+ // turn a committed approval into an API error that invites a duplicate action.
27
+ export const notifyTimesheetEvent = async (
28
+ submission: TimesheetSubmissionRecord | undefined,
29
+ event: NotificationEvent,
30
+ client?: { id: string; version: number; status: string; comment: string | null },
31
+ reply?: string,
32
+ replyClientId?: string
33
+ ) => {
34
+ if (!submission) {
35
+ return
36
+ }
37
+ try {
38
+ const [person] = await db.select().from(user).where(eq(user.id, submission.userId)).limit(1)
39
+ const [workspace] = await db
40
+ .select()
41
+ .from(organization)
42
+ .where(eq(organization.id, submission.organizationId))
43
+ .limit(1)
44
+ if (!person || !workspace) {
45
+ return
46
+ }
47
+ const { defaultLocale: locale } = await getPortalEmailSettings()
48
+ const baseUrl = process.env.BETTER_AUTH_URL || process.env.PUBLIC_URL || 'http://localhost:3051'
49
+ const send = async (
50
+ id: string,
51
+ recipient: { id: string; email: string; name: string },
52
+ path: string,
53
+ clientName = '',
54
+ comment = '',
55
+ scope = '',
56
+ emailLocale = locale
57
+ ) => {
58
+ try {
59
+ await sendPortalEmail({
60
+ moduleId: 'timesheets',
61
+ locale: emailLocale,
62
+ definition: timesheetEmails.find((definition) => definition.id === id)!,
63
+ to: recipient.email,
64
+ idempotencyKey: `timesheet/${submission.id}/${submission.version}/${id}/${scope}/${recipient.id}`,
65
+ values: {
66
+ recipient_name: escapeHtml(recipient.name),
67
+ person_name: escapeHtml(person.name),
68
+ organization_name: escapeHtml(workspace.name),
69
+ period: escapeHtml(formatTimesheetPeriod(submission.periodStartsOn, submission.periodEndsOn, emailLocale)),
70
+ period_start: submission.periodStartsOn,
71
+ period_end: submission.periodEndsOn,
72
+ client_name: escapeHtml(clientName),
73
+ comment: escapeHtml(comment),
74
+ action_url: escapeHtml(new URL(path, baseUrl).href)
75
+ }
76
+ })
77
+ } catch (error) {
78
+ console.error(
79
+ 'Timesheet email delivery failed',
80
+ { submissionId: submission.id, event: id, recipientId: recipient.id },
81
+ error
82
+ )
83
+ }
84
+ }
85
+ if (client) {
86
+ const [clientOrganization] = await db.select().from(organization).where(eq(organization.id, client.id)).limit(1)
87
+ await send(
88
+ client.status === 'APPROVED' ? 'client-approved' : 'client-disputed',
89
+ person,
90
+ '/timesheets',
91
+ clientOrganization?.name,
92
+ client.comment ?? '',
93
+ `${client.id}/${client.version}`
94
+ )
95
+ return
96
+ }
97
+ if (event === 'submitted' && submission.status === 'SUBMITTED') {
98
+ const reviewers = await db
99
+ .selectDistinct({ id: user.id, email: user.email, name: user.name })
100
+ .from(internalApproverAssignment)
101
+ .innerJoin(user, eq(user.id, internalApproverAssignment.approverUserId))
102
+ .innerJoin(member, and(eq(member.userId, user.id), eq(member.organizationId, submission.organizationId)))
103
+ .where(
104
+ and(
105
+ eq(internalApproverAssignment.organizationId, submission.organizationId),
106
+ eq(internalApproverAssignment.submitterUserId, submission.userId)
107
+ )
108
+ )
109
+ await Promise.all(
110
+ reviewers.map((reviewer) =>
111
+ send(
112
+ 'internal-requested',
113
+ reviewer,
114
+ `/timesheets/internal-approvals?${new URLSearchParams({ status: 'SUBMITTED', userId: submission.userId })}`,
115
+ '',
116
+ reply ?? ''
117
+ )
118
+ )
119
+ )
120
+ return
121
+ }
122
+ const outcome = event === 'submitted' ? 'approved' : event
123
+ if (!replyClientId) {
124
+ await send(`internal-${outcome}`, person, '/timesheets', '', reply ?? submission.rejectionComment ?? '')
125
+ }
126
+ if (submission.status !== 'APPROVED') {
127
+ return
128
+ }
129
+ const links = await db
130
+ .select({ id: workspaceClient.id, clientId: workspaceClient.clientOrganizationId })
131
+ .from(workspaceClient)
132
+ .where(
133
+ and(
134
+ eq(workspaceClient.workspaceOrganizationId, submission.organizationId),
135
+ eq(workspaceClient.accessMode, 'REVIEW')
136
+ )
137
+ )
138
+ const { ensureClientReviewers } = await import('./timesheet-repository')
139
+ await Promise.all(links.map((link) => ensureClientReviewers(link.id, link.clientId)))
140
+ const reviewers = await db
141
+ .selectDistinct({
142
+ id: user.id,
143
+ email: user.email,
144
+ name: user.name,
145
+ clientId: organization.id,
146
+ clientName: organization.name,
147
+ reviewId: timesheetClientReview.id,
148
+ version: timesheetClientReview.version
149
+ })
150
+ .from(timesheetClientReview)
151
+ .innerJoin(
152
+ workspaceClient,
153
+ and(
154
+ eq(workspaceClient.clientOrganizationId, timesheetClientReview.clientOrganizationId),
155
+ eq(workspaceClient.workspaceOrganizationId, submission.organizationId),
156
+ eq(workspaceClient.accessMode, 'REVIEW')
157
+ )
158
+ )
159
+ .innerJoin(member, eq(member.organizationId, workspaceClient.clientOrganizationId))
160
+ .innerJoin(user, eq(user.id, member.userId))
161
+ .leftJoin(
162
+ workspaceClientReviewer,
163
+ and(
164
+ eq(workspaceClientReviewer.workspaceClientId, workspaceClient.id),
165
+ eq(workspaceClientReviewer.userId, member.userId)
166
+ )
167
+ )
168
+ .innerJoin(organization, eq(organization.id, workspaceClient.clientOrganizationId))
169
+ .where(
170
+ and(
171
+ eq(timesheetClientReview.submissionId, submission.id),
172
+ eq(timesheetClientReview.status, 'PENDING'),
173
+ isNotNull(workspaceClientReviewer.userId)
174
+ )
175
+ )
176
+ await Promise.all(
177
+ reviewers
178
+ .filter((reviewer) => !replyClientId || reviewer.clientId === replyClientId)
179
+ .map(async (reviewer) =>
180
+ send(
181
+ 'client-requested',
182
+ reviewer,
183
+ `/timesheets/approvals?${new URLSearchParams({ status: 'PENDING', userId: submission.userId })}`,
184
+ reviewer.clientName,
185
+ replyClientId ? (reply ?? '') : '',
186
+ `${reviewer.reviewId}/${reviewer.version}`,
187
+ await getClientEmailLocale(reviewer.clientId)
188
+ )
189
+ )
190
+ )
191
+ } catch (error) {
192
+ console.error('Timesheet email notification failed', { submissionId: submission.id, event }, error)
193
+ }
194
+ }
195
+
196
+ export const notifyClientTimesheetEvent = async (review: {
197
+ submissionId: string | null
198
+ clientOrganizationId: string
199
+ version: number
200
+ status: string
201
+ comment: string | null
202
+ }) => {
203
+ if (!review.submissionId) {
204
+ return
205
+ }
206
+ try {
207
+ const [submission] = await db
208
+ .select()
209
+ .from(timesheetSubmission)
210
+ .where(eq(timesheetSubmission.id, review.submissionId))
211
+ .limit(1)
212
+ await notifyTimesheetEvent(submission, 'approved', {
213
+ id: review.clientOrganizationId,
214
+ version: review.version,
215
+ status: review.status,
216
+ comment: review.comment
217
+ })
218
+ } catch (error) {
219
+ console.error('Client timesheet email notification failed', { submissionId: review.submissionId }, error)
220
+ }
221
+ }