@nuxt-customer-portal/timesheets 0.3.2 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # @nuxt-customer-portal/timesheets
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 59c8921: Support organization and personal clients with optional verified self-registration, single-person access safeguards, and shared provider, client, and user timezone preferences. Preserve existing B2B defaults, module references, invoice history, and Timesheets timezone behavior.
8
+
9
+ Collect and store first and last names during signup and personal onboarding. Route ordinary personal registration and verified users without memberships or pending invitations through onboarding.
10
+
11
+ Allow users to edit first name, last name, and an independent display name in profile settings.
12
+
13
+ Synchronize display-name edits with the linked private client name and current billing name in the same transaction, preserving organizations and historical invoices.
14
+
15
+ Let private clients edit their own billing address in profile settings, independently of the active organization, with server-side ownership checks.
16
+
17
+ Private clients use their own invoice email without contact persons; hide contact management and reject private-client contact creation or selection server-side.
18
+
19
+ Add a B2C-only Invite private client action to the Users page, supporting new and existing private clients with atomic record creation, request retry protection, and pending invitation recovery when email delivery fails.
20
+
21
+ - Updated dependencies [59c8921]
22
+ - Updated dependencies [59c8921]
23
+ - Updated dependencies [59c8921]
24
+ - Updated dependencies [59c8921]
25
+ - Updated dependencies [59c8921]
26
+ - Updated dependencies [59c8921]
27
+ - Updated dependencies [59c8921]
28
+ - Updated dependencies [59c8921]
29
+ - Updated dependencies [59c8921]
30
+ - @nuxt-customer-portal/core@0.4.0
31
+ - @nuxt-customer-portal/clients@0.4.0
32
+ - @nuxt-customer-portal/organizations@0.4.0
33
+ - @nuxt-customer-portal/ui@0.4.0
34
+
35
+ ## 0.3.3
36
+
37
+ ### Patch Changes
38
+
39
+ - @nuxt-customer-portal/clients@0.3.3
40
+ - @nuxt-customer-portal/core@0.3.3
41
+ - @nuxt-customer-portal/organizations@0.3.3
42
+ - @nuxt-customer-portal/ui@0.3.3
43
+
3
44
  ## 0.3.2
4
45
 
5
46
  ### Patch Changes
@@ -52,7 +52,12 @@ const schema = computed(() =>
52
52
  <UButton v-if="showCancel" type="button" color="neutral" variant="outline" @click="emit('cancel')">
53
53
  {{ t('features.timesheets.cancel') }}
54
54
  </UButton>
55
- <UButton type="submit" :loading="busy">
55
+ <UButton
56
+ type="submit"
57
+ :icon="editing ? 'i-lucide-save' : 'i-lucide-plus'"
58
+ class="ml-auto flex min-w-28 justify-center"
59
+ :loading="busy"
60
+ >
56
61
  {{ editing ? t('features.timesheets.save') : t('features.timesheets.admin.createActivity') }}
57
62
  </UButton>
58
63
  </div>
@@ -372,7 +372,7 @@ const reject = () =>
372
372
  </div>
373
373
  </details>
374
374
 
375
- <UModal v-model:open="requestOpen" :title="t('features.timesheets.submissions.title')">
375
+ <UModal v-if="requestOpen" v-model:open="requestOpen" :title="t('features.timesheets.submissions.title')">
376
376
  <template #body>
377
377
  <UForm
378
378
  :schema="requestSchema"
@@ -395,7 +395,7 @@ const reject = () =>
395
395
  </UForm>
396
396
  </template>
397
397
  </UModal>
398
- <UModal v-model:open="clientReplyOpen" :title="t('features.timesheets.submissions.reply')">
398
+ <UModal v-if="clientReplyOpen" v-model:open="clientReplyOpen" :title="t('features.timesheets.submissions.reply')">
399
399
  <template #body>
400
400
  <UForm
401
401
  :schema="clientReplySchema"
@@ -411,7 +411,7 @@ const reject = () =>
411
411
  </UForm>
412
412
  </template>
413
413
  </UModal>
414
- <UModal v-model:open="approvalOpen" :title="t('features.timesheets.admin.approve')">
414
+ <UModal v-if="approvalOpen" v-model:open="approvalOpen" :title="t('features.timesheets.admin.approve')">
415
415
  <template #body>
416
416
  <UForm :state="approvalState" :schema="approvalSchema" novalidate class="space-y-4" @submit="approve">
417
417
  <UFormField name="comment" :label="t('features.timesheets.submissions.remark')">
@@ -428,7 +428,7 @@ const reject = () =>
428
428
  </UForm>
429
429
  </template>
430
430
  </UModal>
431
- <UModal v-model:open="rejectionOpen" :title="t('features.timesheets.admin.rejectTimesheet')">
431
+ <UModal v-if="rejectionOpen" v-model:open="rejectionOpen" :title="t('features.timesheets.admin.rejectTimesheet')">
432
432
  <template #body>
433
433
  <UForm novalidate :state="rejectionState" :schema="rejectionSchema" class="space-y-4" @submit="reject">
434
434
  <UFormField name="comment" :label="t('features.timesheets.admin.rejectionReason')" required>
@@ -72,10 +72,12 @@ watch(
72
72
  </UFormField>
73
73
  <UButton
74
74
  icon="i-lucide-save"
75
+ class="ml-auto flex min-w-28 justify-center"
75
76
  :aria-label="t('features.timesheets.save')"
76
77
  :loading="busy"
77
78
  @click="saveMember(member.id)"
78
- />
79
+ >{{ t('features.timesheets.save') }}</UButton
80
+ >
79
81
  </div>
80
82
  </div>
81
83
  </UCard>
@@ -45,7 +45,9 @@ const save = async () => {
45
45
  />
46
46
  </UFormField>
47
47
  <div class="flex justify-end">
48
- <UButton type="submit" icon="i-lucide-save" :loading="busy">{{ t('features.timesheets.save') }}</UButton>
48
+ <UButton type="submit" icon="i-lucide-save" class="ml-auto flex min-w-28 justify-center" :loading="busy">{{
49
+ t('features.timesheets.save')
50
+ }}</UButton>
49
51
  </div>
50
52
  </UForm>
51
53
  </UCard>
@@ -292,7 +292,7 @@ await listing.load()
292
292
  </footer>
293
293
  </article>
294
294
  <UModal
295
- v-if="isReview && selected && isApprovalItem(selected)"
295
+ v-if="disputeOpen && isReview && selected && isApprovalItem(selected)"
296
296
  v-model:open="disputeOpen"
297
297
  :title="t(`features.timesheets.clientPortal.${reviewAction === 'APPROVE' ? 'approve' : 'dispute'}`)"
298
298
  >
@@ -251,7 +251,14 @@ onKeyStroke('Escape', () => {
251
251
  />
252
252
  </div>
253
253
  <div class="flex justify-end">
254
- <UButton size="sm" variant="outline" icon="i-lucide-save" :loading="busy" @click="saveRates">
254
+ <UButton
255
+ size="sm"
256
+ variant="outline"
257
+ icon="i-lucide-save"
258
+ class="ml-auto flex min-w-28 justify-center"
259
+ :loading="busy"
260
+ @click="saveRates"
261
+ >
255
262
  {{ t('features.timesheets.admin.saveOverrides') }}
256
263
  </UButton>
257
264
  </div>
@@ -206,7 +206,12 @@ const createActivity = async () => {
206
206
  <UButton v-if="showCancel" type="button" color="neutral" variant="outline" @click="emit('cancel')">
207
207
  {{ t('features.timesheets.cancel') }}
208
208
  </UButton>
209
- <UButton type="submit" :loading="busy">
209
+ <UButton
210
+ type="submit"
211
+ :icon="editing ? 'i-lucide-save' : 'i-lucide-plus'"
212
+ class="ml-auto flex min-w-28 justify-center"
213
+ :loading="busy"
214
+ >
210
215
  {{ editing ? t('features.timesheets.save') : t('features.timesheets.admin.createProject') }}
211
216
  </UButton>
212
217
  </div>
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { z } from 'zod'
3
+ import { isValidTimezone } from '@nuxt-customer-portal/core/shared/timezone'
3
4
  import type { TimesheetsAdminBootstrap } from '@nuxt-customer-portal/timesheets/app/composables/useTimesheets'
4
5
 
5
6
  const props = defineProps<{
@@ -23,7 +24,7 @@ const schema = computed(() =>
23
24
  z.object({
24
25
  timerRoundingMinutes: z.number().int().min(1).max(60),
25
26
  currency: z.string().trim().length(3, t('features.timesheets.validation.currencyLength')),
26
- timezone: z.string().trim().min(3, t('features.timesheets.validation.required')).max(100)
27
+ timezone: z.string().trim().max(100).refine(isValidTimezone, t('timezones.invalid'))
27
28
  })
28
29
  )
29
30
 
@@ -72,7 +73,7 @@ const save = async () => {
72
73
  >
73
74
  <UInput v-model.number="draft.timerRoundingMinutes" type="number" :min="1" :max="60" class="w-full sm:w-28" />
74
75
  </UFormField>
75
- <UButton type="submit" class="w-full justify-center sm:w-auto" icon="i-lucide-save" :loading="busy">
76
+ <UButton type="submit" class="ml-auto flex min-w-28 justify-center" icon="i-lucide-save" :loading="busy">
76
77
  {{ t('features.timesheets.save') }}
77
78
  </UButton>
78
79
  </UForm>
@@ -1194,7 +1194,7 @@ const runningDuration = computed(() => {
1194
1194
  </div>
1195
1195
  </details>
1196
1196
 
1197
- <UModal v-model:open="replyOpen" :title="t('features.timesheets.submissions.resubmit')">
1197
+ <UModal v-if="replyOpen" v-model:open="replyOpen" :title="t('features.timesheets.submissions.resubmit')">
1198
1198
  <template #body>
1199
1199
  <UForm :schema="submissionReplySchema" :state="replyState" novalidate class="space-y-4" @submit="resubmit">
1200
1200
  <UFormField name="reply" :label="t('features.timesheets.submissions.reply')">
@@ -1208,6 +1208,7 @@ const runningDuration = computed(() => {
1208
1208
  </UModal>
1209
1209
 
1210
1210
  <UModal
1211
+ v-if="modalOpen"
1211
1212
  v-model:open="modalOpen"
1212
1213
  :title="form.id ? t('features.timesheets.editEntry') : t('features.timesheets.addEntry')"
1213
1214
  @after:enter="focusPrefilledHours"
@@ -1283,7 +1284,13 @@ const runningDuration = computed(() => {
1283
1284
  <UButton type="button" color="neutral" variant="outline" @click="modalOpen = false">
1284
1285
  {{ t('features.timesheets.cancel') }}
1285
1286
  </UButton>
1286
- <UButton type="submit" :loading="saving" :disabled="tariffMissing">
1287
+ <UButton
1288
+ type="submit"
1289
+ icon="i-lucide-save"
1290
+ class="ml-auto flex min-w-28 justify-center"
1291
+ :loading="saving"
1292
+ :disabled="tariffMissing"
1293
+ >
1287
1294
  {{ t('features.timesheets.save') }}
1288
1295
  </UButton>
1289
1296
  </div>
@@ -1292,7 +1299,7 @@ const runningDuration = computed(() => {
1292
1299
  </template>
1293
1300
  </UModal>
1294
1301
 
1295
- <UModal v-model:open="cellEntriesOpen" :title="t('features.timesheets.cellEntries.title')">
1302
+ <UModal v-if="cellEntriesOpen" v-model:open="cellEntriesOpen" :title="t('features.timesheets.cellEntries.title')">
1296
1303
  <template #body>
1297
1304
  <div v-if="selectedCell" class="space-y-4">
1298
1305
  <div>
@@ -1341,7 +1348,11 @@ const runningDuration = computed(() => {
1341
1348
  </template>
1342
1349
  </UModal>
1343
1350
 
1344
- <UModal v-model:open="submissionModalOpen" :title="t('features.timesheets.submissions.title')">
1351
+ <UModal
1352
+ v-if="submissionModalOpen"
1353
+ v-model:open="submissionModalOpen"
1354
+ :title="t('features.timesheets.submissions.title')"
1355
+ >
1345
1356
  <template #body>
1346
1357
  <UForm :state="submissionState" :schema="submissionSchema" novalidate class="space-y-4" @submit="submit">
1347
1358
  <p class="text-sm text-muted">{{ t('features.timesheets.submissions.description') }}</p>
@@ -1397,7 +1408,7 @@ const runningDuration = computed(() => {
1397
1408
  </template>
1398
1409
  </UModal>
1399
1410
 
1400
- <UModal v-model:open="timerModalOpen" :title="t('features.timesheets.timer.start')">
1411
+ <UModal v-if="timerModalOpen" v-model:open="timerModalOpen" :title="t('features.timesheets.timer.start')">
1401
1412
  <template #body>
1402
1413
  <UAlert
1403
1414
  v-if="!isOrganizationAdmin && workspaceStructureIncomplete"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuxt-customer-portal/timesheets",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "homepage": "https://nuxt-customer-portal.com",
6
6
  "repository": {
@@ -45,10 +45,10 @@
45
45
  "pg": "^8.22.0",
46
46
  "resend": "^6.18.1",
47
47
  "zod": "^4.4.3",
48
- "@nuxt-customer-portal/core": "^0.3.2",
49
- "@nuxt-customer-portal/clients": "^0.3.2",
50
- "@nuxt-customer-portal/organizations": "^0.3.2",
51
- "@nuxt-customer-portal/ui": "^0.3.2"
48
+ "@nuxt-customer-portal/core": "^0.4.0",
49
+ "@nuxt-customer-portal/organizations": "^0.4.0",
50
+ "@nuxt-customer-portal/clients": "^0.4.0",
51
+ "@nuxt-customer-portal/ui": "^0.4.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "nuxt": "^4.5.1",
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  id: 'timesheets',
3
- version: '0.3.2',
3
+ version: '0.4.0',
4
4
  source: '@nuxt-customer-portal/timesheets',
5
5
  clientModuleId: 'timesheets',
6
6
  dependsOn: ['core', 'ui', 'organizations', 'clients'],
@@ -4,6 +4,7 @@ import { and, eq, isNotNull } from 'drizzle-orm'
4
4
  import { db } from '@nuxt-customer-portal/core/server/portal'
5
5
  import { member, organization, user } from '@nuxt-customer-portal/core/schema'
6
6
  import { getPortalEmailSettings, sendPortalEmail } from '@nuxt-customer-portal/core/server/utils/portal-email'
7
+ import { emailRecipientName } from '@nuxt-customer-portal/core/shared/email-recipient'
7
8
  import { timesheetEmails } from '../../shared/emails'
8
9
  import {
9
10
  internalApproverAssignment,
@@ -48,7 +49,7 @@ export const notifyTimesheetEvent = async (
48
49
  const baseUrl = process.env.BETTER_AUTH_URL || process.env.PUBLIC_URL || 'http://localhost:3051'
49
50
  const send = async (
50
51
  id: string,
51
- recipient: { id: string; email: string; name: string },
52
+ recipient: { id: string; email: string; name: string; firstName?: string | null },
52
53
  path: string,
53
54
  clientName = '',
54
55
  comment = '',
@@ -63,7 +64,13 @@ export const notifyTimesheetEvent = async (
63
64
  to: recipient.email,
64
65
  idempotencyKey: `timesheet/${submission.id}/${submission.version}/${id}/${scope}/${recipient.id}`,
65
66
  values: {
66
- recipient_name: escapeHtml(recipient.name),
67
+ recipient_name: escapeHtml(
68
+ emailRecipientName({
69
+ firstName: recipient.firstName,
70
+ displayName: recipient.name,
71
+ email: recipient.email
72
+ })
73
+ ),
67
74
  person_name: escapeHtml(person.name),
68
75
  organization_name: escapeHtml(workspace.name),
69
76
  period: escapeHtml(formatTimesheetPeriod(submission.periodStartsOn, submission.periodEndsOn, emailLocale)),
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod'
2
+ import { timezoneSchema } from '@nuxt-customer-portal/core/server/utils/timezone-validation'
2
3
 
3
4
  const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
4
5
  const id = z.string().min(1).max(128)
@@ -195,7 +196,7 @@ export const settingsUpdateSchema = z.object({
195
196
  .length(3)
196
197
  .transform((v) => v.toUpperCase())
197
198
  .optional(),
198
- timezone: z.string().min(3).max(100).optional()
199
+ timezone: timezoneSchema.optional()
199
200
  })
200
201
 
201
202
  export const reviewSchema = z
package/shared/emails.ts CHANGED
@@ -7,11 +7,15 @@ export const timesheetEmails: PortalEmailDefinition[] = [
7
7
  defaults: {
8
8
  en: {
9
9
  subject: 'Internal approval requested — {{person_name}} ({{period}})',
10
- body: 'Dear {{recipient_name}},<br><br>Please review the timesheet.<br><br>{{organization_name}}<br>{{person_name}}: {{period}}<br>{{client_name}}<br>{{comment}}<br><br><a href="{{action_url}}" class="button">Open timesheets</a>'
10
+ body: 'Dear {{recipient_name}},\n\nA timesheet is ready for your internal review. Please check the submitted hours and details before deciding whether it can move to the next step.\n\n- **Organization:** {{organization_name}}\n- **Team member:** {{person_name}}\n- **Period:** {{period}}\n- **Client:** {{client_name}}\n- **Comment:** {{comment}}\n\n[Review the timesheet]({{action_url}})',
11
+ footer:
12
+ 'Thank you for taking the time to review this submission. Your prompt response helps keep the approval process moving smoothly.'
11
13
  },
12
14
  nl: {
13
15
  subject: 'Interne goedkeuring gevraagd — {{person_name}} ({{period}})',
14
- body: 'Beste {{recipient_name}},<br><br>Beoordeel de urenstaat.<br><br>{{organization_name}}<br>{{person_name}}: {{period}}<br>{{client_name}}<br>{{comment}}<br><br><a href="{{action_url}}" class="button">Urenstaten openen</a>'
16
+ body: 'Beste {{recipient_name}},\n\nEr staat een urenstaat klaar voor uw interne beoordeling. Controleer de ingediende uren en gegevens voordat u beslist of deze door kan naar de volgende stap.\n\n- **Organisatie:** {{organization_name}}\n- **Medewerker:** {{person_name}}\n- **Periode:** {{period}}\n- **Klant:** {{client_name}}\n- **Opmerking:** {{comment}}\n\n[Urenstaat beoordelen]({{action_url}})',
17
+ footer:
18
+ 'Bedankt dat u de tijd neemt om deze urenstaat te beoordelen. Met een snelle reactie blijft het goedkeuringsproces soepel verlopen.'
15
19
  }
16
20
  },
17
21
  placeholders: [
@@ -64,11 +68,15 @@ export const timesheetEmails: PortalEmailDefinition[] = [
64
68
  defaults: {
65
69
  en: {
66
70
  subject: 'Timesheet approved internally — {{person_name}} ({{period}})',
67
- body: 'Dear {{recipient_name}},<br><br>Your timesheet has been approved internally.<br><br>{{organization_name}}<br>{{person_name}}: {{period}}<br>{{client_name}}<br>{{comment}}<br><br><a href="{{action_url}}" class="button">Open timesheets</a>'
71
+ body: 'Dear {{recipient_name}},\n\nGood news: your timesheet has been approved internally and can now continue through the approval process.\n\n- **Organization:** {{organization_name}}\n- **Team member:** {{person_name}}\n- **Period:** {{period}}\n- **Client:** {{client_name}}\n- **Comment:** {{comment}}\n\n[View the approved timesheet]({{action_url}})',
72
+ footer:
73
+ 'No further action is needed from you right now. You can use the link above whenever you want to review the latest status.'
68
74
  },
69
75
  nl: {
70
76
  subject: 'Urenstaat intern goedgekeurd — {{person_name}} ({{period}})',
71
- body: 'Beste {{recipient_name}},<br><br>Uw urenstaat is intern goedgekeurd.<br><br>{{organization_name}}<br>{{person_name}}: {{period}}<br>{{client_name}}<br>{{comment}}<br><br><a href="{{action_url}}" class="button">Urenstaten openen</a>'
77
+ body: 'Beste {{recipient_name}},\n\nGoed nieuws: uw urenstaat is intern goedgekeurd en kan nu verder in het goedkeuringsproces.\n\n- **Organisatie:** {{organization_name}}\n- **Medewerker:** {{person_name}}\n- **Periode:** {{period}}\n- **Klant:** {{client_name}}\n- **Opmerking:** {{comment}}\n\n[Goedgekeurde urenstaat bekijken]({{action_url}})',
78
+ footer:
79
+ 'U hoeft op dit moment niets te doen. Via de bovenstaande link kunt u altijd de meest recente status bekijken.'
72
80
  }
73
81
  },
74
82
  placeholders: [
@@ -121,11 +129,15 @@ export const timesheetEmails: PortalEmailDefinition[] = [
121
129
  defaults: {
122
130
  en: {
123
131
  subject: 'Timesheet rejected internally — {{person_name}} ({{period}})',
124
- body: 'Dear {{recipient_name}},<br><br>Your timesheet has been rejected internally.<br><br>{{organization_name}}<br>{{person_name}}: {{period}}<br>{{client_name}}<br>{{comment}}<br><br><a href="{{action_url}}" class="button">Open timesheets</a>'
132
+ body: 'Dear {{recipient_name}},\n\nYour timesheet could not be approved internally yet. Please review the feedback below, update the submission where needed, and submit it again when it is ready.\n\n- **Organization:** {{organization_name}}\n- **Team member:** {{person_name}}\n- **Period:** {{period}}\n- **Client:** {{client_name}}\n- **Feedback:** {{comment}}\n\n[Review and update the timesheet]({{action_url}})',
133
+ footer:
134
+ 'If the feedback is unclear, please contact the person responsible for the internal review before resubmitting.'
125
135
  },
126
136
  nl: {
127
137
  subject: 'Urenstaat intern afgewezen — {{person_name}} ({{period}})',
128
- body: 'Beste {{recipient_name}},<br><br>Uw urenstaat is intern afgewezen.<br><br>{{organization_name}}<br>{{person_name}}: {{period}}<br>{{client_name}}<br>{{comment}}<br><br><a href="{{action_url}}" class="button">Urenstaten openen</a>'
138
+ body: 'Beste {{recipient_name}},\n\nUw urenstaat kon intern nog niet worden goedgekeurd. Bekijk de feedback hieronder, pas de urenstaat waar nodig aan en dien deze opnieuw in zodra alles klopt.\n\n- **Organisatie:** {{organization_name}}\n- **Medewerker:** {{person_name}}\n- **Periode:** {{period}}\n- **Klant:** {{client_name}}\n- **Feedback:** {{comment}}\n\n[Urenstaat bekijken en aanpassen]({{action_url}})',
139
+ footer:
140
+ 'Is de feedback niet duidelijk? Neem dan contact op met de interne beoordelaar voordat u de urenstaat opnieuw indient.'
129
141
  }
130
142
  },
131
143
  placeholders: [
@@ -178,11 +190,14 @@ export const timesheetEmails: PortalEmailDefinition[] = [
178
190
  defaults: {
179
191
  en: {
180
192
  subject: 'Timesheet reopened — {{person_name}} ({{period}})',
181
- body: 'Dear {{recipient_name}},<br><br>Your timesheet has been reopened for editing.<br><br>{{organization_name}}<br>{{person_name}}: {{period}}<br>{{client_name}}<br>{{comment}}<br><br><a href="{{action_url}}" class="button">Open timesheets</a>'
193
+ body: 'Dear {{recipient_name}},\n\nYour timesheet has been reopened, so you can make changes again. Please review the reason below and update the relevant entries before resubmitting.\n\n- **Organization:** {{organization_name}}\n- **Team member:** {{person_name}}\n- **Period:** {{period}}\n- **Client:** {{client_name}}\n- **Comment:** {{comment}}\n\n[Open and edit the timesheet]({{action_url}})',
194
+ footer: 'When your changes are complete, submit the timesheet again so the approval process can continue.'
182
195
  },
183
196
  nl: {
184
197
  subject: 'Urenstaat heropend — {{person_name}} ({{period}})',
185
- body: 'Beste {{recipient_name}},<br><br>Uw urenstaat is heropend voor wijzigingen.<br><br>{{organization_name}}<br>{{person_name}}: {{period}}<br>{{client_name}}<br>{{comment}}<br><br><a href="{{action_url}}" class="button">Urenstaten openen</a>'
198
+ body: 'Beste {{recipient_name}},\n\nUw urenstaat is heropend, zodat u opnieuw wijzigingen kunt aanbrengen. Bekijk de reden hieronder en pas de betreffende registraties aan voordat u de urenstaat opnieuw indient.\n\n- **Organisatie:** {{organization_name}}\n- **Medewerker:** {{person_name}}\n- **Periode:** {{period}}\n- **Klant:** {{client_name}}\n- **Opmerking:** {{comment}}\n\n[Urenstaat openen en bewerken]({{action_url}})',
199
+ footer:
200
+ 'Dien de urenstaat opnieuw in zodra uw wijzigingen klaar zijn, zodat het goedkeuringsproces verder kan gaan.'
186
201
  }
187
202
  },
188
203
  placeholders: [
@@ -235,11 +250,15 @@ export const timesheetEmails: PortalEmailDefinition[] = [
235
250
  defaults: {
236
251
  en: {
237
252
  subject: 'Client approval requested — {{person_name}} ({{period}})',
238
- body: 'Dear {{recipient_name}},<br><br>Please review the timesheet for your organization.<br><br>{{organization_name}}<br>{{person_name}}: {{period}}<br>{{client_name}}<br>{{comment}}<br><br><a href="{{action_url}}" class="button">Open timesheets</a>'
253
+ body: 'Dear {{recipient_name}},\n\nA timesheet has completed its internal review and is now waiting for your organization’s approval. Please check the hours and supporting details below.\n\n- **Organization:** {{organization_name}}\n- **Team member:** {{person_name}}\n- **Period:** {{period}}\n- **Client:** {{client_name}}\n- **Comment:** {{comment}}\n\n[Review the timesheet]({{action_url}})',
254
+ footer:
255
+ 'You can approve the timesheet or raise a concern from the review page. Thank you for helping us complete the process.'
239
256
  },
240
257
  nl: {
241
258
  subject: 'Klantgoedkeuring gevraagd — {{person_name}} ({{period}})',
242
- body: 'Beste {{recipient_name}},<br><br>Beoordeel de urenstaat voor uw organisatie.<br><br>{{organization_name}}<br>{{person_name}}: {{period}}<br>{{client_name}}<br>{{comment}}<br><br><a href="{{action_url}}" class="button">Urenstaten openen</a>'
259
+ body: 'Beste {{recipient_name}},\n\nEen urenstaat heeft de interne beoordeling doorlopen en wacht nu op goedkeuring door uw organisatie. Controleer de uren en aanvullende gegevens hieronder.\n\n- **Organisatie:** {{organization_name}}\n- **Medewerker:** {{person_name}}\n- **Periode:** {{period}}\n- **Klant:** {{client_name}}\n- **Opmerking:** {{comment}}\n\n[Urenstaat beoordelen]({{action_url}})',
260
+ footer:
261
+ 'Op de beoordelingspagina kunt u de urenstaat goedkeuren of een bezwaar aangeven. Bedankt dat u helpt het proces af te ronden.'
243
262
  }
244
263
  },
245
264
  placeholders: [
@@ -292,11 +311,15 @@ export const timesheetEmails: PortalEmailDefinition[] = [
292
311
  defaults: {
293
312
  en: {
294
313
  subject: 'Timesheet approved by client — {{person_name}} ({{period}})',
295
- body: 'Dear {{recipient_name}},<br><br>The client has approved your timesheet.<br><br>{{organization_name}}<br>{{person_name}}: {{period}}<br>{{client_name}}<br>{{comment}}<br><br><a href="{{action_url}}" class="button">Open timesheets</a>'
314
+ body: 'Dear {{recipient_name}},\n\nGood news: the client has reviewed and approved the timesheet below. The approval process for this submission is now complete.\n\n- **Organization:** {{organization_name}}\n- **Team member:** {{person_name}}\n- **Period:** {{period}}\n- **Client:** {{client_name}}\n- **Comment:** {{comment}}\n\n[View the approved timesheet]({{action_url}})',
315
+ footer:
316
+ 'Thank you for keeping the timesheet accurate and complete. No further action is required for this submission.'
296
317
  },
297
318
  nl: {
298
319
  subject: 'Urenstaat goedgekeurd door klant — {{person_name}} ({{period}})',
299
- body: 'Beste {{recipient_name}},<br><br>De klant heeft uw urenstaat goedgekeurd.<br><br>{{organization_name}}<br>{{person_name}}: {{period}}<br>{{client_name}}<br>{{comment}}<br><br><a href="{{action_url}}" class="button">Urenstaten openen</a>'
320
+ body: 'Beste {{recipient_name}},\n\nGoed nieuws: de klant heeft de onderstaande urenstaat beoordeeld en goedgekeurd. Het goedkeuringsproces voor deze inzending is daarmee afgerond.\n\n- **Organisatie:** {{organization_name}}\n- **Medewerker:** {{person_name}}\n- **Periode:** {{period}}\n- **Klant:** {{client_name}}\n- **Opmerking:** {{comment}}\n\n[Goedgekeurde urenstaat bekijken]({{action_url}})',
321
+ footer:
322
+ 'Bedankt voor het zorgvuldig en volledig bijhouden van de uren. Voor deze inzending hoeft u niets meer te doen.'
300
323
  }
301
324
  },
302
325
  placeholders: [
@@ -349,11 +372,15 @@ export const timesheetEmails: PortalEmailDefinition[] = [
349
372
  defaults: {
350
373
  en: {
351
374
  subject: 'Timesheet disputed by client — {{person_name}} ({{period}})',
352
- body: 'Dear {{recipient_name}},<br><br>The client has disputed your timesheet.<br><br>{{organization_name}}<br>{{person_name}}: {{period}}<br>{{client_name}}<br>{{comment}}<br><br><a href="{{action_url}}" class="button">Open timesheets</a>'
375
+ body: 'Dear {{recipient_name}},\n\nThe client has raised a concern about the timesheet below. Please review their feedback and coordinate any necessary corrections before the timesheet is submitted again.\n\n- **Organization:** {{organization_name}}\n- **Team member:** {{person_name}}\n- **Period:** {{period}}\n- **Client:** {{client_name}}\n- **Client feedback:** {{comment}}\n\n[Review the disputed timesheet]({{action_url}})',
376
+ footer:
377
+ 'Please follow up with the client if clarification is needed. Resolving the concern promptly will help keep the approval process on track.'
353
378
  },
354
379
  nl: {
355
380
  subject: 'Urenstaat betwist door klant — {{person_name}} ({{period}})',
356
- body: 'Beste {{recipient_name}},<br><br>De klant heeft uw urenstaat betwist.<br><br>{{organization_name}}<br>{{person_name}}: {{period}}<br>{{client_name}}<br>{{comment}}<br><br><a href="{{action_url}}" class="button">Urenstaten openen</a>'
381
+ body: 'Beste {{recipient_name}},\n\nDe klant heeft een bezwaar aangegeven bij de onderstaande urenstaat. Bekijk de feedback en stem eventuele correcties af voordat de urenstaat opnieuw wordt ingediend.\n\n- **Organisatie:** {{organization_name}}\n- **Medewerker:** {{person_name}}\n- **Periode:** {{period}}\n- **Klant:** {{client_name}}\n- **Feedback van de klant:** {{comment}}\n\n[Betwiste urenstaat bekijken]({{action_url}})',
382
+ footer:
383
+ 'Neem contact op met de klant als nadere uitleg nodig is. Door het bezwaar snel op te lossen, blijft het goedkeuringsproces op schema.'
357
384
  }
358
385
  },
359
386
  placeholders: [