@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,22 @@
1
+ import { requireFeatureAccess } from '@nuxt-customer-portal/core/server/portal'
2
+ import { timesheetsFeature } from '@nuxt-customer-portal/timesheets/shared/feature'
3
+ import {
4
+ createEntry,
5
+ requireTimesheetWorkspace
6
+ } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
7
+ import { entryCreateSchema } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-validation'
8
+
9
+ defineRouteMeta({
10
+ openAPI: {
11
+ tags: ['Timesheets'],
12
+ operationId: 'timesheetsEntriesPost',
13
+ summary: 'Create a time entry',
14
+ description: 'Create a time entry. Scoped to the active organization and the applicable Timesheets permission.'
15
+ }
16
+ })
17
+
18
+ export default defineEventHandler(async (event) => {
19
+ const { session, organizationId } = await requireFeatureAccess(event, timesheetsFeature.policy, 'create')
20
+ await requireTimesheetWorkspace(organizationId)
21
+ return createEntry(organizationId, session.user.id, entryCreateSchema.parse(await readBody(event)))
22
+ })
@@ -0,0 +1,9 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { reviewSubmission } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
3
+ import { reviewSchema } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-validation'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const { session, organizationId } = await requireActiveOrganizationRole(event)
7
+ const input = reviewSchema.parse(await readBody(event))
8
+ return reviewSubmission(organizationId, session.user.id, getRouterParam(event, 'id')!, input.action, input.comment)
9
+ })
@@ -0,0 +1,37 @@
1
+ import { requireActiveOrganizationRole, listPortalOrganizationMembers } from '@nuxt-customer-portal/core/server/portal'
2
+ import {
3
+ ensureSettings,
4
+ listActivities,
5
+ listInternalApprovalMembers,
6
+ listApprovalQueue,
7
+ listClients,
8
+ listProjects
9
+ } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
10
+
11
+ export default defineEventHandler(async (event) => {
12
+ const { session, organizationId, role } = await requireActiveOrganizationRole(event)
13
+ const [settings, approvals, clients, projects, activities, teamMembers] = await Promise.all([
14
+ ensureSettings(organizationId),
15
+ getQuery(event).contextOnly === 'true' ? Promise.resolve([]) : listApprovalQueue(organizationId, session.user.id),
16
+ listClients(organizationId),
17
+ listProjects(organizationId),
18
+ listActivities(organizationId),
19
+ ['owner', 'admin'].includes(role)
20
+ ? listPortalOrganizationMembers(organizationId)
21
+ : listInternalApprovalMembers(organizationId, session.user.id)
22
+ ])
23
+ return {
24
+ settings: {
25
+ currency: settings.currency,
26
+ timezone: settings.timezone,
27
+ timerRoundingMinutes: settings.timerRoundingMinutes,
28
+ weekStartsOn: settings.weekStartsOn,
29
+ internalApprovalsEnabled: settings.internalApprovalsEnabled
30
+ },
31
+ approvals,
32
+ teamMembers,
33
+ clients,
34
+ projects,
35
+ activities
36
+ }
37
+ })
@@ -0,0 +1,12 @@
1
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
2
+ import { internalApprovalListQuerySchema } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-validation'
3
+ import { listInternalApprovalsPage } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-admin-listing'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const { session, organizationId } = await requireActiveOrganizationRole(event)
7
+ return listInternalApprovalsPage(
8
+ organizationId,
9
+ session.user.id,
10
+ internalApprovalListQuerySchema.parse(getQuery(event))
11
+ )
12
+ })
@@ -0,0 +1,21 @@
1
+ import { requireFeatureAccess } from '@nuxt-customer-portal/core/server/portal'
2
+ import { timesheetsFeature } from '@nuxt-customer-portal/timesheets/shared/feature'
3
+ import {
4
+ getTimesheetsSetupStatus,
5
+ requireTimesheetWorkspace
6
+ } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
7
+
8
+ defineRouteMeta({
9
+ openAPI: {
10
+ tags: ['Timesheets'],
11
+ operationId: 'timesheetsSetupStatusGet',
12
+ summary: 'Get Timesheets setup readiness',
13
+ description: 'Get the active organization’s Timesheets setup checklist. Requires Timesheets management access.'
14
+ }
15
+ })
16
+
17
+ export default defineEventHandler(async (event) => {
18
+ const { organizationId } = await requireFeatureAccess(event, timesheetsFeature.policy, 'manage')
19
+ await requireTimesheetWorkspace(organizationId)
20
+ return getTimesheetsSetupStatus(organizationId)
21
+ })
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod'
2
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
3
+ import { replyClientTimesheet } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
4
+ import { submissionReplySchema } from '@nuxt-customer-portal/timesheets/shared/submission-reply'
5
+
6
+ export default defineEventHandler(async (event) => {
7
+ const { organizationId, session } = await requireActiveOrganizationRole(event)
8
+ const input = submissionReplySchema
9
+ .extend({ clientOrganizationId: z.string().min(1), expectedVersion: z.number().int().min(0) })
10
+ .parse(await readBody(event))
11
+ await replyClientTimesheet(
12
+ organizationId,
13
+ session.user.id,
14
+ getRouterParam(event, 'id')!,
15
+ input.clientOrganizationId,
16
+ input.expectedVersion,
17
+ input.reply
18
+ )
19
+ return { success: true }
20
+ })
@@ -0,0 +1,21 @@
1
+ import { z } from 'zod'
2
+ import { requireActiveOrganizationRole } from '@nuxt-customer-portal/core/server/portal'
3
+ import { requestClientApproval } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const { organizationId, role, session } = await requireActiveOrganizationRole(event)
7
+ if (!['owner', 'admin'].includes(role)) {
8
+ throw createError({ statusCode: 403, message: 'Workspace administrator access required' })
9
+ }
10
+ const input = z
11
+ .object({ clientOrganizationId: z.string().min(1), message: z.string().trim().max(5000).optional() })
12
+ .parse(await readBody(event))
13
+ await requestClientApproval(
14
+ organizationId,
15
+ getRouterParam(event, 'id')!,
16
+ input.clientOrganizationId,
17
+ session.user.id,
18
+ input.message
19
+ )
20
+ return { success: true }
21
+ })
@@ -0,0 +1,10 @@
1
+ import { submissionReplySchema } from '@nuxt-customer-portal/timesheets/shared/submission-reply'
2
+ import { requireFeatureAccess } from '@nuxt-customer-portal/core/server/portal'
3
+ import { timesheetsFeature } from '@nuxt-customer-portal/timesheets/shared/feature'
4
+ import { resubmitSubmission } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
5
+
6
+ export default defineEventHandler(async (event) => {
7
+ const { organizationId, session } = await requireFeatureAccess(event, timesheetsFeature.policy, 'submit')
8
+ const input = submissionReplySchema.parse((await readBody(event)) ?? {})
9
+ return resubmitSubmission(organizationId, session.user.id, getRouterParam(event, 'id')!, input.reply)
10
+ })
@@ -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 { stopTimer } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
4
+
5
+ defineRouteMeta({
6
+ openAPI: {
7
+ tags: ['Timesheets'],
8
+ operationId: 'timesheetsTimerDelete',
9
+ summary: 'Stop the time tracking timer',
10
+ description:
11
+ 'Stop the time tracking timer. 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 stopTimer(organizationId, session.user.id)
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 {
4
+ requireTimesheetWorkspace,
5
+ startTimer
6
+ } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
7
+ import { timerStartSchema } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-validation'
8
+
9
+ defineRouteMeta({
10
+ openAPI: {
11
+ tags: ['Timesheets'],
12
+ operationId: 'timesheetsTimerPost',
13
+ summary: 'Start the time tracking timer',
14
+ description:
15
+ 'Start the time tracking timer. 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, 'create')
21
+ await requireTimesheetWorkspace(organizationId)
22
+ return startTimer(organizationId, session.user.id, timerStartSchema.parse(await readBody(event)))
23
+ })
@@ -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 { submitWeek } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-repository'
4
+ import { submissionCreateSchema } from '@nuxt-customer-portal/timesheets/server/utils/timesheet-validation'
5
+
6
+ defineRouteMeta({
7
+ openAPI: {
8
+ tags: ['Timesheets'],
9
+ operationId: 'timesheetsWeeksByIdSubmissionsPost',
10
+ summary: 'Create a timesheet submission batch',
11
+ description: 'Submit eligible entries through a cutoff date as a new approval batch.'
12
+ }
13
+ })
14
+
15
+ export default defineEventHandler(async (event) => {
16
+ const { session, organizationId } = await requireFeatureAccess(event, timesheetsFeature.policy, 'submit')
17
+ const input = submissionCreateSchema.parse(await readBody(event))
18
+ return submitWeek(organizationId, session.user.id, getRouterParam(event, 'id')!, input.cutoffDate, input.comment)
19
+ })
@@ -0,0 +1,383 @@
1
+ import { boolean, date, index, integer, pgSchema, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'
2
+ import { organization, user } from '@nuxt-customer-portal/core/schema'
3
+
4
+ export const timesheetsSchema = pgSchema('timesheets')
5
+
6
+ export const timesheetStatus = timesheetsSchema.enum('timesheet_status', ['DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED'])
7
+ export const projectStatus = timesheetsSchema.enum('project_status', ['ACTIVE', 'ARCHIVED'])
8
+ export const approvalAction = timesheetsSchema.enum('approval_action', [
9
+ 'SUBMITTED',
10
+ 'APPROVED',
11
+ 'REJECTED',
12
+ 'REOPENED'
13
+ ])
14
+ export const clientAccessMode = timesheetsSchema.enum('client_access_mode', ['DISABLED', 'VIEW', 'REVIEW'])
15
+ export const clientReviewStatus = timesheetsSchema.enum('client_review_status', ['PENDING', 'APPROVED', 'DISPUTED'])
16
+ export const clientReviewAction = timesheetsSchema.enum('client_review_action', ['APPROVED', 'DISPUTED', 'SUBMITTED'])
17
+
18
+ const auditColumns = {
19
+ createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
20
+ updatedAt: timestamp('updated_at', { mode: 'date' })
21
+ .defaultNow()
22
+ .$onUpdate(() => new Date())
23
+ .notNull()
24
+ }
25
+
26
+ export const workspaceSettings = timesheetsSchema.table('workspace_settings', {
27
+ organizationId: text('organization_id')
28
+ .primaryKey()
29
+ .references(() => organization.id, { onDelete: 'cascade' }),
30
+ currency: text('currency').default('EUR').notNull(),
31
+ timezone: text('timezone').default('Europe/Amsterdam').notNull(),
32
+ timerRoundingMinutes: integer('timer_rounding_minutes').default(1).notNull(),
33
+ weekStartsOn: integer('week_starts_on').default(1).notNull(),
34
+ workspaceEnabled: boolean('workspace_enabled').default(false).notNull(),
35
+ internalApprovalsEnabled: boolean('internal_approvals_enabled').default(true).notNull(),
36
+ ...auditColumns
37
+ })
38
+
39
+ export const workspaceClient = timesheetsSchema.table(
40
+ 'workspace_client',
41
+ {
42
+ id: text('id').primaryKey(),
43
+ workspaceOrganizationId: text('workspace_organization_id')
44
+ .notNull()
45
+ .references(() => organization.id, { onDelete: 'cascade' }),
46
+ clientOrganizationId: text('client_organization_id')
47
+ .notNull()
48
+ .references(() => organization.id, { onDelete: 'restrict' }),
49
+ accessMode: clientAccessMode('access_mode').default('DISABLED').notNull(),
50
+ ...auditColumns
51
+ },
52
+ (table) => [
53
+ uniqueIndex('workspace_client_workspace_client_uidx').on(table.workspaceOrganizationId, table.clientOrganizationId),
54
+ index('workspace_client_workspace_idx').on(table.workspaceOrganizationId)
55
+ ]
56
+ )
57
+
58
+ export const workspaceClientReviewer = timesheetsSchema.table(
59
+ 'workspace_client_reviewer',
60
+ {
61
+ id: text('id').primaryKey(),
62
+ workspaceClientId: text('workspace_client_id')
63
+ .notNull()
64
+ .references(() => workspaceClient.id, { onDelete: 'cascade' }),
65
+ userId: text('user_id')
66
+ .notNull()
67
+ .references(() => user.id, { onDelete: 'cascade' }),
68
+ createdById: text('created_by_id')
69
+ .notNull()
70
+ .references(() => user.id, { onDelete: 'restrict' }),
71
+ createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull()
72
+ },
73
+ (table) => [
74
+ uniqueIndex('workspace_client_reviewer_link_user_uidx').on(table.workspaceClientId, table.userId),
75
+ index('workspace_client_reviewer_user_idx').on(table.userId)
76
+ ]
77
+ )
78
+
79
+ export const activityType = timesheetsSchema.table(
80
+ 'activity_type',
81
+ {
82
+ id: text('id').primaryKey(),
83
+ organizationId: text('organization_id')
84
+ .notNull()
85
+ .references(() => organization.id, { onDelete: 'cascade' }),
86
+ name: text('name').notNull(),
87
+ billable: boolean('billable').default(true).notNull(),
88
+ active: boolean('active').default(true).notNull(),
89
+ ...auditColumns
90
+ },
91
+ (table) => [
92
+ uniqueIndex('activity_type_org_name_uidx').on(table.organizationId, table.name),
93
+ index('activity_type_org_idx').on(table.organizationId)
94
+ ]
95
+ )
96
+
97
+ export const project = timesheetsSchema.table(
98
+ 'project',
99
+ {
100
+ id: text('id').primaryKey(),
101
+ organizationId: text('organization_id')
102
+ .notNull()
103
+ .references(() => organization.id, { onDelete: 'cascade' }),
104
+ clientOrganizationId: text('client_organization_id')
105
+ .notNull()
106
+ .references(() => organization.id, { onDelete: 'restrict' }),
107
+ name: text('name').notNull(),
108
+ code: text('code'),
109
+ status: projectStatus('status').default('ACTIVE').notNull(),
110
+ startsOn: date('starts_on', { mode: 'string' }),
111
+ endsOn: date('ends_on', { mode: 'string' }),
112
+ budgetMinutes: integer('budget_minutes'),
113
+ budgetMinor: integer('budget_minor'),
114
+ ...auditColumns
115
+ },
116
+ (table) => [
117
+ uniqueIndex('project_org_name_uidx').on(table.organizationId, table.name),
118
+ index('project_org_status_idx').on(table.organizationId, table.status),
119
+ index('project_client_idx').on(table.clientOrganizationId)
120
+ ]
121
+ )
122
+
123
+ export const projectActivity = timesheetsSchema.table(
124
+ 'project_activity',
125
+ {
126
+ id: text('id').primaryKey(),
127
+ projectId: text('project_id')
128
+ .notNull()
129
+ .references(() => project.id, { onDelete: 'cascade' }),
130
+ activityTypeId: text('activity_type_id')
131
+ .notNull()
132
+ .references(() => activityType.id, { onDelete: 'cascade' })
133
+ },
134
+ (table) => [uniqueIndex('project_activity_project_activity_uidx').on(table.projectId, table.activityTypeId)]
135
+ )
136
+
137
+ export const teamTariff = timesheetsSchema.table(
138
+ 'team_tariff',
139
+ {
140
+ id: text('id').primaryKey(),
141
+ organizationId: text('organization_id')
142
+ .notNull()
143
+ .references(() => organization.id, { onDelete: 'cascade' }),
144
+ userId: text('user_id')
145
+ .notNull()
146
+ .references(() => user.id, { onDelete: 'cascade' }),
147
+ hourlyRateMinor: integer('hourly_rate_minor').notNull(),
148
+ ...auditColumns
149
+ },
150
+ (table) => [uniqueIndex('team_tariff_org_user_uidx').on(table.organizationId, table.userId)]
151
+ )
152
+
153
+ export const teamMemberSettings = timesheetsSchema.table(
154
+ 'team_member_settings',
155
+ {
156
+ id: text('id').primaryKey(),
157
+ organizationId: text('organization_id')
158
+ .notNull()
159
+ .references(() => organization.id, { onDelete: 'cascade' }),
160
+ userId: text('user_id')
161
+ .notNull()
162
+ .references(() => user.id, { onDelete: 'cascade' }),
163
+ canEnterTime: boolean('can_enter_time').default(true).notNull(),
164
+ internalApprovalRequired: boolean('internal_approval_required').default(true).notNull(),
165
+ ...auditColumns
166
+ },
167
+ (table) => [uniqueIndex('team_member_settings_org_user_uidx').on(table.organizationId, table.userId)]
168
+ )
169
+
170
+ export const internalApproverAssignment = timesheetsSchema.table(
171
+ 'internal_approver_assignment',
172
+ {
173
+ id: text('id').primaryKey(),
174
+ organizationId: text('organization_id')
175
+ .notNull()
176
+ .references(() => organization.id, { onDelete: 'cascade' }),
177
+ submitterUserId: text('submitter_user_id')
178
+ .notNull()
179
+ .references(() => user.id, { onDelete: 'cascade' }),
180
+ approverUserId: text('approver_user_id')
181
+ .notNull()
182
+ .references(() => user.id, { onDelete: 'cascade' }),
183
+ createdById: text('created_by_id')
184
+ .notNull()
185
+ .references(() => user.id, { onDelete: 'restrict' }),
186
+ createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull()
187
+ },
188
+ (table) => [
189
+ uniqueIndex('internal_approver_assignment_org_submitter_approver_uidx').on(
190
+ table.organizationId,
191
+ table.submitterUserId,
192
+ table.approverUserId
193
+ ),
194
+ index('internal_approver_assignment_approver_idx').on(table.organizationId, table.approverUserId),
195
+ index('internal_approver_assignment_submitter_idx').on(table.organizationId, table.submitterUserId)
196
+ ]
197
+ )
198
+
199
+ export const projectPersonTariff = timesheetsSchema.table(
200
+ 'project_person_tariff',
201
+ {
202
+ id: text('id').primaryKey(),
203
+ projectId: text('project_id')
204
+ .notNull()
205
+ .references(() => project.id, { onDelete: 'cascade' }),
206
+ userId: text('user_id')
207
+ .notNull()
208
+ .references(() => user.id, { onDelete: 'cascade' }),
209
+ hourlyRateMinor: integer('hourly_rate_minor').notNull(),
210
+ ...auditColumns
211
+ },
212
+ (table) => [uniqueIndex('project_person_tariff_project_user_uidx').on(table.projectId, table.userId)]
213
+ )
214
+
215
+ export const weeklyTimesheet = timesheetsSchema.table(
216
+ 'weekly_timesheet',
217
+ {
218
+ id: text('id').primaryKey(),
219
+ organizationId: text('organization_id')
220
+ .notNull()
221
+ .references(() => organization.id, { onDelete: 'cascade' }),
222
+ userId: text('user_id')
223
+ .notNull()
224
+ .references(() => user.id, { onDelete: 'cascade' }),
225
+ weekStartsOn: date('week_starts_on', { mode: 'string' }).notNull(),
226
+ status: timesheetStatus('status').default('DRAFT').notNull(),
227
+ submittedAt: timestamp('submitted_at', { mode: 'date' }),
228
+ reviewedAt: timestamp('reviewed_at', { mode: 'date' }),
229
+ reviewedById: text('reviewed_by_id').references(() => user.id, { onDelete: 'set null' }),
230
+ rejectionComment: text('rejection_comment'),
231
+ ...auditColumns
232
+ },
233
+ (table) => [
234
+ uniqueIndex('weekly_timesheet_org_user_week_uidx').on(table.organizationId, table.userId, table.weekStartsOn),
235
+ index('weekly_timesheet_org_status_idx').on(table.organizationId, table.status)
236
+ ]
237
+ )
238
+
239
+ export const timesheetSubmission = timesheetsSchema.table(
240
+ 'submission',
241
+ {
242
+ id: text('id').primaryKey(),
243
+ weeklyTimesheetId: text('weekly_timesheet_id')
244
+ .notNull()
245
+ .references(() => weeklyTimesheet.id, { onDelete: 'cascade' }),
246
+ organizationId: text('organization_id')
247
+ .notNull()
248
+ .references(() => organization.id, { onDelete: 'cascade' }),
249
+ userId: text('user_id')
250
+ .notNull()
251
+ .references(() => user.id, { onDelete: 'cascade' }),
252
+ periodStartsOn: date('period_starts_on', { mode: 'string' }).notNull(),
253
+ periodEndsOn: date('period_ends_on', { mode: 'string' }).notNull(),
254
+ status: timesheetStatus('status').default('DRAFT').notNull(),
255
+ submittedAt: timestamp('submitted_at', { mode: 'date' }),
256
+ reviewedAt: timestamp('reviewed_at', { mode: 'date' }),
257
+ reviewedById: text('reviewed_by_id').references(() => user.id, { onDelete: 'set null' }),
258
+ rejectionComment: text('rejection_comment'),
259
+ version: integer('version').default(1).notNull(),
260
+ ...auditColumns
261
+ },
262
+ (table) => [
263
+ index('submission_week_idx').on(table.weeklyTimesheetId),
264
+ index('submission_org_status_idx').on(table.organizationId, table.status),
265
+ index('submission_user_period_idx').on(table.userId, table.periodStartsOn, table.periodEndsOn)
266
+ ]
267
+ )
268
+
269
+ export const timeEntry = timesheetsSchema.table(
270
+ 'time_entry',
271
+ {
272
+ id: text('id').primaryKey(),
273
+ organizationId: text('organization_id')
274
+ .notNull()
275
+ .references(() => organization.id, { onDelete: 'cascade' }),
276
+ weeklyTimesheetId: text('weekly_timesheet_id')
277
+ .notNull()
278
+ .references(() => weeklyTimesheet.id, { onDelete: 'cascade' }),
279
+ submissionId: text('submission_id').references(() => timesheetSubmission.id, { onDelete: 'restrict' }),
280
+ userId: text('user_id')
281
+ .notNull()
282
+ .references(() => user.id, { onDelete: 'cascade' }),
283
+ projectId: text('project_id')
284
+ .notNull()
285
+ .references(() => project.id, { onDelete: 'restrict' }),
286
+ clientOrganizationId: text('client_organization_id')
287
+ .notNull()
288
+ .references(() => organization.id, { onDelete: 'restrict' }),
289
+ activityTypeId: text('activity_type_id')
290
+ .notNull()
291
+ .references(() => activityType.id, { onDelete: 'restrict' }),
292
+ entryDate: date('entry_date', { mode: 'string' }).notNull(),
293
+ durationMinutes: integer('duration_minutes').default(0).notNull(),
294
+ note: text('note'),
295
+ billableSnapshot: boolean('billable_snapshot').notNull(),
296
+ hourlyRateMinorSnapshot: integer('hourly_rate_minor_snapshot').notNull(),
297
+ currencySnapshot: text('currency_snapshot').notNull(),
298
+ timerStartedAt: timestamp('timer_started_at', { mode: 'date' }),
299
+ ...auditColumns
300
+ },
301
+ (table) => [
302
+ index('time_entry_org_user_date_idx').on(table.organizationId, table.userId, table.entryDate),
303
+ index('time_entry_week_idx').on(table.weeklyTimesheetId),
304
+ index('time_entry_submission_idx').on(table.submissionId),
305
+ index('time_entry_project_idx').on(table.projectId),
306
+ index('time_entry_client_idx').on(table.clientOrganizationId)
307
+ ]
308
+ )
309
+
310
+ export const timesheetClientReview = timesheetsSchema.table(
311
+ 'client_review',
312
+ {
313
+ id: text('id').primaryKey(),
314
+ weeklyTimesheetId: text('weekly_timesheet_id')
315
+ .notNull()
316
+ .references(() => weeklyTimesheet.id, { onDelete: 'cascade' }),
317
+ submissionId: text('submission_id').references(() => timesheetSubmission.id, { onDelete: 'cascade' }),
318
+ clientOrganizationId: text('client_organization_id')
319
+ .notNull()
320
+ .references(() => organization.id, { onDelete: 'restrict' }),
321
+ status: clientReviewStatus('status').default('PENDING').notNull(),
322
+ reviewerUserId: text('reviewer_user_id').references(() => user.id, { onDelete: 'set null' }),
323
+ comment: text('comment'),
324
+ version: integer('version').default(1).notNull(),
325
+ reviewedAt: timestamp('reviewed_at', { mode: 'date' }),
326
+ ...auditColumns
327
+ },
328
+ (table) => [
329
+ uniqueIndex('client_review_submission_client_uidx').on(table.submissionId, table.clientOrganizationId),
330
+ index('client_review_client_status_idx').on(table.clientOrganizationId, table.status)
331
+ ]
332
+ )
333
+
334
+ export const timesheetClientReviewHistory = timesheetsSchema.table(
335
+ 'client_review_history',
336
+ {
337
+ id: text('id').primaryKey(),
338
+ weeklyTimesheetId: text('weekly_timesheet_id')
339
+ .notNull()
340
+ .references(() => weeklyTimesheet.id, { onDelete: 'cascade' }),
341
+ submissionId: text('submission_id').references(() => timesheetSubmission.id, { onDelete: 'cascade' }),
342
+ clientOrganizationId: text('client_organization_id')
343
+ .notNull()
344
+ .references(() => organization.id, { onDelete: 'restrict' }),
345
+ action: clientReviewAction('action').notNull(),
346
+ actorUserId: text('actor_user_id')
347
+ .notNull()
348
+ .references(() => user.id, { onDelete: 'restrict' }),
349
+ comment: text('comment'),
350
+ createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull()
351
+ },
352
+ (table) => [
353
+ index('client_review_history_week_client_idx').on(
354
+ table.weeklyTimesheetId,
355
+ table.clientOrganizationId,
356
+ table.createdAt
357
+ )
358
+ ]
359
+ )
360
+
361
+ export const timesheetApprovalHistory = timesheetsSchema.table(
362
+ 'approval_history',
363
+ {
364
+ id: text('id').primaryKey(),
365
+ weeklyTimesheetId: text('weekly_timesheet_id')
366
+ .notNull()
367
+ .references(() => weeklyTimesheet.id, { onDelete: 'cascade' }),
368
+ submissionId: text('submission_id').references(() => timesheetSubmission.id, { onDelete: 'cascade' }),
369
+ action: approvalAction('action').notNull(),
370
+ actorUserId: text('actor_user_id')
371
+ .notNull()
372
+ .references(() => user.id, { onDelete: 'restrict' }),
373
+ comment: text('comment'),
374
+ createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull()
375
+ },
376
+ (table) => [index('approval_history_week_idx').on(table.weeklyTimesheetId)]
377
+ )
378
+
379
+ export type ProjectRecord = typeof project.$inferSelect
380
+ export type ActivityTypeRecord = typeof activityType.$inferSelect
381
+ export type TimeEntryRecord = typeof timeEntry.$inferSelect
382
+ export type WeeklyTimesheetRecord = typeof weeklyTimesheet.$inferSelect
383
+ export type TimesheetSubmissionRecord = typeof timesheetSubmission.$inferSelect
@@ -0,0 +1,30 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import { nanoid } from 'nanoid'
3
+ import { organization } from '@nuxt-customer-portal/core/schema'
4
+ import { registerClientCreatedHook } from '@nuxt-customer-portal/core/server/utils/business-hooks'
5
+ import { workspaceClient } from '@nuxt-customer-portal/timesheets/server/db/schema/timesheets'
6
+
7
+ type PortalTransaction = typeof import('@nuxt-customer-portal/core/server/portal').db
8
+
9
+ export default defineNitroPlugin(() =>
10
+ registerClientCreatedHook(async (transaction, clientOrganizationId) => {
11
+ const tx = transaction as PortalTransaction
12
+ const providers = await tx
13
+ .select({ id: organization.id })
14
+ .from(organization)
15
+ .where(eq(organization.organizationType, 'PROVIDER'))
16
+ if (!providers.length) {
17
+ return
18
+ }
19
+ await tx
20
+ .insert(workspaceClient)
21
+ .values(
22
+ providers.map((provider) => ({
23
+ id: nanoid(),
24
+ workspaceOrganizationId: provider.id,
25
+ clientOrganizationId
26
+ }))
27
+ )
28
+ .onConflictDoNothing()
29
+ })
30
+ )