@kernhq/module-hr 0.10.4 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 } 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. */
@@ -364,6 +376,191 @@ export function createMockHrApi() {
364
376
  updatedAt: iso(),
365
377
  })
366
378
 
379
+ // ---------------------------------------------------------------- the org chart
380
+
381
+ /**
382
+ * An ltree label: `u` and the id with its dashes removed.
383
+ *
384
+ * The prefix is not decoration — an ltree label cannot start with a digit, and every id here
385
+ * does.
386
+ */
387
+ const unitLabel = (unitId: string) => `u${unitId.replaceAll('-', '')}`
388
+
389
+ const orgUnits: Row<OrgUnit>[] = []
390
+ const pathFor = (parentId: string | null, unitId: string): string => {
391
+ const parent = parentId ? orgUnits.find((u) => u.id === parentId) : undefined
392
+ return parent ? `${parent.path}.${unitLabel(unitId)}` : unitLabel(unitId)
393
+ }
394
+ const seedUnit = (
395
+ unitId: string,
396
+ parentId: string | null,
397
+ name: string,
398
+ code: string | null = null,
399
+ headPersonId: string | null = null,
400
+ ) => {
401
+ orgUnits.push({
402
+ id: unitId,
403
+ parentId,
404
+ path: pathFor(parentId, unitId),
405
+ name,
406
+ code,
407
+ headPersonId,
408
+ archivedAt: null,
409
+ })
410
+ }
411
+
412
+ // Parent before child, because a path is built from the one above it. Four levels deep so the
413
+ // tree rails and the depth figure have something to draw, and Operations is left empty and
414
+ // childless so archiving a department is reachable at all.
415
+ seedUnit(id('0a01'), null, 'Northstar', 'NS', people[0]!.id)
416
+ seedUnit(id('0a02'), id('0a01'), 'Engineering', 'ENG', people[0]!.id)
417
+ seedUnit(id('0a03'), id('0a02'), 'Platform', 'PLT', people[1]!.id)
418
+ seedUnit(id('0a04'), id('0a03'), 'Infrastructure')
419
+ seedUnit(id('0a05'), id('0a02'), 'Product Engineering')
420
+ seedUnit(id('0a06'), id('0a01'), 'People & Culture', 'PC', people[1]!.id)
421
+ seedUnit(id('0a07'), id('0a01'), 'Operations')
422
+
423
+ const positions: Row<Position>[] = [
424
+ {
425
+ id: id('05a1'),
426
+ title: 'Software Engineer',
427
+ code: 'SE',
428
+ jobFamily: 'Engineering',
429
+ level: 'L3',
430
+ archivedAt: null,
431
+ },
432
+ {
433
+ id: id('05a2'),
434
+ title: 'Senior Software Engineer',
435
+ code: 'SSE',
436
+ jobFamily: 'Engineering',
437
+ level: 'L4',
438
+ archivedAt: null,
439
+ },
440
+ {
441
+ id: id('05a3'),
442
+ title: 'Engineering Manager',
443
+ code: 'EM',
444
+ jobFamily: 'Engineering',
445
+ level: 'M1',
446
+ archivedAt: null,
447
+ },
448
+ // A mix on purpose: not every position is levelled, and plenty carry no code at all.
449
+ {
450
+ id: id('05a4'),
451
+ title: 'People Partner',
452
+ code: null,
453
+ jobFamily: 'People',
454
+ level: null,
455
+ archivedAt: null,
456
+ },
457
+ { id: id('05a5'), title: 'Office Manager', code: 'OM', jobFamily: null, level: null, archivedAt: null },
458
+ ]
459
+
460
+ /**
461
+ * Who holds which job, effective-dated.
462
+ *
463
+ * The org tree's headcount is a count of *these* — one row per person whose `effectiveTo` is
464
+ * still null — rather than a number stated beside the department. That is what makes archiving a
465
+ * department refuse for a reason somebody can act on, and it is why moving a person changes two
466
+ * screens at once.
467
+ */
468
+ const employments: Row<Employment>[] = [
469
+ // Ayşe was promoted, so her history has two rows and the current one is not the first. A single
470
+ // open row per person makes `employment.history` a list of one and proves nothing about the
471
+ // effective-dated shape it exists to show.
472
+ {
473
+ id: id('eb05'),
474
+ personId: people[0]!.id,
475
+ effectiveFrom: day(-400),
476
+ effectiveTo: day(-201),
477
+ orgUnitId: id('0a02'),
478
+ positionId: id('05a1'),
479
+ legalEntityId: id('1e01'),
480
+ costCenterId: null,
481
+ managerPersonId: null,
482
+ employmentType: 'full_time',
483
+ fte: 1,
484
+ contractHoursWeek: 40,
485
+ reason: null,
486
+ createdAt: iso(400 * 86_400_000),
487
+ },
488
+ {
489
+ id: id('eb01'),
490
+ personId: people[0]!.id,
491
+ effectiveFrom: day(-200),
492
+ effectiveTo: null,
493
+ orgUnitId: id('0a02'),
494
+ positionId: id('05a3'),
495
+ legalEntityId: id('1e01'),
496
+ costCenterId: null,
497
+ managerPersonId: null,
498
+ employmentType: 'full_time',
499
+ fte: 1,
500
+ contractHoursWeek: 40,
501
+ reason: 'Promoted to Engineering Manager',
502
+ createdAt: iso(200 * 86_400_000),
503
+ },
504
+ {
505
+ id: id('eb02'),
506
+ personId: people[1]!.id,
507
+ effectiveFrom: day(-300),
508
+ effectiveTo: null,
509
+ orgUnitId: id('0a03'),
510
+ positionId: id('05a2'),
511
+ legalEntityId: id('1e02'),
512
+ costCenterId: null,
513
+ managerPersonId: people[0]!.id,
514
+ employmentType: 'full_time',
515
+ fte: 1,
516
+ contractHoursWeek: 40,
517
+ reason: null,
518
+ createdAt: iso(300 * 86_400_000),
519
+ },
520
+ {
521
+ id: id('eb03'),
522
+ personId: people[2]!.id,
523
+ effectiveFrom: day(-250),
524
+ effectiveTo: null,
525
+ orgUnitId: id('0a02'),
526
+ positionId: id('05a1'),
527
+ legalEntityId: id('1e01'),
528
+ costCenterId: null,
529
+ managerPersonId: people[0]!.id,
530
+ employmentType: 'full_time',
531
+ fte: 1,
532
+ contractHoursWeek: 40,
533
+ reason: null,
534
+ createdAt: iso(250 * 86_400_000),
535
+ },
536
+ {
537
+ id: id('eb04'),
538
+ personId: people[3]!.id,
539
+ effectiveFrom: day(-80),
540
+ effectiveTo: null,
541
+ orgUnitId: id('0a06'),
542
+ positionId: id('05a4'),
543
+ legalEntityId: id('1e01'),
544
+ costCenterId: null,
545
+ managerPersonId: people[0]!.id,
546
+ employmentType: 'part_time',
547
+ fte: 0.8,
548
+ contractHoursWeek: 32,
549
+ reason: null,
550
+ createdAt: iso(80 * 86_400_000),
551
+ },
552
+ ]
553
+
554
+ /** Direct only. The tree sums the subtree itself, so a subtree total here double-counts. */
555
+ const unitHeadcount = (unitId: string) =>
556
+ employments.filter((e) => e.orgUnitId === unitId && e.effectiveTo === null).length
557
+
558
+ const descendants = (unitId: string) => {
559
+ const root = orgUnits.find((u) => u.id === unitId)
560
+ if (!root) return []
561
+ return orgUnits.filter((u) => u.path === root.path || u.path.startsWith(`${root.path}.`))
562
+ }
563
+
367
564
  // ---------------------------------------------------------------- calendars
368
565
 
369
566
  const calendars: Row<Calendar>[] = [
@@ -704,6 +901,333 @@ export function createMockHrApi() {
704
901
  },
705
902
  ]
706
903
 
904
+ /**
905
+ * The recent working days, most recent first.
906
+ *
907
+ * The interesting days are pinned to positions in *this* list rather than to a raw offset from
908
+ * today: `day(-3)` is a Sunday one week in three, and a leave day or a missing clock-out on a
909
+ * Sunday is a contradiction the day sheet would then have to draw.
910
+ */
911
+ const workdays = (count: number): string[] => {
912
+ const out: string[] = []
913
+ for (let back = 0; out.length < count; back++) {
914
+ const date = day(-back)
915
+ const weekday = WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]!
916
+ if (weekday !== 'sat' && weekday !== 'sun') out.push(date)
917
+ }
918
+ return out
919
+ }
920
+ const WD = workdays(5)
921
+ /** How far the punch seed reaches back — a month plus a fortnight, so any month-start is covered. */
922
+ const SEED_DAYS = 45
923
+
924
+ /**
925
+ * An instant from a business date and a wall-clock reading **in the office's zone**.
926
+ *
927
+ * Built as UTC these read three hours late to everybody: the screens format in the viewer's zone,
928
+ * so a seeded `09:00` clock-in was drawn as "12:00 PM" and a nine-to-six day looked like noon to
929
+ * nine. Istanbul has been a fixed +03:00 with no daylight saving since 2016, so the offset is
930
+ * safe to write literally — a zone that still shifts would need the date to decide it.
931
+ */
932
+ const stamp = (date: string, wall: string) => new Date(`${date}T${wall}:00+03:00`).toISOString()
933
+
934
+ let punchCounter = 0
935
+ const punch = (
936
+ date: string,
937
+ wall: string,
938
+ direction: Punch['direction'],
939
+ over: Partial<Row<Punch>> = {},
940
+ ): Row<Punch> => ({
941
+ id: id(`9c${(++punchCounter).toString(16).padStart(4, '0')}`),
942
+ personId: people[0]!.id,
943
+ direction,
944
+ at: stamp(date, wall),
945
+ clientReportedAt: null,
946
+ skewMs: null,
947
+ businessDate: date,
948
+ timezone: 'Europe/Istanbul',
949
+ method: 'web',
950
+ officeId: primaryOfficeId(people[0]!.id),
951
+ deviceId: null,
952
+ geo: null,
953
+ trust: 'trusted',
954
+ voidedByPunchId: null,
955
+ note: null,
956
+ createdAt: iso(),
957
+ ...over,
958
+ })
959
+
960
+ /**
961
+ * The raw punches behind the day sheet.
962
+ *
963
+ * Every past working day gets a pair, not just the interesting ones. A day sheet that says eight
964
+ * hours with nothing underneath it is the exact statement this page's own header warns about —
965
+ * and opening such a row showed a total above an empty list, which reads as a broken panel.
966
+ *
967
+ * The times match the seeded `Office hours` schedule, so the arithmetic holds: 09:00 to 18:00 is
968
+ * nine hours, less an hour of break, is the 480 minutes the row claims.
969
+ *
970
+ * Three states the panel draws differently sit on top: a punch the device *claimed* while
971
+ * offline, a day whose clock-out never arrived, and a voided punch beside the correcting row that
972
+ * carries the reason.
973
+ */
974
+ const punches: Row<Punch>[] = []
975
+
976
+ // Far enough back to cover the current month whatever day of it this runs on.
977
+ for (let back = SEED_DAYS; back >= 1; back--) {
978
+ const date = day(-back)
979
+ const weekday = WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]!
980
+ if (weekday === 'sat' || weekday === 'sun') continue
981
+ if (date === WD[2]) continue // on leave, so nothing was punched
982
+ if (date === WD[3]) {
983
+ // Punched from a phone that was offline: the instant is the device's claim, and it was four
984
+ // minutes out. That pair of facts is what `trust` and `skewMs` exist to keep. No clock-out
985
+ // ever arrived, which is the day's anomaly.
986
+ punches.push(
987
+ punch(date, '09:12', 'in', {
988
+ method: 'mobile',
989
+ trust: 'claimed',
990
+ clientReportedAt: stamp(date, '09:08'),
991
+ skewMs: 240_000,
992
+ }),
993
+ )
994
+ continue
995
+ }
996
+ punches.push(punch(date, '09:00', 'in'))
997
+ punches.push(punch(date, '13:00', 'break_start'))
998
+ punches.push(punch(date, '14:00', 'break_end'))
999
+ punches.push(punch(date, date === WD[1] ? '18:45' : '18:00', 'out'))
1000
+ }
1001
+
1002
+ /**
1003
+ * The voided pair, wired the way `voidPunch` wires it.
1004
+ *
1005
+ * Both rows point at the correction: the original because it was voided, and the correction
1006
+ * because it is not a punch — it exists to carry the reason and to say what it replaced. The
1007
+ * panel hides the self-voiding row and reads the sentence out of its note.
1008
+ */
1009
+ const voidedOriginal = punch(WD[4]!, '08:00', 'in')
1010
+ const voidCorrection = punch(WD[4]!, '08:00', 'in', {
1011
+ method: 'manual',
1012
+ note: `Voids ${voidedOriginal.id}: Badge reader at the door fired as I walked past.`,
1013
+ })
1014
+ voidedOriginal.voidedByPunchId = voidCorrection.id
1015
+ voidCorrection.voidedByPunchId = voidCorrection.id
1016
+ punches.push(voidedOriginal, voidCorrection)
1017
+
1018
+ /**
1019
+ * One correction already asked for, on the day the seeded approval names.
1020
+ *
1021
+ * `subjectId` and `approvalRequestId` line up with the `regularization` row in the approvals
1022
+ * inbox on purpose — the same request seen from the two screens that show it, which is the thing
1023
+ * a demo cannot fake with two unrelated rows.
1024
+ */
1025
+ const regularizations: Row<Regularization>[] = [
1026
+ {
1027
+ id: id('c002'),
1028
+ personId: people[0]!.id,
1029
+ businessDate: WD[1]!,
1030
+ punchId: punches.find((x) => x.businessDate === WD[1] && x.direction === 'out')?.id ?? null,
1031
+ proposed: [{ direction: 'out', at: stamp(WD[1]!, '19:00') }],
1032
+ reason: 'I worked until 19:00 finishing the migration; the clock-out is wrong.',
1033
+ status: 'pending',
1034
+ approvalRequestId: id('f002'),
1035
+ appliedAt: null,
1036
+ createdAt: iso(2 * 86_400_000),
1037
+ },
1038
+ ]
1039
+
1040
+ // ---------------------------------------------------------------- documents, sensitive, periods
1041
+
1042
+ const documents: Row<PersonDocument>[] = [
1043
+ {
1044
+ id: id('d0c1'),
1045
+ personId: people[0]!.id,
1046
+ fileId: id('f11e01'),
1047
+ name: 'Employment contract',
1048
+ kind: 'contract',
1049
+ issuedOn: day(-400),
1050
+ expiresOn: null,
1051
+ uploadedBy: null,
1052
+ createdAt: iso(400 * 86_400_000),
1053
+ },
1054
+ // Expiring inside the month, because "expires on" is the column the section exists for and a
1055
+ // list where nothing ever expires never shows what it does with one.
1056
+ {
1057
+ id: id('d0c2'),
1058
+ personId: people[0]!.id,
1059
+ fileId: id('f11e02'),
1060
+ name: 'Work permit',
1061
+ kind: 'permit',
1062
+ issuedOn: day(-380),
1063
+ expiresOn: day(20),
1064
+ uploadedBy: null,
1065
+ createdAt: iso(380 * 86_400_000),
1066
+ },
1067
+ {
1068
+ id: id('d0c3'),
1069
+ personId: people[1]!.id,
1070
+ fileId: id('f11e03'),
1071
+ name: 'Employment contract',
1072
+ kind: 'contract',
1073
+ issuedOn: day(-300),
1074
+ expiresOn: null,
1075
+ uploadedBy: null,
1076
+ createdAt: iso(300 * 86_400_000),
1077
+ },
1078
+ ]
1079
+
1080
+ /**
1081
+ * Behind a second permission, and a separate shape for that reason.
1082
+ *
1083
+ * Seeded for one person only: the section has to be able to render "nothing recorded" as well as
1084
+ * a filled-in card, and every other person here is that case.
1085
+ */
1086
+ const sensitive: Row<PersonSensitive>[] = [
1087
+ {
1088
+ personId: people[0]!.id,
1089
+ nationalId: '12345678901',
1090
+ birthDate: '1991-04-17',
1091
+ iban: 'TR33 0006 1005 1978 6457 8413 26',
1092
+ emergencyContact: { name: 'Elif Yılmaz', relationship: 'Sister', phone: '+90 532 000 0000' },
1093
+ },
1094
+ ]
1095
+
1096
+ const monthStart = (offset: number) => {
1097
+ const base = new Date(now)
1098
+ const d = new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth() + offset, 1))
1099
+ return d.toISOString().slice(0, 10)
1100
+ }
1101
+ const monthEnd = (offset: number) => {
1102
+ const base = new Date(now)
1103
+ const d = new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth() + offset + 1, 0))
1104
+ return d.toISOString().slice(0, 10)
1105
+ }
1106
+
1107
+ const periods: Row<Period>[] = [
1108
+ // Last month closed and this month open — the two states the screen switches between, so
1109
+ // neither the lock button nor the reopen warning is reachable only in theory.
1110
+ {
1111
+ id: id('9e01'),
1112
+ kind: 'payroll',
1113
+ legalEntityId: id('1e01'),
1114
+ startsOn: monthStart(-1),
1115
+ endsOn: monthEnd(-1),
1116
+ status: 'locked',
1117
+ lockedAt: iso(5 * 86_400_000),
1118
+ lockedBy: null,
1119
+ note: 'Filed with payroll',
1120
+ },
1121
+ {
1122
+ id: id('9e02'),
1123
+ kind: 'payroll',
1124
+ legalEntityId: id('1e01'),
1125
+ startsOn: monthStart(0),
1126
+ endsOn: monthEnd(0),
1127
+ status: 'open',
1128
+ lockedAt: null,
1129
+ lockedBy: null,
1130
+ note: null,
1131
+ },
1132
+ {
1133
+ id: id('9e03'),
1134
+ kind: 'attendance',
1135
+ legalEntityId: null,
1136
+ startsOn: monthStart(-1),
1137
+ endsOn: monthEnd(-1),
1138
+ status: 'open',
1139
+ lockedAt: null,
1140
+ lockedBy: null,
1141
+ note: null,
1142
+ },
1143
+ ]
1144
+
1145
+ /** Working days in a range — what lock and unlock report as the days they froze or released. */
1146
+ const workingDaysIn = (from: string, to: string) =>
1147
+ eachDate(from, to).filter((date) => {
1148
+ const weekday = WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]!
1149
+ return weekday !== 'sat' && weekday !== 'sun' && date <= day(0)
1150
+ }).length
1151
+
1152
+ // ---------------------------------------------------------------- approval chains
1153
+
1154
+ const chains: Row<ApprovalChain>[] = [
1155
+ {
1156
+ id: id('ca11'),
1157
+ name: 'Leave — manager then HR',
1158
+ subjectType: 'leave',
1159
+ isDefault: true,
1160
+ archivedAt: null,
1161
+ spec: {
1162
+ steps: [
1163
+ {
1164
+ name: 'Manager',
1165
+ approvers: [{ kind: 'manager' }],
1166
+ mode: 'any',
1167
+ minApprovals: 1,
1168
+ slaHours: 48,
1169
+ onTimeout: 'remind',
1170
+ },
1171
+ {
1172
+ name: 'HR',
1173
+ approvers: [{ kind: 'permission', id: 'hr.leave.manage' }],
1174
+ mode: 'any',
1175
+ minApprovals: 1,
1176
+ slaHours: 72,
1177
+ onTimeout: 'escalate',
1178
+ },
1179
+ ],
1180
+ },
1181
+ },
1182
+ // Not the default, so the table has a row without the in-use badge — which is the case the
1183
+ // column's description is about, and a table where every row looks the same never shows it.
1184
+ {
1185
+ id: id('ca12'),
1186
+ name: 'Leave — local HR only',
1187
+ subjectType: 'leave',
1188
+ isDefault: false,
1189
+ archivedAt: null,
1190
+ spec: {
1191
+ steps: [
1192
+ {
1193
+ name: 'Office head',
1194
+ approvers: [{ kind: 'office_head' }],
1195
+ mode: 'any',
1196
+ minApprovals: 1,
1197
+ slaHours: null,
1198
+ onTimeout: 'remind',
1199
+ },
1200
+ ],
1201
+ },
1202
+ },
1203
+ {
1204
+ id: id('ca13'),
1205
+ name: 'Corrections — manager',
1206
+ subjectType: 'regularization',
1207
+ isDefault: true,
1208
+ archivedAt: null,
1209
+ spec: {
1210
+ steps: [
1211
+ {
1212
+ name: 'Manager',
1213
+ approvers: [{ kind: 'manager' }, { kind: 'org_unit_head' }],
1214
+ mode: 'quorum',
1215
+ minApprovals: 1,
1216
+ slaHours: 24,
1217
+ onTimeout: 'auto_approve',
1218
+ },
1219
+ ],
1220
+ },
1221
+ },
1222
+ ]
1223
+
1224
+ /** Exactly one default per subject type, which is what `clearDefaultChain` keeps true. */
1225
+ const clearDefaultChain = (subjectType: string, except: string) => {
1226
+ for (const chain of chains) {
1227
+ if (chain.subjectType === subjectType && chain.id !== except) chain.isDefault = false
1228
+ }
1229
+ }
1230
+
707
1231
  const delegations: Array<Record<string, unknown>> = []
708
1232
 
709
1233
  const approvalRequests = [
@@ -728,8 +1252,8 @@ export function createMockHrApi() {
728
1252
  subjectType: 'regularization' as const,
729
1253
  workspaceId: '',
730
1254
  subjectId: id('c002'),
731
- summary: `Correction for ${day(-1)}`,
732
- summaryParams: { date: day(-1) } as Record<string, string | number> | null,
1255
+ summary: `Correction for ${WD[1]}`,
1256
+ summaryParams: { date: WD[1]! } as Record<string, string | number> | null,
733
1257
  status: 'pending' as string,
734
1258
  currentStep: 0,
735
1259
  requestedBy: null,
@@ -778,8 +1302,129 @@ export function createMockHrApi() {
778
1302
  createdAt: iso(),
779
1303
  updatedAt: iso(),
780
1304
  },
1305
+ // The row `f003` in the approvals inbox refers to. It had no request behind it, so the decided
1306
+ // tab named a subject nothing could open — and `withdrawn`, which only an approved request can
1307
+ // reach, was unreachable in the mock.
1308
+ {
1309
+ id: id('c003'),
1310
+ workspaceId: '',
1311
+ personId: people[0]!.id,
1312
+ leaveTypeId: id('b001'),
1313
+ startsOn: day(-20),
1314
+ endsOn: day(-20),
1315
+ startPart: 'full',
1316
+ endPart: 'full',
1317
+ hours: null,
1318
+ workingDays: 1,
1319
+ minutes: 480,
1320
+ status: 'approved',
1321
+ reason: null,
1322
+ documentFileId: null,
1323
+ approvalRequestId: id('f003'),
1324
+ decidedAt: iso(19 * 86_400_000),
1325
+ createdAt: iso(20 * 86_400_000),
1326
+ updatedAt: iso(19 * 86_400_000),
1327
+ },
1328
+ // Booked, then cancelled. The ledger below carries its consumption *and* the reversal that
1329
+ // undid it, rather than the consumption having quietly disappeared.
1330
+ {
1331
+ id: id('c004'),
1332
+ workspaceId: '',
1333
+ personId: people[0]!.id,
1334
+ leaveTypeId: id('b001'),
1335
+ startsOn: day(-45),
1336
+ endsOn: day(-44),
1337
+ startPart: 'full',
1338
+ endPart: 'full',
1339
+ hours: null,
1340
+ workingDays: 2,
1341
+ minutes: 2 * 480,
1342
+ status: 'cancelled',
1343
+ reason: null,
1344
+ documentFileId: null,
1345
+ approvalRequestId: null,
1346
+ decidedAt: iso(50 * 86_400_000),
1347
+ createdAt: iso(60 * 86_400_000),
1348
+ updatedAt: iso(50 * 86_400_000),
1349
+ },
781
1350
  ]
782
1351
 
1352
+ /**
1353
+ * The movements behind a balance.
1354
+ *
1355
+ * This is the screen somebody opens when they disagree with a number, so it has to contain the
1356
+ * shape of the argument: an entitlement granted, months of accrual, leave spent — and a
1357
+ * **reversal sitting beside the consumption it reverses**, because a cancelled booking that
1358
+ * showed as a gap would misrepresent the one property the ledger exists to have. Nothing here is
1359
+ * ever edited or removed; a mistake is another row.
1360
+ */
1361
+ let ledgerCounter = 0
1362
+ const entry = (
1363
+ personId: string,
1364
+ leaveTypeId: string,
1365
+ kind: LeaveLedgerEntry['kind'],
1366
+ amountMinutes: number,
1367
+ effectiveOn: string,
1368
+ over: Partial<Row<LeaveLedgerEntry>> = {},
1369
+ ): Row<LeaveLedgerEntry> => ({
1370
+ id: id(`1ed${(++ledgerCounter).toString(16).padStart(3, '0')}`),
1371
+ personId,
1372
+ leaveTypeId,
1373
+ kind,
1374
+ amountMinutes,
1375
+ effectiveOn,
1376
+ periodYear: Number(effectiveOn.slice(0, 4)),
1377
+ requestId: null,
1378
+ reversesEntryId: null,
1379
+ policyHash: null,
1380
+ reason: null,
1381
+ createdBy: null,
1382
+ createdAt: iso(),
1383
+ ...over,
1384
+ })
1385
+
1386
+ const ANNUAL = id('b001')
1387
+ const SICK = id('b002')
1388
+ const ME = people[0]!.id
1389
+
1390
+ const carriedIn = entry(ME, ANNUAL, 'carry_in', 3 * 480, `${YEAR}-01-01`, {
1391
+ reason: 'Carried forward from last year',
1392
+ })
1393
+ const spentThenCancelled = entry(ME, ANNUAL, 'consumption', -2 * 480, day(-45), {
1394
+ requestId: id('c004'),
1395
+ reason: '2 days',
1396
+ })
1397
+
1398
+ const ledger: Row<LeaveLedgerEntry>[] = [
1399
+ carriedIn,
1400
+ entry(ME, ANNUAL, 'grant', 20 * 480, `${YEAR}-01-01`, { reason: 'Annual entitlement' }),
1401
+ entry(ME, ANNUAL, 'accrual', 480, `${YEAR}-01-31`),
1402
+ entry(ME, ANNUAL, 'accrual', 480, `${YEAR}-02-28`),
1403
+ entry(ME, ANNUAL, 'accrual', 480, `${YEAR}-03-31`),
1404
+ // The carry-forward deadline came and went, and what was left of last year lapsed.
1405
+ entry(ME, ANNUAL, 'expiry', -3 * 480, `${YEAR}-03-31`, {
1406
+ reversesEntryId: carriedIn.id,
1407
+ reason: 'Carry-forward deadline',
1408
+ }),
1409
+ spentThenCancelled,
1410
+ entry(ME, ANNUAL, 'reversal', 2 * 480, day(-50), {
1411
+ requestId: id('c004'),
1412
+ reversesEntryId: spentThenCancelled.id,
1413
+ reason: 'Request cancelled',
1414
+ }),
1415
+ entry(ME, ANNUAL, 'consumption', -480, day(-20), { requestId: id('c003'), reason: '1 day' }),
1416
+ entry(ME, ANNUAL, 'adjustment', 480, day(-10), {
1417
+ reason: 'Public holiday fell inside an approved request',
1418
+ }),
1419
+ entry(ME, SICK, 'grant', 10 * 480, `${YEAR}-01-01`, { reason: 'Annual entitlement' }),
1420
+ ]
1421
+ // Everybody else gets their entitlement and nothing else: a balance of zero for three of four
1422
+ // people would read as a broken tile rather than as an untouched allowance.
1423
+ for (const other of people.slice(1)) {
1424
+ ledger.push(entry(other.id, ANNUAL, 'grant', 20 * 480, `${YEAR}-01-01`, { reason: 'Annual entitlement' }))
1425
+ ledger.push(entry(other.id, SICK, 'grant', 10 * 480, `${YEAR}-01-01`, { reason: 'Annual entitlement' }))
1426
+ }
1427
+
783
1428
  return {
784
1429
  people: {
785
1430
  list: async ({
@@ -866,6 +1511,52 @@ export function createMockHrApi() {
866
1511
  phone: input.phone ?? null,
867
1512
  }
868
1513
  },
1514
+ /**
1515
+ * Behind a second permission, and never folded into `Person`.
1516
+ *
1517
+ * Returns an empty record rather than refusing when nothing is on file: "nothing recorded" is
1518
+ * an ordinary answer and a 404 would make the section look broken for most of the directory.
1519
+ */
1520
+ sensitive: {
1521
+ get: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => {
1522
+ const row = sensitive.find((x) => x.personId === personId)
1523
+ return {
1524
+ workspaceId,
1525
+ personId,
1526
+ nationalId: row?.nationalId ?? null,
1527
+ birthDate: row?.birthDate ?? null,
1528
+ iban: row?.iban ?? null,
1529
+ emergencyContact: row?.emergencyContact ?? null,
1530
+ }
1531
+ },
1532
+
1533
+ update: async (input: {
1534
+ workspaceId: string
1535
+ personId: string
1536
+ nationalId?: string | null
1537
+ birthDate?: string | null
1538
+ iban?: string | null
1539
+ emergencyContact?: PersonSensitive['emergencyContact']
1540
+ }) => {
1541
+ let row = sensitive.find((x) => x.personId === input.personId)
1542
+ if (!row) {
1543
+ row = {
1544
+ personId: input.personId,
1545
+ nationalId: null,
1546
+ birthDate: null,
1547
+ iban: null,
1548
+ emergencyContact: null,
1549
+ }
1550
+ sensitive.push(row)
1551
+ }
1552
+ if (input.nationalId !== undefined) row.nationalId = input.nationalId
1553
+ if (input.birthDate !== undefined) row.birthDate = input.birthDate
1554
+ if (input.iban !== undefined) row.iban = input.iban
1555
+ if (input.emergencyContact !== undefined) row.emergencyContact = input.emergencyContact
1556
+ return { ...row, workspaceId: input.workspaceId }
1557
+ },
1558
+ },
1559
+
869
1560
  offboard: async (input: { workspaceId: string; personId: string; on: string }) => {
870
1561
  const found = people.find((p) => p.id === input.personId) ?? people[0]!
871
1562
  found.status = 'terminated'
@@ -874,23 +1565,270 @@ export function createMockHrApi() {
874
1565
  },
875
1566
 
876
1567
  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
- }),
1568
+ /**
1569
+ * The open row, or null.
1570
+ *
1571
+ * Read out of the same table the org chart counts rather than synthesised beside it — a
1572
+ * person's department on their own page and the headcount on the chart have to be the same
1573
+ * fact. Null is an ordinary answer: a record created a minute ago has no employment yet.
1574
+ */
1575
+ current: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => {
1576
+ const row = employments.find((e) => e.personId === personId && e.effectiveTo === null)
1577
+ return row ? { ...row, workspaceId } : null
1578
+ },
1579
+
1580
+ /** Newest first: the question this answers is "what changed", and the last change is the news. */
1581
+ history: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) =>
1582
+ employments
1583
+ .filter((e) => e.personId === personId)
1584
+ .slice()
1585
+ .sort((a, b) => b.effectiveFrom.localeCompare(a.effectiveFrom))
1586
+ .map((e) => ({ ...e, workspaceId })),
1587
+
1588
+ /**
1589
+ * Closes the open row and opens a new one. Never an update.
1590
+ *
1591
+ * Overwriting would lose the answer to "who did she report to in March", which is the
1592
+ * question a leave approval from March needs — so the previous row is closed the day before
1593
+ * the new one starts and everything unstated is carried forward from it.
1594
+ */
1595
+ change: async (input: {
1596
+ workspaceId: string
1597
+ personId: string
1598
+ effectiveFrom: string
1599
+ orgUnitId?: string | null
1600
+ positionId?: string | null
1601
+ legalEntityId?: string | null
1602
+ costCenterId?: string | null
1603
+ managerPersonId?: string | null
1604
+ employmentType?: Employment['employmentType']
1605
+ fte?: number
1606
+ contractHoursWeek?: number | null
1607
+ reason?: string | null
1608
+ }) => {
1609
+ const open = employments.find((e) => e.personId === input.personId && e.effectiveTo === null)
1610
+ if (open) {
1611
+ const dayBefore = new Date(Date.parse(`${input.effectiveFrom}T00:00:00Z`) - 86_400_000)
1612
+ open.effectiveTo = dayBefore.toISOString().slice(0, 10)
1613
+ }
1614
+ const created: Row<Employment> = {
1615
+ id: crypto.randomUUID(),
1616
+ personId: input.personId,
1617
+ effectiveFrom: input.effectiveFrom,
1618
+ effectiveTo: null,
1619
+ orgUnitId: input.orgUnitId !== undefined ? input.orgUnitId : (open?.orgUnitId ?? null),
1620
+ positionId: input.positionId !== undefined ? input.positionId : (open?.positionId ?? null),
1621
+ legalEntityId:
1622
+ input.legalEntityId !== undefined ? input.legalEntityId : (open?.legalEntityId ?? null),
1623
+ costCenterId: input.costCenterId !== undefined ? input.costCenterId : (open?.costCenterId ?? null),
1624
+ managerPersonId:
1625
+ input.managerPersonId !== undefined ? input.managerPersonId : (open?.managerPersonId ?? null),
1626
+ employmentType: input.employmentType ?? open?.employmentType ?? 'full_time',
1627
+ fte: input.fte ?? open?.fte ?? 1,
1628
+ contractHoursWeek:
1629
+ input.contractHoursWeek !== undefined
1630
+ ? input.contractHoursWeek
1631
+ : (open?.contractHoursWeek ?? null),
1632
+ reason: input.reason ?? null,
1633
+ createdAt: iso(),
1634
+ }
1635
+ employments.push(created)
1636
+ return { ...created, workspaceId: input.workspaceId }
1637
+ },
1638
+ },
1639
+
1640
+ documents: {
1641
+ list: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) =>
1642
+ documents
1643
+ .filter((d) => d.personId === personId)
1644
+ .slice()
1645
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
1646
+ .map((d) => ({ ...d, workspaceId })),
1647
+
1648
+ attach: async (input: {
1649
+ workspaceId: string
1650
+ personId: string
1651
+ fileId: string
1652
+ name: string
1653
+ kind?: string
1654
+ issuedOn?: string | null
1655
+ expiresOn?: string | null
1656
+ }) => {
1657
+ const created: Row<PersonDocument> = {
1658
+ id: crypto.randomUUID(),
1659
+ personId: input.personId,
1660
+ fileId: input.fileId,
1661
+ name: input.name,
1662
+ kind: input.kind ?? 'other',
1663
+ issuedOn: input.issuedOn ?? null,
1664
+ expiresOn: input.expiresOn ?? null,
1665
+ uploadedBy: null,
1666
+ createdAt: iso(),
1667
+ }
1668
+ documents.push(created)
1669
+ return { ...created, workspaceId: input.workspaceId }
1670
+ },
1671
+
1672
+ remove: async ({ documentId }: { workspaceId: string; personId: string; documentId: string }) => {
1673
+ const at = documents.findIndex((d) => d.id === documentId)
1674
+ if (at < 0) refuse('NOT_FOUND', 'Document not found')
1675
+ documents.splice(at, 1)
1676
+ return { ok: true as const }
1677
+ },
1678
+ },
1679
+
1680
+ org: {
1681
+ units: {
1682
+ tree: async ({
1683
+ workspaceId,
1684
+ includeArchived = false,
1685
+ }: {
1686
+ workspaceId: string
1687
+ includeArchived?: boolean
1688
+ }) =>
1689
+ orgUnits
1690
+ .filter((u) => includeArchived || u.archivedAt === null)
1691
+ .slice()
1692
+ .sort((a, b) => a.path.localeCompare(b.path))
1693
+ .map((u) => ({ ...u, workspaceId, headcount: unitHeadcount(u.id) })),
1694
+
1695
+ create: async (input: {
1696
+ workspaceId: string
1697
+ name: string
1698
+ parentId?: string | null
1699
+ code?: string | null
1700
+ headPersonId?: string | null
1701
+ }) => {
1702
+ const unitId = crypto.randomUUID()
1703
+ const created: Row<OrgUnit> = {
1704
+ id: unitId,
1705
+ parentId: input.parentId ?? null,
1706
+ path: pathFor(input.parentId ?? null, unitId),
1707
+ name: input.name,
1708
+ code: input.code ?? null,
1709
+ headPersonId: input.headPersonId ?? null,
1710
+ archivedAt: null,
1711
+ }
1712
+ orgUnits.push(created)
1713
+ return { ...created, workspaceId: input.workspaceId }
1714
+ },
1715
+
1716
+ update: async (input: {
1717
+ workspaceId: string
1718
+ unitId: string
1719
+ name?: string
1720
+ code?: string | null
1721
+ headPersonId?: string | null
1722
+ }) => {
1723
+ const found = orgUnits.find((u) => u.id === input.unitId)
1724
+ if (!found) refuse('NOT_FOUND', 'Department not found')
1725
+ if (input.name !== undefined) found.name = input.name
1726
+ if (input.code !== undefined) found.code = input.code
1727
+ if (input.headPersonId !== undefined) found.headPersonId = input.headPersonId
1728
+ return { ...found, workspaceId: input.workspaceId }
1729
+ },
1730
+
1731
+ /**
1732
+ * Reparent a unit and rewrite the path of everything beneath it.
1733
+ *
1734
+ * Moving a unit under its own descendant would detach that branch from the root — the one
1735
+ * way an ltree hierarchy is corrupted beyond repair by an ordinary drag — so it is refused
1736
+ * before anything is written, exactly as the router refuses it.
1737
+ */
1738
+ move: async (input: { workspaceId: string; unitId: string; parentId: string | null }) => {
1739
+ const unit = orgUnits.find((u) => u.id === input.unitId)
1740
+ if (!unit) refuse('NOT_FOUND', 'Department not found')
1741
+ let parentPath: string | null = null
1742
+ if (input.parentId) {
1743
+ const target = orgUnits.find((u) => u.id === input.parentId)
1744
+ if (!target) refuse('NOT_FOUND', 'Department not found')
1745
+ if (target.path === unit.path || target.path.startsWith(`${unit.path}.`)) {
1746
+ refuse('BAD_REQUEST', 'A department cannot be moved underneath itself.')
1747
+ }
1748
+ parentPath = target.path
1749
+ }
1750
+ const label = unit.path.split('.').pop()!
1751
+ const nextPath = parentPath ? `${parentPath}.${label}` : label
1752
+ // The whole subtree in one pass, the way the server's single UPDATE does it — walking
1753
+ // and reparenting one node at a time is where a half-moved branch comes from.
1754
+ const moved = descendants(unit.id)
1755
+ const wasPath = unit.path
1756
+ for (const row of moved) row.path = `${nextPath}${row.path.slice(wasPath.length)}`
1757
+ unit.parentId = input.parentId
1758
+ return moved
1759
+ .slice()
1760
+ .sort((a, b) => a.path.localeCompare(b.path))
1761
+ .map((u) => ({ ...u, workspaceId: input.workspaceId }))
1762
+ },
1763
+
1764
+ archive: async ({ unitId }: { workspaceId: string; unitId: string }) => {
1765
+ const found = orgUnits.find((u) => u.id === unitId)
1766
+ if (!found) refuse('NOT_FOUND', 'Department not found')
1767
+ const held = unitHeadcount(unitId)
1768
+ if (held > 0) {
1769
+ refuse('CONFLICT', `${held} people still report into this department. Move them first.`)
1770
+ }
1771
+ found.archivedAt = iso()
1772
+ return { ok: true as const }
1773
+ },
1774
+ },
1775
+
1776
+ positions: {
1777
+ list: async ({
1778
+ workspaceId,
1779
+ includeArchived = false,
1780
+ }: {
1781
+ workspaceId: string
1782
+ includeArchived?: boolean
1783
+ }) =>
1784
+ positions
1785
+ .filter((row) => includeArchived || row.archivedAt === null)
1786
+ .map((row) => ({ ...row, workspaceId })),
1787
+
1788
+ create: async (input: {
1789
+ workspaceId: string
1790
+ title: string
1791
+ code?: string | null
1792
+ jobFamily?: string | null
1793
+ level?: string | null
1794
+ }) => {
1795
+ const created: Row<Position> = {
1796
+ id: crypto.randomUUID(),
1797
+ title: input.title,
1798
+ code: input.code ?? null,
1799
+ jobFamily: input.jobFamily ?? null,
1800
+ level: input.level ?? null,
1801
+ archivedAt: null,
1802
+ }
1803
+ positions.push(created)
1804
+ return { ...created, workspaceId: input.workspaceId }
1805
+ },
1806
+
1807
+ update: async (input: {
1808
+ workspaceId: string
1809
+ positionId: string
1810
+ title?: string
1811
+ code?: string | null
1812
+ jobFamily?: string | null
1813
+ level?: string | null
1814
+ }) => {
1815
+ const found = positions.find((row) => row.id === input.positionId)
1816
+ if (!found) refuse('NOT_FOUND', 'Position not found')
1817
+ if (input.title !== undefined) found.title = input.title
1818
+ if (input.code !== undefined) found.code = input.code
1819
+ if (input.jobFamily !== undefined) found.jobFamily = input.jobFamily
1820
+ if (input.level !== undefined) found.level = input.level
1821
+ return { ...found, workspaceId: input.workspaceId }
1822
+ },
1823
+
1824
+ // No refusal here: the router archives a position without checking who holds it.
1825
+ archive: async ({ positionId }: { workspaceId: string; positionId: string }) => {
1826
+ const found = positions.find((row) => row.id === positionId)
1827
+ if (!found) refuse('NOT_FOUND', 'Position not found')
1828
+ found.archivedAt = iso()
1829
+ return { ok: true as const }
1830
+ },
1831
+ },
894
1832
  },
895
1833
 
896
1834
  offices: {
@@ -1593,28 +2531,102 @@ export function createMockHrApi() {
1593
2531
  },
1594
2532
  },
1595
2533
  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
2534
+ /**
2535
+ * Summed from the ledger, never stored.
2536
+ *
2537
+ * The ledger screen exists to explain this number, so a tile that stated its own would
2538
+ * contradict the screen that opens from it — on first click, which is the worst place for
2539
+ * two numbers to disagree.
2540
+ */
2541
+ get: async ({ personId, periodYear }: { personId?: string; periodYear?: number }) => {
2542
+ const who = personId ?? people[0]!.id
2543
+ const year = periodYear ?? YEAR
2544
+ return leaveTypes
1599
2545
  .filter((lt) => lt.archivedAt === null)
1600
- .map((lt, index) => {
1601
- const balanceMinutes = [20, 10, 0][index] ?? 0
1602
- const pending = index === 0 ? 5 : 0
2546
+ .map((lt) => {
2547
+ const rows = ledger.filter(
2548
+ (e) => e.personId === who && e.leaveTypeId === lt.id && e.periodYear === year,
2549
+ )
2550
+ const balanceMinutes = rows.reduce((sum, e) => sum + e.amountMinutes, 0)
2551
+ const mine = leaveRequests.filter(
2552
+ (r) => r.personId === who && r.leaveTypeId === lt.id,
2553
+ ) as Array<Record<string, unknown>>
2554
+ const minutesOf = (status: string) =>
2555
+ mine.filter((r) => r.status === status).reduce((sum, r) => sum + Number(r.minutes ?? 0), 0)
2556
+ const pendingMinutes = minutesOf('pending')
2557
+ const bookedMinutes = minutesOf('approved')
2558
+ const perUnit = lt.unit === 'hour' ? 60 : 480
1603
2559
  return {
1604
- personId: personId ?? people[0]!.id,
2560
+ personId: who,
1605
2561
  leaveTypeId: lt.id,
1606
2562
  leaveTypeName: lt.name,
1607
2563
  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,
2564
+ periodYear: year,
2565
+ balanceMinutes,
2566
+ bookedMinutes,
2567
+ pendingMinutes,
2568
+ availableMinutes: balanceMinutes - pendingMinutes,
2569
+ balance: Math.round((balanceMinutes / perUnit) * 100) / 100,
2570
+ available: Math.round(((balanceMinutes - pendingMinutes) / perUnit) * 100) / 100,
1615
2571
  }
1616
- }),
2572
+ })
2573
+ },
1617
2574
  },
2575
+
2576
+ ledger: {
2577
+ /** Newest first, so the movement somebody is arguing about is the one at the top. */
2578
+ list: async ({
2579
+ workspaceId,
2580
+ personId,
2581
+ leaveTypeId,
2582
+ periodYear,
2583
+ limit = 50,
2584
+ }: {
2585
+ workspaceId: string
2586
+ personId: string
2587
+ leaveTypeId?: string
2588
+ periodYear?: number
2589
+ limit?: number
2590
+ }) => {
2591
+ const items = ledger
2592
+ .filter(
2593
+ (e) =>
2594
+ e.personId === personId &&
2595
+ (!leaveTypeId || e.leaveTypeId === leaveTypeId) &&
2596
+ (periodYear === undefined || e.periodYear === periodYear),
2597
+ )
2598
+ .slice()
2599
+ .sort((a, b) => b.effectiveOn.localeCompare(a.effectiveOn) || b.id.localeCompare(a.id))
2600
+ return {
2601
+ items: items.slice(0, limit).map((e) => ({ ...e, workspaceId })),
2602
+ nextCursor: null,
2603
+ total: items.length,
2604
+ }
2605
+ },
2606
+ },
2607
+
2608
+ /** Appends. There is no edit and no delete — a wrong adjustment is corrected by another row. */
2609
+ adjust: async (input: {
2610
+ workspaceId: string
2611
+ personId: string
2612
+ leaveTypeId: string
2613
+ kind?: LeaveLedgerEntry['kind']
2614
+ amountMinutes: number
2615
+ effectiveOn: string
2616
+ reason: string
2617
+ }) => {
2618
+ const created = entry(
2619
+ input.personId,
2620
+ input.leaveTypeId,
2621
+ input.kind ?? 'adjustment',
2622
+ input.amountMinutes,
2623
+ input.effectiveOn,
2624
+ { reason: input.reason },
2625
+ )
2626
+ ledger.push(created)
2627
+ return { ...created, workspaceId: input.workspaceId }
2628
+ },
2629
+
1618
2630
  requests: {
1619
2631
  list: async ({ workspaceId }: { workspaceId: string }) => ({
1620
2632
  items: leaveRequests.map((r) => ({ ...r, workspaceId })),
@@ -1672,9 +2684,29 @@ export function createMockHrApi() {
1672
2684
  leaveRequests.push(row)
1673
2685
  return row
1674
2686
  },
2687
+ /**
2688
+ * Two end states, not one, and each refuses differently.
2689
+ *
2690
+ * `withdrawn` is the requester taking approved leave back and `cancelled` is a request that
2691
+ * never got that far — telling somebody their own withdrawal was "already cancelled" is a
2692
+ * small lie about who did what, which is why the router carries a reason beside each
2693
+ * sentence rather than one sentence for both.
2694
+ *
2695
+ * The old version fell back to `leaveRequests[0]` when the id did not match, so an unknown
2696
+ * id cancelled somebody else's leave and reported success.
2697
+ */
1675
2698
  cancel: async ({ requestId }: { workspaceId: string; requestId: string }) => {
1676
- const row = leaveRequests.find((r) => r.id === requestId) ?? leaveRequests[0]!
1677
- row.status = 'cancelled'
2699
+ const row = leaveRequests.find((r) => r.id === requestId)
2700
+ if (!row) refuse('NOT_FOUND', 'Leave request not found')
2701
+ if (row.status === 'cancelled') {
2702
+ refuse('CONFLICT', 'That request is already cancelled.', 'hr.leave.already_cancelled')
2703
+ }
2704
+ if (row.status === 'withdrawn') {
2705
+ refuse('CONFLICT', 'That request was already withdrawn.', 'hr.leave.already_withdrawn')
2706
+ }
2707
+ row.status = row.status === 'approved' ? 'withdrawn' : 'cancelled'
2708
+ row.decidedAt = iso()
2709
+ row.updatedAt = iso()
1678
2710
  return { ...row }
1679
2711
  },
1680
2712
  },
@@ -1715,56 +2747,192 @@ export function createMockHrApi() {
1715
2747
  * constraint error, and the widget renders that sentence. A mock that accepts all four
1716
2748
  * leaves a probe edited into a component as the only way to reach that branch — which is
1717
2749
  * exactly what happened, in a file nobody meant to ship.
2750
+ *
2751
+ * Each carries the router's `reason` beside its sentence. The sentence is English; the reason
2752
+ * is what a client can translate, and it reaches `data.reason` on both sides.
1718
2753
  */
1719
2754
  clockIn: async () => {
1720
- if (clockedInAt !== null) refuse('CONFLICT', 'You are already clocked in.')
2755
+ if (clockedInAt !== null)
2756
+ refuse('CONFLICT', 'You are already clocked in.', 'hr.clock.already_clocked_in')
1721
2757
  clockedInAt = Date.now()
1722
2758
  return mockPunch('in')
1723
2759
  },
1724
2760
  clockOut: async () => {
1725
- if (clockedInAt === null) refuse('CONFLICT', 'You are not clocked in.')
2761
+ if (clockedInAt === null) refuse('CONFLICT', 'You are not clocked in.', 'hr.clock.not_clocked_in')
1726
2762
  clockedInAt = null
1727
2763
  onBreak = false
1728
2764
  return mockPunch('out')
1729
2765
  },
1730
2766
  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.')
2767
+ if (clockedInAt === null)
2768
+ refuse('CONFLICT', 'Clock in before starting a break.', 'hr.clock.break_before_clock_in')
2769
+ if (onBreak) refuse('CONFLICT', 'You are already on a break.', 'hr.clock.already_on_break')
1733
2770
  onBreak = true
1734
2771
  return mockPunch('break_start')
1735
2772
  },
1736
2773
  breakEnd: async () => {
1737
- if (!onBreak) refuse('CONFLICT', 'You are not on a break.')
2774
+ if (!onBreak) refuse('CONFLICT', 'You are not on a break.', 'hr.clock.not_on_break')
1738
2775
  onBreak = false
1739
2776
  return mockPunch('break_end')
1740
2777
  },
1741
2778
  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
- }),
2779
+ /**
2780
+ * A month of day sheets, built for the range asked for.
2781
+ *
2782
+ * The page asks for `monthRange()`, so a fixed handful of rows answered the same five days
2783
+ * whatever it asked and left the rest of the month empty. Weekends come out of the working
2784
+ * week; the days worth looking at are pinned to `WD`, and the totals on a day that has
2785
+ * punches are read off them rather than invented beside them.
2786
+ */
2787
+ list: async ({
2788
+ workspaceId,
2789
+ personId,
2790
+ from,
2791
+ to,
2792
+ limit = 50,
2793
+ }: {
2794
+ workspaceId: string
2795
+ personId?: string
2796
+ from: string
2797
+ to: string
2798
+ limit?: number
2799
+ }) => {
2800
+ const who = personId ?? people[0]!.id
2801
+ const today = day(0)
2802
+ // Built up to `limit` rather than built and then sliced: a caller that asks for a decade
2803
+ // would otherwise materialise every day of it to return the first fifty.
2804
+ const items = []
2805
+ for (const date of eachDate(from, to)) {
2806
+ if (date > today) break
2807
+ if (items.length >= limit) break
2808
+ items.push(attendanceDay(date, who, workspaceId))
2809
+ }
2810
+ return { items, nextCursor: null }
2811
+ },
2812
+ },
2813
+
2814
+ punches: {
2815
+ list: async ({
2816
+ workspaceId,
2817
+ personId,
2818
+ from,
2819
+ to,
2820
+ includeVoided = false,
2821
+ limit = 50,
2822
+ }: {
2823
+ workspaceId: string
2824
+ personId?: string
2825
+ from: string
2826
+ to: string
2827
+ includeVoided?: boolean
2828
+ limit?: number
2829
+ }) => {
2830
+ const who = personId ?? people[0]!.id
2831
+ const items = punches
2832
+ .filter(
2833
+ (row) =>
2834
+ row.personId === who &&
2835
+ row.businessDate >= from &&
2836
+ row.businessDate <= to &&
2837
+ (includeVoided || row.voidedByPunchId === null),
2838
+ )
2839
+ .sort((a, b) => a.at.localeCompare(b.at))
2840
+ return { items: items.slice(0, limit).map((row) => ({ ...row, workspaceId })), nextCursor: null }
2841
+ },
2842
+
2843
+ /**
2844
+ * A void writes a correcting row; it never edits or deletes the original.
2845
+ *
2846
+ * The correction is stamped with the original's own instant and direction and points at
2847
+ * itself, so nothing counts it as a punch — it is there to carry the reason and to say what
2848
+ * it replaced. That is the whole difference between a corrected timesheet and an edited one.
2849
+ */
2850
+ void: async ({
2851
+ workspaceId,
2852
+ punchId,
2853
+ reason,
2854
+ }: {
2855
+ workspaceId: string
2856
+ punchId: string
2857
+ reason: string
2858
+ }) => {
2859
+ void workspaceId
2860
+ const original = punches.find((row) => row.id === punchId)
2861
+ if (!original) refuse('NOT_FOUND', 'Punch not found')
2862
+ if (original.voidedByPunchId) refuse('CONFLICT', 'That punch is already voided')
2863
+ const correction = punch(original.businessDate, '00:00', original.direction, {
2864
+ at: original.at,
2865
+ method: 'manual',
2866
+ note: `Voids ${punchId}: ${reason}`,
2867
+ })
2868
+ correction.voidedByPunchId = correction.id
2869
+ original.voidedByPunchId = correction.id
2870
+ punches.push(correction)
2871
+ return { ok: true as const }
2872
+ },
2873
+ },
2874
+
2875
+ regularizations: {
2876
+ list: async ({
2877
+ workspaceId,
2878
+ personId,
2879
+ status,
2880
+ limit = 50,
2881
+ }: {
2882
+ workspaceId: string
2883
+ personId?: string
2884
+ status?: string[]
2885
+ limit?: number
2886
+ }) => {
2887
+ const who = personId ?? people[0]!.id
2888
+ const items = regularizations
2889
+ .filter((row) => row.personId === who && (!status?.length || status.includes(row.status)))
2890
+ .sort((a, b) => b.businessDate.localeCompare(a.businessDate))
2891
+ return { items: items.slice(0, limit).map((row) => ({ ...row, workspaceId })), nextCursor: null }
2892
+ },
2893
+
2894
+ request: async (input: {
2895
+ workspaceId: string
2896
+ personId?: string
2897
+ businessDate: string
2898
+ punchId?: string | null
2899
+ proposed: Array<{ direction: string; at: string }>
2900
+ reason: string
2901
+ }) => {
2902
+ const who = input.personId ?? people[0]!.id
2903
+ const created: Row<Regularization> = {
2904
+ id: crypto.randomUUID(),
2905
+ personId: who,
2906
+ businessDate: input.businessDate,
2907
+ punchId: input.punchId ?? null,
2908
+ proposed: input.proposed as Regularization['proposed'],
2909
+ reason: input.reason,
2910
+ status: 'pending',
2911
+ approvalRequestId: crypto.randomUUID(),
2912
+ appliedAt: null,
2913
+ createdAt: iso(),
2914
+ }
2915
+ regularizations.push(created)
2916
+ // The same engine leave uses, so the request appears in the approvals inbox rather than
2917
+ // only in the list it was made from — one request, both screens, as the server has it.
2918
+ approvalRequests.push({
2919
+ id: created.approvalRequestId!,
2920
+ workspaceId: '',
2921
+ subjectType: 'regularization' as const,
2922
+ subjectId: created.id,
2923
+ summary: `Correction for ${input.businessDate}`,
2924
+ summaryParams: { date: input.businessDate },
2925
+ status: 'pending',
2926
+ currentStep: 0,
2927
+ requestedBy: null,
2928
+ requesterPersonId: who,
2929
+ requesterName: people.find((x) => x.id === who)?.displayName ?? '',
2930
+ requestedAt: iso(),
2931
+ decidedAt: null,
2932
+ steps: [],
2933
+ })
2934
+ return { ...created, workspaceId: input.workspaceId }
2935
+ },
1768
2936
  },
1769
2937
 
1770
2938
  schedules: {
@@ -1869,6 +3037,76 @@ export function createMockHrApi() {
1869
3037
  },
1870
3038
  },
1871
3039
 
3040
+ periods: {
3041
+ list: async ({
3042
+ workspaceId,
3043
+ kind,
3044
+ limit = 50,
3045
+ }: {
3046
+ workspaceId: string
3047
+ kind?: string
3048
+ limit?: number
3049
+ }) => {
3050
+ const items = periods
3051
+ .filter((row) => !kind || row.kind === kind)
3052
+ .slice()
3053
+ .sort((a, b) => b.startsOn.localeCompare(a.startsOn))
3054
+ return { items: items.slice(0, limit).map((row) => ({ ...row, workspaceId })), nextCursor: null }
3055
+ },
3056
+
3057
+ create: async (input: {
3058
+ workspaceId: string
3059
+ kind?: Period['kind']
3060
+ legalEntityId?: string | null
3061
+ startsOn: string
3062
+ endsOn: string
3063
+ }) => {
3064
+ if (input.endsOn < input.startsOn) refuse('BAD_REQUEST', 'A period cannot end before it starts.')
3065
+ const created: Row<Period> = {
3066
+ id: crypto.randomUUID(),
3067
+ kind: input.kind ?? 'payroll',
3068
+ legalEntityId: input.legalEntityId ?? null,
3069
+ startsOn: input.startsOn,
3070
+ endsOn: input.endsOn,
3071
+ status: 'open',
3072
+ lockedAt: null,
3073
+ lockedBy: null,
3074
+ note: null,
3075
+ }
3076
+ periods.push(created)
3077
+ return { ...created, workspaceId: input.workspaceId }
3078
+ },
3079
+
3080
+ lock: async (input: { workspaceId: string; periodId: string; note?: string | null }) => {
3081
+ const found = periods.find((row) => row.id === input.periodId)
3082
+ if (!found) refuse('NOT_FOUND', 'Period not found')
3083
+ if (found.status === 'locked') refuse('CONFLICT', 'That period is already locked.')
3084
+ found.status = 'locked'
3085
+ found.lockedAt = iso()
3086
+ found.note = input.note ?? null
3087
+ return {
3088
+ ...found,
3089
+ workspaceId: input.workspaceId,
3090
+ lockedDays: workingDaysIn(found.startsOn, found.endsOn),
3091
+ }
3092
+ },
3093
+
3094
+ /**
3095
+ * Reopens it. No refusal for a period that is already open — the router has none either, and
3096
+ * inventing one here would be a rule the server does not have.
3097
+ */
3098
+ unlock: async (input: { workspaceId: string; periodId: string; reason: string }) => {
3099
+ const found = periods.find((row) => row.id === input.periodId)
3100
+ if (!found) refuse('NOT_FOUND', 'Period not found')
3101
+ const days = found.status === 'locked' ? workingDaysIn(found.startsOn, found.endsOn) : 0
3102
+ found.status = 'open'
3103
+ found.lockedAt = null
3104
+ found.lockedBy = null
3105
+ found.note = `Reopened: ${input.reason}`
3106
+ return { ...found, workspaceId: input.workspaceId, unlockedDays: days }
3107
+ },
3108
+ },
3109
+
1872
3110
  approvals: {
1873
3111
  /**
1874
3112
  * Both tabs have something in them on purpose.
@@ -1914,6 +3152,58 @@ export function createMockHrApi() {
1914
3152
  return { ...found, workspaceId }
1915
3153
  },
1916
3154
 
3155
+ chains: {
3156
+ /** Archived chains are gone from here: the list is what a request can still be routed by. */
3157
+ list: async ({ workspaceId, subjectType }: { workspaceId: string; subjectType?: string }) =>
3158
+ chains
3159
+ .filter((c) => c.archivedAt === null && (!subjectType || c.subjectType === subjectType))
3160
+ .map((c) => ({ ...c, workspaceId })),
3161
+
3162
+ create: async (input: {
3163
+ workspaceId: string
3164
+ name: string
3165
+ subjectType: ApprovalChain['subjectType']
3166
+ spec: ApprovalChainSpec
3167
+ isDefault?: boolean
3168
+ }) => {
3169
+ const created: Row<ApprovalChain> = {
3170
+ id: crypto.randomUUID(),
3171
+ name: input.name,
3172
+ subjectType: input.subjectType,
3173
+ spec: clone(input.spec),
3174
+ isDefault: input.isDefault ?? false,
3175
+ archivedAt: null,
3176
+ }
3177
+ chains.push(created)
3178
+ // Exactly one default per subject type: promoting this one demotes whichever held it.
3179
+ if (created.isDefault) clearDefaultChain(created.subjectType, created.id)
3180
+ return { ...created, workspaceId: input.workspaceId }
3181
+ },
3182
+
3183
+ update: async (input: {
3184
+ workspaceId: string
3185
+ chainId: string
3186
+ name?: string
3187
+ spec?: ApprovalChainSpec
3188
+ isDefault?: boolean
3189
+ }) => {
3190
+ const found = chains.find((c) => c.id === input.chainId)
3191
+ if (!found) refuse('NOT_FOUND', 'Approval chain not found')
3192
+ if (input.name !== undefined) found.name = input.name
3193
+ if (input.spec !== undefined) found.spec = clone(input.spec)
3194
+ if (input.isDefault !== undefined) found.isDefault = input.isDefault
3195
+ if (found.isDefault) clearDefaultChain(found.subjectType, found.id)
3196
+ return { ...found, workspaceId: input.workspaceId }
3197
+ },
3198
+
3199
+ archive: async ({ chainId }: { workspaceId: string; chainId: string }) => {
3200
+ const found = chains.find((c) => c.id === chainId)
3201
+ if (!found) refuse('NOT_FOUND', 'Approval chain not found')
3202
+ found.archivedAt = iso()
3203
+ return { ok: true as const }
3204
+ },
3205
+ },
3206
+
1917
3207
  delegations: async ({ workspaceId }: { workspaceId: string }) =>
1918
3208
  delegations.map((d) => ({ ...d, workspaceId })),
1919
3209
 
@@ -1955,25 +3245,63 @@ export function createMockHrApi() {
1955
3245
  },
1956
3246
  }
1957
3247
 
1958
- function mockPunch(direction: string) {
3248
+ /** Kept, not just returned: expanding today's row after clocking in has to show the punch. */
3249
+ function mockPunch(direction: Punch['direction']) {
3250
+ const row = punch(day(0), '00:00', direction, { at: new Date().toISOString() })
3251
+ punches.push(row)
3252
+ return { ...row, workspaceId: '' }
3253
+ }
3254
+
3255
+ /**
3256
+ * One day's sheet, derived the way the server's is.
3257
+ *
3258
+ * `firstIn` and `lastOut` are read off the live punches rather than stated beside them, so a void
3259
+ * or a fresh clock-in moves the header of the panel it sits above instead of contradicting it.
3260
+ */
3261
+ function attendanceDay(date: string, personId: string, workspaceId: string) {
3262
+ const weekday = WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]!
3263
+ const weekend = weekday === 'sat' || weekday === 'sun'
3264
+ const live = punches
3265
+ .filter((row) => row.personId === personId && row.businessDate === date && !row.voidedByPunchId)
3266
+ .sort((a, b) => a.at.localeCompare(b.at))
3267
+ const firstIn = live.find((row) => row.direction === 'in')?.at ?? null
3268
+ const lastOut = [...live].reverse().find((row) => row.direction === 'out')?.at ?? null
3269
+
3270
+ const leave = date === WD[2]
3271
+ // A day whose clock-out never arrived. It is the only seeded anomaly, and without one the
3272
+ // counted badge on the row and the list of sentences inside the panel are both unreachable.
3273
+ const unclosed = date === WD[3]
3274
+ const overtime = date === WD[1] ? 45 : 0
3275
+ const worked = weekend || leave ? 0 : unclosed ? 240 : 480 + overtime
3276
+
1959
3277
  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(),
3278
+ id: id(`a${date.replaceAll('-', '')}`),
3279
+ workspaceId,
3280
+ personId,
3281
+ businessDate: date,
3282
+ scheduledMinutes: weekend ? 0 : 480,
3283
+ workedMinutes: date === day(0) && clockedInAt === null ? 0 : worked,
3284
+ breakMinutes: weekend || leave ? 0 : 60,
3285
+ overtimeMinutes: overtime,
3286
+ // Null, not zero: the demo workspace has no overtime policy with an annual cap, and
3287
+ // "no ceiling applied" is a different fact from "one applied and nothing exceeded it".
3288
+ beyondCapMinutes: null,
3289
+ lateMinutes: 0,
3290
+ earlyLeaveMinutes: 0,
3291
+ status: weekend
3292
+ ? ('weekend' as const)
3293
+ : leave
3294
+ ? ('leave' as const)
3295
+ : unclosed || date === day(0)
3296
+ ? ('pending' as const)
3297
+ : ('present' as const),
3298
+ leaveRequestId: leave ? ((leaveRequests[0]?.id as string | null) ?? null) : null,
3299
+ anomalies: unclosed ? ['missing_clock_out'] : [],
3300
+ firstIn,
3301
+ lastOut: unclosed ? null : lastOut,
3302
+ policyHash: null,
3303
+ locked: false,
3304
+ computedAt: iso(),
1977
3305
  }
1978
3306
  }
1979
3307
  }