@ossy/timesheets 3.9.0 → 3.11.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 +5 -3
- package/src/TimesheetCard.jsx +2 -0
- package/src/TimesheetPanel.jsx +27 -5
- package/src/TimesheetsFreeTool.jsx +10 -6
- package/src/TimesheetsProductHome.jsx +16 -9
- 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-and-save-timesheet.flow.js +34 -0
- package/src/create-timesheet-draft.js +81 -0
- package/src/delete-timesheet.flow.js +41 -0
- package/src/disable-timesheets.flow.js +58 -0
- package/src/enable-timesheets.flow.js +24 -0
- package/src/export-csv.action.js +7 -0
- package/src/export-pdf.action.js +7 -0
- package/src/export-png.action.js +7 -0
- package/src/export-timesheet-csv.flow.js +39 -0
- package/src/export-timesheet-pdf.flow.js +39 -0
- package/src/export-timesheet-png.flow.js +39 -0
- package/src/generate.task.js +8 -45
- package/src/index.js +14 -0
- package/src/open-delete.action.js +7 -0
- package/src/open-export.action.js +7 -0
- package/src/send-timesheet-reminder.task.js +76 -0
- package/src/timesheet-dto.js +26 -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 -40
- package/src/timesheet-resources.spec.js +1 -1
- package/src/timesheet.schema.js +5 -0
- package/src/timesheets.page.jsx +6 -3
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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.11.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"module": "./src/index.js",
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
"src": "./src"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@ossy/
|
|
20
|
+
"@ossy/email": "^3.11.0",
|
|
21
|
+
"@ossy/resources": "^3.11.0",
|
|
22
|
+
"@ossy/schema": "^3.8.0",
|
|
21
23
|
"html-to-image": "^1.11.13",
|
|
22
24
|
"nanoid": "^5.1.11",
|
|
23
25
|
"pdf-lib": "^1.17.1"
|
|
@@ -41,5 +43,5 @@
|
|
|
41
43
|
"/src",
|
|
42
44
|
"README.md"
|
|
43
45
|
],
|
|
44
|
-
"gitHead": "
|
|
46
|
+
"gitHead": "53ff8456920e09151c9d88df4a6c3ee958d811eb"
|
|
45
47
|
}
|
package/src/TimesheetCard.jsx
CHANGED
|
@@ -33,6 +33,8 @@ export function TimesheetCard ({ timesheet, onClick, selected = false, actions }
|
|
|
33
33
|
selected={selected}
|
|
34
34
|
onClick={onClick}
|
|
35
35
|
onContextMenu={handleContextMenu}
|
|
36
|
+
data-timesheet-id={timesheet?.id}
|
|
37
|
+
data-timesheet-status={status}
|
|
36
38
|
leading={(
|
|
37
39
|
<Icon size="s" name="calendar" style={{ fill: 'hsl(0, 0%, 80%)' }} />
|
|
38
40
|
)}
|
package/src/TimesheetPanel.jsx
CHANGED
|
@@ -11,11 +11,16 @@ import { useTimesheetParties } from './TimesheetPartyFields.jsx'
|
|
|
11
11
|
import { contractBillingMeta, labelForContract, labelForEmployee } from './timesheet-parties.js'
|
|
12
12
|
import { metadata as GetTimesheet } from './get.action.js'
|
|
13
13
|
import { metadata as SaveTimesheet } from './save.action.js'
|
|
14
|
+
import { metadata as OpenDelete } from './open-delete.action.js'
|
|
15
|
+
import { metadata as OpenExport } from './open-export.action.js'
|
|
16
|
+
import { metadata as ExportCsv } from './export-csv.action.js'
|
|
17
|
+
import { metadata as ExportPng } from './export-png.action.js'
|
|
18
|
+
import { metadata as ExportPdf } from './export-pdf.action.js'
|
|
14
19
|
|
|
15
20
|
/**
|
|
16
21
|
* Storage-style side panel for reviewing and saving a timesheet.
|
|
17
22
|
*/
|
|
18
|
-
export function TimesheetPanel ({ timesheetId, onClose, onSaved, width = '50%' }) {
|
|
23
|
+
export function TimesheetPanel ({ timesheetId, onClose, onSaved, onRequestDelete, width = '50%' }) {
|
|
19
24
|
const { t } = useLocale()
|
|
20
25
|
const sdk = useSdk()
|
|
21
26
|
const captureRef = useRef(null)
|
|
@@ -130,6 +135,8 @@ export function TimesheetPanel ({ timesheetId, onClose, onSaved, width = '50%' }
|
|
|
130
135
|
surface="primary"
|
|
131
136
|
bordered
|
|
132
137
|
roundness="xs"
|
|
138
|
+
data-timesheet-panel={timesheetId || undefined}
|
|
139
|
+
data-timesheet-panel-status={timesheet?.status || (loading ? 'loading' : undefined)}
|
|
133
140
|
style={{ height: '100%', width, minHeight: 0, minWidth: 0, flexShrink: 0 }}
|
|
134
141
|
>
|
|
135
142
|
<View.Item>
|
|
@@ -151,6 +158,8 @@ export function TimesheetPanel ({ timesheetId, onClose, onSaved, width = '50%' }
|
|
|
151
158
|
<Dropdown
|
|
152
159
|
trigger={(
|
|
153
160
|
<Button
|
|
161
|
+
{...OpenExport}
|
|
162
|
+
label={undefined}
|
|
154
163
|
variant="command"
|
|
155
164
|
prefix="software-download"
|
|
156
165
|
disabled={exportDisabled}
|
|
@@ -160,25 +169,26 @@ export function TimesheetPanel ({ timesheetId, onClose, onSaved, width = '50%' }
|
|
|
160
169
|
>
|
|
161
170
|
<ContextMenu roundness="s" surface="primary">
|
|
162
171
|
<ContextMenu.Item
|
|
172
|
+
{...ExportCsv}
|
|
163
173
|
prefix="list"
|
|
164
|
-
label="timesheets.exportCsv"
|
|
165
174
|
onClick={() => runExport('csv')}
|
|
166
175
|
/>
|
|
167
176
|
<ContextMenu.Item
|
|
177
|
+
{...ExportPng}
|
|
168
178
|
prefix="image"
|
|
169
|
-
label="timesheets.exportPng"
|
|
170
179
|
onClick={() => runExport('png')}
|
|
171
180
|
/>
|
|
172
181
|
<ContextMenu.Item
|
|
182
|
+
{...ExportPdf}
|
|
173
183
|
prefix="file-document"
|
|
174
|
-
label="timesheets.exportPdf"
|
|
175
184
|
onClick={() => runExport('pdf')}
|
|
176
185
|
/>
|
|
177
186
|
</ContextMenu>
|
|
178
187
|
</Dropdown>
|
|
179
188
|
|
|
180
189
|
<Button
|
|
181
|
-
|
|
190
|
+
{...SaveTimesheet}
|
|
191
|
+
label={undefined}
|
|
182
192
|
variant="command"
|
|
183
193
|
prefix="check"
|
|
184
194
|
disabled={!timesheet?.id || saving || loading}
|
|
@@ -186,6 +196,18 @@ export function TimesheetPanel ({ timesheetId, onClose, onSaved, width = '50%' }
|
|
|
186
196
|
onClick={handleSave}
|
|
187
197
|
/>
|
|
188
198
|
|
|
199
|
+
{onRequestDelete && (
|
|
200
|
+
<Button
|
|
201
|
+
{...OpenDelete}
|
|
202
|
+
label={undefined}
|
|
203
|
+
variant="command-danger"
|
|
204
|
+
prefix="trash-empty"
|
|
205
|
+
disabled={!timesheet?.id || loading}
|
|
206
|
+
aria-label={t('timesheets.delete')}
|
|
207
|
+
onClick={() => onRequestDelete(timesheet)}
|
|
208
|
+
/>
|
|
209
|
+
)}
|
|
210
|
+
|
|
189
211
|
{onClose && (
|
|
190
212
|
<Button
|
|
191
213
|
prefix="close"
|
|
@@ -17,6 +17,10 @@ import { downloadTimesheetPdf } from './download-timesheet-pdf.js'
|
|
|
17
17
|
import { invokeErrorMessage } from './invoke-error-message.js'
|
|
18
18
|
import { sampleShowcaseTimesheet } from './sample-showcase-timesheet.js'
|
|
19
19
|
import { timesheetsAuthHref } from './timesheets-auth-return.js'
|
|
20
|
+
import { metadata as OpenExport } from './open-export.action.js'
|
|
21
|
+
import { metadata as ExportCsv } from './export-csv.action.js'
|
|
22
|
+
import { metadata as ExportPng } from './export-png.action.js'
|
|
23
|
+
import { metadata as ExportPdf } from './export-pdf.action.js'
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
26
|
* Client-only timesheet for the sales page — prefilled, editable, exportable.
|
|
@@ -230,29 +234,29 @@ export function TimesheetsFreeTool ({
|
|
|
230
234
|
<Dropdown
|
|
231
235
|
trigger={(
|
|
232
236
|
<Button
|
|
237
|
+
{...OpenExport}
|
|
233
238
|
variant="secondary"
|
|
234
239
|
prefix="software-download"
|
|
235
240
|
suffix="chevron-down"
|
|
236
241
|
disabled={exportDisabled}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
</Button>
|
|
242
|
+
label={exporting ? 'timesheets.exporting' : OpenExport.label}
|
|
243
|
+
/>
|
|
240
244
|
)}
|
|
241
245
|
>
|
|
242
246
|
<ContextMenu roundness="s" surface="primary">
|
|
243
247
|
<ContextMenu.Item
|
|
248
|
+
{...ExportCsv}
|
|
244
249
|
prefix="list"
|
|
245
|
-
label="timesheets.exportCsv"
|
|
246
250
|
onClick={() => runExport('csv')}
|
|
247
251
|
/>
|
|
248
252
|
<ContextMenu.Item
|
|
253
|
+
{...ExportPng}
|
|
249
254
|
prefix="image"
|
|
250
|
-
label="timesheets.exportPng"
|
|
251
255
|
onClick={() => runExport('png')}
|
|
252
256
|
/>
|
|
253
257
|
<ContextMenu.Item
|
|
258
|
+
{...ExportPdf}
|
|
254
259
|
prefix="file-document"
|
|
255
|
-
label="timesheets.exportPdf"
|
|
256
260
|
onClick={() => runExport('pdf')}
|
|
257
261
|
/>
|
|
258
262
|
</ContextMenu>
|
|
@@ -16,6 +16,10 @@ import { metadata as ListTimesheets } from './list.action.js'
|
|
|
16
16
|
import { metadata as GenerateTimesheet } from './generate.action.js'
|
|
17
17
|
import { metadata as OpenNewTimesheet } from './open-new.action.js'
|
|
18
18
|
import { metadata as DeleteTimesheet } from './delete.action.js'
|
|
19
|
+
import { metadata as OpenDelete } from './open-delete.action.js'
|
|
20
|
+
import { metadata as ExportCsv } from './export-csv.action.js'
|
|
21
|
+
import { metadata as ExportPng } from './export-png.action.js'
|
|
22
|
+
import { metadata as ExportPdf } from './export-pdf.action.js'
|
|
19
23
|
import { HolidayLocaleSelect } from './HolidayLocaleSelect.jsx'
|
|
20
24
|
import { resolveHolidayLocale } from './holiday-locale.js'
|
|
21
25
|
|
|
@@ -212,6 +216,7 @@ export default function TimesheetsProductHome () {
|
|
|
212
216
|
<>
|
|
213
217
|
<Page
|
|
214
218
|
scroll
|
|
219
|
+
data-timesheets-status="on"
|
|
215
220
|
title="timesheets.home.title"
|
|
216
221
|
near={statusTags.length > 0 ? <Tags tags={statusTags} size="s" /> : undefined}
|
|
217
222
|
description="timesheets.home.description"
|
|
@@ -232,7 +237,8 @@ export default function TimesheetsProductHome () {
|
|
|
232
237
|
<Text as="h2" variant="small" weight="medium" text="timesheets.listTitle" />
|
|
233
238
|
</View.Item>
|
|
234
239
|
<Button
|
|
235
|
-
|
|
240
|
+
{...OpenNewTimesheet}
|
|
241
|
+
label={undefined}
|
|
236
242
|
variant="command"
|
|
237
243
|
prefix="add"
|
|
238
244
|
aria-label={t('timesheets.new')}
|
|
@@ -272,25 +278,26 @@ export default function TimesheetsProductHome () {
|
|
|
272
278
|
/>,
|
|
273
279
|
<ContextMenu.Item
|
|
274
280
|
key="csv"
|
|
281
|
+
{...ExportCsv}
|
|
275
282
|
prefix="list"
|
|
276
|
-
label="timesheets.exportCsv"
|
|
277
283
|
onClick={() => requestExport(sheet, 'csv')}
|
|
278
284
|
/>,
|
|
279
285
|
<ContextMenu.Item
|
|
280
286
|
key="png"
|
|
287
|
+
{...ExportPng}
|
|
281
288
|
prefix="image"
|
|
282
|
-
label="timesheets.exportPng"
|
|
283
289
|
onClick={() => requestExport(sheet, 'png')}
|
|
284
290
|
/>,
|
|
285
291
|
<ContextMenu.Item
|
|
286
292
|
key="pdf"
|
|
293
|
+
{...ExportPdf}
|
|
287
294
|
prefix="file-document"
|
|
288
|
-
label="timesheets.exportPdf"
|
|
289
295
|
onClick={() => requestExport(sheet, 'pdf')}
|
|
290
296
|
/>,
|
|
291
297
|
<ContextMenu.Separator key="separator" />,
|
|
292
298
|
<ContextMenu.Item
|
|
293
299
|
key="delete"
|
|
300
|
+
{...OpenDelete}
|
|
294
301
|
variant="command-danger"
|
|
295
302
|
prefix="trash-empty"
|
|
296
303
|
label="timesheets.delete"
|
|
@@ -309,6 +316,7 @@ export default function TimesheetsProductHome () {
|
|
|
309
316
|
timesheetId={selectedResourceId}
|
|
310
317
|
onClose={clearSelectedResourceId}
|
|
311
318
|
onSaved={refreshList}
|
|
319
|
+
onRequestDelete={setSheetToDelete}
|
|
312
320
|
/>
|
|
313
321
|
</View>
|
|
314
322
|
</Page>
|
|
@@ -328,7 +336,7 @@ export default function TimesheetsProductHome () {
|
|
|
328
336
|
onClick: () => setSheetToDelete(null),
|
|
329
337
|
},
|
|
330
338
|
{
|
|
331
|
-
|
|
339
|
+
...DeleteTimesheet,
|
|
332
340
|
variant: 'command-danger',
|
|
333
341
|
disabled: deleting,
|
|
334
342
|
onClick: confirmDelete,
|
|
@@ -410,13 +418,12 @@ export default function TimesheetsProductHome () {
|
|
|
410
418
|
onClick={closeCreateModal}
|
|
411
419
|
/>
|
|
412
420
|
<Button
|
|
413
|
-
|
|
421
|
+
{...GenerateTimesheet}
|
|
422
|
+
label={generating ? 'timesheets.loading' : GenerateTimesheet.label}
|
|
414
423
|
variant="cta"
|
|
415
424
|
disabled={generating || !selected}
|
|
416
425
|
onClick={handleGenerate}
|
|
417
|
-
|
|
418
|
-
{generating ? t('timesheets.loading') : t('timesheets.generate')}
|
|
419
|
-
</Button>
|
|
426
|
+
/>
|
|
420
427
|
</View>
|
|
421
428
|
</View>
|
|
422
429
|
</View>
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ResourcesQueries } from '@ossy/resources/server'
|
|
2
|
+
import {
|
|
3
|
+
AUTO_GENERATE_QUERY,
|
|
4
|
+
contractsNeedingTimesheets,
|
|
5
|
+
holidayLocaleForWorkspace,
|
|
6
|
+
} from './auto-generate-timesheets.js'
|
|
7
|
+
import { createTimesheetDraft } from './create-timesheet-draft.js'
|
|
8
|
+
|
|
9
|
+
export const metadata = {
|
|
10
|
+
id: '@ossy/timesheets/tasks/auto-generate',
|
|
11
|
+
// Daily at 06:00 UTC — create sheets when a billing period is open
|
|
12
|
+
schedule: '0 6 * * *',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Auto-generate draft timesheets for active consultancy contracts based on
|
|
17
|
+
* each contract's billing period (weekly / monthly). Idempotent per
|
|
18
|
+
* (workspace, contractId, periodStart).
|
|
19
|
+
*/
|
|
20
|
+
export async function run ({ log }) {
|
|
21
|
+
const now = Date.now()
|
|
22
|
+
|
|
23
|
+
const [contracts, timesheets, companies] = await Promise.all([
|
|
24
|
+
ResourcesQueries.GetResources(AUTO_GENERATE_QUERY.contracts),
|
|
25
|
+
ResourcesQueries.GetResources(AUTO_GENERATE_QUERY.timesheets),
|
|
26
|
+
ResourcesQueries.GetResources(AUTO_GENERATE_QUERY.companies),
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
const needed = contractsNeedingTimesheets({ contracts, timesheets, asOf: now })
|
|
30
|
+
log?.info(`[timesheets/tasks/auto-generate] ${needed.length} contract(s) need a sheet`)
|
|
31
|
+
|
|
32
|
+
let created = 0
|
|
33
|
+
let failed = 0
|
|
34
|
+
|
|
35
|
+
for (const item of needed) {
|
|
36
|
+
try {
|
|
37
|
+
const locale = holidayLocaleForWorkspace(companies, item.workspaceId)
|
|
38
|
+
await createTimesheetDraft({
|
|
39
|
+
workspaceId: item.workspaceId,
|
|
40
|
+
periodStart: item.periodStart,
|
|
41
|
+
periodEnd: item.periodEnd,
|
|
42
|
+
locale,
|
|
43
|
+
employeeId: item.employeeId,
|
|
44
|
+
contractId: item.contractId,
|
|
45
|
+
createdBy: metadata.id,
|
|
46
|
+
autoGenerated: true,
|
|
47
|
+
log,
|
|
48
|
+
})
|
|
49
|
+
created++
|
|
50
|
+
} catch (err) {
|
|
51
|
+
failed++
|
|
52
|
+
log?.error(
|
|
53
|
+
`[timesheets/tasks/auto-generate] failed for contract ${item.contractId}`,
|
|
54
|
+
err,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { created, failed, candidates: needed.length }
|
|
60
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { optionalReference } from './timesheet-refs.js'
|
|
2
|
+
|
|
3
|
+
export const BILLING_PERIODS = Object.freeze(['weekly', 'monthly'])
|
|
4
|
+
export const DEFAULT_BILLING_PERIOD = 'monthly'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {unknown} value
|
|
8
|
+
* @returns {'weekly' | 'monthly'}
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeBillingPeriod (value) {
|
|
11
|
+
if (value === 'weekly' || value === 'monthly') return value
|
|
12
|
+
return DEFAULT_BILLING_PERIOD
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {unknown} value — Unix ms, ISO string, or Date
|
|
17
|
+
* @returns {number | null}
|
|
18
|
+
*/
|
|
19
|
+
export function toMs (value) {
|
|
20
|
+
if (value == null || value === '') return null
|
|
21
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
22
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.getTime()
|
|
23
|
+
if (typeof value === 'string') {
|
|
24
|
+
const parsed = Date.parse(value)
|
|
25
|
+
if (!Number.isNaN(parsed)) return parsed
|
|
26
|
+
}
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Calendar-month period bounds as local midnights, returned as Unix ms.
|
|
32
|
+
* @param {number} year
|
|
33
|
+
* @param {number} monthIndex 0–11
|
|
34
|
+
*/
|
|
35
|
+
export function monthPeriodBounds (year, monthIndex) {
|
|
36
|
+
const start = new Date(year, monthIndex, 1)
|
|
37
|
+
const end = new Date(year, monthIndex + 1, 0)
|
|
38
|
+
start.setHours(0, 0, 0, 0)
|
|
39
|
+
end.setHours(0, 0, 0, 0)
|
|
40
|
+
return {
|
|
41
|
+
periodStart: start.getTime(),
|
|
42
|
+
periodEnd: end.getTime(),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* ISO-week (Mon–Sun) period bounds as local midnights, returned as Unix ms.
|
|
48
|
+
* @param {Date | number} [asOf]
|
|
49
|
+
*/
|
|
50
|
+
export function weekPeriodBounds (asOf = Date.now()) {
|
|
51
|
+
const ref = new Date(asOf)
|
|
52
|
+
ref.setHours(0, 0, 0, 0)
|
|
53
|
+
const day = ref.getDay() // 0 Sun … 6 Sat
|
|
54
|
+
const daysFromMonday = day === 0 ? 6 : day - 1
|
|
55
|
+
const start = new Date(ref)
|
|
56
|
+
start.setDate(ref.getDate() - daysFromMonday)
|
|
57
|
+
start.setHours(0, 0, 0, 0)
|
|
58
|
+
const end = new Date(start)
|
|
59
|
+
end.setDate(start.getDate() + 6)
|
|
60
|
+
end.setHours(0, 0, 0, 0)
|
|
61
|
+
return {
|
|
62
|
+
periodStart: start.getTime(),
|
|
63
|
+
periodEnd: end.getTime(),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Billing period that contains `asOf` for a contract's billing policy.
|
|
69
|
+
* @param {{ billingPeriod?: string } | null | undefined} contract
|
|
70
|
+
* @param {Date | number} [asOf]
|
|
71
|
+
*/
|
|
72
|
+
export function periodForContract (contract, asOf = Date.now()) {
|
|
73
|
+
const billingPeriod = normalizeBillingPeriod(contract?.billingPeriod)
|
|
74
|
+
if (billingPeriod === 'weekly') return weekPeriodBounds(asOf)
|
|
75
|
+
const ref = new Date(asOf)
|
|
76
|
+
return monthPeriodBounds(ref.getFullYear(), ref.getMonth())
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Contract is active on `asOf` when start ≤ asOf and (no end or end ≥ asOf day start).
|
|
81
|
+
* @param {{ startDate?: unknown, endDate?: unknown } | null | undefined} contract
|
|
82
|
+
* @param {Date | number} [asOf]
|
|
83
|
+
*/
|
|
84
|
+
export function isContractActive (contract, asOf = Date.now()) {
|
|
85
|
+
if (!contract) return false
|
|
86
|
+
const asOfMs = toMs(asOf) ?? Date.now()
|
|
87
|
+
const dayStart = new Date(asOfMs)
|
|
88
|
+
dayStart.setHours(0, 0, 0, 0)
|
|
89
|
+
const dayMs = dayStart.getTime()
|
|
90
|
+
|
|
91
|
+
const startMs = toMs(contract.startDate)
|
|
92
|
+
if (startMs != null) {
|
|
93
|
+
const startDay = new Date(startMs)
|
|
94
|
+
startDay.setHours(0, 0, 0, 0)
|
|
95
|
+
if (dayMs < startDay.getTime()) return false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const endMs = toMs(contract.endDate)
|
|
99
|
+
if (endMs != null) {
|
|
100
|
+
const endDay = new Date(endMs)
|
|
101
|
+
endDay.setHours(0, 0, 0, 0)
|
|
102
|
+
if (dayMs > endDay.getTime()) return false
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Newest timesheet for a contract + periodStart pair.
|
|
110
|
+
* @param {Array<{ id: string, createdAt?: number, content?: object }>} resources
|
|
111
|
+
* @param {string} contractId
|
|
112
|
+
* @param {number} periodStart
|
|
113
|
+
*/
|
|
114
|
+
export function findTimesheetForContractPeriod (resources, contractId, periodStart) {
|
|
115
|
+
if (!contractId || periodStart == null) return null
|
|
116
|
+
const matches = (resources || []).filter((r) => {
|
|
117
|
+
if (r.content?.periodStart !== periodStart) return false
|
|
118
|
+
const ref = optionalReference(r.content?.contractId)
|
|
119
|
+
return ref?.resourceId === contractId
|
|
120
|
+
})
|
|
121
|
+
if (matches.length === 0) return null
|
|
122
|
+
return [...matches].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))[0]
|
|
123
|
+
}
|