@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.
Files changed (38) hide show
  1. package/package.json +8 -3
  2. package/src/Timesheet.jsx +36 -1
  3. package/src/TimesheetCard.jsx +57 -18
  4. package/src/TimesheetExportCard.jsx +56 -1
  5. package/src/TimesheetPanel.jsx +249 -0
  6. package/src/TimesheetPartyFields.jsx +121 -0
  7. package/src/TimesheetPartyRows.jsx +101 -0
  8. package/src/TimesheetsProductHome.jsx +257 -69
  9. package/src/auto-generate-timesheets.js +70 -0
  10. package/src/auto-generate-timesheets.spec.js +90 -0
  11. package/src/auto-generate-timesheets.task.js +60 -0
  12. package/src/billing-period.js +123 -0
  13. package/src/billing-period.spec.js +89 -0
  14. package/src/build-prefilled-lines.js +3 -16
  15. package/src/create-timesheet-draft.js +81 -0
  16. package/src/delete.action.js +5 -0
  17. package/src/delete.task.js +42 -0
  18. package/src/download-timesheet-csv.js +20 -1
  19. package/src/en.translations.json +23 -3
  20. package/src/generate.task.js +13 -71
  21. package/src/get.task.js +1 -1
  22. package/src/index.js +12 -1
  23. package/src/list.task.js +5 -1
  24. package/src/save.task.js +16 -5
  25. package/src/schema-ids.js +6 -0
  26. package/src/send-timesheet-reminder.task.js +76 -0
  27. package/src/sv.translations.json +23 -3
  28. package/src/timesheet-detail.page.jsx +10 -193
  29. package/src/timesheet-dto.js +26 -0
  30. package/src/timesheet-parties.js +57 -0
  31. package/src/timesheet-parties.spec.js +40 -0
  32. package/src/timesheet-refs.js +16 -0
  33. package/src/timesheet-reminder.email.jsx +47 -0
  34. package/src/timesheet-reminder.js +62 -0
  35. package/src/timesheet-reminder.spec.js +75 -0
  36. package/src/timesheet-resources.js +6 -18
  37. package/src/timesheet-resources.spec.js +59 -0
  38. 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,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/timesheets/actions/delete',
3
+ access: 'workspace',
4
+ label: 'timesheets.delete',
5
+ }
@@ -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 }> }} timesheet
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 = [
@@ -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
  }
@@ -1,24 +1,16 @@
1
- import { nanoid } from 'nanoid'
2
- import { ResourcesEvents, commitResource, mutateResource } from '@ossy/resources/server'
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 (or load) a timesheet for a calendar month.
7
+ * Generate a new timesheet for a calendar month.
17
8
  *
18
- * Payload: `{ year, monthIndex, locale? }` (monthIndex 0–11) OR `{ periodStart, periodEnd, locale? }`.
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
- * If a saved sheet exists for the period, returns it without regenerating.
21
- * If a draft exists, regenerates prefilled lines (keeps id).
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
- periodStart = Number(periodStart)
44
- periodEnd = Number(periodEnd)
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
- status: 'draft',
72
- holidayLocale,
73
- lines,
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
- belongsTo: workspaceId,
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'