@kernhq/module-hr 0.10.5 → 0.12.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.
@@ -14,18 +14,25 @@
14
14
  * does not is how a screen works in `dev:mock` and breaks against the real API.
15
15
  */
16
16
  import { ORPCError } from '@orpc/contract'
17
- import type { Schedule, ScheduleAssignment } from '../contract/attendance.js'
18
- import type { LeaveType } from '../contract/leave.js'
17
+ import type { ApprovalChain, ApprovalChainSpec } from '../contract/approvals.js'
18
+ import type { Punch, Regularization, Schedule, ScheduleAssignment } from '../contract/attendance.js'
19
+ import type { LeaveLedgerEntry, LeaveType } from '../contract/leave.js'
19
20
  import type {
20
21
  Calendar,
21
22
  CalendarDay,
22
23
  CalendarDayKind,
24
+ Employment,
23
25
  LegalEntity,
24
26
  Office,
25
27
  OfficeAssignment,
28
+ OrgUnit,
29
+ PersonDocument,
30
+ PersonSensitive,
31
+ Position,
26
32
  ResolvedCalendarDay,
27
33
  WorkingWeek,
28
34
  } from '../contract/models.js'
35
+ import type { Period, Policy, PolicyAssignment, PolicySubjectKind } from '../contract/policies.js'
29
36
 
30
37
  /**
31
38
  * A refusal the client cannot tell from the server's.
@@ -38,10 +45,15 @@ import type {
38
45
  * Every sentence below is copied from `src/server/router.ts`, because the widget renders the
39
46
  * server's own words rather than a translated string.
40
47
  */
41
- function refuse(code: 'CONFLICT' | 'NOT_FOUND' | 'BAD_REQUEST', message: string): never {
48
+ function refuse(code: 'CONFLICT' | 'NOT_FOUND' | 'BAD_REQUEST', message: string, reason?: string): never {
42
49
  // A declaration, not a `const` arrow: TypeScript only narrows on a `never` return for one of
43
50
  // those, so an arrow would leave every caller believing the row after the guard is still optional.
44
- throw new ORPCError(code, { message })
51
+ //
52
+ // `reason` lands in `data`, which is where `kernErrorToORPC` puts it — and it is passed *only*
53
+ // where the router passes one. A mock that invented a reason the server does not send would be
54
+ // the same bug as a mock that dropped one it does, pointing the other way: the client's lookup
55
+ // would fire in `dev:mock` and never in production.
56
+ throw new ORPCError(code, { message, data: reason ? { reason } : undefined })
45
57
  }
46
58
 
47
59
  /** Stored without the tenant, which every call stamps back on. */
@@ -242,6 +254,7 @@ export function createMockHrApi() {
242
254
  {
243
255
  id: id('d001'),
244
256
  displayName: 'Ayşe Yılmaz',
257
+ hiredOn: day(-400),
245
258
  workEmail: 'ayse@example.test',
246
259
  status: 'active',
247
260
  timezone: 'Europe/Istanbul',
@@ -250,6 +263,7 @@ export function createMockHrApi() {
250
263
  {
251
264
  id: id('d002'),
252
265
  displayName: 'Sanne de Vries',
266
+ hiredOn: day(-300),
253
267
  workEmail: 'sanne@example.test',
254
268
  status: 'active',
255
269
  timezone: 'Europe/Amsterdam',
@@ -258,6 +272,7 @@ export function createMockHrApi() {
258
272
  {
259
273
  id: id('d003'),
260
274
  displayName: 'Mehmet Kaya',
275
+ hiredOn: day(-2000),
261
276
  workEmail: 'mehmet@example.test',
262
277
  status: 'on_leave',
263
278
  timezone: 'Europe/Istanbul',
@@ -266,6 +281,7 @@ export function createMockHrApi() {
266
281
  {
267
282
  id: id('d004'),
268
283
  displayName: 'Jonas Weber',
284
+ hiredOn: day(-80),
269
285
  workEmail: 'jonas@example.test',
270
286
  status: 'active',
271
287
  timezone: 'Europe/Berlin',
@@ -357,13 +373,197 @@ export function createMockHrApi() {
357
373
  personalEmail: null,
358
374
  phone: null,
359
375
  photoFileId: null,
360
- hiredOn: day(-400),
361
376
  terminatedOn: null,
362
377
  custom: {},
363
378
  createdAt: iso(400 * 86_400_000),
364
379
  updatedAt: iso(),
365
380
  })
366
381
 
382
+ // ---------------------------------------------------------------- the org chart
383
+
384
+ /**
385
+ * An ltree label: `u` and the id with its dashes removed.
386
+ *
387
+ * The prefix is not decoration — an ltree label cannot start with a digit, and every id here
388
+ * does.
389
+ */
390
+ const unitLabel = (unitId: string) => `u${unitId.replaceAll('-', '')}`
391
+
392
+ const orgUnits: Row<OrgUnit>[] = []
393
+ const pathFor = (parentId: string | null, unitId: string): string => {
394
+ const parent = parentId ? orgUnits.find((u) => u.id === parentId) : undefined
395
+ return parent ? `${parent.path}.${unitLabel(unitId)}` : unitLabel(unitId)
396
+ }
397
+ const seedUnit = (
398
+ unitId: string,
399
+ parentId: string | null,
400
+ name: string,
401
+ code: string | null = null,
402
+ headPersonId: string | null = null,
403
+ ) => {
404
+ orgUnits.push({
405
+ id: unitId,
406
+ parentId,
407
+ path: pathFor(parentId, unitId),
408
+ name,
409
+ code,
410
+ headPersonId,
411
+ archivedAt: null,
412
+ })
413
+ }
414
+
415
+ // Parent before child, because a path is built from the one above it. Four levels deep so the
416
+ // tree rails and the depth figure have something to draw, and Operations is left empty and
417
+ // childless so archiving a department is reachable at all.
418
+ seedUnit(id('0a01'), null, 'Northstar', 'NS', people[0]!.id)
419
+ seedUnit(id('0a02'), id('0a01'), 'Engineering', 'ENG', people[0]!.id)
420
+ seedUnit(id('0a03'), id('0a02'), 'Platform', 'PLT', people[1]!.id)
421
+ seedUnit(id('0a04'), id('0a03'), 'Infrastructure')
422
+ seedUnit(id('0a05'), id('0a02'), 'Product Engineering')
423
+ seedUnit(id('0a06'), id('0a01'), 'People & Culture', 'PC', people[1]!.id)
424
+ seedUnit(id('0a07'), id('0a01'), 'Operations')
425
+
426
+ const positions: Row<Position>[] = [
427
+ {
428
+ id: id('05a1'),
429
+ title: 'Software Engineer',
430
+ code: 'SE',
431
+ jobFamily: 'Engineering',
432
+ level: 'L3',
433
+ archivedAt: null,
434
+ },
435
+ {
436
+ id: id('05a2'),
437
+ title: 'Senior Software Engineer',
438
+ code: 'SSE',
439
+ jobFamily: 'Engineering',
440
+ level: 'L4',
441
+ archivedAt: null,
442
+ },
443
+ {
444
+ id: id('05a3'),
445
+ title: 'Engineering Manager',
446
+ code: 'EM',
447
+ jobFamily: 'Engineering',
448
+ level: 'M1',
449
+ archivedAt: null,
450
+ },
451
+ // A mix on purpose: not every position is levelled, and plenty carry no code at all.
452
+ {
453
+ id: id('05a4'),
454
+ title: 'People Partner',
455
+ code: null,
456
+ jobFamily: 'People',
457
+ level: null,
458
+ archivedAt: null,
459
+ },
460
+ { id: id('05a5'), title: 'Office Manager', code: 'OM', jobFamily: null, level: null, archivedAt: null },
461
+ ]
462
+
463
+ /**
464
+ * Who holds which job, effective-dated.
465
+ *
466
+ * The org tree's headcount is a count of *these* — one row per person whose `effectiveTo` is
467
+ * still null — rather than a number stated beside the department. That is what makes archiving a
468
+ * department refuse for a reason somebody can act on, and it is why moving a person changes two
469
+ * screens at once.
470
+ */
471
+ const employments: Row<Employment>[] = [
472
+ // Ayşe was promoted, so her history has two rows and the current one is not the first. A single
473
+ // open row per person makes `employment.history` a list of one and proves nothing about the
474
+ // effective-dated shape it exists to show.
475
+ {
476
+ id: id('eb05'),
477
+ personId: people[0]!.id,
478
+ effectiveFrom: day(-400),
479
+ effectiveTo: day(-201),
480
+ orgUnitId: id('0a02'),
481
+ positionId: id('05a1'),
482
+ legalEntityId: id('1e01'),
483
+ costCenterId: null,
484
+ managerPersonId: null,
485
+ employmentType: 'full_time',
486
+ fte: 1,
487
+ contractHoursWeek: 40,
488
+ reason: null,
489
+ createdAt: iso(400 * 86_400_000),
490
+ },
491
+ {
492
+ id: id('eb01'),
493
+ personId: people[0]!.id,
494
+ effectiveFrom: day(-200),
495
+ effectiveTo: null,
496
+ orgUnitId: id('0a02'),
497
+ positionId: id('05a3'),
498
+ legalEntityId: id('1e01'),
499
+ costCenterId: null,
500
+ managerPersonId: null,
501
+ employmentType: 'full_time',
502
+ fte: 1,
503
+ contractHoursWeek: 40,
504
+ reason: 'Promoted to Engineering Manager',
505
+ createdAt: iso(200 * 86_400_000),
506
+ },
507
+ {
508
+ id: id('eb02'),
509
+ personId: people[1]!.id,
510
+ effectiveFrom: day(-300),
511
+ effectiveTo: null,
512
+ orgUnitId: id('0a03'),
513
+ positionId: id('05a2'),
514
+ legalEntityId: id('1e02'),
515
+ costCenterId: null,
516
+ managerPersonId: people[0]!.id,
517
+ employmentType: 'full_time',
518
+ fte: 1,
519
+ contractHoursWeek: 40,
520
+ reason: null,
521
+ createdAt: iso(300 * 86_400_000),
522
+ },
523
+ {
524
+ id: id('eb03'),
525
+ personId: people[2]!.id,
526
+ effectiveFrom: day(-250),
527
+ effectiveTo: null,
528
+ orgUnitId: id('0a02'),
529
+ positionId: id('05a1'),
530
+ legalEntityId: id('1e01'),
531
+ costCenterId: null,
532
+ managerPersonId: people[0]!.id,
533
+ employmentType: 'full_time',
534
+ fte: 1,
535
+ contractHoursWeek: 40,
536
+ reason: null,
537
+ createdAt: iso(250 * 86_400_000),
538
+ },
539
+ {
540
+ id: id('eb04'),
541
+ personId: people[3]!.id,
542
+ effectiveFrom: day(-80),
543
+ effectiveTo: null,
544
+ orgUnitId: id('0a06'),
545
+ positionId: id('05a4'),
546
+ legalEntityId: id('1e01'),
547
+ costCenterId: null,
548
+ managerPersonId: people[0]!.id,
549
+ employmentType: 'part_time',
550
+ fte: 0.8,
551
+ contractHoursWeek: 32,
552
+ reason: null,
553
+ createdAt: iso(80 * 86_400_000),
554
+ },
555
+ ]
556
+
557
+ /** Direct only. The tree sums the subtree itself, so a subtree total here double-counts. */
558
+ const unitHeadcount = (unitId: string) =>
559
+ employments.filter((e) => e.orgUnitId === unitId && e.effectiveTo === null).length
560
+
561
+ const descendants = (unitId: string) => {
562
+ const root = orgUnits.find((u) => u.id === unitId)
563
+ if (!root) return []
564
+ return orgUnits.filter((u) => u.path === root.path || u.path.startsWith(`${root.path}.`))
565
+ }
566
+
367
567
  // ---------------------------------------------------------------- calendars
368
568
 
369
569
  const calendars: Row<Calendar>[] = [
@@ -704,6 +904,597 @@ export function createMockHrApi() {
704
904
  },
705
905
  ]
706
906
 
907
+ /**
908
+ * The recent working days, most recent first.
909
+ *
910
+ * The interesting days are pinned to positions in *this* list rather than to a raw offset from
911
+ * today: `day(-3)` is a Sunday one week in three, and a leave day or a missing clock-out on a
912
+ * Sunday is a contradiction the day sheet would then have to draw.
913
+ */
914
+ const workdays = (count: number): string[] => {
915
+ const out: string[] = []
916
+ for (let back = 0; out.length < count; back++) {
917
+ const date = day(-back)
918
+ const weekday = WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]!
919
+ if (weekday !== 'sat' && weekday !== 'sun') out.push(date)
920
+ }
921
+ return out
922
+ }
923
+ const WD = workdays(5)
924
+ /** How far the punch seed reaches back — a month plus a fortnight, so any month-start is covered. */
925
+ const SEED_DAYS = 45
926
+
927
+ /**
928
+ * An instant from a business date and a wall-clock reading **in the office's zone**.
929
+ *
930
+ * Built as UTC these read three hours late to everybody: the screens format in the viewer's zone,
931
+ * so a seeded `09:00` clock-in was drawn as "12:00 PM" and a nine-to-six day looked like noon to
932
+ * nine. Istanbul has been a fixed +03:00 with no daylight saving since 2016, so the offset is
933
+ * safe to write literally — a zone that still shifts would need the date to decide it.
934
+ */
935
+ const stamp = (date: string, wall: string) => new Date(`${date}T${wall}:00+03:00`).toISOString()
936
+
937
+ let punchCounter = 0
938
+ const punch = (
939
+ date: string,
940
+ wall: string,
941
+ direction: Punch['direction'],
942
+ over: Partial<Row<Punch>> = {},
943
+ ): Row<Punch> => ({
944
+ id: id(`9c${(++punchCounter).toString(16).padStart(4, '0')}`),
945
+ personId: people[0]!.id,
946
+ direction,
947
+ at: stamp(date, wall),
948
+ clientReportedAt: null,
949
+ skewMs: null,
950
+ businessDate: date,
951
+ timezone: 'Europe/Istanbul',
952
+ method: 'web',
953
+ officeId: primaryOfficeId(people[0]!.id),
954
+ deviceId: null,
955
+ geo: null,
956
+ trust: 'trusted',
957
+ voidedByPunchId: null,
958
+ note: null,
959
+ createdAt: iso(),
960
+ ...over,
961
+ })
962
+
963
+ /**
964
+ * The raw punches behind the day sheet.
965
+ *
966
+ * Every past working day gets a pair, not just the interesting ones. A day sheet that says eight
967
+ * hours with nothing underneath it is the exact statement this page's own header warns about —
968
+ * and opening such a row showed a total above an empty list, which reads as a broken panel.
969
+ *
970
+ * The times match the seeded `Office hours` schedule, so the arithmetic holds: 09:00 to 18:00 is
971
+ * nine hours, less an hour of break, is the 480 minutes the row claims.
972
+ *
973
+ * Three states the panel draws differently sit on top: a punch the device *claimed* while
974
+ * offline, a day whose clock-out never arrived, and a voided punch beside the correcting row that
975
+ * carries the reason.
976
+ */
977
+ const punches: Row<Punch>[] = []
978
+
979
+ // Far enough back to cover the current month whatever day of it this runs on.
980
+ for (let back = SEED_DAYS; back >= 1; back--) {
981
+ const date = day(-back)
982
+ const weekday = WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]!
983
+ if (weekday === 'sat' || weekday === 'sun') continue
984
+ if (date === WD[2]) continue // on leave, so nothing was punched
985
+ if (date === WD[3]) {
986
+ // Punched from a phone that was offline: the instant is the device's claim, and it was four
987
+ // minutes out. That pair of facts is what `trust` and `skewMs` exist to keep. No clock-out
988
+ // ever arrived, which is the day's anomaly.
989
+ punches.push(
990
+ punch(date, '09:12', 'in', {
991
+ method: 'mobile',
992
+ trust: 'claimed',
993
+ clientReportedAt: stamp(date, '09:08'),
994
+ skewMs: 240_000,
995
+ }),
996
+ )
997
+ continue
998
+ }
999
+ punches.push(punch(date, '09:00', 'in'))
1000
+ punches.push(punch(date, '13:00', 'break_start'))
1001
+ punches.push(punch(date, '14:00', 'break_end'))
1002
+ punches.push(punch(date, date === WD[1] ? '18:45' : '18:00', 'out'))
1003
+ }
1004
+
1005
+ /**
1006
+ * The voided pair, wired the way `voidPunch` wires it.
1007
+ *
1008
+ * Both rows point at the correction: the original because it was voided, and the correction
1009
+ * because it is not a punch — it exists to carry the reason and to say what it replaced. The
1010
+ * panel hides the self-voiding row and reads the sentence out of its note.
1011
+ */
1012
+ const voidedOriginal = punch(WD[4]!, '08:00', 'in')
1013
+ const voidCorrection = punch(WD[4]!, '08:00', 'in', {
1014
+ method: 'manual',
1015
+ note: `Voids ${voidedOriginal.id}: Badge reader at the door fired as I walked past.`,
1016
+ })
1017
+ voidedOriginal.voidedByPunchId = voidCorrection.id
1018
+ voidCorrection.voidedByPunchId = voidCorrection.id
1019
+ punches.push(voidedOriginal, voidCorrection)
1020
+
1021
+ /**
1022
+ * One correction already asked for, on the day the seeded approval names.
1023
+ *
1024
+ * `subjectId` and `approvalRequestId` line up with the `regularization` row in the approvals
1025
+ * inbox on purpose — the same request seen from the two screens that show it, which is the thing
1026
+ * a demo cannot fake with two unrelated rows.
1027
+ */
1028
+ const regularizations: Row<Regularization>[] = [
1029
+ {
1030
+ id: id('c002'),
1031
+ personId: people[0]!.id,
1032
+ businessDate: WD[1]!,
1033
+ punchId: punches.find((x) => x.businessDate === WD[1] && x.direction === 'out')?.id ?? null,
1034
+ proposed: [{ direction: 'out', at: stamp(WD[1]!, '19:00') }],
1035
+ reason: 'I worked until 19:00 finishing the migration; the clock-out is wrong.',
1036
+ status: 'pending',
1037
+ approvalRequestId: id('f002'),
1038
+ appliedAt: null,
1039
+ createdAt: iso(2 * 86_400_000),
1040
+ },
1041
+ ]
1042
+
1043
+ // ---------------------------------------------------------------- documents, sensitive, periods
1044
+
1045
+ const documents: Row<PersonDocument>[] = [
1046
+ {
1047
+ id: id('d0c1'),
1048
+ personId: people[0]!.id,
1049
+ fileId: id('f11e01'),
1050
+ name: 'Employment contract',
1051
+ kind: 'contract',
1052
+ issuedOn: day(-400),
1053
+ expiresOn: null,
1054
+ uploadedBy: null,
1055
+ createdAt: iso(400 * 86_400_000),
1056
+ },
1057
+ // Expiring inside the month, because "expires on" is the column the section exists for and a
1058
+ // list where nothing ever expires never shows what it does with one.
1059
+ {
1060
+ id: id('d0c2'),
1061
+ personId: people[0]!.id,
1062
+ fileId: id('f11e02'),
1063
+ name: 'Work permit',
1064
+ kind: 'permit',
1065
+ issuedOn: day(-380),
1066
+ expiresOn: day(20),
1067
+ uploadedBy: null,
1068
+ createdAt: iso(380 * 86_400_000),
1069
+ },
1070
+ {
1071
+ id: id('d0c3'),
1072
+ personId: people[1]!.id,
1073
+ fileId: id('f11e03'),
1074
+ name: 'Employment contract',
1075
+ kind: 'contract',
1076
+ issuedOn: day(-300),
1077
+ expiresOn: null,
1078
+ uploadedBy: null,
1079
+ createdAt: iso(300 * 86_400_000),
1080
+ },
1081
+ ]
1082
+
1083
+ /**
1084
+ * Behind a second permission, and a separate shape for that reason.
1085
+ *
1086
+ * Seeded for one person only: the section has to be able to render "nothing recorded" as well as
1087
+ * a filled-in card, and every other person here is that case.
1088
+ */
1089
+ const sensitive: Row<PersonSensitive>[] = [
1090
+ {
1091
+ personId: people[0]!.id,
1092
+ nationalId: '12345678901',
1093
+ birthDate: '1991-04-17',
1094
+ iban: 'TR33 0006 1005 1978 6457 8413 26',
1095
+ emergencyContact: { name: 'Elif Yılmaz', relationship: 'Sister', phone: '+90 532 000 0000' },
1096
+ },
1097
+ ]
1098
+
1099
+ const monthStart = (offset: number) => {
1100
+ const base = new Date(now)
1101
+ const d = new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth() + offset, 1))
1102
+ return d.toISOString().slice(0, 10)
1103
+ }
1104
+ const monthEnd = (offset: number) => {
1105
+ const base = new Date(now)
1106
+ const d = new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth() + offset + 1, 0))
1107
+ return d.toISOString().slice(0, 10)
1108
+ }
1109
+
1110
+ const periods: Row<Period>[] = [
1111
+ // Last month closed and this month open — the two states the screen switches between, so
1112
+ // neither the lock button nor the reopen warning is reachable only in theory.
1113
+ {
1114
+ id: id('9e01'),
1115
+ kind: 'payroll',
1116
+ legalEntityId: id('1e01'),
1117
+ startsOn: monthStart(-1),
1118
+ endsOn: monthEnd(-1),
1119
+ status: 'locked',
1120
+ lockedAt: iso(5 * 86_400_000),
1121
+ lockedBy: null,
1122
+ note: 'Filed with payroll',
1123
+ },
1124
+ {
1125
+ id: id('9e02'),
1126
+ kind: 'payroll',
1127
+ legalEntityId: id('1e01'),
1128
+ startsOn: monthStart(0),
1129
+ endsOn: monthEnd(0),
1130
+ status: 'open',
1131
+ lockedAt: null,
1132
+ lockedBy: null,
1133
+ note: null,
1134
+ },
1135
+ {
1136
+ id: id('9e03'),
1137
+ kind: 'attendance',
1138
+ legalEntityId: null,
1139
+ startsOn: monthStart(-1),
1140
+ endsOn: monthEnd(-1),
1141
+ status: 'open',
1142
+ lockedAt: null,
1143
+ lockedBy: null,
1144
+ note: null,
1145
+ },
1146
+ ]
1147
+
1148
+ /** Working days in a range — what lock and unlock report as the days they froze or released. */
1149
+ const workingDaysIn = (from: string, to: string) =>
1150
+ eachDate(from, to).filter((date) => {
1151
+ const weekday = WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]!
1152
+ return weekday !== 'sat' && weekday !== 'sun' && date <= day(0)
1153
+ }).length
1154
+
1155
+ // ---------------------------------------------------------------- accrual policies
1156
+
1157
+ /** The ladder made explicit, straight from `SUBJECT_PRIORITY` in the contract. */
1158
+ const PRIORITY: Record<PolicySubjectKind, number> = {
1159
+ person: 100,
1160
+ office: 80,
1161
+ legal_entity: 60,
1162
+ org_unit: 40,
1163
+ position: 30,
1164
+ workspace: 0,
1165
+ }
1166
+
1167
+ /**
1168
+ * Two policies that differ in more than their name.
1169
+ *
1170
+ * A list whose column reads the same sentence twice demonstrates nothing — these take different
1171
+ * branches of the evaluator: one accrues monthly with a waiting period for joiners, the other on
1172
+ * the employee's own anniversary with seniority tiers.
1173
+ */
1174
+ const policies: Row<Policy>[] = [
1175
+ {
1176
+ id: id('b011'),
1177
+ kind: 'accrual',
1178
+ name: 'Monthly accrual',
1179
+ config: {
1180
+ frequency: 'monthly',
1181
+ daysPerYear: 20,
1182
+ minutesPerDay: 480,
1183
+ seniorityTiers: [],
1184
+ waitingPeriodMonths: 3,
1185
+ calendar: 'gregorian',
1186
+ roundToMinutes: 30,
1187
+ leaveTypeKey: 'annual',
1188
+ },
1189
+ effectiveFrom: `${YEAR}-01-01`,
1190
+ effectiveTo: null,
1191
+ source: 'custom',
1192
+ packKey: null,
1193
+ configHash: 'a1b2c3d4',
1194
+ archivedAt: null,
1195
+ },
1196
+ {
1197
+ id: id('b012'),
1198
+ kind: 'accrual',
1199
+ name: 'Anniversary accrual, with seniority',
1200
+ config: {
1201
+ frequency: 'anniversary',
1202
+ daysPerYear: 22,
1203
+ minutesPerDay: 480,
1204
+ seniorityTiers: [
1205
+ { afterYears: 5, daysPerYear: 26 },
1206
+ { afterYears: 10, daysPerYear: 30 },
1207
+ ],
1208
+ waitingPeriodMonths: 0,
1209
+ calendar: 'gregorian',
1210
+ roundToMinutes: 0,
1211
+ leaveTypeKey: 'annual',
1212
+ },
1213
+ effectiveFrom: `${YEAR}-01-01`,
1214
+ effectiveTo: null,
1215
+ source: 'custom',
1216
+ packKey: null,
1217
+ configHash: 'e5f6a7b8',
1218
+ archivedAt: null,
1219
+ },
1220
+ ]
1221
+
1222
+ /**
1223
+ * Two rungs, on purpose.
1224
+ *
1225
+ * The screen's copy says the nearest subject wins; with everything assigned at one rung nothing on
1226
+ * screen tests that claim. The workspace-wide monthly policy is what most people get, and Sanne
1227
+ * has the anniversary one at the `person` rung — which must visibly beat the office below it.
1228
+ */
1229
+ const policyAssignments: Row<PolicyAssignment>[] = [
1230
+ {
1231
+ id: id('b0a1'),
1232
+ policyId: id('b011'),
1233
+ subjectKind: 'workspace',
1234
+ subjectId: null,
1235
+ effectiveFrom: `${YEAR}-01-01`,
1236
+ effectiveTo: null,
1237
+ priority: PRIORITY.workspace,
1238
+ },
1239
+ {
1240
+ id: id('b0a2'),
1241
+ policyId: id('b012'),
1242
+ subjectKind: 'office',
1243
+ subjectId: id('e002'),
1244
+ effectiveFrom: `${YEAR}-01-01`,
1245
+ effectiveTo: null,
1246
+ priority: PRIORITY.office,
1247
+ },
1248
+ // Mehmet has been here five years, so the tier above `afterYears: 5` is the one that answers
1249
+ // for him — without somebody long-serving the seniority branch of the evaluator never fires and
1250
+ // the second policy's whole reason for existing goes untested.
1251
+ {
1252
+ id: id('b0a4'),
1253
+ policyId: id('b012'),
1254
+ subjectKind: 'person',
1255
+ subjectId: people[2]!.id,
1256
+ effectiveFrom: `${YEAR}-01-01`,
1257
+ effectiveTo: null,
1258
+ priority: PRIORITY.person,
1259
+ },
1260
+ {
1261
+ id: id('b0a3'),
1262
+ policyId: id('b012'),
1263
+ subjectKind: 'person',
1264
+ subjectId: people[1]!.id,
1265
+ effectiveFrom: `${YEAR}-01-01`,
1266
+ effectiveTo: null,
1267
+ priority: PRIORITY.person,
1268
+ },
1269
+ ]
1270
+
1271
+ const assignmentsOf = (policyId: string) => policyAssignments.filter((a) => a.policyId === policyId)
1272
+
1273
+ /**
1274
+ * Which policy applies to somebody, and which rung answered.
1275
+ *
1276
+ * Nearest wins, by the priority the contract publishes — so a policy given to one person beats the
1277
+ * one on their office, which beats their legal entity, their department, their position, and
1278
+ * finally the whole workspace. This is the claim the screen's copy makes, so it has to be the
1279
+ * claim the fixture keeps.
1280
+ */
1281
+ function resolvePolicyFor(personId: string) {
1282
+ const officeId = primaryOfficeId(personId)
1283
+ const job = employments.find((e) => e.personId === personId && e.effectiveTo === null)
1284
+ const matches = (a: Row<PolicyAssignment>) => {
1285
+ switch (a.subjectKind) {
1286
+ case 'person':
1287
+ return a.subjectId === personId
1288
+ case 'office':
1289
+ return a.subjectId === officeId
1290
+ case 'legal_entity':
1291
+ return a.subjectId === job?.legalEntityId
1292
+ case 'org_unit':
1293
+ return a.subjectId === job?.orgUnitId
1294
+ case 'position':
1295
+ return a.subjectId === job?.positionId
1296
+ default:
1297
+ return true
1298
+ }
1299
+ }
1300
+ const best = policyAssignments
1301
+ .filter((a) => {
1302
+ if (a.effectiveTo !== null) return false
1303
+ const policy = policies.find((x) => x.id === a.policyId)
1304
+ return Boolean(policy && policy.archivedAt === null) && matches(a)
1305
+ })
1306
+ .sort((a, b) => b.priority - a.priority)[0]
1307
+ const policy = best ? policies.find((x) => x.id === best.policyId) : undefined
1308
+ return best && policy ? { assignment: best, policy } : null
1309
+ }
1310
+
1311
+ const wholeMonthsBetween = (from: string, to: string) => {
1312
+ const a = new Date(`${from}T00:00:00Z`)
1313
+ const b = new Date(`${to}T00:00:00Z`)
1314
+ let months = (b.getUTCFullYear() - a.getUTCFullYear()) * 12 + (b.getUTCMonth() - a.getUTCMonth())
1315
+ if (b.getUTCDate() < a.getUTCDate()) months -= 1
1316
+ return months
1317
+ }
1318
+
1319
+ /**
1320
+ * What a run would credit — computed, never written.
1321
+ *
1322
+ * `preview` and `run` share this function for the reason the contract gives: a preview computed
1323
+ * differently from the thing it previews is a preview that eventually lies. Only `run` writes,
1324
+ * and it writes from these rows.
1325
+ */
1326
+ function accrualRows(from: string, to: string, onlyPersonId?: string) {
1327
+ const rows: Array<{
1328
+ personId: string
1329
+ displayName: string
1330
+ leaveTypeId: string
1331
+ leaveTypeName: string
1332
+ minutes: number
1333
+ days: number
1334
+ reason: string
1335
+ alreadyAccrued: boolean
1336
+ }> = []
1337
+ const skipped: Array<{ personId: string; displayName: string; reason: string }> = []
1338
+
1339
+ for (const who of people) {
1340
+ if (onlyPersonId && who.id !== onlyPersonId) continue
1341
+ const skip = (reason: string) =>
1342
+ skipped.push({ personId: who.id, displayName: who.displayName, reason })
1343
+
1344
+ if (who.status === 'terminated') {
1345
+ skip('No longer employed')
1346
+ continue
1347
+ }
1348
+ const applicable = resolvePolicyFor(who.id)
1349
+ if (!applicable) {
1350
+ skip('No accrual policy applies')
1351
+ continue
1352
+ }
1353
+ const config = applicable.policy.config as {
1354
+ frequency: string
1355
+ daysPerYear: number
1356
+ minutesPerDay: number
1357
+ seniorityTiers: Array<{ afterYears: number; daysPerYear: number }>
1358
+ waitingPeriodMonths: number
1359
+ roundToMinutes: number
1360
+ leaveTypeKey: string
1361
+ }
1362
+ const served = wholeMonthsBetween(who.hiredOn, from)
1363
+ if (served < config.waitingPeriodMonths) {
1364
+ skip(`Within the ${config.waitingPeriodMonths}-month waiting period — ${served} served`)
1365
+ continue
1366
+ }
1367
+ const leaveType = leaveTypes.find((lt) => lt.key === config.leaveTypeKey && lt.archivedAt === null)
1368
+ if (!leaveType) {
1369
+ skip(`No leave type keyed "${config.leaveTypeKey}"`)
1370
+ continue
1371
+ }
1372
+
1373
+ // Most senior tier reached wins, whatever order they are written in.
1374
+ const years = Math.floor(served / 12)
1375
+ const tier = [...(config.seniorityTiers ?? [])]
1376
+ .filter((t) => years >= t.afterYears)
1377
+ .sort((a, b) => b.afterYears - a.afterYears)[0]
1378
+ const perYear = tier?.daysPerYear ?? config.daysPerYear
1379
+
1380
+ let minutes = Math.round((perYear * config.minutesPerDay) / 12)
1381
+ if (config.roundToMinutes > 0) {
1382
+ minutes = Math.round(minutes / config.roundToMinutes) * config.roundToMinutes
1383
+ }
1384
+ const alreadyAccrued = ledger.some(
1385
+ (e) =>
1386
+ e.personId === who.id &&
1387
+ e.leaveTypeId === leaveType.id &&
1388
+ e.kind === 'accrual' &&
1389
+ e.effectiveOn >= from &&
1390
+ e.effectiveOn <= to,
1391
+ )
1392
+ const basis = tier
1393
+ ? `${perYear} days a year after ${tier.afterYears} years' service`
1394
+ : `${perYear} days a year`
1395
+ rows.push({
1396
+ personId: who.id,
1397
+ displayName: who.displayName,
1398
+ leaveTypeId: leaveType.id,
1399
+ leaveTypeName: leaveType.name,
1400
+ minutes,
1401
+ days: Math.round((minutes / config.minutesPerDay) * 100) / 100,
1402
+ reason: alreadyAccrued
1403
+ ? `${basis}, ${config.frequency} — already credited for this period`
1404
+ : `${basis}, ${config.frequency}`,
1405
+ alreadyAccrued,
1406
+ })
1407
+ }
1408
+
1409
+ return {
1410
+ periodFrom: from,
1411
+ periodTo: to,
1412
+ rows,
1413
+ // What the run would actually add: a row already credited contributes nothing to it.
1414
+ totalMinutes: rows.filter((r) => !r.alreadyAccrued).reduce((sum, r) => sum + r.minutes, 0),
1415
+ skipped,
1416
+ }
1417
+ }
1418
+
1419
+ // ---------------------------------------------------------------- approval chains
1420
+
1421
+ const chains: Row<ApprovalChain>[] = [
1422
+ {
1423
+ id: id('ca11'),
1424
+ name: 'Leave — manager then HR',
1425
+ subjectType: 'leave',
1426
+ isDefault: true,
1427
+ archivedAt: null,
1428
+ spec: {
1429
+ steps: [
1430
+ {
1431
+ name: 'Manager',
1432
+ approvers: [{ kind: 'manager' }],
1433
+ mode: 'any',
1434
+ minApprovals: 1,
1435
+ slaHours: 48,
1436
+ onTimeout: 'remind',
1437
+ },
1438
+ {
1439
+ name: 'HR',
1440
+ approvers: [{ kind: 'permission', id: 'hr.leave.manage' }],
1441
+ mode: 'any',
1442
+ minApprovals: 1,
1443
+ slaHours: 72,
1444
+ onTimeout: 'escalate',
1445
+ },
1446
+ ],
1447
+ },
1448
+ },
1449
+ // Not the default, so the table has a row without the in-use badge — which is the case the
1450
+ // column's description is about, and a table where every row looks the same never shows it.
1451
+ {
1452
+ id: id('ca12'),
1453
+ name: 'Leave — local HR only',
1454
+ subjectType: 'leave',
1455
+ isDefault: false,
1456
+ archivedAt: null,
1457
+ spec: {
1458
+ steps: [
1459
+ {
1460
+ name: 'Office head',
1461
+ approvers: [{ kind: 'office_head' }],
1462
+ mode: 'any',
1463
+ minApprovals: 1,
1464
+ slaHours: null,
1465
+ onTimeout: 'remind',
1466
+ },
1467
+ ],
1468
+ },
1469
+ },
1470
+ {
1471
+ id: id('ca13'),
1472
+ name: 'Corrections — manager',
1473
+ subjectType: 'regularization',
1474
+ isDefault: true,
1475
+ archivedAt: null,
1476
+ spec: {
1477
+ steps: [
1478
+ {
1479
+ name: 'Manager',
1480
+ approvers: [{ kind: 'manager' }, { kind: 'org_unit_head' }],
1481
+ mode: 'quorum',
1482
+ minApprovals: 1,
1483
+ slaHours: 24,
1484
+ onTimeout: 'auto_approve',
1485
+ },
1486
+ ],
1487
+ },
1488
+ },
1489
+ ]
1490
+
1491
+ /** Exactly one default per subject type, which is what `clearDefaultChain` keeps true. */
1492
+ const clearDefaultChain = (subjectType: string, except: string) => {
1493
+ for (const chain of chains) {
1494
+ if (chain.subjectType === subjectType && chain.id !== except) chain.isDefault = false
1495
+ }
1496
+ }
1497
+
707
1498
  const delegations: Array<Record<string, unknown>> = []
708
1499
 
709
1500
  const approvalRequests = [
@@ -728,8 +1519,8 @@ export function createMockHrApi() {
728
1519
  subjectType: 'regularization' as const,
729
1520
  workspaceId: '',
730
1521
  subjectId: id('c002'),
731
- summary: `Correction for ${day(-1)}`,
732
- summaryParams: { date: day(-1) } as Record<string, string | number> | null,
1522
+ summary: `Correction for ${WD[1]}`,
1523
+ summaryParams: { date: WD[1]! } as Record<string, string | number> | null,
733
1524
  status: 'pending' as string,
734
1525
  currentStep: 0,
735
1526
  requestedBy: null,
@@ -778,20 +1569,141 @@ export function createMockHrApi() {
778
1569
  createdAt: iso(),
779
1570
  updatedAt: iso(),
780
1571
  },
1572
+ // The row `f003` in the approvals inbox refers to. It had no request behind it, so the decided
1573
+ // tab named a subject nothing could open — and `withdrawn`, which only an approved request can
1574
+ // reach, was unreachable in the mock.
1575
+ {
1576
+ id: id('c003'),
1577
+ workspaceId: '',
1578
+ personId: people[0]!.id,
1579
+ leaveTypeId: id('b001'),
1580
+ startsOn: day(-20),
1581
+ endsOn: day(-20),
1582
+ startPart: 'full',
1583
+ endPart: 'full',
1584
+ hours: null,
1585
+ workingDays: 1,
1586
+ minutes: 480,
1587
+ status: 'approved',
1588
+ reason: null,
1589
+ documentFileId: null,
1590
+ approvalRequestId: id('f003'),
1591
+ decidedAt: iso(19 * 86_400_000),
1592
+ createdAt: iso(20 * 86_400_000),
1593
+ updatedAt: iso(19 * 86_400_000),
1594
+ },
1595
+ // Booked, then cancelled. The ledger below carries its consumption *and* the reversal that
1596
+ // undid it, rather than the consumption having quietly disappeared.
1597
+ {
1598
+ id: id('c004'),
1599
+ workspaceId: '',
1600
+ personId: people[0]!.id,
1601
+ leaveTypeId: id('b001'),
1602
+ startsOn: day(-45),
1603
+ endsOn: day(-44),
1604
+ startPart: 'full',
1605
+ endPart: 'full',
1606
+ hours: null,
1607
+ workingDays: 2,
1608
+ minutes: 2 * 480,
1609
+ status: 'cancelled',
1610
+ reason: null,
1611
+ documentFileId: null,
1612
+ approvalRequestId: null,
1613
+ decidedAt: iso(50 * 86_400_000),
1614
+ createdAt: iso(60 * 86_400_000),
1615
+ updatedAt: iso(50 * 86_400_000),
1616
+ },
781
1617
  ]
782
1618
 
783
- return {
784
- people: {
785
- list: async ({
786
- workspaceId,
787
- q,
788
- officeId,
789
- status,
790
- limit = 50,
791
- }: {
792
- workspaceId: string
793
- q?: string
794
- officeId?: string
1619
+ /**
1620
+ * The movements behind a balance.
1621
+ *
1622
+ * This is the screen somebody opens when they disagree with a number, so it has to contain the
1623
+ * shape of the argument: an entitlement granted, months of accrual, leave spent — and a
1624
+ * **reversal sitting beside the consumption it reverses**, because a cancelled booking that
1625
+ * showed as a gap would misrepresent the one property the ledger exists to have. Nothing here is
1626
+ * ever edited or removed; a mistake is another row.
1627
+ */
1628
+ let ledgerCounter = 0
1629
+ const entry = (
1630
+ personId: string,
1631
+ leaveTypeId: string,
1632
+ kind: LeaveLedgerEntry['kind'],
1633
+ amountMinutes: number,
1634
+ effectiveOn: string,
1635
+ over: Partial<Row<LeaveLedgerEntry>> = {},
1636
+ ): Row<LeaveLedgerEntry> => ({
1637
+ id: id(`1ed${(++ledgerCounter).toString(16).padStart(3, '0')}`),
1638
+ personId,
1639
+ leaveTypeId,
1640
+ kind,
1641
+ amountMinutes,
1642
+ effectiveOn,
1643
+ periodYear: Number(effectiveOn.slice(0, 4)),
1644
+ requestId: null,
1645
+ reversesEntryId: null,
1646
+ policyHash: null,
1647
+ reason: null,
1648
+ createdBy: null,
1649
+ createdAt: iso(),
1650
+ ...over,
1651
+ })
1652
+
1653
+ const ANNUAL = id('b001')
1654
+ const SICK = id('b002')
1655
+ const ME = people[0]!.id
1656
+
1657
+ const carriedIn = entry(ME, ANNUAL, 'carry_in', 3 * 480, `${YEAR}-01-01`, {
1658
+ reason: 'Carried forward from last year',
1659
+ })
1660
+ const spentThenCancelled = entry(ME, ANNUAL, 'consumption', -2 * 480, day(-45), {
1661
+ requestId: id('c004'),
1662
+ reason: '2 days',
1663
+ })
1664
+
1665
+ const ledger: Row<LeaveLedgerEntry>[] = [
1666
+ carriedIn,
1667
+ entry(ME, ANNUAL, 'grant', 20 * 480, `${YEAR}-01-01`, { reason: 'Annual entitlement' }),
1668
+ entry(ME, ANNUAL, 'accrual', 480, `${YEAR}-01-31`),
1669
+ entry(ME, ANNUAL, 'accrual', 480, `${YEAR}-02-28`),
1670
+ entry(ME, ANNUAL, 'accrual', 480, `${YEAR}-03-31`),
1671
+ // The carry-forward deadline came and went, and what was left of last year lapsed.
1672
+ entry(ME, ANNUAL, 'expiry', -3 * 480, `${YEAR}-03-31`, {
1673
+ reversesEntryId: carriedIn.id,
1674
+ reason: 'Carry-forward deadline',
1675
+ }),
1676
+ spentThenCancelled,
1677
+ entry(ME, ANNUAL, 'reversal', 2 * 480, day(-50), {
1678
+ requestId: id('c004'),
1679
+ reversesEntryId: spentThenCancelled.id,
1680
+ reason: 'Request cancelled',
1681
+ }),
1682
+ entry(ME, ANNUAL, 'consumption', -480, day(-20), { requestId: id('c003'), reason: '1 day' }),
1683
+ entry(ME, ANNUAL, 'adjustment', 480, day(-10), {
1684
+ reason: 'Public holiday fell inside an approved request',
1685
+ }),
1686
+ entry(ME, SICK, 'grant', 10 * 480, `${YEAR}-01-01`, { reason: 'Annual entitlement' }),
1687
+ ]
1688
+ // Everybody else gets their entitlement and nothing else: a balance of zero for three of four
1689
+ // people would read as a broken tile rather than as an untouched allowance.
1690
+ for (const other of people.slice(1)) {
1691
+ ledger.push(entry(other.id, ANNUAL, 'grant', 20 * 480, `${YEAR}-01-01`, { reason: 'Annual entitlement' }))
1692
+ ledger.push(entry(other.id, SICK, 'grant', 10 * 480, `${YEAR}-01-01`, { reason: 'Annual entitlement' }))
1693
+ }
1694
+
1695
+ return {
1696
+ people: {
1697
+ list: async ({
1698
+ workspaceId,
1699
+ q,
1700
+ officeId,
1701
+ status,
1702
+ limit = 50,
1703
+ }: {
1704
+ workspaceId: string
1705
+ q?: string
1706
+ officeId?: string
795
1707
  status?: string[]
796
1708
  limit?: number
797
1709
  }) => {
@@ -832,6 +1744,7 @@ export function createMockHrApi() {
832
1744
  workEmail: input.workEmail ?? '',
833
1745
  status: 'active' as const,
834
1746
  timezone: 'Europe/Istanbul',
1747
+ hiredOn: input.hiredOn ?? day(0),
835
1748
  employeeNo: input.employeeNo ?? `E-${people.length + 1}`,
836
1749
  }
837
1750
  people.push(added)
@@ -866,6 +1779,52 @@ export function createMockHrApi() {
866
1779
  phone: input.phone ?? null,
867
1780
  }
868
1781
  },
1782
+ /**
1783
+ * Behind a second permission, and never folded into `Person`.
1784
+ *
1785
+ * Returns an empty record rather than refusing when nothing is on file: "nothing recorded" is
1786
+ * an ordinary answer and a 404 would make the section look broken for most of the directory.
1787
+ */
1788
+ sensitive: {
1789
+ get: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => {
1790
+ const row = sensitive.find((x) => x.personId === personId)
1791
+ return {
1792
+ workspaceId,
1793
+ personId,
1794
+ nationalId: row?.nationalId ?? null,
1795
+ birthDate: row?.birthDate ?? null,
1796
+ iban: row?.iban ?? null,
1797
+ emergencyContact: row?.emergencyContact ?? null,
1798
+ }
1799
+ },
1800
+
1801
+ update: async (input: {
1802
+ workspaceId: string
1803
+ personId: string
1804
+ nationalId?: string | null
1805
+ birthDate?: string | null
1806
+ iban?: string | null
1807
+ emergencyContact?: PersonSensitive['emergencyContact']
1808
+ }) => {
1809
+ let row = sensitive.find((x) => x.personId === input.personId)
1810
+ if (!row) {
1811
+ row = {
1812
+ personId: input.personId,
1813
+ nationalId: null,
1814
+ birthDate: null,
1815
+ iban: null,
1816
+ emergencyContact: null,
1817
+ }
1818
+ sensitive.push(row)
1819
+ }
1820
+ if (input.nationalId !== undefined) row.nationalId = input.nationalId
1821
+ if (input.birthDate !== undefined) row.birthDate = input.birthDate
1822
+ if (input.iban !== undefined) row.iban = input.iban
1823
+ if (input.emergencyContact !== undefined) row.emergencyContact = input.emergencyContact
1824
+ return { ...row, workspaceId: input.workspaceId }
1825
+ },
1826
+ },
1827
+
869
1828
  offboard: async (input: { workspaceId: string; personId: string; on: string }) => {
870
1829
  const found = people.find((p) => p.id === input.personId) ?? people[0]!
871
1830
  found.status = 'terminated'
@@ -874,23 +1833,270 @@ export function createMockHrApi() {
874
1833
  },
875
1834
 
876
1835
  employment: {
877
- current: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => ({
878
- id: id('ee01'),
879
- workspaceId,
880
- personId,
881
- effectiveFrom: day(-400),
882
- effectiveTo: null,
883
- orgUnitId: null,
884
- positionId: null,
885
- legalEntityId: null,
886
- costCenterId: null,
887
- managerPersonId: people.find((x) => x.id !== personId)?.id ?? null,
888
- employmentType: 'full_time' as const,
889
- fte: 1,
890
- contractHoursWeek: 40,
891
- reason: null,
892
- createdAt: iso(),
893
- }),
1836
+ /**
1837
+ * The open row, or null.
1838
+ *
1839
+ * Read out of the same table the org chart counts rather than synthesised beside it — a
1840
+ * person's department on their own page and the headcount on the chart have to be the same
1841
+ * fact. Null is an ordinary answer: a record created a minute ago has no employment yet.
1842
+ */
1843
+ current: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => {
1844
+ const row = employments.find((e) => e.personId === personId && e.effectiveTo === null)
1845
+ return row ? { ...row, workspaceId } : null
1846
+ },
1847
+
1848
+ /** Newest first: the question this answers is "what changed", and the last change is the news. */
1849
+ history: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) =>
1850
+ employments
1851
+ .filter((e) => e.personId === personId)
1852
+ .slice()
1853
+ .sort((a, b) => b.effectiveFrom.localeCompare(a.effectiveFrom))
1854
+ .map((e) => ({ ...e, workspaceId })),
1855
+
1856
+ /**
1857
+ * Closes the open row and opens a new one. Never an update.
1858
+ *
1859
+ * Overwriting would lose the answer to "who did she report to in March", which is the
1860
+ * question a leave approval from March needs — so the previous row is closed the day before
1861
+ * the new one starts and everything unstated is carried forward from it.
1862
+ */
1863
+ change: async (input: {
1864
+ workspaceId: string
1865
+ personId: string
1866
+ effectiveFrom: string
1867
+ orgUnitId?: string | null
1868
+ positionId?: string | null
1869
+ legalEntityId?: string | null
1870
+ costCenterId?: string | null
1871
+ managerPersonId?: string | null
1872
+ employmentType?: Employment['employmentType']
1873
+ fte?: number
1874
+ contractHoursWeek?: number | null
1875
+ reason?: string | null
1876
+ }) => {
1877
+ const open = employments.find((e) => e.personId === input.personId && e.effectiveTo === null)
1878
+ if (open) {
1879
+ const dayBefore = new Date(Date.parse(`${input.effectiveFrom}T00:00:00Z`) - 86_400_000)
1880
+ open.effectiveTo = dayBefore.toISOString().slice(0, 10)
1881
+ }
1882
+ const created: Row<Employment> = {
1883
+ id: crypto.randomUUID(),
1884
+ personId: input.personId,
1885
+ effectiveFrom: input.effectiveFrom,
1886
+ effectiveTo: null,
1887
+ orgUnitId: input.orgUnitId !== undefined ? input.orgUnitId : (open?.orgUnitId ?? null),
1888
+ positionId: input.positionId !== undefined ? input.positionId : (open?.positionId ?? null),
1889
+ legalEntityId:
1890
+ input.legalEntityId !== undefined ? input.legalEntityId : (open?.legalEntityId ?? null),
1891
+ costCenterId: input.costCenterId !== undefined ? input.costCenterId : (open?.costCenterId ?? null),
1892
+ managerPersonId:
1893
+ input.managerPersonId !== undefined ? input.managerPersonId : (open?.managerPersonId ?? null),
1894
+ employmentType: input.employmentType ?? open?.employmentType ?? 'full_time',
1895
+ fte: input.fte ?? open?.fte ?? 1,
1896
+ contractHoursWeek:
1897
+ input.contractHoursWeek !== undefined
1898
+ ? input.contractHoursWeek
1899
+ : (open?.contractHoursWeek ?? null),
1900
+ reason: input.reason ?? null,
1901
+ createdAt: iso(),
1902
+ }
1903
+ employments.push(created)
1904
+ return { ...created, workspaceId: input.workspaceId }
1905
+ },
1906
+ },
1907
+
1908
+ documents: {
1909
+ list: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) =>
1910
+ documents
1911
+ .filter((d) => d.personId === personId)
1912
+ .slice()
1913
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
1914
+ .map((d) => ({ ...d, workspaceId })),
1915
+
1916
+ attach: async (input: {
1917
+ workspaceId: string
1918
+ personId: string
1919
+ fileId: string
1920
+ name: string
1921
+ kind?: string
1922
+ issuedOn?: string | null
1923
+ expiresOn?: string | null
1924
+ }) => {
1925
+ const created: Row<PersonDocument> = {
1926
+ id: crypto.randomUUID(),
1927
+ personId: input.personId,
1928
+ fileId: input.fileId,
1929
+ name: input.name,
1930
+ kind: input.kind ?? 'other',
1931
+ issuedOn: input.issuedOn ?? null,
1932
+ expiresOn: input.expiresOn ?? null,
1933
+ uploadedBy: null,
1934
+ createdAt: iso(),
1935
+ }
1936
+ documents.push(created)
1937
+ return { ...created, workspaceId: input.workspaceId }
1938
+ },
1939
+
1940
+ remove: async ({ documentId }: { workspaceId: string; personId: string; documentId: string }) => {
1941
+ const at = documents.findIndex((d) => d.id === documentId)
1942
+ if (at < 0) refuse('NOT_FOUND', 'Document not found')
1943
+ documents.splice(at, 1)
1944
+ return { ok: true as const }
1945
+ },
1946
+ },
1947
+
1948
+ org: {
1949
+ units: {
1950
+ tree: async ({
1951
+ workspaceId,
1952
+ includeArchived = false,
1953
+ }: {
1954
+ workspaceId: string
1955
+ includeArchived?: boolean
1956
+ }) =>
1957
+ orgUnits
1958
+ .filter((u) => includeArchived || u.archivedAt === null)
1959
+ .slice()
1960
+ .sort((a, b) => a.path.localeCompare(b.path))
1961
+ .map((u) => ({ ...u, workspaceId, headcount: unitHeadcount(u.id) })),
1962
+
1963
+ create: async (input: {
1964
+ workspaceId: string
1965
+ name: string
1966
+ parentId?: string | null
1967
+ code?: string | null
1968
+ headPersonId?: string | null
1969
+ }) => {
1970
+ const unitId = crypto.randomUUID()
1971
+ const created: Row<OrgUnit> = {
1972
+ id: unitId,
1973
+ parentId: input.parentId ?? null,
1974
+ path: pathFor(input.parentId ?? null, unitId),
1975
+ name: input.name,
1976
+ code: input.code ?? null,
1977
+ headPersonId: input.headPersonId ?? null,
1978
+ archivedAt: null,
1979
+ }
1980
+ orgUnits.push(created)
1981
+ return { ...created, workspaceId: input.workspaceId }
1982
+ },
1983
+
1984
+ update: async (input: {
1985
+ workspaceId: string
1986
+ unitId: string
1987
+ name?: string
1988
+ code?: string | null
1989
+ headPersonId?: string | null
1990
+ }) => {
1991
+ const found = orgUnits.find((u) => u.id === input.unitId)
1992
+ if (!found) refuse('NOT_FOUND', 'Department not found')
1993
+ if (input.name !== undefined) found.name = input.name
1994
+ if (input.code !== undefined) found.code = input.code
1995
+ if (input.headPersonId !== undefined) found.headPersonId = input.headPersonId
1996
+ return { ...found, workspaceId: input.workspaceId }
1997
+ },
1998
+
1999
+ /**
2000
+ * Reparent a unit and rewrite the path of everything beneath it.
2001
+ *
2002
+ * Moving a unit under its own descendant would detach that branch from the root — the one
2003
+ * way an ltree hierarchy is corrupted beyond repair by an ordinary drag — so it is refused
2004
+ * before anything is written, exactly as the router refuses it.
2005
+ */
2006
+ move: async (input: { workspaceId: string; unitId: string; parentId: string | null }) => {
2007
+ const unit = orgUnits.find((u) => u.id === input.unitId)
2008
+ if (!unit) refuse('NOT_FOUND', 'Department not found')
2009
+ let parentPath: string | null = null
2010
+ if (input.parentId) {
2011
+ const target = orgUnits.find((u) => u.id === input.parentId)
2012
+ if (!target) refuse('NOT_FOUND', 'Department not found')
2013
+ if (target.path === unit.path || target.path.startsWith(`${unit.path}.`)) {
2014
+ refuse('BAD_REQUEST', 'A department cannot be moved underneath itself.')
2015
+ }
2016
+ parentPath = target.path
2017
+ }
2018
+ const label = unit.path.split('.').pop()!
2019
+ const nextPath = parentPath ? `${parentPath}.${label}` : label
2020
+ // The whole subtree in one pass, the way the server's single UPDATE does it — walking
2021
+ // and reparenting one node at a time is where a half-moved branch comes from.
2022
+ const moved = descendants(unit.id)
2023
+ const wasPath = unit.path
2024
+ for (const row of moved) row.path = `${nextPath}${row.path.slice(wasPath.length)}`
2025
+ unit.parentId = input.parentId
2026
+ return moved
2027
+ .slice()
2028
+ .sort((a, b) => a.path.localeCompare(b.path))
2029
+ .map((u) => ({ ...u, workspaceId: input.workspaceId }))
2030
+ },
2031
+
2032
+ archive: async ({ unitId }: { workspaceId: string; unitId: string }) => {
2033
+ const found = orgUnits.find((u) => u.id === unitId)
2034
+ if (!found) refuse('NOT_FOUND', 'Department not found')
2035
+ const held = unitHeadcount(unitId)
2036
+ if (held > 0) {
2037
+ refuse('CONFLICT', `${held} people still report into this department. Move them first.`)
2038
+ }
2039
+ found.archivedAt = iso()
2040
+ return { ok: true as const }
2041
+ },
2042
+ },
2043
+
2044
+ positions: {
2045
+ list: async ({
2046
+ workspaceId,
2047
+ includeArchived = false,
2048
+ }: {
2049
+ workspaceId: string
2050
+ includeArchived?: boolean
2051
+ }) =>
2052
+ positions
2053
+ .filter((row) => includeArchived || row.archivedAt === null)
2054
+ .map((row) => ({ ...row, workspaceId })),
2055
+
2056
+ create: async (input: {
2057
+ workspaceId: string
2058
+ title: string
2059
+ code?: string | null
2060
+ jobFamily?: string | null
2061
+ level?: string | null
2062
+ }) => {
2063
+ const created: Row<Position> = {
2064
+ id: crypto.randomUUID(),
2065
+ title: input.title,
2066
+ code: input.code ?? null,
2067
+ jobFamily: input.jobFamily ?? null,
2068
+ level: input.level ?? null,
2069
+ archivedAt: null,
2070
+ }
2071
+ positions.push(created)
2072
+ return { ...created, workspaceId: input.workspaceId }
2073
+ },
2074
+
2075
+ update: async (input: {
2076
+ workspaceId: string
2077
+ positionId: string
2078
+ title?: string
2079
+ code?: string | null
2080
+ jobFamily?: string | null
2081
+ level?: string | null
2082
+ }) => {
2083
+ const found = positions.find((row) => row.id === input.positionId)
2084
+ if (!found) refuse('NOT_FOUND', 'Position not found')
2085
+ if (input.title !== undefined) found.title = input.title
2086
+ if (input.code !== undefined) found.code = input.code
2087
+ if (input.jobFamily !== undefined) found.jobFamily = input.jobFamily
2088
+ if (input.level !== undefined) found.level = input.level
2089
+ return { ...found, workspaceId: input.workspaceId }
2090
+ },
2091
+
2092
+ // No refusal here: the router archives a position without checking who holds it.
2093
+ archive: async ({ positionId }: { workspaceId: string; positionId: string }) => {
2094
+ const found = positions.find((row) => row.id === positionId)
2095
+ if (!found) refuse('NOT_FOUND', 'Position not found')
2096
+ found.archivedAt = iso()
2097
+ return { ok: true as const }
2098
+ },
2099
+ },
894
2100
  },
895
2101
 
896
2102
  offices: {
@@ -1593,28 +2799,102 @@ export function createMockHrApi() {
1593
2799
  },
1594
2800
  },
1595
2801
  balance: {
1596
- /** One row per type somebody can still book, so the tiles say as much as the picker offers. */
1597
- get: async ({ personId }: { personId?: string }) =>
1598
- leaveTypes
2802
+ /**
2803
+ * Summed from the ledger, never stored.
2804
+ *
2805
+ * The ledger screen exists to explain this number, so a tile that stated its own would
2806
+ * contradict the screen that opens from it — on first click, which is the worst place for
2807
+ * two numbers to disagree.
2808
+ */
2809
+ get: async ({ personId, periodYear }: { personId?: string; periodYear?: number }) => {
2810
+ const who = personId ?? people[0]!.id
2811
+ const year = periodYear ?? YEAR
2812
+ return leaveTypes
1599
2813
  .filter((lt) => lt.archivedAt === null)
1600
- .map((lt, index) => {
1601
- const balanceMinutes = [20, 10, 0][index] ?? 0
1602
- const pending = index === 0 ? 5 : 0
2814
+ .map((lt) => {
2815
+ const rows = ledger.filter(
2816
+ (e) => e.personId === who && e.leaveTypeId === lt.id && e.periodYear === year,
2817
+ )
2818
+ const balanceMinutes = rows.reduce((sum, e) => sum + e.amountMinutes, 0)
2819
+ const mine = leaveRequests.filter(
2820
+ (r) => r.personId === who && r.leaveTypeId === lt.id,
2821
+ ) as Array<Record<string, unknown>>
2822
+ const minutesOf = (status: string) =>
2823
+ mine.filter((r) => r.status === status).reduce((sum, r) => sum + Number(r.minutes ?? 0), 0)
2824
+ const pendingMinutes = minutesOf('pending')
2825
+ const bookedMinutes = minutesOf('approved')
2826
+ const perUnit = lt.unit === 'hour' ? 60 : 480
1603
2827
  return {
1604
- personId: personId ?? people[0]!.id,
2828
+ personId: who,
1605
2829
  leaveTypeId: lt.id,
1606
2830
  leaveTypeName: lt.name,
1607
2831
  unit: lt.unit,
1608
- periodYear: YEAR,
1609
- balanceMinutes: balanceMinutes * 480,
1610
- bookedMinutes: 0,
1611
- pendingMinutes: pending * 480,
1612
- availableMinutes: (balanceMinutes - pending) * 480,
1613
- balance: balanceMinutes,
1614
- available: balanceMinutes - pending,
2832
+ periodYear: year,
2833
+ balanceMinutes,
2834
+ bookedMinutes,
2835
+ pendingMinutes,
2836
+ availableMinutes: balanceMinutes - pendingMinutes,
2837
+ balance: Math.round((balanceMinutes / perUnit) * 100) / 100,
2838
+ available: Math.round(((balanceMinutes - pendingMinutes) / perUnit) * 100) / 100,
1615
2839
  }
1616
- }),
2840
+ })
2841
+ },
2842
+ },
2843
+
2844
+ ledger: {
2845
+ /** Newest first, so the movement somebody is arguing about is the one at the top. */
2846
+ list: async ({
2847
+ workspaceId,
2848
+ personId,
2849
+ leaveTypeId,
2850
+ periodYear,
2851
+ limit = 50,
2852
+ }: {
2853
+ workspaceId: string
2854
+ personId: string
2855
+ leaveTypeId?: string
2856
+ periodYear?: number
2857
+ limit?: number
2858
+ }) => {
2859
+ const items = ledger
2860
+ .filter(
2861
+ (e) =>
2862
+ e.personId === personId &&
2863
+ (!leaveTypeId || e.leaveTypeId === leaveTypeId) &&
2864
+ (periodYear === undefined || e.periodYear === periodYear),
2865
+ )
2866
+ .slice()
2867
+ .sort((a, b) => b.effectiveOn.localeCompare(a.effectiveOn) || b.id.localeCompare(a.id))
2868
+ return {
2869
+ items: items.slice(0, limit).map((e) => ({ ...e, workspaceId })),
2870
+ nextCursor: null,
2871
+ total: items.length,
2872
+ }
2873
+ },
1617
2874
  },
2875
+
2876
+ /** Appends. There is no edit and no delete — a wrong adjustment is corrected by another row. */
2877
+ adjust: async (input: {
2878
+ workspaceId: string
2879
+ personId: string
2880
+ leaveTypeId: string
2881
+ kind?: LeaveLedgerEntry['kind']
2882
+ amountMinutes: number
2883
+ effectiveOn: string
2884
+ reason: string
2885
+ }) => {
2886
+ const created = entry(
2887
+ input.personId,
2888
+ input.leaveTypeId,
2889
+ input.kind ?? 'adjustment',
2890
+ input.amountMinutes,
2891
+ input.effectiveOn,
2892
+ { reason: input.reason },
2893
+ )
2894
+ ledger.push(created)
2895
+ return { ...created, workspaceId: input.workspaceId }
2896
+ },
2897
+
1618
2898
  requests: {
1619
2899
  list: async ({ workspaceId }: { workspaceId: string }) => ({
1620
2900
  items: leaveRequests.map((r) => ({ ...r, workspaceId })),
@@ -1672,9 +2952,29 @@ export function createMockHrApi() {
1672
2952
  leaveRequests.push(row)
1673
2953
  return row
1674
2954
  },
2955
+ /**
2956
+ * Two end states, not one, and each refuses differently.
2957
+ *
2958
+ * `withdrawn` is the requester taking approved leave back and `cancelled` is a request that
2959
+ * never got that far — telling somebody their own withdrawal was "already cancelled" is a
2960
+ * small lie about who did what, which is why the router carries a reason beside each
2961
+ * sentence rather than one sentence for both.
2962
+ *
2963
+ * The old version fell back to `leaveRequests[0]` when the id did not match, so an unknown
2964
+ * id cancelled somebody else's leave and reported success.
2965
+ */
1675
2966
  cancel: async ({ requestId }: { workspaceId: string; requestId: string }) => {
1676
- const row = leaveRequests.find((r) => r.id === requestId) ?? leaveRequests[0]!
1677
- row.status = 'cancelled'
2967
+ const row = leaveRequests.find((r) => r.id === requestId)
2968
+ if (!row) refuse('NOT_FOUND', 'Leave request not found')
2969
+ if (row.status === 'cancelled') {
2970
+ refuse('CONFLICT', 'That request is already cancelled.', 'hr.leave.already_cancelled')
2971
+ }
2972
+ if (row.status === 'withdrawn') {
2973
+ refuse('CONFLICT', 'That request was already withdrawn.', 'hr.leave.already_withdrawn')
2974
+ }
2975
+ row.status = row.status === 'approved' ? 'withdrawn' : 'cancelled'
2976
+ row.decidedAt = iso()
2977
+ row.updatedAt = iso()
1678
2978
  return { ...row }
1679
2979
  },
1680
2980
  },
@@ -1715,56 +3015,192 @@ export function createMockHrApi() {
1715
3015
  * constraint error, and the widget renders that sentence. A mock that accepts all four
1716
3016
  * leaves a probe edited into a component as the only way to reach that branch — which is
1717
3017
  * exactly what happened, in a file nobody meant to ship.
3018
+ *
3019
+ * Each carries the router's `reason` beside its sentence. The sentence is English; the reason
3020
+ * is what a client can translate, and it reaches `data.reason` on both sides.
1718
3021
  */
1719
3022
  clockIn: async () => {
1720
- if (clockedInAt !== null) refuse('CONFLICT', 'You are already clocked in.')
3023
+ if (clockedInAt !== null)
3024
+ refuse('CONFLICT', 'You are already clocked in.', 'hr.clock.already_clocked_in')
1721
3025
  clockedInAt = Date.now()
1722
3026
  return mockPunch('in')
1723
3027
  },
1724
3028
  clockOut: async () => {
1725
- if (clockedInAt === null) refuse('CONFLICT', 'You are not clocked in.')
3029
+ if (clockedInAt === null) refuse('CONFLICT', 'You are not clocked in.', 'hr.clock.not_clocked_in')
1726
3030
  clockedInAt = null
1727
3031
  onBreak = false
1728
3032
  return mockPunch('out')
1729
3033
  },
1730
3034
  breakStart: async () => {
1731
- if (clockedInAt === null) refuse('CONFLICT', 'Clock in before starting a break.')
1732
- if (onBreak) refuse('CONFLICT', 'You are already on a break.')
3035
+ if (clockedInAt === null)
3036
+ refuse('CONFLICT', 'Clock in before starting a break.', 'hr.clock.break_before_clock_in')
3037
+ if (onBreak) refuse('CONFLICT', 'You are already on a break.', 'hr.clock.already_on_break')
1733
3038
  onBreak = true
1734
3039
  return mockPunch('break_start')
1735
3040
  },
1736
3041
  breakEnd: async () => {
1737
- if (!onBreak) refuse('CONFLICT', 'You are not on a break.')
3042
+ if (!onBreak) refuse('CONFLICT', 'You are not on a break.', 'hr.clock.not_on_break')
1738
3043
  onBreak = false
1739
3044
  return mockPunch('break_end')
1740
3045
  },
1741
3046
  days: {
1742
- list: async ({ workspaceId }: { workspaceId: string }) => ({
1743
- items: [0, 1, 2, 3, 4].map((n) => ({
1744
- id: id(`a000${n}`),
1745
- workspaceId,
1746
- personId: people[0]!.id,
1747
- businessDate: day(-n),
1748
- scheduledMinutes: 480,
1749
- workedMinutes: n === 2 ? 0 : 480 + (n === 1 ? 45 : 0),
1750
- breakMinutes: 60,
1751
- overtimeMinutes: n === 1 ? 45 : 0,
1752
- // Null, not zero: the demo workspace has no overtime policy with an annual cap, and
1753
- // "no ceiling applied" is a different fact from "one applied and nothing exceeded it".
1754
- beyondCapMinutes: null,
1755
- lateMinutes: 0,
1756
- earlyLeaveMinutes: 0,
1757
- status: n === 2 ? ('leave' as const) : ('present' as const),
1758
- leaveRequestId: null,
1759
- anomalies: [],
1760
- firstIn: iso(n * 86_400_000),
1761
- lastOut: iso(n * 86_400_000 - 8 * 3600_000),
1762
- policyHash: null,
1763
- locked: false,
1764
- computedAt: iso(),
1765
- })),
1766
- nextCursor: null,
1767
- }),
3047
+ /**
3048
+ * A month of day sheets, built for the range asked for.
3049
+ *
3050
+ * The page asks for `monthRange()`, so a fixed handful of rows answered the same five days
3051
+ * whatever it asked and left the rest of the month empty. Weekends come out of the working
3052
+ * week; the days worth looking at are pinned to `WD`, and the totals on a day that has
3053
+ * punches are read off them rather than invented beside them.
3054
+ */
3055
+ list: async ({
3056
+ workspaceId,
3057
+ personId,
3058
+ from,
3059
+ to,
3060
+ limit = 50,
3061
+ }: {
3062
+ workspaceId: string
3063
+ personId?: string
3064
+ from: string
3065
+ to: string
3066
+ limit?: number
3067
+ }) => {
3068
+ const who = personId ?? people[0]!.id
3069
+ const today = day(0)
3070
+ // Built up to `limit` rather than built and then sliced: a caller that asks for a decade
3071
+ // would otherwise materialise every day of it to return the first fifty.
3072
+ const items = []
3073
+ for (const date of eachDate(from, to)) {
3074
+ if (date > today) break
3075
+ if (items.length >= limit) break
3076
+ items.push(attendanceDay(date, who, workspaceId))
3077
+ }
3078
+ return { items, nextCursor: null }
3079
+ },
3080
+ },
3081
+
3082
+ punches: {
3083
+ list: async ({
3084
+ workspaceId,
3085
+ personId,
3086
+ from,
3087
+ to,
3088
+ includeVoided = false,
3089
+ limit = 50,
3090
+ }: {
3091
+ workspaceId: string
3092
+ personId?: string
3093
+ from: string
3094
+ to: string
3095
+ includeVoided?: boolean
3096
+ limit?: number
3097
+ }) => {
3098
+ const who = personId ?? people[0]!.id
3099
+ const items = punches
3100
+ .filter(
3101
+ (row) =>
3102
+ row.personId === who &&
3103
+ row.businessDate >= from &&
3104
+ row.businessDate <= to &&
3105
+ (includeVoided || row.voidedByPunchId === null),
3106
+ )
3107
+ .sort((a, b) => a.at.localeCompare(b.at))
3108
+ return { items: items.slice(0, limit).map((row) => ({ ...row, workspaceId })), nextCursor: null }
3109
+ },
3110
+
3111
+ /**
3112
+ * A void writes a correcting row; it never edits or deletes the original.
3113
+ *
3114
+ * The correction is stamped with the original's own instant and direction and points at
3115
+ * itself, so nothing counts it as a punch — it is there to carry the reason and to say what
3116
+ * it replaced. That is the whole difference between a corrected timesheet and an edited one.
3117
+ */
3118
+ void: async ({
3119
+ workspaceId,
3120
+ punchId,
3121
+ reason,
3122
+ }: {
3123
+ workspaceId: string
3124
+ punchId: string
3125
+ reason: string
3126
+ }) => {
3127
+ void workspaceId
3128
+ const original = punches.find((row) => row.id === punchId)
3129
+ if (!original) refuse('NOT_FOUND', 'Punch not found')
3130
+ if (original.voidedByPunchId) refuse('CONFLICT', 'That punch is already voided')
3131
+ const correction = punch(original.businessDate, '00:00', original.direction, {
3132
+ at: original.at,
3133
+ method: 'manual',
3134
+ note: `Voids ${punchId}: ${reason}`,
3135
+ })
3136
+ correction.voidedByPunchId = correction.id
3137
+ original.voidedByPunchId = correction.id
3138
+ punches.push(correction)
3139
+ return { ok: true as const }
3140
+ },
3141
+ },
3142
+
3143
+ regularizations: {
3144
+ list: async ({
3145
+ workspaceId,
3146
+ personId,
3147
+ status,
3148
+ limit = 50,
3149
+ }: {
3150
+ workspaceId: string
3151
+ personId?: string
3152
+ status?: string[]
3153
+ limit?: number
3154
+ }) => {
3155
+ const who = personId ?? people[0]!.id
3156
+ const items = regularizations
3157
+ .filter((row) => row.personId === who && (!status?.length || status.includes(row.status)))
3158
+ .sort((a, b) => b.businessDate.localeCompare(a.businessDate))
3159
+ return { items: items.slice(0, limit).map((row) => ({ ...row, workspaceId })), nextCursor: null }
3160
+ },
3161
+
3162
+ request: async (input: {
3163
+ workspaceId: string
3164
+ personId?: string
3165
+ businessDate: string
3166
+ punchId?: string | null
3167
+ proposed: Array<{ direction: string; at: string }>
3168
+ reason: string
3169
+ }) => {
3170
+ const who = input.personId ?? people[0]!.id
3171
+ const created: Row<Regularization> = {
3172
+ id: crypto.randomUUID(),
3173
+ personId: who,
3174
+ businessDate: input.businessDate,
3175
+ punchId: input.punchId ?? null,
3176
+ proposed: input.proposed as Regularization['proposed'],
3177
+ reason: input.reason,
3178
+ status: 'pending',
3179
+ approvalRequestId: crypto.randomUUID(),
3180
+ appliedAt: null,
3181
+ createdAt: iso(),
3182
+ }
3183
+ regularizations.push(created)
3184
+ // The same engine leave uses, so the request appears in the approvals inbox rather than
3185
+ // only in the list it was made from — one request, both screens, as the server has it.
3186
+ approvalRequests.push({
3187
+ id: created.approvalRequestId!,
3188
+ workspaceId: '',
3189
+ subjectType: 'regularization' as const,
3190
+ subjectId: created.id,
3191
+ summary: `Correction for ${input.businessDate}`,
3192
+ summaryParams: { date: input.businessDate },
3193
+ status: 'pending',
3194
+ currentStep: 0,
3195
+ requestedBy: null,
3196
+ requesterPersonId: who,
3197
+ requesterName: people.find((x) => x.id === who)?.displayName ?? '',
3198
+ requestedAt: iso(),
3199
+ decidedAt: null,
3200
+ steps: [],
3201
+ })
3202
+ return { ...created, workspaceId: input.workspaceId }
3203
+ },
1768
3204
  },
1769
3205
 
1770
3206
  schedules: {
@@ -1869,6 +3305,273 @@ export function createMockHrApi() {
1869
3305
  },
1870
3306
  },
1871
3307
 
3308
+ policies: {
3309
+ list: async ({
3310
+ workspaceId,
3311
+ kind,
3312
+ includeArchived = false,
3313
+ }: {
3314
+ workspaceId: string
3315
+ kind?: string
3316
+ includeArchived?: boolean
3317
+ }) =>
3318
+ policies
3319
+ .filter((row) => (!kind || row.kind === kind) && (includeArchived || row.archivedAt === null))
3320
+ .map((row) => ({
3321
+ ...row,
3322
+ workspaceId,
3323
+ assignments: assignmentsOf(row.id).map((a) => ({ ...a, workspaceId })),
3324
+ })),
3325
+
3326
+ get: async ({ workspaceId, policyId }: { workspaceId: string; policyId: string }) => {
3327
+ const found = policies.find((row) => row.id === policyId)
3328
+ if (!found) refuse('NOT_FOUND', 'Policy not found')
3329
+ return {
3330
+ ...found,
3331
+ workspaceId,
3332
+ assignments: assignmentsOf(found.id).map((a) => ({ ...a, workspaceId })),
3333
+ }
3334
+ },
3335
+
3336
+ create: async (input: {
3337
+ workspaceId: string
3338
+ kind: Policy['kind']
3339
+ name: string
3340
+ config: Record<string, unknown>
3341
+ effectiveFrom: string
3342
+ effectiveTo?: string | null
3343
+ }) => {
3344
+ const created: Row<Policy> = {
3345
+ id: crypto.randomUUID(),
3346
+ kind: input.kind,
3347
+ name: input.name,
3348
+ config: clone(input.config),
3349
+ effectiveFrom: input.effectiveFrom,
3350
+ effectiveTo: input.effectiveTo ?? null,
3351
+ source: 'custom',
3352
+ packKey: null,
3353
+ // A hash of the config, because that is what a derived row records — a literal would make
3354
+ // every policy look identical to a recomputation deciding whether a figure is stale.
3355
+ configHash: Math.abs(
3356
+ [...JSON.stringify(input.config)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) | 0, 7),
3357
+ )
3358
+ .toString(16)
3359
+ .padStart(8, '0'),
3360
+ archivedAt: null,
3361
+ }
3362
+ policies.push(created)
3363
+ return { ...created, workspaceId: input.workspaceId }
3364
+ },
3365
+
3366
+ update: async (input: {
3367
+ workspaceId: string
3368
+ policyId: string
3369
+ name?: string
3370
+ config?: Record<string, unknown>
3371
+ effectiveTo?: string | null
3372
+ }) => {
3373
+ const found = policies.find((row) => row.id === input.policyId)
3374
+ if (!found) refuse('NOT_FOUND', 'Policy not found')
3375
+ if (input.name !== undefined) found.name = input.name
3376
+ if (input.config !== undefined) found.config = clone(input.config)
3377
+ if (input.effectiveTo !== undefined) found.effectiveTo = input.effectiveTo
3378
+ return { ...found, workspaceId: input.workspaceId }
3379
+ },
3380
+
3381
+ /**
3382
+ * Archived, never deleted, and **the assignments are left where they are**.
3383
+ *
3384
+ * The router neither refuses nor cascades — a ledger entry names the policy that produced it,
3385
+ * so a movement whose policy had vanished would be a number nobody can explain. That means an
3386
+ * archived policy with live assignments is a state an administrator genuinely reaches, and the
3387
+ * fixture can reach it too rather than only in theory.
3388
+ */
3389
+ archive: async ({ policyId }: { workspaceId: string; policyId: string }) => {
3390
+ const found = policies.find((row) => row.id === policyId)
3391
+ if (!found) refuse('NOT_FOUND', 'Policy not found')
3392
+ found.archivedAt = iso()
3393
+ return { ok: true as const }
3394
+ },
3395
+
3396
+ assign: async (input: {
3397
+ workspaceId: string
3398
+ policyId: string
3399
+ subjectKind: PolicySubjectKind
3400
+ subjectId?: string | null
3401
+ effectiveFrom: string
3402
+ effectiveTo?: string | null
3403
+ }) => {
3404
+ const created: Row<PolicyAssignment> = {
3405
+ id: crypto.randomUUID(),
3406
+ policyId: input.policyId,
3407
+ subjectKind: input.subjectKind,
3408
+ // `workspace` needs no id, and storing one would make the rung look narrower than it is.
3409
+ subjectId: input.subjectKind === 'workspace' ? null : (input.subjectId ?? null),
3410
+ effectiveFrom: input.effectiveFrom,
3411
+ effectiveTo: input.effectiveTo ?? null,
3412
+ priority: PRIORITY[input.subjectKind],
3413
+ }
3414
+ policyAssignments.push(created)
3415
+ return { ...created, workspaceId: input.workspaceId }
3416
+ },
3417
+
3418
+ unassign: async ({ assignmentId }: { workspaceId: string; assignmentId: string }) => {
3419
+ const at = policyAssignments.findIndex((a) => a.id === assignmentId)
3420
+ if (at < 0) refuse('NOT_FOUND', 'Policy assignment not found')
3421
+ policyAssignments.splice(at, 1)
3422
+ return { ok: true as const }
3423
+ },
3424
+
3425
+ resolveFor: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => {
3426
+ const applicable = resolvePolicyFor(personId)
3427
+ void workspaceId
3428
+ return applicable
3429
+ ? [
3430
+ {
3431
+ kind: 'accrual' as const,
3432
+ policyId: applicable.policy.id,
3433
+ policyName: applicable.policy.name,
3434
+ config: applicable.policy.config,
3435
+ from: applicable.assignment.subjectKind,
3436
+ subjectId: applicable.assignment.subjectId,
3437
+ },
3438
+ ]
3439
+ : []
3440
+ },
3441
+ },
3442
+
3443
+ accrual: {
3444
+ /**
3445
+ * A query, and it **writes nothing**.
3446
+ *
3447
+ * The contract makes it a query for exactly this reason: a preview that filed ledger entries
3448
+ * would credit everybody the moment somebody looked at the screen meant to tell them what a
3449
+ * run *would* do. It returns the same rows `run` credits from, so the two cannot disagree.
3450
+ */
3451
+ preview: async ({
3452
+ workspaceId,
3453
+ from,
3454
+ to,
3455
+ personId,
3456
+ }: {
3457
+ workspaceId: string
3458
+ from: string
3459
+ to: string
3460
+ personId?: string
3461
+ }) => {
3462
+ void workspaceId
3463
+ return accrualRows(from, to, personId)
3464
+ },
3465
+
3466
+ /**
3467
+ * Credits the ledger, and is idempotent per person, per leave type, per period.
3468
+ *
3469
+ * A second run over the same window credits nothing, because every row it would write is
3470
+ * already `alreadyAccrued` — an accrual job that double-credits when somebody clicks twice is
3471
+ * worse than one that never ran.
3472
+ */
3473
+ run: async ({
3474
+ workspaceId,
3475
+ from,
3476
+ to,
3477
+ personId,
3478
+ }: {
3479
+ workspaceId: string
3480
+ from: string
3481
+ to: string
3482
+ personId?: string
3483
+ }) => {
3484
+ void workspaceId
3485
+ const preview = accrualRows(from, to, personId)
3486
+ let credited = 0
3487
+ let totalMinutes = 0
3488
+ for (const row of preview.rows) {
3489
+ if (row.alreadyAccrued) continue
3490
+ ledger.push(
3491
+ entry(row.personId, row.leaveTypeId, 'accrual', row.minutes, to, { reason: row.reason }),
3492
+ )
3493
+ credited += 1
3494
+ totalMinutes += row.minutes
3495
+ }
3496
+ return {
3497
+ credited,
3498
+ // Everything the run passed over: those already credited, and those it never reached.
3499
+ skipped: preview.skipped.length + preview.rows.filter((r) => r.alreadyAccrued).length,
3500
+ totalMinutes,
3501
+ }
3502
+ },
3503
+ },
3504
+
3505
+ periods: {
3506
+ list: async ({
3507
+ workspaceId,
3508
+ kind,
3509
+ limit = 50,
3510
+ }: {
3511
+ workspaceId: string
3512
+ kind?: string
3513
+ limit?: number
3514
+ }) => {
3515
+ const items = periods
3516
+ .filter((row) => !kind || row.kind === kind)
3517
+ .slice()
3518
+ .sort((a, b) => b.startsOn.localeCompare(a.startsOn))
3519
+ return { items: items.slice(0, limit).map((row) => ({ ...row, workspaceId })), nextCursor: null }
3520
+ },
3521
+
3522
+ create: async (input: {
3523
+ workspaceId: string
3524
+ kind?: Period['kind']
3525
+ legalEntityId?: string | null
3526
+ startsOn: string
3527
+ endsOn: string
3528
+ }) => {
3529
+ if (input.endsOn < input.startsOn) refuse('BAD_REQUEST', 'A period cannot end before it starts.')
3530
+ const created: Row<Period> = {
3531
+ id: crypto.randomUUID(),
3532
+ kind: input.kind ?? 'payroll',
3533
+ legalEntityId: input.legalEntityId ?? null,
3534
+ startsOn: input.startsOn,
3535
+ endsOn: input.endsOn,
3536
+ status: 'open',
3537
+ lockedAt: null,
3538
+ lockedBy: null,
3539
+ note: null,
3540
+ }
3541
+ periods.push(created)
3542
+ return { ...created, workspaceId: input.workspaceId }
3543
+ },
3544
+
3545
+ lock: async (input: { workspaceId: string; periodId: string; note?: string | null }) => {
3546
+ const found = periods.find((row) => row.id === input.periodId)
3547
+ if (!found) refuse('NOT_FOUND', 'Period not found')
3548
+ if (found.status === 'locked') refuse('CONFLICT', 'That period is already locked.')
3549
+ found.status = 'locked'
3550
+ found.lockedAt = iso()
3551
+ found.note = input.note ?? null
3552
+ return {
3553
+ ...found,
3554
+ workspaceId: input.workspaceId,
3555
+ lockedDays: workingDaysIn(found.startsOn, found.endsOn),
3556
+ }
3557
+ },
3558
+
3559
+ /**
3560
+ * Reopens it. No refusal for a period that is already open — the router has none either, and
3561
+ * inventing one here would be a rule the server does not have.
3562
+ */
3563
+ unlock: async (input: { workspaceId: string; periodId: string; reason: string }) => {
3564
+ const found = periods.find((row) => row.id === input.periodId)
3565
+ if (!found) refuse('NOT_FOUND', 'Period not found')
3566
+ const days = found.status === 'locked' ? workingDaysIn(found.startsOn, found.endsOn) : 0
3567
+ found.status = 'open'
3568
+ found.lockedAt = null
3569
+ found.lockedBy = null
3570
+ found.note = `Reopened: ${input.reason}`
3571
+ return { ...found, workspaceId: input.workspaceId, unlockedDays: days }
3572
+ },
3573
+ },
3574
+
1872
3575
  approvals: {
1873
3576
  /**
1874
3577
  * Both tabs have something in them on purpose.
@@ -1914,6 +3617,58 @@ export function createMockHrApi() {
1914
3617
  return { ...found, workspaceId }
1915
3618
  },
1916
3619
 
3620
+ chains: {
3621
+ /** Archived chains are gone from here: the list is what a request can still be routed by. */
3622
+ list: async ({ workspaceId, subjectType }: { workspaceId: string; subjectType?: string }) =>
3623
+ chains
3624
+ .filter((c) => c.archivedAt === null && (!subjectType || c.subjectType === subjectType))
3625
+ .map((c) => ({ ...c, workspaceId })),
3626
+
3627
+ create: async (input: {
3628
+ workspaceId: string
3629
+ name: string
3630
+ subjectType: ApprovalChain['subjectType']
3631
+ spec: ApprovalChainSpec
3632
+ isDefault?: boolean
3633
+ }) => {
3634
+ const created: Row<ApprovalChain> = {
3635
+ id: crypto.randomUUID(),
3636
+ name: input.name,
3637
+ subjectType: input.subjectType,
3638
+ spec: clone(input.spec),
3639
+ isDefault: input.isDefault ?? false,
3640
+ archivedAt: null,
3641
+ }
3642
+ chains.push(created)
3643
+ // Exactly one default per subject type: promoting this one demotes whichever held it.
3644
+ if (created.isDefault) clearDefaultChain(created.subjectType, created.id)
3645
+ return { ...created, workspaceId: input.workspaceId }
3646
+ },
3647
+
3648
+ update: async (input: {
3649
+ workspaceId: string
3650
+ chainId: string
3651
+ name?: string
3652
+ spec?: ApprovalChainSpec
3653
+ isDefault?: boolean
3654
+ }) => {
3655
+ const found = chains.find((c) => c.id === input.chainId)
3656
+ if (!found) refuse('NOT_FOUND', 'Approval chain not found')
3657
+ if (input.name !== undefined) found.name = input.name
3658
+ if (input.spec !== undefined) found.spec = clone(input.spec)
3659
+ if (input.isDefault !== undefined) found.isDefault = input.isDefault
3660
+ if (found.isDefault) clearDefaultChain(found.subjectType, found.id)
3661
+ return { ...found, workspaceId: input.workspaceId }
3662
+ },
3663
+
3664
+ archive: async ({ chainId }: { workspaceId: string; chainId: string }) => {
3665
+ const found = chains.find((c) => c.id === chainId)
3666
+ if (!found) refuse('NOT_FOUND', 'Approval chain not found')
3667
+ found.archivedAt = iso()
3668
+ return { ok: true as const }
3669
+ },
3670
+ },
3671
+
1917
3672
  delegations: async ({ workspaceId }: { workspaceId: string }) =>
1918
3673
  delegations.map((d) => ({ ...d, workspaceId })),
1919
3674
 
@@ -1955,25 +3710,63 @@ export function createMockHrApi() {
1955
3710
  },
1956
3711
  }
1957
3712
 
1958
- function mockPunch(direction: string) {
3713
+ /** Kept, not just returned: expanding today's row after clocking in has to show the punch. */
3714
+ function mockPunch(direction: Punch['direction']) {
3715
+ const row = punch(day(0), '00:00', direction, { at: new Date().toISOString() })
3716
+ punches.push(row)
3717
+ return { ...row, workspaceId: '' }
3718
+ }
3719
+
3720
+ /**
3721
+ * One day's sheet, derived the way the server's is.
3722
+ *
3723
+ * `firstIn` and `lastOut` are read off the live punches rather than stated beside them, so a void
3724
+ * or a fresh clock-in moves the header of the panel it sits above instead of contradicting it.
3725
+ */
3726
+ function attendanceDay(date: string, personId: string, workspaceId: string) {
3727
+ const weekday = WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]!
3728
+ const weekend = weekday === 'sat' || weekday === 'sun'
3729
+ const live = punches
3730
+ .filter((row) => row.personId === personId && row.businessDate === date && !row.voidedByPunchId)
3731
+ .sort((a, b) => a.at.localeCompare(b.at))
3732
+ const firstIn = live.find((row) => row.direction === 'in')?.at ?? null
3733
+ const lastOut = [...live].reverse().find((row) => row.direction === 'out')?.at ?? null
3734
+
3735
+ const leave = date === WD[2]
3736
+ // A day whose clock-out never arrived. It is the only seeded anomaly, and without one the
3737
+ // counted badge on the row and the list of sentences inside the panel are both unreachable.
3738
+ const unclosed = date === WD[3]
3739
+ const overtime = date === WD[1] ? 45 : 0
3740
+ const worked = weekend || leave ? 0 : unclosed ? 240 : 480 + overtime
3741
+
1959
3742
  return {
1960
- id: crypto.randomUUID(),
1961
- workspaceId: '',
1962
- personId: people[0]!.id,
1963
- direction,
1964
- at: new Date().toISOString(),
1965
- clientReportedAt: null,
1966
- skewMs: null,
1967
- businessDate: day(0),
1968
- timezone: 'Europe/Istanbul',
1969
- method: 'web',
1970
- officeId: primaryOfficeId(people[0]!.id),
1971
- deviceId: null,
1972
- geo: null,
1973
- trust: 'trusted',
1974
- voidedByPunchId: null,
1975
- note: null,
1976
- createdAt: new Date().toISOString(),
3743
+ id: id(`a${date.replaceAll('-', '')}`),
3744
+ workspaceId,
3745
+ personId,
3746
+ businessDate: date,
3747
+ scheduledMinutes: weekend ? 0 : 480,
3748
+ workedMinutes: date === day(0) && clockedInAt === null ? 0 : worked,
3749
+ breakMinutes: weekend || leave ? 0 : 60,
3750
+ overtimeMinutes: overtime,
3751
+ // Null, not zero: the demo workspace has no overtime policy with an annual cap, and
3752
+ // "no ceiling applied" is a different fact from "one applied and nothing exceeded it".
3753
+ beyondCapMinutes: null,
3754
+ lateMinutes: 0,
3755
+ earlyLeaveMinutes: 0,
3756
+ status: weekend
3757
+ ? ('weekend' as const)
3758
+ : leave
3759
+ ? ('leave' as const)
3760
+ : unclosed || date === day(0)
3761
+ ? ('pending' as const)
3762
+ : ('present' as const),
3763
+ leaveRequestId: leave ? ((leaveRequests[0]?.id as string | null) ?? null) : null,
3764
+ anomalies: unclosed ? ['missing_clock_out'] : [],
3765
+ firstIn,
3766
+ lastOut: unclosed ? null : lastOut,
3767
+ policyHash: null,
3768
+ locked: false,
3769
+ computedAt: iso(),
1977
3770
  }
1978
3771
  }
1979
3772
  }