@ossy/timesheets 3.8.0 → 3.9.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.
@@ -1,24 +1,21 @@
1
1
  import { nanoid } from 'nanoid'
2
- import { ResourcesEvents, commitResource, mutateResource } from '@ossy/resources/server'
2
+ import { ResourcesEvents, commitResource } from '@ossy/resources/server'
3
3
  import { TimesheetsSchema } from './schema-ids.js'
4
4
  import { timesheets } from './locations.js'
5
5
  import { buildPrefilledLines, monthPeriodBounds } from './build-prefilled-lines.js'
6
6
  import { DEFAULT_HOLIDAY_LOCALE, resolveHolidayLocale } from './holiday-locale.js'
7
- import {
8
- findTimesheetForPeriod,
9
- getTimesheetResources,
10
- toTimesheetDto,
11
- } from './timesheet-resources.js'
7
+ import { toTimesheetDto, optionalReference } from './timesheet-resources.js'
12
8
 
13
9
  export const metadata = { id: '@ossy/timesheets/tasks/generate' }
14
10
 
15
11
  /**
16
- * Generate (or load) a timesheet for a calendar month.
12
+ * Generate a new timesheet for a calendar month.
17
13
  *
18
- * Payload: `{ year, monthIndex, locale? }` (monthIndex 0–11) OR `{ periodStart, periodEnd, locale? }`.
14
+ * Payload: `{ year, monthIndex, locale?, employeeId?, contractId? }` (monthIndex 0–11)
15
+ * OR `{ periodStart, periodEnd, locale?, employeeId?, contractId? }`.
19
16
  * `locale` is an ISO country code or BCP 47 tag used for workday/holiday prefill.
20
- * If a saved sheet exists for the period, returns it without regenerating.
21
- * If a draft exists, regenerates prefilled lines (keeps id).
17
+ * Always creates a new draft multiple sheets may share the same period.
18
+ * Optional `employeeId` / `contractId` are stored as `{ resourceId }` references.
22
19
  */
23
20
  export async function run ({ payload, req, log }) {
24
21
  const workspaceId = req?.workspaceId
@@ -53,16 +50,8 @@ export async function run ({ payload, req, log }) {
53
50
  `[timesheets/tasks/generate] period ${periodStart}–${periodEnd} for workspace ${workspaceId} (locale ${holidayLocale})`,
54
51
  )
55
52
 
56
- const resources = await getTimesheetResources({
57
- schemaId: TimesheetsSchema.timesheet,
58
- belongsTo: workspaceId,
59
- })
60
- const existing = findTimesheetForPeriod(resources, periodStart)
61
-
62
- if (existing?.content?.status === 'saved') {
63
- log?.info(`[timesheets/tasks/generate] Returning saved timesheet ${existing.id}`)
64
- return toTimesheetDto(existing)
65
- }
53
+ const employeeId = optionalReference(payload?.employeeId)
54
+ const contractId = optionalReference(payload?.contractId)
66
55
 
67
56
  const lines = buildPrefilledLines(periodStart, periodEnd, holidayLocale)
68
57
  const content = {
@@ -71,18 +60,8 @@ export async function run ({ payload, req, log }) {
71
60
  status: 'draft',
72
61
  holidayLocale,
73
62
  lines,
74
- }
75
-
76
- if (existing) {
77
- log?.info(`[timesheets/tasks/generate] Regenerating draft ${existing.id}`)
78
- await mutateResource(
79
- existing.id,
80
- ResourcesEvents.Patched({
81
- createdBy: req?.userId ?? 'system',
82
- content,
83
- }),
84
- )
85
- return toTimesheetDto({ ...existing, content })
63
+ ...(employeeId ? { employeeId } : {}),
64
+ ...(contractId ? { contractId } : {}),
86
65
  }
87
66
 
88
67
  const resourceId = nanoid()
@@ -92,7 +71,7 @@ export async function run ({ payload, req, log }) {
92
71
  createdBy: req?.userId ?? 'system',
93
72
  belongsTo: workspaceId,
94
73
  location: timesheets,
95
- name: `timesheet-${periodStart}.json`,
74
+ name: `timesheet-${periodStart}-${resourceId}.json`,
96
75
  content,
97
76
  })
98
77
 
package/src/get.task.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  export const metadata = { id: '@ossy/timesheets/tasks/get' }
10
10
 
11
11
  /**
12
- * Get a timesheet by `{ timesheetId }` or `{ periodStart }`.
12
+ * Get a timesheet by `{ timesheetId }` or `{ periodStart }` (newest sheet for that period).
13
13
  */
14
14
  export async function run ({ payload, req, log }) {
15
15
  const workspaceId = req?.workspaceId
package/src/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { Definition } from './Definition.js'
2
- export { TimesheetsSchema } from './schema-ids.js'
2
+ export { TimesheetsSchema, TimesheetRefs } from './schema-ids.js'
3
3
  export {
4
4
  location,
5
5
  timesheets,
@@ -9,6 +9,7 @@ export { metadata as GenerateTimesheet } from './generate.action.js'
9
9
  export { metadata as SaveTimesheet } from './save.action.js'
10
10
  export { metadata as ListTimesheets } from './list.action.js'
11
11
  export { metadata as GetTimesheet } from './get.action.js'
12
+ export { metadata as DeleteTimesheet } from './delete.action.js'
12
13
  export { metadata as OpenNewTimesheet } from './open-new.action.js'
13
14
  export { invokeErrorMessage } from './invoke-error-message.js'
14
15
  export { downloadTimesheetCsv } from './download-timesheet-csv.js'
@@ -21,3 +22,4 @@ export { TimesheetExportCard } from './TimesheetExportCard.jsx'
21
22
  export { TimesheetsFreeTool } from './TimesheetsFreeTool.jsx'
22
23
  export { TIMESHEET_EXPORT_BASE_BG } from './export-capture.js'
23
24
  export { TimesheetCard } from './TimesheetCard.jsx'
25
+ export { TimesheetPanel } from './TimesheetPanel.jsx'
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
+ })
@@ -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,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
+ })
@@ -9,8 +9,28 @@ export function getTimesheetResources ({ schemaId, belongsTo } = {}) {
9
9
  return ResourcesQueries.GetResources(query)
10
10
  }
11
11
 
12
+ /** Newest sheet for a period. Generate always creates a new resource; use get-by-id for a specific sheet. */
12
13
  export function findTimesheetForPeriod (resources, periodStart) {
13
- return (resources || []).find((r) => r.content?.periodStart === periodStart) ?? null
14
+ const matches = (resources || []).filter((r) => r.content?.periodStart === periodStart)
15
+ if (matches.length === 0) return null
16
+ return [...matches].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))[0]
17
+ }
18
+
19
+ /**
20
+ * Normalize an optional document reference to `{ resourceId }` or `null`.
21
+ * Accepts a stored object, a plain id string, or empty.
22
+ */
23
+ export function optionalReference (value) {
24
+ if (value == null || value === '') return null
25
+ if (typeof value === 'string') {
26
+ const id = value.trim()
27
+ return id ? { resourceId: id } : null
28
+ }
29
+ if (typeof value === 'object' && typeof value.resourceId === 'string') {
30
+ const id = value.resourceId.trim()
31
+ return id ? { resourceId: id } : null
32
+ }
33
+ return null
14
34
  }
15
35
 
16
36
  export function toTimesheetDto (resource) {
@@ -21,6 +41,8 @@ export function toTimesheetDto (resource) {
21
41
  periodEnd: resource.content?.periodEnd,
22
42
  status: resource.content?.status ?? 'draft',
23
43
  holidayLocale: resource.content?.holidayLocale ?? null,
44
+ employeeId: optionalReference(resource.content?.employeeId),
45
+ contractId: optionalReference(resource.content?.contractId),
24
46
  lines: resource.content?.lines ?? [],
25
47
  createdAt: resource.createdAt,
26
48
  updatedAt: resource.updatedAt,
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import {
3
+ findTimesheetForPeriod,
4
+ optionalReference,
5
+ toTimesheetDto,
6
+ } from './timesheet-resources.js'
7
+
8
+ describe('optionalReference', () => {
9
+ it('normalizes ids, objects, and empty values', () => {
10
+ expect(optionalReference(' emp-1 ')).toEqual({ resourceId: 'emp-1' })
11
+ expect(optionalReference({ resourceId: 'emp-1' })).toEqual({ resourceId: 'emp-1' })
12
+ expect(optionalReference('')).toBe(null)
13
+ expect(optionalReference(null)).toBe(null)
14
+ expect(optionalReference({})).toBe(null)
15
+ })
16
+ })
17
+
18
+ describe('toTimesheetDto', () => {
19
+ it('includes optional employee and contract refs', () => {
20
+ expect(toTimesheetDto({
21
+ id: 'sheet-1',
22
+ content: {
23
+ periodStart: 1,
24
+ periodEnd: 2,
25
+ status: 'draft',
26
+ holidayLocale: 'SE',
27
+ employeeId: 'emp-1',
28
+ contractId: { resourceId: 'con-1' },
29
+ lines: [],
30
+ },
31
+ createdAt: 10,
32
+ updatedAt: 20,
33
+ })).toEqual({
34
+ id: 'sheet-1',
35
+ periodStart: 1,
36
+ periodEnd: 2,
37
+ status: 'draft',
38
+ holidayLocale: 'SE',
39
+ employeeId: { resourceId: 'emp-1' },
40
+ contractId: { resourceId: 'con-1' },
41
+ lines: [],
42
+ createdAt: 10,
43
+ updatedAt: 20,
44
+ })
45
+ })
46
+ })
47
+
48
+ describe('findTimesheetForPeriod', () => {
49
+ it('returns the newest sheet when several share a period', () => {
50
+ const resources = [
51
+ { id: 'old', createdAt: 1, content: { periodStart: 100 } },
52
+ { id: 'new', createdAt: 3, content: { periodStart: 100 } },
53
+ { id: 'mid', createdAt: 2, content: { periodStart: 100 } },
54
+ { id: 'other', createdAt: 9, content: { periodStart: 200 } },
55
+ ]
56
+ expect(findTimesheetForPeriod(resources, 100)?.id).toBe('new')
57
+ expect(findTimesheetForPeriod(resources, 999)).toBe(null)
58
+ })
59
+ })