@kernhq/module-hr 0.13.2 → 0.14.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 (55) hide show
  1. package/dist/contract/attendance.d.ts +35 -0
  2. package/dist/contract/attendance.d.ts.map +1 -1
  3. package/dist/contract/attendance.js +25 -1
  4. package/dist/contract/attendance.js.map +1 -1
  5. package/dist/contract/capabilities.d.ts.map +1 -1
  6. package/dist/contract/capabilities.js +10 -0
  7. package/dist/contract/capabilities.js.map +1 -1
  8. package/dist/contract/events.d.ts +17 -16
  9. package/dist/contract/events.d.ts.map +1 -1
  10. package/dist/contract/events.js +23 -16
  11. package/dist/contract/events.js.map +1 -1
  12. package/dist/contract/models.d.ts +1 -0
  13. package/dist/contract/models.d.ts.map +1 -1
  14. package/dist/contract/models.js +11 -0
  15. package/dist/contract/models.js.map +1 -1
  16. package/dist/contract/router.d.ts +12 -0
  17. package/dist/contract/router.d.ts.map +1 -1
  18. package/dist/contract/router.js +11 -3
  19. package/dist/contract/router.js.map +1 -1
  20. package/dist/contract/settings.d.ts +0 -1
  21. package/dist/contract/settings.d.ts.map +1 -1
  22. package/dist/contract/settings.js +11 -5
  23. package/dist/contract/settings.js.map +1 -1
  24. package/dist/server/index.d.ts +0 -1
  25. package/dist/server/index.d.ts.map +1 -1
  26. package/dist/server/jobs.d.ts.map +1 -1
  27. package/dist/server/jobs.js +13 -5
  28. package/dist/server/jobs.js.map +1 -1
  29. package/dist/server/router.d.ts +12 -0
  30. package/dist/server/router.d.ts.map +1 -1
  31. package/dist/server/router.js +99 -16
  32. package/dist/server/router.js.map +1 -1
  33. package/dist/server/services/access.d.ts +176 -0
  34. package/dist/server/services/access.d.ts.map +1 -0
  35. package/dist/server/services/access.js +260 -0
  36. package/dist/server/services/access.js.map +1 -0
  37. package/dist/server/services/people.d.ts +1 -0
  38. package/dist/server/services/people.d.ts.map +1 -1
  39. package/dist/server/services/people.js +3 -0
  40. package/dist/server/services/people.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/client/components/DayDetail.svelte +32 -14
  43. package/src/client/components/PersonPanel.svelte +66 -5
  44. package/src/client/components/redaction.ts +39 -0
  45. package/src/client/messages.ts +241 -22
  46. package/src/client/pages/DirectoryPage.svelte +48 -1
  47. package/src/client/permissions.ts +6 -8
  48. package/src/client/settings/AccrualSettings.svelte +596 -57
  49. package/src/client/settings/GeneralSettings.svelte +12 -9
  50. package/src/contract/attendance.ts +27 -1
  51. package/src/contract/capabilities.ts +10 -0
  52. package/src/contract/events.ts +23 -19
  53. package/src/contract/models.ts +11 -0
  54. package/src/contract/router.ts +11 -3
  55. package/src/contract/settings.ts +11 -5
@@ -30,13 +30,18 @@ import { t } from '../i18n.js'
30
30
  * `workspaces.modules.list` and written with `workspaces.modules.updateSettings` — the same
31
31
  * mechanism the capabilities screen uses, and the reason the module never needs a settings table.
32
32
  *
33
- * **The write carries the whole `HrSettings` object.** Core merges a partial write now, but this
34
- * page owns every field in that schema and sending it whole is what makes the round trip
35
- * self-evident: nothing here can be a field somebody forgot to carry forward.
33
+ * **Core merges a partial write; it does not replace the stored object.** `setModuleSettings` in
34
+ * core patches the incoming keys over what is stored an omitted key is left exactly as it was,
35
+ * and only an explicit `null` removes one and then parses the merged blob through this module's
36
+ * schema, so even a key that is somehow absent comes back as the schema's default rather than as
37
+ * nothing. A field this page never sends is therefore carried forward by the server; saving this
38
+ * form cannot drop it.
36
39
  *
37
- * `directoryVisibleToMembers` is in the schema and is deliberately *not* on this page. Nothing in
38
- * `src/server` reads it, so a switch for it would promise a rule the API does not enforce. It is
39
- * carried through the write unchanged until it is either enforced or removed.
40
+ * The write below still spreads `stored` before the three edited fields. Under those semantics it
41
+ * costs nothing it re-sends values identical to the ones already held and it is what keeps the
42
+ * round trip correct if core ever replaces instead of merges: whatever the schema gains goes back
43
+ * as it came, without this page having to grow a control for it first. `stored` is the *parsed*
44
+ * settings, so `$capabilities` is not in it and cannot be echoed back over the platform's own key.
40
45
  */
41
46
  const api = coreApi<CoreApi>()
42
47
  const queryClient = useQueryClient()
@@ -136,12 +141,10 @@ const save = createMutation(() => ({
136
141
  workspaceId,
137
142
  moduleId: 'hr',
138
143
  settings: {
144
+ ...stored,
139
145
  country,
140
146
  employeeNumberPrefix: prefix,
141
147
  employeeNumberNext: parsedNext,
142
- // Not editable here, and carried through rather than dropped: omitting it would leave the
143
- // stored value to core's merge, which is a fact about core rather than about this page.
144
- directoryVisibleToMembers: stored.directoryVisibleToMembers,
145
148
  },
146
149
  }),
147
150
  onSuccess: () => {
@@ -19,9 +19,35 @@ const ws = { workspaceId: WorkspaceId }
19
19
  export const PunchDirection = z.enum(['in', 'out', 'break_start', 'break_end'])
20
20
  export type PunchDirection = z.infer<typeof PunchDirection>
21
21
 
22
- export const PunchMethod = z.enum(['web', 'mobile', 'kiosk', 'qr', 'device', 'import', 'manual'])
22
+ /**
23
+ * What recorded the punch.
24
+ *
25
+ * `manual` means **a person typed it** — a void carrying the reason somebody wrote, or an approved
26
+ * regularization. `auto` is the machine's own: the `auto-clock-out` job closing a shift nobody
27
+ * clocked out of, at an instant it computed rather than observed. They are different facts about
28
+ * whose hand is on somebody's timesheet, and for a while the job wrote `manual`, so an employee
29
+ * reading their own day was told a colleague had entered a punch that no colleague had touched.
30
+ *
31
+ * **Widening this is additive for parsing and not for reading.** Every already-stored punch still
32
+ * validates, and a client built before `auto` existed has no label for it — it falls through to
33
+ * whatever its unknown-method default is, which is the sentence above. So the value lands in the
34
+ * contract, and in a reader that knows what to call it, before anything writes one.
35
+ */
36
+ export const PunchMethod = z.enum(['web', 'mobile', 'kiosk', 'qr', 'device', 'import', 'manual', 'auto'])
23
37
  export type PunchMethod = z.infer<typeof PunchMethod>
24
38
 
39
+ /**
40
+ * The methods a **caller** may claim, which is every one except `auto`.
41
+ *
42
+ * `clockIn` and `clockOut` take the method out of the request and store it unread, so widening
43
+ * `PunchMethod` widens the input alongside the output: without this, anybody holding
44
+ * `attendancePunch` could post `method: 'auto'` and have a punch they made themselves presented as
45
+ * the machine's. `auto` is written by the `auto-clock-out` job and by nothing else, and this is the
46
+ * schema the two punch inputs take so that stays true.
47
+ */
48
+ export const ClientPunchMethod = PunchMethod.exclude(['auto'])
49
+ export type ClientPunchMethod = z.infer<typeof ClientPunchMethod>
50
+
25
51
  /**
26
52
  * How much the recorded instant can be trusted.
27
53
  *
@@ -82,6 +82,16 @@ export const hrCapabilities = defineCapabilities([
82
82
  // Off by default. Plenty of companies grant a fixed allowance on 1 January and never accrue —
83
83
  // and for them an accrual engine is a screen full of settings that change nothing.
84
84
  defaultEnabled: false,
85
+ // Carry-forward and expiry are named here because they are part of the same switch: a
86
+ // `carry_forward` policy is a `policies` row, and every `policies.*` procedure is gated by
87
+ // *this* capability rather than by `leave` — the `leave_accrual` list in
88
+ // `hrCapabilityProcedures` below names all eight, and each carries `cap('leave_accrual')` in
89
+ // `src/server/router.ts`. There is no `policies` capability; the whole record type lives behind
90
+ // accrual, which is why switching accrual off takes carry-forward and expiry with it. The
91
+ // policy is written on the accrual settings screen beside the accrual policies. The engine had
92
+ // both from the start (`carryForward`, the `carry-forward` job, `carry_in` / `carry_out` /
93
+ // `expiry` in the ledger) while nothing could write the policy, which is the one way this
94
+ // description was ever untrue: the product did it and no administrator could reach it.
85
95
  level: 2,
86
96
  },
87
97
  {
@@ -65,13 +65,6 @@ export const hrEvents = {
65
65
  'hr.office.created',
66
66
  z.object({ officeId: z.uuid(), workspaceId: WorkspaceId, country: z.string() }),
67
67
  ),
68
- /**
69
- * A calendar's days changed — a holiday added, a pack applied.
70
- *
71
- * Everything derived from a calendar (working days, leave day counts, later the attendance day
72
- * sheet) is stale from here. The payload names the date range touched so a consumer can recompute
73
- * that window rather than everything.
74
- */
75
68
  leaveRequested: defineEvent(
76
69
  'hr.leave.requested',
77
70
  z.object({
@@ -109,6 +102,18 @@ export const hrEvents = {
109
102
  deltaMinutes: z.number().int(),
110
103
  }),
111
104
  ),
105
+ /**
106
+ * Something needs signing off, and these are the people it is waiting on.
107
+ *
108
+ * Raised once per approval request, after the transaction that created the subject has committed —
109
+ * a rollback must not leave an approver holding a card for a leave request that does not exist.
110
+ * `approverIds` is the *first* step only: later steps are resolved at request time but nobody on
111
+ * them is waiting yet, and telling somebody to act before their turn is worse than telling them
112
+ * late. The step that becomes current later announces itself through `hr.approval.decided`.
113
+ *
114
+ * Nothing is emitted for a chain that resolved to nobody. Auto-approval is not a request, and an
115
+ * empty `approverIds` would only teach a subscriber to filter it back out.
116
+ */
112
117
  approvalRequested: defineEvent(
113
118
  'hr.approval.requested',
114
119
  z.object({
@@ -139,20 +144,19 @@ export const hrEvents = {
139
144
  businessDate: z.iso.date(),
140
145
  }),
141
146
  ),
147
+ // `hr.attendance.day_computed` was declared here and never emitted. It fires on every punch —
148
+ // four a day per person, every workday — so declaring it committed us to a stampede on behalf of
149
+ // nobody: nothing in the product subscribes, and a subscriber that appeared would have had to
150
+ // debounce it before doing anything useful. `hr.punch.recorded` already says a day is stale and
151
+ // costs the same. It comes back when something needs the derived totals *and* the emit can afford
152
+ // the fan-out — batched per day rather than per punch, most likely from the nightly job.
142
153
  /**
143
- * A derived day changed. Carries the date so a consumer recomputes that window rather than
144
- * everything — this fires on every punch, so a coarse payload would be a stampede.
154
+ * A calendar's days changed a holiday added, a pack applied.
155
+ *
156
+ * Everything derived from a calendar (working days, leave day counts, the attendance day sheet)
157
+ * is stale from here. The payload names the date range touched so a consumer can recompute that
158
+ * window rather than everything.
145
159
  */
146
- attendanceDayComputed: defineEvent(
147
- 'hr.attendance.day_computed',
148
- z.object({
149
- workspaceId: WorkspaceId,
150
- personId: z.uuid(),
151
- businessDate: z.iso.date(),
152
- status: z.string(),
153
- workedMinutes: z.number().int(),
154
- }),
155
- ),
156
160
  calendarChanged: defineEvent(
157
161
  'hr.calendar.changed',
158
162
  z.object({
@@ -60,6 +60,17 @@ export const Person = z.object({
60
60
  /** Overrides the primary office's zone for somebody who genuinely works elsewhere. */
61
61
  timezone: TimeZone.nullable(),
62
62
  custom: z.record(z.string(), z.unknown()),
63
+ /**
64
+ * The server withheld the personnel fields on this record — `personalEmail`, `phone`, `hiredOn`
65
+ * and `terminatedOn` are null because the reader may not see them, not because they are empty.
66
+ *
67
+ * Without this the two are indistinguishable, and they are different facts: an empty phone field
68
+ * reads as "this person has no phone number". The client cannot work it out for itself, because
69
+ * *which* people fall inside a reader's team or office is resolved server-side from the org chart
70
+ * — headship over an ltree subtree, office headship, and `manager_person_id`, all as of today —
71
+ * from data the directory does not even fetch. So the one place that does the nulling says so.
72
+ */
73
+ personnelHidden: z.boolean().default(false),
63
74
  createdAt: Timestamp,
64
75
  updatedAt: Timestamp,
65
76
  })
@@ -9,10 +9,10 @@ import {
9
9
  } from './approvals.js'
10
10
  import {
11
11
  AttendanceDay,
12
+ ClientPunchMethod,
12
13
  ClockState,
13
14
  Punch,
14
15
  PunchDirection,
15
- PunchMethod,
16
16
  Regularization,
17
17
  Schedule,
18
18
  ScheduleAssignment,
@@ -769,7 +769,11 @@ export const hrContract = {
769
769
  .input(
770
770
  ws.extend({
771
771
  personId: z.uuid().optional(),
772
- method: PunchMethod.default('web'),
772
+ // `ClientPunchMethod`, which is `PunchMethod` minus `auto`: `auto` means "the nightly
773
+ // sweep closed a shift nobody clocked out of", and it is the one value a caller must not
774
+ // be able to claim for itself. The employee's timeline labels it "Closed automatically",
775
+ // so accepting it here would let a punch dressed as the machine's disown a person's own.
776
+ method: ClientPunchMethod.default('web'),
773
777
  clientReportedAt: z.iso.datetime({ offset: true }).nullish(),
774
778
  geo: z.object({ lat: z.number(), lng: z.number(), accuracyM: z.number().optional() }).nullish(),
775
779
  note: z.string().max(500).nullish(),
@@ -782,7 +786,11 @@ export const hrContract = {
782
786
  .input(
783
787
  ws.extend({
784
788
  personId: z.uuid().optional(),
785
- method: PunchMethod.default('web'),
789
+ // `ClientPunchMethod`, which is `PunchMethod` minus `auto`: `auto` means "the nightly
790
+ // sweep closed a shift nobody clocked out of", and it is the one value a caller must not
791
+ // be able to claim for itself. The employee's timeline labels it "Closed automatically",
792
+ // so accepting it here would let a punch dressed as the machine's disown a person's own.
793
+ method: ClientPunchMethod.default('web'),
786
794
  clientReportedAt: z.iso.datetime({ offset: true }).nullish(),
787
795
  geo: z.object({ lat: z.number(), lng: z.number(), accuracyM: z.number().optional() }).nullish(),
788
796
  note: z.string().max(500).nullish(),
@@ -24,12 +24,18 @@ export const HrSettings = z.object({
24
24
  /** Employee numbers are generated from this when a person is created without one. */
25
25
  employeeNumberPrefix: z.string().max(8).default(''),
26
26
  employeeNumberNext: z.number().int().min(1).default(1),
27
- /**
28
- * Whether a member who is not in HR can see the directory at all.
27
+ /*
28
+ * `directoryVisibleToMembers` was declared here and removed. The idea is sound some companies
29
+ * publish their org chart to everyone and some treat it as HR-only, which is coarser than
30
+ * `hr.person.view` and would sit above it — but nothing read the field, and no route in shell
31
+ * renders a module settings schema, so it could not be flipped through the product either. A
32
+ * setting whose documented rule nothing enforces teaches an administrator that settings do not
33
+ * mean anything, which is the same failure as a capability nothing checks.
29
34
  *
30
- * Some companies publish their org chart to everyone; some treat it as HR-only. This is coarser
31
- * than the permission and sits above it: off, and `hr.person.view` is not enough on its own.
35
+ * Bringing it back honestly needs both halves in the change that declares it: an enforcement site
36
+ * (`people.list` and `people.get` in `src/server/services/people.ts`, refusing a caller who has
37
+ * only `hr.person.view` while the flag is off), and a way for an administrator to reach it —
38
+ * a control on `src/client/settings/GeneralSettings.svelte`, which is already routed.
32
39
  */
33
- directoryVisibleToMembers: z.boolean().default(true),
34
40
  })
35
41
  export type HrSettings = z.infer<typeof HrSettings>