@ossy/timesheets 3.8.0 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -3
- package/src/Timesheet.jsx +36 -1
- package/src/TimesheetCard.jsx +57 -18
- package/src/TimesheetExportCard.jsx +56 -1
- package/src/TimesheetPanel.jsx +249 -0
- package/src/TimesheetPartyFields.jsx +121 -0
- package/src/TimesheetPartyRows.jsx +101 -0
- package/src/TimesheetsProductHome.jsx +257 -69
- package/src/auto-generate-timesheets.js +70 -0
- package/src/auto-generate-timesheets.spec.js +90 -0
- package/src/auto-generate-timesheets.task.js +60 -0
- package/src/billing-period.js +123 -0
- package/src/billing-period.spec.js +89 -0
- package/src/build-prefilled-lines.js +3 -16
- package/src/create-timesheet-draft.js +81 -0
- package/src/delete.action.js +5 -0
- package/src/delete.task.js +42 -0
- package/src/download-timesheet-csv.js +20 -1
- package/src/en.translations.json +23 -3
- package/src/generate.task.js +13 -71
- package/src/get.task.js +1 -1
- package/src/index.js +12 -1
- package/src/list.task.js +5 -1
- package/src/save.task.js +16 -5
- package/src/schema-ids.js +6 -0
- package/src/send-timesheet-reminder.task.js +76 -0
- package/src/sv.translations.json +23 -3
- package/src/timesheet-detail.page.jsx +10 -193
- package/src/timesheet-dto.js +26 -0
- package/src/timesheet-parties.js +57 -0
- package/src/timesheet-parties.spec.js +40 -0
- package/src/timesheet-refs.js +16 -0
- package/src/timesheet-reminder.email.jsx +47 -0
- package/src/timesheet-reminder.js +62 -0
- package/src/timesheet-reminder.spec.js +75 -0
- package/src/timesheet-resources.js +6 -18
- package/src/timesheet-resources.spec.js +59 -0
- package/src/timesheet.schema.js +20 -1
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -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
|
}
|
package/src/generate.task.js
CHANGED
|
@@ -1,24 +1,16 @@
|
|
|
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 {
|
|
8
|
-
findTimesheetForPeriod,
|
|
9
|
-
getTimesheetResources,
|
|
10
|
-
toTimesheetDto,
|
|
11
|
-
} from './timesheet-resources.js'
|
|
1
|
+
import { monthPeriodBounds } from './billing-period.js'
|
|
2
|
+
import { createTimesheetDraft } from './create-timesheet-draft.js'
|
|
12
3
|
|
|
13
4
|
export const metadata = { id: '@ossy/timesheets/tasks/generate' }
|
|
14
5
|
|
|
15
6
|
/**
|
|
16
|
-
* Generate
|
|
7
|
+
* Generate a new timesheet for a calendar month.
|
|
17
8
|
*
|
|
18
|
-
* Payload: `{ year, monthIndex, locale? }` (monthIndex 0–11)
|
|
9
|
+
* Payload: `{ year, monthIndex, locale?, employeeId?, contractId? }` (monthIndex 0–11)
|
|
10
|
+
* OR `{ periodStart, periodEnd, locale?, employeeId?, contractId? }`.
|
|
19
11
|
* `locale` is an ISO country code or BCP 47 tag used for workday/holiday prefill.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
12
|
+
* Always creates a new draft — multiple sheets may share the same period.
|
|
13
|
+
* Optional `employeeId` / `contractId` are stored as `{ resourceId }` references.
|
|
22
14
|
*/
|
|
23
15
|
export async function run ({ payload, req, log }) {
|
|
24
16
|
const workspaceId = req?.workspaceId
|
|
@@ -40,64 +32,14 @@ export async function run ({ payload, req, log }) {
|
|
|
40
32
|
;({ periodStart, periodEnd } = monthPeriodBounds(Number(year), Number(monthIndex)))
|
|
41
33
|
}
|
|
42
34
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd) || periodEnd < periodStart) {
|
|
47
|
-
throw Object.assign(new Error('invalid period'), { status: 400 })
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const holidayLocale = resolveHolidayLocale(payloadLocale, DEFAULT_HOLIDAY_LOCALE)
|
|
51
|
-
|
|
52
|
-
log?.info(
|
|
53
|
-
`[timesheets/tasks/generate] period ${periodStart}–${periodEnd} for workspace ${workspaceId} (locale ${holidayLocale})`,
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
const resources = await getTimesheetResources({
|
|
57
|
-
schemaId: TimesheetsSchema.timesheet,
|
|
58
|
-
belongsTo: workspaceId,
|
|
59
|
-
})
|
|
60
|
-
const existing = findTimesheetForPeriod(resources, periodStart)
|
|
61
|
-
|
|
62
|
-
if (existing?.content?.status === 'saved') {
|
|
63
|
-
log?.info(`[timesheets/tasks/generate] Returning saved timesheet ${existing.id}`)
|
|
64
|
-
return toTimesheetDto(existing)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const lines = buildPrefilledLines(periodStart, periodEnd, holidayLocale)
|
|
68
|
-
const content = {
|
|
35
|
+
return createTimesheetDraft({
|
|
36
|
+
workspaceId,
|
|
69
37
|
periodStart,
|
|
70
38
|
periodEnd,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (existing) {
|
|
77
|
-
log?.info(`[timesheets/tasks/generate] Regenerating draft ${existing.id}`)
|
|
78
|
-
await mutateResource(
|
|
79
|
-
existing.id,
|
|
80
|
-
ResourcesEvents.Patched({
|
|
81
|
-
createdBy: req?.userId ?? 'system',
|
|
82
|
-
content,
|
|
83
|
-
}),
|
|
84
|
-
)
|
|
85
|
-
return toTimesheetDto({ ...existing, content })
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const resourceId = nanoid()
|
|
89
|
-
const event = ResourcesEvents.Created({
|
|
90
|
-
resourceId,
|
|
91
|
-
schemaId: TimesheetsSchema.timesheet,
|
|
39
|
+
locale: payloadLocale,
|
|
40
|
+
employeeId: payload?.employeeId,
|
|
41
|
+
contractId: payload?.contractId,
|
|
92
42
|
createdBy: req?.userId ?? 'system',
|
|
93
|
-
|
|
94
|
-
location: timesheets,
|
|
95
|
-
name: `timesheet-${periodStart}.json`,
|
|
96
|
-
content,
|
|
43
|
+
log,
|
|
97
44
|
})
|
|
98
|
-
|
|
99
|
-
const created = await commitResource(event)
|
|
100
|
-
log?.info(`[timesheets/tasks/generate] Created timesheet ${resourceId}`)
|
|
101
|
-
|
|
102
|
-
return toTimesheetDto({ id: resourceId, content, ...created })
|
|
103
45
|
}
|
package/src/get.task.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
export const metadata = { id: '@ossy/timesheets/tasks/get' }
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Get a timesheet by `{ timesheetId }` or `{ periodStart }
|
|
12
|
+
* Get a timesheet by `{ timesheetId }` or `{ periodStart }` (newest sheet for that period).
|
|
13
13
|
*/
|
|
14
14
|
export async function run ({ payload, req, log }) {
|
|
15
15
|
const workspaceId = req?.workspaceId
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { Definition } from './Definition.js'
|
|
2
|
-
export { TimesheetsSchema } from './schema-ids.js'
|
|
2
|
+
export { TimesheetsSchema, TimesheetRefs } from './schema-ids.js'
|
|
3
3
|
export {
|
|
4
4
|
location,
|
|
5
5
|
timesheets,
|
|
@@ -9,15 +9,26 @@ export { metadata as GenerateTimesheet } from './generate.action.js'
|
|
|
9
9
|
export { metadata as SaveTimesheet } from './save.action.js'
|
|
10
10
|
export { metadata as ListTimesheets } from './list.action.js'
|
|
11
11
|
export { metadata as GetTimesheet } from './get.action.js'
|
|
12
|
+
export { metadata as DeleteTimesheet } from './delete.action.js'
|
|
12
13
|
export { metadata as OpenNewTimesheet } from './open-new.action.js'
|
|
13
14
|
export { invokeErrorMessage } from './invoke-error-message.js'
|
|
14
15
|
export { downloadTimesheetCsv } from './download-timesheet-csv.js'
|
|
15
16
|
export { downloadTimesheetImage } from './download-timesheet-image.js'
|
|
16
17
|
export { downloadTimesheetPdf } from './download-timesheet-pdf.js'
|
|
17
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'
|
|
18
28
|
export { resolveHolidayLocale, DEFAULT_HOLIDAY_LOCALE, HOLIDAY_COUNTRY_CODES } from './holiday-locale.js'
|
|
19
29
|
export { Timesheet } from './Timesheet.jsx'
|
|
20
30
|
export { TimesheetExportCard } from './TimesheetExportCard.jsx'
|
|
21
31
|
export { TimesheetsFreeTool } from './TimesheetsFreeTool.jsx'
|
|
22
32
|
export { TIMESHEET_EXPORT_BASE_BG } from './export-capture.js'
|
|
23
33
|
export { TimesheetCard } from './TimesheetCard.jsx'
|
|
34
|
+
export { TimesheetPanel } from './TimesheetPanel.jsx'
|