@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.
- package/package.json +3 -3
- package/src/client/components/DayDetail.svelte +641 -0
- package/src/client/components/EmploymentChangeDialog.svelte +341 -0
- package/src/client/components/HrSidebar.svelte +7 -0
- package/src/client/components/LeaveLedgerPanel.svelte +654 -0
- package/src/client/components/PersonDocumentsSection.svelte +492 -0
- package/src/client/components/PersonJobSection.svelte +343 -0
- package/src/client/components/PersonPanel.svelte +23 -44
- package/src/client/components/PersonSensitiveSection.svelte +382 -0
- package/src/client/components/RegularizationDialog.svelte +334 -0
- package/src/client/components/refusal.ts +21 -0
- package/src/client/messages.ts +3391 -0
- package/src/client/mock.ts +1895 -102
- package/src/client/module.ts +50 -0
- package/src/client/pages/AttendancePage.svelte +116 -11
- package/src/client/pages/LeavePage.svelte +86 -5
- package/src/client/pages/OrgPage.svelte +1623 -0
- package/src/client/pages/leave-and-attendance.test.ts +29 -2
- package/src/client/query.ts +21 -0
- package/src/client/settings/AccrualSettings.svelte +1779 -0
- package/src/client/settings/ApprovalsSettings.svelte +1206 -0
- package/src/client/settings/PeriodsSettings.svelte +858 -0
package/src/client/mock.ts
CHANGED
|
@@ -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 {
|
|
18
|
-
import type {
|
|
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
|
-
|
|
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 ${
|
|
732
|
-
summaryParams: { date:
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
-
/**
|
|
1597
|
-
|
|
1598
|
-
|
|
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
|
|
1601
|
-
const
|
|
1602
|
-
|
|
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:
|
|
2828
|
+
personId: who,
|
|
1605
2829
|
leaveTypeId: lt.id,
|
|
1606
2830
|
leaveTypeName: lt.name,
|
|
1607
2831
|
unit: lt.unit,
|
|
1608
|
-
periodYear:
|
|
1609
|
-
balanceMinutes
|
|
1610
|
-
bookedMinutes
|
|
1611
|
-
pendingMinutes
|
|
1612
|
-
availableMinutes:
|
|
1613
|
-
balance: balanceMinutes,
|
|
1614
|
-
available: balanceMinutes -
|
|
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)
|
|
1677
|
-
row
|
|
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)
|
|
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)
|
|
1732
|
-
|
|
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
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
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
|
-
|
|
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:
|
|
1961
|
-
workspaceId
|
|
1962
|
-
personId
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
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
|
}
|