@nuxt-customer-portal/timesheets 0.5.0 → 0.6.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 (45) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +6 -0
  3. package/app/components/TimesheetsAdminApprovals.vue +26 -0
  4. package/app/components/TimesheetsApprovalEmails.vue +175 -0
  5. package/app/components/TimesheetsClientSettingsPanel.vue +47 -9
  6. package/app/components/TimesheetsReviewRequests.vue +320 -0
  7. package/app/composables/useTimesheets.ts +17 -0
  8. package/app/pages/timesheets/approvals.vue +5 -1
  9. package/app/pages/timesheets/client-approvals.vue +3 -1
  10. package/app/plugins/timesheets-feature.ts +1 -1
  11. package/i18n/locales/en.json +82 -1
  12. package/i18n/locales/nl.json +82 -1
  13. package/migrations/0006_approval_email.sql +16 -0
  14. package/migrations/0007_client_review_requests.sql +109 -0
  15. package/package.json +5 -5
  16. package/portal.manifest.mjs +1 -1
  17. package/server/api/timesheets/admin/client-settings/[clientId]/index.get.ts +5 -1
  18. package/server/api/timesheets/admin/client-settings/[clientId]/index.put.ts +14 -5
  19. package/server/api/timesheets/admin/review-periods.get.ts +27 -0
  20. package/server/api/timesheets/admin/review-requests/[id]/index.get.ts +21 -0
  21. package/server/api/timesheets/admin/review-requests/[id]/withdraw.post.ts +27 -0
  22. package/server/api/timesheets/admin/review-requests/index.get.ts +15 -0
  23. package/server/api/timesheets/admin/review-requests/index.post.ts +31 -0
  24. package/server/api/timesheets/admin/review-submissions/[id]/reopen.post.ts +15 -0
  25. package/server/api/timesheets/client/review-requests/[id]/index.get.ts +18 -0
  26. package/server/api/timesheets/client/review-requests/[id]/index.post.ts +23 -0
  27. package/server/api/timesheets/client/review-requests/index.get.ts +12 -0
  28. package/server/api/timesheets/dashboard.get.ts +26 -1
  29. package/server/api/timesheets/submissions/[id]/approval-email.get.ts +17 -0
  30. package/server/api/timesheets/submissions/[id]/approval-email.post.ts +64 -0
  31. package/server/db/schema/timesheets.ts +127 -1
  32. package/server/utils/client-review-request-email.ts +85 -0
  33. package/server/utils/client-review-requests.ts +376 -0
  34. package/server/utils/timesheet-admin-listing.ts +18 -4
  35. package/server/utils/timesheet-approval-email-context.ts +102 -0
  36. package/server/utils/timesheet-approval-email-draft.ts +56 -0
  37. package/server/utils/timesheet-approval-email.ts +56 -0
  38. package/server/utils/timesheet-email-recipients.ts +68 -0
  39. package/server/utils/timesheet-email.ts +49 -77
  40. package/server/utils/timesheet-repository.ts +74 -13
  41. package/shared/approval-email.ts +31 -0
  42. package/shared/client-review-periods.ts +42 -0
  43. package/shared/emails.ts +27 -0
  44. package/shared/types/client-review-request.ts +58 -0
  45. package/shared/types/timesheet.ts +21 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # @nuxt-customer-portal/timesheets
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cff4891: Add weekly, two-weekly, and monthly client review periods with provider-controlled combined requests, entry snapshots, withdrawal and correction history, supplementary requests, and English/Dutch notifications. Keep weekly internal approvals and historical reviews intact; interval changes start next month.
8
+
9
+ Require client approval before invoicing review-enabled client time from the persisted next-month rollout date. Run package migrations before deploying the new application code.
10
+
11
+ ### Patch Changes
12
+
13
+ - @nuxt-customer-portal/clients@0.6.0
14
+ - @nuxt-customer-portal/core@0.6.0
15
+ - @nuxt-customer-portal/organizations@0.6.0
16
+ - @nuxt-customer-portal/ui@0.6.0
17
+
18
+ ## 0.5.1
19
+
20
+ ### Patch Changes
21
+
22
+ - 1528005: Track approval request email recipients, timestamps, messages and send status. Allow pending internal and client approval emails to be resent to current approvers with an editable message.
23
+ - @nuxt-customer-portal/clients@0.5.1
24
+ - @nuxt-customer-portal/core@0.5.1
25
+ - @nuxt-customer-portal/organizations@0.5.1
26
+ - @nuxt-customer-portal/ui@0.5.1
27
+
3
28
  ## 0.5.0
4
29
 
5
30
  ### Patch Changes
package/README.md CHANGED
@@ -3,3 +3,9 @@
3
3
  An isolated PortalNuxt feature for weekly time entry, timers, approval, project configuration, tariffs, budgets, and reporting.
4
4
 
5
5
  All API access is scoped to the active organization through portal-core. Feature-owned database objects live in the PostgreSQL `timesheets` schema.
6
+
7
+ ### Approval email history and reminders
8
+
9
+ Internal and provider client approval cards show approval request email history with the recipient name and address at send time, timestamp, message, and sending/sent/failed status. Sent means accepted by the email provider, not confirmed delivery or reading. Tracking begins with migration `0006_approval_email.sql`; earlier sends cannot be reconstructed.
10
+
11
+ Pending requests can be resent with an edited message. The editor starts with the currently configured approval email body in the client or portal email language, with timesheet details filled in. When sending to multiple approvers, the recipient name placeholder stays personalized per email. The edited body replaces the request email body; it is not inserted as a comment inside another template. The dialog lists the **current approvers**, who may differ from historical recipients. Internal resends require a currently assigned internal approver; client resends require a workspace owner or administrator. Recipients and approval version are checked again on submission. A resend does not change approval status or overwrite prior email messages.
@@ -16,6 +16,7 @@ const { t, locale } = useI18n()
16
16
  const toast = useToast()
17
17
  const timesheets = useTimesheets()
18
18
  const busy = ref(false)
19
+ const affectedRequests = ref<Array<{ id: string; clientName: string; startsOn: string; endsOn: string }>>([])
19
20
  const rejectionOpen = ref(false)
20
21
  const rejectionSubmissionId = ref('')
21
22
  const rejectionComment = ref('')
@@ -104,6 +105,8 @@ const run = async (operation: () => Promise<unknown>) => {
104
105
  await operation()
105
106
  await props.refresh()
106
107
  } catch (error) {
108
+ const failure = error as { data?: { data?: { requests?: typeof affectedRequests.value } } }
109
+ affectedRequests.value = failure.data?.data?.requests ?? []
107
110
  toast.add({
108
111
  title: t('features.timesheets.messages.saveError'),
109
112
  description: String(error),
@@ -177,6 +180,22 @@ const reject = () =>
177
180
  </script>
178
181
 
179
182
  <template>
183
+ <UAlert
184
+ v-if="affectedRequests.length"
185
+ color="warning"
186
+ :title="t('features.timesheets.reviewPeriods.withdrawHelp')"
187
+ class="mb-4"
188
+ >
189
+ <template #description
190
+ ><ul>
191
+ <li v-for="request in affectedRequests" :key="request.id">
192
+ <NuxtLink :to="`/timesheets/client-approvals?request=${request.id}`" class="underline"
193
+ >{{ request.clientName }} · {{ request.startsOn }} – {{ request.endsOn }}</NuxtLink
194
+ >
195
+ </li>
196
+ </ul></template
197
+ >
198
+ </UAlert>
180
199
  <section class="space-y-3">
181
200
  <UCard v-if="!data.approvals.length">
182
201
  <div class="py-8 text-center">
@@ -313,6 +332,13 @@ const reject = () =>
313
332
  </article>
314
333
  </div>
315
334
  <TimesheetsSubmissionTimeline :events="item.history ?? []" />
335
+ <TimesheetsApprovalEmails
336
+ :submission-id="item.id"
337
+ :client-organization-id="clientMode ? item.clientReviews[0]?.clientOrganizationId : undefined"
338
+ :emails="item.approvalEmails ?? []"
339
+ :pending="clientMode ? item.clientReviews[0]?.status === 'PENDING' : item.status === 'SUBMITTED'"
340
+ :refresh="refresh"
341
+ />
316
342
  <section
317
343
  v-if="
318
344
  item.clientReviews.length &&
@@ -0,0 +1,175 @@
1
+ <script setup lang="ts">
2
+ import { z } from 'zod'
3
+ import type { DeepReadonly } from 'vue'
4
+ import type { ApprovalEmailDto, ApprovalEmailContextDto } from '@nuxt-customer-portal/timesheets/shared/types/timesheet'
5
+
6
+ const props = defineProps<{
7
+ submissionId: string
8
+ clientOrganizationId?: string
9
+ emails: DeepReadonly<ApprovalEmailDto[]>
10
+ pending: boolean
11
+ refresh: () => Promise<unknown>
12
+ }>()
13
+ const { t, locale } = useI18n()
14
+ const timesheets = useTimesheets()
15
+ const toast = useToast()
16
+ const isOpen = ref(false)
17
+ const busy = ref(false)
18
+ const loading = ref(false)
19
+ const loadError = ref(false)
20
+ const context = ref<ApprovalEmailContextDto | null>(null)
21
+ const state = reactive({ message: '' })
22
+ const schema = z.object({ message: z.string().trim().min(1).max(50000) })
23
+ const requestId = ref('')
24
+ const history = computed(() =>
25
+ props.emails.filter((email) =>
26
+ props.clientOrganizationId
27
+ ? email.kind === 'client-requested' && email.clientOrganizationId === props.clientOrganizationId
28
+ : email.kind === 'internal-requested'
29
+ )
30
+ )
31
+ const formatDate = (value: string) =>
32
+ new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
33
+ const openResend = async () => {
34
+ if (loading.value) {
35
+ return
36
+ }
37
+ loading.value = true
38
+ loadError.value = false
39
+ try {
40
+ context.value = await timesheets.approvalEmailContext(props.submissionId, props.clientOrganizationId)
41
+ state.message = context.value.message
42
+ requestId.value = crypto.randomUUID()
43
+ isOpen.value = true
44
+ } catch {
45
+ loadError.value = true
46
+ } finally {
47
+ loading.value = false
48
+ }
49
+ }
50
+ const resend = async () => {
51
+ if (!context.value || busy.value) {
52
+ return
53
+ }
54
+ busy.value = true
55
+ try {
56
+ const result = await timesheets.resendApprovalEmail(props.submissionId, {
57
+ clientOrganizationId: props.clientOrganizationId,
58
+ version: context.value.version,
59
+ message: state.message,
60
+ requestId: requestId.value,
61
+ recipients: context.value.recipients.map(({ id, email }) => ({ id, email }))
62
+ })
63
+ toast.add({
64
+ title: t(
65
+ result.failed
66
+ ? 'features.timesheets.approvalEmails.partialFailure'
67
+ : result.sending
68
+ ? 'features.timesheets.approvalEmails.sending'
69
+ : 'features.timesheets.approvalEmails.sent'
70
+ ),
71
+ description: t('features.timesheets.approvalEmails.result', { sent: result.sent, failed: result.failed }),
72
+ color: result.failed ? 'warning' : result.sending ? 'info' : 'success'
73
+ })
74
+ isOpen.value = false
75
+ await props.refresh()
76
+ } catch (error) {
77
+ const response = error as { data?: { message?: string } }
78
+ toast.add({
79
+ title: t('features.timesheets.approvalEmails.sendError'),
80
+ description: response.data?.message,
81
+ color: 'error'
82
+ })
83
+ } finally {
84
+ busy.value = false
85
+ }
86
+ }
87
+ </script>
88
+
89
+ <template>
90
+ <section class="mt-4 space-y-3 border-t border-default pt-4">
91
+ <div class="flex flex-wrap items-center justify-between gap-3">
92
+ <h3 class="text-sm font-semibold">{{ t('features.timesheets.approvalEmails.title') }}</h3>
93
+ <UButton
94
+ v-if="pending"
95
+ color="neutral"
96
+ variant="outline"
97
+ size="sm"
98
+ icon="i-lucide-mail"
99
+ :loading="loading"
100
+ @click="openResend"
101
+ >
102
+ {{ t('features.timesheets.approvalEmails.resend') }}
103
+ </UButton>
104
+ </div>
105
+ <UAlert v-if="loadError" color="error" :title="t('features.timesheets.approvalEmails.loadError')" />
106
+ <p v-if="!history.length" class="text-sm text-muted">{{ t('features.timesheets.approvalEmails.empty') }}</p>
107
+ <ol v-else class="space-y-2">
108
+ <li v-for="email in history" :key="email.id" class="rounded-md border border-default p-3 text-sm">
109
+ <div class="flex flex-wrap items-start justify-between gap-2">
110
+ <div class="min-w-0">
111
+ <p class="font-medium break-words">{{ email.recipientName }} · {{ email.recipientEmail }}</p>
112
+ <time class="text-muted" :datetime="email.sentAt || email.createdAt">{{
113
+ formatDate(email.sentAt || email.createdAt)
114
+ }}</time>
115
+ </div>
116
+ <UBadge
117
+ :color="email.status === 'SENT' ? 'success' : email.status === 'FAILED' ? 'error' : 'warning'"
118
+ variant="subtle"
119
+ >
120
+ {{ t(`features.timesheets.approvalEmails.status.${email.status}`) }}
121
+ </UBadge>
122
+ </div>
123
+ <details v-if="email.message" class="mt-2">
124
+ <summary class="cursor-pointer text-muted">{{ t('features.timesheets.approvalEmails.message') }}</summary>
125
+ <p class="mt-1 whitespace-pre-wrap break-words">{{ email.message }}</p>
126
+ </details>
127
+ </li>
128
+ </ol>
129
+ <p v-if="history.length" class="text-xs text-muted">{{ t('features.timesheets.approvalEmails.sentHelp') }}</p>
130
+ <UModal
131
+ v-if="isOpen"
132
+ v-model:open="isOpen"
133
+ :dismissible="!busy"
134
+ :title="t('features.timesheets.approvalEmails.resend')"
135
+ >
136
+ <template #body>
137
+ <UForm :state="state" :schema="schema" novalidate class="space-y-4" @submit="resend">
138
+ <p class="text-sm text-muted">{{ t('features.timesheets.approvalEmails.currentHelp') }}</p>
139
+ <div>
140
+ <h4 class="text-sm font-semibold">{{ t('features.timesheets.approvalEmails.currentApprovers') }}</h4>
141
+ <ul class="mt-2 space-y-1 text-sm">
142
+ <li v-for="recipient in context?.recipients" :key="recipient.id" class="break-words">
143
+ {{ recipient.name }} · {{ recipient.email }}
144
+ </li>
145
+ </ul>
146
+ </div>
147
+ <UAlert
148
+ v-if="!context?.canResend"
149
+ color="warning"
150
+ :title="t('features.timesheets.approvalEmails.unavailable')"
151
+ />
152
+ <UFormField name="message" :label="t('features.timesheets.approvalEmails.message')">
153
+ <PortalMarkdownEditor
154
+ v-model="state.message"
155
+ :rows="10"
156
+ class="w-full"
157
+ :disabled="busy"
158
+ :placeholders="[
159
+ { label: t('features.timesheets.email.placeholders.recipient_name'), value: '{{recipient_name}}' }
160
+ ]"
161
+ />
162
+ </UFormField>
163
+ <div class="flex justify-end gap-2">
164
+ <UButton color="neutral" variant="outline" :disabled="busy" @click="isOpen = false">{{
165
+ t('features.timesheets.cancel')
166
+ }}</UButton>
167
+ <UButton type="submit" icon="i-lucide-send" :loading="busy" :disabled="!context?.canResend">{{
168
+ t('features.timesheets.approvalEmails.send')
169
+ }}</UButton>
170
+ </div>
171
+ </UForm>
172
+ </template>
173
+ </UModal>
174
+ </section>
175
+ </template>
@@ -1,24 +1,36 @@
1
1
  <script setup lang="ts">
2
2
  import { z } from 'zod'
3
+ import type { ClientTimesheetSettingsDto } from '../../shared/types/client-review-request'
3
4
  import type { GenericClientDto } from '@nuxt-customer-portal/clients/types'
4
5
 
5
6
  const props = defineProps<{ client: GenericClientDto }>()
6
7
  const { t } = useI18n()
7
8
  const toast = useToast()
8
9
  const busy = ref(false)
9
- const state = reactive({ accessMode: 'DISABLED' as 'DISABLED' | 'VIEW' | 'REVIEW' })
10
- const schema = z.object({ accessMode: z.enum(['DISABLED', 'VIEW', 'REVIEW']) })
11
- const { data } = await useFetch(`/api/timesheets/admin/client-settings/${props.client.organizationId}`)
10
+ const state = reactive({
11
+ accessMode: 'DISABLED' as 'DISABLED' | 'VIEW' | 'REVIEW',
12
+ reviewInterval: 'WEEKLY' as 'WEEKLY' | 'TWO_WEEKLY' | 'MONTHLY'
13
+ })
14
+ const schema = z.object({
15
+ accessMode: z.enum(['DISABLED', 'VIEW', 'REVIEW']),
16
+ reviewInterval: z.enum(['WEEKLY', 'TWO_WEEKLY', 'MONTHLY'])
17
+ })
18
+ const { data } = await useFetch<ClientTimesheetSettingsDto>(
19
+ `/api/timesheets/admin/client-settings/${props.client.organizationId}`
20
+ )
12
21
  if (data.value) {
13
- Object.assign(state, data.value)
22
+ Object.assign(state, data.value, { reviewInterval: data.value.scheduledReviewInterval ?? data.value.reviewInterval })
14
23
  }
15
24
  const save = async () => {
16
25
  busy.value = true
17
26
  try {
18
- await $fetch(`/api/timesheets/admin/client-settings/${props.client.organizationId}`, {
19
- method: 'PUT',
20
- body: state
21
- })
27
+ data.value = await $fetch<ClientTimesheetSettingsDto>(
28
+ `/api/timesheets/admin/client-settings/${props.client.organizationId}`,
29
+ {
30
+ method: 'PUT',
31
+ body: state
32
+ }
33
+ )
22
34
  toast.add({ title: t('features.timesheets.messages.saved'), color: 'success' })
23
35
  } finally {
24
36
  busy.value = false
@@ -31,7 +43,7 @@ const save = async () => {
31
43
  <template #header>
32
44
  <h2 class="font-semibold">{{ t('features.timesheets.admin.clientSettings') }}</h2>
33
45
  </template>
34
- <UForm :state="state" :schema="schema" class="space-y-4" @submit="save">
46
+ <UForm novalidate :state="state" :schema="schema" class="space-y-4" @submit="save">
35
47
  <UFormField name="accessMode" :label="t('features.timesheets.timesheetAccess')">
36
48
  <USelect
37
49
  v-model="state.accessMode"
@@ -44,6 +56,32 @@ const save = async () => {
44
56
  class="w-full"
45
57
  />
46
58
  </UFormField>
59
+ <UFormField
60
+ v-if="state.accessMode === 'REVIEW'"
61
+ name="reviewInterval"
62
+ :label="t('features.timesheets.reviewPeriods.interval')"
63
+ :description="t('features.timesheets.reviewPeriods.nextMonth')"
64
+ >
65
+ <USelect
66
+ v-model="state.reviewInterval"
67
+ :items="
68
+ ['WEEKLY', 'TWO_WEEKLY', 'MONTHLY'].map((value) => ({
69
+ value,
70
+ label: t(`features.timesheets.reviewPeriods.intervals.${value}`)
71
+ }))
72
+ "
73
+ value-key="value"
74
+ class="w-full"
75
+ />
76
+ </UFormField>
77
+ <p v-if="data?.reviewIntervalEffectiveOn" class="text-sm text-muted">
78
+ {{
79
+ t('features.timesheets.reviewPeriods.scheduled', {
80
+ date: data.reviewIntervalEffectiveOn,
81
+ interval: t(`features.timesheets.reviewPeriods.intervals.${data.scheduledReviewInterval}`)
82
+ })
83
+ }}
84
+ </p>
47
85
  <div class="flex justify-end">
48
86
  <UButton type="submit" icon="i-lucide-save" class="ml-auto flex min-w-28 justify-center" :loading="busy">{{
49
87
  t('features.timesheets.save')