@ossy/timesheets 3.9.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 +5 -3
- 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/generate.task.js +8 -45
- package/src/index.js +9 -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/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.10.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.0.9",
|
|
21
|
+
"@ossy/resources": "^3.10.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": "198084eb98871876396ae625d043dd0f03aa0325"
|
|
45
47
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
findTimesheetForContractPeriod,
|
|
4
|
+
isContractActive,
|
|
5
|
+
normalizeBillingPeriod,
|
|
6
|
+
periodForContract,
|
|
7
|
+
weekPeriodBounds,
|
|
8
|
+
} from './billing-period.js'
|
|
9
|
+
|
|
10
|
+
describe('normalizeBillingPeriod', () => {
|
|
11
|
+
it('defaults unknown values to monthly', () => {
|
|
12
|
+
expect(normalizeBillingPeriod('weekly')).toBe('weekly')
|
|
13
|
+
expect(normalizeBillingPeriod('monthly')).toBe('monthly')
|
|
14
|
+
expect(normalizeBillingPeriod(undefined)).toBe('monthly')
|
|
15
|
+
expect(normalizeBillingPeriod('quarterly')).toBe('monthly')
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('weekPeriodBounds', () => {
|
|
20
|
+
it('returns Mon–Sun containing the reference day', () => {
|
|
21
|
+
// Wednesday 2026-08-19 local
|
|
22
|
+
const { periodStart, periodEnd } = weekPeriodBounds(new Date(2026, 7, 19))
|
|
23
|
+
expect(new Date(periodStart).getDay()).toBe(1) // Monday
|
|
24
|
+
expect(new Date(periodEnd).getDay()).toBe(0) // Sunday
|
|
25
|
+
expect(new Date(periodStart).getDate()).toBe(17)
|
|
26
|
+
expect(new Date(periodEnd).getDate()).toBe(23)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('treats Sunday as end of the current ISO week', () => {
|
|
30
|
+
const { periodStart, periodEnd } = weekPeriodBounds(new Date(2026, 7, 23))
|
|
31
|
+
expect(new Date(periodStart).getDate()).toBe(17)
|
|
32
|
+
expect(new Date(periodEnd).getDate()).toBe(23)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('periodForContract', () => {
|
|
37
|
+
it('uses calendar month when billingPeriod is monthly or missing', () => {
|
|
38
|
+
const asOf = new Date(2026, 7, 19)
|
|
39
|
+
expect(periodForContract({ billingPeriod: 'monthly' }, asOf)).toEqual({
|
|
40
|
+
periodStart: new Date(2026, 7, 1).setHours(0, 0, 0, 0),
|
|
41
|
+
periodEnd: new Date(2026, 7, 31).setHours(0, 0, 0, 0),
|
|
42
|
+
})
|
|
43
|
+
expect(periodForContract({}, asOf).periodStart).toBe(
|
|
44
|
+
new Date(2026, 7, 1).setHours(0, 0, 0, 0),
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('uses ISO week when billingPeriod is weekly', () => {
|
|
49
|
+
const asOf = new Date(2026, 7, 19)
|
|
50
|
+
expect(periodForContract({ billingPeriod: 'weekly' }, asOf)).toEqual(
|
|
51
|
+
weekPeriodBounds(asOf),
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('isContractActive', () => {
|
|
57
|
+
it('requires start ≤ asOf and end ≥ asOf when set', () => {
|
|
58
|
+
const asOf = new Date(2026, 7, 19)
|
|
59
|
+
expect(isContractActive({
|
|
60
|
+
startDate: new Date(2026, 0, 1).getTime(),
|
|
61
|
+
endDate: new Date(2026, 11, 31).getTime(),
|
|
62
|
+
}, asOf)).toBe(true)
|
|
63
|
+
|
|
64
|
+
expect(isContractActive({
|
|
65
|
+
startDate: new Date(2026, 8, 1).getTime(),
|
|
66
|
+
}, asOf)).toBe(false)
|
|
67
|
+
|
|
68
|
+
expect(isContractActive({
|
|
69
|
+
startDate: new Date(2026, 0, 1).getTime(),
|
|
70
|
+
endDate: new Date(2026, 6, 1).getTime(),
|
|
71
|
+
}, asOf)).toBe(false)
|
|
72
|
+
|
|
73
|
+
expect(isContractActive({
|
|
74
|
+
startDate: new Date(2026, 0, 1).getTime(),
|
|
75
|
+
}, asOf)).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('findTimesheetForContractPeriod', () => {
|
|
80
|
+
it('matches contract id + period and returns the newest', () => {
|
|
81
|
+
const resources = [
|
|
82
|
+
{ id: 'a', createdAt: 1, content: { periodStart: 100, contractId: 'c1' } },
|
|
83
|
+
{ id: 'b', createdAt: 3, content: { periodStart: 100, contractId: { resourceId: 'c1' } } },
|
|
84
|
+
{ id: 'c', createdAt: 9, content: { periodStart: 100, contractId: 'c2' } },
|
|
85
|
+
]
|
|
86
|
+
expect(findTimesheetForContractPeriod(resources, 'c1', 100)?.id).toBe('b')
|
|
87
|
+
expect(findTimesheetForContractPeriod(resources, 'c1', 999)).toBe(null)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -1,25 +1,12 @@
|
|
|
1
1
|
import { CalendarClient } from '@ossy/calendar'
|
|
2
2
|
import { DEFAULT_HOLIDAY_LOCALE, resolveHolidayLocale } from './holiday-locale.js'
|
|
3
|
+
import { monthPeriodBounds } from './billing-period.js'
|
|
4
|
+
|
|
5
|
+
export { monthPeriodBounds }
|
|
3
6
|
|
|
4
7
|
/** Prefill hours for a workday (SPEC). */
|
|
5
8
|
export const WORKDAY_HOURS = 8
|
|
6
9
|
|
|
7
|
-
/**
|
|
8
|
-
* Calendar-month period bounds as local midnights, returned as Unix ms.
|
|
9
|
-
* @param {number} year
|
|
10
|
-
* @param {number} monthIndex 0–11
|
|
11
|
-
*/
|
|
12
|
-
export function monthPeriodBounds (year, monthIndex) {
|
|
13
|
-
const start = new Date(year, monthIndex, 1)
|
|
14
|
-
const end = new Date(year, monthIndex + 1, 0)
|
|
15
|
-
start.setHours(0, 0, 0, 0)
|
|
16
|
-
end.setHours(0, 0, 0, 0)
|
|
17
|
-
return {
|
|
18
|
-
periodStart: start.getTime(),
|
|
19
|
-
periodEnd: end.getTime(),
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
10
|
/**
|
|
24
11
|
* Build prefilled timesheet lines for an inclusive period.
|
|
25
12
|
* Workdays → 8h; non-workdays → 0h. `isWorkday` is snapshotted.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid'
|
|
2
|
+
import { ResourcesEvents, commitResource } from '@ossy/resources/server'
|
|
3
|
+
import { TimesheetsSchema } from './schema-ids.js'
|
|
4
|
+
import { timesheets } from './locations.js'
|
|
5
|
+
import { buildPrefilledLines } from './build-prefilled-lines.js'
|
|
6
|
+
import { DEFAULT_HOLIDAY_LOCALE, resolveHolidayLocale } from './holiday-locale.js'
|
|
7
|
+
import { toTimesheetDto } from './timesheet-dto.js'
|
|
8
|
+
import { optionalReference } from './timesheet-refs.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create a draft timesheet resource (shared by manual generate and auto-generate).
|
|
12
|
+
*
|
|
13
|
+
* @param {object} options
|
|
14
|
+
* @param {string} options.workspaceId
|
|
15
|
+
* @param {number} options.periodStart
|
|
16
|
+
* @param {number} options.periodEnd
|
|
17
|
+
* @param {string} [options.locale]
|
|
18
|
+
* @param {unknown} [options.employeeId]
|
|
19
|
+
* @param {unknown} [options.contractId]
|
|
20
|
+
* @param {string} [options.createdBy]
|
|
21
|
+
* @param {boolean} [options.autoGenerated]
|
|
22
|
+
* @param {{ info?: Function }} [options.log]
|
|
23
|
+
*/
|
|
24
|
+
export async function createTimesheetDraft ({
|
|
25
|
+
workspaceId,
|
|
26
|
+
periodStart,
|
|
27
|
+
periodEnd,
|
|
28
|
+
locale,
|
|
29
|
+
employeeId: employeeIdRaw,
|
|
30
|
+
contractId: contractIdRaw,
|
|
31
|
+
createdBy = 'system',
|
|
32
|
+
autoGenerated = false,
|
|
33
|
+
log,
|
|
34
|
+
} = {}) {
|
|
35
|
+
if (!workspaceId) {
|
|
36
|
+
throw Object.assign(new Error('workspaceId is required'), { status: 400 })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
periodStart = Number(periodStart)
|
|
40
|
+
periodEnd = Number(periodEnd)
|
|
41
|
+
|
|
42
|
+
if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd) || periodEnd < periodStart) {
|
|
43
|
+
throw Object.assign(new Error('invalid period'), { status: 400 })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const holidayLocale = resolveHolidayLocale(locale, DEFAULT_HOLIDAY_LOCALE)
|
|
47
|
+
const employeeId = optionalReference(employeeIdRaw)
|
|
48
|
+
const contractId = optionalReference(contractIdRaw)
|
|
49
|
+
|
|
50
|
+
log?.info?.(
|
|
51
|
+
`[timesheets] create draft ${periodStart}–${periodEnd} for workspace ${workspaceId} (locale ${holidayLocale})`,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const lines = buildPrefilledLines(periodStart, periodEnd, holidayLocale)
|
|
55
|
+
const content = {
|
|
56
|
+
periodStart,
|
|
57
|
+
periodEnd,
|
|
58
|
+
status: 'draft',
|
|
59
|
+
holidayLocale,
|
|
60
|
+
lines,
|
|
61
|
+
...(employeeId ? { employeeId } : {}),
|
|
62
|
+
...(contractId ? { contractId } : {}),
|
|
63
|
+
...(autoGenerated ? { autoGenerated: true } : {}),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const resourceId = nanoid()
|
|
67
|
+
const event = ResourcesEvents.Created({
|
|
68
|
+
resourceId,
|
|
69
|
+
schemaId: TimesheetsSchema.timesheet,
|
|
70
|
+
createdBy,
|
|
71
|
+
belongsTo: workspaceId,
|
|
72
|
+
location: timesheets,
|
|
73
|
+
name: `timesheet-${periodStart}-${resourceId}.json`,
|
|
74
|
+
content,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const created = await commitResource(event)
|
|
78
|
+
log?.info?.(`[timesheets] Created timesheet ${resourceId}`)
|
|
79
|
+
|
|
80
|
+
return toTimesheetDto({ id: resourceId, content, ...created })
|
|
81
|
+
}
|
package/src/generate.task.js
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { TimesheetsSchema } from './schema-ids.js'
|
|
4
|
-
import { timesheets } from './locations.js'
|
|
5
|
-
import { buildPrefilledLines, monthPeriodBounds } from './build-prefilled-lines.js'
|
|
6
|
-
import { DEFAULT_HOLIDAY_LOCALE, resolveHolidayLocale } from './holiday-locale.js'
|
|
7
|
-
import { toTimesheetDto, optionalReference } from './timesheet-resources.js'
|
|
1
|
+
import { monthPeriodBounds } from './billing-period.js'
|
|
2
|
+
import { createTimesheetDraft } from './create-timesheet-draft.js'
|
|
8
3
|
|
|
9
4
|
export const metadata = { id: '@ossy/timesheets/tasks/generate' }
|
|
10
5
|
|
|
@@ -37,46 +32,14 @@ export async function run ({ payload, req, log }) {
|
|
|
37
32
|
;({ periodStart, periodEnd } = monthPeriodBounds(Number(year), Number(monthIndex)))
|
|
38
33
|
}
|
|
39
34
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd) || periodEnd < periodStart) {
|
|
44
|
-
throw Object.assign(new Error('invalid period'), { status: 400 })
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const holidayLocale = resolveHolidayLocale(payloadLocale, DEFAULT_HOLIDAY_LOCALE)
|
|
48
|
-
|
|
49
|
-
log?.info(
|
|
50
|
-
`[timesheets/tasks/generate] period ${periodStart}–${periodEnd} for workspace ${workspaceId} (locale ${holidayLocale})`,
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
const employeeId = optionalReference(payload?.employeeId)
|
|
54
|
-
const contractId = optionalReference(payload?.contractId)
|
|
55
|
-
|
|
56
|
-
const lines = buildPrefilledLines(periodStart, periodEnd, holidayLocale)
|
|
57
|
-
const content = {
|
|
35
|
+
return createTimesheetDraft({
|
|
36
|
+
workspaceId,
|
|
58
37
|
periodStart,
|
|
59
38
|
periodEnd,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
...(employeeId ? { employeeId } : {}),
|
|
64
|
-
...(contractId ? { contractId } : {}),
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const resourceId = nanoid()
|
|
68
|
-
const event = ResourcesEvents.Created({
|
|
69
|
-
resourceId,
|
|
70
|
-
schemaId: TimesheetsSchema.timesheet,
|
|
39
|
+
locale: payloadLocale,
|
|
40
|
+
employeeId: payload?.employeeId,
|
|
41
|
+
contractId: payload?.contractId,
|
|
71
42
|
createdBy: req?.userId ?? 'system',
|
|
72
|
-
|
|
73
|
-
location: timesheets,
|
|
74
|
-
name: `timesheet-${periodStart}-${resourceId}.json`,
|
|
75
|
-
content,
|
|
43
|
+
log,
|
|
76
44
|
})
|
|
77
|
-
|
|
78
|
-
const created = await commitResource(event)
|
|
79
|
-
log?.info(`[timesheets/tasks/generate] Created timesheet ${resourceId}`)
|
|
80
|
-
|
|
81
|
-
return toTimesheetDto({ id: resourceId, content, ...created })
|
|
82
45
|
}
|
package/src/index.js
CHANGED
|
@@ -16,6 +16,15 @@ export { downloadTimesheetCsv } from './download-timesheet-csv.js'
|
|
|
16
16
|
export { downloadTimesheetImage } from './download-timesheet-image.js'
|
|
17
17
|
export { downloadTimesheetPdf } from './download-timesheet-pdf.js'
|
|
18
18
|
export { buildPrefilledLines, monthPeriodBounds, WORKDAY_HOURS } from './build-prefilled-lines.js'
|
|
19
|
+
export {
|
|
20
|
+
BILLING_PERIODS,
|
|
21
|
+
DEFAULT_BILLING_PERIOD,
|
|
22
|
+
findTimesheetForContractPeriod,
|
|
23
|
+
isContractActive,
|
|
24
|
+
normalizeBillingPeriod,
|
|
25
|
+
periodForContract,
|
|
26
|
+
weekPeriodBounds,
|
|
27
|
+
} from './billing-period.js'
|
|
19
28
|
export { resolveHolidayLocale, DEFAULT_HOLIDAY_LOCALE, HOLIDAY_COUNTRY_CODES } from './holiday-locale.js'
|
|
20
29
|
export { Timesheet } from './Timesheet.jsx'
|
|
21
30
|
export { TimesheetExportCard } from './TimesheetExportCard.jsx'
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { viewResource } from '@ossy/resources/server'
|
|
2
|
+
import { TimesheetsSchema } from './schema-ids.js'
|
|
3
|
+
import TimesheetReminderEmail, { subject as assignedSubject } from './timesheet-reminder.email.jsx'
|
|
4
|
+
import {
|
|
5
|
+
formatPeriodLabel,
|
|
6
|
+
resolveTimesheetReminderRecipient,
|
|
7
|
+
shouldNotifyTimesheetCreated,
|
|
8
|
+
timesheetAssignedUrl,
|
|
9
|
+
} from './timesheet-reminder.js'
|
|
10
|
+
import { optionalReference } from './timesheet-refs.js'
|
|
11
|
+
|
|
12
|
+
export const metadata = {
|
|
13
|
+
id: '@ossy/timesheets/tasks/send-reminder',
|
|
14
|
+
triggers: [
|
|
15
|
+
{ type: TimesheetsSchema.timesheet, event: 'Created' },
|
|
16
|
+
],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Email the linked employee when an auto-generated timesheet is created.
|
|
21
|
+
* Manual generate does not notify. Created fires once, so there is no daily cron.
|
|
22
|
+
*/
|
|
23
|
+
export async function run ({ event, log, integrations }) {
|
|
24
|
+
if (!shouldNotifyTimesheetCreated(event)) {
|
|
25
|
+
return { sent: 0, skipped: 1 }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const content = event.payload?.content ?? {}
|
|
29
|
+
const resourceId = event.resourceId
|
|
30
|
+
const timesheet = { id: resourceId, belongsTo: event.payload?.belongsTo, content }
|
|
31
|
+
|
|
32
|
+
const employeeId = optionalReference(content.employeeId)?.resourceId
|
|
33
|
+
const contractId = optionalReference(content.contractId)?.resourceId
|
|
34
|
+
|
|
35
|
+
const [employee, contract] = await Promise.all([
|
|
36
|
+
employeeId ? viewResource(employeeId).catch(() => null) : null,
|
|
37
|
+
contractId ? viewResource(contractId).catch(() => null) : null,
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
const recipient = resolveTimesheetReminderRecipient(
|
|
41
|
+
employee ? [employee] : [],
|
|
42
|
+
timesheet,
|
|
43
|
+
)
|
|
44
|
+
if (!recipient) {
|
|
45
|
+
log?.warn(`[timesheets/tasks/send-reminder] no employee email for timesheet ${resourceId}`)
|
|
46
|
+
return { sent: 0, skipped: 1 }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const emailClient = integrations?.get?.('email')
|
|
50
|
+
if (!emailClient) {
|
|
51
|
+
log?.warn('[timesheets/tasks/send-reminder] No email integration available, skipping')
|
|
52
|
+
return { sent: 0, skipped: 1 }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const clientName = contract?.content?.clientName ?? null
|
|
56
|
+
const periodLabel = formatPeriodLabel(content.periodStart, content.periodEnd)
|
|
57
|
+
const timesheetsUrl = timesheetAssignedUrl(resourceId)
|
|
58
|
+
|
|
59
|
+
await emailClient.sendTemplate(
|
|
60
|
+
TimesheetReminderEmail,
|
|
61
|
+
{
|
|
62
|
+
employeeName: recipient.name,
|
|
63
|
+
clientName,
|
|
64
|
+
periodLabel,
|
|
65
|
+
timesheetsUrl,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
to: recipient.email,
|
|
69
|
+
from: 'noreply@ossy.se',
|
|
70
|
+
subject: assignedSubject,
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
log?.info(`[timesheets/tasks/send-reminder] Assigned-timesheet email sent to ${recipient.email}`)
|
|
75
|
+
return { sent: 1, skipped: 0 }
|
|
76
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { optionalReference } from './timesheet-refs.js'
|
|
2
|
+
|
|
3
|
+
export { optionalReference } from './timesheet-refs.js'
|
|
4
|
+
|
|
5
|
+
/** Newest sheet for a period. Generate always creates a new resource; use get-by-id for a specific sheet. */
|
|
6
|
+
export function findTimesheetForPeriod (resources, periodStart) {
|
|
7
|
+
const matches = (resources || []).filter((r) => r.content?.periodStart === periodStart)
|
|
8
|
+
if (matches.length === 0) return null
|
|
9
|
+
return [...matches].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))[0]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function toTimesheetDto (resource) {
|
|
13
|
+
if (!resource) return null
|
|
14
|
+
return {
|
|
15
|
+
id: resource.id,
|
|
16
|
+
periodStart: resource.content?.periodStart,
|
|
17
|
+
periodEnd: resource.content?.periodEnd,
|
|
18
|
+
status: resource.content?.status ?? 'draft',
|
|
19
|
+
holidayLocale: resource.content?.holidayLocale ?? null,
|
|
20
|
+
employeeId: optionalReference(resource.content?.employeeId),
|
|
21
|
+
contractId: optionalReference(resource.content?.contractId),
|
|
22
|
+
lines: resource.content?.lines ?? [],
|
|
23
|
+
createdAt: resource.createdAt,
|
|
24
|
+
updatedAt: resource.updatedAt,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize an optional document reference to `{ resourceId }` or `null`.
|
|
3
|
+
* Accepts a stored object, a plain id string, or empty.
|
|
4
|
+
*/
|
|
5
|
+
export function optionalReference (value) {
|
|
6
|
+
if (value == null || value === '') return null
|
|
7
|
+
if (typeof value === 'string') {
|
|
8
|
+
const id = value.trim()
|
|
9
|
+
return id ? { resourceId: id } : null
|
|
10
|
+
}
|
|
11
|
+
if (typeof value === 'object' && typeof value.resourceId === 'string') {
|
|
12
|
+
const id = value.resourceId.trim()
|
|
13
|
+
return id ? { resourceId: id } : null
|
|
14
|
+
}
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { EmailLayout, EmailButton, EmailText } from '@ossy/email'
|
|
3
|
+
|
|
4
|
+
export const id = '@ossy/timesheets/emails/reminder'
|
|
5
|
+
export const subject = 'En tidrapport har skapats åt dig'
|
|
6
|
+
|
|
7
|
+
export default function TimesheetReminderEmail ({
|
|
8
|
+
employeeName,
|
|
9
|
+
clientName,
|
|
10
|
+
periodLabel,
|
|
11
|
+
timesheetsUrl,
|
|
12
|
+
}) {
|
|
13
|
+
const greeting = employeeName ? `Hej ${employeeName},` : 'Hej,'
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<EmailLayout>
|
|
17
|
+
<h1 style={{ color: '#111111', fontSize: 24, fontWeight: 700, margin: '0 0 24px' }}>
|
|
18
|
+
Du har tilldelats en tidrapport
|
|
19
|
+
</h1>
|
|
20
|
+
|
|
21
|
+
<EmailText>{greeting}</EmailText>
|
|
22
|
+
|
|
23
|
+
<EmailText>
|
|
24
|
+
En tidrapport har skapats åt dig
|
|
25
|
+
{clientName ? (
|
|
26
|
+
<>
|
|
27
|
+
{' '}för uppdraget <strong>{clientName}</strong>
|
|
28
|
+
</>
|
|
29
|
+
) : null}
|
|
30
|
+
{periodLabel ? (
|
|
31
|
+
<>
|
|
32
|
+
{' '}för perioden <strong>{periodLabel}</strong>
|
|
33
|
+
</>
|
|
34
|
+
) : null}
|
|
35
|
+
. Kontrollera timmarna och spara när de stämmer.
|
|
36
|
+
</EmailText>
|
|
37
|
+
|
|
38
|
+
{timesheetsUrl ? (
|
|
39
|
+
<EmailButton href={timesheetsUrl}>Öppna tidrapporten</EmailButton>
|
|
40
|
+
) : null}
|
|
41
|
+
|
|
42
|
+
<EmailText style={{ color: '#888888', fontSize: 13 }}>
|
|
43
|
+
Du får det här mejlet för att en tidrapport skapades automatiskt utifrån ditt avtal.
|
|
44
|
+
</EmailText>
|
|
45
|
+
</EmailLayout>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { isEmailLike } from '@ossy/schema'
|
|
2
|
+
import { optionalReference } from './timesheet-refs.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether a timesheet Created event should notify the assigned employee.
|
|
6
|
+
* Manual generate does not email; auto-generate does, once, on create.
|
|
7
|
+
*
|
|
8
|
+
* @param {{ payload?: { content?: object }, resourceId?: string } | null | undefined} event
|
|
9
|
+
*/
|
|
10
|
+
export function shouldNotifyTimesheetCreated (event) {
|
|
11
|
+
const content = event?.payload?.content ?? {}
|
|
12
|
+
return content.autoGenerated === true && Boolean(event?.resourceId)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve reminder recipient from the linked employee resource.
|
|
17
|
+
*
|
|
18
|
+
* @param {Array<{ id: string, content?: { name?: string, email?: string } }>} employees
|
|
19
|
+
* @param {{ content?: { employeeId?: unknown } }} timesheet
|
|
20
|
+
* @returns {{ email: string, name: string | null } | null}
|
|
21
|
+
*/
|
|
22
|
+
export function resolveTimesheetReminderRecipient (employees, timesheet) {
|
|
23
|
+
const employeeId = optionalReference(timesheet?.content?.employeeId)?.resourceId
|
|
24
|
+
if (!employeeId) return null
|
|
25
|
+
|
|
26
|
+
const employee = (employees || []).find((e) => e.id === employeeId)
|
|
27
|
+
const email = employee?.content?.email?.trim?.() ?? employee?.content?.email
|
|
28
|
+
if (!isEmailLike(email)) return null
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
email: String(email).trim(),
|
|
32
|
+
name: employee?.content?.name ?? null,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Public timesheets home URL, optionally opening a specific sheet (`?r=`).
|
|
38
|
+
* Swedish email copy uses `/tidrapporter`.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} [resourceId]
|
|
41
|
+
* @param {string} [origin]
|
|
42
|
+
*/
|
|
43
|
+
export function timesheetAssignedUrl (resourceId, origin = process.env.WEB_CLIENT_DOMAIN) {
|
|
44
|
+
const base = String(origin || '').replace(/\/$/, '')
|
|
45
|
+
if (!base) return null
|
|
46
|
+
const path = '/tidrapporter'
|
|
47
|
+
if (!resourceId) return `${base}${path}`
|
|
48
|
+
return `${base}${path}?r=${encodeURIComponent(resourceId)}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Format a period for email copy (local calendar dates).
|
|
53
|
+
* @param {number} periodStart
|
|
54
|
+
* @param {number} periodEnd
|
|
55
|
+
* @param {string} [locale]
|
|
56
|
+
*/
|
|
57
|
+
export function formatPeriodLabel (periodStart, periodEnd, locale = 'sv-SE') {
|
|
58
|
+
const start = new Date(periodStart)
|
|
59
|
+
const end = new Date(periodEnd)
|
|
60
|
+
const opts = { year: 'numeric', month: 'long', day: 'numeric' }
|
|
61
|
+
return `${start.toLocaleDateString(locale, opts)} – ${end.toLocaleDateString(locale, opts)}`
|
|
62
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
formatPeriodLabel,
|
|
4
|
+
resolveTimesheetReminderRecipient,
|
|
5
|
+
shouldNotifyTimesheetCreated,
|
|
6
|
+
timesheetAssignedUrl,
|
|
7
|
+
} from './timesheet-reminder.js'
|
|
8
|
+
|
|
9
|
+
describe('shouldNotifyTimesheetCreated', () => {
|
|
10
|
+
it('notifies only auto-generated creates', () => {
|
|
11
|
+
expect(shouldNotifyTimesheetCreated({
|
|
12
|
+
resourceId: 'sheet-1',
|
|
13
|
+
payload: { content: { autoGenerated: true } },
|
|
14
|
+
})).toBe(true)
|
|
15
|
+
|
|
16
|
+
expect(shouldNotifyTimesheetCreated({
|
|
17
|
+
resourceId: 'sheet-2',
|
|
18
|
+
payload: { content: { autoGenerated: false } },
|
|
19
|
+
})).toBe(false)
|
|
20
|
+
|
|
21
|
+
expect(shouldNotifyTimesheetCreated({
|
|
22
|
+
resourceId: 'sheet-3',
|
|
23
|
+
payload: { content: {} },
|
|
24
|
+
})).toBe(false)
|
|
25
|
+
|
|
26
|
+
expect(shouldNotifyTimesheetCreated({
|
|
27
|
+
payload: { content: { autoGenerated: true } },
|
|
28
|
+
})).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('resolveTimesheetReminderRecipient', () => {
|
|
33
|
+
it('requires a linked employee with a valid email', () => {
|
|
34
|
+
const employees = [
|
|
35
|
+
{ id: 'emp-1', content: { name: 'Ada', email: 'ada@example.com' } },
|
|
36
|
+
{ id: 'emp-2', content: { name: 'Bob', email: 'not-an-email' } },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
expect(resolveTimesheetReminderRecipient(employees, {
|
|
40
|
+
content: { employeeId: 'emp-1' },
|
|
41
|
+
})).toEqual({ email: 'ada@example.com', name: 'Ada' })
|
|
42
|
+
|
|
43
|
+
expect(resolveTimesheetReminderRecipient(employees, {
|
|
44
|
+
content: { employeeId: 'emp-2' },
|
|
45
|
+
})).toBe(null)
|
|
46
|
+
|
|
47
|
+
expect(resolveTimesheetReminderRecipient(employees, {
|
|
48
|
+
content: {},
|
|
49
|
+
})).toBe(null)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('timesheetAssignedUrl', () => {
|
|
54
|
+
it('builds the Swedish home URL with an optional sheet query', () => {
|
|
55
|
+
expect(timesheetAssignedUrl(undefined, 'https://app.ossy.se/')).toBe(
|
|
56
|
+
'https://app.ossy.se/tidrapporter',
|
|
57
|
+
)
|
|
58
|
+
expect(timesheetAssignedUrl('sheet-1', 'https://app.ossy.se')).toBe(
|
|
59
|
+
'https://app.ossy.se/tidrapporter?r=sheet-1',
|
|
60
|
+
)
|
|
61
|
+
expect(timesheetAssignedUrl('sheet-1', '')).toBe(null)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('formatPeriodLabel', () => {
|
|
66
|
+
it('formats inclusive local dates', () => {
|
|
67
|
+
const label = formatPeriodLabel(
|
|
68
|
+
new Date(2026, 7, 1).getTime(),
|
|
69
|
+
new Date(2026, 7, 31).getTime(),
|
|
70
|
+
'en-US',
|
|
71
|
+
)
|
|
72
|
+
expect(label).toContain('2026')
|
|
73
|
+
expect(label).toContain('–')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { ResourcesQueries } from '@ossy/resources/server'
|
|
2
2
|
|
|
3
|
+
export {
|
|
4
|
+
optionalReference,
|
|
5
|
+
findTimesheetForPeriod,
|
|
6
|
+
toTimesheetDto,
|
|
7
|
+
} from './timesheet-dto.js'
|
|
8
|
+
|
|
3
9
|
/**
|
|
4
10
|
* List timesheet resources by schema id (and optional workspace).
|
|
5
11
|
*/
|
|
@@ -8,43 +14,3 @@ export function getTimesheetResources ({ schemaId, belongsTo } = {}) {
|
|
|
8
14
|
if (belongsTo) query.belongsTo = belongsTo
|
|
9
15
|
return ResourcesQueries.GetResources(query)
|
|
10
16
|
}
|
|
11
|
-
|
|
12
|
-
/** Newest sheet for a period. Generate always creates a new resource; use get-by-id for a specific sheet. */
|
|
13
|
-
export function findTimesheetForPeriod (resources, periodStart) {
|
|
14
|
-
const matches = (resources || []).filter((r) => r.content?.periodStart === periodStart)
|
|
15
|
-
if (matches.length === 0) return null
|
|
16
|
-
return [...matches].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))[0]
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Normalize an optional document reference to `{ resourceId }` or `null`.
|
|
21
|
-
* Accepts a stored object, a plain id string, or empty.
|
|
22
|
-
*/
|
|
23
|
-
export function optionalReference (value) {
|
|
24
|
-
if (value == null || value === '') return null
|
|
25
|
-
if (typeof value === 'string') {
|
|
26
|
-
const id = value.trim()
|
|
27
|
-
return id ? { resourceId: id } : null
|
|
28
|
-
}
|
|
29
|
-
if (typeof value === 'object' && typeof value.resourceId === 'string') {
|
|
30
|
-
const id = value.resourceId.trim()
|
|
31
|
-
return id ? { resourceId: id } : null
|
|
32
|
-
}
|
|
33
|
-
return null
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function toTimesheetDto (resource) {
|
|
37
|
-
if (!resource) return null
|
|
38
|
-
return {
|
|
39
|
-
id: resource.id,
|
|
40
|
-
periodStart: resource.content?.periodStart,
|
|
41
|
-
periodEnd: resource.content?.periodEnd,
|
|
42
|
-
status: resource.content?.status ?? 'draft',
|
|
43
|
-
holidayLocale: resource.content?.holidayLocale ?? null,
|
|
44
|
-
employeeId: optionalReference(resource.content?.employeeId),
|
|
45
|
-
contractId: optionalReference(resource.content?.contractId),
|
|
46
|
-
lines: resource.content?.lines ?? [],
|
|
47
|
-
createdAt: resource.createdAt,
|
|
48
|
-
updatedAt: resource.updatedAt,
|
|
49
|
-
}
|
|
50
|
-
}
|
package/src/timesheet.schema.js
CHANGED
|
@@ -38,6 +38,11 @@ export default {
|
|
|
38
38
|
of: TimesheetRefs.contract,
|
|
39
39
|
description: 'Optional contract this timesheet belongs to',
|
|
40
40
|
},
|
|
41
|
+
{
|
|
42
|
+
name: 'autoGenerated',
|
|
43
|
+
type: 'boolean',
|
|
44
|
+
description: 'Set when the daily auto-generate cron created this sheet',
|
|
45
|
+
},
|
|
41
46
|
// `lines` is stored in content (array of day rows) — same nested-content pattern as booking availability.
|
|
42
47
|
],
|
|
43
48
|
}
|