@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.
@@ -0,0 +1,249 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { Text, View, Button, useLocale, Alert, Dropdown, ContextMenu, DateDisplay } from '@ossy/design-system'
3
+ import { useSdk } from '@ossy/sdk-react'
4
+ import { Timesheet } from './Timesheet.jsx'
5
+ import { TimesheetExportCard } from './TimesheetExportCard.jsx'
6
+ import { downloadTimesheetCsv } from './download-timesheet-csv.js'
7
+ import { downloadTimesheetImage } from './download-timesheet-image.js'
8
+ import { downloadTimesheetPdf } from './download-timesheet-pdf.js'
9
+ import { invokeErrorMessage } from './invoke-error-message.js'
10
+ import { useTimesheetParties } from './TimesheetPartyFields.jsx'
11
+ import { contractBillingMeta, labelForContract, labelForEmployee } from './timesheet-parties.js'
12
+ import { metadata as GetTimesheet } from './get.action.js'
13
+ import { metadata as SaveTimesheet } from './save.action.js'
14
+
15
+ /**
16
+ * Storage-style side panel for reviewing and saving a timesheet.
17
+ */
18
+ export function TimesheetPanel ({ timesheetId, onClose, onSaved, width = '50%' }) {
19
+ const { t } = useLocale()
20
+ const sdk = useSdk()
21
+ const captureRef = useRef(null)
22
+ const { employees, contracts } = useTimesheetParties()
23
+
24
+ const [timesheet, setTimesheet] = useState(null)
25
+ const [lines, setLines] = useState([])
26
+ const [employeeId, setEmployeeId] = useState(null)
27
+ const [contractId, setContractId] = useState(null)
28
+ const [loading, setLoading] = useState(true)
29
+ const [saving, setSaving] = useState(false)
30
+ const [exporting, setExporting] = useState(false)
31
+ const [error, setError] = useState(null)
32
+ const [message, setMessage] = useState(null)
33
+
34
+ const load = useCallback(async () => {
35
+ if (!timesheetId) return
36
+ setLoading(true)
37
+ setError(null)
38
+ setMessage(null)
39
+ try {
40
+ const data = await sdk.invoke(GetTimesheet, { timesheetId })
41
+ if (!data) {
42
+ setError(t('timesheets.errorNotFound'))
43
+ setTimesheet(null)
44
+ setLines([])
45
+ setEmployeeId(null)
46
+ setContractId(null)
47
+ return
48
+ }
49
+ setTimesheet(data)
50
+ setLines(data.lines ?? [])
51
+ setEmployeeId(data.employeeId ?? null)
52
+ setContractId(data.contractId ?? null)
53
+ } catch (err) {
54
+ setError(await invokeErrorMessage(err, t('timesheets.errorLoad')))
55
+ setTimesheet(null)
56
+ setLines([])
57
+ setEmployeeId(null)
58
+ setContractId(null)
59
+ } finally {
60
+ setLoading(false)
61
+ }
62
+ }, [sdk, t, timesheetId])
63
+
64
+ useEffect(() => {
65
+ load()
66
+ }, [load])
67
+
68
+ const handleSave = async () => {
69
+ if (!timesheet?.id) return
70
+ setSaving(true)
71
+ setError(null)
72
+ setMessage(null)
73
+ try {
74
+ const data = await sdk.invoke(SaveTimesheet, {
75
+ timesheetId: timesheet.id,
76
+ lines,
77
+ employeeId,
78
+ contractId,
79
+ })
80
+ setTimesheet(data)
81
+ setLines(data?.lines ?? lines)
82
+ setEmployeeId(data?.employeeId ?? null)
83
+ setContractId(data?.contractId ?? null)
84
+ setMessage(t('timesheets.saved'))
85
+ onSaved?.(data)
86
+ } catch (err) {
87
+ setError(await invokeErrorMessage(err, t('timesheets.errorSave')))
88
+ } finally {
89
+ setSaving(false)
90
+ }
91
+ }
92
+
93
+ const billing = contractBillingMeta(contracts, contractId)
94
+
95
+ const exportPayload = {
96
+ ...timesheet,
97
+ lines,
98
+ status: timesheet?.status,
99
+ clientName: billing.clientName,
100
+ hourlyRate: billing.hourlyRate,
101
+ }
102
+
103
+ const runExport = async (kind) => {
104
+ if (!lines.length) return
105
+ setExporting(true)
106
+ setError(null)
107
+ setMessage(null)
108
+ try {
109
+ if (kind === 'csv') {
110
+ downloadTimesheetCsv(exportPayload)
111
+ } else if (kind === 'png') {
112
+ await downloadTimesheetImage(captureRef.current, exportPayload)
113
+ } else if (kind === 'pdf') {
114
+ await downloadTimesheetPdf(captureRef.current, exportPayload)
115
+ }
116
+ } catch (err) {
117
+ setError(await invokeErrorMessage(err, t('timesheets.errorExport')))
118
+ } finally {
119
+ setExporting(false)
120
+ }
121
+ }
122
+
123
+ if (!timesheetId) return null
124
+
125
+ const exportDisabled = !lines.length || loading || exporting
126
+
127
+ return (
128
+ <View
129
+ stack
130
+ surface="primary"
131
+ bordered
132
+ roundness="xs"
133
+ style={{ height: '100%', width, minHeight: 0, minWidth: 0, flexShrink: 0 }}
134
+ >
135
+ <View.Item>
136
+ <View stack horizontal style={{ minHeight: '48px', alignItems: 'center', gap: '4px' }}>
137
+ <View.Item fill surface="primary" style={{ padding: '4px 8px', minWidth: 0 }}>
138
+ {timesheet?.periodStart != null ? (
139
+ <DateDisplay
140
+ value={timesheet.periodStart}
141
+ formatOptions={{ month: 'long', year: 'numeric' }}
142
+ as="h3"
143
+ variant="small"
144
+ weight="medium"
145
+ />
146
+ ) : (
147
+ <Text as="h3" variant="small" weight="medium" text="timesheets.detail.title" />
148
+ )}
149
+ </View.Item>
150
+
151
+ <Dropdown
152
+ trigger={(
153
+ <Button
154
+ variant="command"
155
+ prefix="software-download"
156
+ disabled={exportDisabled}
157
+ aria-label={t('timesheets.export')}
158
+ />
159
+ )}
160
+ >
161
+ <ContextMenu roundness="s" surface="primary">
162
+ <ContextMenu.Item
163
+ prefix="list"
164
+ label="timesheets.exportCsv"
165
+ onClick={() => runExport('csv')}
166
+ />
167
+ <ContextMenu.Item
168
+ prefix="image"
169
+ label="timesheets.exportPng"
170
+ onClick={() => runExport('png')}
171
+ />
172
+ <ContextMenu.Item
173
+ prefix="file-document"
174
+ label="timesheets.exportPdf"
175
+ onClick={() => runExport('pdf')}
176
+ />
177
+ </ContextMenu>
178
+ </Dropdown>
179
+
180
+ <Button
181
+ id={SaveTimesheet.id}
182
+ variant="command"
183
+ prefix="check"
184
+ disabled={!timesheet?.id || saving || loading}
185
+ aria-label={saving ? t('timesheets.saving') : t('timesheets.save')}
186
+ onClick={handleSave}
187
+ />
188
+
189
+ {onClose && (
190
+ <Button
191
+ prefix="close"
192
+ variant="command"
193
+ aria-label={t('timesheets.closePanel')}
194
+ onClick={onClose}
195
+ />
196
+ )}
197
+ </View>
198
+ </View.Item>
199
+
200
+ <View.Item
201
+ fill
202
+ style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}
203
+ >
204
+ <View inset="m" gap="m">
205
+ {error && <Alert variant="danger">{error}</Alert>}
206
+ {message && !error && <Alert variant="success">{message}</Alert>}
207
+
208
+ {loading ? (
209
+ <Text color="secondary" text="timesheets.loading" />
210
+ ) : timesheet ? (
211
+ <>
212
+ <Timesheet
213
+ lines={lines}
214
+ onChange={setLines}
215
+ disabled={loading || saving || exporting}
216
+ periodStart={timesheet.periodStart}
217
+ employeeId={employeeId}
218
+ contractId={contractId}
219
+ onEmployeeChange={setEmployeeId}
220
+ onContractChange={setContractId}
221
+ />
222
+ <div
223
+ aria-hidden
224
+ style={{
225
+ position: 'fixed',
226
+ left: 0,
227
+ top: 0,
228
+ transform: 'translateX(-200vw)',
229
+ pointerEvents: 'none',
230
+ zIndex: -1,
231
+ }}
232
+ >
233
+ <TimesheetExportCard
234
+ lines={lines}
235
+ periodStart={timesheet.periodStart}
236
+ employeeLabel={labelForEmployee(employees, employeeId)}
237
+ contractLabel={labelForContract(contracts, contractId)}
238
+ clientName={billing.clientName}
239
+ hourlyRate={billing.hourlyRate}
240
+ captureRef={captureRef}
241
+ />
242
+ </div>
243
+ </>
244
+ ) : null}
245
+ </View>
246
+ </View.Item>
247
+ </View>
248
+ )
249
+ }
@@ -0,0 +1,121 @@
1
+ import React, { useMemo } from 'react'
2
+ import { Select, Text, useLocale, View } from '@ossy/design-system'
3
+ import { useSdk } from '@ossy/sdk-react'
4
+ import { SearchResources } from '@ossy/resources'
5
+ import { TimesheetRefs } from './schema-ids.js'
6
+ import { contractLabel, employeeLabel, resourceIdOf } from './timesheet-parties.js'
7
+
8
+ export function useTimesheetParties () {
9
+ const sdk = useSdk()
10
+ const { data: employees = [] } = sdk.read(SearchResources, { type: TimesheetRefs.employee })
11
+ const { data: contracts = [] } = sdk.read(SearchResources, { type: TimesheetRefs.contract })
12
+ return { employees, contracts }
13
+ }
14
+
15
+ export function TimesheetPartyFields ({
16
+ employeeId,
17
+ contractId,
18
+ onEmployeeChange,
19
+ onContractChange,
20
+ disabled,
21
+ layout = 'stack',
22
+ employees: employeesProp,
23
+ contracts: contractsProp,
24
+ children,
25
+ }) {
26
+ const { t } = useLocale()
27
+ const loaded = useTimesheetParties()
28
+ const employees = employeesProp ?? loaded.employees
29
+ const contracts = contractsProp ?? loaded.contracts
30
+ const selectedEmployeeId = resourceIdOf(employeeId) ?? ''
31
+ const selectedContractId = resourceIdOf(contractId) ?? ''
32
+
33
+ const contractOptions = useMemo(() => {
34
+ if (!selectedEmployeeId) return contracts
35
+ return contracts.filter((contract) => {
36
+ const owner = resourceIdOf(contract.content?.employeeId)
37
+ return !owner || owner === selectedEmployeeId || contract.id === selectedContractId
38
+ })
39
+ }, [contracts, selectedEmployeeId, selectedContractId])
40
+
41
+ const setEmployee = (event) => {
42
+ const next = event.target.value
43
+ onEmployeeChange(next ? { resourceId: next } : null)
44
+ if (!next || !selectedContractId) return
45
+ const current = contracts.find((contract) => contract.id === selectedContractId)
46
+ const owner = resourceIdOf(current?.content?.employeeId)
47
+ if (owner && owner !== next) onContractChange(null)
48
+ }
49
+
50
+ const setContract = (event) => {
51
+ const next = event.target.value
52
+ onContractChange(next ? { resourceId: next } : null)
53
+ }
54
+
55
+ const rowSelectStyle = layout === 'rows'
56
+ ? { width: '10rem', maxWidth: '10rem', minWidth: 0, fontWeight: 400 }
57
+ : undefined
58
+
59
+ const employeeSelect = (
60
+ <Select
61
+ value={selectedEmployeeId}
62
+ onChange={setEmployee}
63
+ disabled={disabled}
64
+ aria-label={t('timesheets.employee')}
65
+ style={rowSelectStyle}
66
+ >
67
+ <option value="">{t('timesheets.employeeNone')}</option>
68
+ {employees.map((employee) => (
69
+ <option key={employee.id} value={employee.id}>
70
+ {employeeLabel(employee)}
71
+ </option>
72
+ ))}
73
+ </Select>
74
+ )
75
+
76
+ const contractSelect = (
77
+ <Select
78
+ value={selectedContractId}
79
+ onChange={setContract}
80
+ disabled={disabled}
81
+ aria-label={t('timesheets.contract')}
82
+ style={rowSelectStyle}
83
+ >
84
+ <option value="">{t('timesheets.contractNone')}</option>
85
+ {contractOptions.map((contract) => (
86
+ <option key={contract.id} value={contract.id}>
87
+ {contractLabel(contract)}
88
+ </option>
89
+ ))}
90
+ </Select>
91
+ )
92
+
93
+ if (layout === 'rows') {
94
+ return (
95
+ <>
96
+ <View layout="row" gap="s" style={{ justifyContent: 'space-between', alignItems: 'center', minWidth: 0 }}>
97
+ <Text variant="m" style={{ marginBottom: 0, minWidth: 0, fontWeight: 400, '--font-weight': '400' }} text="timesheets.employee" />
98
+ {employeeSelect}
99
+ </View>
100
+ {children}
101
+ <View layout="row" gap="s" style={{ justifyContent: 'space-between', alignItems: 'center', minWidth: 0 }}>
102
+ <Text variant="m" style={{ marginBottom: 0, minWidth: 0, fontWeight: 400, '--font-weight': '400' }} text="timesheets.contract" />
103
+ {contractSelect}
104
+ </View>
105
+ </>
106
+ )
107
+ }
108
+
109
+ return (
110
+ <>
111
+ <View gap="xs">
112
+ <Text variant="small" weight="medium" text="timesheets.employee" />
113
+ {employeeSelect}
114
+ </View>
115
+ <View gap="xs">
116
+ <Text variant="small" weight="medium" text="timesheets.contract" />
117
+ {contractSelect}
118
+ </View>
119
+ </>
120
+ )
121
+ }
@@ -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
+ }