@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
|
@@ -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,42 @@
|
|
|
1
|
+
import { ResourcesEvents, mutateResource, viewResource } from '@ossy/resources/server'
|
|
2
|
+
import { TimesheetsSchema } from './schema-ids.js'
|
|
3
|
+
|
|
4
|
+
export const metadata = { id: '@ossy/timesheets/tasks/delete' }
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Delete a timesheet. Payload: `{ timesheetId }`
|
|
8
|
+
*/
|
|
9
|
+
export async function run ({ payload, req, log }) {
|
|
10
|
+
const workspaceId = req?.workspaceId
|
|
11
|
+
|
|
12
|
+
if (!workspaceId) {
|
|
13
|
+
throw Object.assign(new Error('workspaceId is required'), { status: 400 })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const timesheetId = payload?.timesheetId
|
|
17
|
+
|
|
18
|
+
if (!timesheetId) {
|
|
19
|
+
throw Object.assign(new Error('timesheetId is required'), { status: 400 })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const resource = await viewResource(timesheetId)
|
|
23
|
+
|
|
24
|
+
if (!resource || resource.type !== TimesheetsSchema.timesheet) {
|
|
25
|
+
throw Object.assign(new Error('timesheet not found'), { status: 404 })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (resource.belongsTo && resource.belongsTo !== workspaceId) {
|
|
29
|
+
throw Object.assign(new Error('timesheet not found'), { status: 404 })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
log?.info(`[timesheets/tasks/delete] Deleting timesheet ${timesheetId}`)
|
|
33
|
+
|
|
34
|
+
await mutateResource(
|
|
35
|
+
timesheetId,
|
|
36
|
+
ResourcesEvents.Deleted({
|
|
37
|
+
createdBy: req?.userId ?? 'system',
|
|
38
|
+
}),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return { id: timesheetId, deleted: true }
|
|
42
|
+
}
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { formatDateKey } from './build-prefilled-lines.js'
|
|
2
2
|
|
|
3
|
+
function csvCell (value) {
|
|
4
|
+
const text = String(value ?? '')
|
|
5
|
+
if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`
|
|
6
|
+
return text
|
|
7
|
+
}
|
|
8
|
+
|
|
3
9
|
/**
|
|
4
10
|
* Build a CSV string for a timesheet and trigger a browser download.
|
|
5
|
-
* @param {{ periodStart?: number, periodEnd?: number, status?: string, lines?: Array<{ date: number, hours: number }
|
|
11
|
+
* @param {{ periodStart?: number, periodEnd?: number, status?: string, lines?: Array<{ date: number, hours: number }>, clientName?: string, hourlyRate?: number }} timesheet
|
|
6
12
|
* @param {string} [filename]
|
|
7
13
|
*/
|
|
8
14
|
export function downloadTimesheetCsv (timesheet, filename) {
|
|
@@ -12,6 +18,19 @@ export function downloadTimesheetCsv (timesheet, filename) {
|
|
|
12
18
|
if (timesheet?.periodStart != null && timesheet?.periodEnd != null) {
|
|
13
19
|
headerRows.push(`# period,${formatDateKey(timesheet.periodStart)},${formatDateKey(timesheet.periodEnd)}`)
|
|
14
20
|
headerRows.push(`# status,${timesheet.status ?? ''}`)
|
|
21
|
+
const employeeId = timesheet.employeeId?.resourceId ?? timesheet.employeeId
|
|
22
|
+
const contractId = timesheet.contractId?.resourceId ?? timesheet.contractId
|
|
23
|
+
if (employeeId) headerRows.push(`# employee,${csvCell(employeeId)}`)
|
|
24
|
+
if (timesheet.clientName) headerRows.push(`# client,${csvCell(timesheet.clientName)}`)
|
|
25
|
+
if (contractId) headerRows.push(`# contract,${csvCell(contractId)}`)
|
|
26
|
+
if (timesheet.hourlyRate != null && timesheet.hourlyRate !== '') {
|
|
27
|
+
const rate = Number(timesheet.hourlyRate)
|
|
28
|
+
if (Number.isFinite(rate)) {
|
|
29
|
+
const totalHours = lines.reduce((sum, line) => sum + (Number(line.hours) || 0), 0)
|
|
30
|
+
headerRows.push(`# hourlyRate,${rate}`)
|
|
31
|
+
headerRows.push(`# totalCost,${totalHours * rate}`)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
15
34
|
}
|
|
16
35
|
|
|
17
36
|
const rows = [
|
package/src/en.translations.json
CHANGED
|
@@ -35,9 +35,15 @@
|
|
|
35
35
|
"timesheets.createDescription": "Choose a month and holiday calendar to generate a prefilled timesheet.",
|
|
36
36
|
"timesheets.period": "Period",
|
|
37
37
|
"timesheets.holidayLocale": "Holiday calendar",
|
|
38
|
+
"timesheets.employee": "Assigned to",
|
|
39
|
+
"timesheets.employeeNone": "Unassigned",
|
|
40
|
+
"timesheets.contract": "Contract",
|
|
41
|
+
"timesheets.contractNone": "No contract",
|
|
42
|
+
"@ossy/timesheets/schema/timesheet.employeeId.label": "Assigned to",
|
|
43
|
+
"@ossy/timesheets/schema/timesheet.employeeId.description": "Optional. Who this timesheet is assigned to.",
|
|
44
|
+
"@ossy/timesheets/schema/timesheet.contractId.label": "Contract",
|
|
45
|
+
"@ossy/timesheets/schema/timesheet.contractId.description": "Optional. The contract this timesheet belongs to.",
|
|
38
46
|
"timesheets.generate": "Generate",
|
|
39
|
-
"timesheets.openExisting": "Open",
|
|
40
|
-
"timesheets.periodExistsHint": "A timesheet already exists for this period — Open loads it.",
|
|
41
47
|
"timesheets.cancel": "Cancel",
|
|
42
48
|
"timesheets.save": "Save",
|
|
43
49
|
"timesheets.saving": "Saving…",
|
|
@@ -52,13 +58,26 @@
|
|
|
52
58
|
"timesheets.listTitle": "Your timesheets",
|
|
53
59
|
"timesheets.listEmpty": "No timesheets yet. Click New to generate one for a month.",
|
|
54
60
|
"timesheets.open": "Open",
|
|
61
|
+
"timesheets.view": "View",
|
|
62
|
+
"timesheets.rowActions": "Timesheet actions",
|
|
63
|
+
"timesheets.delete": "Delete",
|
|
64
|
+
"timesheets.deleteTitle": "Delete timesheet?",
|
|
65
|
+
"timesheets.deleteText": "This timesheet will be removed. This cannot be undone.",
|
|
55
66
|
"timesheets.backToList": "← All timesheets",
|
|
67
|
+
"timesheets.closePanel": "Close",
|
|
56
68
|
"timesheets.saved": "Timesheet saved.",
|
|
57
69
|
"timesheets.unsaved": "Unsaved changes",
|
|
58
70
|
"timesheets.statusLabel": "Status: {status}",
|
|
59
71
|
"timesheets.status.draft": "Draft",
|
|
60
72
|
"timesheets.status.saved": "Saved",
|
|
61
73
|
"timesheets.totalHours": "Total: {hours} h",
|
|
74
|
+
"timesheets.meta.status": "Status",
|
|
75
|
+
"timesheets.meta.hoursValue": "{hours} h",
|
|
76
|
+
"timesheets.meta.client": "Client",
|
|
77
|
+
"timesheets.meta.hourlyRate": "Hourly rate",
|
|
78
|
+
"timesheets.meta.totalCost": "Total cost",
|
|
79
|
+
"timesheets.meta.perHour": "/h",
|
|
80
|
+
"timesheets.meta.dash": "—",
|
|
62
81
|
"timesheets.weekday.mon": "Mon",
|
|
63
82
|
"timesheets.weekday.tue": "Tue",
|
|
64
83
|
"timesheets.weekday.wed": "Wed",
|
|
@@ -70,5 +89,6 @@
|
|
|
70
89
|
"timesheets.errorNotFound": "Timesheet not found.",
|
|
71
90
|
"timesheets.errorGenerate": "Could not generate timesheet.",
|
|
72
91
|
"timesheets.errorSave": "Could not save timesheet.",
|
|
73
|
-
"timesheets.errorExport": "Could not export timesheet."
|
|
92
|
+
"timesheets.errorExport": "Could not export timesheet.",
|
|
93
|
+
"timesheets.errorDelete": "Could not delete timesheet."
|
|
74
94
|
}
|