@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.
Files changed (38) hide show
  1. package/package.json +8 -3
  2. package/src/Timesheet.jsx +36 -1
  3. package/src/TimesheetCard.jsx +57 -18
  4. package/src/TimesheetExportCard.jsx +56 -1
  5. package/src/TimesheetPanel.jsx +249 -0
  6. package/src/TimesheetPartyFields.jsx +121 -0
  7. package/src/TimesheetPartyRows.jsx +101 -0
  8. package/src/TimesheetsProductHome.jsx +257 -69
  9. package/src/auto-generate-timesheets.js +70 -0
  10. package/src/auto-generate-timesheets.spec.js +90 -0
  11. package/src/auto-generate-timesheets.task.js +60 -0
  12. package/src/billing-period.js +123 -0
  13. package/src/billing-period.spec.js +89 -0
  14. package/src/build-prefilled-lines.js +3 -16
  15. package/src/create-timesheet-draft.js +81 -0
  16. package/src/delete.action.js +5 -0
  17. package/src/delete.task.js +42 -0
  18. package/src/download-timesheet-csv.js +20 -1
  19. package/src/en.translations.json +23 -3
  20. package/src/generate.task.js +13 -71
  21. package/src/get.task.js +1 -1
  22. package/src/index.js +12 -1
  23. package/src/list.task.js +5 -1
  24. package/src/save.task.js +16 -5
  25. package/src/schema-ids.js +6 -0
  26. package/src/send-timesheet-reminder.task.js +76 -0
  27. package/src/sv.translations.json +23 -3
  28. package/src/timesheet-detail.page.jsx +10 -193
  29. package/src/timesheet-dto.js +26 -0
  30. package/src/timesheet-parties.js +57 -0
  31. package/src/timesheet-parties.spec.js +40 -0
  32. package/src/timesheet-refs.js +16 -0
  33. package/src/timesheet-reminder.email.jsx +47 -0
  34. package/src/timesheet-reminder.js +62 -0
  35. package/src/timesheet-reminder.spec.js +75 -0
  36. package/src/timesheet-resources.js +6 -18
  37. package/src/timesheet-resources.spec.js +59 -0
  38. 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) => (b.periodStart ?? 0) - (a.periodStart ?? 0))
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
- periodStart: resource.content?.periodStart,
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
+ }
@@ -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, { useCallback, useEffect, useRef, useState } from '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
- load()
62
- }, [load])
63
-
64
- const handleSave = async () => {
65
- if (!timesheet?.id) return
66
- setSaving(true)
67
- setError(null)
68
- setMessage(null)
69
- try {
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
+ }