@ossy/timesheets 3.9.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +5 -3
  2. package/src/TimesheetCard.jsx +2 -0
  3. package/src/TimesheetPanel.jsx +27 -5
  4. package/src/TimesheetsFreeTool.jsx +10 -6
  5. package/src/TimesheetsProductHome.jsx +16 -9
  6. package/src/auto-generate-timesheets.js +70 -0
  7. package/src/auto-generate-timesheets.spec.js +90 -0
  8. package/src/auto-generate-timesheets.task.js +60 -0
  9. package/src/billing-period.js +123 -0
  10. package/src/billing-period.spec.js +89 -0
  11. package/src/build-prefilled-lines.js +3 -16
  12. package/src/create-and-save-timesheet.flow.js +34 -0
  13. package/src/create-timesheet-draft.js +81 -0
  14. package/src/delete-timesheet.flow.js +41 -0
  15. package/src/disable-timesheets.flow.js +58 -0
  16. package/src/enable-timesheets.flow.js +24 -0
  17. package/src/export-csv.action.js +7 -0
  18. package/src/export-pdf.action.js +7 -0
  19. package/src/export-png.action.js +7 -0
  20. package/src/export-timesheet-csv.flow.js +39 -0
  21. package/src/export-timesheet-pdf.flow.js +39 -0
  22. package/src/export-timesheet-png.flow.js +39 -0
  23. package/src/generate.task.js +8 -45
  24. package/src/index.js +14 -0
  25. package/src/open-delete.action.js +7 -0
  26. package/src/open-export.action.js +7 -0
  27. package/src/send-timesheet-reminder.task.js +76 -0
  28. package/src/timesheet-dto.js +26 -0
  29. package/src/timesheet-refs.js +16 -0
  30. package/src/timesheet-reminder.email.jsx +47 -0
  31. package/src/timesheet-reminder.js +62 -0
  32. package/src/timesheet-reminder.spec.js +75 -0
  33. package/src/timesheet-resources.js +6 -40
  34. package/src/timesheet-resources.spec.js +1 -1
  35. package/src/timesheet.schema.js +5 -0
  36. package/src/timesheets.page.jsx +6 -3
@@ -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
- }
@@ -3,7 +3,7 @@ import {
3
3
  findTimesheetForPeriod,
4
4
  optionalReference,
5
5
  toTimesheetDto,
6
- } from './timesheet-resources.js'
6
+ } from './timesheet-dto.js'
7
7
 
8
8
  describe('optionalReference', () => {
9
9
  it('normalizes ids, objects, and empty values', () => {
@@ -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
  }
@@ -20,11 +20,12 @@ export default function TimesheetsHomePage () {
20
20
  const app = useApp()
21
21
  const router = useRouter()
22
22
  const { workspace: shellWorkspace } = useShellWorkspace()
23
- const { read, invoke, invalidate } = useSdk()
23
+ const { read, invoke } = useSdk()
24
24
  const isAuthenticated = !!app?.isAuthenticated
25
25
  const {
26
26
  data: workspaceFromSdk,
27
27
  status: workspaceStatus,
28
+ refetch: refetchWorkspace,
28
29
  } = read(GetWorkspace, undefined, { enabled: isAuthenticated })
29
30
  const autoEnableStarted = useRef(false)
30
31
 
@@ -52,10 +53,12 @@ export default function TimesheetsHomePage () {
52
53
  && workspaceStatus !== AsyncStatus.Error
53
54
  && app?.workspaceServices == null
54
55
 
56
+ // Shared GetWorkspace cache with useShellWorkspace — refetch (not bare invalidate)
57
+ // awaits the post-enable read so home can switch sales → product reliably.
55
58
  const enableService = useCallback(() => {
56
59
  return invoke(EnableService, { service: TIMESHEETS_SERVICE })
57
- .then(() => invalidate(cacheKey(GetWorkspace)))
58
- }, [invoke, invalidate])
60
+ .then(() => refetchWorkspace())
61
+ }, [invoke, refetchWorkspace])
59
62
 
60
63
  useEffect(() => {
61
64
  if (!isAuthenticated || !enableRequested || autoEnableStarted.current) return