@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.
- package/package.json +6 -3
- package/src/HolidayLocaleSelect.jsx +32 -0
- package/src/Timesheet.jsx +97 -21
- package/src/TimesheetCard.jsx +57 -18
- package/src/TimesheetExportCard.jsx +71 -16
- package/src/TimesheetPanel.jsx +249 -0
- package/src/TimesheetPartyFields.jsx +121 -0
- package/src/TimesheetPartyRows.jsx +101 -0
- package/src/TimesheetsProductHome.jsx +268 -70
- package/src/delete.action.js +5 -0
- package/src/delete.task.js +42 -0
- package/src/download-timesheet-csv.js +20 -1
- package/src/en.translations.json +25 -4
- package/src/generate.task.js +12 -33
- package/src/get.task.js +1 -1
- package/src/holiday-locale.js +6 -64
- package/src/index.js +4 -2
- package/src/list.task.js +5 -1
- package/src/save.task.js +16 -5
- package/src/schema-ids.js +6 -0
- package/src/sv.translations.json +25 -4
- package/src/timesheet-detail.page.jsx +10 -193
- package/src/timesheet-parties.js +57 -0
- package/src/timesheet-parties.spec.js +40 -0
- package/src/timesheet-resources.js +23 -1
- package/src/timesheet-resources.spec.js +59 -0
- package/src/timesheet.schema.js +15 -1
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
}, [
|
|
100
|
+
}, [refreshList])
|
|
83
101
|
|
|
84
102
|
useEffect(() => {
|
|
85
103
|
loadList()
|
|
86
104
|
}, [loadList])
|
|
87
105
|
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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:
|
|
192
|
+
locale: holidayLocale,
|
|
193
|
+
...(employeeId ? { employeeId } : {}),
|
|
194
|
+
...(contractId ? { contractId } : {}),
|
|
118
195
|
})
|
|
119
196
|
if (data?.id) {
|
|
120
|
-
|
|
197
|
+
await refreshList()
|
|
198
|
+
setCreating(false)
|
|
199
|
+
openSheet(data.id)
|
|
121
200
|
return
|
|
122
201
|
}
|
|
123
|
-
await
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
</
|
|
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
|
-
</
|
|
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-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
425
|
+
</>
|
|
228
426
|
)
|
|
229
427
|
}
|
|
@@ -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 }
|
|
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 = [
|
package/src/en.translations.json
CHANGED
|
@@ -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
|
}
|
package/src/generate.task.js
CHANGED
|
@@ -1,24 +1,21 @@
|
|
|
1
1
|
import { nanoid } from 'nanoid'
|
|
2
|
-
import { ResourcesEvents, commitResource
|
|
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
|
|
12
|
+
* Generate a new timesheet for a calendar month.
|
|
17
13
|
*
|
|
18
|
-
* Payload: `{ year, monthIndex, locale? }` (monthIndex 0–11)
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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
|
|
57
|
-
|
|
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
|