@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
@@ -0,0 +1,101 @@
1
+ import React, { useMemo } from 'react'
2
+ import { Text, View, useLocale } from '@ossy/design-system'
3
+ import { TimesheetPartyFields, useTimesheetParties } from './TimesheetPartyFields.jsx'
4
+ import { findByRef, partyContent } from './timesheet-parties.js'
5
+
6
+ const metaTextStyle = {
7
+ marginBottom: 0,
8
+ minWidth: 0,
9
+ fontWeight: 400,
10
+ '--font-weight': '400',
11
+ }
12
+
13
+ function MetaRow ({ label, children }) {
14
+ return (
15
+ <View layout="row" gap="s" style={{ justifyContent: 'space-between', alignItems: 'baseline', minWidth: 0 }}>
16
+ <Text variant="m" style={metaTextStyle} text={label} />
17
+ <Text variant="m" style={{ ...metaTextStyle, textAlign: 'right' }}>
18
+ {children}
19
+ </Text>
20
+ </View>
21
+ )
22
+ }
23
+
24
+ /**
25
+ * Employee/contract selects plus rate and total cost under the timesheet header divider.
26
+ */
27
+ export function TimesheetPartyRows ({
28
+ employeeId,
29
+ contractId,
30
+ onEmployeeChange,
31
+ onContractChange,
32
+ disabled,
33
+ totalHours = 0,
34
+ }) {
35
+ const { t, language } = useLocale()
36
+ const { employees, contracts } = useTimesheetParties()
37
+ const contract = partyContent(findByRef(contracts, contractId))
38
+ const dash = t('timesheets.meta.dash')
39
+
40
+ const money = useMemo(
41
+ () => (value) => {
42
+ if (value == null || value === '') return dash
43
+ return new Intl.NumberFormat(language === 'sv' ? 'sv-SE' : 'en-SE', {
44
+ style: 'currency',
45
+ currency: 'SEK',
46
+ }).format(Number(value))
47
+ },
48
+ [dash, language],
49
+ )
50
+
51
+ const hourlyRate = contract?.hourlyCompensation == null || contract?.hourlyCompensation === ''
52
+ ? null
53
+ : Number(contract.hourlyCompensation)
54
+ const totalCost = hourlyRate != null && Number.isFinite(hourlyRate)
55
+ ? Number(totalHours) * hourlyRate
56
+ : null
57
+
58
+ return (
59
+ <View
60
+ gap="s"
61
+ style={{
62
+ minWidth: 0,
63
+ fontWeight: 400,
64
+ '--font-weight': '400',
65
+ '--text-default-font-weight': '400',
66
+ '--text-m-font-weight': '400',
67
+ '--text-small-font-weight': '400',
68
+ }}
69
+ >
70
+ <TimesheetPartyFields
71
+ layout="rows"
72
+ employeeId={employeeId}
73
+ contractId={contractId}
74
+ onEmployeeChange={onEmployeeChange}
75
+ onContractChange={onContractChange}
76
+ disabled={disabled}
77
+ employees={employees}
78
+ contracts={contracts}
79
+ >
80
+ {contract ? (
81
+ <MetaRow label="timesheets.meta.client">
82
+ {contract.clientName || dash}
83
+ </MetaRow>
84
+ ) : null}
85
+ </TimesheetPartyFields>
86
+
87
+ {contract ? (
88
+ <>
89
+ <MetaRow label="timesheets.meta.hourlyRate">
90
+ {hourlyRate == null || !Number.isFinite(hourlyRate)
91
+ ? dash
92
+ : `${money(hourlyRate)}${t('timesheets.meta.perHour')}`}
93
+ </MetaRow>
94
+ <MetaRow label="timesheets.meta.totalCost">
95
+ {totalCost == null || !Number.isFinite(totalCost) ? dash : money(totalCost)}
96
+ </MetaRow>
97
+ </>
98
+ ) : null}
99
+ </View>
100
+ )
101
+ }
@@ -1,14 +1,21 @@
1
- import React, { useCallback, useEffect, useMemo, useState } from 'react'
2
- import { Text, View, Button, Page, useLocale, Tags, Alert, Select, List, Overlay } from '@ossy/design-system'
3
- import { useRouter } from '@ossy/router-react'
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { Text, View, Button, Page, useLocale, Tags, Alert, Select, List, Overlay, ContextMenu, Guide } from '@ossy/design-system'
4
3
  import { useSdk } from '@ossy/sdk-react'
4
+ import { useSelectedResourceId } from '@ossy/resources'
5
5
  import { Definition } from './Definition.js'
6
6
  import { TimesheetCard } from './TimesheetCard.jsx'
7
- import { monthPeriodBounds } from './build-prefilled-lines.js'
7
+ import { TimesheetPanel } from './TimesheetPanel.jsx'
8
+ import { TimesheetExportCard } from './TimesheetExportCard.jsx'
9
+ import { TimesheetPartyFields, useTimesheetParties } from './TimesheetPartyFields.jsx'
10
+ import { contractBillingMeta, labelForContract, labelForEmployee } from './timesheet-parties.js'
8
11
  import { invokeErrorMessage } from './invoke-error-message.js'
12
+ import { downloadTimesheetCsv } from './download-timesheet-csv.js'
13
+ import { downloadTimesheetImage } from './download-timesheet-image.js'
14
+ import { downloadTimesheetPdf } from './download-timesheet-pdf.js'
9
15
  import { metadata as ListTimesheets } from './list.action.js'
10
16
  import { metadata as GenerateTimesheet } from './generate.action.js'
11
17
  import { metadata as OpenNewTimesheet } from './open-new.action.js'
18
+ import { metadata as DeleteTimesheet } from './delete.action.js'
12
19
  import { HolidayLocaleSelect } from './HolidayLocaleSelect.jsx'
13
20
  import { resolveHolidayLocale } from './holiday-locale.js'
14
21
 
@@ -41,7 +48,8 @@ function buildMonthOptions (language, yearsBack = 1, yearsForward = 0) {
41
48
  export default function TimesheetsProductHome () {
42
49
  const { t, language } = useLocale()
43
50
  const sdk = useSdk()
44
- const router = useRouter()
51
+ const { employees, contracts } = useTimesheetParties()
52
+ const { selectedResourceId, setSelectedResourceId, clearSelectedResourceId } = useSelectedResourceId()
45
53
 
46
54
  const monthOptions = useMemo(() => buildMonthOptions(language), [language])
47
55
  const [periodKey, setPeriodKey] = useState(() => {
@@ -49,51 +57,112 @@ export default function TimesheetsProductHome () {
49
57
  return `${n.getFullYear()}-${n.getMonth()}`
50
58
  })
51
59
  const [holidayLocale, setHolidayLocale] = useState(() => resolveHolidayLocale(language))
60
+ const [employeeId, setEmployeeId] = useState(null)
61
+ const [contractId, setContractId] = useState(null)
52
62
  const [creating, setCreating] = useState(false)
53
63
  const [sheets, setSheets] = useState([])
54
64
  const [loading, setLoading] = useState(true)
55
65
  const [generating, setGenerating] = useState(false)
56
66
  const [error, setError] = useState(null)
57
67
  const [createError, setCreateError] = useState(null)
68
+ const [sheetToDelete, setSheetToDelete] = useState(null)
69
+ const [deleting, setDeleting] = useState(false)
70
+ const [exportJob, setExportJob] = useState(null)
71
+ const exportCaptureRef = useRef(null)
58
72
 
59
73
  const selected = useMemo(
60
74
  () => monthOptions.find((o) => o.value === periodKey) ?? monthOptions[0],
61
75
  [monthOptions, periodKey],
62
76
  )
63
77
 
64
- const existingPeriodStarts = useMemo(
65
- () => new Set(sheets.map((s) => s.periodStart)),
66
- [sheets],
67
- )
68
-
69
78
  const statusTags = (Definition.status ?? [])
70
79
  .map((s) => ({ beta: 'Beta', 'coming-soon': 'Coming Soon' }[s]))
71
80
  .filter(Boolean)
72
81
 
73
- const loadList = useCallback(async () => {
74
- setLoading(true)
75
- setError(null)
82
+ const refreshList = useCallback(async () => {
76
83
  try {
77
84
  const data = await sdk.invoke(ListTimesheets, {})
78
85
  setSheets(Array.isArray(data) ? data : [])
79
86
  } catch (err) {
80
87
  setError(await invokeErrorMessage(err, t('timesheets.errorLoad')))
81
88
  setSheets([])
89
+ }
90
+ }, [sdk, t])
91
+
92
+ const loadList = useCallback(async () => {
93
+ setLoading(true)
94
+ setError(null)
95
+ try {
96
+ await refreshList()
82
97
  } finally {
83
98
  setLoading(false)
84
99
  }
85
- }, [sdk, t])
100
+ }, [refreshList])
86
101
 
87
102
  useEffect(() => {
88
103
  loadList()
89
104
  }, [loadList])
90
105
 
91
- const openDetail = (timesheetId) => {
92
- const href = router.getHref({
93
- id: 'timesheets/detail',
94
- params: { timesheetId },
106
+ const openSheet = (timesheetId) => {
107
+ setSelectedResourceId(timesheetId)
108
+ }
109
+
110
+ const requestExport = useCallback((sheet, kind) => {
111
+ if (!sheet?.lines?.length) return
112
+ const billing = contractBillingMeta(contracts, sheet.contractId)
113
+ const payload = {
114
+ ...sheet,
115
+ clientName: billing.clientName,
116
+ hourlyRate: billing.hourlyRate,
117
+ }
118
+ if (kind === 'csv') {
119
+ downloadTimesheetCsv(payload)
120
+ return
121
+ }
122
+ setExportJob({ sheet: payload, kind })
123
+ }, [contracts])
124
+
125
+ useEffect(() => {
126
+ if (!exportJob) return
127
+ let cancelled = false
128
+ const frame = requestAnimationFrame(() => {
129
+ requestAnimationFrame(async () => {
130
+ try {
131
+ const node = exportCaptureRef.current
132
+ if (!node || cancelled) return
133
+ if (exportJob.kind === 'png') {
134
+ await downloadTimesheetImage(node, exportJob.sheet)
135
+ } else {
136
+ await downloadTimesheetPdf(node, exportJob.sheet)
137
+ }
138
+ } catch (err) {
139
+ if (!cancelled) setError(await invokeErrorMessage(err, t('timesheets.errorExport')))
140
+ } finally {
141
+ if (!cancelled) setExportJob(null)
142
+ }
143
+ })
95
144
  })
96
- if (href) window.location.href = href
145
+ return () => {
146
+ cancelled = true
147
+ cancelAnimationFrame(frame)
148
+ }
149
+ }, [exportJob, t])
150
+
151
+ const confirmDelete = async () => {
152
+ const sheet = sheetToDelete
153
+ if (!sheet?.id) return
154
+ setDeleting(true)
155
+ setError(null)
156
+ try {
157
+ await sdk.invoke(DeleteTimesheet, { timesheetId: sheet.id })
158
+ setSheetToDelete(null)
159
+ if (selectedResourceId === sheet.id) clearSelectedResourceId()
160
+ await refreshList()
161
+ } catch (err) {
162
+ setError(await invokeErrorMessage(err, t('timesheets.errorDelete')))
163
+ } finally {
164
+ setDeleting(false)
165
+ }
97
166
  }
98
167
 
99
168
  const closeCreateModal = () => {
@@ -108,6 +177,8 @@ export default function TimesheetsProductHome () {
108
177
  const n = new Date()
109
178
  setPeriodKey(`${n.getFullYear()}-${n.getMonth()}`)
110
179
  setHolidayLocale(resolveHolidayLocale(language))
180
+ setEmployeeId(null)
181
+ setContractId(null)
111
182
  }
112
183
 
113
184
  const handleGenerate = async () => {
@@ -119,12 +190,16 @@ export default function TimesheetsProductHome () {
119
190
  year: selected.year,
120
191
  monthIndex: selected.monthIndex,
121
192
  locale: holidayLocale,
193
+ ...(employeeId ? { employeeId } : {}),
194
+ ...(contractId ? { contractId } : {}),
122
195
  })
123
196
  if (data?.id) {
124
- openDetail(data.id)
197
+ await refreshList()
198
+ setCreating(false)
199
+ openSheet(data.id)
125
200
  return
126
201
  }
127
- await loadList()
202
+ await refreshList()
128
203
  setCreating(false)
129
204
  } catch (err) {
130
205
  setCreateError(await invokeErrorMessage(err, t('timesheets.errorGenerate')))
@@ -133,51 +208,164 @@ export default function TimesheetsProductHome () {
133
208
  }
134
209
  }
135
210
 
136
- const periodAlreadyExists = selected
137
- && existingPeriodStarts.has(monthPeriodBounds(selected.year, selected.monthIndex).periodStart)
138
-
139
211
  return (
140
- <Page
141
- title="timesheets.home.title"
142
- near={statusTags.length > 0 ? <Tags tags={statusTags} size="s" /> : undefined}
143
- description="timesheets.home.description"
144
- far={(
145
- <Button
146
- id={OpenNewTimesheet.id}
147
- variant="cta"
148
- prefix="add"
149
- label="timesheets.new"
150
- onClick={openCreateModal}
151
- />
152
- )}
153
- >
154
- <View gap="m">
155
- {error && <Alert variant="danger">{error}</Alert>}
156
-
157
- <View gap="s">
158
- <Text variant="heading-secondary" as="h2" text="timesheets.listTitle" />
159
- {loading ? (
160
- <Text color="secondary" text="timesheets.loading" />
161
- ) : sheets.length === 0 ? (
162
- <Text color="secondary" text="timesheets.listEmpty" />
163
- ) : (
164
- <View gap="s">
165
- <List>
166
- {sheets.map((sheet) => (
167
- <TimesheetCard
168
- key={sheet.id}
169
- timesheet={sheet}
170
- onClick={() => openDetail(sheet.id)}
212
+ <>
213
+ <Page
214
+ scroll
215
+ title="timesheets.home.title"
216
+ near={statusTags.length > 0 ? <Tags tags={statusTags} size="s" /> : undefined}
217
+ description="timesheets.home.description"
218
+ style={{ minHeight: 0 }}
219
+ contentStyle={{
220
+ overflow: 'hidden',
221
+ display: 'flex',
222
+ flexDirection: 'column',
223
+ minHeight: 0,
224
+ }}
225
+ >
226
+ <View stack horizontal gap="xs" style={{ height: '100%', minHeight: 0, flex: '1 1 auto' }}>
227
+ <View.Item fill surface="primary" roundness="xs" style={{ minHeight: 0, minWidth: 0 }}>
228
+ <View stack bordered style={{ height: '100%', minHeight: 0 }}>
229
+ <View.Item>
230
+ <View stack horizontal style={{ minHeight: '48px', alignItems: 'center', gap: '4px' }}>
231
+ <View.Item fill surface="primary" style={{ padding: '4px 8px', minWidth: 0 }}>
232
+ <Text as="h2" variant="small" weight="medium" text="timesheets.listTitle" />
233
+ </View.Item>
234
+ <Button
235
+ id={OpenNewTimesheet.id}
236
+ variant="command"
237
+ prefix="add"
238
+ aria-label={t('timesheets.new')}
239
+ onClick={openCreateModal}
171
240
  />
172
- ))}
173
- </List>
241
+ </View>
242
+ </View.Item>
243
+
244
+ <View.Item fill style={{ overflowY: 'auto', minHeight: 0 }}>
245
+ {error && (
246
+ <View inset="m">
247
+ <Alert variant="danger">{error}</Alert>
248
+ </View>
249
+ )}
250
+ {loading ? (
251
+ <View inset="m">
252
+ <Text color="secondary" text="timesheets.loading" />
253
+ </View>
254
+ ) : sheets.length === 0 ? (
255
+ <View inset="m">
256
+ <Text color="secondary" text="timesheets.listEmpty" />
257
+ </View>
258
+ ) : (
259
+ <List>
260
+ {sheets.map((sheet) => (
261
+ <TimesheetCard
262
+ key={sheet.id}
263
+ timesheet={sheet}
264
+ selected={sheet.id === selectedResourceId}
265
+ onClick={() => openSheet(sheet.id)}
266
+ actions={[
267
+ <ContextMenu.Item
268
+ key="view"
269
+ prefix="details-more"
270
+ label="timesheets.view"
271
+ onClick={() => openSheet(sheet.id)}
272
+ />,
273
+ <ContextMenu.Item
274
+ key="csv"
275
+ prefix="list"
276
+ label="timesheets.exportCsv"
277
+ onClick={() => requestExport(sheet, 'csv')}
278
+ />,
279
+ <ContextMenu.Item
280
+ key="png"
281
+ prefix="image"
282
+ label="timesheets.exportPng"
283
+ onClick={() => requestExport(sheet, 'png')}
284
+ />,
285
+ <ContextMenu.Item
286
+ key="pdf"
287
+ prefix="file-document"
288
+ label="timesheets.exportPdf"
289
+ onClick={() => requestExport(sheet, 'pdf')}
290
+ />,
291
+ <ContextMenu.Separator key="separator" />,
292
+ <ContextMenu.Item
293
+ key="delete"
294
+ variant="command-danger"
295
+ prefix="trash-empty"
296
+ label="timesheets.delete"
297
+ onClick={() => setSheetToDelete(sheet)}
298
+ />,
299
+ ]}
300
+ />
301
+ ))}
302
+ </List>
303
+ )}
304
+ </View.Item>
174
305
  </View>
175
- )}
306
+ </View.Item>
307
+
308
+ <TimesheetPanel
309
+ timesheetId={selectedResourceId}
310
+ onClose={clearSelectedResourceId}
311
+ onSaved={refreshList}
312
+ />
176
313
  </View>
177
- </View>
314
+ </Page>
315
+
316
+ <Overlay isVisible={Boolean(sheetToDelete)} onClose={() => !deleting && setSheetToDelete(null)}>
317
+ <View layout="off-center-s" style={{ height: '100%' }}>
318
+ <View data-region="content">
319
+ <View surface="primary" roundness="s" inset="l">
320
+ <Guide
321
+ title="timesheets.deleteTitle"
322
+ text="timesheets.deleteText"
323
+ actions={[
324
+ {
325
+ label: 'timesheets.cancel',
326
+ variant: 'command',
327
+ disabled: deleting,
328
+ onClick: () => setSheetToDelete(null),
329
+ },
330
+ {
331
+ label: 'timesheets.delete',
332
+ variant: 'command-danger',
333
+ disabled: deleting,
334
+ onClick: confirmDelete,
335
+ },
336
+ ]}
337
+ />
338
+ </View>
339
+ </View>
340
+ </View>
341
+ </Overlay>
342
+
343
+ {exportJob ? (
344
+ <div
345
+ aria-hidden
346
+ style={{
347
+ position: 'fixed',
348
+ left: 0,
349
+ top: 0,
350
+ transform: 'translateX(-200vw)',
351
+ pointerEvents: 'none',
352
+ zIndex: -1,
353
+ }}
354
+ >
355
+ <TimesheetExportCard
356
+ lines={exportJob.sheet.lines}
357
+ periodStart={exportJob.sheet.periodStart}
358
+ employeeLabel={labelForEmployee(employees, exportJob.sheet.employeeId)}
359
+ contractLabel={labelForContract(contracts, exportJob.sheet.contractId)}
360
+ clientName={exportJob.sheet.clientName}
361
+ hourlyRate={exportJob.sheet.hourlyRate}
362
+ captureRef={exportCaptureRef}
363
+ />
364
+ </div>
365
+ ) : null}
178
366
 
179
367
  <Overlay isVisible={creating} onClose={closeCreateModal}>
180
- <View layout="off-center-m" style={{ height: '100%' }}>
368
+ <View layout="off-center-s" style={{ height: '100%' }}>
181
369
  <View data-region="content" style={{ width: 'min(28rem, calc(100vw - 2rem))' }}>
182
370
  <View surface="primary" roundness="m" inset="l" gap="m">
183
371
  <View gap="xs">
@@ -206,9 +394,13 @@ export default function TimesheetsProductHome () {
206
394
  onChange={(e) => setHolidayLocale(e.target.value)}
207
395
  />
208
396
 
209
- {periodAlreadyExists && (
210
- <Text variant="small" color="secondary" text="timesheets.periodExistsHint" />
211
- )}
397
+ <TimesheetPartyFields
398
+ employeeId={employeeId}
399
+ contractId={contractId}
400
+ onEmployeeChange={setEmployeeId}
401
+ onContractChange={setContractId}
402
+ disabled={generating}
403
+ />
212
404
 
213
405
  <View layout="row" gap="s" justifyContent="flex-end" style={{ flexWrap: 'wrap' }}>
214
406
  <Button
@@ -223,17 +415,13 @@ export default function TimesheetsProductHome () {
223
415
  disabled={generating || !selected}
224
416
  onClick={handleGenerate}
225
417
  >
226
- {generating
227
- ? t('timesheets.loading')
228
- : periodAlreadyExists
229
- ? t('timesheets.openExisting')
230
- : t('timesheets.generate')}
418
+ {generating ? t('timesheets.loading') : t('timesheets.generate')}
231
419
  </Button>
232
420
  </View>
233
421
  </View>
234
422
  </View>
235
423
  </View>
236
424
  </Overlay>
237
- </Page>
425
+ </>
238
426
  )
239
427
  }
@@ -0,0 +1,70 @@
1
+ import { TimesheetRefs, TimesheetsSchema } from './schema-ids.js'
2
+ import {
3
+ findTimesheetForContractPeriod,
4
+ isContractActive,
5
+ periodForContract,
6
+ } from './billing-period.js'
7
+ import { optionalReference } from './timesheet-refs.js'
8
+
9
+ /** Company basic-info schema (consultancy) — used for holiday locale. */
10
+ export const COMPANY_BASIC_INFO_SCHEMA = '@ossy/consultancy/schema/company-basic-info'
11
+
12
+ /**
13
+ * Pick holiday locale for a workspace from company basic-info resources.
14
+ * @param {Array<{ belongsTo?: string, content?: { holidayLocale?: string } }>} companyResources
15
+ * @param {string} workspaceId
16
+ * @returns {string | undefined}
17
+ */
18
+ export function holidayLocaleForWorkspace (companyResources, workspaceId) {
19
+ const match = (companyResources || []).find((r) => r.belongsTo === workspaceId)
20
+ return match?.content?.holidayLocale
21
+ }
22
+
23
+ /**
24
+ * Decide which active contracts need a new auto-generated sheet for `asOf`.
25
+ *
26
+ * @param {object} input
27
+ * @param {Array<{ id: string, belongsTo?: string, content?: object }>} input.contracts
28
+ * @param {Array<{ id: string, belongsTo?: string, createdAt?: number, content?: object }>} input.timesheets
29
+ * @param {Date | number} [input.asOf]
30
+ * @returns {Array<{
31
+ * contractId: string,
32
+ * workspaceId: string,
33
+ * employeeId: string | null,
34
+ * periodStart: number,
35
+ * periodEnd: number,
36
+ * billingPeriod: string,
37
+ * }>}
38
+ */
39
+ export function contractsNeedingTimesheets ({ contracts, timesheets, asOf = Date.now() }) {
40
+ const needed = []
41
+
42
+ for (const contract of contracts || []) {
43
+ const workspaceId = contract.belongsTo
44
+ if (!workspaceId) continue
45
+ if (!isContractActive(contract.content, asOf)) continue
46
+
47
+ const { periodStart, periodEnd } = periodForContract(contract.content, asOf)
48
+ const existing = findTimesheetForContractPeriod(timesheets, contract.id, periodStart)
49
+ if (existing) continue
50
+
51
+ const employeeId = optionalReference(contract.content?.employeeId)?.resourceId ?? null
52
+
53
+ needed.push({
54
+ contractId: contract.id,
55
+ workspaceId,
56
+ employeeId,
57
+ periodStart,
58
+ periodEnd,
59
+ billingPeriod: contract.content?.billingPeriod === 'weekly' ? 'weekly' : 'monthly',
60
+ })
61
+ }
62
+
63
+ return needed
64
+ }
65
+
66
+ export const AUTO_GENERATE_QUERY = {
67
+ contracts: { type: TimesheetRefs.contract },
68
+ timesheets: { type: TimesheetsSchema.timesheet },
69
+ companies: { type: COMPANY_BASIC_INFO_SCHEMA },
70
+ }
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import {
3
+ contractsNeedingTimesheets,
4
+ holidayLocaleForWorkspace,
5
+ } from './auto-generate-timesheets.js'
6
+
7
+ describe('holidayLocaleForWorkspace', () => {
8
+ it('returns the company holidayLocale for the workspace', () => {
9
+ const companies = [
10
+ { belongsTo: 'ws-a', content: { holidayLocale: 'SE' } },
11
+ { belongsTo: 'ws-b', content: { holidayLocale: 'NO' } },
12
+ ]
13
+ expect(holidayLocaleForWorkspace(companies, 'ws-b')).toBe('NO')
14
+ expect(holidayLocaleForWorkspace(companies, 'ws-missing')).toBe(undefined)
15
+ })
16
+ })
17
+
18
+ describe('contractsNeedingTimesheets', () => {
19
+ const augustStart = new Date(2026, 7, 1).setHours(0, 0, 0, 0)
20
+ const asOf = new Date(2026, 7, 19)
21
+
22
+ it('skips inactive contracts and existing sheets', () => {
23
+ const contracts = [
24
+ {
25
+ id: 'c-active',
26
+ belongsTo: 'ws-1',
27
+ content: {
28
+ billingPeriod: 'monthly',
29
+ employeeId: 'emp-1',
30
+ startDate: new Date(2026, 0, 1).getTime(),
31
+ },
32
+ },
33
+ {
34
+ id: 'c-done',
35
+ belongsTo: 'ws-1',
36
+ content: {
37
+ billingPeriod: 'monthly',
38
+ startDate: new Date(2025, 0, 1).getTime(),
39
+ endDate: new Date(2026, 0, 1).getTime(),
40
+ },
41
+ },
42
+ {
43
+ id: 'c-has-sheet',
44
+ belongsTo: 'ws-1',
45
+ content: {
46
+ billingPeriod: 'monthly',
47
+ startDate: new Date(2026, 0, 1).getTime(),
48
+ },
49
+ },
50
+ ]
51
+
52
+ const timesheets = [
53
+ {
54
+ id: 'sheet-1',
55
+ createdAt: 1,
56
+ content: { periodStart: augustStart, contractId: 'c-has-sheet' },
57
+ },
58
+ ]
59
+
60
+ expect(contractsNeedingTimesheets({ contracts, timesheets, asOf })).toEqual([
61
+ {
62
+ contractId: 'c-active',
63
+ workspaceId: 'ws-1',
64
+ employeeId: 'emp-1',
65
+ periodStart: augustStart,
66
+ periodEnd: new Date(2026, 7, 31).setHours(0, 0, 0, 0),
67
+ billingPeriod: 'monthly',
68
+ },
69
+ ])
70
+ })
71
+
72
+ it('uses weekly period bounds when configured', () => {
73
+ const contracts = [{
74
+ id: 'c-week',
75
+ belongsTo: 'ws-2',
76
+ content: {
77
+ billingPeriod: 'weekly',
78
+ employeeId: { resourceId: 'emp-9' },
79
+ startDate: new Date(2026, 0, 1).getTime(),
80
+ },
81
+ }]
82
+
83
+ const [needed] = contractsNeedingTimesheets({ contracts, timesheets: [], asOf })
84
+ expect(needed.contractId).toBe('c-week')
85
+ expect(needed.employeeId).toBe('emp-9')
86
+ expect(needed.billingPeriod).toBe('weekly')
87
+ expect(new Date(needed.periodStart).getDay()).toBe(1)
88
+ expect(new Date(needed.periodEnd).getDay()).toBe(0)
89
+ })
90
+ })