@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
|
@@ -18,6 +18,7 @@ import { getHrApi } from '../api-instance.js'
|
|
|
18
18
|
import ClockControls from '../components/ClockControls.svelte'
|
|
19
19
|
import DayDetail from '../components/DayDetail.svelte'
|
|
20
20
|
import { t } from '../i18n.js'
|
|
21
|
+
import { canHr } from '../permissions.js'
|
|
21
22
|
import { formatDuration, hrKeys, monthRange } from '../query.js'
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -47,6 +48,26 @@ const workspaceId = $derived(workspace?.id ?? '')
|
|
|
47
48
|
|
|
48
49
|
const range = $derived(monthRange())
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The clock is the punch permission, and nothing else on this page is.
|
|
53
|
+
*
|
|
54
|
+
* `hr.attendance.view` is what opens this route — reading your own month — while every one of the
|
|
55
|
+
* four transitions behind `ClockControls` is `requires('hr.attendance.punch')` on the server. A
|
|
56
|
+
* workspace revokes that from somebody whose punches arrive from a badge terminal or from their
|
|
57
|
+
* manager, and for them the clock row was a set of buttons that can only answer 403 — an offer the
|
|
58
|
+
* product cannot honour.
|
|
59
|
+
*
|
|
60
|
+
* Hidden rather than disabled-with-a-reason, because this is a permission and not a state: they may
|
|
61
|
+
* never do it, there is nothing to wait for, and "you cannot clock in" repeated on every visit
|
|
62
|
+
* teaches nothing. It is also the same answer the dashboard already gives — `module.ts` declares the
|
|
63
|
+
* clock widget with `permission: attendancePunch`, so the shell has always hidden this exact
|
|
64
|
+
* component for these people. The page was the one place it leaked.
|
|
65
|
+
*
|
|
66
|
+
* The status line goes with the buttons it captions; the month underneath is what a viewer keeps,
|
|
67
|
+
* and today is a row in it.
|
|
68
|
+
*/
|
|
69
|
+
const canPunch = $derived(canHr('attendancePunch'))
|
|
70
|
+
|
|
50
71
|
const daysQuery = createQuery(() => ({
|
|
51
72
|
queryKey: hrKeys.attendanceDays(workspaceId, undefined, range.from, range.to),
|
|
52
73
|
enabled: Boolean(workspaceId),
|
|
@@ -168,7 +189,9 @@ const dayLabel = (iso: string) =>
|
|
|
168
189
|
{/snippet}
|
|
169
190
|
|
|
170
191
|
<Page>
|
|
171
|
-
|
|
192
|
+
{#if canPunch}
|
|
193
|
+
<ClockControls {workspaceId} />
|
|
194
|
+
{/if}
|
|
172
195
|
|
|
173
196
|
{#if totalsUnknown}
|
|
174
197
|
{@render tilesUnknown()}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
EmptyState,
|
|
9
9
|
formatCount,
|
|
10
10
|
formatDate,
|
|
11
|
+
Icon,
|
|
11
12
|
Input,
|
|
12
13
|
messageLocale,
|
|
13
14
|
navigation,
|
|
@@ -24,6 +25,7 @@ import { getHrApi } from '../api-instance.js'
|
|
|
24
25
|
import DecisionDialog from '../components/DecisionDialog.svelte'
|
|
25
26
|
import PersonFormDialog from '../components/PersonFormDialog.svelte'
|
|
26
27
|
import PersonPanel from '../components/PersonPanel.svelte'
|
|
28
|
+
import { personnelVisibility } from '../components/redaction.js'
|
|
27
29
|
import { t } from '../i18n.js'
|
|
28
30
|
import type { ApprovalRequest } from '../index.js'
|
|
29
31
|
import { canHr, HR_CAPABILITIES } from '../permissions.js'
|
|
@@ -48,11 +50,101 @@ const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
|
48
50
|
const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
|
|
49
51
|
const workspaceId = $derived(workspace?.id ?? '')
|
|
50
52
|
|
|
53
|
+
/**
|
|
54
|
+
* A query parameter is whatever somebody pasted into the address bar, and the contract types both
|
|
55
|
+
* filters as uuids — so a truncated or hand-edited link would be refused by the server and this
|
|
56
|
+
* screen would draw a red error where a filter was meant. Anything that is not a uuid is no filter.
|
|
57
|
+
*/
|
|
58
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
59
|
+
const asId = (value: string | undefined): string | null => (value && UUID.test(value) ? value : null)
|
|
60
|
+
|
|
51
61
|
let search = $state('')
|
|
52
|
-
|
|
62
|
+
/**
|
|
63
|
+
* The filter the directory was opened with.
|
|
64
|
+
*
|
|
65
|
+
* Both are links from somewhere else — an office row on the offices screen, "See the people" on a
|
|
66
|
+
* department — and until this read the query string they landed on the unfiltered company
|
|
67
|
+
* directory, which reads as the link being broken rather than as a filter that never applied.
|
|
68
|
+
*
|
|
69
|
+
* Seeded here rather than in the `$effect` below, and that is load-bearing: an effect runs after
|
|
70
|
+
* the first render, so `people.list` would fetch and draw the whole company for a beat before
|
|
71
|
+
* narrowing to the office somebody actually clicked.
|
|
72
|
+
*/
|
|
73
|
+
let officeTab = $state(asId(navigation.search.officeId) ?? 'all')
|
|
74
|
+
let orgUnitId = $state<string | null>(asId(navigation.search.orgUnit))
|
|
53
75
|
const selected = $derived(navigation.search.person)
|
|
54
76
|
const creating = $derived(navigation.search.new === '1')
|
|
55
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The URL keeps seeding the filter after that first render: arriving from the offices screen while
|
|
80
|
+
* the directory is already mounted changes the query string without remounting anything, and the
|
|
81
|
+
* back button is a filter change too. It only ever writes what it does not read, so there is no loop.
|
|
82
|
+
*/
|
|
83
|
+
$effect(() => {
|
|
84
|
+
officeTab = asId(navigation.search.officeId) ?? 'all'
|
|
85
|
+
orgUnitId = asId(navigation.search.orgUnit)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A filter change is written to the URL as well as to state, so the address bar always describes
|
|
90
|
+
* what is on screen: a reload, a shared link and the back button all land on the same directory.
|
|
91
|
+
* `person` and `new` are carried through rather than rebuilt, or switching office would close a
|
|
92
|
+
* panel somebody has open.
|
|
93
|
+
*
|
|
94
|
+
* `replaceState` because a filter is not a place — the way back from a filtered directory is the
|
|
95
|
+
* screen that linked into it, not one history entry per pill.
|
|
96
|
+
*/
|
|
97
|
+
function writeFilter(patch: { officeId?: string | null; orgUnit?: string | null }) {
|
|
98
|
+
const params = new URLSearchParams(navigation.search)
|
|
99
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
100
|
+
if (value) params.set(key, value)
|
|
101
|
+
else params.delete(key)
|
|
102
|
+
}
|
|
103
|
+
const query = params.toString()
|
|
104
|
+
navigation.go(`/${workspaceSlug}/hr${query ? `?${query}` : ''}`, {
|
|
105
|
+
replaceState: true,
|
|
106
|
+
keepFocus: true,
|
|
107
|
+
noScroll: true,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** State first, URL second: the shell's router is asynchronous and the pills must not lag a click. */
|
|
112
|
+
function chooseOffice(value: string) {
|
|
113
|
+
officeTab = value
|
|
114
|
+
writeFilter({ officeId: value === 'all' ? null : value })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Clears the search along with the office and the department.
|
|
119
|
+
*
|
|
120
|
+
* The empty state that offers this button is reached when a filter is on, which includes the case
|
|
121
|
+
* where a *search* inside that filter is what found nobody. Clearing only the filter there leaves
|
|
122
|
+
* the term in the box, so the table can still be empty after the click — a button that visibly does
|
|
123
|
+
* nothing, on the one screen where the reader has already failed to find someone.
|
|
124
|
+
*/
|
|
125
|
+
function clearFilters() {
|
|
126
|
+
officeTab = 'all'
|
|
127
|
+
orgUnitId = null
|
|
128
|
+
search = ''
|
|
129
|
+
debounced = ''
|
|
130
|
+
writeFilter({ officeId: null, orgUnit: null })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A row link carries the filter it was opened from.
|
|
135
|
+
*
|
|
136
|
+
* Now that the filter is in the query string, a bare `?person=…` is also a request to show the
|
|
137
|
+
* whole company — so opening somebody from an office directory would quietly re-fetch and redraw
|
|
138
|
+
* every person in the workspace behind the panel. `new` is dropped rather than kept: two dialogs
|
|
139
|
+
* over one directory is not a state anything should be able to link to.
|
|
140
|
+
*/
|
|
141
|
+
function personHref(personId: string): string {
|
|
142
|
+
const params = new URLSearchParams(navigation.search)
|
|
143
|
+
params.delete('new')
|
|
144
|
+
params.set('person', personId)
|
|
145
|
+
return `/${workspaceSlug}/hr?${params.toString()}`
|
|
146
|
+
}
|
|
147
|
+
|
|
56
148
|
const showOffices = $derived(session.hasCapability('hr', HR_CAPABILITIES.offices))
|
|
57
149
|
/**
|
|
58
150
|
* Leave is a capability, and a workspace that never switched it on has no balance to show.
|
|
@@ -79,6 +171,23 @@ $effect(() => {
|
|
|
79
171
|
return () => clearTimeout(handle)
|
|
80
172
|
})
|
|
81
173
|
|
|
174
|
+
/**
|
|
175
|
+
* What the filter asks the server for, shared by the table and the counts above it.
|
|
176
|
+
*
|
|
177
|
+
* `includeDescendants` is passed rather than left to the contract's default: the number on the
|
|
178
|
+
* "See the people" button that links here is the department's *subtree* total, and a directory
|
|
179
|
+
* holding fewer people than the link promised is the same defect one layer down.
|
|
180
|
+
*/
|
|
181
|
+
const scopeArgs = $derived({
|
|
182
|
+
...(officeTab !== 'all' ? { officeId: officeTab } : {}),
|
|
183
|
+
...(orgUnitId ? { orgUnitId, includeDescendants: true } : {}),
|
|
184
|
+
})
|
|
185
|
+
/**
|
|
186
|
+
* The same filter as a cache key. `'all'` is spelled out rather than left absent, so that no two
|
|
187
|
+
* scopes ever hash alike and the cache cannot answer a filtered directory with the whole company.
|
|
188
|
+
*/
|
|
189
|
+
const scopeKey = $derived({ officeId: officeTab, orgUnitId: orgUnitId ?? 'all' })
|
|
190
|
+
|
|
82
191
|
const officesQuery = createQuery(() => ({
|
|
83
192
|
queryKey: hrKeys.offices(workspaceId),
|
|
84
193
|
enabled: Boolean(workspaceId) && showOffices && canHr('officeView'),
|
|
@@ -87,24 +196,77 @@ const officesQuery = createQuery(() => ({
|
|
|
87
196
|
const offices = $derived(officesQuery.data ?? [])
|
|
88
197
|
|
|
89
198
|
const peopleQuery = createQuery(() => ({
|
|
90
|
-
queryKey: hrKeys.people(workspaceId, { q: debounced
|
|
199
|
+
queryKey: hrKeys.people(workspaceId, { ...scopeKey, q: debounced }),
|
|
91
200
|
enabled: Boolean(workspaceId),
|
|
92
201
|
queryFn: () =>
|
|
93
202
|
api.people.list({
|
|
94
203
|
workspaceId,
|
|
95
204
|
limit: 100,
|
|
96
205
|
...(debounced ? { q: debounced } : {}),
|
|
97
|
-
...
|
|
206
|
+
...scopeArgs,
|
|
98
207
|
}),
|
|
99
208
|
}))
|
|
100
209
|
const people = $derived(peopleQuery.data?.items ?? [])
|
|
101
210
|
|
|
211
|
+
/**
|
|
212
|
+
* The tiles count the company, not the page.
|
|
213
|
+
*
|
|
214
|
+
* `people.list` returns a `total` for the whole filter, so both numbers are asked of the server
|
|
215
|
+
* with `limit: 1` rather than counted off the rows on screen. Counting the rows made "On leave"
|
|
216
|
+
* fall as somebody typed a name — the table narrows on every keystroke and stops at a hundred rows
|
|
217
|
+
* either way — which is a tile reporting the search box while wearing the label of a company fact.
|
|
218
|
+
*
|
|
219
|
+
* The search is deliberately not part of these: a tile describes the scope the directory is
|
|
220
|
+
* showing, and finding one person inside it does not change how many of them are away. The filter
|
|
221
|
+
* *is* part of them, so an office's directory shows that office's numbers.
|
|
222
|
+
*/
|
|
223
|
+
const headcountQuery = createQuery(() => ({
|
|
224
|
+
queryKey: hrKeys.people(workspaceId, { ...scopeKey, count: 'headcount' }),
|
|
225
|
+
enabled: Boolean(workspaceId),
|
|
226
|
+
queryFn: () => api.people.list({ workspaceId, limit: 1, ...scopeArgs }),
|
|
227
|
+
}))
|
|
228
|
+
|
|
229
|
+
const onLeaveQuery = createQuery(() => ({
|
|
230
|
+
queryKey: hrKeys.people(workspaceId, { ...scopeKey, count: 'on_leave' }),
|
|
231
|
+
enabled: Boolean(workspaceId),
|
|
232
|
+
queryFn: () => api.people.list({ workspaceId, limit: 1, status: ['on_leave'], ...scopeArgs }),
|
|
233
|
+
}))
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Only to name the department in the filter chip, so it is asked for only when there is one to
|
|
237
|
+
* name. It shares `hrKeys.orgUnits` with the org chart, which is where "See the people" is clicked
|
|
238
|
+
* — so arriving from there costs no request at all.
|
|
239
|
+
*/
|
|
240
|
+
const orgUnitsQuery = createQuery(() => ({
|
|
241
|
+
queryKey: hrKeys.orgUnits(workspaceId),
|
|
242
|
+
enabled: Boolean(workspaceId) && orgUnitId !== null && canHr('orgView'),
|
|
243
|
+
queryFn: () => api.org.units.tree({ workspaceId, includeArchived: false }),
|
|
244
|
+
}))
|
|
245
|
+
const orgUnitName = $derived(orgUnitsQuery.data?.find((u) => u.id === orgUnitId)?.name ?? null)
|
|
246
|
+
|
|
102
247
|
/** Office tabs only once there is more than one place of work — otherwise they say nothing. */
|
|
103
248
|
const tabs = $derived([
|
|
104
249
|
{ value: 'all', label: t('title') },
|
|
105
250
|
...offices.map((o) => ({ value: o.id, label: o.name })),
|
|
106
251
|
])
|
|
107
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Whether the office filter has to be said in words.
|
|
255
|
+
*
|
|
256
|
+
* A selected pill already says it, so this is for what the tabs cannot cover: a workspace with one
|
|
257
|
+
* office (no tabs at all), a viewer without `hr.office.view`, and an id that is not in the list any
|
|
258
|
+
* more — an archived office whose link somebody kept. There is no name to give in any of those
|
|
259
|
+
* cases, which is why the chip's wording does not promise one.
|
|
260
|
+
*
|
|
261
|
+
* While the offices are still loading there is nothing to say yet and the table is drawing
|
|
262
|
+
* skeletons, so it waits rather than flashing a sentence a pill is about to replace.
|
|
263
|
+
*/
|
|
264
|
+
const officeNeedsChip = $derived(
|
|
265
|
+
officeTab !== 'all' && !officesQuery.isLoading && !tabs.some((tab) => tab.value === officeTab),
|
|
266
|
+
)
|
|
267
|
+
/** Any filter at all — what decides whether an empty table is "nobody yet" or "nobody here". */
|
|
268
|
+
const filtered = $derived(officeTab !== 'all' || orgUnitId !== null)
|
|
269
|
+
|
|
108
270
|
const balancesQuery = createQuery(() => ({
|
|
109
271
|
queryKey: hrKeys.leaveBalance(workspaceId, undefined),
|
|
110
272
|
enabled: Boolean(workspaceId) && showBalance,
|
|
@@ -205,9 +367,9 @@ const SUBJECT_LABELS: Record<string, () => string> = {
|
|
|
205
367
|
const subjectLabel = (subjectType: string) => SUBJECT_LABELS[subjectType]?.() ?? subjectType
|
|
206
368
|
|
|
207
369
|
const stats = $derived({
|
|
208
|
-
headcount:
|
|
370
|
+
headcount: headcountQuery.data?.total ?? null,
|
|
209
371
|
offices: offices.length,
|
|
210
|
-
away:
|
|
372
|
+
away: onLeaveQuery.data?.total ?? null,
|
|
211
373
|
balance: balancesQuery.data?.[0]?.available ?? 0,
|
|
212
374
|
})
|
|
213
375
|
|
|
@@ -265,8 +427,33 @@ function localTime(timezone: string | null, _tick: number): string | null {
|
|
|
265
427
|
const started = (iso: string | null) =>
|
|
266
428
|
iso ? formatDate(`${iso}T00:00:00`, { month: 'short', year: 'numeric' }) : '—'
|
|
267
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Whether this row's start date is blank because the server withheld it.
|
|
432
|
+
*
|
|
433
|
+
* The hire date is one of the four fields `HrAccessService` nulls for a reader outside its scope,
|
|
434
|
+
* and it arrives here as a null like any other — so the em dash this column drew for it said
|
|
435
|
+
* "never started", about everybody in the company, to every colleague without a widening key.
|
|
436
|
+
*
|
|
437
|
+
* `personnelVisibility` answers `unknown` for a reader whose scope only the server can resolve, and
|
|
438
|
+
* an unknown row keeps the dash: a dash over an empty field is imprecise, and "Hidden" over one is
|
|
439
|
+
* a lie. Both are marked once, under the table, rather than per row.
|
|
440
|
+
*/
|
|
441
|
+
const startWithheld = (person: { userId: string | null; hiredOn: string | null }) =>
|
|
442
|
+
!person.hiredOn && personnelVisibility(person) === 'withheld'
|
|
443
|
+
|
|
444
|
+
/** Whether anything on the page is actually marked — the sentence must not outlive the marks. */
|
|
445
|
+
const anyWithheld = $derived(people.some(startWithheld))
|
|
446
|
+
|
|
268
447
|
/** `formatCount` caps at 99 for badges. A headcount is a real number and must not read "99+". */
|
|
269
448
|
const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* A count nobody has yet is an em dash, never a zero.
|
|
452
|
+
*
|
|
453
|
+
* "0 on leave" is a fact about the company, and a tile stating it while the request is still in
|
|
454
|
+
* flight — or after it failed — is the same lie the tile was counting rows to tell.
|
|
455
|
+
*/
|
|
456
|
+
const tileCount = (n: number | null) => (n === null ? '—' : count(n))
|
|
270
457
|
</script>
|
|
271
458
|
|
|
272
459
|
<PageHeader
|
|
@@ -283,7 +470,7 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
|
|
|
283
470
|
|
|
284
471
|
<Page>
|
|
285
472
|
<div class="tiles">
|
|
286
|
-
<StatTile size="md" label={t('widget_headcount_title')} value={
|
|
473
|
+
<StatTile size="md" label={t('widget_headcount_title')} value={tileCount(stats.headcount)} />
|
|
287
474
|
<!--
|
|
288
475
|
Only where the workspace has offices. It rendered unconditionally and read "Offices 0" on a
|
|
289
476
|
single-site workspace — a tile counting a feature nobody switched on, sitting beside three
|
|
@@ -292,7 +479,11 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
|
|
|
292
479
|
{#if showOffices}
|
|
293
480
|
<StatTile size="md" label={t('offices_title')} value={count(stats.offices)} />
|
|
294
481
|
{/if}
|
|
295
|
-
|
|
482
|
+
<!--
|
|
483
|
+
The number comes from the server, and the note says which number it is: "On leave" over a
|
|
484
|
+
figure could as easily mean this week or this month, and it means neither.
|
|
485
|
+
-->
|
|
486
|
+
<StatTile size="md" label={t('status_on_leave')} value={tileCount(stats.away)} note={t('on_leave_note')} />
|
|
296
487
|
<!-- Same rule for leave: the balance tile is the surface of a capability, so it goes with it. -->
|
|
297
488
|
{#if showBalance}
|
|
298
489
|
<StatTile
|
|
@@ -310,7 +501,28 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
|
|
|
310
501
|
|
|
311
502
|
<div class="filters">
|
|
312
503
|
{#if tabs.length > 1}
|
|
313
|
-
<Tabs items={tabs} value={officeTab} variant="pill"
|
|
504
|
+
<Tabs items={tabs} value={officeTab} variant="pill" label={t('office')} onValueChange={chooseOffice} />
|
|
505
|
+
{/if}
|
|
506
|
+
<!--
|
|
507
|
+
What the pills cannot say. A directory that opened filtered and says nothing about it is
|
|
508
|
+
indistinguishable from a directory that lost half the company, so anything the tabs are not
|
|
509
|
+
already showing gets a chip — and one control puts every filter back at once.
|
|
510
|
+
-->
|
|
511
|
+
{#if officeNeedsChip || orgUnitId}
|
|
512
|
+
<div class="active">
|
|
513
|
+
{#if officeNeedsChip}
|
|
514
|
+
<span class="chip">
|
|
515
|
+
<Icon name="building" size={12} strokeWidth={1.8} />{t('filter_office_unnamed')}
|
|
516
|
+
</span>
|
|
517
|
+
{/if}
|
|
518
|
+
{#if orgUnitId}
|
|
519
|
+
<span class="chip">
|
|
520
|
+
<Icon name="git-branch" size={12} strokeWidth={1.8} />
|
|
521
|
+
{orgUnitName ? t('filter_department', { name: orgUnitName }) : t('filter_department_unnamed')}
|
|
522
|
+
</span>
|
|
523
|
+
{/if}
|
|
524
|
+
<Button size="xs" variant="ghost" onclick={clearFilters}>{t('filter_clear')}</Button>
|
|
525
|
+
</div>
|
|
314
526
|
{/if}
|
|
315
527
|
<div class="search">
|
|
316
528
|
<Input bind:value={search} placeholder={t('search_people')} type="search" size="sm" />
|
|
@@ -339,7 +551,7 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
|
|
|
339
551
|
</div>
|
|
340
552
|
{#each people as person (person.id)}
|
|
341
553
|
{@const time = localTime(person.timezone, tick)}
|
|
342
|
-
<a class="trow" role="row" href={
|
|
554
|
+
<a class="trow" role="row" href={personHref(person.id)}>
|
|
343
555
|
<span class="cell who" role="cell">
|
|
344
556
|
<Avatar name={person.displayName} id={person.id} size={28} />
|
|
345
557
|
<span class="stack">
|
|
@@ -349,7 +561,11 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
|
|
|
349
561
|
</span>
|
|
350
562
|
<span class="cell role" role="cell">{person.employeeNo ?? '—'}</span>
|
|
351
563
|
<span class="cell muted" role="cell">{person.officeName ?? '—'}</span>
|
|
352
|
-
<span class="cell muted" role="cell">
|
|
564
|
+
<span class="cell muted" role="cell">
|
|
565
|
+
{#if startWithheld(person)}
|
|
566
|
+
<span class="withheld"><Icon name="eye-off" size={12} strokeWidth={1.8} />{t('person_hidden')}</span>
|
|
567
|
+
{:else}{started(person.hiredOn)}{/if}
|
|
568
|
+
</span>
|
|
353
569
|
<span class="cell num" role="cell" title={person.timezone ?? ''}>{time ?? '—'}</span>
|
|
354
570
|
<span class="cell" role="cell">
|
|
355
571
|
<Badge tone={statusTone(person.status)}>{statusLabel(person.status)}</Badge>
|
|
@@ -363,9 +579,30 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
|
|
|
363
579
|
<Button variant="secondary" onclick={() => void peopleQuery.refetch()}>{t('retry')}</Button>
|
|
364
580
|
{/snippet}
|
|
365
581
|
</EmptyState>
|
|
582
|
+
<!--
|
|
583
|
+
"Nobody here yet — add the first person" is the right sentence for an empty company and the
|
|
584
|
+
wrong one for an office with nobody in it: it invites somebody to create a second record for
|
|
585
|
+
a person the filter is simply hiding. A filtered miss offers the way out instead.
|
|
586
|
+
-->
|
|
587
|
+
{:else if filtered}
|
|
588
|
+
<EmptyState icon="search" title={t('no_people_match')} description={t('no_people_match_filtered')}>
|
|
589
|
+
{#snippet actions()}
|
|
590
|
+
<Button variant="secondary" onclick={clearFilters}>{t('filter_clear')}</Button>
|
|
591
|
+
{/snippet}
|
|
592
|
+
</EmptyState>
|
|
593
|
+
{:else if debounced}
|
|
594
|
+
<EmptyState icon="search" title={t('no_people_match')} description={t('no_people_match_search')} />
|
|
366
595
|
{:else}
|
|
367
596
|
<EmptyState icon="users" title={t('no_people')} description={t('no_people_desc')} />
|
|
368
597
|
{/if}
|
|
598
|
+
|
|
599
|
+
<!--
|
|
600
|
+
Once for the table, not once per row. A marked cell says which fact is missing and this says
|
|
601
|
+
why it is missing — a column of "Hidden" with nothing accounting for it reads as a fault.
|
|
602
|
+
-->
|
|
603
|
+
{#if anyWithheld}
|
|
604
|
+
<p class="hint">{t('person_hidden_hint')}</p>
|
|
605
|
+
{/if}
|
|
369
606
|
</section>
|
|
370
607
|
|
|
371
608
|
<aside>
|
|
@@ -458,11 +695,34 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
|
|
|
458
695
|
display: flex;
|
|
459
696
|
align-items: center;
|
|
460
697
|
gap: 12px;
|
|
698
|
+
/* A department name is as long as somebody named it, and Persian and German run longer than the
|
|
699
|
+
English it was laid out in. The row wraps rather than crushing the search box. */
|
|
700
|
+
flex-wrap: wrap;
|
|
461
701
|
margin-block: 4px 8px;
|
|
462
702
|
}
|
|
463
703
|
.search {
|
|
464
704
|
margin-inline-start: auto;
|
|
465
|
-
|
|
705
|
+
inline-size: min(260px, 40%);
|
|
706
|
+
}
|
|
707
|
+
.active {
|
|
708
|
+
display: flex;
|
|
709
|
+
align-items: center;
|
|
710
|
+
gap: 6px;
|
|
711
|
+
flex-wrap: wrap;
|
|
712
|
+
min-inline-size: 0;
|
|
713
|
+
}
|
|
714
|
+
.chip {
|
|
715
|
+
display: inline-flex;
|
|
716
|
+
align-items: center;
|
|
717
|
+
gap: 5px;
|
|
718
|
+
padding-inline: 9px;
|
|
719
|
+
block-size: 24px;
|
|
720
|
+
border-radius: var(--kern-r-full);
|
|
721
|
+
background: var(--kern-surface-chip);
|
|
722
|
+
font-size: 12px;
|
|
723
|
+
/* A colour, not opacity: opacity fades text against the page whatever token it names. */
|
|
724
|
+
color: var(--kern-ink-700);
|
|
725
|
+
white-space: nowrap;
|
|
466
726
|
}
|
|
467
727
|
.rows {
|
|
468
728
|
display: grid;
|
|
@@ -529,6 +789,20 @@ const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
|
|
|
529
789
|
.role {
|
|
530
790
|
font-size: 13px;
|
|
531
791
|
}
|
|
792
|
+
/* A colour, never opacity, for the same reason `.sub` and `.muted` above use one. */
|
|
793
|
+
.withheld {
|
|
794
|
+
display: inline-flex;
|
|
795
|
+
align-items: center;
|
|
796
|
+
gap: 5px;
|
|
797
|
+
min-inline-size: 0;
|
|
798
|
+
}
|
|
799
|
+
.hint {
|
|
800
|
+
margin: 10px 0 0;
|
|
801
|
+
max-inline-size: 68ch;
|
|
802
|
+
font-size: 12px;
|
|
803
|
+
line-height: 1.45;
|
|
804
|
+
color: var(--kern-ink-500);
|
|
805
|
+
}
|
|
532
806
|
.num {
|
|
533
807
|
font-size: 13px;
|
|
534
808
|
color: var(--kern-ink-500);
|
|
@@ -160,6 +160,12 @@ const kindTone = (kind: string): BadgeTone => (kind === 'remote' ? 'info' : 'gre
|
|
|
160
160
|
<span role="columnheader">{t('local_time')}</span>
|
|
161
161
|
</div>
|
|
162
162
|
{#each offices as office (office.id)}
|
|
163
|
+
<!--
|
|
164
|
+
`officeId` is read by the directory, which seeds its office filter from it and shows the
|
|
165
|
+
filter it landed with. The parameter name is the whole contract between the two screens:
|
|
166
|
+
renaming it here alone breaks nothing loudly — the link still resolves, and quietly lands
|
|
167
|
+
on the unfiltered company directory, which is exactly how it shipped.
|
|
168
|
+
-->
|
|
163
169
|
<a
|
|
164
170
|
class="trow"
|
|
165
171
|
role="row"
|
|
@@ -871,6 +871,13 @@ const tabs = $derived([
|
|
|
871
871
|
</div>
|
|
872
872
|
|
|
873
873
|
{#if selected.total > 0}
|
|
874
|
+
<!--
|
|
875
|
+
`orgUnit` is read by the directory, which seeds an org-unit filter from it and asks
|
|
876
|
+
for the whole subtree — which is why the count here is `total` and not `headcount`.
|
|
877
|
+
The parameter name is the whole contract between the two screens: renaming it here
|
|
878
|
+
alone breaks nothing loudly, it just lands on the unfiltered company directory,
|
|
879
|
+
which is exactly how it shipped.
|
|
880
|
+
-->
|
|
874
881
|
<Button
|
|
875
882
|
size="sm"
|
|
876
883
|
variant="secondary"
|
|
@@ -23,10 +23,18 @@ export function canHr(permission: HrPermission): boolean {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* Whether the viewer
|
|
26
|
+
* Whether the viewer reads the personnel record behind a directory card, for anybody but themselves.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* Not "can they open somebody else's page" — everybody can, and should. `hr.person.view` is a
|
|
29
|
+
* `member` default and the directory is meant to be read. What the three widening keys decide is
|
|
30
|
+
* how much of each person comes back: personal email, phone, hire date and termination date are the
|
|
31
|
+
* personnel record, and the server nulls all four for anybody outside the reader's scope.
|
|
32
|
+
*
|
|
33
|
+
* The three do not imply one another — a country HR manager must not silently become a global one —
|
|
34
|
+
* so a screen asks the union once, here. It is only a hint: which *people* fall inside a team or an
|
|
35
|
+
* office is resolved on the server from the org chart, so a screen may not conclude from `true`
|
|
36
|
+
* that a particular person's record is readable. Render what came back; use this to decide whether
|
|
37
|
+
* a "personal details" section is worth offering at all.
|
|
30
38
|
*/
|
|
31
|
-
export const
|
|
39
|
+
export const canSeeFullRecords = (): boolean =>
|
|
32
40
|
canHr('personViewTeam') || canHr('personViewOffice') || canHr('personViewAll')
|