@ossy/timesheets 3.8.0 → 3.10.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 +8 -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/auto-generate-timesheets.js +70 -0
- package/src/auto-generate-timesheets.spec.js +90 -0
- package/src/auto-generate-timesheets.task.js +60 -0
- package/src/billing-period.js +123 -0
- package/src/billing-period.spec.js +89 -0
- package/src/build-prefilled-lines.js +3 -16
- package/src/create-timesheet-draft.js +81 -0
- 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 +13 -71
- package/src/get.task.js +1 -1
- package/src/index.js +12 -1
- package/src/list.task.js +5 -1
- package/src/save.task.js +16 -5
- package/src/schema-ids.js +6 -0
- package/src/send-timesheet-reminder.task.js +76 -0
- package/src/sv.translations.json +23 -3
- package/src/timesheet-detail.page.jsx +10 -193
- package/src/timesheet-dto.js +26 -0
- package/src/timesheet-parties.js +57 -0
- package/src/timesheet-parties.spec.js +40 -0
- package/src/timesheet-refs.js +16 -0
- package/src/timesheet-reminder.email.jsx +47 -0
- package/src/timesheet-reminder.js +62 -0
- package/src/timesheet-reminder.spec.js +75 -0
- package/src/timesheet-resources.js +6 -18
- package/src/timesheet-resources.spec.js +59 -0
- package/src/timesheet.schema.js +20 -1
|
@@ -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
|
+
}
|
|
@@ -1,14 +1,21 @@
|
|
|
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'
|
|
12
19
|
import { HolidayLocaleSelect } from './HolidayLocaleSelect.jsx'
|
|
13
20
|
import { resolveHolidayLocale } from './holiday-locale.js'
|
|
14
21
|
|
|
@@ -41,7 +48,8 @@ function buildMonthOptions (language, yearsBack = 1, yearsForward = 0) {
|
|
|
41
48
|
export default function TimesheetsProductHome () {
|
|
42
49
|
const { t, language } = useLocale()
|
|
43
50
|
const sdk = useSdk()
|
|
44
|
-
const
|
|
51
|
+
const { employees, contracts } = useTimesheetParties()
|
|
52
|
+
const { selectedResourceId, setSelectedResourceId, clearSelectedResourceId } = useSelectedResourceId()
|
|
45
53
|
|
|
46
54
|
const monthOptions = useMemo(() => buildMonthOptions(language), [language])
|
|
47
55
|
const [periodKey, setPeriodKey] = useState(() => {
|
|
@@ -49,51 +57,112 @@ export default function TimesheetsProductHome () {
|
|
|
49
57
|
return `${n.getFullYear()}-${n.getMonth()}`
|
|
50
58
|
})
|
|
51
59
|
const [holidayLocale, setHolidayLocale] = useState(() => resolveHolidayLocale(language))
|
|
60
|
+
const [employeeId, setEmployeeId] = useState(null)
|
|
61
|
+
const [contractId, setContractId] = useState(null)
|
|
52
62
|
const [creating, setCreating] = useState(false)
|
|
53
63
|
const [sheets, setSheets] = useState([])
|
|
54
64
|
const [loading, setLoading] = useState(true)
|
|
55
65
|
const [generating, setGenerating] = useState(false)
|
|
56
66
|
const [error, setError] = useState(null)
|
|
57
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)
|
|
58
72
|
|
|
59
73
|
const selected = useMemo(
|
|
60
74
|
() => monthOptions.find((o) => o.value === periodKey) ?? monthOptions[0],
|
|
61
75
|
[monthOptions, periodKey],
|
|
62
76
|
)
|
|
63
77
|
|
|
64
|
-
const existingPeriodStarts = useMemo(
|
|
65
|
-
() => new Set(sheets.map((s) => s.periodStart)),
|
|
66
|
-
[sheets],
|
|
67
|
-
)
|
|
68
|
-
|
|
69
78
|
const statusTags = (Definition.status ?? [])
|
|
70
79
|
.map((s) => ({ beta: 'Beta', 'coming-soon': 'Coming Soon' }[s]))
|
|
71
80
|
.filter(Boolean)
|
|
72
81
|
|
|
73
|
-
const
|
|
74
|
-
setLoading(true)
|
|
75
|
-
setError(null)
|
|
82
|
+
const refreshList = useCallback(async () => {
|
|
76
83
|
try {
|
|
77
84
|
const data = await sdk.invoke(ListTimesheets, {})
|
|
78
85
|
setSheets(Array.isArray(data) ? data : [])
|
|
79
86
|
} catch (err) {
|
|
80
87
|
setError(await invokeErrorMessage(err, t('timesheets.errorLoad')))
|
|
81
88
|
setSheets([])
|
|
89
|
+
}
|
|
90
|
+
}, [sdk, t])
|
|
91
|
+
|
|
92
|
+
const loadList = useCallback(async () => {
|
|
93
|
+
setLoading(true)
|
|
94
|
+
setError(null)
|
|
95
|
+
try {
|
|
96
|
+
await refreshList()
|
|
82
97
|
} finally {
|
|
83
98
|
setLoading(false)
|
|
84
99
|
}
|
|
85
|
-
}, [
|
|
100
|
+
}, [refreshList])
|
|
86
101
|
|
|
87
102
|
useEffect(() => {
|
|
88
103
|
loadList()
|
|
89
104
|
}, [loadList])
|
|
90
105
|
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
})
|
|
95
144
|
})
|
|
96
|
-
|
|
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
|
+
}
|
|
97
166
|
}
|
|
98
167
|
|
|
99
168
|
const closeCreateModal = () => {
|
|
@@ -108,6 +177,8 @@ export default function TimesheetsProductHome () {
|
|
|
108
177
|
const n = new Date()
|
|
109
178
|
setPeriodKey(`${n.getFullYear()}-${n.getMonth()}`)
|
|
110
179
|
setHolidayLocale(resolveHolidayLocale(language))
|
|
180
|
+
setEmployeeId(null)
|
|
181
|
+
setContractId(null)
|
|
111
182
|
}
|
|
112
183
|
|
|
113
184
|
const handleGenerate = async () => {
|
|
@@ -119,12 +190,16 @@ export default function TimesheetsProductHome () {
|
|
|
119
190
|
year: selected.year,
|
|
120
191
|
monthIndex: selected.monthIndex,
|
|
121
192
|
locale: holidayLocale,
|
|
193
|
+
...(employeeId ? { employeeId } : {}),
|
|
194
|
+
...(contractId ? { contractId } : {}),
|
|
122
195
|
})
|
|
123
196
|
if (data?.id) {
|
|
124
|
-
|
|
197
|
+
await refreshList()
|
|
198
|
+
setCreating(false)
|
|
199
|
+
openSheet(data.id)
|
|
125
200
|
return
|
|
126
201
|
}
|
|
127
|
-
await
|
|
202
|
+
await refreshList()
|
|
128
203
|
setCreating(false)
|
|
129
204
|
} catch (err) {
|
|
130
205
|
setCreateError(await invokeErrorMessage(err, t('timesheets.errorGenerate')))
|
|
@@ -133,51 +208,164 @@ export default function TimesheetsProductHome () {
|
|
|
133
208
|
}
|
|
134
209
|
}
|
|
135
210
|
|
|
136
|
-
const periodAlreadyExists = selected
|
|
137
|
-
&& existingPeriodStarts.has(monthPeriodBounds(selected.year, selected.monthIndex).periodStart)
|
|
138
|
-
|
|
139
211
|
return (
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
key={sheet.id}
|
|
169
|
-
timesheet={sheet}
|
|
170
|
-
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}
|
|
171
240
|
/>
|
|
172
|
-
|
|
173
|
-
</
|
|
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>
|
|
174
305
|
</View>
|
|
175
|
-
|
|
306
|
+
</View.Item>
|
|
307
|
+
|
|
308
|
+
<TimesheetPanel
|
|
309
|
+
timesheetId={selectedResourceId}
|
|
310
|
+
onClose={clearSelectedResourceId}
|
|
311
|
+
onSaved={refreshList}
|
|
312
|
+
/>
|
|
176
313
|
</View>
|
|
177
|
-
</
|
|
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}
|
|
178
366
|
|
|
179
367
|
<Overlay isVisible={creating} onClose={closeCreateModal}>
|
|
180
|
-
<View layout="off-center-
|
|
368
|
+
<View layout="off-center-s" style={{ height: '100%' }}>
|
|
181
369
|
<View data-region="content" style={{ width: 'min(28rem, calc(100vw - 2rem))' }}>
|
|
182
370
|
<View surface="primary" roundness="m" inset="l" gap="m">
|
|
183
371
|
<View gap="xs">
|
|
@@ -206,9 +394,13 @@ export default function TimesheetsProductHome () {
|
|
|
206
394
|
onChange={(e) => setHolidayLocale(e.target.value)}
|
|
207
395
|
/>
|
|
208
396
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
397
|
+
<TimesheetPartyFields
|
|
398
|
+
employeeId={employeeId}
|
|
399
|
+
contractId={contractId}
|
|
400
|
+
onEmployeeChange={setEmployeeId}
|
|
401
|
+
onContractChange={setContractId}
|
|
402
|
+
disabled={generating}
|
|
403
|
+
/>
|
|
212
404
|
|
|
213
405
|
<View layout="row" gap="s" justifyContent="flex-end" style={{ flexWrap: 'wrap' }}>
|
|
214
406
|
<Button
|
|
@@ -223,17 +415,13 @@ export default function TimesheetsProductHome () {
|
|
|
223
415
|
disabled={generating || !selected}
|
|
224
416
|
onClick={handleGenerate}
|
|
225
417
|
>
|
|
226
|
-
{generating
|
|
227
|
-
? t('timesheets.loading')
|
|
228
|
-
: periodAlreadyExists
|
|
229
|
-
? t('timesheets.openExisting')
|
|
230
|
-
: t('timesheets.generate')}
|
|
418
|
+
{generating ? t('timesheets.loading') : t('timesheets.generate')}
|
|
231
419
|
</Button>
|
|
232
420
|
</View>
|
|
233
421
|
</View>
|
|
234
422
|
</View>
|
|
235
423
|
</View>
|
|
236
424
|
</Overlay>
|
|
237
|
-
|
|
425
|
+
</>
|
|
238
426
|
)
|
|
239
427
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { TimesheetRefs, TimesheetsSchema } from './schema-ids.js'
|
|
2
|
+
import {
|
|
3
|
+
findTimesheetForContractPeriod,
|
|
4
|
+
isContractActive,
|
|
5
|
+
periodForContract,
|
|
6
|
+
} from './billing-period.js'
|
|
7
|
+
import { optionalReference } from './timesheet-refs.js'
|
|
8
|
+
|
|
9
|
+
/** Company basic-info schema (consultancy) — used for holiday locale. */
|
|
10
|
+
export const COMPANY_BASIC_INFO_SCHEMA = '@ossy/consultancy/schema/company-basic-info'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Pick holiday locale for a workspace from company basic-info resources.
|
|
14
|
+
* @param {Array<{ belongsTo?: string, content?: { holidayLocale?: string } }>} companyResources
|
|
15
|
+
* @param {string} workspaceId
|
|
16
|
+
* @returns {string | undefined}
|
|
17
|
+
*/
|
|
18
|
+
export function holidayLocaleForWorkspace (companyResources, workspaceId) {
|
|
19
|
+
const match = (companyResources || []).find((r) => r.belongsTo === workspaceId)
|
|
20
|
+
return match?.content?.holidayLocale
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Decide which active contracts need a new auto-generated sheet for `asOf`.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} input
|
|
27
|
+
* @param {Array<{ id: string, belongsTo?: string, content?: object }>} input.contracts
|
|
28
|
+
* @param {Array<{ id: string, belongsTo?: string, createdAt?: number, content?: object }>} input.timesheets
|
|
29
|
+
* @param {Date | number} [input.asOf]
|
|
30
|
+
* @returns {Array<{
|
|
31
|
+
* contractId: string,
|
|
32
|
+
* workspaceId: string,
|
|
33
|
+
* employeeId: string | null,
|
|
34
|
+
* periodStart: number,
|
|
35
|
+
* periodEnd: number,
|
|
36
|
+
* billingPeriod: string,
|
|
37
|
+
* }>}
|
|
38
|
+
*/
|
|
39
|
+
export function contractsNeedingTimesheets ({ contracts, timesheets, asOf = Date.now() }) {
|
|
40
|
+
const needed = []
|
|
41
|
+
|
|
42
|
+
for (const contract of contracts || []) {
|
|
43
|
+
const workspaceId = contract.belongsTo
|
|
44
|
+
if (!workspaceId) continue
|
|
45
|
+
if (!isContractActive(contract.content, asOf)) continue
|
|
46
|
+
|
|
47
|
+
const { periodStart, periodEnd } = periodForContract(contract.content, asOf)
|
|
48
|
+
const existing = findTimesheetForContractPeriod(timesheets, contract.id, periodStart)
|
|
49
|
+
if (existing) continue
|
|
50
|
+
|
|
51
|
+
const employeeId = optionalReference(contract.content?.employeeId)?.resourceId ?? null
|
|
52
|
+
|
|
53
|
+
needed.push({
|
|
54
|
+
contractId: contract.id,
|
|
55
|
+
workspaceId,
|
|
56
|
+
employeeId,
|
|
57
|
+
periodStart,
|
|
58
|
+
periodEnd,
|
|
59
|
+
billingPeriod: contract.content?.billingPeriod === 'weekly' ? 'weekly' : 'monthly',
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return needed
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const AUTO_GENERATE_QUERY = {
|
|
67
|
+
contracts: { type: TimesheetRefs.contract },
|
|
68
|
+
timesheets: { type: TimesheetsSchema.timesheet },
|
|
69
|
+
companies: { type: COMPANY_BASIC_INFO_SCHEMA },
|
|
70
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
contractsNeedingTimesheets,
|
|
4
|
+
holidayLocaleForWorkspace,
|
|
5
|
+
} from './auto-generate-timesheets.js'
|
|
6
|
+
|
|
7
|
+
describe('holidayLocaleForWorkspace', () => {
|
|
8
|
+
it('returns the company holidayLocale for the workspace', () => {
|
|
9
|
+
const companies = [
|
|
10
|
+
{ belongsTo: 'ws-a', content: { holidayLocale: 'SE' } },
|
|
11
|
+
{ belongsTo: 'ws-b', content: { holidayLocale: 'NO' } },
|
|
12
|
+
]
|
|
13
|
+
expect(holidayLocaleForWorkspace(companies, 'ws-b')).toBe('NO')
|
|
14
|
+
expect(holidayLocaleForWorkspace(companies, 'ws-missing')).toBe(undefined)
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('contractsNeedingTimesheets', () => {
|
|
19
|
+
const augustStart = new Date(2026, 7, 1).setHours(0, 0, 0, 0)
|
|
20
|
+
const asOf = new Date(2026, 7, 19)
|
|
21
|
+
|
|
22
|
+
it('skips inactive contracts and existing sheets', () => {
|
|
23
|
+
const contracts = [
|
|
24
|
+
{
|
|
25
|
+
id: 'c-active',
|
|
26
|
+
belongsTo: 'ws-1',
|
|
27
|
+
content: {
|
|
28
|
+
billingPeriod: 'monthly',
|
|
29
|
+
employeeId: 'emp-1',
|
|
30
|
+
startDate: new Date(2026, 0, 1).getTime(),
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'c-done',
|
|
35
|
+
belongsTo: 'ws-1',
|
|
36
|
+
content: {
|
|
37
|
+
billingPeriod: 'monthly',
|
|
38
|
+
startDate: new Date(2025, 0, 1).getTime(),
|
|
39
|
+
endDate: new Date(2026, 0, 1).getTime(),
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'c-has-sheet',
|
|
44
|
+
belongsTo: 'ws-1',
|
|
45
|
+
content: {
|
|
46
|
+
billingPeriod: 'monthly',
|
|
47
|
+
startDate: new Date(2026, 0, 1).getTime(),
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
const timesheets = [
|
|
53
|
+
{
|
|
54
|
+
id: 'sheet-1',
|
|
55
|
+
createdAt: 1,
|
|
56
|
+
content: { periodStart: augustStart, contractId: 'c-has-sheet' },
|
|
57
|
+
},
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
expect(contractsNeedingTimesheets({ contracts, timesheets, asOf })).toEqual([
|
|
61
|
+
{
|
|
62
|
+
contractId: 'c-active',
|
|
63
|
+
workspaceId: 'ws-1',
|
|
64
|
+
employeeId: 'emp-1',
|
|
65
|
+
periodStart: augustStart,
|
|
66
|
+
periodEnd: new Date(2026, 7, 31).setHours(0, 0, 0, 0),
|
|
67
|
+
billingPeriod: 'monthly',
|
|
68
|
+
},
|
|
69
|
+
])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('uses weekly period bounds when configured', () => {
|
|
73
|
+
const contracts = [{
|
|
74
|
+
id: 'c-week',
|
|
75
|
+
belongsTo: 'ws-2',
|
|
76
|
+
content: {
|
|
77
|
+
billingPeriod: 'weekly',
|
|
78
|
+
employeeId: { resourceId: 'emp-9' },
|
|
79
|
+
startDate: new Date(2026, 0, 1).getTime(),
|
|
80
|
+
},
|
|
81
|
+
}]
|
|
82
|
+
|
|
83
|
+
const [needed] = contractsNeedingTimesheets({ contracts, timesheets: [], asOf })
|
|
84
|
+
expect(needed.contractId).toBe('c-week')
|
|
85
|
+
expect(needed.employeeId).toBe('emp-9')
|
|
86
|
+
expect(needed.billingPeriod).toBe('weekly')
|
|
87
|
+
expect(new Date(needed.periodStart).getDay()).toBe(1)
|
|
88
|
+
expect(new Date(needed.periodEnd).getDay()).toBe(0)
|
|
89
|
+
})
|
|
90
|
+
})
|