@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,18 @@
1
+ import { requireFeatureAccess } from '@nuxt-customer-portal/core/server/portal'
2
+ import { timesheetsFeature } from '@nuxt-customer-portal/timesheets/shared/feature'
3
+ import { projectListQuerySchema } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-validation'
4
+ import { listProjectsPage } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-admin-listing'
5
+
6
+ defineRouteMeta({
7
+ openAPI: {
8
+ tags: ['Timesheets'],
9
+ operationId: 'timesheetsAdminProjectsGet',
10
+ summary: 'List timesheet projects',
11
+ description: 'List timesheet projects. Scoped to the active organization and the applicable Timesheets permission.'
12
+ }
13
+ })
14
+
15
+ export default defineEventHandler(async (event) => {
16
+ const { organizationId } = await requireFeatureAccess(event, timesheetsFeature.policy, 'manage')
17
+ return listProjectsPage(organizationId, projectListQuerySchema.parse(getQuery(event)))
18
+ })
@@ -0,0 +1,19 @@
1
+ import { requireFeatureAccess } from '@nuxt-customer-portal/core/server/portal'
2
+ import { timesheetsFeature } from '@nuxt-customer-portal/timesheets/shared/feature'
3
+ import { createProject } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
4
+ import { projectCreateSchema } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-validation'
5
+
6
+ defineRouteMeta({
7
+ openAPI: {
8
+ tags: ['Timesheets'],
9
+ operationId: 'timesheetsAdminProjectsPost',
10
+ summary: 'Create a timesheet project',
11
+ description:
12
+ 'Create a timesheet project. Scoped to the active organization and the applicable Timesheets permission.'
13
+ }
14
+ })
15
+
16
+ export default defineEventHandler(async (event) => {
17
+ const { organizationId } = await requireFeatureAccess(event, timesheetsFeature.policy, 'manage')
18
+ return createProject(organizationId, projectCreateSchema.parse(await readBody(event)))
19
+ })
@@ -0,0 +1,26 @@
1
+ import { requireFeatureAccess } from '@nuxt-customer-portal/core/server/portal'
2
+ import { timesheetsFeature } from '@nuxt-customer-portal/timesheets/shared/feature'
3
+ import { getReport, reportToCsv } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
4
+ import { reportQuerySchema } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-validation'
5
+
6
+ defineRouteMeta({
7
+ openAPI: {
8
+ tags: ['Timesheets'],
9
+ operationId: 'timesheetsAdminReportGet',
10
+ summary: 'Get a timesheets report',
11
+ description: 'Get a timesheets report. Scoped to the active organization and the applicable Timesheets permission.'
12
+ }
13
+ })
14
+
15
+ export default defineEventHandler(async (event) => {
16
+ const { organizationId } = await requireFeatureAccess(event, timesheetsFeature.policy, 'report')
17
+ const rawQuery = getQuery(event)
18
+ const format = rawQuery.format
19
+ const report = await getReport(organizationId, reportQuerySchema.parse(rawQuery))
20
+ if (format === 'csv') {
21
+ setResponseHeader(event, 'content-type', 'text/csv; charset=utf-8')
22
+ setResponseHeader(event, 'content-disposition', 'attachment; filename="timesheets.csv"')
23
+ return reportToCsv(report)
24
+ }
25
+ return report
26
+ })
@@ -0,0 +1,19 @@
1
+ import { requireFeatureAccess } from '@nuxt-customer-portal/core/server/portal'
2
+ import { timesheetsFeature } from '@nuxt-customer-portal/timesheets/shared/feature'
3
+ import { updateSettings } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
4
+ import { settingsUpdateSchema } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-validation'
5
+
6
+ defineRouteMeta({
7
+ openAPI: {
8
+ tags: ['Timesheets'],
9
+ operationId: 'timesheetsAdminSettingsPatch',
10
+ summary: 'Update timesheets workspace settings',
11
+ description:
12
+ 'Update timesheets workspace settings. Scoped to the active organization and the applicable Timesheets permission.'
13
+ }
14
+ })
15
+
16
+ export default defineEventHandler(async (event) => {
17
+ const { organizationId } = await requireFeatureAccess(event, timesheetsFeature.policy, 'manage')
18
+ return updateSettings(organizationId, settingsUpdateSchema.parse(await readBody(event)))
19
+ })
@@ -0,0 +1,20 @@
1
+ import { requireFeatureAccess } from '@nuxt-customer-portal/core/server/portal'
2
+ import { timesheetsFeature } from '@nuxt-customer-portal/timesheets/shared/feature'
3
+ import { setTeamTariff } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
4
+ import { tariffUpdateSchema } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-validation'
5
+
6
+ defineRouteMeta({
7
+ openAPI: {
8
+ tags: ['Timesheets'],
9
+ operationId: 'timesheetsAdminTariffsPut',
10
+ summary: 'Update team billing rates',
11
+ description:
12
+ 'Update team billing rates. Scoped to the active organization and the applicable Timesheets permission.'
13
+ }
14
+ })
15
+
16
+ export default defineEventHandler(async (event) => {
17
+ const { organizationId } = await requireFeatureAccess(event, timesheetsFeature.policy, 'manage')
18
+ const input = tariffUpdateSchema.parse(await readBody(event))
19
+ return setTeamTariff(organizationId, input.userId, input.hourlyRateMinor)
20
+ })
@@ -0,0 +1,24 @@
1
+ import { requireFeatureAccess } from '@nuxt-customer-portal/core/server/portal'
2
+ import { timesheetsFeature } from '@nuxt-customer-portal/timesheets/shared/feature'
3
+ import { updateTeamMemberSettings } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
4
+ import { teamMemberSettingsUpdateSchema } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-validation'
5
+
6
+ defineRouteMeta({
7
+ openAPI: {
8
+ tags: ['Timesheets'],
9
+ operationId: 'timesheetsAdminTeamMemberPut',
10
+ summary: 'Update a team member’s Timesheets settings',
11
+ description: 'Update time-entry eligibility and the default hourly rate for an organization member.'
12
+ }
13
+ })
14
+
15
+ export default defineEventHandler(async (event) => {
16
+ const { organizationId } = await requireFeatureAccess(event, timesheetsFeature.policy, 'manage')
17
+ const userId = getRouterParam(event, 'userId')
18
+ if (!userId) {
19
+ throw createError({ statusCode: 400, message: 'Team member is required' })
20
+ }
21
+ const input = teamMemberSettingsUpdateSchema.parse(await readBody(event))
22
+ await updateTeamMemberSettings(organizationId, userId, input)
23
+ return { updated: true }
24
+ })
@@ -0,0 +1,24 @@
1
+ import { requireFeatureAccess } from '@nuxt-customer-portal/core/server/portal'
2
+ import { timesheetsFeature } from '@nuxt-customer-portal/timesheets/shared/feature'
3
+ import {
4
+ getBootstrap,
5
+ requireTimesheetWorkspace
6
+ } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
7
+ import { weekQuerySchema } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-validation'
8
+
9
+ defineRouteMeta({
10
+ openAPI: {
11
+ tags: ['Timesheets'],
12
+ operationId: 'timesheetsBootstrapGet',
13
+ summary: 'Get timesheets workspace data',
14
+ description:
15
+ 'Get timesheets workspace data. Scoped to the active organization and the applicable Timesheets permission.'
16
+ }
17
+ })
18
+
19
+ export default defineEventHandler(async (event) => {
20
+ const { session, organizationId } = await requireFeatureAccess(event, timesheetsFeature.policy, 'read')
21
+ const query = weekQuerySchema.parse(getQuery(event))
22
+ await requireTimesheetWorkspace(organizationId)
23
+ return getBootstrap(organizationId, session.user.id, query.week)
24
+ })
@@ -0,0 +1,58 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import { db, requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
3
+ import { workspaceSettings } from '@nuxt-customer-portal/timesheets/server/db/schema/timesheets'
4
+ import {
5
+ canMemberEnterTime,
6
+ hasInternalApprovalAssignment,
7
+ listApprovalQueue,
8
+ listClientApprovals,
9
+ listClientReviewerSuppliers,
10
+ listClientWorkspaces
11
+ } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
12
+ import type { TimesheetCapabilitiesDto } from '@nuxt-customer-portal/timesheets/shared/types/timesheet'
13
+
14
+ export default defineEventHandler(async (event) => {
15
+ const { session, organizationId, organizationType, role } = await requireActiveOrganizationRole(event)
16
+ const isAdmin = role === 'owner' || role === 'admin' || session.user.role === 'admin'
17
+ const [settings, clientWorkspaces, approvals, reviewerSuppliers, memberCanEnterTime] = await Promise.all([
18
+ db
19
+ .select({
20
+ workspaceEnabled: workspaceSettings.workspaceEnabled,
21
+ internalApprovalsEnabled: workspaceSettings.internalApprovalsEnabled
22
+ })
23
+ .from(workspaceSettings)
24
+ .where(eq(workspaceSettings.organizationId, organizationId))
25
+ .limit(1),
26
+ listClientWorkspaces(organizationId, session.user.id, isAdmin),
27
+ listClientApprovals(organizationId, session.user.id, isAdmin),
28
+ isAdmin ? listClientReviewerSuppliers(organizationId, session.user.id) : Promise.resolve([]),
29
+ canMemberEnterTime(organizationId, session.user.id)
30
+ ])
31
+ const reviewWorkspaces = clientWorkspaces.filter((item) => item.accessMode === 'REVIEW')
32
+ const workspaceEnabled = organizationType === 'PROVIDER' && (settings[0]?.workspaceEnabled ?? false)
33
+ const internalApprovals =
34
+ workspaceEnabled && (settings[0]?.internalApprovalsEnabled ?? true)
35
+ ? await listApprovalQueue(organizationId, session.user.id)
36
+ : []
37
+ const hasInternalApprovalAssignments =
38
+ workspaceEnabled &&
39
+ (settings[0]?.internalApprovalsEnabled ?? true) &&
40
+ (await hasInternalApprovalAssignment(organizationId, session.user.id))
41
+ return {
42
+ canEnterTime: workspaceEnabled && memberCanEnterTime,
43
+ canApproveInternalTimesheets: hasInternalApprovalAssignments,
44
+ hasInternalApprovalAssignments,
45
+ canManageTimesheets: organizationType === 'PROVIDER' && isAdmin && workspaceEnabled,
46
+ canReviewClientTimesheets:
47
+ reviewWorkspaces.length > 0 &&
48
+ (isAdmin || reviewWorkspaces.some((item) => item.canReview) || approvals.items.length > 0),
49
+ canViewSupplierTime: organizationType === 'CLIENT' && clientWorkspaces.some((item) => item.accessMode === 'VIEW'),
50
+ canAccessApprovals:
51
+ reviewWorkspaces.length > 0 &&
52
+ (isAdmin || reviewWorkspaces.some((item) => item.canReview) || approvals.items.length > 0),
53
+ canManageClientReviewers: isAdmin && reviewWorkspaces.length > 0,
54
+ pendingInternalApprovalCount: internalApprovals.filter((item) => item.status === 'SUBMITTED').length,
55
+ pendingClientApprovalCount: approvals.pendingCount,
56
+ unassignedClientReviewerSupplierCount: reviewerSuppliers.filter((item) => item.reviewerCount === 0).length
57
+ } satisfies TimesheetCapabilitiesDto
58
+ })
@@ -0,0 +1,12 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { getClientTimesheets } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const { session, organizationId, role } = await requireActiveOrganizationRole(event)
6
+ return getClientTimesheets(
7
+ getRouterParam(event, 'workspaceClientId')!,
8
+ organizationId,
9
+ session.user.id,
10
+ role === 'owner' || role === 'admin' || session.user.role === 'admin'
11
+ )
12
+ })
@@ -0,0 +1,10 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { listClientReviewers } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const { session, organizationId, role } = await requireActiveOrganizationRole(event)
6
+ if (role !== 'owner' && role !== 'admin' && session.user.role !== 'admin') {
7
+ throw createError({ statusCode: 403, message: 'Client organization admin access required' })
8
+ }
9
+ return listClientReviewers(getRouterParam(event, 'workspaceClientId')!, organizationId)
10
+ })
@@ -0,0 +1,18 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { setClientReviewer } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
3
+ import { clientReviewerUpdateSchema } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-validation'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const { session, organizationId, role } = await requireActiveOrganizationRole(event)
7
+ if (role !== 'owner' && role !== 'admin' && session.user.role !== 'admin') {
8
+ throw createError({ statusCode: 403, message: 'Client organization admin access required' })
9
+ }
10
+ const input = clientReviewerUpdateSchema.parse(await readBody(event))
11
+ return setClientReviewer(
12
+ getRouterParam(event, 'workspaceClientId')!,
13
+ organizationId,
14
+ session.user.id,
15
+ input.userId,
16
+ input.assigned
17
+ )
18
+ })
@@ -0,0 +1,18 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { reviewClientTimesheet } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
3
+ import { clientReviewSchema } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-validation'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const { session, organizationId, role } = await requireActiveOrganizationRole(event)
7
+ const input = clientReviewSchema.parse(await readBody(event))
8
+ return reviewClientTimesheet(
9
+ getRouterParam(event, 'workspaceClientId')!,
10
+ organizationId,
11
+ session.user.id,
12
+ role === 'owner' || role === 'admin' || session.user.role === 'admin',
13
+ getRouterParam(event, 'submissionId')!,
14
+ input.action,
15
+ input.expectedVersion,
16
+ input.comment
17
+ )
18
+ })
@@ -0,0 +1,11 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { listClientApprovals } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const { session, organizationId, role } = await requireActiveOrganizationRole(event)
6
+ const isAdmin = role === 'owner' || role === 'admin' || session.user.role === 'admin'
7
+ const approvals = await listClientApprovals(organizationId, session.user.id, isAdmin)
8
+ return [
9
+ ...new Map(approvals.items.map((item) => [item.userId, { id: item.userId, name: item.person }])).values()
10
+ ].sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))
11
+ })
@@ -0,0 +1,8 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { listClientApprovalSuppliers } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const { session, organizationId, role } = await requireActiveOrganizationRole(event)
6
+ const isAdmin = role === 'owner' || role === 'admin' || session.user.role === 'admin'
7
+ return listClientApprovalSuppliers(organizationId, session.user.id, isAdmin)
8
+ })
@@ -0,0 +1,14 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { clientApprovalListQuerySchema } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-validation'
3
+ import { listClientApprovalsPage } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-admin-listing'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const { session, organizationId, role } = await requireActiveOrganizationRole(event)
7
+ const isAdmin = role === 'owner' || role === 'admin' || session.user.role === 'admin'
8
+ return listClientApprovalsPage(
9
+ organizationId,
10
+ session.user.id,
11
+ isAdmin,
12
+ clientApprovalListQuerySchema.parse(getQuery(event))
13
+ )
14
+ })
@@ -0,0 +1,10 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { listClientReviewerSuppliers } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const { session, organizationId, role } = await requireActiveOrganizationRole(event)
6
+ if (role !== 'owner' && role !== 'admin' && session.user.role !== 'admin') {
7
+ throw createError({ statusCode: 403, message: 'Client organization admin access required' })
8
+ }
9
+ return listClientReviewerSuppliers(organizationId, session.user.id)
10
+ })
@@ -0,0 +1,14 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { listClientSupplierOptions } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const { session, organizationId, organizationType, role } = await requireActiveOrganizationRole(event)
6
+ if (organizationType !== 'CLIENT') {
7
+ throw createError({ statusCode: 403, message: 'Supplier timesheets are only available to client organizations' })
8
+ }
9
+ return listClientSupplierOptions(
10
+ organizationId,
11
+ session.user.id,
12
+ role === 'owner' || role === 'admin' || session.user.role === 'admin'
13
+ )
14
+ })
@@ -0,0 +1,17 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { clientSupplierTimesheetListQuerySchema } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-validation'
3
+ import { listClientSupplierTimesheetsPage } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-admin-listing'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const { session, organizationId, organizationType, role } = await requireActiveOrganizationRole(event)
7
+ if (organizationType !== 'CLIENT') {
8
+ throw createError({ statusCode: 403, message: 'Supplier timesheets are only available to client organizations' })
9
+ }
10
+ const isAdmin = role === 'owner' || role === 'admin' || session.user.role === 'admin'
11
+ return listClientSupplierTimesheetsPage(
12
+ organizationId,
13
+ session.user.id,
14
+ isAdmin,
15
+ clientSupplierTimesheetListQuerySchema.parse(getQuery(event))
16
+ )
17
+ })
@@ -0,0 +1,11 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { listClientWorkspaces } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const { session, organizationId, role } = await requireActiveOrganizationRole(event)
6
+ return listClientWorkspaces(
7
+ organizationId,
8
+ session.user.id,
9
+ role === 'owner' || role === 'admin' || session.user.role === 'admin'
10
+ )
11
+ })
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod'
2
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
3
+ import { internalApprovalListQuerySchema } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-validation'
4
+ import { listProviderClientApprovalsPage } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-admin-listing'
5
+
6
+ export default defineEventHandler(async (event) => {
7
+ const { session, organizationId, role } = await requireActiveOrganizationRole(event)
8
+ if (!['owner', 'admin'].includes(role)) {
9
+ throw createError({ statusCode: 403, message: 'Workspace administrator access required' })
10
+ }
11
+ const query = internalApprovalListQuerySchema
12
+ .extend({
13
+ clientOrganizationId: z.string().min(1).optional(),
14
+ status: z.enum(['PENDING', 'APPROVED', 'DISPUTED', 'AUTO_APPROVED']).optional()
15
+ })
16
+ .parse(getQuery(event))
17
+ return listProviderClientApprovalsPage(organizationId, session.user.id, query)
18
+ })
@@ -0,0 +1,191 @@
1
+ import { selectClientDashboardApprovals } from '@nuxt-customer-portal/timesheets/shared/client-approval-dashboard'
2
+ import { and, desc, eq, inArray, sql } from 'drizzle-orm'
3
+ import { db, requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
4
+ import {
5
+ workspaceSettings,
6
+ timesheetSubmission,
7
+ weeklyTimesheet,
8
+ timeEntry
9
+ } from '@nuxt-customer-portal/timesheets/server/db/schema/timesheets'
10
+ import {
11
+ canMemberEnterTime,
12
+ getBootstrap,
13
+ hasInternalApprovalAssignment,
14
+ listApprovalQueue,
15
+ listClientApprovals,
16
+ listClientReviewerSuppliers,
17
+ listClientSupplierTimesheets,
18
+ listClientWorkspaces
19
+ } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
20
+ import type { TimesheetsDashboardDto } from '@nuxt-customer-portal/timesheets/shared/types/timesheet'
21
+
22
+ export default defineEventHandler(async (event): Promise<TimesheetsDashboardDto> => {
23
+ const { session, organizationId, organizationType, role } = await requireActiveOrganizationRole(event)
24
+ const isAdmin = role === 'owner' || role === 'admin' || session.user.role === 'admin'
25
+ const [settingsRow, clientWorkspaces, memberCanEnterTime] = await Promise.all([
26
+ db
27
+ .select({
28
+ workspaceEnabled: workspaceSettings.workspaceEnabled,
29
+ internalApprovalsEnabled: workspaceSettings.internalApprovalsEnabled,
30
+ currency: workspaceSettings.currency
31
+ })
32
+ .from(workspaceSettings)
33
+ .where(eq(workspaceSettings.organizationId, organizationId))
34
+ .limit(1),
35
+ listClientWorkspaces(organizationId, session.user.id, isAdmin),
36
+ canMemberEnterTime(organizationId, session.user.id)
37
+ ])
38
+ const settings = settingsRow[0]
39
+ const workspaceEnabled = settings?.workspaceEnabled ?? false
40
+ const canEnterTime = workspaceEnabled && memberCanEnterTime
41
+ const reviewWorkspaces = clientWorkspaces.filter((item) => item.accessMode === 'REVIEW')
42
+ const canViewSupplierTime =
43
+ organizationType === 'CLIENT' && clientWorkspaces.some((item) => item.accessMode === 'VIEW')
44
+ const hasInternalApprovals =
45
+ workspaceEnabled &&
46
+ (settings?.internalApprovalsEnabled ?? true) &&
47
+ (await hasInternalApprovalAssignment(organizationId, session.user.id))
48
+
49
+ const [bootstrap, approvalQueue, clientApprovals, reviewerSuppliers, supplierTimesheets] = await Promise.all([
50
+ canEnterTime ? getBootstrap(organizationId, session.user.id) : Promise.resolve(null),
51
+ hasInternalApprovals ? listApprovalQueue(organizationId, session.user.id) : Promise.resolve([]),
52
+ reviewWorkspaces.length ? listClientApprovals(organizationId, session.user.id, isAdmin) : Promise.resolve(null),
53
+ isAdmin && reviewWorkspaces.length
54
+ ? listClientReviewerSuppliers(organizationId, session.user.id)
55
+ : Promise.resolve([]),
56
+ canViewSupplierTime ? listClientSupplierTimesheets(organizationId, session.user.id, isAdmin) : Promise.resolve([])
57
+ ])
58
+
59
+ const pendingInternal = approvalQueue.filter((item) => item.status === 'SUBMITTED')
60
+ const clientDashboard = selectClientDashboardApprovals(clientApprovals?.items ?? [], session.user.id)
61
+ const unsubmittedEntries = bootstrap?.week.entries.filter((entry) => !entry.submissionId) ?? []
62
+ const unsubmittedDates = unsubmittedEntries.map((entry) => entry.entryDate).sort()
63
+
64
+ const previousSubmissions = bootstrap
65
+ ? await db
66
+ .select({
67
+ id: timesheetSubmission.id,
68
+ weekStartsOn: weeklyTimesheet.weekStartsOn,
69
+ periodStartsOn: timesheetSubmission.periodStartsOn,
70
+ periodEndsOn: timesheetSubmission.periodEndsOn,
71
+ status: timesheetSubmission.status,
72
+ totalMinutes: sql<number>`coalesce(sum(${timeEntry.durationMinutes}), 0)`.mapWith(Number)
73
+ })
74
+ .from(timesheetSubmission)
75
+ .innerJoin(weeklyTimesheet, eq(weeklyTimesheet.id, timesheetSubmission.weeklyTimesheetId))
76
+ .leftJoin(timeEntry, eq(timeEntry.submissionId, timesheetSubmission.id))
77
+ .where(
78
+ and(
79
+ eq(timesheetSubmission.organizationId, organizationId),
80
+ eq(timesheetSubmission.userId, session.user.id),
81
+ inArray(timesheetSubmission.status, ['SUBMITTED', 'REJECTED', 'APPROVED'])
82
+ )
83
+ )
84
+ .groupBy(timesheetSubmission.id, weeklyTimesheet.weekStartsOn)
85
+ .orderBy(
86
+ desc(weeklyTimesheet.weekStartsOn),
87
+ desc(timesheetSubmission.periodEndsOn),
88
+ desc(timesheetSubmission.id)
89
+ )
90
+ .limit(5)
91
+ : []
92
+
93
+ return {
94
+ ...(bootstrap && {
95
+ myWeek: {
96
+ previousSubmissions,
97
+ projects: [...new Set(bootstrap.week.entries.map((entry) => entry.projectId))].map((id) => {
98
+ const project = bootstrap.projects.find((item) => item.id === id)!
99
+ return {
100
+ id,
101
+ name: project.name,
102
+ clientName: project.clientName,
103
+ totalMinutes: bootstrap.week.entries
104
+ .filter((entry) => entry.projectId === id)
105
+ .reduce((sum, entry) => sum + entry.durationMinutes, 0)
106
+ }
107
+ }),
108
+ weekStartsOn: bootstrap.week.weekStartsOn,
109
+ status: bootstrap.week.submissions.some((item) => item.status === 'REJECTED')
110
+ ? ('REJECTED' as const)
111
+ : bootstrap.week.submissions.some((item) => item.status === 'SUBMITTED')
112
+ ? ('SUBMITTED' as const)
113
+ : bootstrap.week.entries.length > 0 &&
114
+ bootstrap.week.entries.every((item) => item.submissionStatus === 'APPROVED')
115
+ ? ('APPROVED' as const)
116
+ : ('DRAFT' as const),
117
+ totalMinutes: bootstrap.week.entries.reduce((sum, entry) => sum + entry.durationMinutes, 0),
118
+ rejectionComment:
119
+ bootstrap.week.submissions.find((item) => item.status === 'REJECTED')?.rejectionComment ?? null,
120
+ hasRunningTimer: bootstrap.week.entries.some((entry) => Boolean(entry.timerStartedAt)),
121
+ batches: bootstrap.week.submissions.map((submission) => ({
122
+ id: submission.id,
123
+ status: submission.status,
124
+ totalMinutes: bootstrap.week.entries
125
+ .filter((entry) => entry.submissionId === submission.id)
126
+ .reduce((sum, entry) => sum + entry.durationMinutes, 0),
127
+ periodStartsOn: submission.periodStartsOn,
128
+ periodEndsOn: submission.periodEndsOn
129
+ })),
130
+ unsubmitted: unsubmittedEntries.length
131
+ ? {
132
+ totalMinutes: unsubmittedEntries.reduce((sum, entry) => sum + entry.durationMinutes, 0),
133
+ periodStartsOn: unsubmittedDates[0]!,
134
+ periodEndsOn: unsubmittedDates.at(-1)!
135
+ }
136
+ : null
137
+ }
138
+ }),
139
+ ...(hasInternalApprovals && {
140
+ internalApprovals: {
141
+ pendingCount: pendingInternal.length,
142
+ items: approvalQueue
143
+ .slice(0, 5)
144
+ .map(({ id, userName, weekStartsOn, periodStartsOn, periodEndsOn, totalMinutes, submittedAt, status }) => ({
145
+ id,
146
+ userName,
147
+ weekStartsOn,
148
+ periodStartsOn,
149
+ periodEndsOn,
150
+ totalMinutes,
151
+ submittedAt,
152
+ status
153
+ }))
154
+ }
155
+ }),
156
+ ...(reviewWorkspaces.length && {
157
+ clientApprovals: {
158
+ pendingCount: clientDashboard.pendingCount,
159
+ hasHistory: clientDashboard.hasHistory,
160
+ unassignedSupplierCount: reviewerSuppliers.filter((item) => item.reviewerCount === 0).length,
161
+ items: clientDashboard.items.map(
162
+ ({ id, supplierName, person, weekStartsOn, periodStartsOn, periodEndsOn, totalMinutes, status }) => ({
163
+ status,
164
+ id,
165
+ supplierName,
166
+ person,
167
+ weekStartsOn,
168
+ periodStartsOn,
169
+ periodEndsOn,
170
+ totalMinutes
171
+ })
172
+ )
173
+ }
174
+ }),
175
+ ...(canViewSupplierTime && {
176
+ supplierTimesheets: {
177
+ items: supplierTimesheets
178
+ .slice(0, 5)
179
+ .map(({ id, supplierName, person, weekStartsOn, periodStartsOn, periodEndsOn, totalMinutes }) => ({
180
+ id,
181
+ supplierName,
182
+ person,
183
+ weekStartsOn,
184
+ periodStartsOn,
185
+ periodEndsOn,
186
+ totalMinutes
187
+ }))
188
+ }
189
+ })
190
+ }
191
+ })
@@ -0,0 +1,18 @@
1
+ import { requireFeatureAccess } from '@nuxt-customer-portal/core/server/portal'
2
+ import { timesheetsFeature } from '@nuxt-customer-portal/timesheets/shared/feature'
3
+ import { deleteEntry } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
4
+
5
+ defineRouteMeta({
6
+ openAPI: {
7
+ tags: ['Timesheets'],
8
+ operationId: 'timesheetsEntriesByIdDelete',
9
+ summary: 'Delete a time entry',
10
+ description: 'Delete a time entry. Scoped to the active organization and the applicable Timesheets permission.'
11
+ }
12
+ })
13
+
14
+ export default defineEventHandler(async (event) => {
15
+ const { session, organizationId } = await requireFeatureAccess(event, timesheetsFeature.policy, 'update')
16
+ await deleteEntry(organizationId, session.user.id, getRouterParam(event, 'id')!)
17
+ return { success: true }
18
+ })
@@ -0,0 +1,23 @@
1
+ import { requireFeatureAccess } from '@nuxt-customer-portal/core/server/portal'
2
+ import { timesheetsFeature } from '@nuxt-customer-portal/timesheets/shared/feature'
3
+ import { updateEntry } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
4
+ import { entryUpdateSchema } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-validation'
5
+
6
+ defineRouteMeta({
7
+ openAPI: {
8
+ tags: ['Timesheets'],
9
+ operationId: 'timesheetsEntriesByIdPatch',
10
+ summary: 'Update a time entry',
11
+ description: 'Update a time entry. Scoped to the active organization and the applicable Timesheets permission.'
12
+ }
13
+ })
14
+
15
+ export default defineEventHandler(async (event) => {
16
+ const { session, organizationId } = await requireFeatureAccess(event, timesheetsFeature.policy, 'update')
17
+ return updateEntry(
18
+ organizationId,
19
+ session.user.id,
20
+ getRouterParam(event, 'id')!,
21
+ entryUpdateSchema.parse(await readBody(event))
22
+ )
23
+ })