@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.
- package/package.json +5 -5
- 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 +1417 -89
- 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 } 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. */
|
|
@@ -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 ${
|
|
732
|
-
summaryParams: { date:
|
|
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
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
-
/**
|
|
1597
|
-
|
|
1598
|
-
|
|
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
|
|
1601
|
-
const
|
|
1602
|
-
|
|
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:
|
|
2560
|
+
personId: who,
|
|
1605
2561
|
leaveTypeId: lt.id,
|
|
1606
2562
|
leaveTypeName: lt.name,
|
|
1607
2563
|
unit: lt.unit,
|
|
1608
|
-
periodYear:
|
|
1609
|
-
balanceMinutes
|
|
1610
|
-
bookedMinutes
|
|
1611
|
-
pendingMinutes
|
|
1612
|
-
availableMinutes:
|
|
1613
|
-
balance: balanceMinutes,
|
|
1614
|
-
available: balanceMinutes -
|
|
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)
|
|
1677
|
-
row
|
|
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)
|
|
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)
|
|
1732
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1961
|
-
workspaceId
|
|
1962
|
-
personId
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
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
|
}
|