@kernhq/module-hr 0.7.0 → 0.9.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.
Files changed (74) hide show
  1. package/dist/contract/capabilities.d.ts.map +1 -1
  2. package/dist/contract/capabilities.js +31 -0
  3. package/dist/contract/capabilities.js.map +1 -1
  4. package/dist/contract/index.d.ts +1 -0
  5. package/dist/contract/index.d.ts.map +1 -1
  6. package/dist/contract/index.js +1 -0
  7. package/dist/contract/index.js.map +1 -1
  8. package/dist/contract/permissions.d.ts +23 -0
  9. package/dist/contract/permissions.d.ts.map +1 -1
  10. package/dist/contract/permissions.js +27 -0
  11. package/dist/contract/permissions.js.map +1 -1
  12. package/dist/contract/policies.d.ts +256 -0
  13. package/dist/contract/policies.d.ts.map +1 -0
  14. package/dist/contract/policies.js +175 -0
  15. package/dist/contract/policies.js.map +1 -0
  16. package/dist/contract/router.d.ts +707 -0
  17. package/dist/contract/router.d.ts.map +1 -1
  18. package/dist/contract/router.js +127 -0
  19. package/dist/contract/router.js.map +1 -1
  20. package/dist/policy/accrual.d.ts +127 -0
  21. package/dist/policy/accrual.d.ts.map +1 -0
  22. package/dist/policy/accrual.js +134 -0
  23. package/dist/policy/accrual.js.map +1 -0
  24. package/dist/server/jobs.d.ts.map +1 -1
  25. package/dist/server/jobs.js +186 -1
  26. package/dist/server/jobs.js.map +1 -1
  27. package/dist/server/router.d.ts +772 -0
  28. package/dist/server/router.d.ts.map +1 -1
  29. package/dist/server/router.js +462 -1
  30. package/dist/server/router.js.map +1 -1
  31. package/dist/server/schema.d.ts +612 -1
  32. package/dist/server/schema.d.ts.map +1 -1
  33. package/dist/server/schema.js +76 -0
  34. package/dist/server/schema.js.map +1 -1
  35. package/dist/server/services/policies.d.ts +53 -0
  36. package/dist/server/services/policies.d.ts.map +1 -0
  37. package/dist/server/services/policies.js +158 -0
  38. package/dist/server/services/policies.js.map +1 -0
  39. package/migrations/meta/_journal.json +7 -0
  40. package/package.json +22 -2
  41. package/src/client/api-instance.ts +29 -0
  42. package/src/client/components/ClockControls.svelte +127 -0
  43. package/src/client/components/HrSidebar.svelte +73 -0
  44. package/src/client/components/LeaveRequestDialog.svelte +169 -0
  45. package/src/client/components/PersonFormDialog.svelte +184 -0
  46. package/src/client/components/PersonInline.svelte +49 -0
  47. package/src/client/components/PersonPanel.svelte +328 -0
  48. package/src/client/core-api.ts +34 -0
  49. package/src/client/i18n.ts +640 -0
  50. package/src/client/index.ts +3 -0
  51. package/src/client/mock.ts +447 -0
  52. package/src/client/module.ts +295 -0
  53. package/src/client/pages/ApprovalsPage.svelte +129 -0
  54. package/src/client/pages/AttendancePage.svelte +147 -0
  55. package/src/client/pages/DirectoryPage.svelte +428 -0
  56. package/src/client/pages/LeavePage.svelte +180 -0
  57. package/src/client/pages/OfficesPage.svelte +119 -0
  58. package/src/client/permissions.ts +31 -0
  59. package/src/client/query.test.ts +70 -0
  60. package/src/client/query.ts +69 -0
  61. package/src/client/settings/CalendarsSettings.svelte +21 -0
  62. package/src/client/settings/CapabilitiesSettings.svelte +122 -0
  63. package/src/client/settings/LeaveSettings.svelte +21 -0
  64. package/src/client/settings/OfficesSettings.svelte +21 -0
  65. package/src/client/settings/SchedulesSettings.svelte +21 -0
  66. package/src/client/widgets/ApprovalsWidget.svelte +86 -0
  67. package/src/client/widgets/ClockWidget.svelte +9 -0
  68. package/src/client/widgets/HeadcountWidget.svelte +29 -0
  69. package/src/client/widgets/LeaveBalanceWidget.svelte +67 -0
  70. package/src/client/widgets/WhosOutWidget.svelte +75 -0
  71. package/src/contract/capabilities.ts +31 -0
  72. package/src/contract/index.ts +1 -0
  73. package/src/contract/permissions.ts +28 -0
  74. package/src/contract/router.ts +148 -0
@@ -0,0 +1,447 @@
1
+ /**
2
+ * The in-memory HR API.
3
+ *
4
+ * A module missing from the mock has working pages and no way to reach them in exactly the
5
+ * environment used for demos and end-to-end tests — so this exists to be *reachable*, not to be a
6
+ * second implementation. It answers the shapes the screens ask for, with data a demo can show.
7
+ */
8
+ const now = Date.now()
9
+ const iso = (msAgo = 0) => new Date(now - msAgo).toISOString()
10
+ const day = (offset: number) => new Date(now + offset * 86_400_000).toISOString().slice(0, 10)
11
+
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',
49
+ },
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',
58
+ },
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
+ })
74
+
75
+ export function createMockHrApi() {
76
+ /** Clock state lives here so the widget behaves across clicks in a demo. */
77
+ let clockedInAt: number | null = null
78
+ let onBreak = false
79
+
80
+ const leaveRequests: Array<Record<string, unknown>> = [
81
+ {
82
+ id: '01920000-0000-7000-8000-00000000c001',
83
+ workspaceId: '',
84
+ personId: PEOPLE[0]!.id,
85
+ leaveTypeId: '01920000-0000-7000-8000-00000000b001',
86
+ startsOn: day(14),
87
+ endsOn: day(18),
88
+ startPart: 'full',
89
+ endPart: 'full',
90
+ hours: null,
91
+ workingDays: 5,
92
+ minutes: 5 * 480,
93
+ status: 'pending',
94
+ reason: null,
95
+ documentFileId: null,
96
+ approvalRequestId: null,
97
+ decidedAt: null,
98
+ createdAt: iso(),
99
+ updatedAt: iso(),
100
+ },
101
+ ]
102
+
103
+ return {
104
+ 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)
108
+ if (q) items = items.filter((p) => p.displayName.toLowerCase().includes(q.toLowerCase()))
109
+ // Carries officeName too: a mock that answers a different shape from core is how a screen
110
+ // works in `dev:mock` and breaks against the real API.
111
+ return {
112
+ items: items.map((p) => ({
113
+ ...person(p, workspaceId),
114
+ officeId: p.officeId,
115
+ officeName: OFFICES.find((o) => o.id === p.officeId)?.name ?? null,
116
+ })),
117
+ nextCursor: null,
118
+ total: items.length,
119
+ }
120
+ },
121
+ get: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => {
122
+ const found = PEOPLE.find((p) => p.id === personId) ?? PEOPLE[0]!
123
+ return person(found, workspaceId)
124
+ },
125
+ me: async ({ workspaceId }: { workspaceId: string }) => person(PEOPLE[0]!, workspaceId),
126
+ create: async (input: {
127
+ workspaceId: string
128
+ displayName: string
129
+ workEmail?: string | null
130
+ employeeNo?: string | null
131
+ hiredOn?: string | null
132
+ officeId?: string | null
133
+ employmentType?: string
134
+ }) => {
135
+ const added = {
136
+ id: crypto.randomUUID(),
137
+ displayName: input.displayName,
138
+ workEmail: input.workEmail ?? '',
139
+ status: 'active' as const,
140
+ timezone: 'Europe/Istanbul',
141
+ officeId: input.officeId ?? OFFICES[0]!.id,
142
+ employeeNo: input.employeeNo ?? `E-${PEOPLE.length + 1}`,
143
+ }
144
+ PEOPLE.push(added)
145
+ return person(added, input.workspaceId)
146
+ },
147
+ update: async (input: {
148
+ workspaceId: string
149
+ personId: string
150
+ displayName?: string
151
+ workEmail?: string | null
152
+ personalEmail?: string | null
153
+ phone?: string | null
154
+ }) => {
155
+ const found = PEOPLE.find((p) => p.id === input.personId) ?? PEOPLE[0]!
156
+ if (input.displayName) found.displayName = input.displayName
157
+ if (input.workEmail !== undefined) found.workEmail = input.workEmail ?? ''
158
+ return {
159
+ ...person(found, input.workspaceId),
160
+ personalEmail: input.personalEmail ?? null,
161
+ phone: input.phone ?? null,
162
+ }
163
+ },
164
+ 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 }
167
+ },
168
+ },
169
+
170
+ employment: {
171
+ current: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => ({
172
+ id: '01920000-0000-7000-8000-00000000ee01',
173
+ workspaceId,
174
+ personId,
175
+ effectiveFrom: day(-400),
176
+ effectiveTo: null,
177
+ orgUnitId: null,
178
+ positionId: null,
179
+ legalEntityId: null,
180
+ costCenterId: null,
181
+ managerPersonId: PEOPLE[1]?.id ?? null,
182
+ employmentType: 'full_time' as const,
183
+ fte: 1,
184
+ contractHoursWeek: 40,
185
+ reason: null,
186
+ createdAt: iso(),
187
+ }),
188
+ },
189
+
190
+ 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,
200
+ calendarId: null,
201
+ address: null,
202
+ headPersonId: null,
203
+ archivedAt: null,
204
+ createdAt: iso(),
205
+ headcount: PEOPLE.filter((p) => p.officeId === o.id).length,
206
+ })),
207
+ 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]!
210
+ void workspaceId
211
+ return {
212
+ personId: p.id,
213
+ on: day(0),
214
+ primaryOfficeId: office.id,
215
+ primaryOfficeName: office.name,
216
+ otherOfficeIds: [],
217
+ country: office.country,
218
+ timezone: office.timezone,
219
+ 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,
224
+ orgUnitId: null,
225
+ orgUnitPath: null,
226
+ managerPersonId: PEOPLE[1]!.id,
227
+ }
228
+ },
229
+ },
230
+
231
+ leave: {
232
+ 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,
248
+ archivedAt: null,
249
+ },
250
+ ],
251
+ },
252
+ 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
+ ],
268
+ },
269
+ requests: {
270
+ list: async ({ workspaceId }: { workspaceId: string }) => ({
271
+ items: leaveRequests.map((r) => ({ ...r, workspaceId })),
272
+ nextCursor: null,
273
+ }),
274
+ simulate: async ({
275
+ startsOn,
276
+ endsOn,
277
+ }: {
278
+ workspaceId: string
279
+ leaveTypeId: string
280
+ startsOn: string
281
+ endsOn: string
282
+ }) => {
283
+ const from = Date.parse(`${startsOn}T00:00:00Z`)
284
+ const to = Date.parse(`${endsOn}T00:00:00Z`)
285
+ const workingDays = Math.max(1, Math.round((to - from) / 86_400_000) + 1)
286
+ const minutes = workingDays * 480
287
+ return {
288
+ workingDays,
289
+ minutes,
290
+ days: [],
291
+ balanceBeforeMinutes: 20 * 480,
292
+ balanceAfterMinutes: 20 * 480 - minutes,
293
+ blockers: minutes > 20 * 480 ? [{ code: 'insufficient', message: 'Not enough balance' }] : [],
294
+ }
295
+ },
296
+ create: async (input: {
297
+ workspaceId: string
298
+ leaveTypeId: string
299
+ startsOn: string
300
+ endsOn: string
301
+ reason?: string | null
302
+ }) => {
303
+ const row = {
304
+ id: crypto.randomUUID(),
305
+ workspaceId: input.workspaceId,
306
+ personId: PEOPLE[0]!.id,
307
+ leaveTypeId: input.leaveTypeId,
308
+ startsOn: input.startsOn,
309
+ endsOn: input.endsOn,
310
+ startPart: 'full',
311
+ endPart: 'full',
312
+ hours: null,
313
+ workingDays: 1,
314
+ minutes: 480,
315
+ status: 'pending',
316
+ reason: input.reason ?? null,
317
+ documentFileId: null,
318
+ approvalRequestId: null,
319
+ decidedAt: null,
320
+ createdAt: iso(),
321
+ updatedAt: iso(),
322
+ }
323
+ leaveRequests.push(row)
324
+ return row
325
+ },
326
+ cancel: async ({ requestId }: { workspaceId: string; requestId: string }) => {
327
+ const row = leaveRequests.find((r) => r.id === requestId) ?? leaveRequests[0]!
328
+ row.status = 'cancelled'
329
+ return { ...row }
330
+ },
331
+ },
332
+ team: {
333
+ 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
+ })),
344
+ },
345
+ },
346
+
347
+ attendance: {
348
+ state: async ({ workspaceId, personId }: { workspaceId: string; personId?: string }) => {
349
+ void workspaceId
350
+ return {
351
+ personId: personId ?? PEOPLE[0]!.id,
352
+ businessDate: day(0),
353
+ clockedIn: clockedInAt !== null,
354
+ onBreak,
355
+ since: clockedInAt ? new Date(clockedInAt).toISOString() : null,
356
+ workedMinutesToday: clockedInAt ? Math.round((Date.now() - clockedInAt) / 60_000) : 0,
357
+ timezone: 'Europe/Istanbul',
358
+ }
359
+ },
360
+ clockIn: async () => {
361
+ clockedInAt = Date.now()
362
+ return mockPunch('in')
363
+ },
364
+ clockOut: async () => {
365
+ clockedInAt = null
366
+ onBreak = false
367
+ return mockPunch('out')
368
+ },
369
+ breakStart: async () => {
370
+ onBreak = true
371
+ return mockPunch('break_start')
372
+ },
373
+ breakEnd: async () => {
374
+ onBreak = false
375
+ return mockPunch('break_end')
376
+ },
377
+ days: {
378
+ list: async ({ workspaceId }: { workspaceId: string }) => ({
379
+ items: [0, 1, 2, 3, 4].map((n) => ({
380
+ id: `01920000-0000-7000-8000-0000000a000${n}`,
381
+ workspaceId,
382
+ personId: PEOPLE[0]!.id,
383
+ businessDate: day(-n),
384
+ scheduledMinutes: 480,
385
+ workedMinutes: n === 2 ? 0 : 480 + (n === 1 ? 45 : 0),
386
+ breakMinutes: 60,
387
+ overtimeMinutes: n === 1 ? 45 : 0,
388
+ lateMinutes: 0,
389
+ earlyLeaveMinutes: 0,
390
+ status: n === 2 ? ('leave' as const) : ('present' as const),
391
+ leaveRequestId: null,
392
+ anomalies: [],
393
+ firstIn: iso(n * 86_400_000),
394
+ lastOut: iso(n * 86_400_000 - 8 * 3600_000),
395
+ policyHash: null,
396
+ locked: false,
397
+ computedAt: iso(),
398
+ })),
399
+ nextCursor: null,
400
+ }),
401
+ },
402
+ },
403
+
404
+ 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
+ ],
421
+ nextCursor: null,
422
+ }),
423
+ },
424
+ }
425
+
426
+ function mockPunch(direction: string) {
427
+ return {
428
+ id: crypto.randomUUID(),
429
+ workspaceId: '',
430
+ personId: PEOPLE[0]!.id,
431
+ direction,
432
+ at: new Date().toISOString(),
433
+ clientReportedAt: null,
434
+ skewMs: null,
435
+ businessDate: day(0),
436
+ timezone: 'Europe/Istanbul',
437
+ method: 'web',
438
+ officeId: OFFICES[0]!.id,
439
+ deviceId: null,
440
+ geo: null,
441
+ trust: 'trusted',
442
+ voidedByPunchId: null,
443
+ note: null,
444
+ createdAt: new Date().toISOString(),
445
+ }
446
+ }
447
+ }