@kernhq/module-hr 0.8.0 → 0.9.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 (31) hide show
  1. package/package.json +25 -5
  2. package/src/client/api-instance.ts +29 -0
  3. package/src/client/components/ClockControls.svelte +127 -0
  4. package/src/client/components/HrSidebar.svelte +73 -0
  5. package/src/client/components/LeaveRequestDialog.svelte +169 -0
  6. package/src/client/components/PersonFormDialog.svelte +184 -0
  7. package/src/client/components/PersonInline.svelte +49 -0
  8. package/src/client/components/PersonPanel.svelte +328 -0
  9. package/src/client/core-api.ts +34 -0
  10. package/src/client/i18n.ts +640 -0
  11. package/src/client/index.ts +3 -0
  12. package/src/client/mock.ts +447 -0
  13. package/src/client/module.ts +295 -0
  14. package/src/client/pages/ApprovalsPage.svelte +129 -0
  15. package/src/client/pages/AttendancePage.svelte +147 -0
  16. package/src/client/pages/DirectoryPage.svelte +428 -0
  17. package/src/client/pages/LeavePage.svelte +180 -0
  18. package/src/client/pages/OfficesPage.svelte +119 -0
  19. package/src/client/permissions.ts +31 -0
  20. package/src/client/query.test.ts +70 -0
  21. package/src/client/query.ts +69 -0
  22. package/src/client/settings/CalendarsSettings.svelte +21 -0
  23. package/src/client/settings/CapabilitiesSettings.svelte +122 -0
  24. package/src/client/settings/LeaveSettings.svelte +21 -0
  25. package/src/client/settings/OfficesSettings.svelte +21 -0
  26. package/src/client/settings/SchedulesSettings.svelte +21 -0
  27. package/src/client/widgets/ApprovalsWidget.svelte +86 -0
  28. package/src/client/widgets/ClockWidget.svelte +9 -0
  29. package/src/client/widgets/HeadcountWidget.svelte +29 -0
  30. package/src/client/widgets/LeaveBalanceWidget.svelte +67 -0
  31. package/src/client/widgets/WhosOutWidget.svelte +75 -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
+ }