@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.
- package/package.json +6 -3
- package/src/Timesheet.jsx +36 -1
- package/src/TimesheetCard.jsx +57 -18
- package/src/TimesheetExportCard.jsx +56 -1
- package/src/TimesheetPanel.jsx +249 -0
- package/src/TimesheetPartyFields.jsx +121 -0
- package/src/TimesheetPartyRows.jsx +101 -0
- package/src/TimesheetsProductHome.jsx +257 -69
- 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 +23 -3
- package/src/generate.task.js +12 -33
- package/src/get.task.js +1 -1
- package/src/index.js +3 -1
- 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 +23 -3
- 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
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
|
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) =>
|
|
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
|
-
|
|
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
|
+
})
|
package/src/sv.translations.json
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
+
})
|