@kernhq/module-hr 0.3.0 → 0.4.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 (61) hide show
  1. package/dist/contract/attendance.d.ts +240 -0
  2. package/dist/contract/attendance.d.ts.map +1 -0
  3. package/dist/contract/attendance.js +154 -0
  4. package/dist/contract/attendance.js.map +1 -0
  5. package/dist/contract/capabilities.d.ts.map +1 -1
  6. package/dist/contract/capabilities.js +36 -0
  7. package/dist/contract/capabilities.js.map +1 -1
  8. package/dist/contract/events.d.ts +18 -0
  9. package/dist/contract/events.d.ts.map +1 -1
  10. package/dist/contract/events.js +18 -0
  11. package/dist/contract/events.js.map +1 -1
  12. package/dist/contract/index.d.ts +1 -0
  13. package/dist/contract/index.d.ts.map +1 -1
  14. package/dist/contract/index.js +1 -0
  15. package/dist/contract/index.js.map +1 -1
  16. package/dist/contract/permissions.d.ts +43 -0
  17. package/dist/contract/permissions.d.ts.map +1 -1
  18. package/dist/contract/permissions.js +50 -0
  19. package/dist/contract/permissions.js.map +1 -1
  20. package/dist/contract/router.d.ts +994 -0
  21. package/dist/contract/router.d.ts.map +1 -1
  22. package/dist/contract/router.js +158 -0
  23. package/dist/contract/router.js.map +1 -1
  24. package/dist/policy/time.d.ts +48 -0
  25. package/dist/policy/time.d.ts.map +1 -0
  26. package/dist/policy/time.js +144 -0
  27. package/dist/policy/time.js.map +1 -0
  28. package/dist/policy/working-time.d.ts +98 -0
  29. package/dist/policy/working-time.d.ts.map +1 -0
  30. package/dist/policy/working-time.js +190 -0
  31. package/dist/policy/working-time.js.map +1 -0
  32. package/dist/server/index.d.ts.map +1 -1
  33. package/dist/server/index.js +2 -0
  34. package/dist/server/index.js.map +1 -1
  35. package/dist/server/jobs.d.ts +16 -0
  36. package/dist/server/jobs.d.ts.map +1 -0
  37. package/dist/server/jobs.js +166 -0
  38. package/dist/server/jobs.js.map +1 -0
  39. package/dist/server/router.d.ts +1085 -0
  40. package/dist/server/router.d.ts.map +1 -1
  41. package/dist/server/router.js +495 -2
  42. package/dist/server/router.js.map +1 -1
  43. package/dist/server/schema.d.ts +1261 -1
  44. package/dist/server/schema.d.ts.map +1 -1
  45. package/dist/server/schema.js +116 -0
  46. package/dist/server/schema.js.map +1 -1
  47. package/dist/server/services/attendance.d.ts +150 -0
  48. package/dist/server/services/attendance.d.ts.map +1 -0
  49. package/dist/server/services/attendance.js +239 -0
  50. package/dist/server/services/attendance.js.map +1 -0
  51. package/dist/server/services/ledger.d.ts +2 -2
  52. package/migrations/0003_attendance.sql +206 -0
  53. package/migrations/meta/0003_snapshot.json +3681 -0
  54. package/migrations/meta/_journal.json +7 -0
  55. package/package.json +1 -1
  56. package/src/contract/attendance.ts +180 -0
  57. package/src/contract/capabilities.ts +36 -0
  58. package/src/contract/events.ts +24 -0
  59. package/src/contract/index.ts +1 -0
  60. package/src/contract/permissions.ts +51 -0
  61. package/src/contract/router.ts +196 -0
@@ -22,6 +22,13 @@
22
22
  "when": 1787601877456,
23
23
  "tag": "0002_leave",
24
24
  "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "7",
29
+ "when": 1787634652573,
30
+ "tag": "0003_attendance",
31
+ "breakpoints": true
25
32
  }
26
33
  ]
27
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernhq/module-hr",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Kern HR module: people, offices, org chart, calendars",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -0,0 +1,180 @@
1
+ import { Timestamp, WorkspaceId } from '@kernhq/contracts'
2
+ import { z } from 'zod'
3
+ import { IsoDate, TimeZone, WallClock } from './models.js'
4
+
5
+ const ws = { workspaceId: WorkspaceId }
6
+
7
+ /**
8
+ * Attendance: who was at work, when, and for how long.
9
+ *
10
+ * Two rules shape everything here:
11
+ *
12
+ * - **Raw punches are immutable.** A wrong punch is *voided* by a correcting row, never edited. An
13
+ * attendance record somebody can quietly rewrite is worth nothing in the dispute it exists for.
14
+ * - **The day sheet is derived.** `AttendanceDay` is a projection of punches + schedule + calendar +
15
+ * leave, recomputable from scratch at any time. It is never the source of truth, so a bad
16
+ * computation is a bug to fix and re-run rather than data to repair by hand.
17
+ */
18
+
19
+ export const PunchDirection = z.enum(['in', 'out', 'break_start', 'break_end'])
20
+ export type PunchDirection = z.infer<typeof PunchDirection>
21
+
22
+ export const PunchMethod = z.enum(['web', 'mobile', 'kiosk', 'qr', 'device', 'import', 'manual'])
23
+ export type PunchMethod = z.infer<typeof PunchMethod>
24
+
25
+ /**
26
+ * How much the recorded instant can be trusted.
27
+ *
28
+ * `trusted` was stamped by the server as it happened. `claimed` came from a client that was offline
29
+ * and is asserting when it happened — believed within a policy threshold, flagged beyond it.
30
+ * `disputed` is a claim somebody has to look at. Client clocks lie, sometimes by accident and
31
+ * sometimes not, and a system that cannot say which is which cannot defend any of its numbers.
32
+ */
33
+ export const PunchTrust = z.enum(['trusted', 'claimed', 'disputed'])
34
+ export type PunchTrust = z.infer<typeof PunchTrust>
35
+
36
+ export const Punch = z.object({
37
+ id: z.uuid(),
38
+ ...ws,
39
+ personId: z.uuid(),
40
+ direction: PunchDirection,
41
+ /** Server-stamped, or the client's claim when it was offline. Always an instant, never a local time. */
42
+ at: Timestamp,
43
+ /** What the client believed the time was. Kept for audit even when it disagrees. */
44
+ clientReportedAt: Timestamp.nullable(),
45
+ /** How far the client's clock was out, in milliseconds. Null when the server stamped it. */
46
+ skewMs: z.number().int().nullable(),
47
+ /** The day this belongs to, in the person's schedule zone. A night shift lands on its start date. */
48
+ businessDate: IsoDate,
49
+ /** The zone the punch actually happened in — for audit, not for attribution. */
50
+ timezone: TimeZone,
51
+ method: PunchMethod,
52
+ /** The office whose geofence or allowlist accepted it; any office the person is assigned to. */
53
+ officeId: z.uuid().nullable(),
54
+ deviceId: z.uuid().nullable(),
55
+ geo: z.object({ lat: z.number(), lng: z.number(), accuracyM: z.number().optional() }).nullable(),
56
+ trust: PunchTrust,
57
+ /** Set when a later correction voided this row. The row itself never changes otherwise. */
58
+ voidedByPunchId: z.uuid().nullable(),
59
+ note: z.string().max(500).nullable(),
60
+ createdAt: Timestamp,
61
+ })
62
+ export type Punch = z.infer<typeof Punch>
63
+
64
+ export const AttendanceStatus = z.enum([
65
+ 'present',
66
+ 'absent',
67
+ 'leave',
68
+ 'holiday',
69
+ 'weekend',
70
+ 'partial',
71
+ /** Something is unpaired — usually a missing clock-out. Needs a human. */
72
+ 'pending',
73
+ ])
74
+ export type AttendanceStatus = z.infer<typeof AttendanceStatus>
75
+
76
+ /**
77
+ * One person, one day, derived.
78
+ *
79
+ * Recomputable from punches, schedule, calendar and leave at any moment. `policyHash` records what
80
+ * produced it, so a recomputation can tell whether a row is stale; `locked` mirrors the period, so
81
+ * a closed month cannot silently move.
82
+ */
83
+ export const AttendanceDay = z.object({
84
+ id: z.uuid(),
85
+ ...ws,
86
+ personId: z.uuid(),
87
+ businessDate: IsoDate,
88
+ scheduledMinutes: z.number().int(),
89
+ workedMinutes: z.number().int(),
90
+ breakMinutes: z.number().int(),
91
+ overtimeMinutes: z.number().int(),
92
+ lateMinutes: z.number().int(),
93
+ earlyLeaveMinutes: z.number().int(),
94
+ status: AttendanceStatus,
95
+ leaveRequestId: z.uuid().nullable(),
96
+ anomalies: z.array(z.string()),
97
+ firstIn: Timestamp.nullable(),
98
+ lastOut: Timestamp.nullable(),
99
+ policyHash: z.string().max(64).nullable(),
100
+ locked: z.boolean(),
101
+ computedAt: Timestamp,
102
+ })
103
+ export type AttendanceDay = z.infer<typeof AttendanceDay>
104
+
105
+ /** A week of shifts, in wall-clock readings. Meaningless until a date and a zone arrive. */
106
+ export const ScheduleWeek = z.record(
107
+ z.enum(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']),
108
+ z
109
+ .object({
110
+ start: WallClock,
111
+ end: WallClock,
112
+ breakMinutes: z.number().int().min(0).max(480).default(0),
113
+ })
114
+ .nullable(),
115
+ )
116
+ export type ScheduleWeek = z.infer<typeof ScheduleWeek>
117
+
118
+ export const Schedule = z.object({
119
+ id: z.uuid(),
120
+ ...ws,
121
+ name: z.string().min(1).max(120),
122
+ kind: z.enum(['fixed', 'flexible', 'shift']),
123
+ week: ScheduleWeek,
124
+ /** `office` means the person's primary office — the default, and almost always right. */
125
+ tzMode: z.enum(['office', 'person', 'fixed']),
126
+ tz: TimeZone.nullable(),
127
+ graceInMinutes: z.number().int().min(0).max(240),
128
+ graceOutMinutes: z.number().int().min(0).max(240),
129
+ roundingStepMinutes: z.number().int().min(0).max(60),
130
+ roundingDirection: z.enum(['nearest', 'employee', 'employer']),
131
+ /** Close an open shift automatically after this many minutes. Null leaves it for a human. */
132
+ autoClockOutAfterMinutes: z.number().int().min(60).nullable(),
133
+ archivedAt: Timestamp.nullable(),
134
+ })
135
+ export type Schedule = z.infer<typeof Schedule>
136
+
137
+ export const ScheduleAssignment = z.object({
138
+ id: z.uuid(),
139
+ ...ws,
140
+ personId: z.uuid(),
141
+ scheduleId: z.uuid(),
142
+ effectiveFrom: IsoDate,
143
+ effectiveTo: IsoDate.nullable(),
144
+ })
145
+ export type ScheduleAssignment = z.infer<typeof ScheduleAssignment>
146
+
147
+ /**
148
+ * A request to fix a wrong or missing punch.
149
+ *
150
+ * It does not edit anything. Approving it writes *new* punches that void the old ones, so the
151
+ * original record and the correction both survive — which is the difference between a corrected
152
+ * timesheet and an edited one.
153
+ */
154
+ export const Regularization = z.object({
155
+ id: z.uuid(),
156
+ ...ws,
157
+ personId: z.uuid(),
158
+ businessDate: IsoDate,
159
+ /** Null when the person never punched at all and is asking for the whole day to be recorded. */
160
+ punchId: z.uuid().nullable(),
161
+ proposed: z.array(z.object({ direction: PunchDirection, at: Timestamp })),
162
+ reason: z.string().min(1).max(1000),
163
+ status: z.enum(['pending', 'approved', 'rejected', 'cancelled']),
164
+ approvalRequestId: z.uuid().nullable(),
165
+ appliedAt: Timestamp.nullable(),
166
+ createdAt: Timestamp,
167
+ })
168
+ export type Regularization = z.infer<typeof Regularization>
169
+
170
+ /** The person's own live state: are they clocked in, since when, on a break? */
171
+ export const ClockState = z.object({
172
+ personId: z.uuid(),
173
+ businessDate: IsoDate,
174
+ clockedIn: z.boolean(),
175
+ onBreak: z.boolean(),
176
+ since: Timestamp.nullable(),
177
+ workedMinutesToday: z.number().int(),
178
+ timezone: TimeZone,
179
+ })
180
+ export type ClockState = z.infer<typeof ClockState>
@@ -84,6 +84,24 @@ export const hrCapabilities = defineCapabilities([
84
84
  defaultEnabled: false,
85
85
  level: 2,
86
86
  },
87
+ {
88
+ id: 'attendance',
89
+ label: 'Attendance',
90
+ description: 'Clock in and out, schedules and a daily sheet',
91
+ dependsOn: ['core', 'calendars'],
92
+ // Off by default. Plenty of companies never clock anybody in, and a directory that offers a
93
+ // clock button to salaried staff is offering a feature nobody asked for.
94
+ defaultEnabled: false,
95
+ level: 1,
96
+ },
97
+ {
98
+ id: 'overtime',
99
+ label: 'Overtime',
100
+ description: 'Detect and approve time worked beyond the schedule',
101
+ dependsOn: ['attendance'],
102
+ defaultEnabled: false,
103
+ level: 2,
104
+ },
87
105
  {
88
106
  id: 'documents',
89
107
  label: 'Employee documents',
@@ -149,6 +167,24 @@ export const hrCapabilityProcedures: Record<string, readonly string[]> = {
149
167
  'leave.requests.cancel',
150
168
  'leave.team.calendar',
151
169
  ],
170
+ attendance: [
171
+ 'attendance.state',
172
+ 'attendance.clockIn',
173
+ 'attendance.clockOut',
174
+ 'attendance.breakStart',
175
+ 'attendance.breakEnd',
176
+ 'attendance.punches.list',
177
+ 'attendance.punches.void',
178
+ 'attendance.days.list',
179
+ 'attendance.days.recompute',
180
+ 'attendance.schedules.list',
181
+ 'attendance.schedules.create',
182
+ 'attendance.schedules.update',
183
+ 'attendance.schedules.archive',
184
+ 'attendance.schedules.assign',
185
+ 'attendance.regularizations.list',
186
+ 'attendance.regularizations.request',
187
+ ],
152
188
  approvals: [
153
189
  'approvals.chains.list',
154
190
  'approvals.chains.create',
@@ -129,6 +129,30 @@ export const hrEvents = {
129
129
  status: z.string(),
130
130
  }),
131
131
  ),
132
+ punchRecorded: defineEvent(
133
+ 'hr.punch.recorded',
134
+ z.object({
135
+ punchId: z.uuid(),
136
+ workspaceId: WorkspaceId,
137
+ personId: z.uuid(),
138
+ direction: z.string(),
139
+ businessDate: z.iso.date(),
140
+ }),
141
+ ),
142
+ /**
143
+ * A derived day changed. Carries the date so a consumer recomputes that window rather than
144
+ * everything — this fires on every punch, so a coarse payload would be a stampede.
145
+ */
146
+ attendanceDayComputed: defineEvent(
147
+ 'hr.attendance.day_computed',
148
+ z.object({
149
+ workspaceId: WorkspaceId,
150
+ personId: z.uuid(),
151
+ businessDate: z.iso.date(),
152
+ status: z.string(),
153
+ workedMinutes: z.number().int(),
154
+ }),
155
+ ),
132
156
  calendarChanged: defineEvent(
133
157
  'hr.calendar.changed',
134
158
  z.object({
@@ -7,6 +7,7 @@
7
7
  * and also that every procedure listed in `hrCapabilityProcedures` carries its capability guard.
8
8
  */
9
9
  export * from './approvals.js'
10
+ export * from './attendance.js'
10
11
  export * from './capabilities.js'
11
12
  export * from './events.js'
12
13
  export * from './leave.js'
@@ -228,6 +228,51 @@ export const hrPermissions = definePermissions([
228
228
  dangerous: true,
229
229
  },
230
230
 
231
+ // ---------------------------------------------------------------- attendance
232
+ {
233
+ key: 'hr.attendance.punch',
234
+ label: 'Clock yourself in and out',
235
+ scope: 'workspace',
236
+ defaultRoles: ['owner', 'admin', 'member'],
237
+ dangerous: false,
238
+ },
239
+ {
240
+ key: 'hr.attendance.view',
241
+ label: 'View your own attendance',
242
+ scope: 'workspace',
243
+ defaultRoles: ['owner', 'admin', 'member'],
244
+ dangerous: false,
245
+ },
246
+ {
247
+ key: 'hr.attendance.view_team',
248
+ label: "View your team's attendance",
249
+ scope: 'object',
250
+ defaultRoles: ['owner', 'admin'],
251
+ dangerous: false,
252
+ },
253
+ {
254
+ key: 'hr.attendance.manage',
255
+ label: 'Correct punches, schedules and day sheets',
256
+ description: 'Voids punches and recomputes days. Every change leaves the original visible.',
257
+ scope: 'workspace',
258
+ defaultRoles: ['owner', 'admin'],
259
+ dangerous: true,
260
+ },
261
+ {
262
+ key: 'hr.overtime.view',
263
+ label: 'View overtime',
264
+ scope: 'workspace',
265
+ defaultRoles: ['owner', 'admin'],
266
+ dangerous: false,
267
+ },
268
+ {
269
+ key: 'hr.overtime.manage',
270
+ label: 'Approve and adjust overtime',
271
+ scope: 'workspace',
272
+ defaultRoles: ['owner', 'admin'],
273
+ dangerous: false,
274
+ },
275
+
231
276
  // ---------------------------------------------------------------- approvals
232
277
  {
233
278
  key: 'hr.approval.manage',
@@ -283,6 +328,12 @@ export const HR_PERMISSIONS = {
283
328
  leaveViewLedger: 'hr.leave.view_ledger',
284
329
  leaveManage: 'hr.leave.manage',
285
330
  leaveAdjust: 'hr.leave.adjust',
331
+ attendancePunch: 'hr.attendance.punch',
332
+ attendanceView: 'hr.attendance.view',
333
+ attendanceViewTeam: 'hr.attendance.view_team',
334
+ attendanceManage: 'hr.attendance.manage',
335
+ overtimeView: 'hr.overtime.view',
336
+ overtimeManage: 'hr.overtime.manage',
286
337
  approvalManage: 'hr.approval.manage',
287
338
  approvalDelegate: 'hr.approval.delegate',
288
339
  } as const
@@ -7,6 +7,17 @@ import {
7
7
  ApprovalSubjectType,
8
8
  Delegation,
9
9
  } from './approvals.js'
10
+ import {
11
+ AttendanceDay,
12
+ ClockState,
13
+ Punch,
14
+ PunchDirection,
15
+ PunchMethod,
16
+ Regularization,
17
+ Schedule,
18
+ ScheduleAssignment,
19
+ ScheduleWeek,
20
+ } from './attendance.js'
10
21
  import {
11
22
  DayPart,
12
23
  LeaveBalance,
@@ -585,6 +596,191 @@ export const hrContract = {
585
596
  .output(ok),
586
597
  },
587
598
 
599
+ // ---------------------------------------------------------------- attendance
600
+ attendance: {
601
+ /** Am I clocked in? The one call a clock widget makes. */
602
+ state: baseContract
603
+ .route({ method: 'GET', path: '/attendance/state', tags: t })
604
+ .input(ws.extend({ personId: z.uuid().optional() }))
605
+ .output(ClockState),
606
+
607
+ /**
608
+ * Clock in. The instant is **stamped by the server**, never taken from the caller.
609
+ *
610
+ * `clientReportedAt` is recorded alongside it for audit — a device whose clock is an hour out is
611
+ * worth knowing about — but it never decides anything.
612
+ */
613
+ clockIn: baseContract
614
+ .route({ method: 'POST', path: '/attendance/clock-in', tags: t })
615
+ .input(
616
+ ws.extend({
617
+ personId: z.uuid().optional(),
618
+ method: PunchMethod.default('web'),
619
+ clientReportedAt: z.iso.datetime({ offset: true }).nullish(),
620
+ geo: z.object({ lat: z.number(), lng: z.number(), accuracyM: z.number().optional() }).nullish(),
621
+ note: z.string().max(500).nullish(),
622
+ idempotencyKey: z.string().min(8).max(128).optional(),
623
+ }),
624
+ )
625
+ .output(Punch),
626
+ clockOut: baseContract
627
+ .route({ method: 'POST', path: '/attendance/clock-out', tags: t })
628
+ .input(
629
+ ws.extend({
630
+ personId: z.uuid().optional(),
631
+ method: PunchMethod.default('web'),
632
+ clientReportedAt: z.iso.datetime({ offset: true }).nullish(),
633
+ geo: z.object({ lat: z.number(), lng: z.number(), accuracyM: z.number().optional() }).nullish(),
634
+ note: z.string().max(500).nullish(),
635
+ idempotencyKey: z.string().min(8).max(128).optional(),
636
+ }),
637
+ )
638
+ .output(Punch),
639
+ breakStart: baseContract
640
+ .route({ method: 'POST', path: '/attendance/break-start', tags: t })
641
+ .input(
642
+ ws.extend({ personId: z.uuid().optional(), idempotencyKey: z.string().min(8).max(128).optional() }),
643
+ )
644
+ .output(Punch),
645
+ breakEnd: baseContract
646
+ .route({ method: 'POST', path: '/attendance/break-end', tags: t })
647
+ .input(
648
+ ws.extend({ personId: z.uuid().optional(), idempotencyKey: z.string().min(8).max(128).optional() }),
649
+ )
650
+ .output(Punch),
651
+
652
+ punches: {
653
+ list: baseContract
654
+ .route({ method: 'GET', path: '/attendance/punches', tags: t })
655
+ .input(
656
+ ws.extend({
657
+ personId: z.uuid().optional(),
658
+ from: IsoDate,
659
+ to: IsoDate,
660
+ includeVoided: z.boolean().default(false),
661
+ ...PageInput.shape,
662
+ }),
663
+ )
664
+ .output(page(Punch)),
665
+ /**
666
+ * Void a punch by writing a correcting row.
667
+ *
668
+ * The original is never edited or deleted — an attendance record somebody can quietly rewrite
669
+ * is worth nothing in the dispute it exists for.
670
+ */
671
+ void: baseContract
672
+ .route({ method: 'POST', path: '/attendance/punches/{punchId}/void', tags: t })
673
+ .input(ws.extend({ punchId: z.uuid(), reason: z.string().min(1).max(500) }))
674
+ .output(ok),
675
+ },
676
+
677
+ days: {
678
+ list: baseContract
679
+ .route({ method: 'GET', path: '/attendance/days', tags: t })
680
+ .input(
681
+ ws.extend({
682
+ personId: z.uuid().optional(),
683
+ officeId: z.uuid().optional(),
684
+ from: IsoDate,
685
+ to: IsoDate,
686
+ ...PageInput.shape,
687
+ }),
688
+ )
689
+ .output(page(AttendanceDay)),
690
+ /**
691
+ * Recompute a range from the punches.
692
+ *
693
+ * Safe to call at any time — the day sheet is a projection, so this is idempotent by
694
+ * construction. Locked days are skipped and named in the response rather than silently
695
+ * ignored.
696
+ */
697
+ recompute: baseContract
698
+ .route({ method: 'POST', path: '/attendance/days/recompute', tags: t })
699
+ .input(ws.extend({ personId: z.uuid().optional(), from: IsoDate, to: IsoDate }))
700
+ .output(z.object({ recomputed: z.number().int(), skippedLocked: z.array(IsoDate) })),
701
+ },
702
+
703
+ schedules: {
704
+ list: baseContract
705
+ .route({ method: 'GET', path: '/attendance/schedules', tags: t })
706
+ .input(ws.extend({ includeArchived: z.boolean().default(false) }))
707
+ .output(z.array(Schedule)),
708
+ create: baseContract
709
+ .route({ method: 'POST', path: '/attendance/schedules', tags: t })
710
+ .input(
711
+ ws.extend({
712
+ name: z.string().min(1).max(120),
713
+ kind: Schedule.shape.kind.default('fixed'),
714
+ week: ScheduleWeek,
715
+ tzMode: Schedule.shape.tzMode.default('office'),
716
+ tz: Schedule.shape.tz.optional(),
717
+ graceInMinutes: z.number().int().min(0).max(240).default(0),
718
+ graceOutMinutes: z.number().int().min(0).max(240).default(0),
719
+ roundingStepMinutes: z.number().int().min(0).max(60).default(0),
720
+ roundingDirection: Schedule.shape.roundingDirection.default('nearest'),
721
+ autoClockOutAfterMinutes: z.number().int().min(60).nullish(),
722
+ }),
723
+ )
724
+ .output(Schedule),
725
+ update: baseContract
726
+ .route({ method: 'PATCH', path: '/attendance/schedules/{scheduleId}', tags: t })
727
+ .input(
728
+ ws.extend({
729
+ scheduleId: z.uuid(),
730
+ name: z.string().min(1).max(120).optional(),
731
+ week: ScheduleWeek.optional(),
732
+ graceInMinutes: z.number().int().min(0).max(240).optional(),
733
+ graceOutMinutes: z.number().int().min(0).max(240).optional(),
734
+ roundingStepMinutes: z.number().int().min(0).max(60).optional(),
735
+ roundingDirection: Schedule.shape.roundingDirection.optional(),
736
+ autoClockOutAfterMinutes: z.number().int().min(60).nullish(),
737
+ }),
738
+ )
739
+ .output(Schedule),
740
+ archive: baseContract
741
+ .route({ method: 'DELETE', path: '/attendance/schedules/{scheduleId}', tags: t })
742
+ .input(ws.extend({ scheduleId: z.uuid() }))
743
+ .output(ok),
744
+ assign: baseContract
745
+ .route({ method: 'POST', path: '/attendance/schedules/{scheduleId}/assign', tags: t })
746
+ .input(ws.extend({ scheduleId: z.uuid(), personId: z.uuid(), effectiveFrom: IsoDate }))
747
+ .output(z.array(ScheduleAssignment)),
748
+ },
749
+
750
+ regularizations: {
751
+ list: baseContract
752
+ .route({ method: 'GET', path: '/attendance/regularizations', tags: t })
753
+ .input(
754
+ ws.extend({
755
+ personId: z.uuid().optional(),
756
+ status: z.array(Regularization.shape.status).optional(),
757
+ ...PageInput.shape,
758
+ }),
759
+ )
760
+ .output(page(Regularization)),
761
+ /** Ask for a wrong or missing punch to be fixed. Goes through the same approval engine. */
762
+ request: baseContract
763
+ .route({ method: 'POST', path: '/attendance/regularizations', tags: t })
764
+ .input(
765
+ ws.extend({
766
+ personId: z.uuid().optional(),
767
+ businessDate: IsoDate,
768
+ punchId: z.uuid().nullish(),
769
+ proposed: z
770
+ .array(
771
+ z.object({
772
+ direction: PunchDirection,
773
+ at: z.iso.datetime({ offset: true }),
774
+ }),
775
+ )
776
+ .min(1),
777
+ reason: z.string().min(1).max(1000),
778
+ }),
779
+ )
780
+ .output(Regularization),
781
+ },
782
+ },
783
+
588
784
  // ---------------------------------------------------------------- leave
589
785
  leave: {
590
786
  types: {