@kernhq/module-hr 0.10.4 → 0.11.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/package.json +5 -5
- package/src/client/components/DayDetail.svelte +641 -0
- package/src/client/components/EmploymentChangeDialog.svelte +341 -0
- package/src/client/components/HrSidebar.svelte +7 -0
- package/src/client/components/LeaveLedgerPanel.svelte +654 -0
- package/src/client/components/PersonDocumentsSection.svelte +492 -0
- package/src/client/components/PersonJobSection.svelte +343 -0
- package/src/client/components/PersonPanel.svelte +23 -44
- package/src/client/components/PersonSensitiveSection.svelte +382 -0
- package/src/client/components/RegularizationDialog.svelte +334 -0
- package/src/client/components/refusal.ts +21 -0
- package/src/client/messages.ts +3391 -0
- package/src/client/mock.ts +1417 -89
- package/src/client/module.ts +50 -0
- package/src/client/pages/AttendancePage.svelte +116 -11
- package/src/client/pages/LeavePage.svelte +86 -5
- package/src/client/pages/OrgPage.svelte +1623 -0
- package/src/client/pages/leave-and-attendance.test.ts +29 -2
- package/src/client/query.ts +21 -0
- package/src/client/settings/AccrualSettings.svelte +1779 -0
- package/src/client/settings/ApprovalsSettings.svelte +1206 -0
- package/src/client/settings/PeriodsSettings.svelte +858 -0
|
@@ -0,0 +1,1779 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
Dialog,
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
EmptyState,
|
|
8
|
+
Field,
|
|
9
|
+
formatCount,
|
|
10
|
+
formatDate,
|
|
11
|
+
formatDateRange,
|
|
12
|
+
IconButton,
|
|
13
|
+
Input,
|
|
14
|
+
type MenuItem,
|
|
15
|
+
messageLocale,
|
|
16
|
+
navigation,
|
|
17
|
+
SectionLabel,
|
|
18
|
+
Select,
|
|
19
|
+
SettingsPage,
|
|
20
|
+
SettingsSection,
|
|
21
|
+
Skeleton,
|
|
22
|
+
StatTile,
|
|
23
|
+
Switch,
|
|
24
|
+
session,
|
|
25
|
+
toast,
|
|
26
|
+
} from '@kernhq/ui'
|
|
27
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
28
|
+
// Straight from the contract rather than through the client barrel: policies were never one of the
|
|
29
|
+
// models the barrel re-exports, and widening it is a shared file three other screens are editing.
|
|
30
|
+
// `AccrualConfig` is imported as a value on purpose — see `configOf`.
|
|
31
|
+
import {
|
|
32
|
+
AccrualConfig,
|
|
33
|
+
type Policy,
|
|
34
|
+
type PolicyAssignment,
|
|
35
|
+
type PolicySubjectKind,
|
|
36
|
+
SUBJECT_PRIORITY,
|
|
37
|
+
} from '../../contract/policies.js'
|
|
38
|
+
import { getHrApi } from '../api-instance.js'
|
|
39
|
+
import { HR_CAPABILITIES } from '../capabilities.js'
|
|
40
|
+
import { t } from '../i18n.js'
|
|
41
|
+
import { canHr } from '../permissions.js'
|
|
42
|
+
import { formatDays, formatDuration, hrKeys, isoDate, monthRange } from '../query.js'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How leave is earned over time.
|
|
46
|
+
*
|
|
47
|
+
* Switching `leave_accrual` on used to start an hourly job and give nobody a way to look at it: the
|
|
48
|
+
* policies existed, the ladder existed, the preview existed, and none of the three had a caller. So
|
|
49
|
+
* three things justify this screen, and everything on it serves one of them.
|
|
50
|
+
*
|
|
51
|
+
* **A policy is data, and the data has to say when leave appears.** All four frequencies are
|
|
52
|
+
* implemented and each answers a different question — a twelfth every month, the whole year on one
|
|
53
|
+
* date, a year on each anniversary, or a share scaled by hours actually worked. Naming them is not
|
|
54
|
+
* enough; the form says what the one you picked will do, because "anniversary" and "annual" sound
|
|
55
|
+
* alike and credit on entirely different days.
|
|
56
|
+
*
|
|
57
|
+
* **The ladder is the module's load-bearing abstraction, and this is where somebody sees it.**
|
|
58
|
+
* Person → office → legal entity → org unit → position → workspace, nearest wins. The rungs are
|
|
59
|
+
* drawn in that order with every accrual assignment in the workspace on them, rather than one
|
|
60
|
+
* policy's assignments at a time: "which policy applies to her" is answered by what is *nearest*,
|
|
61
|
+
* so a view that hides the other policies hides the answer. The order comes from the contract's own
|
|
62
|
+
* `SUBJECT_PRIORITY` rather than being retyped here.
|
|
63
|
+
*
|
|
64
|
+
* **Nothing reaches the ledger unpreviewed.** `accrual.preview` runs the same code the run does, so
|
|
65
|
+
* the run button lives inside the preview dialog and is disabled until there are numbers on screen.
|
|
66
|
+
* A credit cannot be undone from here — it is corrected with a leave adjustment — which is exactly
|
|
67
|
+
* why an admin gets to read it first.
|
|
68
|
+
*/
|
|
69
|
+
const api = getHrApi()
|
|
70
|
+
const queryClient = useQueryClient()
|
|
71
|
+
|
|
72
|
+
const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
73
|
+
const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
|
|
74
|
+
const workspaceId = $derived(workspace?.id ?? '')
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The page is contributed under `hr.policy.manage`, so this is normally true — it is read anyway
|
|
78
|
+
* because a settings URL can be typed, and a screen full of live buttons the server will refuse is
|
|
79
|
+
* worse than a screen that says so.
|
|
80
|
+
*/
|
|
81
|
+
const manage = $derived(canHr('policyManage'))
|
|
82
|
+
|
|
83
|
+
/** Which rungs can be *named*: two of them are behind their own capability and their own read. */
|
|
84
|
+
const hasOffices = $derived(session.hasCapability('hr', HR_CAPABILITIES.offices) && canHr('officeView'))
|
|
85
|
+
const hasEntities = $derived(
|
|
86
|
+
session.hasCapability('hr', HR_CAPABILITIES.legalEntities) && canHr('entityView'),
|
|
87
|
+
)
|
|
88
|
+
const hasOrg = $derived(canHr('orgView'))
|
|
89
|
+
const hasPeople = $derived(canHr('personView'))
|
|
90
|
+
/** `per_hour_worked` reads attendance day sheets; without them it accrues nothing at all. */
|
|
91
|
+
const hasAttendance = $derived(session.hasCapability('hr', HR_CAPABILITIES.attendance))
|
|
92
|
+
|
|
93
|
+
type PolicyRow = Policy & { assignments: PolicyAssignment[] }
|
|
94
|
+
type Frequency = AccrualConfig['frequency']
|
|
95
|
+
type Tier = { afterYears: string; daysPerYear: string }
|
|
96
|
+
|
|
97
|
+
const FREQUENCIES: Frequency[] = ['monthly', 'annual', 'anniversary', 'per_hour_worked']
|
|
98
|
+
|
|
99
|
+
const freqLabel = (frequency: Frequency): string =>
|
|
100
|
+
frequency === 'monthly'
|
|
101
|
+
? t('accr_freq_monthly')
|
|
102
|
+
: frequency === 'annual'
|
|
103
|
+
? t('accr_freq_annual')
|
|
104
|
+
: frequency === 'anniversary'
|
|
105
|
+
? t('accr_freq_anniversary')
|
|
106
|
+
: t('accr_freq_per_hour')
|
|
107
|
+
|
|
108
|
+
/** What the frequency actually does. Copied from `grantForPeriod`, which is what performs it. */
|
|
109
|
+
const freqDesc = (frequency: Frequency): string =>
|
|
110
|
+
frequency === 'monthly'
|
|
111
|
+
? t('accr_freq_monthly_desc')
|
|
112
|
+
: frequency === 'annual'
|
|
113
|
+
? t('accr_freq_annual_desc')
|
|
114
|
+
: frequency === 'anniversary'
|
|
115
|
+
? t('accr_freq_anniversary_desc')
|
|
116
|
+
: t('accr_freq_per_hour_desc')
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The ladder, ordered by the contract's own numbers.
|
|
120
|
+
*
|
|
121
|
+
* Written out, the sequence would be a second place to keep it — and the day the two disagree the
|
|
122
|
+
* screen draws a precedence the resolver does not use, which is the one thing this page exists to
|
|
123
|
+
* make honest.
|
|
124
|
+
*/
|
|
125
|
+
const LADDER: PolicySubjectKind[] = (Object.keys(SUBJECT_PRIORITY) as PolicySubjectKind[]).sort(
|
|
126
|
+
(a, b) => SUBJECT_PRIORITY[b] - SUBJECT_PRIORITY[a],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const rungLabel = (kind: PolicySubjectKind): string =>
|
|
130
|
+
kind === 'person'
|
|
131
|
+
? t('accr_rung_person')
|
|
132
|
+
: kind === 'office'
|
|
133
|
+
? t('accr_rung_office')
|
|
134
|
+
: kind === 'legal_entity'
|
|
135
|
+
? t('accr_rung_legal_entity')
|
|
136
|
+
: kind === 'org_unit'
|
|
137
|
+
? t('accr_rung_org_unit')
|
|
138
|
+
: kind === 'position'
|
|
139
|
+
? t('accr_rung_position')
|
|
140
|
+
: t('accr_rung_workspace')
|
|
141
|
+
|
|
142
|
+
const rungHint = (kind: PolicySubjectKind): string =>
|
|
143
|
+
kind === 'person'
|
|
144
|
+
? t('accr_rung_person_hint')
|
|
145
|
+
: kind === 'office'
|
|
146
|
+
? t('accr_rung_office_hint')
|
|
147
|
+
: kind === 'legal_entity'
|
|
148
|
+
? t('accr_rung_legal_entity_hint')
|
|
149
|
+
: kind === 'org_unit'
|
|
150
|
+
? t('accr_rung_org_unit_hint')
|
|
151
|
+
: kind === 'position'
|
|
152
|
+
? t('accr_rung_position_hint')
|
|
153
|
+
: t('accr_rung_workspace_hint')
|
|
154
|
+
|
|
155
|
+
const durationWords = {
|
|
156
|
+
hours: (n: string) => t('hours_short', { n }),
|
|
157
|
+
minutes: (n: string) => t('minutes_short', { n }),
|
|
158
|
+
}
|
|
159
|
+
const duration = (minutes: number) => formatDuration(minutes, durationWords, messageLocale())
|
|
160
|
+
const days = (n: number) => formatDays(n, messageLocale())
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------- the policies
|
|
163
|
+
|
|
164
|
+
let showArchived = $state(false)
|
|
165
|
+
|
|
166
|
+
const policiesQuery = createQuery(() => ({
|
|
167
|
+
queryKey: ['hr', 'policies', workspaceId, 'accrual', showArchived] as const,
|
|
168
|
+
enabled: Boolean(workspaceId),
|
|
169
|
+
queryFn: () => api.policies.list({ workspaceId, kind: 'accrual', includeArchived: showArchived }),
|
|
170
|
+
}))
|
|
171
|
+
const policies = $derived((policiesQuery.data ?? []) as PolicyRow[])
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A disabled query is `pending` and not fetching, so it is not loading — without the workspace test
|
|
175
|
+
* the first frame offers "no policies yet" to a workspace that has six.
|
|
176
|
+
*/
|
|
177
|
+
const policiesLoading = $derived(!workspaceId || policiesQuery.isLoading)
|
|
178
|
+
/**
|
|
179
|
+
* A failed refetch that still has an answer. Every write here invalidates the whole module, so a
|
|
180
|
+
* refetch failing while the last good list is on screen is the ordinary case — an error branch
|
|
181
|
+
* above the data would blank a working page.
|
|
182
|
+
*/
|
|
183
|
+
const stale = $derived(policiesQuery.isError && policies.length > 0)
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The config, parsed rather than cast.
|
|
187
|
+
*
|
|
188
|
+
* `config` is a `jsonb` record on the wire, and the schema is the only thing that knows which keys
|
|
189
|
+
* belong to an accrual policy. A row the server wrote is valid by construction — but a row written
|
|
190
|
+
* by an older release, or by hand, is not, and a cast would render `undefined d/yr` instead of
|
|
191
|
+
* saying the row cannot be read.
|
|
192
|
+
*/
|
|
193
|
+
function configOf(policy: PolicyRow): AccrualConfig | null {
|
|
194
|
+
const parsed = AccrualConfig.safeParse(policy.config)
|
|
195
|
+
return parsed.success ? parsed.data : null
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const leaveTypesQuery = createQuery(() => ({
|
|
199
|
+
queryKey: hrKeys.leaveTypes(workspaceId),
|
|
200
|
+
enabled: Boolean(workspaceId) && canHr('leaveView'),
|
|
201
|
+
queryFn: () => api.leave.types.list({ workspaceId, includeArchived: false }),
|
|
202
|
+
}))
|
|
203
|
+
const leaveTypes = $derived(leaveTypesQuery.data ?? [])
|
|
204
|
+
const leaveTypeName = (key: string): string => leaveTypes.find((type) => type.key === key)?.name ?? key
|
|
205
|
+
|
|
206
|
+
const liveAssignments = $derived(policies.filter((p) => !p.archivedAt).flatMap((p) => p.assignments))
|
|
207
|
+
/** The rung everybody with nothing nearer falls to. Its absence is the interesting case. */
|
|
208
|
+
const workspaceDefault = $derived(
|
|
209
|
+
policies.find((p) => !p.archivedAt && p.assignments.some((a) => a.subjectKind === 'workspace')) ?? null,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A policy change moves leave balances, the accrual preview and every resolution beneath it, so the
|
|
214
|
+
* module's cache is dropped whole rather than guessing which keys a recomputation touched.
|
|
215
|
+
*/
|
|
216
|
+
const refresh = () => {
|
|
217
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* One click, one write.
|
|
222
|
+
*
|
|
223
|
+
* `disabled={mutation.isPending}` reaches the button on the next render and two quick clicks are one
|
|
224
|
+
* render apart — which on this screen means two policies, two assignments, or an accrual run fired
|
|
225
|
+
* twice. The flag is set in the same tick as the click and cleared when the call settles.
|
|
226
|
+
*/
|
|
227
|
+
let firing = $state(false)
|
|
228
|
+
function once(run: () => void) {
|
|
229
|
+
if (firing) return
|
|
230
|
+
firing = true
|
|
231
|
+
run()
|
|
232
|
+
}
|
|
233
|
+
const settled = () => {
|
|
234
|
+
firing = false
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The refusals this module has its own sentence for, keyed by the `reason` the router sends beside
|
|
239
|
+
* the refusal — never by the sentence, because a list of sentences is a list somebody has to keep
|
|
240
|
+
* in sync and the day it drifts the reader is told nothing.
|
|
241
|
+
*
|
|
242
|
+
* Empty on purpose. The policy router refuses a bad config through `KernError.badRequest` with an
|
|
243
|
+
* `issues` list rather than a reason, and that list is the only thing that says *which* field the
|
|
244
|
+
* server disagreed with — so it is what `failureText` shows. A reason added to the router later
|
|
245
|
+
* gets its string here without touching a call site.
|
|
246
|
+
*/
|
|
247
|
+
const policyRefusalMessages: Record<string, string> = {}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* What a refused write says.
|
|
251
|
+
*
|
|
252
|
+
* Same shape as `LeavePage.svelte` and `ClockControls.svelte`: a machine-readable `reason` first,
|
|
253
|
+
* then the server's own words. `t()` answers a key it has no string for with the key itself, so a
|
|
254
|
+
* reason no key covers falls through to the router's sentence rather than putting `hr.accr_…` in
|
|
255
|
+
* front of somebody.
|
|
256
|
+
*/
|
|
257
|
+
function failureText(error: unknown, fallbackKey: string): string {
|
|
258
|
+
const failure = error as {
|
|
259
|
+
message?: string
|
|
260
|
+
data?: { reason?: unknown; issues?: unknown }
|
|
261
|
+
}
|
|
262
|
+
const reason = typeof failure.data?.reason === 'string' ? failure.data.reason : null
|
|
263
|
+
const key = reason ? policyRefusalMessages[reason] : undefined
|
|
264
|
+
const translated = key ? t(key) : undefined
|
|
265
|
+
if (translated && translated !== key) return translated
|
|
266
|
+
const issues = failure.data?.issues
|
|
267
|
+
if (Array.isArray(issues) && issues.length > 0) return issues.map(String).join(' · ')
|
|
268
|
+
return failure.message || t(fallbackKey)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------- the policy form
|
|
272
|
+
|
|
273
|
+
let policyDialog = $state<'create' | 'edit' | null>(null)
|
|
274
|
+
let policyId = $state('')
|
|
275
|
+
let policyName = $state('')
|
|
276
|
+
let policyFrom = $state(isoDate())
|
|
277
|
+
let policyTo = $state('')
|
|
278
|
+
let frequency = $state<Frequency>('monthly')
|
|
279
|
+
let leaveTypeKey = $state('')
|
|
280
|
+
let daysPerYear = $state('20')
|
|
281
|
+
let minutesPerDay = $state('480')
|
|
282
|
+
let waitingMonths = $state('0')
|
|
283
|
+
let roundTo = $state('0')
|
|
284
|
+
let tiers = $state<Tier[]>([])
|
|
285
|
+
let policyError = $state<string | null>(null)
|
|
286
|
+
|
|
287
|
+
function openCreate() {
|
|
288
|
+
policyDialog = 'create'
|
|
289
|
+
policyId = ''
|
|
290
|
+
policyName = ''
|
|
291
|
+
policyFrom = `${new Date().getFullYear()}-01-01`
|
|
292
|
+
policyTo = ''
|
|
293
|
+
frequency = 'monthly'
|
|
294
|
+
leaveTypeKey = leaveTypes[0]?.key ?? ''
|
|
295
|
+
daysPerYear = '20'
|
|
296
|
+
minutesPerDay = '480'
|
|
297
|
+
waitingMonths = '0'
|
|
298
|
+
roundTo = '0'
|
|
299
|
+
tiers = []
|
|
300
|
+
policyError = null
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function fillFrom(policy: PolicyRow) {
|
|
304
|
+
const config = configOf(policy)
|
|
305
|
+
policyName = policy.name
|
|
306
|
+
policyTo = policy.effectiveTo ?? ''
|
|
307
|
+
frequency = config?.frequency ?? 'monthly'
|
|
308
|
+
leaveTypeKey = config?.leaveTypeKey ?? leaveTypes[0]?.key ?? ''
|
|
309
|
+
daysPerYear = String(config?.daysPerYear ?? 20)
|
|
310
|
+
minutesPerDay = String(config?.minutesPerDay ?? 480)
|
|
311
|
+
waitingMonths = String(config?.waitingPeriodMonths ?? 0)
|
|
312
|
+
roundTo = String(config?.roundToMinutes ?? 0)
|
|
313
|
+
tiers = (config?.seniorityTiers ?? []).map((tier) => ({
|
|
314
|
+
afterYears: String(tier.afterYears),
|
|
315
|
+
daysPerYear: String(tier.daysPerYear),
|
|
316
|
+
}))
|
|
317
|
+
policyError = null
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function openEdit(policy: PolicyRow) {
|
|
321
|
+
policyDialog = 'edit'
|
|
322
|
+
policyId = policy.id
|
|
323
|
+
policyFrom = policy.effectiveFrom
|
|
324
|
+
fillFrom(policy)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The affordance behind the rule the contract states: a change that should apply from a date is a
|
|
329
|
+
* *new* policy with a later `effectiveFrom`, not an edit. Saying that in the edit dialog and then
|
|
330
|
+
* making somebody retype eleven fields is how the rule gets ignored.
|
|
331
|
+
*/
|
|
332
|
+
function openDuplicate(policy: PolicyRow) {
|
|
333
|
+
policyDialog = 'create'
|
|
334
|
+
policyId = ''
|
|
335
|
+
fillFrom(policy)
|
|
336
|
+
policyName = t('accr_copy_of', { name: policy.name })
|
|
337
|
+
policyFrom = isoDate()
|
|
338
|
+
policyTo = ''
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const clamped = (value: string, min: number, max: number) =>
|
|
342
|
+
Math.min(Math.max(Math.round(Number(value) || 0), min), max)
|
|
343
|
+
const clampedDays = (value: string, min: number, max: number) => {
|
|
344
|
+
const parsed = Math.round((Number(value) || 0) * 100) / 100
|
|
345
|
+
return Math.min(Math.max(parsed, min), max)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Rounding offered as the steps somebody actually means, half and whole days included — those are
|
|
350
|
+
* derived from the day length in the same form, because "half a day" is 210 minutes on a
|
|
351
|
+
* seven-hour day. The contract caps a step at 480 minutes, so a long day's whole-day option is
|
|
352
|
+
* dropped rather than offered and refused.
|
|
353
|
+
*/
|
|
354
|
+
const roundOptions = $derived.by(() => {
|
|
355
|
+
const dayMinutes = clamped(minutesPerDay, 1, 1440)
|
|
356
|
+
const steps = [
|
|
357
|
+
{ minutes: 15, label: t('accr_round_minutes', { count: 15 }) },
|
|
358
|
+
{ minutes: 30, label: t('accr_round_minutes', { count: 30 }) },
|
|
359
|
+
{ minutes: 60, label: t('accr_round_minutes', { count: 60 }) },
|
|
360
|
+
{ minutes: Math.round(dayMinutes / 2), label: t('accr_round_half_day') },
|
|
361
|
+
{ minutes: dayMinutes, label: t('accr_round_day') },
|
|
362
|
+
].filter((step) => step.minutes > 0 && step.minutes <= 480)
|
|
363
|
+
|
|
364
|
+
const seen = new Set<number>()
|
|
365
|
+
const options = [{ value: '0', label: t('accr_round_exact') }]
|
|
366
|
+
for (const step of steps) {
|
|
367
|
+
if (seen.has(step.minutes)) continue
|
|
368
|
+
seen.add(step.minutes)
|
|
369
|
+
options.push({ value: String(step.minutes), label: step.label })
|
|
370
|
+
}
|
|
371
|
+
// A stored step none of these produce would otherwise vanish the moment somebody opened the
|
|
372
|
+
// policy to change its name.
|
|
373
|
+
if (!options.some((option) => option.value === roundTo))
|
|
374
|
+
options.push({ value: roundTo, label: t('accr_round_minutes', { count: Number(roundTo) || 0 }) })
|
|
375
|
+
return options.sort((a, b) => Number(a.value) - Number(b.value))
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
const tierYears = $derived(tiers.map((tier) => clamped(tier.afterYears, 0, 60)))
|
|
379
|
+
const tiersClash = $derived(new Set(tierYears).size !== tierYears.length)
|
|
380
|
+
|
|
381
|
+
const formProblem = $derived.by(() => {
|
|
382
|
+
if (!policyName.trim()) return t('accr_error_name')
|
|
383
|
+
if (!leaveTypeKey) return t('accr_error_leave_type')
|
|
384
|
+
if (!policyFrom) return t('accr_error_from')
|
|
385
|
+
if (policyTo && policyTo < policyFrom) return t('accr_error_to_before_from')
|
|
386
|
+
if (tiersClash) return t('accr_error_tier_clash')
|
|
387
|
+
return null
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
const configDraft = $derived({
|
|
391
|
+
frequency,
|
|
392
|
+
daysPerYear: clampedDays(daysPerYear, 0, 365),
|
|
393
|
+
minutesPerDay: clamped(minutesPerDay, 1, 1440),
|
|
394
|
+
seniorityTiers: tiers.map((tier) => ({
|
|
395
|
+
afterYears: clamped(tier.afterYears, 0, 60),
|
|
396
|
+
daysPerYear: clampedDays(tier.daysPerYear, 0, 365),
|
|
397
|
+
})),
|
|
398
|
+
waitingPeriodMonths: clamped(waitingMonths, 0, 24),
|
|
399
|
+
roundToMinutes: clamped(roundTo, 0, 480),
|
|
400
|
+
leaveTypeKey,
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
const savePolicy = createMutation(() => ({
|
|
404
|
+
mutationFn: () =>
|
|
405
|
+
policyDialog === 'edit'
|
|
406
|
+
? api.policies.update({
|
|
407
|
+
workspaceId,
|
|
408
|
+
policyId,
|
|
409
|
+
name: policyName.trim(),
|
|
410
|
+
// `$state.snapshot` because the tiers are a state proxy, and a proxy cannot be cloned on
|
|
411
|
+
// its way into the request — the call throws instead of saving.
|
|
412
|
+
config: $state.snapshot(configDraft),
|
|
413
|
+
effectiveTo: policyTo || null,
|
|
414
|
+
})
|
|
415
|
+
: api.policies.create({
|
|
416
|
+
workspaceId,
|
|
417
|
+
kind: 'accrual',
|
|
418
|
+
name: policyName.trim(),
|
|
419
|
+
config: $state.snapshot(configDraft),
|
|
420
|
+
effectiveFrom: policyFrom,
|
|
421
|
+
effectiveTo: policyTo || null,
|
|
422
|
+
}),
|
|
423
|
+
onSuccess: (policy: Policy) => {
|
|
424
|
+
toast.success(policyDialog === 'edit' ? t('accr_saved') : t('accr_created', { name: policy.name }))
|
|
425
|
+
policyDialog = null
|
|
426
|
+
policyError = null
|
|
427
|
+
refresh()
|
|
428
|
+
},
|
|
429
|
+
onError: (error: Error) => {
|
|
430
|
+
policyError = failureText(error, 'accr_save_error')
|
|
431
|
+
},
|
|
432
|
+
onSettled: settled,
|
|
433
|
+
}))
|
|
434
|
+
|
|
435
|
+
let archiving = $state<PolicyRow | null>(null)
|
|
436
|
+
|
|
437
|
+
const archivePolicy = createMutation(() => ({
|
|
438
|
+
mutationFn: (policy: PolicyRow) => api.policies.archive({ workspaceId, policyId: policy.id }),
|
|
439
|
+
onSuccess: (_ok, policy: PolicyRow) => {
|
|
440
|
+
toast.success(t('accr_archived', { name: policy.name }))
|
|
441
|
+
archiving = null
|
|
442
|
+
refresh()
|
|
443
|
+
},
|
|
444
|
+
onError: (error: Error) => toast.error(failureText(error, 'accr_archive_error')),
|
|
445
|
+
onSettled: settled,
|
|
446
|
+
}))
|
|
447
|
+
|
|
448
|
+
function policyMenu(policy: PolicyRow): MenuItem[] {
|
|
449
|
+
return [
|
|
450
|
+
{ label: t('common.edit'), icon: 'square-pen', onSelect: () => openEdit(policy) },
|
|
451
|
+
{ label: t('accr_duplicate'), icon: 'copy', onSelect: () => openDuplicate(policy) },
|
|
452
|
+
{
|
|
453
|
+
label: t('accr_assign'),
|
|
454
|
+
icon: 'user-plus',
|
|
455
|
+
onSelect: () => openAssign(policy.id),
|
|
456
|
+
},
|
|
457
|
+
{ type: 'separator' },
|
|
458
|
+
{
|
|
459
|
+
label: t('common.archive'),
|
|
460
|
+
icon: 'archive',
|
|
461
|
+
danger: true,
|
|
462
|
+
disabled: Boolean(policy.archivedAt),
|
|
463
|
+
hint: policy.archivedAt ? t('accr_already_archived') : undefined,
|
|
464
|
+
onSelect: () => {
|
|
465
|
+
archiving = policy
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
]
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ---------------------------------------------------------------- the ladder
|
|
472
|
+
|
|
473
|
+
/** Every rung needs a name for its subjects, and each name costs a request — so each is asked for
|
|
474
|
+
* only when a rung carries an assignment or the assign dialog is open on it. */
|
|
475
|
+
const rungInUse = (kind: PolicySubjectKind) => liveAssignments.some((a) => a.subjectKind === kind)
|
|
476
|
+
|
|
477
|
+
let assignOpen = $state(false)
|
|
478
|
+
let assignRung = $state<PolicySubjectKind>('workspace')
|
|
479
|
+
|
|
480
|
+
const needsSubjects = (kind: PolicySubjectKind) => rungInUse(kind) || (assignOpen && assignRung === kind)
|
|
481
|
+
|
|
482
|
+
const peopleQuery = createQuery(() => ({
|
|
483
|
+
queryKey: hrKeys.people(workspaceId, { forAccrual: true }),
|
|
484
|
+
enabled: Boolean(workspaceId) && hasPeople && needsSubjects('person'),
|
|
485
|
+
queryFn: () => api.people.list({ workspaceId, limit: 200, status: ['active', 'on_leave'] }),
|
|
486
|
+
}))
|
|
487
|
+
const officesQuery = createQuery(() => ({
|
|
488
|
+
queryKey: hrKeys.offices(workspaceId),
|
|
489
|
+
enabled: Boolean(workspaceId) && hasOffices && needsSubjects('office'),
|
|
490
|
+
queryFn: () => api.offices.list({ workspaceId, includeArchived: false }),
|
|
491
|
+
}))
|
|
492
|
+
const entitiesQuery = createQuery(() => ({
|
|
493
|
+
queryKey: hrKeys.entities(workspaceId),
|
|
494
|
+
enabled: Boolean(workspaceId) && hasEntities && needsSubjects('legal_entity'),
|
|
495
|
+
queryFn: () => api.entities.list({ workspaceId, includeArchived: false }),
|
|
496
|
+
}))
|
|
497
|
+
const unitsQuery = createQuery(() => ({
|
|
498
|
+
queryKey: hrKeys.orgUnits(workspaceId),
|
|
499
|
+
enabled: Boolean(workspaceId) && hasOrg && needsSubjects('org_unit'),
|
|
500
|
+
queryFn: () => api.org.units.tree({ workspaceId, includeArchived: false }),
|
|
501
|
+
}))
|
|
502
|
+
const positionsQuery = createQuery(() => ({
|
|
503
|
+
queryKey: ['hr', 'positions', workspaceId] as const,
|
|
504
|
+
enabled: Boolean(workspaceId) && hasOrg && needsSubjects('position'),
|
|
505
|
+
queryFn: () => api.org.positions.list({ workspaceId, includeArchived: false }),
|
|
506
|
+
}))
|
|
507
|
+
|
|
508
|
+
/** `{ id: name }` per rung, so a row and the picker read the same names from the same answer. */
|
|
509
|
+
const subjectNames = $derived.by(() => {
|
|
510
|
+
const map: Record<PolicySubjectKind, Record<string, string>> = {
|
|
511
|
+
person: {},
|
|
512
|
+
office: {},
|
|
513
|
+
legal_entity: {},
|
|
514
|
+
org_unit: {},
|
|
515
|
+
position: {},
|
|
516
|
+
workspace: {},
|
|
517
|
+
}
|
|
518
|
+
for (const person of peopleQuery.data?.items ?? []) map.person[person.id] = person.displayName
|
|
519
|
+
for (const office of officesQuery.data ?? []) map.office[office.id] = office.name
|
|
520
|
+
for (const entity of entitiesQuery.data ?? []) map.legal_entity[entity.id] = entity.name
|
|
521
|
+
for (const unit of unitsQuery.data ?? []) map.org_unit[unit.id] = unit.name
|
|
522
|
+
for (const position of positionsQuery.data ?? []) map.position[position.id] = position.title
|
|
523
|
+
return map
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
/** Whether this rung can be *named* at all, and why not when it cannot. */
|
|
527
|
+
const rungBlocked = (kind: PolicySubjectKind): string | null => {
|
|
528
|
+
if (kind === 'workspace') return null
|
|
529
|
+
if (kind === 'person') return hasPeople ? null : t('accr_rung_needs_people')
|
|
530
|
+
if (kind === 'office') return hasOffices ? null : t('accr_rung_needs_offices')
|
|
531
|
+
if (kind === 'legal_entity') return hasEntities ? null : t('accr_rung_needs_entities')
|
|
532
|
+
return hasOrg ? null : t('accr_rung_needs_org')
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* A subject's name, or an honest gap.
|
|
537
|
+
*
|
|
538
|
+
* A person who has left is not in the directory this page pulls, and an admin looking at the rung
|
|
539
|
+
* needs to see that the assignment is still there rather than a blank cell — so the row says the
|
|
540
|
+
* subject cannot be named and keeps its short id, which is what a support conversation needs.
|
|
541
|
+
*/
|
|
542
|
+
function subjectName(kind: PolicySubjectKind, id: string | null): string {
|
|
543
|
+
if (kind === 'workspace') return workspace?.name ?? t('accr_rung_workspace')
|
|
544
|
+
if (!id) return t('accr_subject_missing')
|
|
545
|
+
return subjectNames[kind][id] ?? t('accr_subject_unknown', { id: id.slice(0, 8) })
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
type LadderRow = { assignment: PolicyAssignment; policy: PolicyRow }
|
|
549
|
+
|
|
550
|
+
const ladder = $derived(
|
|
551
|
+
LADDER.map((kind) => ({
|
|
552
|
+
kind,
|
|
553
|
+
rows: policies
|
|
554
|
+
.filter((policy) => !policy.archivedAt)
|
|
555
|
+
.flatMap((policy) =>
|
|
556
|
+
policy.assignments
|
|
557
|
+
.filter((assignment) => assignment.subjectKind === kind)
|
|
558
|
+
.map((assignment): LadderRow => ({ assignment, policy })),
|
|
559
|
+
)
|
|
560
|
+
.sort((a, b) =>
|
|
561
|
+
subjectName(kind, a.assignment.subjectId).localeCompare(
|
|
562
|
+
subjectName(kind, b.assignment.subjectId),
|
|
563
|
+
messageLocale(),
|
|
564
|
+
),
|
|
565
|
+
),
|
|
566
|
+
})),
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
const rangeLabel = (from: string, to: string | null): string =>
|
|
570
|
+
to ? formatDateRange(from, to) : t('accr_from_date', { date: formatDate(from) })
|
|
571
|
+
|
|
572
|
+
// ---------------------------------------------------------------- assigning
|
|
573
|
+
|
|
574
|
+
let assignPolicyId = $state('')
|
|
575
|
+
let assignSubjectId = $state('')
|
|
576
|
+
let assignFrom = $state(isoDate())
|
|
577
|
+
let assignTo = $state('')
|
|
578
|
+
let assignError = $state<string | null>(null)
|
|
579
|
+
|
|
580
|
+
const livePolicies = $derived(policies.filter((policy) => !policy.archivedAt))
|
|
581
|
+
|
|
582
|
+
function openAssign(preferredId?: string) {
|
|
583
|
+
assignOpen = true
|
|
584
|
+
assignPolicyId = preferredId ?? livePolicies[0]?.id ?? ''
|
|
585
|
+
assignRung = 'workspace'
|
|
586
|
+
assignSubjectId = ''
|
|
587
|
+
assignFrom = isoDate()
|
|
588
|
+
assignTo = ''
|
|
589
|
+
assignError = null
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function pickRung(next: string) {
|
|
593
|
+
assignRung = next as PolicySubjectKind
|
|
594
|
+
assignSubjectId = ''
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const subjectChoices = $derived(
|
|
598
|
+
Object.entries(subjectNames[assignRung])
|
|
599
|
+
.map(([value, label]) => ({ value, label }))
|
|
600
|
+
.sort((a, b) => a.label.localeCompare(b.label, messageLocale())),
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
const subjectsLoading = $derived(
|
|
604
|
+
assignRung === 'person'
|
|
605
|
+
? peopleQuery.isLoading
|
|
606
|
+
: assignRung === 'office'
|
|
607
|
+
? officesQuery.isLoading
|
|
608
|
+
: assignRung === 'legal_entity'
|
|
609
|
+
? entitiesQuery.isLoading
|
|
610
|
+
: assignRung === 'org_unit'
|
|
611
|
+
? unitsQuery.isLoading
|
|
612
|
+
: assignRung === 'position'
|
|
613
|
+
? positionsQuery.isLoading
|
|
614
|
+
: false,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
/** The rungs that still beat the one being chosen. Drawn as chips rather than written into a
|
|
618
|
+
* sentence, so the order reads down the ladder in every writing direction. */
|
|
619
|
+
const beatenBy = $derived(LADDER.slice(0, LADDER.indexOf(assignRung)))
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Another accrual policy already sitting on this exact subject, open-ended.
|
|
623
|
+
*
|
|
624
|
+
* Two policies of one kind on one rung is not refused by the server — their effective ranges may
|
|
625
|
+
* well separate them — but it is the ambiguity this whole screen exists to surface, so it is named
|
|
626
|
+
* rather than blocked.
|
|
627
|
+
*/
|
|
628
|
+
const rungTaken = $derived(
|
|
629
|
+
assignOpen
|
|
630
|
+
? (livePolicies.find((policy) =>
|
|
631
|
+
policy.assignments.some(
|
|
632
|
+
(assignment) =>
|
|
633
|
+
assignment.subjectKind === assignRung &&
|
|
634
|
+
(assignment.subjectId ?? '') === (assignRung === 'workspace' ? '' : assignSubjectId) &&
|
|
635
|
+
assignment.effectiveTo === null &&
|
|
636
|
+
policy.id !== assignPolicyId,
|
|
637
|
+
),
|
|
638
|
+
) ?? null)
|
|
639
|
+
: null,
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
const assignProblem = $derived.by(() => {
|
|
643
|
+
if (!assignPolicyId) return t('accr_error_pick_policy')
|
|
644
|
+
const blocked = rungBlocked(assignRung)
|
|
645
|
+
if (blocked) return blocked
|
|
646
|
+
if (assignRung !== 'workspace' && !assignSubjectId) return t('accr_error_pick_subject')
|
|
647
|
+
if (!assignFrom) return t('accr_error_from')
|
|
648
|
+
if (assignTo && assignTo < assignFrom) return t('accr_error_to_before_from')
|
|
649
|
+
return null
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
const assign = createMutation(() => ({
|
|
653
|
+
mutationFn: () =>
|
|
654
|
+
api.policies.assign({
|
|
655
|
+
workspaceId,
|
|
656
|
+
policyId: assignPolicyId,
|
|
657
|
+
subjectKind: assignRung,
|
|
658
|
+
subjectId: assignRung === 'workspace' ? null : assignSubjectId,
|
|
659
|
+
effectiveFrom: assignFrom,
|
|
660
|
+
effectiveTo: assignTo || null,
|
|
661
|
+
}),
|
|
662
|
+
onSuccess: () => {
|
|
663
|
+
toast.success(
|
|
664
|
+
t('accr_assigned', {
|
|
665
|
+
name: policies.find((p) => p.id === assignPolicyId)?.name ?? '',
|
|
666
|
+
subject: subjectName(assignRung, assignSubjectId || null),
|
|
667
|
+
}),
|
|
668
|
+
)
|
|
669
|
+
assignOpen = false
|
|
670
|
+
assignError = null
|
|
671
|
+
refresh()
|
|
672
|
+
},
|
|
673
|
+
onError: (error: Error) => {
|
|
674
|
+
assignError = failureText(error, 'accr_assign_error')
|
|
675
|
+
},
|
|
676
|
+
onSettled: settled,
|
|
677
|
+
}))
|
|
678
|
+
|
|
679
|
+
let unassigning = $state<LadderRow | null>(null)
|
|
680
|
+
|
|
681
|
+
const unassign = createMutation(() => ({
|
|
682
|
+
mutationFn: (row: LadderRow) => api.policies.unassign({ workspaceId, assignmentId: row.assignment.id }),
|
|
683
|
+
onSuccess: (_ok, row: LadderRow) => {
|
|
684
|
+
toast.success(
|
|
685
|
+
t('accr_unassigned', {
|
|
686
|
+
name: row.policy.name,
|
|
687
|
+
subject: subjectName(row.assignment.subjectKind, row.assignment.subjectId),
|
|
688
|
+
}),
|
|
689
|
+
)
|
|
690
|
+
unassigning = null
|
|
691
|
+
refresh()
|
|
692
|
+
},
|
|
693
|
+
onError: (error: Error) => toast.error(failureText(error, 'accr_unassign_error')),
|
|
694
|
+
onSettled: settled,
|
|
695
|
+
}))
|
|
696
|
+
|
|
697
|
+
// ---------------------------------------------------------------- preview, then run
|
|
698
|
+
|
|
699
|
+
let runOpen = $state(false)
|
|
700
|
+
/** Last month, because that is the period the job credits when a month turns. */
|
|
701
|
+
const lastMonth = monthRange(new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1))
|
|
702
|
+
let runFrom = $state(lastMonth.from)
|
|
703
|
+
let runTo = $state(lastMonth.to)
|
|
704
|
+
|
|
705
|
+
const runRangeValid = $derived(Boolean(runFrom && runTo && runFrom <= runTo))
|
|
706
|
+
|
|
707
|
+
const previewQuery = createQuery(() => ({
|
|
708
|
+
queryKey: ['hr', 'accrual-preview', workspaceId, runFrom, runTo] as const,
|
|
709
|
+
enabled: Boolean(runOpen && workspaceId && runRangeValid),
|
|
710
|
+
// A period that computes to nothing is an answer, not a network blip: no point retrying it three
|
|
711
|
+
// times behind a spinner while somebody waits to read numbers.
|
|
712
|
+
retry: false,
|
|
713
|
+
queryFn: () => api.accrual.preview({ workspaceId, from: runFrom, to: runTo }),
|
|
714
|
+
}))
|
|
715
|
+
const preview = $derived(previewQuery.data ?? null)
|
|
716
|
+
|
|
717
|
+
/** What a run would actually write. `alreadyAccrued` rows are the idempotence, drawn but not counted. */
|
|
718
|
+
const creditable = $derived(
|
|
719
|
+
preview ? preview.rows.filter((row) => !row.alreadyAccrued && row.minutes > 0) : [],
|
|
720
|
+
)
|
|
721
|
+
const alreadyDone = $derived(preview ? preview.rows.filter((row) => row.alreadyAccrued).length : 0)
|
|
722
|
+
|
|
723
|
+
const runAccrual = createMutation(() => ({
|
|
724
|
+
mutationFn: () => api.accrual.run({ workspaceId, from: runFrom, to: runTo }),
|
|
725
|
+
onSuccess: (result: { credited: number; skipped: number; totalMinutes: number }) => {
|
|
726
|
+
toast.success(t('accr_run_done', { count: result.credited }))
|
|
727
|
+
runOpen = false
|
|
728
|
+
refresh()
|
|
729
|
+
},
|
|
730
|
+
onError: (error: Error) => toast.error(failureText(error, 'accr_run_error')),
|
|
731
|
+
onSettled: settled,
|
|
732
|
+
}))
|
|
733
|
+
|
|
734
|
+
/** Long lists are for reading, not for scrolling past — the tail is counted instead. */
|
|
735
|
+
const HEAD = 10
|
|
736
|
+
</script>
|
|
737
|
+
|
|
738
|
+
<SettingsPage title={t('settings_accrual')} description={t('accr_desc')}>
|
|
739
|
+
{#snippet actions()}
|
|
740
|
+
{#if manage}
|
|
741
|
+
<Button size="sm" icon="plus" onclick={openCreate}>{t('accr_new')}</Button>
|
|
742
|
+
{/if}
|
|
743
|
+
{/snippet}
|
|
744
|
+
|
|
745
|
+
{#if !manage}
|
|
746
|
+
<!-- Typed straight into the address bar: every write below will be refused, so say it once
|
|
747
|
+
rather than letting somebody find out one button at a time. -->
|
|
748
|
+
<p class="note warn" role="status">{t('accr_readonly')}</p>
|
|
749
|
+
{/if}
|
|
750
|
+
|
|
751
|
+
{#if stale}
|
|
752
|
+
<p class="stale" role="status">
|
|
753
|
+
<span>{t('accr_stale')}</span>
|
|
754
|
+
<Button size="sm" variant="ghost" onclick={() => void policiesQuery.refetch()}>{t('retry')}</Button>
|
|
755
|
+
</p>
|
|
756
|
+
{/if}
|
|
757
|
+
|
|
758
|
+
<SettingsSection title={t('accr_policies')} description={t('accr_policies_desc')}>
|
|
759
|
+
{#snippet action()}
|
|
760
|
+
{#if policies.length > 0 || showArchived}
|
|
761
|
+
<Switch
|
|
762
|
+
size="sm"
|
|
763
|
+
checked={showArchived}
|
|
764
|
+
onCheckedChange={(on) => (showArchived = on)}
|
|
765
|
+
label={t('accr_show_archived')}
|
|
766
|
+
/>
|
|
767
|
+
{/if}
|
|
768
|
+
{/snippet}
|
|
769
|
+
|
|
770
|
+
{#if policiesLoading}
|
|
771
|
+
<div class="rows">
|
|
772
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="52px" />{/each}
|
|
773
|
+
</div>
|
|
774
|
+
{:else if policies.length}
|
|
775
|
+
<div class="tiles">
|
|
776
|
+
<StatTile size="md" label={t('accr_stat_policies')} value={formatCount(livePolicies.length, 99)} />
|
|
777
|
+
<StatTile
|
|
778
|
+
size="md"
|
|
779
|
+
label={t('accr_stat_assignments')}
|
|
780
|
+
value={formatCount(liveAssignments.length, 999)}
|
|
781
|
+
/>
|
|
782
|
+
<StatTile
|
|
783
|
+
size="md"
|
|
784
|
+
label={t('accr_stat_default')}
|
|
785
|
+
value={workspaceDefault ? workspaceDefault.name : t('accr_stat_default_none')}
|
|
786
|
+
note={workspaceDefault ? null : t('accr_stat_default_none_note')}
|
|
787
|
+
/>
|
|
788
|
+
</div>
|
|
789
|
+
|
|
790
|
+
<div class="table" role="table" aria-label={t('accr_policies')}>
|
|
791
|
+
<div class="thead" role="row">
|
|
792
|
+
<span role="columnheader">{t('accr_col_policy')}</span>
|
|
793
|
+
<span role="columnheader">{t('accr_col_earns')}</span>
|
|
794
|
+
<span role="columnheader">{t('accr_col_when')}</span>
|
|
795
|
+
<span role="columnheader">{t('accr_col_applies')}</span>
|
|
796
|
+
<span class="sr-only" role="columnheader">{t('approvals_actions')}</span>
|
|
797
|
+
</div>
|
|
798
|
+
{#each policies as policy (policy.id)}
|
|
799
|
+
{@const config = configOf(policy)}
|
|
800
|
+
<div class="trow" role="row">
|
|
801
|
+
<span class="cell what" role="cell">
|
|
802
|
+
<span class="strong">{policy.name}</span>
|
|
803
|
+
<span class="chips">
|
|
804
|
+
{#if policy.archivedAt}
|
|
805
|
+
<Badge tone="grey">{t('accr_badge_archived')}</Badge>
|
|
806
|
+
{/if}
|
|
807
|
+
{#if policy.source === 'pack'}
|
|
808
|
+
<Badge tone="grey">{t('cal_origin_pack')}</Badge>
|
|
809
|
+
{/if}
|
|
810
|
+
</span>
|
|
811
|
+
<span class="sub">{rangeLabel(policy.effectiveFrom, policy.effectiveTo)}</span>
|
|
812
|
+
</span>
|
|
813
|
+
|
|
814
|
+
<span class="cell" role="cell">
|
|
815
|
+
{#if config}
|
|
816
|
+
<span class="num">{t('accr_days_per_year', { count: config.daysPerYear })}</span>
|
|
817
|
+
<span class="sub">
|
|
818
|
+
{leaveTypeName(config.leaveTypeKey)} · {duration(config.minutesPerDay)}
|
|
819
|
+
{#if config.seniorityTiers.length > 0}
|
|
820
|
+
· {t('accr_tiers_count', { count: config.seniorityTiers.length })}
|
|
821
|
+
{/if}
|
|
822
|
+
</span>
|
|
823
|
+
{:else}
|
|
824
|
+
<!-- A row whose config the schema cannot read: say so rather than print undefined. -->
|
|
825
|
+
<span class="sub danger">{t('accr_config_unreadable')}</span>
|
|
826
|
+
{/if}
|
|
827
|
+
</span>
|
|
828
|
+
|
|
829
|
+
<span class="cell" role="cell">
|
|
830
|
+
{#if config}
|
|
831
|
+
<Badge tone="info">{freqLabel(config.frequency)}</Badge>
|
|
832
|
+
<span class="sub">
|
|
833
|
+
{#if config.waitingPeriodMonths > 0}
|
|
834
|
+
{t('accr_waiting_short', { count: config.waitingPeriodMonths })}
|
|
835
|
+
{/if}
|
|
836
|
+
{#if config.roundToMinutes > 0}
|
|
837
|
+
{#if config.waitingPeriodMonths > 0}·{/if}
|
|
838
|
+
{t('accr_round_short', { step: duration(config.roundToMinutes) })}
|
|
839
|
+
{/if}
|
|
840
|
+
</span>
|
|
841
|
+
{/if}
|
|
842
|
+
</span>
|
|
843
|
+
|
|
844
|
+
<span class="cell num" role="cell">
|
|
845
|
+
{#if policy.assignments.length > 0}
|
|
846
|
+
{formatCount(policy.assignments.length, 999)}
|
|
847
|
+
{:else}
|
|
848
|
+
<span class="sub warn-text">{t('accr_nobody')}</span>
|
|
849
|
+
{/if}
|
|
850
|
+
</span>
|
|
851
|
+
|
|
852
|
+
<span class="cell actions" role="cell">
|
|
853
|
+
{#if manage}
|
|
854
|
+
<DropdownMenu items={policyMenu(policy)}>
|
|
855
|
+
{#snippet trigger(props)}
|
|
856
|
+
<IconButton
|
|
857
|
+
icon="ellipsis"
|
|
858
|
+
label={t('accr_actions_for', { name: policy.name })}
|
|
859
|
+
size={28}
|
|
860
|
+
{...props}
|
|
861
|
+
/>
|
|
862
|
+
{/snippet}
|
|
863
|
+
</DropdownMenu>
|
|
864
|
+
{/if}
|
|
865
|
+
</span>
|
|
866
|
+
</div>
|
|
867
|
+
{/each}
|
|
868
|
+
</div>
|
|
869
|
+
{:else if policiesQuery.isError}
|
|
870
|
+
<EmptyState icon="triangle-alert" title={t('accr_error')} description={t('accr_error_desc')}>
|
|
871
|
+
{#snippet actions()}
|
|
872
|
+
<Button variant="secondary" onclick={() => void policiesQuery.refetch()}>{t('retry')}</Button>
|
|
873
|
+
{/snippet}
|
|
874
|
+
</EmptyState>
|
|
875
|
+
{:else}
|
|
876
|
+
<EmptyState icon="gauge" title={t('accr_none')} description={t('accr_none_desc')}>
|
|
877
|
+
{#snippet actions()}
|
|
878
|
+
{#if manage}
|
|
879
|
+
<Button icon="plus" onclick={openCreate}>{t('accr_new')}</Button>
|
|
880
|
+
{/if}
|
|
881
|
+
{/snippet}
|
|
882
|
+
</EmptyState>
|
|
883
|
+
{/if}
|
|
884
|
+
</SettingsSection>
|
|
885
|
+
|
|
886
|
+
<!-- ---------------------------------------------------------------- the ladder -->
|
|
887
|
+
<SettingsSection title={t('accr_ladder')} description={t('accr_ladder_desc')}>
|
|
888
|
+
{#snippet action()}
|
|
889
|
+
{#if manage && livePolicies.length > 0}
|
|
890
|
+
<Button size="sm" variant="secondary" icon="user-plus" onclick={() => openAssign()}>
|
|
891
|
+
{t('accr_assign')}
|
|
892
|
+
</Button>
|
|
893
|
+
{/if}
|
|
894
|
+
{/snippet}
|
|
895
|
+
|
|
896
|
+
{#if policiesLoading}
|
|
897
|
+
<div class="rows">
|
|
898
|
+
{#each [1, 2, 3, 4] as n (n)}<Skeleton height="44px" />{/each}
|
|
899
|
+
</div>
|
|
900
|
+
{:else if liveAssignments.length}
|
|
901
|
+
{#if !workspaceDefault}
|
|
902
|
+
<!-- The bottom rung is the only one that catches everybody, and its absence is silent
|
|
903
|
+
everywhere else: people with nothing nearer simply never accrue. -->
|
|
904
|
+
<p class="note warn">{t('accr_no_default')}</p>
|
|
905
|
+
{/if}
|
|
906
|
+
|
|
907
|
+
<div class="ladder">
|
|
908
|
+
{#each ladder as rung (rung.kind)}
|
|
909
|
+
<div class="rung" class:empty={rung.rows.length === 0}>
|
|
910
|
+
<SectionLabel label={rungLabel(rung.kind)} count={rung.rows.length} sub />
|
|
911
|
+
<p class="hint">{rungHint(rung.kind)}</p>
|
|
912
|
+
|
|
913
|
+
{#if rung.rows.length === 0}
|
|
914
|
+
<p class="sub">{t('accr_rung_empty')}</p>
|
|
915
|
+
{:else}
|
|
916
|
+
<ul class="rlist">
|
|
917
|
+
{#each rung.rows as row (row.assignment.id)}
|
|
918
|
+
<li>
|
|
919
|
+
<span class="rsubject">
|
|
920
|
+
<Badge tone="grey">{rungLabel(rung.kind)}</Badge>
|
|
921
|
+
<span class="strong">{subjectName(rung.kind, row.assignment.subjectId)}</span>
|
|
922
|
+
</span>
|
|
923
|
+
<span class="rpolicy">{row.policy.name}</span>
|
|
924
|
+
<span class="sub rwhen">
|
|
925
|
+
{rangeLabel(row.assignment.effectiveFrom, row.assignment.effectiveTo)}
|
|
926
|
+
</span>
|
|
927
|
+
{#if manage}
|
|
928
|
+
<IconButton
|
|
929
|
+
icon="trash-2"
|
|
930
|
+
label={t('accr_unassign_label', {
|
|
931
|
+
name: row.policy.name,
|
|
932
|
+
subject: subjectName(rung.kind, row.assignment.subjectId),
|
|
933
|
+
})}
|
|
934
|
+
size={28}
|
|
935
|
+
onclick={() => (unassigning = row)}
|
|
936
|
+
/>
|
|
937
|
+
{/if}
|
|
938
|
+
</li>
|
|
939
|
+
{/each}
|
|
940
|
+
</ul>
|
|
941
|
+
{/if}
|
|
942
|
+
</div>
|
|
943
|
+
{/each}
|
|
944
|
+
</div>
|
|
945
|
+
{:else if policiesQuery.isError}
|
|
946
|
+
<EmptyState icon="triangle-alert" title={t('accr_ladder_error')}>
|
|
947
|
+
{#snippet actions()}
|
|
948
|
+
<Button variant="secondary" onclick={() => void policiesQuery.refetch()}>{t('retry')}</Button>
|
|
949
|
+
{/snippet}
|
|
950
|
+
</EmptyState>
|
|
951
|
+
{:else}
|
|
952
|
+
<EmptyState
|
|
953
|
+
icon="git-branch"
|
|
954
|
+
title={t('accr_ladder_none')}
|
|
955
|
+
description={livePolicies.length > 0 ? t('accr_ladder_none_desc') : t('accr_ladder_needs_policy')}
|
|
956
|
+
>
|
|
957
|
+
{#snippet actions()}
|
|
958
|
+
{#if manage && livePolicies.length > 0}
|
|
959
|
+
<Button icon="user-plus" onclick={() => openAssign()}>{t('accr_assign')}</Button>
|
|
960
|
+
{:else if manage}
|
|
961
|
+
<Button icon="plus" onclick={openCreate}>{t('accr_new')}</Button>
|
|
962
|
+
{/if}
|
|
963
|
+
{/snippet}
|
|
964
|
+
</EmptyState>
|
|
965
|
+
{/if}
|
|
966
|
+
</SettingsSection>
|
|
967
|
+
|
|
968
|
+
<!-- ---------------------------------------------------------------- crediting -->
|
|
969
|
+
<SettingsSection title={t('accr_run_section')} description={t('accr_run_section_desc')}>
|
|
970
|
+
<p class="note">{t('accr_job_note')}</p>
|
|
971
|
+
{#if manage && livePolicies.length === 0}
|
|
972
|
+
<!-- Disabled with the reason beside it, rather than a dead button somebody clicks twice. -->
|
|
973
|
+
<p class="hint spaced">{t('accr_run_needs_policy')}</p>
|
|
974
|
+
{/if}
|
|
975
|
+
{#snippet footer()}
|
|
976
|
+
<Button
|
|
977
|
+
variant="secondary"
|
|
978
|
+
icon="play"
|
|
979
|
+
disabled={!manage || livePolicies.length === 0}
|
|
980
|
+
onclick={() => {
|
|
981
|
+
runOpen = true
|
|
982
|
+
}}
|
|
983
|
+
>
|
|
984
|
+
{t('accr_run_open')}
|
|
985
|
+
</Button>
|
|
986
|
+
{/snippet}
|
|
987
|
+
</SettingsSection>
|
|
988
|
+
</SettingsPage>
|
|
989
|
+
|
|
990
|
+
<!-- ---------------------------------------------------------------- the policy form -->
|
|
991
|
+
<Dialog
|
|
992
|
+
open={policyDialog !== null}
|
|
993
|
+
size="lg"
|
|
994
|
+
title={policyDialog === 'edit' ? t('accr_edit_title') : t('accr_create_title')}
|
|
995
|
+
onOpenChange={(open) => {
|
|
996
|
+
if (!open) policyDialog = null
|
|
997
|
+
}}
|
|
998
|
+
>
|
|
999
|
+
<div class="form">
|
|
1000
|
+
{#if policyDialog === 'edit'}
|
|
1001
|
+
<!-- The contract's own rule, said where it matters: an edit rewrites what was true in the
|
|
1002
|
+
past, and everything already derived from it becomes unexplainable. -->
|
|
1003
|
+
<p class="note">{t('accr_edit_retroactive')}</p>
|
|
1004
|
+
{/if}
|
|
1005
|
+
|
|
1006
|
+
<Field label={t('accr_name')} hint={t('accr_name_hint')} required>
|
|
1007
|
+
{#snippet children(id)}
|
|
1008
|
+
<Input {id} bind:value={policyName} maxlength={120} />
|
|
1009
|
+
{/snippet}
|
|
1010
|
+
</Field>
|
|
1011
|
+
|
|
1012
|
+
<Field label={t('accr_frequency')} hint={freqDesc(frequency)}>
|
|
1013
|
+
{#snippet children(id)}
|
|
1014
|
+
<Select
|
|
1015
|
+
{id}
|
|
1016
|
+
value={frequency}
|
|
1017
|
+
onValueChange={(v) => (frequency = v as Frequency)}
|
|
1018
|
+
options={FREQUENCIES.map((f) => ({ value: f, label: freqLabel(f) }))}
|
|
1019
|
+
/>
|
|
1020
|
+
{/snippet}
|
|
1021
|
+
</Field>
|
|
1022
|
+
|
|
1023
|
+
{#if frequency === 'per_hour_worked' && !hasAttendance}
|
|
1024
|
+
<!-- The ratio it accrues on is worked minutes over scheduled minutes, and both come from
|
|
1025
|
+
attendance day sheets. Without them the divisor is zero and every run skips everybody. -->
|
|
1026
|
+
<p class="note warn">{t('accr_needs_attendance')}</p>
|
|
1027
|
+
{/if}
|
|
1028
|
+
|
|
1029
|
+
<Field label={t('accr_leave_type')} hint={t('accr_leave_type_hint')} required>
|
|
1030
|
+
{#snippet children(id)}
|
|
1031
|
+
<Select
|
|
1032
|
+
{id}
|
|
1033
|
+
value={leaveTypeKey}
|
|
1034
|
+
onValueChange={(v) => (leaveTypeKey = v)}
|
|
1035
|
+
placeholder={t('accr_leave_type_pick')}
|
|
1036
|
+
ariaLabel={t('accr_leave_type')}
|
|
1037
|
+
options={leaveTypes.map((type) => ({ value: type.key, label: type.name }))}
|
|
1038
|
+
/>
|
|
1039
|
+
{/snippet}
|
|
1040
|
+
</Field>
|
|
1041
|
+
|
|
1042
|
+
{#if leaveTypes.length === 0 && !leaveTypesQuery.isLoading}
|
|
1043
|
+
<p class="note warn">
|
|
1044
|
+
{t('accr_no_leave_types')}
|
|
1045
|
+
{#if canHr('leaveManage')}
|
|
1046
|
+
<a class="link" href={`/${workspaceSlug}/settings/hr/leave`}>{t('settings_leave')}</a>
|
|
1047
|
+
{/if}
|
|
1048
|
+
</p>
|
|
1049
|
+
{/if}
|
|
1050
|
+
|
|
1051
|
+
<div class="pair">
|
|
1052
|
+
<Field label={t('accr_days_field')} hint={t('accr_days_field_hint')}>
|
|
1053
|
+
{#snippet children(id)}
|
|
1054
|
+
<Input {id} type="number" min={0} max={365} step={0.5} bind:value={daysPerYear} />
|
|
1055
|
+
{/snippet}
|
|
1056
|
+
</Field>
|
|
1057
|
+
<Field
|
|
1058
|
+
label={t('accr_day_length')}
|
|
1059
|
+
hint={t('accr_day_length_hint', { length: duration(clamped(minutesPerDay, 1, 1440)) })}
|
|
1060
|
+
>
|
|
1061
|
+
{#snippet children(id)}
|
|
1062
|
+
<Input {id} type="number" min={1} max={1440} bind:value={minutesPerDay} />
|
|
1063
|
+
{/snippet}
|
|
1064
|
+
</Field>
|
|
1065
|
+
</div>
|
|
1066
|
+
|
|
1067
|
+
<div class="pair">
|
|
1068
|
+
<Field label={t('accr_waiting')} hint={t('accr_waiting_hint')}>
|
|
1069
|
+
{#snippet children(id)}
|
|
1070
|
+
<Input {id} type="number" min={0} max={24} bind:value={waitingMonths} />
|
|
1071
|
+
{/snippet}
|
|
1072
|
+
</Field>
|
|
1073
|
+
<Field label={t('accr_rounding')} hint={t('accr_rounding_hint')}>
|
|
1074
|
+
{#snippet children(id)}
|
|
1075
|
+
<Select {id} value={roundTo} onValueChange={(v) => (roundTo = v)} options={roundOptions} />
|
|
1076
|
+
{/snippet}
|
|
1077
|
+
</Field>
|
|
1078
|
+
</div>
|
|
1079
|
+
|
|
1080
|
+
<div class="tierbox">
|
|
1081
|
+
<SectionLabel label={t('accr_tiers')} count={tiers.length} sub>
|
|
1082
|
+
{#snippet trailing()}
|
|
1083
|
+
<Button
|
|
1084
|
+
size="xs"
|
|
1085
|
+
variant="ghost"
|
|
1086
|
+
icon="plus"
|
|
1087
|
+
onclick={() => (tiers = [...tiers, { afterYears: '5', daysPerYear: daysPerYear }])}
|
|
1088
|
+
>
|
|
1089
|
+
{t('accr_tier_add')}
|
|
1090
|
+
</Button>
|
|
1091
|
+
{/snippet}
|
|
1092
|
+
</SectionLabel>
|
|
1093
|
+
<p class="hint">{t('accr_tiers_hint')}</p>
|
|
1094
|
+
|
|
1095
|
+
{#if tiers.length === 0}
|
|
1096
|
+
<p class="sub">{t('accr_tiers_none', { count: clampedDays(daysPerYear, 0, 365) })}</p>
|
|
1097
|
+
{:else}
|
|
1098
|
+
<ul class="tiers">
|
|
1099
|
+
{#each tiers as tier, index (index)}
|
|
1100
|
+
<li>
|
|
1101
|
+
<Field label={t('accr_tier_after')}>
|
|
1102
|
+
{#snippet children(id)}
|
|
1103
|
+
<Input {id} size="sm" type="number" min={0} max={60} bind:value={tier.afterYears} />
|
|
1104
|
+
{/snippet}
|
|
1105
|
+
</Field>
|
|
1106
|
+
<Field label={t('accr_tier_days')}>
|
|
1107
|
+
{#snippet children(id)}
|
|
1108
|
+
<Input
|
|
1109
|
+
{id}
|
|
1110
|
+
size="sm"
|
|
1111
|
+
type="number"
|
|
1112
|
+
min={0}
|
|
1113
|
+
max={365}
|
|
1114
|
+
step={0.5}
|
|
1115
|
+
bind:value={tier.daysPerYear}
|
|
1116
|
+
/>
|
|
1117
|
+
{/snippet}
|
|
1118
|
+
</Field>
|
|
1119
|
+
<IconButton
|
|
1120
|
+
icon="trash-2"
|
|
1121
|
+
label={t('accr_tier_remove', { years: clamped(tier.afterYears, 0, 60) })}
|
|
1122
|
+
size={28}
|
|
1123
|
+
onclick={() => (tiers = tiers.filter((_, i) => i !== index))}
|
|
1124
|
+
/>
|
|
1125
|
+
</li>
|
|
1126
|
+
{/each}
|
|
1127
|
+
</ul>
|
|
1128
|
+
{/if}
|
|
1129
|
+
{#if tiersClash}
|
|
1130
|
+
<p class="hint danger" role="alert">{t('accr_error_tier_clash')}</p>
|
|
1131
|
+
{/if}
|
|
1132
|
+
</div>
|
|
1133
|
+
|
|
1134
|
+
<div class="pair">
|
|
1135
|
+
<Field label={t('accr_effective_from')} hint={t('accr_effective_from_hint')} required>
|
|
1136
|
+
{#snippet children(id)}
|
|
1137
|
+
<Input {id} type="date" bind:value={policyFrom} disabled={policyDialog === 'edit'} />
|
|
1138
|
+
{/snippet}
|
|
1139
|
+
</Field>
|
|
1140
|
+
<Field label={t('accr_effective_to')} hint={t('accr_effective_to_hint')}>
|
|
1141
|
+
{#snippet children(id)}
|
|
1142
|
+
<Input {id} type="date" bind:value={policyTo} />
|
|
1143
|
+
{/snippet}
|
|
1144
|
+
</Field>
|
|
1145
|
+
</div>
|
|
1146
|
+
|
|
1147
|
+
{#if policyError}
|
|
1148
|
+
<p class="note danger-note" role="alert">{policyError}</p>
|
|
1149
|
+
{/if}
|
|
1150
|
+
</div>
|
|
1151
|
+
|
|
1152
|
+
{#snippet footer()}
|
|
1153
|
+
{#if formProblem}
|
|
1154
|
+
<span class="hint problem">{formProblem}</span>
|
|
1155
|
+
{/if}
|
|
1156
|
+
<Button variant="secondary" onclick={() => (policyDialog = null)} disabled={savePolicy.isPending}>
|
|
1157
|
+
{t('cancel')}
|
|
1158
|
+
</Button>
|
|
1159
|
+
<Button
|
|
1160
|
+
loading={savePolicy.isPending}
|
|
1161
|
+
disabled={!manage || formProblem !== null}
|
|
1162
|
+
onclick={() => once(() => savePolicy.mutate())}
|
|
1163
|
+
>
|
|
1164
|
+
{policyDialog === 'edit' ? t('common.save') : t('common.create')}
|
|
1165
|
+
</Button>
|
|
1166
|
+
{/snippet}
|
|
1167
|
+
</Dialog>
|
|
1168
|
+
|
|
1169
|
+
<!-- ---------------------------------------------------------------- archive a policy -->
|
|
1170
|
+
<Dialog
|
|
1171
|
+
open={archiving !== null}
|
|
1172
|
+
size="sm"
|
|
1173
|
+
title={archiving ? t('accr_archive_title', { name: archiving.name }) : ''}
|
|
1174
|
+
description={t('accr_archive_body')}
|
|
1175
|
+
onOpenChange={(open) => {
|
|
1176
|
+
if (!open) archiving = null
|
|
1177
|
+
}}
|
|
1178
|
+
>
|
|
1179
|
+
{#if archiving}
|
|
1180
|
+
<p class="body">{t('accr_archive_keeps')}</p>
|
|
1181
|
+
{#if archiving.assignments.length > 0}
|
|
1182
|
+
<p class="body warn-text">
|
|
1183
|
+
{t('accr_archive_assigned', { count: archiving.assignments.length })}
|
|
1184
|
+
</p>
|
|
1185
|
+
{/if}
|
|
1186
|
+
{/if}
|
|
1187
|
+
|
|
1188
|
+
{#snippet footer()}
|
|
1189
|
+
<Button variant="secondary" onclick={() => (archiving = null)} disabled={archivePolicy.isPending}>
|
|
1190
|
+
{t('cancel')}
|
|
1191
|
+
</Button>
|
|
1192
|
+
<Button
|
|
1193
|
+
variant="danger"
|
|
1194
|
+
loading={archivePolicy.isPending}
|
|
1195
|
+
onclick={() => {
|
|
1196
|
+
if (archiving) once(() => archiving && archivePolicy.mutate(archiving))
|
|
1197
|
+
}}
|
|
1198
|
+
>
|
|
1199
|
+
{t('common.archive')}
|
|
1200
|
+
</Button>
|
|
1201
|
+
{/snippet}
|
|
1202
|
+
</Dialog>
|
|
1203
|
+
|
|
1204
|
+
<!-- ---------------------------------------------------------------- assign a policy -->
|
|
1205
|
+
<Dialog
|
|
1206
|
+
open={assignOpen}
|
|
1207
|
+
title={t('accr_assign_title')}
|
|
1208
|
+
description={t('accr_assign_desc')}
|
|
1209
|
+
onOpenChange={(open) => {
|
|
1210
|
+
if (!open) assignOpen = false
|
|
1211
|
+
}}
|
|
1212
|
+
>
|
|
1213
|
+
<div class="form">
|
|
1214
|
+
<Field label={t('accr_assign_policy')} required>
|
|
1215
|
+
{#snippet children(id)}
|
|
1216
|
+
<Select
|
|
1217
|
+
{id}
|
|
1218
|
+
value={assignPolicyId}
|
|
1219
|
+
onValueChange={(v) => (assignPolicyId = v)}
|
|
1220
|
+
placeholder={t('accr_assign_policy_pick')}
|
|
1221
|
+
ariaLabel={t('accr_assign_policy')}
|
|
1222
|
+
options={livePolicies.map((policy) => ({ value: policy.id, label: policy.name }))}
|
|
1223
|
+
/>
|
|
1224
|
+
{/snippet}
|
|
1225
|
+
</Field>
|
|
1226
|
+
|
|
1227
|
+
<Field label={t('accr_assign_rung')} hint={rungHint(assignRung)} required>
|
|
1228
|
+
{#snippet children(id)}
|
|
1229
|
+
<Select
|
|
1230
|
+
{id}
|
|
1231
|
+
value={assignRung}
|
|
1232
|
+
onValueChange={pickRung}
|
|
1233
|
+
options={LADDER.map((kind) => ({
|
|
1234
|
+
value: kind,
|
|
1235
|
+
label: rungLabel(kind),
|
|
1236
|
+
}))}
|
|
1237
|
+
/>
|
|
1238
|
+
{/snippet}
|
|
1239
|
+
</Field>
|
|
1240
|
+
|
|
1241
|
+
{#if beatenBy.length > 0}
|
|
1242
|
+
<div class="beaten">
|
|
1243
|
+
<span class="hint">{t('accr_beaten_by')}</span>
|
|
1244
|
+
<span class="chips">
|
|
1245
|
+
{#each beatenBy as kind (kind)}
|
|
1246
|
+
<Badge tone="warning">{rungLabel(kind)}</Badge>
|
|
1247
|
+
{/each}
|
|
1248
|
+
</span>
|
|
1249
|
+
</div>
|
|
1250
|
+
{:else}
|
|
1251
|
+
<p class="hint">{t('accr_beats_everything')}</p>
|
|
1252
|
+
{/if}
|
|
1253
|
+
|
|
1254
|
+
{#if assignRung !== 'workspace'}
|
|
1255
|
+
{@const blocked = rungBlocked(assignRung)}
|
|
1256
|
+
<Field label={t('accr_assign_subject', { rung: rungLabel(assignRung) })} error={blocked} required>
|
|
1257
|
+
{#snippet children(id)}
|
|
1258
|
+
<Select
|
|
1259
|
+
{id}
|
|
1260
|
+
value={assignSubjectId}
|
|
1261
|
+
onValueChange={(v) => (assignSubjectId = v)}
|
|
1262
|
+
disabled={Boolean(blocked) || subjectsLoading || subjectChoices.length === 0}
|
|
1263
|
+
placeholder={subjectsLoading ? t('accr_subject_loading') : t('accr_subject_pick')}
|
|
1264
|
+
ariaLabel={t('accr_assign_subject', { rung: rungLabel(assignRung) })}
|
|
1265
|
+
options={subjectChoices}
|
|
1266
|
+
/>
|
|
1267
|
+
{/snippet}
|
|
1268
|
+
</Field>
|
|
1269
|
+
{#if !blocked && !subjectsLoading && subjectChoices.length === 0}
|
|
1270
|
+
<p class="hint">{t('accr_subject_none', { rung: rungLabel(assignRung) })}</p>
|
|
1271
|
+
{/if}
|
|
1272
|
+
{/if}
|
|
1273
|
+
|
|
1274
|
+
{#if rungTaken}
|
|
1275
|
+
<p class="note warn">
|
|
1276
|
+
{t('accr_rung_taken', {
|
|
1277
|
+
name: rungTaken.name,
|
|
1278
|
+
subject: subjectName(assignRung, assignSubjectId || null),
|
|
1279
|
+
})}
|
|
1280
|
+
</p>
|
|
1281
|
+
{/if}
|
|
1282
|
+
|
|
1283
|
+
<div class="pair">
|
|
1284
|
+
<Field label={t('accr_effective_from')} required>
|
|
1285
|
+
{#snippet children(id)}
|
|
1286
|
+
<Input {id} type="date" bind:value={assignFrom} />
|
|
1287
|
+
{/snippet}
|
|
1288
|
+
</Field>
|
|
1289
|
+
<Field label={t('accr_effective_to')} hint={t('accr_assign_to_hint')}>
|
|
1290
|
+
{#snippet children(id)}
|
|
1291
|
+
<Input {id} type="date" bind:value={assignTo} />
|
|
1292
|
+
{/snippet}
|
|
1293
|
+
</Field>
|
|
1294
|
+
</div>
|
|
1295
|
+
|
|
1296
|
+
{#if assignError}
|
|
1297
|
+
<p class="note danger-note" role="alert">{assignError}</p>
|
|
1298
|
+
{/if}
|
|
1299
|
+
</div>
|
|
1300
|
+
|
|
1301
|
+
{#snippet footer()}
|
|
1302
|
+
{#if assignProblem}
|
|
1303
|
+
<span class="hint problem">{assignProblem}</span>
|
|
1304
|
+
{/if}
|
|
1305
|
+
<Button variant="secondary" onclick={() => (assignOpen = false)} disabled={assign.isPending}>
|
|
1306
|
+
{t('cancel')}
|
|
1307
|
+
</Button>
|
|
1308
|
+
<Button
|
|
1309
|
+
loading={assign.isPending}
|
|
1310
|
+
disabled={!manage || assignProblem !== null}
|
|
1311
|
+
onclick={() => once(() => assign.mutate())}
|
|
1312
|
+
>
|
|
1313
|
+
{t('accr_assign')}
|
|
1314
|
+
</Button>
|
|
1315
|
+
{/snippet}
|
|
1316
|
+
</Dialog>
|
|
1317
|
+
|
|
1318
|
+
<!-- ---------------------------------------------------------------- unassign -->
|
|
1319
|
+
<Dialog
|
|
1320
|
+
open={unassigning !== null}
|
|
1321
|
+
size="sm"
|
|
1322
|
+
title={unassigning
|
|
1323
|
+
? t('accr_unassign_title', {
|
|
1324
|
+
subject: subjectName(unassigning.assignment.subjectKind, unassigning.assignment.subjectId),
|
|
1325
|
+
})
|
|
1326
|
+
: ''}
|
|
1327
|
+
description={unassigning ? t('accr_unassign_body', { name: unassigning.policy.name }) : ''}
|
|
1328
|
+
onOpenChange={(open) => {
|
|
1329
|
+
if (!open) unassigning = null
|
|
1330
|
+
}}
|
|
1331
|
+
>
|
|
1332
|
+
{#if unassigning}
|
|
1333
|
+
<!-- What happens to the people underneath is the whole question, and it has two answers. -->
|
|
1334
|
+
<p class="body">
|
|
1335
|
+
{workspaceDefault && unassigning.assignment.subjectKind !== 'workspace'
|
|
1336
|
+
? t('accr_unassign_falls_back', { name: workspaceDefault.name })
|
|
1337
|
+
: t('accr_unassign_no_fallback')}
|
|
1338
|
+
</p>
|
|
1339
|
+
<p class="body sub">{t('accr_unassign_keeps')}</p>
|
|
1340
|
+
{/if}
|
|
1341
|
+
|
|
1342
|
+
{#snippet footer()}
|
|
1343
|
+
<Button variant="secondary" onclick={() => (unassigning = null)} disabled={unassign.isPending}>
|
|
1344
|
+
{t('cancel')}
|
|
1345
|
+
</Button>
|
|
1346
|
+
<Button
|
|
1347
|
+
variant="danger"
|
|
1348
|
+
loading={unassign.isPending}
|
|
1349
|
+
onclick={() => {
|
|
1350
|
+
if (unassigning) once(() => unassigning && unassign.mutate(unassigning))
|
|
1351
|
+
}}
|
|
1352
|
+
>
|
|
1353
|
+
{t('accr_unassign')}
|
|
1354
|
+
</Button>
|
|
1355
|
+
{/snippet}
|
|
1356
|
+
</Dialog>
|
|
1357
|
+
|
|
1358
|
+
<!-- ---------------------------------------------------------------- preview, then run -->
|
|
1359
|
+
<Dialog
|
|
1360
|
+
bind:open={runOpen}
|
|
1361
|
+
size="lg"
|
|
1362
|
+
title={t('accr_run_title')}
|
|
1363
|
+
description={t('accr_run_desc')}
|
|
1364
|
+
onOpenChange={(open) => {
|
|
1365
|
+
if (!open) runOpen = false
|
|
1366
|
+
}}
|
|
1367
|
+
>
|
|
1368
|
+
<div class="form">
|
|
1369
|
+
<div class="pair">
|
|
1370
|
+
<Field label={t('accr_run_from')} required>
|
|
1371
|
+
{#snippet children(id)}
|
|
1372
|
+
<Input {id} type="date" bind:value={runFrom} />
|
|
1373
|
+
{/snippet}
|
|
1374
|
+
</Field>
|
|
1375
|
+
<Field label={t('accr_run_to')} required>
|
|
1376
|
+
{#snippet children(id)}
|
|
1377
|
+
<Input {id} type="date" bind:value={runTo} />
|
|
1378
|
+
{/snippet}
|
|
1379
|
+
</Field>
|
|
1380
|
+
</div>
|
|
1381
|
+
|
|
1382
|
+
{#if !runRangeValid}
|
|
1383
|
+
<p class="hint">{t('accr_error_to_before_from')}</p>
|
|
1384
|
+
{:else if previewQuery.isLoading || previewQuery.isFetching}
|
|
1385
|
+
<div class="rows">
|
|
1386
|
+
{#each [1, 2, 3, 4] as n (n)}<Skeleton height="40px" />{/each}
|
|
1387
|
+
</div>
|
|
1388
|
+
{:else if preview}
|
|
1389
|
+
<div class="tiles">
|
|
1390
|
+
<StatTile size="md" label={t('accr_run_people')} value={formatCount(creditable.length, 999)} />
|
|
1391
|
+
<StatTile size="md" label={t('accr_run_total')} value={duration(preview.totalMinutes)} />
|
|
1392
|
+
<StatTile size="md" label={t('accr_run_skipped')} value={formatCount(preview.skipped.length, 999)} />
|
|
1393
|
+
</div>
|
|
1394
|
+
|
|
1395
|
+
{#if preview.rows.length === 0}
|
|
1396
|
+
<EmptyState
|
|
1397
|
+
icon="check-check"
|
|
1398
|
+
compact
|
|
1399
|
+
title={t('accr_run_nothing')}
|
|
1400
|
+
description={t('accr_run_nothing_desc')}
|
|
1401
|
+
/>
|
|
1402
|
+
{:else}
|
|
1403
|
+
<SectionLabel label={t('accr_run_rows')} count={preview.rows.length} sub />
|
|
1404
|
+
<div class="scroll">
|
|
1405
|
+
<ul class="plist">
|
|
1406
|
+
{#each preview.rows as row (row.personId + row.leaveTypeId)}
|
|
1407
|
+
<li class:done={row.alreadyAccrued}>
|
|
1408
|
+
<span class="pname">{row.displayName}</span>
|
|
1409
|
+
<span class="pdays num">
|
|
1410
|
+
{days(row.days)}
|
|
1411
|
+
{t('days', { count: row.days })}
|
|
1412
|
+
</span>
|
|
1413
|
+
<span class="sub ptype">{row.leaveTypeName}</span>
|
|
1414
|
+
<!-- The server's own words for why the number is what it is: it is arithmetic, not
|
|
1415
|
+
a sentence this module could translate without recomputing it. -->
|
|
1416
|
+
<span class="sub preason">{row.reason}</span>
|
|
1417
|
+
{#if row.alreadyAccrued}
|
|
1418
|
+
<Badge tone="grey">{t('accr_run_already')}</Badge>
|
|
1419
|
+
{/if}
|
|
1420
|
+
</li>
|
|
1421
|
+
{/each}
|
|
1422
|
+
</ul>
|
|
1423
|
+
</div>
|
|
1424
|
+
{/if}
|
|
1425
|
+
|
|
1426
|
+
{#if preview.skipped.length > 0}
|
|
1427
|
+
<SectionLabel label={t('accr_run_skipped')} count={preview.skipped.length} sub />
|
|
1428
|
+
<ul class="plist">
|
|
1429
|
+
{#each preview.skipped.slice(0, HEAD) as row (row.personId)}
|
|
1430
|
+
<li>
|
|
1431
|
+
<span class="pname">{row.displayName}</span>
|
|
1432
|
+
<span class="sub preason">{row.reason}</span>
|
|
1433
|
+
</li>
|
|
1434
|
+
{/each}
|
|
1435
|
+
{#if preview.skipped.length > HEAD}
|
|
1436
|
+
<li class="sub">{t('accr_run_more', { count: preview.skipped.length - HEAD })}</li>
|
|
1437
|
+
{/if}
|
|
1438
|
+
</ul>
|
|
1439
|
+
{/if}
|
|
1440
|
+
|
|
1441
|
+
{#if alreadyDone > 0}
|
|
1442
|
+
<p class="hint">{t('accr_run_idempotent', { count: alreadyDone })}</p>
|
|
1443
|
+
{/if}
|
|
1444
|
+
<p class="note warn">{t('accr_run_writes')}</p>
|
|
1445
|
+
{:else if previewQuery.isError}
|
|
1446
|
+
<EmptyState icon="triangle-alert" title={t('accr_preview_error')} description={t('accr_error_desc')}>
|
|
1447
|
+
{#snippet actions()}
|
|
1448
|
+
<Button variant="secondary" onclick={() => void previewQuery.refetch()}>{t('retry')}</Button>
|
|
1449
|
+
{/snippet}
|
|
1450
|
+
</EmptyState>
|
|
1451
|
+
{/if}
|
|
1452
|
+
</div>
|
|
1453
|
+
|
|
1454
|
+
{#snippet footer()}
|
|
1455
|
+
<Button variant="secondary" onclick={() => (runOpen = false)} disabled={runAccrual.isPending}>
|
|
1456
|
+
{t('cancel')}
|
|
1457
|
+
</Button>
|
|
1458
|
+
<!--
|
|
1459
|
+
The run cannot be reached without the preview: it is disabled until `accrual.preview` has
|
|
1460
|
+
answered with something to credit, which is the whole reason that procedure exists.
|
|
1461
|
+
-->
|
|
1462
|
+
<Button
|
|
1463
|
+
loading={runAccrual.isPending}
|
|
1464
|
+
disabled={!manage || !preview || creditable.length === 0}
|
|
1465
|
+
onclick={() => once(() => runAccrual.mutate())}
|
|
1466
|
+
>
|
|
1467
|
+
{t('accr_run_credit', { count: creditable.length })}
|
|
1468
|
+
</Button>
|
|
1469
|
+
{/snippet}
|
|
1470
|
+
</Dialog>
|
|
1471
|
+
|
|
1472
|
+
<style>
|
|
1473
|
+
.rows {
|
|
1474
|
+
display: grid;
|
|
1475
|
+
gap: 4px;
|
|
1476
|
+
}
|
|
1477
|
+
.tiles {
|
|
1478
|
+
display: grid;
|
|
1479
|
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
1480
|
+
gap: 10px;
|
|
1481
|
+
margin-block-end: 18px;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/* One grid for the header and every row, so the columns line up down the page. */
|
|
1485
|
+
.table {
|
|
1486
|
+
width: 100%;
|
|
1487
|
+
--hr-accr-cols: minmax(150px, 1.4fr) minmax(120px, 1.2fr) minmax(110px, 1fr) 56px 32px;
|
|
1488
|
+
}
|
|
1489
|
+
.thead,
|
|
1490
|
+
.trow {
|
|
1491
|
+
display: grid;
|
|
1492
|
+
grid-template-columns: var(--hr-accr-cols);
|
|
1493
|
+
gap: 10px;
|
|
1494
|
+
align-items: center;
|
|
1495
|
+
padding-inline: 10px;
|
|
1496
|
+
}
|
|
1497
|
+
.thead {
|
|
1498
|
+
height: 32px;
|
|
1499
|
+
border-block-end: 1px solid var(--kern-border);
|
|
1500
|
+
font-size: 11px;
|
|
1501
|
+
font-weight: 600;
|
|
1502
|
+
letter-spacing: 0.06em;
|
|
1503
|
+
text-transform: uppercase;
|
|
1504
|
+
color: var(--kern-ink-500);
|
|
1505
|
+
}
|
|
1506
|
+
.trow {
|
|
1507
|
+
min-height: 52px;
|
|
1508
|
+
border-block-end: 1px solid var(--kern-border-hairline);
|
|
1509
|
+
border-radius: var(--kern-r-md);
|
|
1510
|
+
}
|
|
1511
|
+
.trow:hover {
|
|
1512
|
+
background: var(--kern-surface-raised);
|
|
1513
|
+
}
|
|
1514
|
+
.cell {
|
|
1515
|
+
min-width: 0;
|
|
1516
|
+
}
|
|
1517
|
+
.what {
|
|
1518
|
+
display: grid;
|
|
1519
|
+
gap: 2px;
|
|
1520
|
+
}
|
|
1521
|
+
.strong {
|
|
1522
|
+
min-width: 0;
|
|
1523
|
+
overflow: hidden;
|
|
1524
|
+
text-overflow: ellipsis;
|
|
1525
|
+
white-space: nowrap;
|
|
1526
|
+
font-size: 13.5px;
|
|
1527
|
+
font-weight: 500;
|
|
1528
|
+
color: var(--kern-ink-900);
|
|
1529
|
+
}
|
|
1530
|
+
/* Muted with a colour, never opacity: opacity fades text against the page whatever token it names. */
|
|
1531
|
+
.sub {
|
|
1532
|
+
min-width: 0;
|
|
1533
|
+
overflow: hidden;
|
|
1534
|
+
text-overflow: ellipsis;
|
|
1535
|
+
font-size: 12px;
|
|
1536
|
+
color: var(--kern-ink-500);
|
|
1537
|
+
}
|
|
1538
|
+
.chips {
|
|
1539
|
+
display: inline-flex;
|
|
1540
|
+
align-items: center;
|
|
1541
|
+
gap: 6px;
|
|
1542
|
+
}
|
|
1543
|
+
.num {
|
|
1544
|
+
font-variant-numeric: tabular-nums;
|
|
1545
|
+
}
|
|
1546
|
+
.actions {
|
|
1547
|
+
display: flex;
|
|
1548
|
+
justify-content: flex-end;
|
|
1549
|
+
overflow: visible;
|
|
1550
|
+
}
|
|
1551
|
+
.danger {
|
|
1552
|
+
color: var(--kern-danger);
|
|
1553
|
+
}
|
|
1554
|
+
.warn-text {
|
|
1555
|
+
color: var(--kern-warning);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
.ladder {
|
|
1559
|
+
display: grid;
|
|
1560
|
+
gap: 16px;
|
|
1561
|
+
}
|
|
1562
|
+
.rung {
|
|
1563
|
+
display: grid;
|
|
1564
|
+
gap: 2px;
|
|
1565
|
+
padding-inline-start: 10px;
|
|
1566
|
+
border-inline-start: 2px solid var(--kern-accent);
|
|
1567
|
+
}
|
|
1568
|
+
/* A rung nobody uses still belongs on the ladder — it is drawn quieter, not dropped, because the
|
|
1569
|
+
gap between two rungs is itself the thing an admin is reading. */
|
|
1570
|
+
.rung.empty {
|
|
1571
|
+
border-inline-start-color: var(--kern-border);
|
|
1572
|
+
}
|
|
1573
|
+
.rlist {
|
|
1574
|
+
display: grid;
|
|
1575
|
+
gap: 2px;
|
|
1576
|
+
margin: 4px 0 0;
|
|
1577
|
+
padding: 0;
|
|
1578
|
+
list-style: none;
|
|
1579
|
+
}
|
|
1580
|
+
.rlist li {
|
|
1581
|
+
display: grid;
|
|
1582
|
+
grid-template-columns: minmax(140px, 1.4fr) minmax(100px, 1fr) minmax(110px, 0.9fr) 32px;
|
|
1583
|
+
gap: 10px;
|
|
1584
|
+
align-items: center;
|
|
1585
|
+
min-height: 36px;
|
|
1586
|
+
min-width: 0;
|
|
1587
|
+
padding-inline: 8px;
|
|
1588
|
+
border-radius: var(--kern-r-md);
|
|
1589
|
+
}
|
|
1590
|
+
.rlist li:hover {
|
|
1591
|
+
background: var(--kern-surface-raised);
|
|
1592
|
+
}
|
|
1593
|
+
.rsubject {
|
|
1594
|
+
display: flex;
|
|
1595
|
+
align-items: center;
|
|
1596
|
+
gap: 8px;
|
|
1597
|
+
min-width: 0;
|
|
1598
|
+
}
|
|
1599
|
+
.rpolicy {
|
|
1600
|
+
min-width: 0;
|
|
1601
|
+
overflow: hidden;
|
|
1602
|
+
text-overflow: ellipsis;
|
|
1603
|
+
white-space: nowrap;
|
|
1604
|
+
font-size: 13px;
|
|
1605
|
+
color: var(--kern-ink-700);
|
|
1606
|
+
}
|
|
1607
|
+
.rwhen {
|
|
1608
|
+
white-space: nowrap;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
.hint {
|
|
1612
|
+
margin: 0;
|
|
1613
|
+
font-size: 12px;
|
|
1614
|
+
color: var(--kern-ink-500);
|
|
1615
|
+
}
|
|
1616
|
+
.hint.spaced {
|
|
1617
|
+
margin-block-start: 10px;
|
|
1618
|
+
}
|
|
1619
|
+
/* The reason a footer button is disabled sits beside it and takes the slack, so a long sentence
|
|
1620
|
+
pushes nothing off the end of the row. */
|
|
1621
|
+
.problem {
|
|
1622
|
+
flex: 1;
|
|
1623
|
+
min-width: 0;
|
|
1624
|
+
text-wrap: pretty;
|
|
1625
|
+
}
|
|
1626
|
+
.note {
|
|
1627
|
+
margin: 0;
|
|
1628
|
+
padding: 10px 12px;
|
|
1629
|
+
border-radius: var(--kern-r-md2);
|
|
1630
|
+
background: var(--kern-info-tint);
|
|
1631
|
+
color: var(--kern-ink-700);
|
|
1632
|
+
font-size: 12.5px;
|
|
1633
|
+
line-height: 1.5;
|
|
1634
|
+
}
|
|
1635
|
+
.note.warn {
|
|
1636
|
+
background: var(--kern-warning-tint);
|
|
1637
|
+
color: var(--kern-warning);
|
|
1638
|
+
}
|
|
1639
|
+
.danger-note {
|
|
1640
|
+
background: var(--kern-danger-tint);
|
|
1641
|
+
color: var(--kern-danger);
|
|
1642
|
+
}
|
|
1643
|
+
.stale {
|
|
1644
|
+
display: flex;
|
|
1645
|
+
align-items: center;
|
|
1646
|
+
justify-content: space-between;
|
|
1647
|
+
flex-wrap: wrap;
|
|
1648
|
+
gap: 8px;
|
|
1649
|
+
margin: 0;
|
|
1650
|
+
padding-block: 6px;
|
|
1651
|
+
padding-inline: 12px 8px;
|
|
1652
|
+
border-radius: var(--kern-r-md);
|
|
1653
|
+
background: var(--kern-warning-tint);
|
|
1654
|
+
color: var(--kern-warning);
|
|
1655
|
+
font-size: 12.5px;
|
|
1656
|
+
}
|
|
1657
|
+
.body {
|
|
1658
|
+
margin: 0 0 4px;
|
|
1659
|
+
font-size: 13.5px;
|
|
1660
|
+
}
|
|
1661
|
+
.link {
|
|
1662
|
+
color: var(--kern-accent-text);
|
|
1663
|
+
text-decoration: underline;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
.form {
|
|
1667
|
+
display: grid;
|
|
1668
|
+
gap: 14px;
|
|
1669
|
+
}
|
|
1670
|
+
.pair {
|
|
1671
|
+
display: grid;
|
|
1672
|
+
grid-template-columns: 1fr 1fr;
|
|
1673
|
+
gap: 12px;
|
|
1674
|
+
align-items: start;
|
|
1675
|
+
}
|
|
1676
|
+
.beaten {
|
|
1677
|
+
display: flex;
|
|
1678
|
+
align-items: center;
|
|
1679
|
+
flex-wrap: wrap;
|
|
1680
|
+
gap: 8px;
|
|
1681
|
+
}
|
|
1682
|
+
.tierbox {
|
|
1683
|
+
display: grid;
|
|
1684
|
+
gap: 4px;
|
|
1685
|
+
}
|
|
1686
|
+
.tiers {
|
|
1687
|
+
display: grid;
|
|
1688
|
+
gap: 8px;
|
|
1689
|
+
margin: 4px 0 0;
|
|
1690
|
+
padding: 0;
|
|
1691
|
+
list-style: none;
|
|
1692
|
+
}
|
|
1693
|
+
.tiers li {
|
|
1694
|
+
display: grid;
|
|
1695
|
+
grid-template-columns: 1fr 1fr 32px;
|
|
1696
|
+
gap: 10px;
|
|
1697
|
+
align-items: end;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
.scroll {
|
|
1701
|
+
max-block-size: 300px;
|
|
1702
|
+
overflow-y: auto;
|
|
1703
|
+
overflow-x: auto;
|
|
1704
|
+
}
|
|
1705
|
+
.plist {
|
|
1706
|
+
display: grid;
|
|
1707
|
+
gap: 2px;
|
|
1708
|
+
margin: 0;
|
|
1709
|
+
padding: 0;
|
|
1710
|
+
list-style: none;
|
|
1711
|
+
}
|
|
1712
|
+
.plist li {
|
|
1713
|
+
display: grid;
|
|
1714
|
+
grid-template-columns: minmax(120px, 1fr) 72px minmax(80px, 0.8fr) minmax(120px, 1.4fr) auto;
|
|
1715
|
+
gap: 10px;
|
|
1716
|
+
align-items: baseline;
|
|
1717
|
+
min-width: 0;
|
|
1718
|
+
padding-block: 4px;
|
|
1719
|
+
font-size: 13px;
|
|
1720
|
+
}
|
|
1721
|
+
/* A row already credited is still shown — that is the idempotence made visible — and it is drawn
|
|
1722
|
+
quieter with a colour rather than faded with opacity. */
|
|
1723
|
+
.plist li.done .pname {
|
|
1724
|
+
color: var(--kern-ink-500);
|
|
1725
|
+
}
|
|
1726
|
+
.pname {
|
|
1727
|
+
min-width: 0;
|
|
1728
|
+
overflow: hidden;
|
|
1729
|
+
text-overflow: ellipsis;
|
|
1730
|
+
white-space: nowrap;
|
|
1731
|
+
}
|
|
1732
|
+
.pdays {
|
|
1733
|
+
white-space: nowrap;
|
|
1734
|
+
}
|
|
1735
|
+
.ptype,
|
|
1736
|
+
.preason {
|
|
1737
|
+
white-space: nowrap;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
.sr-only {
|
|
1741
|
+
position: absolute;
|
|
1742
|
+
width: 1px;
|
|
1743
|
+
height: 1px;
|
|
1744
|
+
overflow: hidden;
|
|
1745
|
+
clip-path: inset(50%);
|
|
1746
|
+
white-space: nowrap;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
@media (max-width: 640px) {
|
|
1750
|
+
.table {
|
|
1751
|
+
--hr-accr-cols: minmax(140px, 1fr) minmax(100px, 0.9fr) 32px;
|
|
1752
|
+
}
|
|
1753
|
+
/* The frequency and the assignment count go; what a policy earns and who it names cannot. */
|
|
1754
|
+
.thead > :nth-child(3),
|
|
1755
|
+
.trow > :nth-child(3),
|
|
1756
|
+
.thead > :nth-child(4),
|
|
1757
|
+
.trow > :nth-child(4) {
|
|
1758
|
+
display: none;
|
|
1759
|
+
}
|
|
1760
|
+
/* Both lists stop being tables and become wrapped lines: the effective range and the server's
|
|
1761
|
+
arithmetic are the parts a narrow screen can lose without losing the answer. */
|
|
1762
|
+
.rlist li,
|
|
1763
|
+
.plist li {
|
|
1764
|
+
display: flex;
|
|
1765
|
+
flex-wrap: wrap;
|
|
1766
|
+
gap: 4px 10px;
|
|
1767
|
+
align-items: center;
|
|
1768
|
+
}
|
|
1769
|
+
.rwhen,
|
|
1770
|
+
.ptype,
|
|
1771
|
+
.preason {
|
|
1772
|
+
display: none;
|
|
1773
|
+
}
|
|
1774
|
+
.pair,
|
|
1775
|
+
.tiers li {
|
|
1776
|
+
grid-template-columns: 1fr;
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
</style>
|