@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/package.json
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/timesheets",
|
|
3
3
|
"description": "Timesheets — generate, review, save, and export monthly work hours",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.9.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"module": "./src/index.js",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./src/index.js"
|
|
10
10
|
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "NODE_OPTIONS=--experimental-vm-modules jest --verbose"
|
|
13
|
+
},
|
|
11
14
|
"author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
|
|
12
15
|
"license": "MIT",
|
|
13
16
|
"ossy": {
|
|
14
17
|
"src": "./src"
|
|
15
18
|
},
|
|
16
19
|
"dependencies": {
|
|
17
|
-
"@ossy/resources": "^3.
|
|
20
|
+
"@ossy/resources": "^3.9.0",
|
|
18
21
|
"html-to-image": "^1.11.13",
|
|
19
22
|
"nanoid": "^5.1.11",
|
|
20
23
|
"pdf-lib": "^1.17.1"
|
|
@@ -38,5 +41,5 @@
|
|
|
38
41
|
"/src",
|
|
39
42
|
"README.md"
|
|
40
43
|
],
|
|
41
|
-
"gitHead": "
|
|
44
|
+
"gitHead": "f404be69becb27a1fd853a6ff1903554e76e7d17"
|
|
42
45
|
}
|
package/src/Timesheet.jsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useMemo } from 'react'
|
|
2
2
|
import { View, Text, Input, DateDisplay, Separator, useLocale } from '@ossy/design-system'
|
|
3
3
|
import { buildMonthGrid, dateKeyFromMs } from '@ossy/calendar'
|
|
4
|
+
import { TimesheetPartyRows } from './TimesheetPartyRows.jsx'
|
|
4
5
|
|
|
5
6
|
const calendarGrid = {
|
|
6
7
|
display: 'grid',
|
|
@@ -53,6 +54,8 @@ const hoursInput = {
|
|
|
53
54
|
borderStyle: 'solid',
|
|
54
55
|
boxShadow: 'none',
|
|
55
56
|
outline: 'none',
|
|
57
|
+
appearance: 'textfield',
|
|
58
|
+
MozAppearance: 'textfield',
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
const WEEKDAY_KEYS = [
|
|
@@ -70,7 +73,17 @@ const WEEKDAY_KEYS = [
|
|
|
70
73
|
* Non-workdays use CalendarDaySurfaceStyles (red).
|
|
71
74
|
* Pass `captureRef` only if capturing this live card; prefer {@link TimesheetExportCard} for PNG/PDF.
|
|
72
75
|
*/
|
|
73
|
-
export function Timesheet ({
|
|
76
|
+
export function Timesheet ({
|
|
77
|
+
lines = [],
|
|
78
|
+
onChange,
|
|
79
|
+
disabled = false,
|
|
80
|
+
periodStart,
|
|
81
|
+
captureRef,
|
|
82
|
+
employeeId,
|
|
83
|
+
contractId,
|
|
84
|
+
onEmployeeChange,
|
|
85
|
+
onContractChange,
|
|
86
|
+
}) {
|
|
74
87
|
const { t } = useLocale()
|
|
75
88
|
|
|
76
89
|
const lineByKey = useMemo(() => {
|
|
@@ -139,6 +152,15 @@ export function Timesheet ({ lines = [], onChange, disabled = false, periodStart
|
|
|
139
152
|
font-weight: 700;
|
|
140
153
|
font-size: 14px;
|
|
141
154
|
text-align: center;
|
|
155
|
+
appearance: textfield;
|
|
156
|
+
-moz-appearance: textfield;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
[data-timesheet-root] [data-ossy-calendar-day] [data-component="input"]::-webkit-outer-spin-button,
|
|
160
|
+
[data-timesheet-root] [data-ossy-calendar-day] [data-component="input"]::-webkit-inner-spin-button {
|
|
161
|
+
-webkit-appearance: none;
|
|
162
|
+
margin: 0;
|
|
163
|
+
display: none;
|
|
142
164
|
}
|
|
143
165
|
|
|
144
166
|
[data-timesheet-root] [data-ossy-calendar-day] [data-component="input"]:focus {
|
|
@@ -178,6 +200,19 @@ export function Timesheet ({ lines = [], onChange, disabled = false, periodStart
|
|
|
178
200
|
/>
|
|
179
201
|
</View>
|
|
180
202
|
|
|
203
|
+
{typeof onEmployeeChange === 'function' && typeof onContractChange === 'function' ? (
|
|
204
|
+
<div style={{ width: 0, minWidth: '100%' }}>
|
|
205
|
+
<TimesheetPartyRows
|
|
206
|
+
employeeId={employeeId}
|
|
207
|
+
contractId={contractId}
|
|
208
|
+
onEmployeeChange={onEmployeeChange}
|
|
209
|
+
onContractChange={onContractChange}
|
|
210
|
+
disabled={disabled}
|
|
211
|
+
totalHours={totalHours}
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
) : null}
|
|
215
|
+
|
|
181
216
|
<div style={calendarGrid}>
|
|
182
217
|
{WEEKDAY_KEYS.map((key) => (
|
|
183
218
|
<Text key={key} variant="small" color="secondary" style={{ textAlign: 'center' }}>
|
package/src/TimesheetCard.jsx
CHANGED
|
@@ -1,36 +1,75 @@
|
|
|
1
|
-
import React, { useMemo } from 'react'
|
|
2
|
-
import {
|
|
1
|
+
import React, { useCallback, useMemo, useState } from 'react'
|
|
2
|
+
import { Button, ContextMenu, DateDisplay, Dropdown, Icon, List, Text, View, useLocale } from '@ossy/design-system'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* Storage-style list row for a timesheet period.
|
|
6
6
|
*/
|
|
7
|
-
export function TimesheetCard ({ timesheet, onClick }) {
|
|
7
|
+
export function TimesheetCard ({ timesheet, onClick, selected = false, actions }) {
|
|
8
8
|
const { t } = useLocale()
|
|
9
|
+
const [menuOpen, setMenuOpen] = useState(false)
|
|
10
|
+
const [menuPosition, setMenuPosition] = useState(null)
|
|
9
11
|
const totalHours = useMemo(
|
|
10
12
|
() => (timesheet?.lines ?? []).reduce((sum, line) => sum + (Number(line.hours) || 0), 0),
|
|
11
13
|
[timesheet?.lines],
|
|
12
14
|
)
|
|
13
15
|
const status = timesheet?.status ?? 'draft'
|
|
14
16
|
|
|
17
|
+
const handleMenuOpenChange = useCallback((open) => {
|
|
18
|
+
setMenuOpen(open)
|
|
19
|
+
if (!open) setMenuPosition(null)
|
|
20
|
+
}, [])
|
|
21
|
+
|
|
22
|
+
const handleContextMenu = useCallback((event) => {
|
|
23
|
+
if (!actions) return
|
|
24
|
+
event.preventDefault()
|
|
25
|
+
event.stopPropagation()
|
|
26
|
+
setMenuPosition({ top: event.clientY, left: event.clientX })
|
|
27
|
+
setMenuOpen(true)
|
|
28
|
+
}, [actions])
|
|
29
|
+
|
|
15
30
|
return (
|
|
16
31
|
<List.Item
|
|
17
32
|
selectable
|
|
18
|
-
|
|
33
|
+
selected={selected}
|
|
19
34
|
onClick={onClick}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
/>
|
|
28
|
-
<Text variant="small" color="secondary">
|
|
29
|
-
{t('timesheets.statusLabel', { status: t(`timesheets.status.${status}`) })}
|
|
30
|
-
{' · '}
|
|
31
|
-
{t('timesheets.totalHours', { hours: totalHours })}
|
|
35
|
+
onContextMenu={handleContextMenu}
|
|
36
|
+
leading={(
|
|
37
|
+
<Icon size="s" name="calendar" style={{ fill: 'hsl(0, 0%, 80%)' }} />
|
|
38
|
+
)}
|
|
39
|
+
meta={(
|
|
40
|
+
<Text variant="small">
|
|
41
|
+
{t('timesheets.meta.hoursValue', { hours: totalHours })} {t(`timesheets.status.${status}`)}
|
|
32
42
|
</Text>
|
|
33
|
-
|
|
43
|
+
)}
|
|
44
|
+
trailing={actions ? (
|
|
45
|
+
<Dropdown
|
|
46
|
+
trigger={(
|
|
47
|
+
<Button
|
|
48
|
+
prefix="more-vertical-alt"
|
|
49
|
+
variant="command"
|
|
50
|
+
aria-label={t('timesheets.rowActions')}
|
|
51
|
+
onClick={(event) => {
|
|
52
|
+
event.stopPropagation()
|
|
53
|
+
setMenuPosition(null)
|
|
54
|
+
}}
|
|
55
|
+
/>
|
|
56
|
+
)}
|
|
57
|
+
open={menuOpen}
|
|
58
|
+
onOpenChange={handleMenuOpenChange}
|
|
59
|
+
position={menuPosition}
|
|
60
|
+
>
|
|
61
|
+
<View inset="xs" surface="primary" roundness="s">
|
|
62
|
+
<ContextMenu roundness="s" surface="primary">
|
|
63
|
+
{actions}
|
|
64
|
+
</ContextMenu>
|
|
65
|
+
</View>
|
|
66
|
+
</Dropdown>
|
|
67
|
+
) : undefined}
|
|
68
|
+
>
|
|
69
|
+
<DateDisplay
|
|
70
|
+
value={timesheet.periodStart}
|
|
71
|
+
formatOptions={{ month: 'long', year: 'numeric' }}
|
|
72
|
+
/>
|
|
34
73
|
</List.Item>
|
|
35
74
|
)
|
|
36
75
|
}
|
|
@@ -46,12 +46,42 @@ const WEEKDAY_KEYS = [
|
|
|
46
46
|
'timesheets.weekday.sun',
|
|
47
47
|
]
|
|
48
48
|
|
|
49
|
+
function ExportMetaRow ({ label, value }) {
|
|
50
|
+
if (value == null || value === '') return null
|
|
51
|
+
return (
|
|
52
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16 }}>
|
|
53
|
+
<span style={{ fontFamily: TIMESHEET_EXPORT_FONT_FAMILY, fontSize: 13, fontWeight: 400, color: FG_MUTED }}>
|
|
54
|
+
{label}
|
|
55
|
+
</span>
|
|
56
|
+
<span style={{ fontFamily: TIMESHEET_EXPORT_FONT_FAMILY, fontSize: 13, fontWeight: 400, color: FG }}>
|
|
57
|
+
{value}
|
|
58
|
+
</span>
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatSek (value, language) {
|
|
64
|
+
if (value == null || value === '' || !Number.isFinite(Number(value))) return ''
|
|
65
|
+
return new Intl.NumberFormat(language === 'sv' ? 'sv-SE' : 'en-SE', {
|
|
66
|
+
style: 'currency',
|
|
67
|
+
currency: 'SEK',
|
|
68
|
+
}).format(Number(value))
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
/**
|
|
50
72
|
* Print-ready timesheet (hours as text — no inputs).
|
|
51
73
|
* Matches on-screen {@link Timesheet} layout; white background, no card chrome.
|
|
52
74
|
* Mount off-screen and pass `captureRef` for PNG/PDF via html-to-image.
|
|
53
75
|
*/
|
|
54
|
-
export function TimesheetExportCard ({
|
|
76
|
+
export function TimesheetExportCard ({
|
|
77
|
+
lines = [],
|
|
78
|
+
periodStart,
|
|
79
|
+
captureRef,
|
|
80
|
+
employeeLabel,
|
|
81
|
+
contractLabel,
|
|
82
|
+
clientName,
|
|
83
|
+
hourlyRate,
|
|
84
|
+
}) {
|
|
55
85
|
const { t, language } = useLocale()
|
|
56
86
|
|
|
57
87
|
const lineByKey = useMemo(() => {
|
|
@@ -74,6 +104,12 @@ export function TimesheetExportCard ({ lines = [], periodStart, captureRef }) {
|
|
|
74
104
|
.format(new Date(anchorMs))
|
|
75
105
|
}, [anchorMs, language])
|
|
76
106
|
|
|
107
|
+
const rate = hourlyRate == null || hourlyRate === '' ? null : Number(hourlyRate)
|
|
108
|
+
const totalCost = rate != null && Number.isFinite(rate) ? totalHours * rate : null
|
|
109
|
+
const hasPartyMeta = Boolean(
|
|
110
|
+
employeeLabel || contractLabel || clientName || (rate != null && Number.isFinite(rate)),
|
|
111
|
+
)
|
|
112
|
+
|
|
77
113
|
if (!lines.length) return null
|
|
78
114
|
|
|
79
115
|
return (
|
|
@@ -135,6 +171,25 @@ export function TimesheetExportCard ({ lines = [], periodStart, captureRef }) {
|
|
|
135
171
|
width: '100%',
|
|
136
172
|
}}
|
|
137
173
|
/>
|
|
174
|
+
{hasPartyMeta ? (
|
|
175
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
176
|
+
<ExportMetaRow label={t('timesheets.employee')} value={employeeLabel} />
|
|
177
|
+
<ExportMetaRow label={t('timesheets.meta.client')} value={clientName} />
|
|
178
|
+
<ExportMetaRow label={t('timesheets.contract')} value={contractLabel} />
|
|
179
|
+
<ExportMetaRow
|
|
180
|
+
label={t('timesheets.meta.hourlyRate')}
|
|
181
|
+
value={
|
|
182
|
+
rate != null && Number.isFinite(rate)
|
|
183
|
+
? `${formatSek(rate, language)}${t('timesheets.meta.perHour')}`
|
|
184
|
+
: ''
|
|
185
|
+
}
|
|
186
|
+
/>
|
|
187
|
+
<ExportMetaRow
|
|
188
|
+
label={t('timesheets.meta.totalCost')}
|
|
189
|
+
value={totalCost != null && Number.isFinite(totalCost) ? formatSek(totalCost, language) : ''}
|
|
190
|
+
/>
|
|
191
|
+
</div>
|
|
192
|
+
) : null}
|
|
138
193
|
</div>
|
|
139
194
|
|
|
140
195
|
<div style={calendarGrid}>
|
|
@@ -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
|
+
}
|