@kernhq/module-hr 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract/attendance.d.ts +35 -0
- package/dist/contract/attendance.d.ts.map +1 -1
- package/dist/contract/attendance.js +25 -1
- package/dist/contract/attendance.js.map +1 -1
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +10 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/events.d.ts +17 -16
- package/dist/contract/events.d.ts.map +1 -1
- package/dist/contract/events.js +23 -16
- package/dist/contract/events.js.map +1 -1
- package/dist/contract/models.d.ts +1 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +11 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/router.d.ts +12 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +11 -3
- package/dist/contract/router.js.map +1 -1
- package/dist/contract/settings.d.ts +0 -1
- package/dist/contract/settings.d.ts.map +1 -1
- package/dist/contract/settings.js +11 -5
- package/dist/contract/settings.js.map +1 -1
- package/dist/server/index.d.ts +0 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +30 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +13 -5
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/router.d.ts +12 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +374 -76
- package/dist/server/router.js.map +1 -1
- package/dist/server/services/access.d.ts +176 -0
- package/dist/server/services/access.d.ts.map +1 -0
- package/dist/server/services/access.js +260 -0
- package/dist/server/services/access.js.map +1 -0
- package/dist/server/services/people.d.ts +1 -0
- package/dist/server/services/people.d.ts.map +1 -1
- package/dist/server/services/people.js +3 -0
- package/dist/server/services/people.js.map +1 -1
- package/package.json +1 -1
- package/src/client/components/DayDetail.svelte +32 -14
- package/src/client/components/PersonPanel.svelte +66 -5
- package/src/client/components/redaction.ts +51 -0
- package/src/client/messages.ts +346 -77
- package/src/client/pages/AttendancePage.svelte +24 -1
- package/src/client/pages/DirectoryPage.svelte +285 -11
- package/src/client/pages/OfficesPage.svelte +6 -0
- package/src/client/pages/OrgPage.svelte +7 -0
- package/src/client/permissions.ts +12 -4
- package/src/client/settings/AccrualSettings.svelte +715 -95
- package/src/client/settings/GeneralSettings.svelte +12 -9
- package/src/client/settings/SchedulesSettings.svelte +31 -78
- package/src/contract/attendance.ts +27 -1
- package/src/contract/capabilities.ts +10 -0
- package/src/contract/events.ts +23 -19
- package/src/contract/models.ts +11 -0
- package/src/contract/router.ts +11 -3
- 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
|
-
* **
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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: () => {
|
|
@@ -43,6 +43,19 @@ import { formatDuration, hrKeys, isoDate } from '../query.js'
|
|
|
43
43
|
* the week runs down the dialog rather than across it, and the label column lands on whichever side
|
|
44
44
|
* the reader's language starts from without a single `left` or `right` in the stylesheet.
|
|
45
45
|
*
|
|
46
|
+
* **A schedule sets the hours, not the zone, and this screen used to say otherwise.** There was a
|
|
47
|
+
* "Time zone" field here offering the person's office, the person's own zone, or one fixed zone,
|
|
48
|
+
* and `schedules.tz_mode` / `schedules.tz` do store whichever was picked. Nothing ever reads them:
|
|
49
|
+
* `attendance.scheduleFor` builds a `ResolvedSchedule` of shifts, rounding and the automatic close
|
|
50
|
+
* and carries neither, and the zone a day is attributed in comes from `resolve.forPerson` — the
|
|
51
|
+
* person's own override if their profile sets one, otherwise their primary office. That ladder is
|
|
52
|
+
* the module's stated rule rather than an accident (`OfficeAssignment` in the contract: only the
|
|
53
|
+
* primary decides "which timezone the person's day is attributed in"), so the control was offering
|
|
54
|
+
* to change something it could not reach, and `fixed` was the option with no rung at all. The
|
|
55
|
+
* control is gone; the two columns stay. They hold what somebody once chose, dropping them would
|
|
56
|
+
* destroy that, and switching a setting off must never destroy data — so the screen states where
|
|
57
|
+
* the zone comes from instead of asking.
|
|
58
|
+
*
|
|
46
59
|
* No stat tiles. Every number worth one here — how many schedules, how long each week is — is
|
|
47
60
|
* already a column of the table below, and OfficesPage learned the hard way that a tile restating
|
|
48
61
|
* the list is noise. The one figure that would earn a tile, how many people are on no schedule at
|
|
@@ -199,8 +212,6 @@ let editing = $state<{ schedule: Schedule | null } | null>(null)
|
|
|
199
212
|
let name = $state('')
|
|
200
213
|
let kind = $state('fixed')
|
|
201
214
|
let week = $state<Week>(defaultWeek())
|
|
202
|
-
let tzMode = $state('office')
|
|
203
|
-
let tz = $state('')
|
|
204
215
|
let graceIn = $state('0')
|
|
205
216
|
let graceOut = $state('0')
|
|
206
217
|
let roundingStep = $state('0')
|
|
@@ -215,8 +226,6 @@ function openEditor(schedule: Schedule | null) {
|
|
|
215
226
|
name = schedule?.name ?? ''
|
|
216
227
|
kind = schedule?.kind ?? 'fixed'
|
|
217
228
|
week = schedule ? readWeek(schedule.week) : defaultWeek()
|
|
218
|
-
tzMode = schedule?.tzMode ?? 'office'
|
|
219
|
-
tz = schedule?.tz ?? ''
|
|
220
229
|
graceIn = String(schedule?.graceInMinutes ?? 0)
|
|
221
230
|
graceOut = String(schedule?.graceOutMinutes ?? 0)
|
|
222
231
|
roundingStep = String(schedule?.roundingStepMinutes ?? 0)
|
|
@@ -261,22 +270,15 @@ const timesMissing = $derived(
|
|
|
261
270
|
}),
|
|
262
271
|
)
|
|
263
272
|
const noWorkingDays = $derived(workingDays.length === 0)
|
|
264
|
-
const zoneMissing = $derived(tzMode === 'fixed' && !tz)
|
|
265
273
|
|
|
266
274
|
const weekError = $derived(
|
|
267
275
|
noWorkingDays ? t('schedule_week_empty') : timesMissing ? t('schedule_time_missing') : null,
|
|
268
276
|
)
|
|
269
|
-
const canSave = $derived(manage && name.trim().length > 0 && !weekError
|
|
277
|
+
const canSave = $derived(manage && name.trim().length > 0 && !weekError)
|
|
270
278
|
|
|
271
279
|
/** Why Save is off, in the order somebody would fix them. Null when nothing is blocking. */
|
|
272
280
|
const blockedReason = $derived(
|
|
273
|
-
canSave
|
|
274
|
-
? null
|
|
275
|
-
: !manage
|
|
276
|
-
? t('schedules_readonly')
|
|
277
|
-
: !name.trim()
|
|
278
|
-
? t('schedule_name_required')
|
|
279
|
-
: (weekError ?? (zoneMissing ? t('schedule_tz_required') : null)),
|
|
281
|
+
canSave ? null : !manage ? t('schedules_readonly') : !name.trim() ? t('schedule_name_required') : weekError,
|
|
280
282
|
)
|
|
281
283
|
|
|
282
284
|
const save = createMutation(() => ({
|
|
@@ -292,16 +294,15 @@ const save = createMutation(() => ({
|
|
|
292
294
|
roundingDirection: roundingDirection as Schedule['roundingDirection'],
|
|
293
295
|
autoClockOutAfterMinutes: autoClockOut ? Number(autoClockOut) : null,
|
|
294
296
|
}
|
|
295
|
-
// Kind
|
|
296
|
-
//
|
|
297
|
+
// Kind is create-only in the contract, and deliberately: the days already computed were
|
|
298
|
+
// measured with it, so changing it would rewrite history rather than the rule.
|
|
299
|
+
//
|
|
300
|
+
// `tzMode`/`tz` are not sent. The contract defaults `tzMode` to `office` and leaves `tz` null,
|
|
301
|
+
// which is the only combination that describes what attendance actually does — see the
|
|
302
|
+
// docblock. Sending anything else would record a choice no computation will honour.
|
|
297
303
|
return current
|
|
298
304
|
? api.attendance.schedules.update({ ...common, scheduleId: current.id })
|
|
299
|
-
: api.attendance.schedules.create({
|
|
300
|
-
...common,
|
|
301
|
-
kind: kind as Schedule['kind'],
|
|
302
|
-
tzMode: tzMode as Schedule['tzMode'],
|
|
303
|
-
tz: tzMode === 'fixed' ? tz : null,
|
|
304
|
-
})
|
|
305
|
+
: api.attendance.schedules.create({ ...common, kind: kind as Schedule['kind'] })
|
|
305
306
|
},
|
|
306
307
|
onSuccess: (schedule) => {
|
|
307
308
|
toast.success(isNew ? t('schedule_created', { name: schedule.name }) : t('schedule_saved'))
|
|
@@ -418,34 +419,6 @@ const kindOptions = $derived([
|
|
|
418
419
|
{ value: 'shift', label: t('schedule_kind_shift'), description: t('schedule_kind_shift_desc') },
|
|
419
420
|
])
|
|
420
421
|
|
|
421
|
-
const tzModeOptions = $derived([
|
|
422
|
-
{ value: 'office', label: t('schedule_tz_office') },
|
|
423
|
-
{ value: 'person', label: t('schedule_tz_person') },
|
|
424
|
-
{ value: 'fixed', label: t('schedule_tz_fixed') },
|
|
425
|
-
])
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* Every zone the runtime knows, grouped by area.
|
|
429
|
-
*
|
|
430
|
-
* `supportedValuesOf` is the only complete list available to a browser; where it is missing there
|
|
431
|
-
* is exactly one zone worth offering, the one this machine is in. Reading it is not formatting, so
|
|
432
|
-
* it does not go through the shared formatters.
|
|
433
|
-
*/
|
|
434
|
-
const ZONES: string[] = (() => {
|
|
435
|
-
try {
|
|
436
|
-
const supported = (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf
|
|
437
|
-
const list = supported?.call(Intl, 'timeZone') ?? []
|
|
438
|
-
return list.length ? list : [Intl.DateTimeFormat().resolvedOptions().timeZone]
|
|
439
|
-
} catch {
|
|
440
|
-
return [Intl.DateTimeFormat().resolvedOptions().timeZone]
|
|
441
|
-
}
|
|
442
|
-
})()
|
|
443
|
-
const zoneOptions = ZONES.map((zone) => ({
|
|
444
|
-
value: zone,
|
|
445
|
-
label: zone.split('/').slice(1).join(' / ').replace(/_/g, ' ') || zone,
|
|
446
|
-
group: zone.split('/')[0]?.replace(/_/g, ' ') ?? '',
|
|
447
|
-
}))
|
|
448
|
-
|
|
449
422
|
/** A break somebody actually typed into the database keeps its place in the list. */
|
|
450
423
|
const breakOptions = (current: number) =>
|
|
451
424
|
[...new Set([0, 15, 30, 45, 60, 90, current])]
|
|
@@ -716,35 +689,15 @@ const autoClockOutOptions = $derived([
|
|
|
716
689
|
</div>
|
|
717
690
|
</section>
|
|
718
691
|
|
|
719
|
-
<!--
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
<Field
|
|
729
|
-
label={t('schedule_tz_label')}
|
|
730
|
-
required
|
|
731
|
-
error={zoneMissing ? t('schedule_tz_required') : null}
|
|
732
|
-
>
|
|
733
|
-
{#snippet children(id)}
|
|
734
|
-
<Select {id} bind:value={tz} options={zoneOptions} placeholder={t('schedule_tz_label')} />
|
|
735
|
-
{/snippet}
|
|
736
|
-
</Field>
|
|
737
|
-
{/if}
|
|
738
|
-
</div>
|
|
739
|
-
{:else}
|
|
740
|
-
<div class="ro">
|
|
741
|
-
<span class="rolabel">{t('schedule_tz_mode')}</span>
|
|
742
|
-
<p class="rovalue">
|
|
743
|
-
{tzModeOptions.find((option) => option.value === tzMode)?.label ?? tzMode}{#if tz} · {tz}{/if}
|
|
744
|
-
</p>
|
|
745
|
-
<p class="note">{t('schedule_immutable_hint')}</p>
|
|
746
|
-
</div>
|
|
747
|
-
{/if}
|
|
692
|
+
<!--
|
|
693
|
+
Whose clock the readings above are on — a sentence, because it is not a choice.
|
|
694
|
+
|
|
695
|
+
A control stood here offering to pick the zone. Nothing read what it stored, and the answer it
|
|
696
|
+
was asking for is the same for every schedule in the workspace, so the honest version of the
|
|
697
|
+
field is the fact it was hiding. Read-only on an existing schedule was the same claim in the
|
|
698
|
+
past tense, so that went with it.
|
|
699
|
+
-->
|
|
700
|
+
<p class="note">{t('schedule_tz_note')}</p>
|
|
748
701
|
|
|
749
702
|
<!-- Grace -->
|
|
750
703
|
<div class="pair">
|
|
@@ -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
|
-
|
|
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
|
{
|
package/src/contract/events.ts
CHANGED
|
@@ -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
|
|
144
|
-
*
|
|
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({
|
package/src/contract/models.ts
CHANGED
|
@@ -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
|
})
|
package/src/contract/router.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(),
|
package/src/contract/settings.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
31
|
-
*
|
|
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>
|