@ossy/timesheets 3.7.1 → 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,14 +1,23 @@
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'
19
+ import { HolidayLocaleSelect } from './HolidayLocaleSelect.jsx'
20
+ import { resolveHolidayLocale } from './holiday-locale.js'
12
21
 
13
22
  function buildMonthOptions (language, yearsBack = 1, yearsForward = 0) {
14
23
  let locale = 'en-GB'
@@ -39,58 +48,121 @@ function buildMonthOptions (language, yearsBack = 1, yearsForward = 0) {
39
48
  export default function TimesheetsProductHome () {
40
49
  const { t, language } = useLocale()
41
50
  const sdk = useSdk()
42
- const router = useRouter()
51
+ const { employees, contracts } = useTimesheetParties()
52
+ const { selectedResourceId, setSelectedResourceId, clearSelectedResourceId } = useSelectedResourceId()
43
53
 
44
54
  const monthOptions = useMemo(() => buildMonthOptions(language), [language])
45
55
  const [periodKey, setPeriodKey] = useState(() => {
46
56
  const n = new Date()
47
57
  return `${n.getFullYear()}-${n.getMonth()}`
48
58
  })
59
+ const [holidayLocale, setHolidayLocale] = useState(() => resolveHolidayLocale(language))
60
+ const [employeeId, setEmployeeId] = useState(null)
61
+ const [contractId, setContractId] = useState(null)
49
62
  const [creating, setCreating] = useState(false)
50
63
  const [sheets, setSheets] = useState([])
51
64
  const [loading, setLoading] = useState(true)
52
65
  const [generating, setGenerating] = useState(false)
53
66
  const [error, setError] = useState(null)
54
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)
55
72
 
56
73
  const selected = useMemo(
57
74
  () => monthOptions.find((o) => o.value === periodKey) ?? monthOptions[0],
58
75
  [monthOptions, periodKey],
59
76
  )
60
77
 
61
- const existingPeriodStarts = useMemo(
62
- () => new Set(sheets.map((s) => s.periodStart)),
63
- [sheets],
64
- )
65
-
66
78
  const statusTags = (Definition.status ?? [])
67
79
  .map((s) => ({ beta: 'Beta', 'coming-soon': 'Coming Soon' }[s]))
68
80
  .filter(Boolean)
69
81
 
70
- const loadList = useCallback(async () => {
71
- setLoading(true)
72
- setError(null)
82
+ const refreshList = useCallback(async () => {
73
83
  try {
74
84
  const data = await sdk.invoke(ListTimesheets, {})
75
85
  setSheets(Array.isArray(data) ? data : [])
76
86
  } catch (err) {
77
87
  setError(await invokeErrorMessage(err, t('timesheets.errorLoad')))
78
88
  setSheets([])
89
+ }
90
+ }, [sdk, t])
91
+
92
+ const loadList = useCallback(async () => {
93
+ setLoading(true)
94
+ setError(null)
95
+ try {
96
+ await refreshList()
79
97
  } finally {
80
98
  setLoading(false)
81
99
  }
82
- }, [sdk, t])
100
+ }, [refreshList])
83
101
 
84
102
  useEffect(() => {
85
103
  loadList()
86
104
  }, [loadList])
87
105
 
88
- const openDetail = (timesheetId) => {
89
- const href = router.getHref({
90
- id: 'timesheets/detail',
91
- 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
+ })
92
144
  })
93
- 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
+ }
94
166
  }
95
167
 
96
168
  const closeCreateModal = () => {
@@ -104,6 +176,9 @@ export default function TimesheetsProductHome () {
104
176
  setCreateError(null)
105
177
  const n = new Date()
106
178
  setPeriodKey(`${n.getFullYear()}-${n.getMonth()}`)
179
+ setHolidayLocale(resolveHolidayLocale(language))
180
+ setEmployeeId(null)
181
+ setContractId(null)
107
182
  }
108
183
 
109
184
  const handleGenerate = async () => {
@@ -114,13 +189,17 @@ export default function TimesheetsProductHome () {
114
189
  const data = await sdk.invoke(GenerateTimesheet, {
115
190
  year: selected.year,
116
191
  monthIndex: selected.monthIndex,
117
- locale: language,
192
+ locale: holidayLocale,
193
+ ...(employeeId ? { employeeId } : {}),
194
+ ...(contractId ? { contractId } : {}),
118
195
  })
119
196
  if (data?.id) {
120
- openDetail(data.id)
197
+ await refreshList()
198
+ setCreating(false)
199
+ openSheet(data.id)
121
200
  return
122
201
  }
123
- await loadList()
202
+ await refreshList()
124
203
  setCreating(false)
125
204
  } catch (err) {
126
205
  setCreateError(await invokeErrorMessage(err, t('timesheets.errorGenerate')))
@@ -129,51 +208,164 @@ export default function TimesheetsProductHome () {
129
208
  }
130
209
  }
131
210
 
132
- const periodAlreadyExists = selected
133
- && existingPeriodStarts.has(monthPeriodBounds(selected.year, selected.monthIndex).periodStart)
134
-
135
211
  return (
136
- <Page
137
- title="timesheets.home.title"
138
- near={statusTags.length > 0 ? <Tags tags={statusTags} size="s" /> : undefined}
139
- description="timesheets.home.description"
140
- far={(
141
- <Button
142
- id={OpenNewTimesheet.id}
143
- variant="cta"
144
- prefix="add"
145
- label="timesheets.new"
146
- onClick={openCreateModal}
147
- />
148
- )}
149
- >
150
- <View gap="m">
151
- {error && <Alert variant="danger">{error}</Alert>}
152
-
153
- <View gap="s">
154
- <Text variant="heading-secondary" as="h2" text="timesheets.listTitle" />
155
- {loading ? (
156
- <Text color="secondary" text="timesheets.loading" />
157
- ) : sheets.length === 0 ? (
158
- <Text color="secondary" text="timesheets.listEmpty" />
159
- ) : (
160
- <View gap="s">
161
- <List>
162
- {sheets.map((sheet) => (
163
- <TimesheetCard
164
- key={sheet.id}
165
- timesheet={sheet}
166
- 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}
167
240
  />
168
- ))}
169
- </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>
170
305
  </View>
171
- )}
306
+ </View.Item>
307
+
308
+ <TimesheetPanel
309
+ timesheetId={selectedResourceId}
310
+ onClose={clearSelectedResourceId}
311
+ onSaved={refreshList}
312
+ />
172
313
  </View>
173
- </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}
174
366
 
175
367
  <Overlay isVisible={creating} onClose={closeCreateModal}>
176
- <View layout="off-center-m" style={{ height: '100%' }}>
368
+ <View layout="off-center-s" style={{ height: '100%' }}>
177
369
  <View data-region="content" style={{ width: 'min(28rem, calc(100vw - 2rem))' }}>
178
370
  <View surface="primary" roundness="m" inset="l" gap="m">
179
371
  <View gap="xs">
@@ -196,9 +388,19 @@ export default function TimesheetsProductHome () {
196
388
  </Select>
197
389
  </View>
198
390
 
199
- {periodAlreadyExists && (
200
- <Text variant="small" color="secondary" text="timesheets.periodExistsHint" />
201
- )}
391
+ <HolidayLocaleSelect
392
+ value={holidayLocale}
393
+ disabled={generating}
394
+ onChange={(e) => setHolidayLocale(e.target.value)}
395
+ />
396
+
397
+ <TimesheetPartyFields
398
+ employeeId={employeeId}
399
+ contractId={contractId}
400
+ onEmployeeChange={setEmployeeId}
401
+ onContractChange={setContractId}
402
+ disabled={generating}
403
+ />
202
404
 
203
405
  <View layout="row" gap="s" justifyContent="flex-end" style={{ flexWrap: 'wrap' }}>
204
406
  <Button
@@ -213,17 +415,13 @@ export default function TimesheetsProductHome () {
213
415
  disabled={generating || !selected}
214
416
  onClick={handleGenerate}
215
417
  >
216
- {generating
217
- ? t('timesheets.loading')
218
- : periodAlreadyExists
219
- ? t('timesheets.openExisting')
220
- : t('timesheets.generate')}
418
+ {generating ? t('timesheets.loading') : t('timesheets.generate')}
221
419
  </Button>
222
420
  </View>
223
421
  </View>
224
422
  </View>
225
423
  </View>
226
424
  </Overlay>
227
- </Page>
425
+ </>
228
426
  )
229
427
  }
@@ -0,0 +1,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/timesheets/actions/delete',
3
+ access: 'workspace',
4
+ label: 'timesheets.delete',
5
+ }
@@ -0,0 +1,42 @@
1
+ import { ResourcesEvents, mutateResource, viewResource } from '@ossy/resources/server'
2
+ import { TimesheetsSchema } from './schema-ids.js'
3
+
4
+ export const metadata = { id: '@ossy/timesheets/tasks/delete' }
5
+
6
+ /**
7
+ * Delete a timesheet. Payload: `{ timesheetId }`
8
+ */
9
+ export async function run ({ payload, req, log }) {
10
+ const workspaceId = req?.workspaceId
11
+
12
+ if (!workspaceId) {
13
+ throw Object.assign(new Error('workspaceId is required'), { status: 400 })
14
+ }
15
+
16
+ const timesheetId = payload?.timesheetId
17
+
18
+ if (!timesheetId) {
19
+ throw Object.assign(new Error('timesheetId is required'), { status: 400 })
20
+ }
21
+
22
+ const resource = await viewResource(timesheetId)
23
+
24
+ if (!resource || resource.type !== TimesheetsSchema.timesheet) {
25
+ throw Object.assign(new Error('timesheet not found'), { status: 404 })
26
+ }
27
+
28
+ if (resource.belongsTo && resource.belongsTo !== workspaceId) {
29
+ throw Object.assign(new Error('timesheet not found'), { status: 404 })
30
+ }
31
+
32
+ log?.info(`[timesheets/tasks/delete] Deleting timesheet ${timesheetId}`)
33
+
34
+ await mutateResource(
35
+ timesheetId,
36
+ ResourcesEvents.Deleted({
37
+ createdBy: req?.userId ?? 'system',
38
+ }),
39
+ )
40
+
41
+ return { id: timesheetId, deleted: true }
42
+ }
@@ -1,8 +1,14 @@
1
1
  import { formatDateKey } from './build-prefilled-lines.js'
2
2
 
3
+ function csvCell (value) {
4
+ const text = String(value ?? '')
5
+ if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`
6
+ return text
7
+ }
8
+
3
9
  /**
4
10
  * Build a CSV string for a timesheet and trigger a browser download.
5
- * @param {{ periodStart?: number, periodEnd?: number, status?: string, lines?: Array<{ date: number, hours: number }> }} timesheet
11
+ * @param {{ periodStart?: number, periodEnd?: number, status?: string, lines?: Array<{ date: number, hours: number }>, clientName?: string, hourlyRate?: number }} timesheet
6
12
  * @param {string} [filename]
7
13
  */
8
14
  export function downloadTimesheetCsv (timesheet, filename) {
@@ -12,6 +18,19 @@ export function downloadTimesheetCsv (timesheet, filename) {
12
18
  if (timesheet?.periodStart != null && timesheet?.periodEnd != null) {
13
19
  headerRows.push(`# period,${formatDateKey(timesheet.periodStart)},${formatDateKey(timesheet.periodEnd)}`)
14
20
  headerRows.push(`# status,${timesheet.status ?? ''}`)
21
+ const employeeId = timesheet.employeeId?.resourceId ?? timesheet.employeeId
22
+ const contractId = timesheet.contractId?.resourceId ?? timesheet.contractId
23
+ if (employeeId) headerRows.push(`# employee,${csvCell(employeeId)}`)
24
+ if (timesheet.clientName) headerRows.push(`# client,${csvCell(timesheet.clientName)}`)
25
+ if (contractId) headerRows.push(`# contract,${csvCell(contractId)}`)
26
+ if (timesheet.hourlyRate != null && timesheet.hourlyRate !== '') {
27
+ const rate = Number(timesheet.hourlyRate)
28
+ if (Number.isFinite(rate)) {
29
+ const totalHours = lines.reduce((sum, line) => sum + (Number(line.hours) || 0), 0)
30
+ headerRows.push(`# hourlyRate,${rate}`)
31
+ headerRows.push(`# totalCost,${totalHours * rate}`)
32
+ }
33
+ }
15
34
  }
16
35
 
17
36
  const rows = [
@@ -32,11 +32,18 @@
32
32
  "timesheets.detail.description": "Review and adjust hours, then save or export.",
33
33
  "timesheets.new": "New",
34
34
  "timesheets.createTitle": "New timesheet",
35
- "timesheets.createDescription": "Choose a month to generate a prefilled timesheet.",
35
+ "timesheets.createDescription": "Choose a month and holiday calendar to generate a prefilled timesheet.",
36
36
  "timesheets.period": "Period",
37
+ "timesheets.holidayLocale": "Holiday calendar",
38
+ "timesheets.employee": "Assigned to",
39
+ "timesheets.employeeNone": "Unassigned",
40
+ "timesheets.contract": "Contract",
41
+ "timesheets.contractNone": "No contract",
42
+ "@ossy/timesheets/schema/timesheet.employeeId.label": "Assigned to",
43
+ "@ossy/timesheets/schema/timesheet.employeeId.description": "Optional. Who this timesheet is assigned to.",
44
+ "@ossy/timesheets/schema/timesheet.contractId.label": "Contract",
45
+ "@ossy/timesheets/schema/timesheet.contractId.description": "Optional. The contract this timesheet belongs to.",
37
46
  "timesheets.generate": "Generate",
38
- "timesheets.openExisting": "Open",
39
- "timesheets.periodExistsHint": "A timesheet already exists for this period — Open loads it.",
40
47
  "timesheets.cancel": "Cancel",
41
48
  "timesheets.save": "Save",
42
49
  "timesheets.saving": "Saving…",
@@ -51,13 +58,26 @@
51
58
  "timesheets.listTitle": "Your timesheets",
52
59
  "timesheets.listEmpty": "No timesheets yet. Click New to generate one for a month.",
53
60
  "timesheets.open": "Open",
61
+ "timesheets.view": "View",
62
+ "timesheets.rowActions": "Timesheet actions",
63
+ "timesheets.delete": "Delete",
64
+ "timesheets.deleteTitle": "Delete timesheet?",
65
+ "timesheets.deleteText": "This timesheet will be removed. This cannot be undone.",
54
66
  "timesheets.backToList": "← All timesheets",
67
+ "timesheets.closePanel": "Close",
55
68
  "timesheets.saved": "Timesheet saved.",
56
69
  "timesheets.unsaved": "Unsaved changes",
57
70
  "timesheets.statusLabel": "Status: {status}",
58
71
  "timesheets.status.draft": "Draft",
59
72
  "timesheets.status.saved": "Saved",
60
73
  "timesheets.totalHours": "Total: {hours} h",
74
+ "timesheets.meta.status": "Status",
75
+ "timesheets.meta.hoursValue": "{hours} h",
76
+ "timesheets.meta.client": "Client",
77
+ "timesheets.meta.hourlyRate": "Hourly rate",
78
+ "timesheets.meta.totalCost": "Total cost",
79
+ "timesheets.meta.perHour": "/h",
80
+ "timesheets.meta.dash": "—",
61
81
  "timesheets.weekday.mon": "Mon",
62
82
  "timesheets.weekday.tue": "Tue",
63
83
  "timesheets.weekday.wed": "Wed",
@@ -69,5 +89,6 @@
69
89
  "timesheets.errorNotFound": "Timesheet not found.",
70
90
  "timesheets.errorGenerate": "Could not generate timesheet.",
71
91
  "timesheets.errorSave": "Could not save timesheet.",
72
- "timesheets.errorExport": "Could not export timesheet."
92
+ "timesheets.errorExport": "Could not export timesheet.",
93
+ "timesheets.errorDelete": "Could not delete timesheet."
73
94
  }
@@ -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