@ossy/timesheets 3.8.0 → 3.10.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/package.json +8 -3
- package/src/Timesheet.jsx +36 -1
- package/src/TimesheetCard.jsx +57 -18
- package/src/TimesheetExportCard.jsx +56 -1
- package/src/TimesheetPanel.jsx +249 -0
- package/src/TimesheetPartyFields.jsx +121 -0
- package/src/TimesheetPartyRows.jsx +101 -0
- package/src/TimesheetsProductHome.jsx +257 -69
- package/src/auto-generate-timesheets.js +70 -0
- package/src/auto-generate-timesheets.spec.js +90 -0
- package/src/auto-generate-timesheets.task.js +60 -0
- package/src/billing-period.js +123 -0
- package/src/billing-period.spec.js +89 -0
- package/src/build-prefilled-lines.js +3 -16
- package/src/create-timesheet-draft.js +81 -0
- package/src/delete.action.js +5 -0
- package/src/delete.task.js +42 -0
- package/src/download-timesheet-csv.js +20 -1
- package/src/en.translations.json +23 -3
- package/src/generate.task.js +13 -71
- package/src/get.task.js +1 -1
- package/src/index.js +12 -1
- package/src/list.task.js +5 -1
- package/src/save.task.js +16 -5
- package/src/schema-ids.js +6 -0
- package/src/send-timesheet-reminder.task.js +76 -0
- package/src/sv.translations.json +23 -3
- package/src/timesheet-detail.page.jsx +10 -193
- package/src/timesheet-dto.js +26 -0
- package/src/timesheet-parties.js +57 -0
- package/src/timesheet-parties.spec.js +40 -0
- package/src/timesheet-refs.js +16 -0
- package/src/timesheet-reminder.email.jsx +47 -0
- package/src/timesheet-reminder.js +62 -0
- package/src/timesheet-reminder.spec.js +75 -0
- package/src/timesheet-resources.js +6 -18
- package/src/timesheet-resources.spec.js +59 -0
- package/src/timesheet.schema.js +20 -1
package/src/list.task.js
CHANGED
|
@@ -29,7 +29,11 @@ export async function run ({ payload, req, log }) {
|
|
|
29
29
|
|
|
30
30
|
const sheets = resources
|
|
31
31
|
.map(toTimesheetDto)
|
|
32
|
-
.sort((a, b) =>
|
|
32
|
+
.sort((a, b) => {
|
|
33
|
+
const period = (b.periodStart ?? 0) - (a.periodStart ?? 0)
|
|
34
|
+
if (period !== 0) return period
|
|
35
|
+
return (b.createdAt ?? 0) - (a.createdAt ?? 0)
|
|
36
|
+
})
|
|
33
37
|
|
|
34
38
|
log?.info(`[timesheets/tasks/list] Found ${sheets.length} timesheet(s)`)
|
|
35
39
|
|
package/src/save.task.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { ResourcesEvents, mutateResource, viewResource } from '@ossy/resources/server'
|
|
2
2
|
import { TimesheetsSchema } from './schema-ids.js'
|
|
3
|
-
import { toTimesheetDto } from './timesheet-resources.js'
|
|
3
|
+
import { toTimesheetDto, optionalReference } from './timesheet-resources.js'
|
|
4
4
|
|
|
5
5
|
export const metadata = { id: '@ossy/timesheets/tasks/save' }
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Persist lines and set status to `saved`.
|
|
9
|
-
* Payload: `{ timesheetId, lines }`
|
|
9
|
+
* Payload: `{ timesheetId, lines, employeeId?, contractId? }`
|
|
10
10
|
*/
|
|
11
11
|
export async function run ({ payload, req, log }) {
|
|
12
12
|
const workspaceId = req?.workspaceId
|
|
@@ -15,7 +15,7 @@ export async function run ({ payload, req, log }) {
|
|
|
15
15
|
throw Object.assign(new Error('workspaceId is required'), { status: 400 })
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const { timesheetId, lines } = payload ?? {}
|
|
18
|
+
const { timesheetId, lines, employeeId: employeePayload, contractId: contractPayload } = payload ?? {}
|
|
19
19
|
|
|
20
20
|
if (!timesheetId) {
|
|
21
21
|
throw Object.assign(new Error('timesheetId is required'), { status: 400 })
|
|
@@ -42,12 +42,23 @@ export async function run ({ payload, req, log }) {
|
|
|
42
42
|
}))
|
|
43
43
|
|
|
44
44
|
const content = {
|
|
45
|
-
|
|
46
|
-
periodEnd: resource.content?.periodEnd,
|
|
45
|
+
...resource.content,
|
|
47
46
|
status: 'saved',
|
|
48
47
|
lines: normalizedLines,
|
|
49
48
|
}
|
|
50
49
|
|
|
50
|
+
if ('employeeId' in (payload ?? {})) {
|
|
51
|
+
const employeeId = optionalReference(employeePayload)
|
|
52
|
+
if (employeeId) content.employeeId = employeeId
|
|
53
|
+
else delete content.employeeId
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if ('contractId' in (payload ?? {})) {
|
|
57
|
+
const contractId = optionalReference(contractPayload)
|
|
58
|
+
if (contractId) content.contractId = contractId
|
|
59
|
+
else delete content.contractId
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
log?.info(`[timesheets/tasks/save] Saving timesheet ${timesheetId}`)
|
|
52
63
|
|
|
53
64
|
await mutateResource(
|
package/src/schema-ids.js
CHANGED
|
@@ -2,3 +2,9 @@
|
|
|
2
2
|
export const TimesheetsSchema = Object.freeze({
|
|
3
3
|
timesheet: '@ossy/timesheets/schema/timesheet',
|
|
4
4
|
})
|
|
5
|
+
|
|
6
|
+
/** Optional document links stored on a timesheet. */
|
|
7
|
+
export const TimesheetRefs = Object.freeze({
|
|
8
|
+
employee: '@ossy/consultancy/schema/employee',
|
|
9
|
+
contract: '@ossy/consultancy/schema/contract',
|
|
10
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { viewResource } from '@ossy/resources/server'
|
|
2
|
+
import { TimesheetsSchema } from './schema-ids.js'
|
|
3
|
+
import TimesheetReminderEmail, { subject as assignedSubject } from './timesheet-reminder.email.jsx'
|
|
4
|
+
import {
|
|
5
|
+
formatPeriodLabel,
|
|
6
|
+
resolveTimesheetReminderRecipient,
|
|
7
|
+
shouldNotifyTimesheetCreated,
|
|
8
|
+
timesheetAssignedUrl,
|
|
9
|
+
} from './timesheet-reminder.js'
|
|
10
|
+
import { optionalReference } from './timesheet-refs.js'
|
|
11
|
+
|
|
12
|
+
export const metadata = {
|
|
13
|
+
id: '@ossy/timesheets/tasks/send-reminder',
|
|
14
|
+
triggers: [
|
|
15
|
+
{ type: TimesheetsSchema.timesheet, event: 'Created' },
|
|
16
|
+
],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Email the linked employee when an auto-generated timesheet is created.
|
|
21
|
+
* Manual generate does not notify. Created fires once, so there is no daily cron.
|
|
22
|
+
*/
|
|
23
|
+
export async function run ({ event, log, integrations }) {
|
|
24
|
+
if (!shouldNotifyTimesheetCreated(event)) {
|
|
25
|
+
return { sent: 0, skipped: 1 }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const content = event.payload?.content ?? {}
|
|
29
|
+
const resourceId = event.resourceId
|
|
30
|
+
const timesheet = { id: resourceId, belongsTo: event.payload?.belongsTo, content }
|
|
31
|
+
|
|
32
|
+
const employeeId = optionalReference(content.employeeId)?.resourceId
|
|
33
|
+
const contractId = optionalReference(content.contractId)?.resourceId
|
|
34
|
+
|
|
35
|
+
const [employee, contract] = await Promise.all([
|
|
36
|
+
employeeId ? viewResource(employeeId).catch(() => null) : null,
|
|
37
|
+
contractId ? viewResource(contractId).catch(() => null) : null,
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
const recipient = resolveTimesheetReminderRecipient(
|
|
41
|
+
employee ? [employee] : [],
|
|
42
|
+
timesheet,
|
|
43
|
+
)
|
|
44
|
+
if (!recipient) {
|
|
45
|
+
log?.warn(`[timesheets/tasks/send-reminder] no employee email for timesheet ${resourceId}`)
|
|
46
|
+
return { sent: 0, skipped: 1 }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const emailClient = integrations?.get?.('email')
|
|
50
|
+
if (!emailClient) {
|
|
51
|
+
log?.warn('[timesheets/tasks/send-reminder] No email integration available, skipping')
|
|
52
|
+
return { sent: 0, skipped: 1 }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const clientName = contract?.content?.clientName ?? null
|
|
56
|
+
const periodLabel = formatPeriodLabel(content.periodStart, content.periodEnd)
|
|
57
|
+
const timesheetsUrl = timesheetAssignedUrl(resourceId)
|
|
58
|
+
|
|
59
|
+
await emailClient.sendTemplate(
|
|
60
|
+
TimesheetReminderEmail,
|
|
61
|
+
{
|
|
62
|
+
employeeName: recipient.name,
|
|
63
|
+
clientName,
|
|
64
|
+
periodLabel,
|
|
65
|
+
timesheetsUrl,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
to: recipient.email,
|
|
69
|
+
from: 'noreply@ossy.se',
|
|
70
|
+
subject: assignedSubject,
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
log?.info(`[timesheets/tasks/send-reminder] Assigned-timesheet email sent to ${recipient.email}`)
|
|
75
|
+
return { sent: 1, skipped: 0 }
|
|
76
|
+
}
|
package/src/sv.translations.json
CHANGED
|
@@ -35,9 +35,15 @@
|
|
|
35
35
|
"timesheets.createDescription": "Välj en månad och helgdagskalender för att generera en förifylld tidrapport.",
|
|
36
36
|
"timesheets.period": "Period",
|
|
37
37
|
"timesheets.holidayLocale": "Helgdagskalender",
|
|
38
|
+
"timesheets.employee": "Tilldelad",
|
|
39
|
+
"timesheets.employeeNone": "Ingen tilldelad",
|
|
40
|
+
"timesheets.contract": "Kontrakt",
|
|
41
|
+
"timesheets.contractNone": "Inget kontrakt",
|
|
42
|
+
"@ossy/timesheets/schema/timesheet.employeeId.label": "Tilldelad",
|
|
43
|
+
"@ossy/timesheets/schema/timesheet.employeeId.description": "Valfritt. Vem tidrapporten är tilldelad.",
|
|
44
|
+
"@ossy/timesheets/schema/timesheet.contractId.label": "Kontrakt",
|
|
45
|
+
"@ossy/timesheets/schema/timesheet.contractId.description": "Valfritt. Kontraktet tidrapporten tillhör.",
|
|
38
46
|
"timesheets.generate": "Generera",
|
|
39
|
-
"timesheets.openExisting": "Öppna",
|
|
40
|
-
"timesheets.periodExistsHint": "Det finns redan en tidrapport för perioden — Öppna laddar den.",
|
|
41
47
|
"timesheets.cancel": "Avbryt",
|
|
42
48
|
"timesheets.save": "Spara",
|
|
43
49
|
"timesheets.saving": "Sparar…",
|
|
@@ -52,13 +58,26 @@
|
|
|
52
58
|
"timesheets.listTitle": "Dina tidrapporter",
|
|
53
59
|
"timesheets.listEmpty": "Inga tidrapporter ännu. Klicka på Ny för att generera en för en månad.",
|
|
54
60
|
"timesheets.open": "Öppna",
|
|
61
|
+
"timesheets.view": "Visa",
|
|
62
|
+
"timesheets.rowActions": "Åtgärder för tidrapport",
|
|
63
|
+
"timesheets.delete": "Ta bort",
|
|
64
|
+
"timesheets.deleteTitle": "Ta bort tidrapporten?",
|
|
65
|
+
"timesheets.deleteText": "Tidrapporten tas bort. Det går inte att ångra.",
|
|
55
66
|
"timesheets.backToList": "← Alla tidrapporter",
|
|
67
|
+
"timesheets.closePanel": "Stäng",
|
|
56
68
|
"timesheets.saved": "Tidrapport sparad.",
|
|
57
69
|
"timesheets.unsaved": "Osparade ändringar",
|
|
58
70
|
"timesheets.statusLabel": "Status: {status}",
|
|
59
71
|
"timesheets.status.draft": "Utkast",
|
|
60
72
|
"timesheets.status.saved": "Sparad",
|
|
61
73
|
"timesheets.totalHours": "Totalt: {hours} h",
|
|
74
|
+
"timesheets.meta.status": "Status",
|
|
75
|
+
"timesheets.meta.hoursValue": "{hours} h",
|
|
76
|
+
"timesheets.meta.client": "Kund",
|
|
77
|
+
"timesheets.meta.hourlyRate": "Timarvode",
|
|
78
|
+
"timesheets.meta.totalCost": "Totalkostnad",
|
|
79
|
+
"timesheets.meta.perHour": "/h",
|
|
80
|
+
"timesheets.meta.dash": "—",
|
|
62
81
|
"timesheets.weekday.mon": "Mån",
|
|
63
82
|
"timesheets.weekday.tue": "Tis",
|
|
64
83
|
"timesheets.weekday.wed": "Ons",
|
|
@@ -70,5 +89,6 @@
|
|
|
70
89
|
"timesheets.errorNotFound": "Tidrapporten hittades inte.",
|
|
71
90
|
"timesheets.errorGenerate": "Kunde inte generera tidrapporten.",
|
|
72
91
|
"timesheets.errorSave": "Kunde inte spara tidrapporten.",
|
|
73
|
-
"timesheets.errorExport": "Kunde inte exportera tidrapporten."
|
|
92
|
+
"timesheets.errorExport": "Kunde inte exportera tidrapporten.",
|
|
93
|
+
"timesheets.errorDelete": "Kunde inte ta bort tidrapporten."
|
|
74
94
|
}
|
|
@@ -1,15 +1,5 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
import { Text, View, Button, Page, useLocale, Alert, Dropdown, ContextMenu } from '@ossy/design-system'
|
|
1
|
+
import React, { useEffect } from 'react'
|
|
3
2
|
import { useRouter } from '@ossy/router-react'
|
|
4
|
-
import { useSdk } from '@ossy/sdk-react'
|
|
5
|
-
import { Timesheet } from './Timesheet.jsx'
|
|
6
|
-
import { TimesheetExportCard } from './TimesheetExportCard.jsx'
|
|
7
|
-
import { downloadTimesheetCsv } from './download-timesheet-csv.js'
|
|
8
|
-
import { downloadTimesheetImage } from './download-timesheet-image.js'
|
|
9
|
-
import { downloadTimesheetPdf } from './download-timesheet-pdf.js'
|
|
10
|
-
import { invokeErrorMessage } from './invoke-error-message.js'
|
|
11
|
-
import { metadata as GetTimesheet } from './get.action.js'
|
|
12
|
-
import { metadata as SaveTimesheet } from './save.action.js'
|
|
13
3
|
|
|
14
4
|
export const metadata = {
|
|
15
5
|
id: 'timesheets/detail',
|
|
@@ -20,190 +10,17 @@ export const metadata = {
|
|
|
20
10
|
}
|
|
21
11
|
|
|
22
12
|
export default function TimesheetDetailPage () {
|
|
23
|
-
const { t } = useLocale()
|
|
24
|
-
const sdk = useSdk()
|
|
25
13
|
const router = useRouter()
|
|
26
14
|
const timesheetId = router.params?.timesheetId
|
|
27
|
-
const captureRef = useRef(null)
|
|
28
|
-
|
|
29
|
-
const [timesheet, setTimesheet] = useState(null)
|
|
30
|
-
const [lines, setLines] = useState([])
|
|
31
|
-
const [loading, setLoading] = useState(true)
|
|
32
|
-
const [saving, setSaving] = useState(false)
|
|
33
|
-
const [exporting, setExporting] = useState(false)
|
|
34
|
-
const [error, setError] = useState(null)
|
|
35
|
-
const [message, setMessage] = useState(null)
|
|
36
|
-
|
|
37
|
-
const load = useCallback(async () => {
|
|
38
|
-
if (!timesheetId) return
|
|
39
|
-
setLoading(true)
|
|
40
|
-
setError(null)
|
|
41
|
-
try {
|
|
42
|
-
const data = await sdk.invoke(GetTimesheet, { timesheetId })
|
|
43
|
-
if (!data) {
|
|
44
|
-
setError(t('timesheets.errorNotFound'))
|
|
45
|
-
setTimesheet(null)
|
|
46
|
-
setLines([])
|
|
47
|
-
return
|
|
48
|
-
}
|
|
49
|
-
setTimesheet(data)
|
|
50
|
-
setLines(data.lines ?? [])
|
|
51
|
-
} catch (err) {
|
|
52
|
-
setError(await invokeErrorMessage(err, t('timesheets.errorLoad')))
|
|
53
|
-
setTimesheet(null)
|
|
54
|
-
setLines([])
|
|
55
|
-
} finally {
|
|
56
|
-
setLoading(false)
|
|
57
|
-
}
|
|
58
|
-
}, [sdk, t, timesheetId])
|
|
59
15
|
|
|
60
16
|
useEffect(() => {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const data = await sdk.invoke(SaveTimesheet, {
|
|
71
|
-
timesheetId: timesheet.id,
|
|
72
|
-
lines,
|
|
73
|
-
})
|
|
74
|
-
setTimesheet(data)
|
|
75
|
-
setLines(data?.lines ?? lines)
|
|
76
|
-
setMessage(t('timesheets.saved'))
|
|
77
|
-
} catch (err) {
|
|
78
|
-
setError(await invokeErrorMessage(err, t('timesheets.errorSave')))
|
|
79
|
-
} finally {
|
|
80
|
-
setSaving(false)
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const exportPayload = {
|
|
85
|
-
...timesheet,
|
|
86
|
-
lines,
|
|
87
|
-
status: timesheet?.status,
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const runExport = async (kind) => {
|
|
91
|
-
if (!lines.length) return
|
|
92
|
-
setExporting(true)
|
|
93
|
-
setError(null)
|
|
94
|
-
setMessage(null)
|
|
95
|
-
try {
|
|
96
|
-
if (kind === 'csv') {
|
|
97
|
-
downloadTimesheetCsv(exportPayload)
|
|
98
|
-
} else if (kind === 'png') {
|
|
99
|
-
await downloadTimesheetImage(captureRef.current, exportPayload)
|
|
100
|
-
} else if (kind === 'pdf') {
|
|
101
|
-
await downloadTimesheetPdf(captureRef.current, exportPayload)
|
|
102
|
-
}
|
|
103
|
-
} catch (err) {
|
|
104
|
-
setError(await invokeErrorMessage(err, t('timesheets.errorExport')))
|
|
105
|
-
} finally {
|
|
106
|
-
setExporting(false)
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const homeHref = router.getHref({ id: 'timesheets/home' })
|
|
111
|
-
const exportDisabled = !lines.length || loading || exporting
|
|
112
|
-
|
|
113
|
-
return (
|
|
114
|
-
<Page
|
|
115
|
-
title={(
|
|
116
|
-
<View layout="row" gap="s" alignItems="center">
|
|
117
|
-
<Button
|
|
118
|
-
prefix="chevron-left"
|
|
119
|
-
variant="command"
|
|
120
|
-
href={homeHref || undefined}
|
|
121
|
-
aria-label="timesheets.backToList"
|
|
122
|
-
onClick={homeHref ? undefined : () => router.back()}
|
|
123
|
-
/>
|
|
124
|
-
<Text variant="heading-default" as="h1" text="timesheets.detail.title" />
|
|
125
|
-
</View>
|
|
126
|
-
)}
|
|
127
|
-
description="timesheets.detail.description"
|
|
128
|
-
far={(
|
|
129
|
-
<View layout="row" gap="s" alignItems="center">
|
|
130
|
-
<Dropdown
|
|
131
|
-
trigger={(
|
|
132
|
-
<Button
|
|
133
|
-
variant="secondary"
|
|
134
|
-
prefix="software-download"
|
|
135
|
-
suffix="chevron-down"
|
|
136
|
-
disabled={exportDisabled}
|
|
137
|
-
>
|
|
138
|
-
{exporting ? t('timesheets.exporting') : t('timesheets.export')}
|
|
139
|
-
</Button>
|
|
140
|
-
)}
|
|
141
|
-
>
|
|
142
|
-
<ContextMenu roundness="s" surface="primary">
|
|
143
|
-
<ContextMenu.Item
|
|
144
|
-
prefix="list"
|
|
145
|
-
label="timesheets.exportCsv"
|
|
146
|
-
onClick={() => runExport('csv')}
|
|
147
|
-
/>
|
|
148
|
-
<ContextMenu.Item
|
|
149
|
-
prefix="image"
|
|
150
|
-
label="timesheets.exportPng"
|
|
151
|
-
onClick={() => runExport('png')}
|
|
152
|
-
/>
|
|
153
|
-
<ContextMenu.Item
|
|
154
|
-
prefix="file-document"
|
|
155
|
-
label="timesheets.exportPdf"
|
|
156
|
-
onClick={() => runExport('pdf')}
|
|
157
|
-
/>
|
|
158
|
-
</ContextMenu>
|
|
159
|
-
</Dropdown>
|
|
160
|
-
<Button
|
|
161
|
-
id={SaveTimesheet.id}
|
|
162
|
-
variant="cta"
|
|
163
|
-
prefix="check"
|
|
164
|
-
disabled={!timesheet?.id || saving || loading}
|
|
165
|
-
onClick={handleSave}
|
|
166
|
-
>
|
|
167
|
-
{saving ? t('timesheets.saving') : t('timesheets.save')}
|
|
168
|
-
</Button>
|
|
169
|
-
</View>
|
|
170
|
-
)}
|
|
171
|
-
>
|
|
172
|
-
<View gap="m">
|
|
173
|
-
{error && <Alert variant="danger">{error}</Alert>}
|
|
174
|
-
{message && !error && <Alert variant="success">{message}</Alert>}
|
|
175
|
-
|
|
176
|
-
{loading ? (
|
|
177
|
-
<Text color="secondary" text="timesheets.loading" />
|
|
178
|
-
) : timesheet ? (
|
|
179
|
-
<>
|
|
180
|
-
<Timesheet
|
|
181
|
-
lines={lines}
|
|
182
|
-
onChange={setLines}
|
|
183
|
-
disabled={loading || saving || exporting}
|
|
184
|
-
periodStart={timesheet.periodStart}
|
|
185
|
-
/>
|
|
186
|
-
{/* Off-screen light-theme card for PNG/PDF capture (no inputs). */}
|
|
187
|
-
<div
|
|
188
|
-
aria-hidden
|
|
189
|
-
style={{
|
|
190
|
-
position: 'fixed',
|
|
191
|
-
left: 0,
|
|
192
|
-
top: 0,
|
|
193
|
-
transform: 'translateX(-200vw)',
|
|
194
|
-
pointerEvents: 'none',
|
|
195
|
-
zIndex: -1,
|
|
196
|
-
}}
|
|
197
|
-
>
|
|
198
|
-
<TimesheetExportCard
|
|
199
|
-
lines={lines}
|
|
200
|
-
periodStart={timesheet.periodStart}
|
|
201
|
-
captureRef={captureRef}
|
|
202
|
-
/>
|
|
203
|
-
</div>
|
|
204
|
-
</>
|
|
205
|
-
) : null}
|
|
206
|
-
</View>
|
|
207
|
-
</Page>
|
|
208
|
-
)
|
|
17
|
+
const home = router.getHref({ id: 'timesheets/home' }) || '/timesheets'
|
|
18
|
+
const sep = home.includes('?') ? '&' : '?'
|
|
19
|
+
const next = timesheetId
|
|
20
|
+
? `${home}${sep}r=${encodeURIComponent(timesheetId)}`
|
|
21
|
+
: home
|
|
22
|
+
window.location.replace(next)
|
|
23
|
+
}, [router, timesheetId])
|
|
24
|
+
|
|
25
|
+
return null
|
|
209
26
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { optionalReference } from './timesheet-refs.js'
|
|
2
|
+
|
|
3
|
+
export { optionalReference } from './timesheet-refs.js'
|
|
4
|
+
|
|
5
|
+
/** Newest sheet for a period. Generate always creates a new resource; use get-by-id for a specific sheet. */
|
|
6
|
+
export function findTimesheetForPeriod (resources, periodStart) {
|
|
7
|
+
const matches = (resources || []).filter((r) => r.content?.periodStart === periodStart)
|
|
8
|
+
if (matches.length === 0) return null
|
|
9
|
+
return [...matches].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))[0]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function toTimesheetDto (resource) {
|
|
13
|
+
if (!resource) return null
|
|
14
|
+
return {
|
|
15
|
+
id: resource.id,
|
|
16
|
+
periodStart: resource.content?.periodStart,
|
|
17
|
+
periodEnd: resource.content?.periodEnd,
|
|
18
|
+
status: resource.content?.status ?? 'draft',
|
|
19
|
+
holidayLocale: resource.content?.holidayLocale ?? null,
|
|
20
|
+
employeeId: optionalReference(resource.content?.employeeId),
|
|
21
|
+
contractId: optionalReference(resource.content?.contractId),
|
|
22
|
+
lines: resource.content?.lines ?? [],
|
|
23
|
+
createdAt: resource.createdAt,
|
|
24
|
+
updatedAt: resource.updatedAt,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { resourceIdOf } from '@ossy/calendar/holiday-locale'
|
|
2
|
+
|
|
3
|
+
export { resourceIdOf }
|
|
4
|
+
|
|
5
|
+
export function employeeLabel (resource) {
|
|
6
|
+
if (!resource) return ''
|
|
7
|
+
const name = typeof resource.content?.name === 'string' ? resource.content.name.trim() : ''
|
|
8
|
+
if (name) return name
|
|
9
|
+
const docName = typeof resource.name === 'string' ? resource.name.trim() : ''
|
|
10
|
+
return docName || resource.id || ''
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function contractLabel (resource) {
|
|
14
|
+
if (!resource) return ''
|
|
15
|
+
const client = typeof resource.content?.clientName === 'string' ? resource.content.clientName.trim() : ''
|
|
16
|
+
if (client) return client
|
|
17
|
+
const docName = typeof resource.name === 'string' ? resource.name.trim() : ''
|
|
18
|
+
return docName || resource.id || ''
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function labelForEmployee (employees, ref) {
|
|
22
|
+
const id = resourceIdOf(ref)
|
|
23
|
+
if (!id || !Array.isArray(employees)) return ''
|
|
24
|
+
const match = employees.find((resource) => resource.id === id)
|
|
25
|
+
return match ? employeeLabel(match) : ''
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function labelForContract (contracts, ref) {
|
|
29
|
+
const id = resourceIdOf(ref)
|
|
30
|
+
if (!id || !Array.isArray(contracts)) return ''
|
|
31
|
+
const match = contracts.find((resource) => resource.id === id)
|
|
32
|
+
return match ? contractLabel(match) : ''
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Client name and hourly rate from a contract ref, for timesheet meta and exports. */
|
|
36
|
+
export function contractBillingMeta (contracts, ref) {
|
|
37
|
+
const content = partyContent(findByRef(contracts, ref))
|
|
38
|
+
if (!content) return { clientName: '', hourlyRate: null }
|
|
39
|
+
const raw = content.hourlyCompensation
|
|
40
|
+
const rate = raw == null || raw === '' ? NaN : Number(raw)
|
|
41
|
+
const clientName = typeof content.clientName === 'string' ? content.clientName.trim() : ''
|
|
42
|
+
return {
|
|
43
|
+
clientName,
|
|
44
|
+
hourlyRate: Number.isFinite(rate) ? rate : null,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function findByRef (resources, ref) {
|
|
49
|
+
const id = resourceIdOf(ref)
|
|
50
|
+
if (!id || !Array.isArray(resources)) return null
|
|
51
|
+
return resources.find((resource) => resource.id === id) ?? null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function partyContent (resource) {
|
|
55
|
+
if (!resource) return null
|
|
56
|
+
return { id: resource.id, ...(resource.content || {}) }
|
|
57
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
contractBillingMeta,
|
|
4
|
+
contractLabel,
|
|
5
|
+
employeeLabel,
|
|
6
|
+
findByRef,
|
|
7
|
+
labelForContract,
|
|
8
|
+
labelForEmployee,
|
|
9
|
+
} from './timesheet-parties.js'
|
|
10
|
+
|
|
11
|
+
describe('timesheet party labels', () => {
|
|
12
|
+
it('prefers content name, then document name', () => {
|
|
13
|
+
expect(employeeLabel({ id: 'e1', name: 'file.json', content: { name: 'Oskar' } })).toBe('Oskar')
|
|
14
|
+
expect(employeeLabel({ id: 'e1', name: 'file.json', content: {} })).toBe('file.json')
|
|
15
|
+
expect(contractLabel({ id: 'c1', content: { clientName: 'Fortnox' } })).toBe('Fortnox')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('resolves labels from a reference', () => {
|
|
19
|
+
const employees = [{ id: 'e1', content: { name: 'Oskar' } }]
|
|
20
|
+
const contracts = [{ id: 'c1', content: { clientName: 'Fortnox' } }]
|
|
21
|
+
expect(labelForEmployee(employees, { resourceId: 'e1' })).toBe('Oskar')
|
|
22
|
+
expect(labelForContract(contracts, 'c1')).toBe('Fortnox')
|
|
23
|
+
expect(labelForEmployee(employees, 'missing')).toBe('')
|
|
24
|
+
expect(findByRef(employees, { resourceId: 'e1' })?.id).toBe('e1')
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('contractBillingMeta', () => {
|
|
29
|
+
it('reads client name and hourly rate', () => {
|
|
30
|
+
const contracts = [{ id: 'c1', content: { clientName: 'Fortnox', hourlyCompensation: 700 } }]
|
|
31
|
+
expect(contractBillingMeta(contracts, 'c1')).toEqual({
|
|
32
|
+
clientName: 'Fortnox',
|
|
33
|
+
hourlyRate: 700,
|
|
34
|
+
})
|
|
35
|
+
expect(contractBillingMeta(contracts, 'missing')).toEqual({
|
|
36
|
+
clientName: '',
|
|
37
|
+
hourlyRate: null,
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize an optional document reference to `{ resourceId }` or `null`.
|
|
3
|
+
* Accepts a stored object, a plain id string, or empty.
|
|
4
|
+
*/
|
|
5
|
+
export function optionalReference (value) {
|
|
6
|
+
if (value == null || value === '') return null
|
|
7
|
+
if (typeof value === 'string') {
|
|
8
|
+
const id = value.trim()
|
|
9
|
+
return id ? { resourceId: id } : null
|
|
10
|
+
}
|
|
11
|
+
if (typeof value === 'object' && typeof value.resourceId === 'string') {
|
|
12
|
+
const id = value.resourceId.trim()
|
|
13
|
+
return id ? { resourceId: id } : null
|
|
14
|
+
}
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { EmailLayout, EmailButton, EmailText } from '@ossy/email'
|
|
3
|
+
|
|
4
|
+
export const id = '@ossy/timesheets/emails/reminder'
|
|
5
|
+
export const subject = 'En tidrapport har skapats åt dig'
|
|
6
|
+
|
|
7
|
+
export default function TimesheetReminderEmail ({
|
|
8
|
+
employeeName,
|
|
9
|
+
clientName,
|
|
10
|
+
periodLabel,
|
|
11
|
+
timesheetsUrl,
|
|
12
|
+
}) {
|
|
13
|
+
const greeting = employeeName ? `Hej ${employeeName},` : 'Hej,'
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<EmailLayout>
|
|
17
|
+
<h1 style={{ color: '#111111', fontSize: 24, fontWeight: 700, margin: '0 0 24px' }}>
|
|
18
|
+
Du har tilldelats en tidrapport
|
|
19
|
+
</h1>
|
|
20
|
+
|
|
21
|
+
<EmailText>{greeting}</EmailText>
|
|
22
|
+
|
|
23
|
+
<EmailText>
|
|
24
|
+
En tidrapport har skapats åt dig
|
|
25
|
+
{clientName ? (
|
|
26
|
+
<>
|
|
27
|
+
{' '}för uppdraget <strong>{clientName}</strong>
|
|
28
|
+
</>
|
|
29
|
+
) : null}
|
|
30
|
+
{periodLabel ? (
|
|
31
|
+
<>
|
|
32
|
+
{' '}för perioden <strong>{periodLabel}</strong>
|
|
33
|
+
</>
|
|
34
|
+
) : null}
|
|
35
|
+
. Kontrollera timmarna och spara när de stämmer.
|
|
36
|
+
</EmailText>
|
|
37
|
+
|
|
38
|
+
{timesheetsUrl ? (
|
|
39
|
+
<EmailButton href={timesheetsUrl}>Öppna tidrapporten</EmailButton>
|
|
40
|
+
) : null}
|
|
41
|
+
|
|
42
|
+
<EmailText style={{ color: '#888888', fontSize: 13 }}>
|
|
43
|
+
Du får det här mejlet för att en tidrapport skapades automatiskt utifrån ditt avtal.
|
|
44
|
+
</EmailText>
|
|
45
|
+
</EmailLayout>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { isEmailLike } from '@ossy/schema'
|
|
2
|
+
import { optionalReference } from './timesheet-refs.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether a timesheet Created event should notify the assigned employee.
|
|
6
|
+
* Manual generate does not email; auto-generate does, once, on create.
|
|
7
|
+
*
|
|
8
|
+
* @param {{ payload?: { content?: object }, resourceId?: string } | null | undefined} event
|
|
9
|
+
*/
|
|
10
|
+
export function shouldNotifyTimesheetCreated (event) {
|
|
11
|
+
const content = event?.payload?.content ?? {}
|
|
12
|
+
return content.autoGenerated === true && Boolean(event?.resourceId)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve reminder recipient from the linked employee resource.
|
|
17
|
+
*
|
|
18
|
+
* @param {Array<{ id: string, content?: { name?: string, email?: string } }>} employees
|
|
19
|
+
* @param {{ content?: { employeeId?: unknown } }} timesheet
|
|
20
|
+
* @returns {{ email: string, name: string | null } | null}
|
|
21
|
+
*/
|
|
22
|
+
export function resolveTimesheetReminderRecipient (employees, timesheet) {
|
|
23
|
+
const employeeId = optionalReference(timesheet?.content?.employeeId)?.resourceId
|
|
24
|
+
if (!employeeId) return null
|
|
25
|
+
|
|
26
|
+
const employee = (employees || []).find((e) => e.id === employeeId)
|
|
27
|
+
const email = employee?.content?.email?.trim?.() ?? employee?.content?.email
|
|
28
|
+
if (!isEmailLike(email)) return null
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
email: String(email).trim(),
|
|
32
|
+
name: employee?.content?.name ?? null,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Public timesheets home URL, optionally opening a specific sheet (`?r=`).
|
|
38
|
+
* Swedish email copy uses `/tidrapporter`.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} [resourceId]
|
|
41
|
+
* @param {string} [origin]
|
|
42
|
+
*/
|
|
43
|
+
export function timesheetAssignedUrl (resourceId, origin = process.env.WEB_CLIENT_DOMAIN) {
|
|
44
|
+
const base = String(origin || '').replace(/\/$/, '')
|
|
45
|
+
if (!base) return null
|
|
46
|
+
const path = '/tidrapporter'
|
|
47
|
+
if (!resourceId) return `${base}${path}`
|
|
48
|
+
return `${base}${path}?r=${encodeURIComponent(resourceId)}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Format a period for email copy (local calendar dates).
|
|
53
|
+
* @param {number} periodStart
|
|
54
|
+
* @param {number} periodEnd
|
|
55
|
+
* @param {string} [locale]
|
|
56
|
+
*/
|
|
57
|
+
export function formatPeriodLabel (periodStart, periodEnd, locale = 'sv-SE') {
|
|
58
|
+
const start = new Date(periodStart)
|
|
59
|
+
const end = new Date(periodEnd)
|
|
60
|
+
const opts = { year: 'numeric', month: 'long', day: 'numeric' }
|
|
61
|
+
return `${start.toLocaleDateString(locale, opts)} – ${end.toLocaleDateString(locale, opts)}`
|
|
62
|
+
}
|