@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.
- package/CHANGELOG.md +25 -0
- package/README.md +6 -0
- package/app/components/TimesheetsAdminApprovals.vue +26 -0
- package/app/components/TimesheetsApprovalEmails.vue +175 -0
- package/app/components/TimesheetsClientSettingsPanel.vue +47 -9
- package/app/components/TimesheetsReviewRequests.vue +320 -0
- package/app/composables/useTimesheets.ts +17 -0
- package/app/pages/timesheets/approvals.vue +5 -1
- package/app/pages/timesheets/client-approvals.vue +3 -1
- package/app/plugins/timesheets-feature.ts +1 -1
- package/i18n/locales/en.json +82 -1
- package/i18n/locales/nl.json +82 -1
- package/migrations/0006_approval_email.sql +16 -0
- package/migrations/0007_client_review_requests.sql +109 -0
- package/package.json +5 -5
- package/portal.manifest.mjs +1 -1
- package/server/api/timesheets/admin/client-settings/[clientId]/index.get.ts +5 -1
- package/server/api/timesheets/admin/client-settings/[clientId]/index.put.ts +14 -5
- package/server/api/timesheets/admin/review-periods.get.ts +27 -0
- package/server/api/timesheets/admin/review-requests/[id]/index.get.ts +21 -0
- package/server/api/timesheets/admin/review-requests/[id]/withdraw.post.ts +27 -0
- package/server/api/timesheets/admin/review-requests/index.get.ts +15 -0
- package/server/api/timesheets/admin/review-requests/index.post.ts +31 -0
- package/server/api/timesheets/admin/review-submissions/[id]/reopen.post.ts +15 -0
- package/server/api/timesheets/client/review-requests/[id]/index.get.ts +18 -0
- package/server/api/timesheets/client/review-requests/[id]/index.post.ts +23 -0
- package/server/api/timesheets/client/review-requests/index.get.ts +12 -0
- package/server/api/timesheets/dashboard.get.ts +26 -1
- package/server/api/timesheets/submissions/[id]/approval-email.get.ts +17 -0
- package/server/api/timesheets/submissions/[id]/approval-email.post.ts +64 -0
- package/server/db/schema/timesheets.ts +127 -1
- package/server/utils/client-review-request-email.ts +85 -0
- package/server/utils/client-review-requests.ts +376 -0
- package/server/utils/timesheet-admin-listing.ts +18 -4
- package/server/utils/timesheet-approval-email-context.ts +102 -0
- package/server/utils/timesheet-approval-email-draft.ts +56 -0
- package/server/utils/timesheet-approval-email.ts +56 -0
- package/server/utils/timesheet-email-recipients.ts +68 -0
- package/server/utils/timesheet-email.ts +49 -77
- package/server/utils/timesheet-repository.ts +74 -13
- package/shared/approval-email.ts +31 -0
- package/shared/client-review-periods.ts +42 -0
- package/shared/emails.ts +27 -0
- package/shared/types/client-review-request.ts +58 -0
- 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({
|
|
10
|
-
|
|
11
|
-
|
|
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(
|
|
19
|
-
|
|
20
|
-
|
|
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')
|