@kernhq/module-hr 0.9.3 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/contract/approvals.d.ts +3 -0
  2. package/dist/contract/approvals.d.ts.map +1 -1
  3. package/dist/contract/approvals.js +13 -1
  4. package/dist/contract/approvals.js.map +1 -1
  5. package/dist/contract/capabilities.d.ts +16 -0
  6. package/dist/contract/capabilities.d.ts.map +1 -1
  7. package/dist/contract/capabilities.js +16 -0
  8. package/dist/contract/capabilities.js.map +1 -1
  9. package/dist/contract/router.d.ts +9 -0
  10. package/dist/contract/router.d.ts.map +1 -1
  11. package/dist/policy/accrual.d.ts +35 -4
  12. package/dist/policy/accrual.d.ts.map +1 -1
  13. package/dist/policy/accrual.js +141 -15
  14. package/dist/policy/accrual.js.map +1 -1
  15. package/dist/policy/working-time.d.ts +115 -9
  16. package/dist/policy/working-time.d.ts.map +1 -1
  17. package/dist/policy/working-time.js +120 -20
  18. package/dist/policy/working-time.js.map +1 -1
  19. package/dist/server/jobs.d.ts +14 -6
  20. package/dist/server/jobs.d.ts.map +1 -1
  21. package/dist/server/jobs.js +458 -144
  22. package/dist/server/jobs.js.map +1 -1
  23. package/dist/server/packs/index.d.ts +14 -0
  24. package/dist/server/packs/index.d.ts.map +1 -1
  25. package/dist/server/packs/index.js +19 -0
  26. package/dist/server/packs/index.js.map +1 -1
  27. package/dist/server/router.d.ts +1896 -111
  28. package/dist/server/router.d.ts.map +1 -1
  29. package/dist/server/router.js +146 -63
  30. package/dist/server/router.js.map +1 -1
  31. package/dist/server/schema.d.ts +53 -0
  32. package/dist/server/schema.d.ts.map +1 -1
  33. package/dist/server/schema.js +101 -2
  34. package/dist/server/schema.js.map +1 -1
  35. package/dist/server/services/approvals.d.ts +14 -0
  36. package/dist/server/services/approvals.d.ts.map +1 -1
  37. package/dist/server/services/approvals.js +14 -3
  38. package/dist/server/services/approvals.js.map +1 -1
  39. package/dist/server/services/attendance.d.ts +120 -6
  40. package/dist/server/services/attendance.d.ts.map +1 -1
  41. package/dist/server/services/attendance.js +226 -13
  42. package/dist/server/services/attendance.js.map +1 -1
  43. package/dist/server/services/policies.d.ts +6 -0
  44. package/dist/server/services/policies.d.ts.map +1 -1
  45. package/dist/server/services/policies.js +6 -0
  46. package/dist/server/services/policies.js.map +1 -1
  47. package/migrations/0005_approval_requester.sql +30 -0
  48. package/migrations/0006_schedule_no_overlap.sql +85 -0
  49. package/migrations/0007_hot_path_indexes.sql +38 -0
  50. package/migrations/0009_beyond_cap_minutes.sql +15 -0
  51. package/migrations/meta/0005_snapshot.json +4047 -0
  52. package/migrations/meta/0007_snapshot.json +4225 -0
  53. package/migrations/meta/0009_snapshot.json +4231 -0
  54. package/migrations/meta/_journal.json +28 -0
  55. package/package.json +7 -7
  56. package/src/client/capabilities.ts +34 -0
  57. package/src/client/components/ClockControls.svelte +127 -20
  58. package/src/client/components/ClockControls.test.ts +446 -0
  59. package/src/client/components/DecisionDialog.svelte +90 -0
  60. package/src/client/components/DelegationDialog.svelte +219 -0
  61. package/src/client/components/LeaveRequestDialog.svelte +254 -25
  62. package/src/client/components/PersonFormDialog.svelte +53 -3
  63. package/src/client/components/PersonPanel.svelte +134 -26
  64. package/src/client/i18n.ts +5 -625
  65. package/src/client/index.ts +1 -20
  66. package/src/client/messages.test.ts +154 -0
  67. package/src/client/messages.ts +3388 -0
  68. package/src/client/mock.ts +1691 -162
  69. package/src/client/module.ts +17 -2
  70. package/src/client/pages/ApprovalsPage.svelte +332 -60
  71. package/src/client/pages/AttendancePage.svelte +106 -20
  72. package/src/client/pages/DirectoryPage.svelte +192 -55
  73. package/src/client/pages/LeavePage.svelte +280 -25
  74. package/src/client/pages/OfficesPage.svelte +181 -39
  75. package/src/client/pages/leave-and-attendance.test.ts +588 -0
  76. package/src/client/permissions.ts +2 -1
  77. package/src/client/query.ts +22 -1
  78. package/src/client/settings/CalendarsSettings.svelte +1363 -12
  79. package/src/client/settings/CapabilitiesSettings.svelte +276 -19
  80. package/src/client/settings/GeneralSettings.svelte +420 -0
  81. package/src/client/settings/LeaveSettings.svelte +884 -12
  82. package/src/client/settings/OfficesSettings.svelte +1182 -12
  83. package/src/client/settings/SchedulesSettings.svelte +1149 -12
  84. package/src/client/summary.ts +43 -0
  85. package/src/client/widgets/ApprovalsWidget.svelte +133 -20
  86. package/src/client/widgets/HeadcountWidget.svelte +74 -8
  87. package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
  88. package/src/client/widgets/WhosOutWidget.svelte +47 -8
  89. package/src/contract/approvals.ts +13 -1
  90. package/src/contract/capabilities.ts +17 -0
@@ -4,85 +4,765 @@
4
4
  * A module missing from the mock has working pages and no way to reach them in exactly the
5
5
  * environment used for demos and end-to-end tests — so this exists to be *reachable*, not to be a
6
6
  * second implementation. It answers the shapes the screens ask for, with data a demo can show.
7
+ *
8
+ * The state below is mutable and every write lands in it. A create that never appears in the next
9
+ * list looks like a broken product rather than a missing backend, and it is the kind of broken
10
+ * nobody can describe well enough to report — so an archive leaves the default list, an update
11
+ * merges, and a suppressed pack day stays suppressed until somebody restores it.
12
+ *
13
+ * Types come from the contract rather than being retyped here. A mock that answers a shape core
14
+ * does not is how a screen works in `dev:mock` and breaks against the real API.
15
+ */
16
+ import { ORPCError } from '@orpc/contract'
17
+ import type { Schedule, ScheduleAssignment } from '../contract/attendance.js'
18
+ import type { LeaveType } from '../contract/leave.js'
19
+ import type {
20
+ Calendar,
21
+ CalendarDay,
22
+ CalendarDayKind,
23
+ LegalEntity,
24
+ Office,
25
+ OfficeAssignment,
26
+ ResolvedCalendarDay,
27
+ WorkingWeek,
28
+ } from '../contract/models.js'
29
+
30
+ /**
31
+ * A refusal the client cannot tell from the server's.
32
+ *
33
+ * `kernErrorToORPC` turns every `KernError` the router throws into exactly this — same code, same
34
+ * status, same sentence — and the oRPC link hands the browser one back. A bare `Error` would carry
35
+ * the message and *not* the code, so a screen branching on `CONFLICT` takes one path against the
36
+ * mock and another against core, which is the difference the mock exists to erase.
37
+ *
38
+ * Every sentence below is copied from `src/server/router.ts`, because the widget renders the
39
+ * server's own words rather than a translated string.
7
40
  */
41
+ function refuse(code: 'CONFLICT' | 'NOT_FOUND' | 'BAD_REQUEST', message: string): never {
42
+ // A declaration, not a `const` arrow: TypeScript only narrows on a `never` return for one of
43
+ // those, so an arrow would leave every caller believing the row after the guard is still optional.
44
+ throw new ORPCError(code, { message })
45
+ }
46
+
47
+ /** Stored without the tenant, which every call stamps back on. */
48
+ type Row<T> = Omit<T, 'workspaceId'>
49
+
50
+ /**
51
+ * A composed day, tenant included.
52
+ *
53
+ * `WorkspaceId` is a branded string in the contract and a plain one everywhere a mock is handed it,
54
+ * so the brand is dropped here rather than asserted at twenty call sites.
55
+ */
56
+ type ResolvedDay = Row<ResolvedCalendarDay> & { workspaceId: string }
57
+
58
+ /**
59
+ * A deep copy through JSON, which is what the real client does to the same value on its way out.
60
+ *
61
+ * A screen hands these procedures a `$state` proxy — `structuredClone` refuses to clone one, and
62
+ * keeping the proxy would let a later edit in the dialog rewrite a schedule nobody saved.
63
+ */
64
+ const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
65
+
8
66
  const now = Date.now()
9
67
  const iso = (msAgo = 0) => new Date(now - msAgo).toISOString()
10
68
  const day = (offset: number) => new Date(now + offset * 86_400_000).toISOString().slice(0, 10)
11
69
 
12
- const OFFICES = [
13
- {
14
- id: '01920000-0000-7000-8000-00000000e001',
15
- name: 'Istanbul',
16
- country: 'TR',
17
- timezone: 'Europe/Istanbul',
18
- isDefault: true,
19
- kind: 'head_office',
20
- },
21
- {
22
- id: '01920000-0000-7000-8000-00000000e002',
23
- name: 'Amsterdam',
24
- country: 'NL',
25
- timezone: 'Europe/Amsterdam',
26
- isDefault: false,
27
- kind: 'branch',
28
- },
29
- ]
30
-
31
- const PEOPLE = [
32
- {
33
- id: '01920000-0000-7000-8000-00000000d001',
34
- displayName: 'Ayşe Yılmaz',
35
- workEmail: 'ayse@example.test',
36
- status: 'active',
37
- timezone: 'Europe/Istanbul',
38
- officeId: OFFICES[0]!.id,
39
- employeeNo: 'E-1',
40
- },
41
- {
42
- id: '01920000-0000-7000-8000-00000000d002',
43
- displayName: 'Sanne de Vries',
44
- workEmail: 'sanne@example.test',
45
- status: 'active',
46
- timezone: 'Europe/Amsterdam',
47
- officeId: OFFICES[1]!.id,
48
- employeeNo: 'E-2',
70
+ /** Stable ids, so reloading a demo does not move every link in it. The suffix stays hexadecimal. */
71
+ const id = (suffix: string) => `01920000-0000-7000-8000-${suffix.padStart(12, '0')}`
72
+
73
+ /**
74
+ * The seed year.
75
+ *
76
+ * A holiday list is read a year at a time and the calendar screen opens on the current one, so
77
+ * seeding a fixed year would hand every demo after it an empty page.
78
+ */
79
+ const YEAR = new Date(now).getFullYear()
80
+ const onDay = (monthDay: string) => `${YEAR}-${monthDay}`
81
+
82
+ const CAL_TR = id('ca01')
83
+ const CAL_NL = id('ca02')
84
+
85
+ const DEFAULT_WEEK: WorkingWeek = { mon: 1, tue: 1, wed: 1, thu: 1, fri: 1, sat: 0, sun: 0 }
86
+
87
+ /** `getUTCDay()` counts from Sunday; the working week is named. */
88
+ const WEEKDAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] as const
89
+
90
+ /** Every date in an inclusive range. Stepped in UTC, so a daylight-saving hour cannot skip a day. */
91
+ function eachDate(from: string, to: string): string[] {
92
+ const out: string[] = []
93
+ const last = Date.parse(`${to}T00:00:00Z`)
94
+ for (let at = Date.parse(`${from}T00:00:00Z`); at <= last; at += 86_400_000) {
95
+ out.push(new Date(at).toISOString().slice(0, 10))
96
+ }
97
+ return out
98
+ }
99
+
100
+ type PackDay = { monthDay: string; name: string; kind: CalendarDayKind; workingFraction: number }
101
+
102
+ /**
103
+ * The country packs, behaving the way a pack does: a version, and a set of days per year.
104
+ *
105
+ * The Türkiye calendar is seeded from an *older* version of `tr` — a renamed day, a dropped one and
106
+ * two it never had — because a `pack.preview` that returns nothing proves nothing about the dialog
107
+ * it feeds, and the promise that dialog makes is that an upgrade cannot eat a company's own days.
108
+ */
109
+ const PACKS: Record<string, { name: string; days: PackDay[] }> = {
110
+ // Keyed by the uppercase ISO code, and looked up exactly, because `COUNTRY_PACKS` on the server
111
+ // is. A tolerant mock here would hide the fact that a lowercase key finds nothing there.
112
+ TR: {
113
+ name: 'Türkiye',
114
+ days: [
115
+ { monthDay: '01-01', name: "New Year's Day", kind: 'public_holiday', workingFraction: 0 },
116
+ { monthDay: '03-19', name: 'Ramazan Bayramı Arifesi', kind: 'half_day', workingFraction: 0.5 },
117
+ { monthDay: '03-20', name: 'Ramazan Bayramı', kind: 'religious', workingFraction: 0 },
118
+ {
119
+ monthDay: '04-23',
120
+ name: "National Sovereignty and Children's Day",
121
+ kind: 'public_holiday',
122
+ workingFraction: 0,
123
+ },
124
+ { monthDay: '05-01', name: 'Labour and Solidarity Day', kind: 'public_holiday', workingFraction: 0 },
125
+ {
126
+ monthDay: '05-19',
127
+ name: 'Commemoration of Atatürk, Youth and Sports Day',
128
+ kind: 'public_holiday',
129
+ workingFraction: 0,
130
+ },
131
+ { monthDay: '05-27', name: 'Kurban Bayramı', kind: 'religious', workingFraction: 0 },
132
+ {
133
+ monthDay: '07-15',
134
+ name: 'Democracy and National Unity Day',
135
+ kind: 'public_holiday',
136
+ workingFraction: 0,
137
+ },
138
+ { monthDay: '08-30', name: 'Victory Day', kind: 'public_holiday', workingFraction: 0 },
139
+ { monthDay: '10-29', name: 'Republic Day', kind: 'public_holiday', workingFraction: 0 },
140
+ ],
49
141
  },
50
- {
51
- id: '01920000-0000-7000-8000-00000000d003',
52
- displayName: 'Mehmet Kaya',
53
- workEmail: 'mehmet@example.test',
54
- status: 'on_leave',
55
- timezone: 'Europe/Istanbul',
56
- officeId: OFFICES[0]!.id,
57
- employeeNo: 'E-3',
142
+ NL: {
143
+ name: 'Nederland',
144
+ days: [
145
+ { monthDay: '01-01', name: 'Nieuwjaarsdag', kind: 'public_holiday', workingFraction: 0 },
146
+ { monthDay: '04-27', name: 'Koningsdag', kind: 'public_holiday', workingFraction: 0 },
147
+ { monthDay: '05-05', name: 'Bevrijdingsdag', kind: 'public_holiday', workingFraction: 0 },
148
+ { monthDay: '12-25', name: 'Eerste Kerstdag', kind: 'public_holiday', workingFraction: 0 },
149
+ { monthDay: '12-26', name: 'Tweede Kerstdag', kind: 'public_holiday', workingFraction: 0 },
150
+ ],
58
151
  },
59
- ]
60
-
61
- const person = (p: (typeof PEOPLE)[number], workspaceId: string) => ({
62
- ...p,
63
- workspaceId,
64
- userId: null,
65
- personalEmail: null,
66
- phone: null,
67
- photoFileId: null,
68
- hiredOn: day(-400),
69
- terminatedOn: null,
70
- custom: {},
71
- createdAt: iso(400 * 86_400_000),
72
- updatedAt: iso(),
73
- })
152
+ }
74
153
 
75
154
  export function createMockHrApi() {
76
155
  /** Clock state lives here so the widget behaves across clicks in a demo. */
77
156
  let clockedInAt: number | null = null
78
157
  let onBreak = false
79
158
 
159
+ // ---------------------------------------------------------------- offices and people
160
+
161
+ const offices: Row<Office>[] = [
162
+ {
163
+ id: id('e001'),
164
+ name: 'Istanbul',
165
+ code: 'IST',
166
+ kind: 'head_office',
167
+ parentOfficeId: null,
168
+ legalEntityId: id('1e01'),
169
+ country: 'TR',
170
+ region: '34',
171
+ city: 'Istanbul',
172
+ timezone: 'Europe/Istanbul',
173
+ calendarId: CAL_TR,
174
+ address: { line1: 'Büyükdere Caddesi 1', postalCode: '34394' },
175
+ isDefault: true,
176
+ headPersonId: null,
177
+ archivedAt: null,
178
+ createdAt: iso(400 * 86_400_000),
179
+ },
180
+ {
181
+ id: id('e002'),
182
+ name: 'Amsterdam',
183
+ code: 'AMS',
184
+ kind: 'branch',
185
+ parentOfficeId: null,
186
+ legalEntityId: id('1e02'),
187
+ country: 'NL',
188
+ region: 'NH',
189
+ city: 'Amsterdam',
190
+ timezone: 'Europe/Amsterdam',
191
+ calendarId: CAL_NL,
192
+ address: { line1: 'Keizersgracht 12', postalCode: '1015 CS' },
193
+ isDefault: false,
194
+ headPersonId: null,
195
+ archivedAt: null,
196
+ createdAt: iso(200 * 86_400_000),
197
+ },
198
+ // A third kind on screen, and the one people forget exists: remote is a place like any other,
199
+ // with a country, a zone and a calendar, because everything downstream inherits from an office.
200
+ {
201
+ id: id('e003'),
202
+ name: 'Remote',
203
+ code: null,
204
+ kind: 'remote',
205
+ parentOfficeId: null,
206
+ legalEntityId: id('1e01'),
207
+ country: 'DE',
208
+ region: null,
209
+ city: null,
210
+ timezone: 'Europe/Berlin',
211
+ calendarId: CAL_TR,
212
+ address: null,
213
+ isDefault: false,
214
+ headPersonId: null,
215
+ archivedAt: null,
216
+ createdAt: iso(90 * 86_400_000),
217
+ },
218
+ ]
219
+
220
+ const entities: Row<LegalEntity>[] = [
221
+ {
222
+ id: id('1e01'),
223
+ name: 'Kern Teknoloji A.Ş.',
224
+ registrationNo: '123456-5',
225
+ taxNo: '4560123456',
226
+ country: 'TR',
227
+ currency: 'TRY',
228
+ archivedAt: null,
229
+ },
230
+ {
231
+ id: id('1e02'),
232
+ name: 'Kern Europe B.V.',
233
+ registrationNo: '81234567',
234
+ taxNo: 'NL812345678B01',
235
+ country: 'NL',
236
+ currency: 'EUR',
237
+ archivedAt: null,
238
+ },
239
+ ]
240
+
241
+ const people = [
242
+ {
243
+ id: id('d001'),
244
+ displayName: 'Ayşe Yılmaz',
245
+ workEmail: 'ayse@example.test',
246
+ status: 'active',
247
+ timezone: 'Europe/Istanbul',
248
+ employeeNo: 'E-1',
249
+ },
250
+ {
251
+ id: id('d002'),
252
+ displayName: 'Sanne de Vries',
253
+ workEmail: 'sanne@example.test',
254
+ status: 'active',
255
+ timezone: 'Europe/Amsterdam',
256
+ employeeNo: 'E-2',
257
+ },
258
+ {
259
+ id: id('d003'),
260
+ displayName: 'Mehmet Kaya',
261
+ workEmail: 'mehmet@example.test',
262
+ status: 'on_leave',
263
+ timezone: 'Europe/Istanbul',
264
+ employeeNo: 'E-3',
265
+ },
266
+ {
267
+ id: id('d004'),
268
+ displayName: 'Jonas Weber',
269
+ workEmail: 'jonas@example.test',
270
+ status: 'active',
271
+ timezone: 'Europe/Berlin',
272
+ employeeNo: 'E-4',
273
+ },
274
+ ]
275
+
276
+ /**
277
+ * Who works where, with exactly one primary each.
278
+ *
279
+ * Sanne holds two: only the primary decides her holidays and her timezone, and the second is
280
+ * presence — she is in Istanbul's roster and nothing about her leave changed. That distinction is
281
+ * the whole reason the roster labels every row, so the seed has to contain a row to label.
282
+ */
283
+ const assignments: Row<OfficeAssignment>[] = [
284
+ {
285
+ id: id('a5001'),
286
+ personId: id('d001'),
287
+ officeId: id('e001'),
288
+ isPrimary: true,
289
+ effectiveFrom: day(-400),
290
+ effectiveTo: null,
291
+ reason: null,
292
+ createdAt: iso(400 * 86_400_000),
293
+ },
294
+ {
295
+ id: id('a5002'),
296
+ personId: id('d002'),
297
+ officeId: id('e002'),
298
+ isPrimary: true,
299
+ effectiveFrom: day(-300),
300
+ effectiveTo: null,
301
+ reason: null,
302
+ createdAt: iso(300 * 86_400_000),
303
+ },
304
+ {
305
+ id: id('a5003'),
306
+ personId: id('d002'),
307
+ officeId: id('e001'),
308
+ isPrimary: false,
309
+ effectiveFrom: day(-120),
310
+ effectiveTo: null,
311
+ reason: 'Two days a week with the platform team',
312
+ createdAt: iso(120 * 86_400_000),
313
+ },
314
+ {
315
+ id: id('a5004'),
316
+ personId: id('d003'),
317
+ officeId: id('e001'),
318
+ isPrimary: true,
319
+ effectiveFrom: day(-250),
320
+ effectiveTo: null,
321
+ reason: null,
322
+ createdAt: iso(250 * 86_400_000),
323
+ },
324
+ {
325
+ id: id('a5005'),
326
+ personId: id('d004'),
327
+ officeId: id('e003'),
328
+ isPrimary: true,
329
+ effectiveFrom: day(-80),
330
+ effectiveTo: null,
331
+ reason: null,
332
+ createdAt: iso(80 * 86_400_000),
333
+ },
334
+ ]
335
+
336
+ const activeAssignments = (personId: string) =>
337
+ assignments.filter((a) => a.personId === personId && a.effectiveTo === null)
338
+ const primaryOfficeId = (personId: string): string | null =>
339
+ activeAssignments(personId).find((a) => a.isPrimary)?.officeId ?? null
340
+ const officeName = (officeId: string | null) => offices.find((o) => o.id === officeId)?.name ?? null
341
+ const liveOffices = () => offices.filter((o) => o.archivedAt === null)
342
+ /**
343
+ * Primary assignments only.
344
+ *
345
+ * Somebody can hold several offices, so counting presence would make the headcounts sum to more
346
+ * people than the company has — and the offices screen adds them up into a "People" tile. The
347
+ * roster is the other half of the same decision: it lists everyone and labels which kind each
348
+ * row is, because presence is worth seeing and is not worth counting.
349
+ */
350
+ const headcount = (officeId: string) =>
351
+ assignments.filter((a) => a.officeId === officeId && a.effectiveTo === null && a.isPrimary).length
352
+
353
+ const person = (p: (typeof people)[number], workspaceId: string) => ({
354
+ ...p,
355
+ workspaceId,
356
+ userId: null,
357
+ personalEmail: null,
358
+ phone: null,
359
+ photoFileId: null,
360
+ hiredOn: day(-400),
361
+ terminatedOn: null,
362
+ custom: {},
363
+ createdAt: iso(400 * 86_400_000),
364
+ updatedAt: iso(),
365
+ })
366
+
367
+ // ---------------------------------------------------------------- calendars
368
+
369
+ const calendars: Row<Calendar>[] = [
370
+ {
371
+ id: CAL_TR,
372
+ name: 'Türkiye',
373
+ extendsId: null,
374
+ country: 'TR',
375
+ region: null,
376
+ workingWeek: { ...DEFAULT_WEEK },
377
+ source: 'pack',
378
+ packKey: 'TR',
379
+ packVersion: String(YEAR),
380
+ archivedAt: null,
381
+ },
382
+ {
383
+ id: CAL_NL,
384
+ name: 'Amsterdam',
385
+ extendsId: CAL_TR,
386
+ country: 'NL',
387
+ region: null,
388
+ workingWeek: { ...DEFAULT_WEEK },
389
+ source: 'custom',
390
+ packKey: null,
391
+ packVersion: null,
392
+ archivedAt: null,
393
+ },
394
+ ]
395
+
396
+ let dayCounter = 0
397
+ const nextDayId = () => id(`da${(++dayCounter).toString(16).padStart(4, '0')}`)
398
+
399
+ const calDay = (
400
+ calendarId: string,
401
+ monthDay: string,
402
+ name: string,
403
+ kind: CalendarDayKind,
404
+ workingFraction: number,
405
+ source: 'pack' | 'custom',
406
+ note: string | null = null,
407
+ ): Row<CalendarDay> => ({
408
+ id: nextDayId(),
409
+ calendarId,
410
+ date: onDay(monthDay),
411
+ kind,
412
+ name,
413
+ workingFraction,
414
+ source,
415
+ paid: true,
416
+ note,
417
+ })
418
+
419
+ /**
420
+ * The raw rows, per calendar. Composition happens on read, exactly as the server does it.
421
+ *
422
+ * The Türkiye rows below are version 1.0 of the `tr` pack: `Labour Day` was renamed in 2.0,
423
+ * `Atatürk Memorial Day` was dropped from it, and two days it now carries are missing here.
424
+ */
425
+ const calendarDays: Row<CalendarDay>[] = [
426
+ calDay(CAL_TR, '01-01', "New Year's Day", 'public_holiday', 0, 'pack'),
427
+ calDay(CAL_TR, '03-19', 'Ramazan Bayramı Arifesi', 'half_day', 0.5, 'pack'),
428
+ calDay(CAL_TR, '04-23', "National Sovereignty and Children's Day", 'public_holiday', 0, 'pack'),
429
+ calDay(CAL_TR, '05-01', 'Labour Day', 'public_holiday', 0, 'pack'),
430
+ calDay(CAL_TR, '05-19', 'Commemoration of Atatürk, Youth and Sports Day', 'public_holiday', 0, 'pack'),
431
+ calDay(CAL_TR, '05-27', 'Kurban Bayramı', 'religious', 0, 'pack'),
432
+ calDay(CAL_TR, '08-30', 'Victory Day', 'public_holiday', 0, 'pack'),
433
+ calDay(CAL_TR, '10-29', 'Republic Day', 'public_holiday', 0, 'pack'),
434
+ calDay(CAL_TR, '11-10', 'Atatürk Memorial Day', 'public_holiday', 0, 'pack'),
435
+
436
+ // The pack gives half of the eve; this company closes the whole day. A custom row over a pack
437
+ // row, which is what the list draws as "changed".
438
+ calDay(
439
+ CAL_TR,
440
+ '03-19',
441
+ 'Ramazan Bayramı Arifesi',
442
+ 'company_closure',
443
+ 0,
444
+ 'custom',
445
+ 'Full day, not the half the pack gives',
446
+ ),
447
+ // The state the screen exists to make visible: a pack day this company works through. The pack
448
+ // row is still underneath it and survives the next upgrade.
449
+ calDay(
450
+ CAL_TR,
451
+ '05-19',
452
+ 'Commemoration of Atatürk, Youth and Sports Day',
453
+ 'working_override',
454
+ 1,
455
+ 'custom',
456
+ 'Deadline week',
457
+ ),
458
+ calDay(CAL_TR, '07-20', 'Summer shutdown', 'company_closure', 0, 'custom'),
459
+ // 29 October is a Thursday in the seeded year, so this really is the bridge the kind is named for.
460
+ calDay(CAL_TR, '10-30', 'Republic Day bridge', 'bridge', 0, 'custom'),
461
+ calDay(CAL_TR, '12-31', "New Year's Eve", 'half_day', 0.5, 'custom'),
462
+ calDay(CAL_TR, '09-05', 'Stock count', 'working_override', 1, 'custom', 'A worked Saturday'),
463
+
464
+ calDay(CAL_NL, '04-27', 'Koningsdag', 'public_holiday', 0, 'custom'),
465
+ calDay(CAL_NL, '05-05', 'Bevrijdingsdag', 'public_holiday', 0, 'custom'),
466
+ calDay(CAL_NL, '12-25', 'Eerste Kerstdag', 'public_holiday', 0, 'custom'),
467
+ calDay(CAL_NL, '12-26', 'Tweede Kerstdag', 'public_holiday', 0, 'custom'),
468
+ // Amsterdam works through two of the days it inherits from Türkiye: one masked outright, one
469
+ // kept as a half day. Both are the branch's own rows sitting over the base's.
470
+ calDay(CAL_NL, '04-23', "National Sovereignty and Children's Day", 'working_override', 1, 'custom'),
471
+ calDay(CAL_NL, '05-01', 'Labour Day (afternoon off)', 'half_day', 0.5, 'custom'),
472
+ ]
473
+
474
+ /** Furthest ancestor first, so the nearer calendar's rows land on top of it. */
475
+ function chainOf(calendarId: string): Row<Calendar>[] {
476
+ const chain: Row<Calendar>[] = []
477
+ const seen = new Set<string>()
478
+ let cursor = calendars.find((c) => c.id === calendarId)
479
+ while (cursor && !seen.has(cursor.id)) {
480
+ seen.add(cursor.id)
481
+ chain.unshift(cursor)
482
+ const parent: string | null = cursor.extendsId
483
+ cursor = parent ? calendars.find((c) => c.id === parent) : undefined
484
+ }
485
+ return chain
486
+ }
487
+
488
+ /**
489
+ * The composed calendar, by date.
490
+ *
491
+ * Nearest calendar wins, and within one calendar a `custom` row wins over a `pack` row on the
492
+ * same date — which is how removing a pack day suppresses it instead of deleting something the
493
+ * next upgrade would only bring back.
494
+ */
495
+ function composeDays(calendarId: string, workspaceId: string): Map<string, ResolvedDay> {
496
+ const byDate = new Map<string, ResolvedDay>()
497
+ for (const cal of chainOf(calendarId)) {
498
+ for (const source of ['pack', 'custom'] as const) {
499
+ for (const row of calendarDays.filter((r) => r.calendarId === cal.id && r.source === source)) {
500
+ byDate.set(row.date, {
501
+ ...row,
502
+ workspaceId,
503
+ fromCalendarId: cal.id,
504
+ fromCalendarName: cal.name,
505
+ overrides: byDate.has(row.date),
506
+ })
507
+ }
508
+ }
509
+ }
510
+ return byDate
511
+ }
512
+
513
+ const resolveDay = (calendarId: string, workspaceId: string, date: string): ResolvedDay => {
514
+ const found = composeDays(calendarId, workspaceId).get(date)
515
+ if (!found) refuse('NOT_FOUND', 'Calendar day not found')
516
+ return found
517
+ }
518
+
519
+ // ---------------------------------------------------------------- leave, schedules
520
+
521
+ const leaveTypes: Row<LeaveType>[] = [
522
+ {
523
+ id: id('b001'),
524
+ key: 'annual',
525
+ name: 'Annual leave',
526
+ paid: true,
527
+ unit: 'day',
528
+ color: '#4c8bf5',
529
+ icon: 'tree-palm',
530
+ requiresDocumentAfterDays: null,
531
+ countsWorkingDaysOnly: true,
532
+ allowNegative: true,
533
+ maxNegativeMinutes: 5 * 480,
534
+ order: 0,
535
+ archivedAt: null,
536
+ },
537
+ {
538
+ id: id('b002'),
539
+ key: 'sick',
540
+ name: 'Sick leave',
541
+ paid: true,
542
+ unit: 'day',
543
+ color: '#dd5a5a',
544
+ icon: 'activity',
545
+ requiresDocumentAfterDays: 3,
546
+ countsWorkingDaysOnly: true,
547
+ allowNegative: false,
548
+ maxNegativeMinutes: 0,
549
+ order: 1,
550
+ archivedAt: null,
551
+ },
552
+ {
553
+ id: id('b003'),
554
+ key: 'unpaid',
555
+ name: 'Unpaid leave',
556
+ paid: false,
557
+ unit: 'day',
558
+ color: '#7a8794',
559
+ icon: 'calendar-days',
560
+ requiresDocumentAfterDays: null,
561
+ countsWorkingDaysOnly: true,
562
+ allowNegative: false,
563
+ maxNegativeMinutes: 0,
564
+ order: 2,
565
+ archivedAt: null,
566
+ },
567
+ // Archived, so the section that shows archived types has something in it and the note about
568
+ // there being no way back is attached to a real row.
569
+ {
570
+ id: id('b004'),
571
+ key: 'study',
572
+ name: 'Study leave',
573
+ paid: true,
574
+ unit: 'day',
575
+ color: '#8a6ee0',
576
+ icon: 'star',
577
+ requiresDocumentAfterDays: null,
578
+ countsWorkingDaysOnly: true,
579
+ allowNegative: false,
580
+ maxNegativeMinutes: 0,
581
+ order: 3,
582
+ archivedAt: iso(60 * 86_400_000),
583
+ },
584
+ ]
585
+
586
+ const officeHours = { start: '09:00', end: '18:00', breakMinutes: 60 }
587
+ const night = { start: '22:00', end: '06:00', breakMinutes: 45 }
588
+ const mornings = { start: '09:00', end: '13:00', breakMinutes: 0 }
589
+
590
+ const schedules: Row<Schedule>[] = [
591
+ {
592
+ id: id('05c1'),
593
+ name: 'Office hours',
594
+ kind: 'fixed',
595
+ week: {
596
+ mon: { ...officeHours },
597
+ tue: { ...officeHours },
598
+ wed: { ...officeHours },
599
+ thu: { ...officeHours },
600
+ fri: { ...officeHours },
601
+ sat: null,
602
+ sun: null,
603
+ },
604
+ tzMode: 'office',
605
+ tz: null,
606
+ graceInMinutes: 10,
607
+ graceOutMinutes: 10,
608
+ roundingStepMinutes: 0,
609
+ roundingDirection: 'nearest',
610
+ autoClockOutAfterMinutes: null,
611
+ archivedAt: null,
612
+ },
613
+ // The interesting one: 22:00–06:00 is eight hours on the day it starts, not a negative number,
614
+ // and the week grid draws it differently. A model that only ever sees daytime shifts is untested.
615
+ {
616
+ id: id('05c2'),
617
+ name: 'Night shift',
618
+ kind: 'shift',
619
+ week: {
620
+ mon: { ...night },
621
+ tue: { ...night },
622
+ wed: { ...night },
623
+ thu: { ...night },
624
+ fri: null,
625
+ sat: null,
626
+ sun: { ...night },
627
+ },
628
+ tzMode: 'fixed',
629
+ tz: 'Europe/Istanbul',
630
+ graceInMinutes: 15,
631
+ graceOutMinutes: 15,
632
+ roundingStepMinutes: 15,
633
+ roundingDirection: 'nearest',
634
+ autoClockOutAfterMinutes: 720,
635
+ archivedAt: null,
636
+ },
637
+ {
638
+ id: id('05c3'),
639
+ name: 'Part-time mornings',
640
+ kind: 'flexible',
641
+ week: {
642
+ mon: { ...mornings },
643
+ tue: null,
644
+ wed: { ...mornings },
645
+ thu: null,
646
+ fri: { ...mornings },
647
+ sat: null,
648
+ sun: null,
649
+ },
650
+ tzMode: 'person',
651
+ tz: null,
652
+ graceInMinutes: 30,
653
+ graceOutMinutes: 30,
654
+ roundingStepMinutes: 0,
655
+ roundingDirection: 'employee',
656
+ autoClockOutAfterMinutes: null,
657
+ archivedAt: null,
658
+ },
659
+ {
660
+ id: id('05c4'),
661
+ name: 'Summer hours',
662
+ kind: 'fixed',
663
+ week: {
664
+ mon: { start: '08:00', end: '15:00', breakMinutes: 30 },
665
+ tue: { start: '08:00', end: '15:00', breakMinutes: 30 },
666
+ wed: { start: '08:00', end: '15:00', breakMinutes: 30 },
667
+ thu: { start: '08:00', end: '15:00', breakMinutes: 30 },
668
+ fri: { start: '08:00', end: '15:00', breakMinutes: 30 },
669
+ sat: null,
670
+ sun: null,
671
+ },
672
+ tzMode: 'office',
673
+ tz: null,
674
+ graceInMinutes: 0,
675
+ graceOutMinutes: 0,
676
+ roundingStepMinutes: 0,
677
+ roundingDirection: 'nearest',
678
+ autoClockOutAfterMinutes: null,
679
+ archivedAt: iso(30 * 86_400_000),
680
+ },
681
+ ]
682
+
683
+ const scheduleAssignments: Row<ScheduleAssignment>[] = [
684
+ {
685
+ id: id('a5c1'),
686
+ personId: id('d001'),
687
+ scheduleId: id('05c1'),
688
+ effectiveFrom: day(-400),
689
+ effectiveTo: null,
690
+ },
691
+ {
692
+ id: id('a5c2'),
693
+ personId: id('d002'),
694
+ scheduleId: id('05c1'),
695
+ effectiveFrom: day(-300),
696
+ effectiveTo: null,
697
+ },
698
+ {
699
+ id: id('a5c3'),
700
+ personId: id('d004'),
701
+ scheduleId: id('05c3'),
702
+ effectiveFrom: day(-80),
703
+ effectiveTo: null,
704
+ },
705
+ ]
706
+
707
+ const delegations: Array<Record<string, unknown>> = []
708
+
709
+ const approvalRequests = [
710
+ {
711
+ id: id('f001'),
712
+ workspaceId: '',
713
+ subjectType: 'leave' as const,
714
+ subjectId: id('c001'),
715
+ summary: `5 day(s) from ${day(14)}`,
716
+ summaryParams: { days: 5, from: day(14), to: day(18) } as Record<string, string | number> | null,
717
+ status: 'pending' as string,
718
+ currentStep: 0,
719
+ requestedBy: null,
720
+ requesterPersonId: people[1]!.id,
721
+ requesterName: people[1]!.displayName,
722
+ requestedAt: iso(3600_000),
723
+ decidedAt: null as string | null,
724
+ steps: [] as Array<Record<string, unknown>>,
725
+ },
726
+ {
727
+ id: id('f002'),
728
+ subjectType: 'regularization' as const,
729
+ workspaceId: '',
730
+ subjectId: id('c002'),
731
+ summary: `Correction for ${day(-1)}`,
732
+ summaryParams: { date: day(-1) } as Record<string, string | number> | null,
733
+ status: 'pending' as string,
734
+ currentStep: 0,
735
+ requestedBy: null,
736
+ requesterPersonId: people[2]!.id,
737
+ requesterName: people[2]!.displayName,
738
+ requestedAt: iso(7200_000),
739
+ decidedAt: null as string | null,
740
+ steps: [{ stepIndex: 0 }, { stepIndex: 1 }] as Array<Record<string, unknown>>,
741
+ },
742
+ {
743
+ id: id('f003'),
744
+ workspaceId: '',
745
+ subjectType: 'leave' as const,
746
+ subjectId: id('c003'),
747
+ summary: `1 day(s) from ${day(-20)}`,
748
+ summaryParams: { days: 1, from: day(-20), to: day(-20) } as Record<string, string | number> | null,
749
+ status: 'approved' as string,
750
+ currentStep: 0,
751
+ requestedBy: null,
752
+ requesterPersonId: people[0]!.id,
753
+ requesterName: people[0]!.displayName,
754
+ requestedAt: iso(20 * 86_400_000),
755
+ decidedAt: iso(19 * 86_400_000) as string | null,
756
+ steps: [] as Array<Record<string, unknown>>,
757
+ },
758
+ ]
759
+
80
760
  const leaveRequests: Array<Record<string, unknown>> = [
81
761
  {
82
- id: '01920000-0000-7000-8000-00000000c001',
762
+ id: id('c001'),
83
763
  workspaceId: '',
84
- personId: PEOPLE[0]!.id,
85
- leaveTypeId: '01920000-0000-7000-8000-00000000b001',
764
+ personId: people[0]!.id,
765
+ leaveTypeId: id('b001'),
86
766
  startsOn: day(14),
87
767
  endsOn: day(18),
88
768
  startPart: 'full',
@@ -102,27 +782,41 @@ export function createMockHrApi() {
102
782
 
103
783
  return {
104
784
  people: {
105
- list: async ({ workspaceId, q, officeId }: { workspaceId: string; q?: string; officeId?: string }) => {
106
- let items = PEOPLE
107
- if (officeId) items = items.filter((p) => p.officeId === officeId)
785
+ list: async ({
786
+ workspaceId,
787
+ q,
788
+ officeId,
789
+ status,
790
+ limit = 50,
791
+ }: {
792
+ workspaceId: string
793
+ q?: string
794
+ officeId?: string
795
+ status?: string[]
796
+ limit?: number
797
+ }) => {
798
+ let items = people
799
+ if (officeId) items = items.filter((p) => primaryOfficeId(p.id) === officeId)
800
+ if (status?.length) items = items.filter((p) => status.includes(p.status))
108
801
  if (q) items = items.filter((p) => p.displayName.toLowerCase().includes(q.toLowerCase()))
109
802
  // Carries officeName too: a mock that answers a different shape from core is how a screen
110
803
  // works in `dev:mock` and breaks against the real API.
111
804
  return {
112
- items: items.map((p) => ({
805
+ items: items.slice(0, limit).map((p) => ({
113
806
  ...person(p, workspaceId),
114
- officeId: p.officeId,
115
- officeName: OFFICES.find((o) => o.id === p.officeId)?.name ?? null,
807
+ officeId: primaryOfficeId(p.id),
808
+ officeName: officeName(primaryOfficeId(p.id)),
116
809
  })),
117
810
  nextCursor: null,
811
+ // The count before the page, because a widget asking for one row draws this number.
118
812
  total: items.length,
119
813
  }
120
814
  },
121
815
  get: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => {
122
- const found = PEOPLE.find((p) => p.id === personId) ?? PEOPLE[0]!
816
+ const found = people.find((p) => p.id === personId) ?? people[0]!
123
817
  return person(found, workspaceId)
124
818
  },
125
- me: async ({ workspaceId }: { workspaceId: string }) => person(PEOPLE[0]!, workspaceId),
819
+ me: async ({ workspaceId }: { workspaceId: string }) => person(people[0]!, workspaceId),
126
820
  create: async (input: {
127
821
  workspaceId: string
128
822
  displayName: string
@@ -138,10 +832,21 @@ export function createMockHrApi() {
138
832
  workEmail: input.workEmail ?? '',
139
833
  status: 'active' as const,
140
834
  timezone: 'Europe/Istanbul',
141
- officeId: input.officeId ?? OFFICES[0]!.id,
142
- employeeNo: input.employeeNo ?? `E-${PEOPLE.length + 1}`,
835
+ employeeNo: input.employeeNo ?? `E-${people.length + 1}`,
143
836
  }
144
- PEOPLE.push(added)
837
+ people.push(added)
838
+ // A new person lands in the default office when nobody chose one, which is what the default
839
+ // flag is for.
840
+ assignments.push({
841
+ id: crypto.randomUUID(),
842
+ personId: added.id,
843
+ officeId: input.officeId ?? liveOffices().find((o) => o.isDefault)?.id ?? offices[0]!.id,
844
+ isPrimary: true,
845
+ effectiveFrom: input.hiredOn ?? day(0),
846
+ effectiveTo: null,
847
+ reason: null,
848
+ createdAt: iso(),
849
+ })
145
850
  return person(added, input.workspaceId)
146
851
  },
147
852
  update: async (input: {
@@ -152,7 +857,7 @@ export function createMockHrApi() {
152
857
  personalEmail?: string | null
153
858
  phone?: string | null
154
859
  }) => {
155
- const found = PEOPLE.find((p) => p.id === input.personId) ?? PEOPLE[0]!
860
+ const found = people.find((p) => p.id === input.personId) ?? people[0]!
156
861
  if (input.displayName) found.displayName = input.displayName
157
862
  if (input.workEmail !== undefined) found.workEmail = input.workEmail ?? ''
158
863
  return {
@@ -162,14 +867,15 @@ export function createMockHrApi() {
162
867
  }
163
868
  },
164
869
  offboard: async (input: { workspaceId: string; personId: string; on: string }) => {
165
- const found = PEOPLE.find((p) => p.id === input.personId) ?? PEOPLE[0]!
166
- return { ...person(found, input.workspaceId), terminatedOn: input.on, status: 'terminated' as const }
870
+ const found = people.find((p) => p.id === input.personId) ?? people[0]!
871
+ found.status = 'terminated'
872
+ return { ...person(found, input.workspaceId), terminatedOn: input.on }
167
873
  },
168
874
  },
169
875
 
170
876
  employment: {
171
877
  current: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => ({
172
- id: '01920000-0000-7000-8000-00000000ee01',
878
+ id: id('ee01'),
173
879
  workspaceId,
174
880
  personId,
175
881
  effectiveFrom: day(-400),
@@ -178,7 +884,7 @@ export function createMockHrApi() {
178
884
  positionId: null,
179
885
  legalEntityId: null,
180
886
  costCenterId: null,
181
- managerPersonId: PEOPLE[1]?.id ?? null,
887
+ managerPersonId: people.find((x) => x.id !== personId)?.id ?? null,
182
888
  employmentType: 'full_time' as const,
183
889
  fte: 1,
184
890
  contractHoursWeek: 40,
@@ -188,83 +894,726 @@ export function createMockHrApi() {
188
894
  },
189
895
 
190
896
  offices: {
191
- list: async ({ workspaceId }: { workspaceId: string }) =>
192
- OFFICES.map((o) => ({
193
- ...o,
194
- workspaceId,
195
- code: null,
196
- parentOfficeId: null,
197
- legalEntityId: null,
198
- region: null,
199
- city: o.name,
897
+ list: async ({
898
+ workspaceId,
899
+ includeArchived = false,
900
+ }: {
901
+ workspaceId: string
902
+ includeArchived?: boolean
903
+ }) =>
904
+ offices
905
+ .filter((o) => includeArchived || o.archivedAt === null)
906
+ .map((o) => ({ ...o, workspaceId, headcount: headcount(o.id) })),
907
+
908
+ get: async ({ workspaceId, officeId }: { workspaceId: string; officeId: string }) => {
909
+ const found = offices.find((o) => o.id === officeId)
910
+ if (!found) refuse('NOT_FOUND', 'Office not found')
911
+ return { ...found, workspaceId }
912
+ },
913
+
914
+ create: async (input: {
915
+ workspaceId: string
916
+ name: string
917
+ kind?: Office['kind']
918
+ country: string
919
+ region?: string | null
920
+ city?: string | null
921
+ timezone: string
922
+ code?: string | null
923
+ parentOfficeId?: string | null
924
+ legalEntityId?: string | null
925
+ seedCalendarFromPack?: boolean
926
+ }) => {
927
+ const created: Row<Office> = {
928
+ id: crypto.randomUUID(),
929
+ name: input.name,
930
+ code: input.code ?? null,
931
+ kind: input.kind ?? 'branch',
932
+ parentOfficeId: input.parentOfficeId ?? null,
933
+ legalEntityId: input.legalEntityId ?? null,
934
+ country: input.country,
935
+ region: input.region ?? null,
936
+ city: input.city ?? null,
937
+ timezone: input.timezone,
200
938
  calendarId: null,
201
939
  address: null,
940
+ // The workspace already has a default and only ever has one.
941
+ isDefault: false,
202
942
  headPersonId: null,
203
943
  archivedAt: null,
204
944
  createdAt: iso(),
205
- headcount: PEOPLE.filter((p) => p.officeId === o.id).length,
206
- })),
945
+ }
946
+ // The pack is copied as a *base* the office's calendar extends, never inlined — so seeding
947
+ // makes a calendar rather than a heap of days.
948
+ // The office's own calendar *extends* the country pack rather than copying it, so a pack
949
+ // refresh reaches this office without reconciling a copy. It is made even when no pack
950
+ // exists for the country — an office with no calendar at all has no working week to resolve,
951
+ // and the router does not leave one in that state.
952
+ if (input.seedCalendarFromPack !== false) {
953
+ const key = input.country
954
+ let base = calendars.find((c) => c.source === 'pack' && c.packKey === key)
955
+ if (!base && PACKS[key]) {
956
+ base = {
957
+ id: crypto.randomUUID(),
958
+ name: PACKS[key]!.name,
959
+ extendsId: null,
960
+ country: input.country,
961
+ region: null,
962
+ workingWeek: { ...DEFAULT_WEEK },
963
+ source: 'pack',
964
+ packKey: key,
965
+ packVersion: String(YEAR),
966
+ archivedAt: null,
967
+ }
968
+ calendars.push(base)
969
+ for (const packDay of PACKS[key]!.days) {
970
+ calendarDays.push(
971
+ calDay(
972
+ base.id,
973
+ packDay.monthDay,
974
+ packDay.name,
975
+ packDay.kind,
976
+ packDay.workingFraction,
977
+ 'pack',
978
+ ),
979
+ )
980
+ }
981
+ }
982
+ const own: Row<Calendar> = {
983
+ id: crypto.randomUUID(),
984
+ name: input.name,
985
+ extendsId: base?.id ?? null,
986
+ country: input.country,
987
+ region: input.region ?? null,
988
+ workingWeek: base ? { ...base.workingWeek } : { ...DEFAULT_WEEK },
989
+ source: 'custom',
990
+ packKey: null,
991
+ packVersion: null,
992
+ archivedAt: null,
993
+ }
994
+ calendars.push(own)
995
+ created.calendarId = own.id
996
+ }
997
+ offices.push(created)
998
+ return { ...created, workspaceId: input.workspaceId }
999
+ },
1000
+
1001
+ update: async (input: {
1002
+ workspaceId: string
1003
+ officeId: string
1004
+ name?: string
1005
+ kind?: Office['kind']
1006
+ country?: string
1007
+ region?: string | null
1008
+ city?: string | null
1009
+ timezone?: string
1010
+ calendarId?: string | null
1011
+ legalEntityId?: string | null
1012
+ headPersonId?: string | null
1013
+ code?: string | null
1014
+ }) => {
1015
+ const found = offices.find((o) => o.id === input.officeId)
1016
+ if (!found) refuse('NOT_FOUND', 'Office not found')
1017
+ if (input.name !== undefined) found.name = input.name
1018
+ if (input.kind !== undefined) found.kind = input.kind
1019
+ if (input.country !== undefined) found.country = input.country
1020
+ if (input.region !== undefined) found.region = input.region
1021
+ if (input.city !== undefined) found.city = input.city
1022
+ if (input.timezone !== undefined) found.timezone = input.timezone
1023
+ if (input.calendarId !== undefined) found.calendarId = input.calendarId
1024
+ if (input.legalEntityId !== undefined) found.legalEntityId = input.legalEntityId
1025
+ if (input.headPersonId !== undefined) found.headPersonId = input.headPersonId
1026
+ if (input.code !== undefined) found.code = input.code
1027
+ return { ...found, workspaceId: input.workspaceId }
1028
+ },
1029
+
1030
+ archive: async ({ officeId }: { workspaceId: string; officeId: string }) => {
1031
+ const found = offices.find((o) => o.id === officeId)
1032
+ if (!found) refuse('NOT_FOUND', 'Office not found')
1033
+ // The default office is where everyone without an assignment lands and where the resolution
1034
+ // ladder bottoms out, so it has to be handed over before it can go.
1035
+ if (found.isDefault) {
1036
+ refuse(
1037
+ 'CONFLICT',
1038
+ 'This is the default office. Make another office the default before archiving it.',
1039
+ )
1040
+ }
1041
+ // Presence counts here, not only primaries: archiving an office out from under somebody who
1042
+ // still appears in its directory is the same problem either way.
1043
+ const held = assignments.filter((a) => a.officeId === officeId && a.effectiveTo === null).length
1044
+ if (held > 0) refuse('CONFLICT', `${held} people still work here. Move them first.`)
1045
+ found.archivedAt = iso()
1046
+ return { ok: true as const }
1047
+ },
1048
+
1049
+ /** Moves the flag rather than adding a second one: exactly one office is ever the default. */
1050
+ setDefault: async ({ workspaceId, officeId }: { workspaceId: string; officeId: string }) => {
1051
+ const found = offices.find((o) => o.id === officeId)
1052
+ if (!found) refuse('NOT_FOUND', 'Office not found')
1053
+ for (const office of offices) office.isDefault = office.id === officeId
1054
+ return { ...found, workspaceId }
1055
+ },
1056
+
1057
+ people: async ({
1058
+ workspaceId,
1059
+ officeId,
1060
+ primaryOnly = false,
1061
+ limit = 50,
1062
+ }: {
1063
+ workspaceId: string
1064
+ officeId: string
1065
+ primaryOnly?: boolean
1066
+ limit?: number
1067
+ }) => {
1068
+ const rows = assignments.filter(
1069
+ (a) => a.officeId === officeId && a.effectiveTo === null && (!primaryOnly || a.isPrimary),
1070
+ )
1071
+ const items = rows.flatMap((a) => {
1072
+ const found = people.find((p) => p.id === a.personId)
1073
+ return found ? [{ ...person(found, workspaceId), isPrimaryHere: a.isPrimary }] : []
1074
+ })
1075
+ return { items: items.slice(0, limit), nextCursor: null, total: items.length }
1076
+ },
1077
+
1078
+ assign: async (input: {
1079
+ workspaceId: string
1080
+ officeId: string
1081
+ personId: string
1082
+ isPrimary?: boolean
1083
+ effectiveFrom: string
1084
+ reason?: string | null
1085
+ }) => {
1086
+ const isPrimary = input.isPrimary ?? true
1087
+ // One primary at a time. Somebody who assigns a second office expecting a calendar to
1088
+ // change has been misled, and the roster says so — so the state behind it has to match.
1089
+ if (isPrimary) {
1090
+ for (const a of activeAssignments(input.personId)) a.isPrimary = false
1091
+ }
1092
+ const existing = activeAssignments(input.personId).find((a) => a.officeId === input.officeId)
1093
+ if (existing) {
1094
+ existing.isPrimary = isPrimary
1095
+ existing.effectiveFrom = input.effectiveFrom
1096
+ existing.reason = input.reason ?? null
1097
+ } else {
1098
+ assignments.push({
1099
+ id: crypto.randomUUID(),
1100
+ personId: input.personId,
1101
+ officeId: input.officeId,
1102
+ isPrimary,
1103
+ effectiveFrom: input.effectiveFrom,
1104
+ effectiveTo: null,
1105
+ reason: input.reason ?? null,
1106
+ createdAt: iso(),
1107
+ })
1108
+ }
1109
+ return activeAssignments(input.personId).map((a) => ({ ...a, workspaceId: input.workspaceId }))
1110
+ },
1111
+
1112
+ unassign: async ({
1113
+ officeId,
1114
+ personId,
1115
+ effectiveTo,
1116
+ }: {
1117
+ workspaceId: string
1118
+ officeId: string
1119
+ personId: string
1120
+ effectiveTo: string
1121
+ }) => {
1122
+ const found = activeAssignments(personId).find((a) => a.officeId === officeId)
1123
+ if (!found) refuse('NOT_FOUND', 'Office assignment not found')
1124
+ // The router will not let the primary go rather than picking a replacement on somebody's
1125
+ // behalf: which office decides their holidays is a decision, not a fallback.
1126
+ if (found.isPrimary) {
1127
+ refuse('CONFLICT', 'This is their primary office. Assign another office as primary first.')
1128
+ }
1129
+ found.effectiveTo = effectiveTo
1130
+ return { ok: true as const }
1131
+ },
1132
+
207
1133
  resolveFor: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => {
208
- const p = PEOPLE.find((x) => x.id === personId) ?? PEOPLE[0]!
209
- const office = OFFICES.find((o) => o.id === p.officeId) ?? OFFICES[0]!
1134
+ const p = people.find((x) => x.id === personId) ?? people[0]!
1135
+ const primary = primaryOfficeId(p.id)
1136
+ const office = offices.find((o) => o.id === primary) ?? offices[0]!
1137
+ const calendar = calendars.find((c) => c.id === office.calendarId) ?? null
210
1138
  void workspaceId
211
1139
  return {
212
1140
  personId: p.id,
213
1141
  on: day(0),
214
1142
  primaryOfficeId: office.id,
215
1143
  primaryOfficeName: office.name,
216
- otherOfficeIds: [],
1144
+ otherOfficeIds: activeAssignments(p.id)
1145
+ .filter((a) => a.officeId !== office.id)
1146
+ .map((a) => a.officeId),
217
1147
  country: office.country,
218
1148
  timezone: office.timezone,
219
1149
  timezoneFrom: 'office' as const,
220
- calendarId: null,
221
- calendarFrom: null,
222
- workingWeek: { mon: 1, tue: 1, wed: 1, thu: 1, fri: 1, sat: 0, sun: 0 },
223
- legalEntityId: null,
1150
+ calendarId: office.calendarId,
1151
+ calendarFrom: office.calendarId ? ('office' as const) : null,
1152
+ workingWeek: calendar?.workingWeek ?? { ...DEFAULT_WEEK },
1153
+ legalEntityId: office.legalEntityId,
224
1154
  orgUnitId: null,
225
1155
  orgUnitPath: null,
226
- managerPersonId: PEOPLE[1]!.id,
1156
+ // Anybody but the subject: a resolution panel saying somebody reports to themselves reads
1157
+ // as a bug in the ladder rather than as seed data.
1158
+ managerPersonId: people.find((x) => x.id !== p.id)?.id ?? null,
1159
+ }
1160
+ },
1161
+ },
1162
+
1163
+ entities: {
1164
+ list: async ({
1165
+ workspaceId,
1166
+ includeArchived = false,
1167
+ }: {
1168
+ workspaceId: string
1169
+ includeArchived?: boolean
1170
+ }) =>
1171
+ entities.filter((e) => includeArchived || e.archivedAt === null).map((e) => ({ ...e, workspaceId })),
1172
+ },
1173
+
1174
+ calendars: {
1175
+ list: async ({
1176
+ workspaceId,
1177
+ includeArchived = false,
1178
+ }: {
1179
+ workspaceId: string
1180
+ includeArchived?: boolean
1181
+ }) =>
1182
+ calendars
1183
+ .filter((c) => includeArchived || c.archivedAt === null)
1184
+ .map((c) => ({
1185
+ ...c,
1186
+ workspaceId,
1187
+ // What makes archiving a calendar an office's problem rather than a free action.
1188
+ officeIds: liveOffices()
1189
+ .filter((o) => o.calendarId === c.id)
1190
+ .map((o) => o.id),
1191
+ })),
1192
+
1193
+ get: async ({ workspaceId, calendarId }: { workspaceId: string; calendarId: string }) => {
1194
+ const found = calendars.find((c) => c.id === calendarId)
1195
+ if (!found) refuse('NOT_FOUND', 'Calendar not found')
1196
+ return { ...found, workspaceId }
1197
+ },
1198
+
1199
+ create: async (input: {
1200
+ workspaceId: string
1201
+ name: string
1202
+ extendsId?: string | null
1203
+ country?: string | null
1204
+ region?: string | null
1205
+ workingWeek?: WorkingWeek
1206
+ }) => {
1207
+ const created: Row<Calendar> = {
1208
+ id: crypto.randomUUID(),
1209
+ name: input.name,
1210
+ extendsId: input.extendsId ?? null,
1211
+ country: input.country ?? null,
1212
+ region: input.region ?? null,
1213
+ workingWeek: input.workingWeek ? clone(input.workingWeek) : { ...DEFAULT_WEEK },
1214
+ source: 'custom',
1215
+ packKey: null,
1216
+ packVersion: null,
1217
+ archivedAt: null,
1218
+ }
1219
+ calendars.push(created)
1220
+ return { ...created, workspaceId: input.workspaceId }
1221
+ },
1222
+
1223
+ update: async (input: {
1224
+ workspaceId: string
1225
+ calendarId: string
1226
+ name?: string
1227
+ workingWeek?: WorkingWeek
1228
+ extendsId?: string | null
1229
+ }) => {
1230
+ const found = calendars.find((c) => c.id === input.calendarId)
1231
+ if (!found) refuse('NOT_FOUND', 'Calendar not found')
1232
+ if (input.name !== undefined) found.name = input.name
1233
+ if (input.workingWeek !== undefined) found.workingWeek = clone(input.workingWeek)
1234
+ if (input.extendsId !== undefined) {
1235
+ // Composing would never terminate, so this is refused rather than quietly ignored.
1236
+ if (input.extendsId === found.id) refuse('BAD_REQUEST', 'A calendar cannot extend itself.')
1237
+ found.extendsId = input.extendsId
1238
+ }
1239
+ return { ...found, workspaceId: input.workspaceId }
1240
+ },
1241
+
1242
+ archive: async ({ calendarId }: { workspaceId: string; calendarId: string }) => {
1243
+ const found = calendars.find((c) => c.id === calendarId)
1244
+ if (!found) refuse('NOT_FOUND', 'Calendar not found')
1245
+ const used = liveOffices().filter((o) => o.calendarId === calendarId).length
1246
+ if (used > 0) {
1247
+ refuse('CONFLICT', `${used} offices use this calendar. Point them at another one first.`)
1248
+ }
1249
+ found.archivedAt = iso()
1250
+ return { ok: true as const }
1251
+ },
1252
+
1253
+ days: {
1254
+ list: async ({
1255
+ workspaceId,
1256
+ calendarId,
1257
+ from,
1258
+ to,
1259
+ }: {
1260
+ workspaceId: string
1261
+ calendarId: string
1262
+ from: string
1263
+ to: string
1264
+ }) =>
1265
+ [...composeDays(calendarId, workspaceId).values()]
1266
+ .filter((d) => d.date >= from && d.date <= to)
1267
+ .sort((a, b) => a.date.localeCompare(b.date)),
1268
+
1269
+ add: async (input: {
1270
+ workspaceId: string
1271
+ calendarId: string
1272
+ date: string
1273
+ name: string
1274
+ kind?: CalendarDayKind
1275
+ workingFraction?: number
1276
+ paid?: boolean
1277
+ note?: string | null
1278
+ }) => {
1279
+ const row: Row<CalendarDay> = {
1280
+ id: nextDayId(),
1281
+ calendarId: input.calendarId,
1282
+ date: input.date,
1283
+ kind: input.kind ?? 'company_closure',
1284
+ name: input.name,
1285
+ workingFraction: input.workingFraction ?? 0,
1286
+ source: 'custom',
1287
+ paid: input.paid ?? true,
1288
+ note: input.note ?? null,
1289
+ }
1290
+ // One custom row per date per calendar. A second would be invisible — composition keeps
1291
+ // the nearest — so adding a day that already exists would look like doing nothing.
1292
+ const at = calendarDays.findIndex(
1293
+ (r) => r.calendarId === input.calendarId && r.date === input.date && r.source === 'custom',
1294
+ )
1295
+ if (at >= 0) calendarDays.splice(at, 1, { ...row, id: calendarDays[at]!.id })
1296
+ else calendarDays.push(row)
1297
+ return resolveDay(input.calendarId, input.workspaceId, input.date)
1298
+ },
1299
+
1300
+ update: async (input: {
1301
+ workspaceId: string
1302
+ calendarId: string
1303
+ dayId: string
1304
+ name?: string
1305
+ kind?: CalendarDayKind
1306
+ workingFraction?: number
1307
+ paid?: boolean
1308
+ note?: string | null
1309
+ }) => {
1310
+ const target = calendarDays.find((r) => r.id === input.dayId)
1311
+ if (!target) refuse('NOT_FOUND', 'Calendar day not found')
1312
+ const merged = {
1313
+ name: input.name ?? target.name,
1314
+ kind: input.kind ?? target.kind,
1315
+ workingFraction: input.workingFraction ?? target.workingFraction,
1316
+ paid: input.paid ?? target.paid,
1317
+ note: input.note !== undefined ? input.note : target.note,
1318
+ }
1319
+ // A pack row belongs to the pack, and so does a row inherited from a base calendar:
1320
+ // editing either writes this calendar's own row over it rather than changing something
1321
+ // the next upgrade would overwrite.
1322
+ if (target.source === 'pack' || target.calendarId !== input.calendarId) {
1323
+ const existing = calendarDays.find(
1324
+ (r) => r.calendarId === input.calendarId && r.date === target.date && r.source === 'custom',
1325
+ )
1326
+ if (existing) Object.assign(existing, merged)
1327
+ else {
1328
+ calendarDays.push({
1329
+ id: nextDayId(),
1330
+ calendarId: input.calendarId,
1331
+ date: target.date,
1332
+ source: 'custom',
1333
+ ...merged,
1334
+ })
1335
+ }
1336
+ } else {
1337
+ Object.assign(target, merged)
1338
+ }
1339
+ return resolveDay(input.calendarId, input.workspaceId, target.date)
1340
+ },
1341
+
1342
+ /**
1343
+ * A custom row is deleted; a pack row is masked.
1344
+ *
1345
+ * Deleting a pack day would only bring it back on the next upgrade, so the mask is a custom
1346
+ * row over it — which is also why deleting *that* row is what gives the pack's day back.
1347
+ */
1348
+ remove: async ({ calendarId, dayId }: { workspaceId: string; calendarId: string; dayId: string }) => {
1349
+ const target = calendarDays.find((r) => r.id === dayId)
1350
+ if (!target) refuse('NOT_FOUND', 'Calendar day not found')
1351
+ if (target.source === 'custom' && target.calendarId === calendarId) {
1352
+ calendarDays.splice(calendarDays.indexOf(target), 1)
1353
+ return { ok: true as const, suppressed: false }
1354
+ }
1355
+ calendarDays.push({
1356
+ id: nextDayId(),
1357
+ calendarId,
1358
+ date: target.date,
1359
+ kind: 'working_override',
1360
+ name: target.name,
1361
+ workingFraction: 1,
1362
+ source: 'custom',
1363
+ paid: target.paid,
1364
+ note: null,
1365
+ })
1366
+ return { ok: true as const, suppressed: true }
1367
+ },
1368
+ },
1369
+
1370
+ pack: {
1371
+ preview: async ({
1372
+ calendarId,
1373
+ packKey,
1374
+ year,
1375
+ }: {
1376
+ workspaceId: string
1377
+ calendarId: string
1378
+ packKey: string
1379
+ year: number
1380
+ }) => {
1381
+ // An unknown key is an empty incoming set rather than an error, because that is what
1382
+ // `packDays` returns on the server — so the diff then proposes dropping every pack day
1383
+ // the calendar has. See the note in the report: that is the server's behaviour, not a
1384
+ // kindness the mock invents, and it is worth somebody looking at.
1385
+ const wanted = new Map((PACKS[packKey]?.days ?? []).map((d) => [`${year}-${d.monthDay}`, d]))
1386
+ const installed = calendarDays.filter(
1387
+ (r) => r.calendarId === calendarId && r.source === 'pack' && r.date.startsWith(`${year}-`),
1388
+ )
1389
+ const byDate = new Map(installed.map((r) => [r.date, r]))
1390
+ return {
1391
+ packKey,
1392
+ packVersion: String(year),
1393
+ added: [...wanted.entries()]
1394
+ .filter(([date]) => !byDate.has(date))
1395
+ .map(([date, d]) => ({ date, name: d.name })),
1396
+ changed: [...wanted.entries()].flatMap(([date, d]) => {
1397
+ const had = byDate.get(date)
1398
+ return had && had.name !== d.name ? [{ date, name: d.name, was: had.name }] : []
1399
+ }),
1400
+ removed: installed
1401
+ .filter((r) => !wanted.has(r.date))
1402
+ .map((r) => ({ date: r.date, name: r.name })),
1403
+ // Always untouched, and listed precisely so the dialog can say so out loud.
1404
+ keptCustom: calendarDays
1405
+ .filter(
1406
+ (r) => r.calendarId === calendarId && r.source === 'custom' && r.date.startsWith(`${year}-`),
1407
+ )
1408
+ .map((r) => ({ date: r.date, name: r.name })),
1409
+ }
1410
+ },
1411
+
1412
+ apply: async ({
1413
+ calendarId,
1414
+ packKey,
1415
+ year,
1416
+ }: {
1417
+ workspaceId: string
1418
+ calendarId: string
1419
+ packKey: string
1420
+ year: number
1421
+ }) => {
1422
+ const wanted = new Map((PACKS[packKey]?.days ?? []).map((d) => [`${year}-${d.monthDay}`, d]))
1423
+ let added = 0
1424
+ let changed = 0
1425
+ let removed = 0
1426
+ for (const row of [...calendarDays]) {
1427
+ if (row.calendarId !== calendarId || row.source !== 'pack' || !row.date.startsWith(`${year}-`)) {
1428
+ continue
1429
+ }
1430
+ const want = wanted.get(row.date)
1431
+ if (!want) {
1432
+ calendarDays.splice(calendarDays.indexOf(row), 1)
1433
+ removed += 1
1434
+ continue
1435
+ }
1436
+ if (
1437
+ row.name !== want.name ||
1438
+ row.kind !== want.kind ||
1439
+ row.workingFraction !== want.workingFraction
1440
+ ) {
1441
+ row.name = want.name
1442
+ row.kind = want.kind
1443
+ row.workingFraction = want.workingFraction
1444
+ changed += 1
1445
+ }
1446
+ wanted.delete(row.date)
1447
+ }
1448
+ for (const [date, want] of wanted) {
1449
+ calendarDays.push({
1450
+ id: nextDayId(),
1451
+ calendarId,
1452
+ date,
1453
+ kind: want.kind,
1454
+ name: want.name,
1455
+ workingFraction: want.workingFraction,
1456
+ source: 'pack',
1457
+ paid: true,
1458
+ note: null,
1459
+ })
1460
+ added += 1
1461
+ }
1462
+ const calendar = calendars.find((c) => c.id === calendarId)
1463
+ if (calendar) {
1464
+ calendar.packKey = packKey
1465
+ calendar.packVersion = String(year)
1466
+ }
1467
+ return { ok: true as const, added, changed, removed }
1468
+ },
1469
+ },
1470
+
1471
+ workingDays: async ({
1472
+ workspaceId,
1473
+ personId,
1474
+ calendarId,
1475
+ from,
1476
+ to,
1477
+ }: {
1478
+ workspaceId: string
1479
+ personId?: string
1480
+ calendarId?: string
1481
+ from: string
1482
+ to: string
1483
+ }) => {
1484
+ const resolved =
1485
+ calendarId ??
1486
+ (personId ? (offices.find((o) => o.id === primaryOfficeId(personId))?.calendarId ?? null) : null) ??
1487
+ calendars[0]?.id ??
1488
+ ''
1489
+ const week = calendars.find((c) => c.id === resolved)?.workingWeek ?? DEFAULT_WEEK
1490
+ const byDate = composeDays(resolved, workspaceId)
1491
+ const breakdown = eachDate(from, to).map((date) => {
1492
+ const weekday = WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]!
1493
+ const base = week[weekday]
1494
+ const hit = byDate.get(date)
1495
+ // The calendar day wins outright rather than being capped by the week: a working override
1496
+ // on a Saturday is exactly the case the kind exists for.
1497
+ return {
1498
+ date,
1499
+ fraction: hit ? hit.workingFraction : base,
1500
+ reason: hit ? hit.name : base === 0 ? 'weekend' : null,
1501
+ }
1502
+ })
1503
+ return {
1504
+ days: Math.round(breakdown.reduce((sum, d) => sum + d.fraction, 0) * 100) / 100,
1505
+ breakdown,
227
1506
  }
228
1507
  },
229
1508
  },
230
1509
 
231
1510
  leave: {
232
1511
  types: {
233
- list: async ({ workspaceId }: { workspaceId: string }) => [
234
- {
235
- id: '01920000-0000-7000-8000-00000000b001',
236
- workspaceId,
237
- key: 'annual',
238
- name: 'Annual leave',
239
- paid: true,
240
- unit: 'day' as const,
241
- color: '#4c8bf5',
242
- icon: 'tree-palm',
243
- requiresDocumentAfterDays: null,
244
- countsWorkingDaysOnly: true,
245
- allowNegative: false,
246
- maxNegativeMinutes: 0,
247
- order: 0,
1512
+ list: async ({
1513
+ workspaceId,
1514
+ includeArchived = false,
1515
+ }: {
1516
+ workspaceId: string
1517
+ includeArchived?: boolean
1518
+ }) =>
1519
+ leaveTypes
1520
+ .filter((lt) => includeArchived || lt.archivedAt === null)
1521
+ .slice()
1522
+ .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
1523
+ .map((lt) => ({ ...lt, workspaceId })),
1524
+
1525
+ create: async (input: {
1526
+ workspaceId: string
1527
+ key: string
1528
+ name: string
1529
+ paid?: boolean
1530
+ unit?: LeaveType['unit']
1531
+ color?: string | null
1532
+ icon?: string | null
1533
+ requiresDocumentAfterDays?: number | null
1534
+ countsWorkingDaysOnly?: boolean
1535
+ allowNegative?: boolean
1536
+ maxNegativeMinutes?: number
1537
+ }) => {
1538
+ const created: Row<LeaveType> = {
1539
+ id: crypto.randomUUID(),
1540
+ key: input.key,
1541
+ name: input.name,
1542
+ paid: input.paid ?? true,
1543
+ unit: input.unit ?? 'day',
1544
+ color: input.color ?? null,
1545
+ icon: input.icon ?? null,
1546
+ requiresDocumentAfterDays: input.requiresDocumentAfterDays ?? null,
1547
+ countsWorkingDaysOnly: input.countsWorkingDaysOnly ?? true,
1548
+ allowNegative: input.allowNegative ?? false,
1549
+ maxNegativeMinutes: input.maxNegativeMinutes ?? 0,
1550
+ order: leaveTypes.length,
248
1551
  archivedAt: null,
249
- },
250
- ],
1552
+ }
1553
+ leaveTypes.push(created)
1554
+ return { ...created, workspaceId: input.workspaceId }
1555
+ },
1556
+
1557
+ update: async (input: {
1558
+ workspaceId: string
1559
+ leaveTypeId: string
1560
+ name?: string
1561
+ paid?: boolean
1562
+ color?: string | null
1563
+ icon?: string | null
1564
+ requiresDocumentAfterDays?: number | null
1565
+ countsWorkingDaysOnly?: boolean
1566
+ allowNegative?: boolean
1567
+ maxNegativeMinutes?: number
1568
+ order?: number
1569
+ }) => {
1570
+ const found = leaveTypes.find((lt) => lt.id === input.leaveTypeId)
1571
+ if (!found) refuse('NOT_FOUND', 'Leave type not found')
1572
+ if (input.name !== undefined) found.name = input.name
1573
+ if (input.paid !== undefined) found.paid = input.paid
1574
+ if (input.color !== undefined) found.color = input.color
1575
+ if (input.icon !== undefined) found.icon = input.icon
1576
+ if (input.requiresDocumentAfterDays !== undefined) {
1577
+ found.requiresDocumentAfterDays = input.requiresDocumentAfterDays
1578
+ }
1579
+ if (input.countsWorkingDaysOnly !== undefined) {
1580
+ found.countsWorkingDaysOnly = input.countsWorkingDaysOnly
1581
+ }
1582
+ if (input.allowNegative !== undefined) found.allowNegative = input.allowNegative
1583
+ if (input.maxNegativeMinutes !== undefined) found.maxNegativeMinutes = input.maxNegativeMinutes
1584
+ if (input.order !== undefined) found.order = input.order
1585
+ return { ...found, workspaceId: input.workspaceId }
1586
+ },
1587
+
1588
+ archive: async ({ leaveTypeId }: { workspaceId: string; leaveTypeId: string }) => {
1589
+ const found = leaveTypes.find((lt) => lt.id === leaveTypeId)
1590
+ if (!found) refuse('NOT_FOUND', 'Leave type not found')
1591
+ found.archivedAt = iso()
1592
+ return { ok: true as const }
1593
+ },
251
1594
  },
252
1595
  balance: {
253
- get: async ({ personId }: { personId?: string }) => [
254
- {
255
- personId: personId ?? PEOPLE[0]!.id,
256
- leaveTypeId: '01920000-0000-7000-8000-00000000b001',
257
- leaveTypeName: 'Annual leave',
258
- unit: 'day' as const,
259
- periodYear: new Date().getFullYear(),
260
- balanceMinutes: 20 * 480,
261
- bookedMinutes: 0,
262
- pendingMinutes: 5 * 480,
263
- availableMinutes: 15 * 480,
264
- balance: 20,
265
- available: 15,
266
- },
267
- ],
1596
+ /** One row per type somebody can still book, so the tiles say as much as the picker offers. */
1597
+ get: async ({ personId }: { personId?: string }) =>
1598
+ leaveTypes
1599
+ .filter((lt) => lt.archivedAt === null)
1600
+ .map((lt, index) => {
1601
+ const balanceMinutes = [20, 10, 0][index] ?? 0
1602
+ const pending = index === 0 ? 5 : 0
1603
+ return {
1604
+ personId: personId ?? people[0]!.id,
1605
+ leaveTypeId: lt.id,
1606
+ leaveTypeName: lt.name,
1607
+ unit: lt.unit,
1608
+ periodYear: YEAR,
1609
+ balanceMinutes: balanceMinutes * 480,
1610
+ bookedMinutes: 0,
1611
+ pendingMinutes: pending * 480,
1612
+ availableMinutes: (balanceMinutes - pending) * 480,
1613
+ balance: balanceMinutes,
1614
+ available: balanceMinutes - pending,
1615
+ }
1616
+ }),
268
1617
  },
269
1618
  requests: {
270
1619
  list: async ({ workspaceId }: { workspaceId: string }) => ({
@@ -303,7 +1652,7 @@ export function createMockHrApi() {
303
1652
  const row = {
304
1653
  id: crypto.randomUUID(),
305
1654
  workspaceId: input.workspaceId,
306
- personId: PEOPLE[0]!.id,
1655
+ personId: people[0]!.id,
307
1656
  leaveTypeId: input.leaveTypeId,
308
1657
  startsOn: input.startsOn,
309
1658
  endsOn: input.endsOn,
@@ -331,16 +1680,18 @@ export function createMockHrApi() {
331
1680
  },
332
1681
  team: {
333
1682
  calendar: async () =>
334
- PEOPLE.filter((p) => p.status === 'on_leave').map((p) => ({
335
- personId: p.id,
336
- displayName: p.displayName,
337
- requestId: '01920000-0000-7000-8000-00000000c002',
338
- startsOn: day(-1),
339
- endsOn: day(3),
340
- status: 'approved' as const,
341
- leaveTypeName: 'Annual leave',
342
- color: '#4c8bf5',
343
- })),
1683
+ people
1684
+ .filter((p) => p.status === 'on_leave')
1685
+ .map((p) => ({
1686
+ personId: p.id,
1687
+ displayName: p.displayName,
1688
+ requestId: id('c002'),
1689
+ startsOn: day(-1),
1690
+ endsOn: day(3),
1691
+ status: 'approved' as const,
1692
+ leaveTypeName: 'Annual leave',
1693
+ color: '#4c8bf5',
1694
+ })),
344
1695
  },
345
1696
  },
346
1697
 
@@ -348,7 +1699,7 @@ export function createMockHrApi() {
348
1699
  state: async ({ workspaceId, personId }: { workspaceId: string; personId?: string }) => {
349
1700
  void workspaceId
350
1701
  return {
351
- personId: personId ?? PEOPLE[0]!.id,
1702
+ personId: personId ?? people[0]!.id,
352
1703
  businessDate: day(0),
353
1704
  clockedIn: clockedInAt !== null,
354
1705
  onBreak,
@@ -357,29 +1708,42 @@ export function createMockHrApi() {
357
1708
  timezone: 'Europe/Istanbul',
358
1709
  }
359
1710
  },
1711
+ /**
1712
+ * The transitions the router refuses, refused here too, in its order and its words.
1713
+ *
1714
+ * `punch()` answers a double-tapped button with a CONFLICT carrying a sentence rather than a
1715
+ * constraint error, and the widget renders that sentence. A mock that accepts all four
1716
+ * leaves a probe edited into a component as the only way to reach that branch — which is
1717
+ * exactly what happened, in a file nobody meant to ship.
1718
+ */
360
1719
  clockIn: async () => {
1720
+ if (clockedInAt !== null) refuse('CONFLICT', 'You are already clocked in.')
361
1721
  clockedInAt = Date.now()
362
1722
  return mockPunch('in')
363
1723
  },
364
1724
  clockOut: async () => {
1725
+ if (clockedInAt === null) refuse('CONFLICT', 'You are not clocked in.')
365
1726
  clockedInAt = null
366
1727
  onBreak = false
367
1728
  return mockPunch('out')
368
1729
  },
369
1730
  breakStart: async () => {
1731
+ if (clockedInAt === null) refuse('CONFLICT', 'Clock in before starting a break.')
1732
+ if (onBreak) refuse('CONFLICT', 'You are already on a break.')
370
1733
  onBreak = true
371
1734
  return mockPunch('break_start')
372
1735
  },
373
1736
  breakEnd: async () => {
1737
+ if (!onBreak) refuse('CONFLICT', 'You are not on a break.')
374
1738
  onBreak = false
375
1739
  return mockPunch('break_end')
376
1740
  },
377
1741
  days: {
378
1742
  list: async ({ workspaceId }: { workspaceId: string }) => ({
379
1743
  items: [0, 1, 2, 3, 4].map((n) => ({
380
- id: `01920000-0000-7000-8000-0000000a000${n}`,
1744
+ id: id(`a000${n}`),
381
1745
  workspaceId,
382
- personId: PEOPLE[0]!.id,
1746
+ personId: people[0]!.id,
383
1747
  businessDate: day(-n),
384
1748
  scheduledMinutes: 480,
385
1749
  workedMinutes: n === 2 ? 0 : 480 + (n === 1 ? 45 : 0),
@@ -399,27 +1763,192 @@ export function createMockHrApi() {
399
1763
  nextCursor: null,
400
1764
  }),
401
1765
  },
1766
+
1767
+ schedules: {
1768
+ list: async ({
1769
+ workspaceId,
1770
+ includeArchived = false,
1771
+ }: {
1772
+ workspaceId: string
1773
+ includeArchived?: boolean
1774
+ }) =>
1775
+ schedules
1776
+ .filter((s) => includeArchived || s.archivedAt === null)
1777
+ .map((s) => ({ ...s, workspaceId })),
1778
+
1779
+ create: async (input: {
1780
+ workspaceId: string
1781
+ name: string
1782
+ kind?: Schedule['kind']
1783
+ week: Schedule['week']
1784
+ tzMode?: Schedule['tzMode']
1785
+ tz?: string | null
1786
+ graceInMinutes?: number
1787
+ graceOutMinutes?: number
1788
+ roundingStepMinutes?: number
1789
+ roundingDirection?: Schedule['roundingDirection']
1790
+ autoClockOutAfterMinutes?: number | null
1791
+ }) => {
1792
+ const created: Row<Schedule> = {
1793
+ id: crypto.randomUUID(),
1794
+ name: input.name,
1795
+ kind: input.kind ?? 'fixed',
1796
+ week: clone(input.week),
1797
+ tzMode: input.tzMode ?? 'office',
1798
+ tz: input.tz ?? null,
1799
+ graceInMinutes: input.graceInMinutes ?? 0,
1800
+ graceOutMinutes: input.graceOutMinutes ?? 0,
1801
+ roundingStepMinutes: input.roundingStepMinutes ?? 0,
1802
+ roundingDirection: input.roundingDirection ?? 'nearest',
1803
+ autoClockOutAfterMinutes: input.autoClockOutAfterMinutes ?? null,
1804
+ archivedAt: null,
1805
+ }
1806
+ schedules.push(created)
1807
+ return { ...created, workspaceId: input.workspaceId }
1808
+ },
1809
+
1810
+ update: async (input: {
1811
+ workspaceId: string
1812
+ scheduleId: string
1813
+ name?: string
1814
+ week?: Schedule['week']
1815
+ graceInMinutes?: number
1816
+ graceOutMinutes?: number
1817
+ roundingStepMinutes?: number
1818
+ roundingDirection?: Schedule['roundingDirection']
1819
+ autoClockOutAfterMinutes?: number | null
1820
+ }) => {
1821
+ const found = schedules.find((s) => s.id === input.scheduleId)
1822
+ if (!found) refuse('NOT_FOUND', 'Schedule not found')
1823
+ if (input.name !== undefined) found.name = input.name
1824
+ if (input.week !== undefined) found.week = clone(input.week)
1825
+ if (input.graceInMinutes !== undefined) found.graceInMinutes = input.graceInMinutes
1826
+ if (input.graceOutMinutes !== undefined) found.graceOutMinutes = input.graceOutMinutes
1827
+ if (input.roundingStepMinutes !== undefined) {
1828
+ found.roundingStepMinutes = input.roundingStepMinutes
1829
+ }
1830
+ if (input.roundingDirection !== undefined) found.roundingDirection = input.roundingDirection
1831
+ if (input.autoClockOutAfterMinutes !== undefined) {
1832
+ found.autoClockOutAfterMinutes = input.autoClockOutAfterMinutes
1833
+ }
1834
+ return { ...found, workspaceId: input.workspaceId }
1835
+ },
1836
+
1837
+ archive: async ({ scheduleId }: { workspaceId: string; scheduleId: string }) => {
1838
+ const found = schedules.find((s) => s.id === scheduleId)
1839
+ if (!found) refuse('NOT_FOUND', 'Schedule not found')
1840
+ found.archivedAt = iso()
1841
+ return { ok: true as const }
1842
+ },
1843
+
1844
+ assign: async (input: {
1845
+ workspaceId: string
1846
+ scheduleId: string
1847
+ personId: string
1848
+ effectiveFrom: string
1849
+ }) => {
1850
+ // Effective-dated, so the earlier assignment is closed rather than replaced: the days
1851
+ // already computed were measured against it.
1852
+ for (const a of scheduleAssignments) {
1853
+ if (a.personId === input.personId && a.effectiveTo === null) a.effectiveTo = input.effectiveFrom
1854
+ }
1855
+ scheduleAssignments.push({
1856
+ id: crypto.randomUUID(),
1857
+ personId: input.personId,
1858
+ scheduleId: input.scheduleId,
1859
+ effectiveFrom: input.effectiveFrom,
1860
+ effectiveTo: null,
1861
+ })
1862
+ return scheduleAssignments
1863
+ .filter((a) => a.personId === input.personId)
1864
+ .map((a) => ({ ...a, workspaceId: input.workspaceId }))
1865
+ },
1866
+ },
402
1867
  },
403
1868
 
404
1869
  approvals: {
405
- inbox: async ({ workspaceId }: { workspaceId: string }) => ({
406
- items: [
407
- {
408
- id: '01920000-0000-7000-8000-00000000f001',
409
- workspaceId,
410
- subjectType: 'leave' as const,
411
- subjectId: leaveRequests[0]!.id as string,
412
- summary: `5 day(s) from ${day(14)}`,
413
- status: 'pending' as const,
414
- currentStep: 0,
415
- requestedBy: null,
416
- requestedAt: iso(3600_000),
417
- decidedAt: null,
418
- steps: [],
419
- },
420
- ],
1870
+ /**
1871
+ * Both tabs have something in them on purpose.
1872
+ *
1873
+ * A demo whose "Decided" tab is empty looks like a broken filter rather than an empty
1874
+ * history, and the two-step request is what shows the step counter at all.
1875
+ */
1876
+ inbox: async ({
1877
+ workspaceId,
1878
+ includeDecided = false,
1879
+ }: {
1880
+ workspaceId: string
1881
+ includeDecided?: boolean
1882
+ }) => ({
1883
+ items: approvalRequests
1884
+ .filter((r) => (includeDecided ? r.status !== 'pending' : r.status === 'pending'))
1885
+ .map((r) => ({ ...r, workspaceId })),
421
1886
  nextCursor: null,
422
1887
  }),
1888
+
1889
+ get: async ({ workspaceId, requestId }: { workspaceId: string; requestId: string }) => {
1890
+ const found = approvalRequests.find((r) => r.id === requestId)
1891
+ if (!found) refuse('NOT_FOUND', 'Approval request not found')
1892
+ return { ...found, workspaceId }
1893
+ },
1894
+
1895
+ decide: async ({
1896
+ workspaceId,
1897
+ requestId,
1898
+ decision,
1899
+ }: {
1900
+ workspaceId: string
1901
+ requestId: string
1902
+ decision: 'approve' | 'reject'
1903
+ }) => {
1904
+ const found = approvalRequests.find((r) => r.id === requestId)
1905
+ if (!found) refuse('NOT_FOUND', 'Approval request not found')
1906
+ // A middle step advances rather than settling: the inbox has to be able to show that.
1907
+ const last = found.currentStep >= Math.max(found.steps.length - 1, 0)
1908
+ if (decision === 'reject' || last) found.status = decision === 'approve' ? 'approved' : 'rejected'
1909
+ else found.currentStep += 1
1910
+ found.decidedAt = found.status === 'pending' ? null : iso()
1911
+ return { ...found, workspaceId }
1912
+ },
1913
+
1914
+ delegations: async ({ workspaceId }: { workspaceId: string }) =>
1915
+ delegations.map((d) => ({ ...d, workspaceId })),
1916
+
1917
+ delegate: async ({
1918
+ workspaceId,
1919
+ toPersonId,
1920
+ startsOn,
1921
+ endsOn,
1922
+ subjectType = null,
1923
+ reason = null,
1924
+ }: {
1925
+ workspaceId: string
1926
+ toPersonId: string
1927
+ startsOn: string
1928
+ endsOn: string
1929
+ subjectType?: string | null
1930
+ reason?: string | null
1931
+ }) => {
1932
+ const created = {
1933
+ id: crypto.randomUUID(),
1934
+ workspaceId,
1935
+ fromPersonId: people[0]!.id,
1936
+ toPersonId,
1937
+ subjectType,
1938
+ startsOn,
1939
+ endsOn,
1940
+ reason,
1941
+ createdAt: iso(),
1942
+ }
1943
+ delegations.push(created)
1944
+ return created
1945
+ },
1946
+
1947
+ revokeDelegation: async ({ delegationId }: { delegationId: string }) => {
1948
+ const at = delegations.findIndex((d) => d.id === delegationId)
1949
+ if (at >= 0) delegations.splice(at, 1)
1950
+ return { ok: true }
1951
+ },
423
1952
  },
424
1953
  }
425
1954
 
@@ -427,7 +1956,7 @@ export function createMockHrApi() {
427
1956
  return {
428
1957
  id: crypto.randomUUID(),
429
1958
  workspaceId: '',
430
- personId: PEOPLE[0]!.id,
1959
+ personId: people[0]!.id,
431
1960
  direction,
432
1961
  at: new Date().toISOString(),
433
1962
  clientReportedAt: null,
@@ -435,7 +1964,7 @@ export function createMockHrApi() {
435
1964
  businessDate: day(0),
436
1965
  timezone: 'Europe/Istanbul',
437
1966
  method: 'web',
438
- officeId: OFFICES[0]!.id,
1967
+ officeId: primaryOfficeId(people[0]!.id),
439
1968
  deviceId: null,
440
1969
  geo: null,
441
1970
  trust: 'trusted',