@kernhq/module-hr 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract/attendance.d.ts +35 -0
- package/dist/contract/attendance.d.ts.map +1 -1
- package/dist/contract/attendance.js +25 -1
- package/dist/contract/attendance.js.map +1 -1
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +10 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/events.d.ts +17 -16
- package/dist/contract/events.d.ts.map +1 -1
- package/dist/contract/events.js +23 -16
- package/dist/contract/events.js.map +1 -1
- package/dist/contract/models.d.ts +1 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +11 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/router.d.ts +12 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +11 -3
- package/dist/contract/router.js.map +1 -1
- package/dist/contract/settings.d.ts +0 -1
- package/dist/contract/settings.d.ts.map +1 -1
- package/dist/contract/settings.js +11 -5
- package/dist/contract/settings.js.map +1 -1
- package/dist/server/index.d.ts +0 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +30 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +13 -5
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/router.d.ts +12 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +374 -76
- package/dist/server/router.js.map +1 -1
- package/dist/server/services/access.d.ts +176 -0
- package/dist/server/services/access.d.ts.map +1 -0
- package/dist/server/services/access.js +260 -0
- package/dist/server/services/access.js.map +1 -0
- package/dist/server/services/people.d.ts +1 -0
- package/dist/server/services/people.d.ts.map +1 -1
- package/dist/server/services/people.js +3 -0
- package/dist/server/services/people.js.map +1 -1
- package/package.json +1 -1
- package/src/client/components/DayDetail.svelte +32 -14
- package/src/client/components/PersonPanel.svelte +66 -5
- package/src/client/components/redaction.ts +51 -0
- package/src/client/messages.ts +346 -77
- package/src/client/pages/AttendancePage.svelte +24 -1
- package/src/client/pages/DirectoryPage.svelte +285 -11
- package/src/client/pages/OfficesPage.svelte +6 -0
- package/src/client/pages/OrgPage.svelte +7 -0
- package/src/client/permissions.ts +12 -4
- package/src/client/settings/AccrualSettings.svelte +715 -95
- package/src/client/settings/GeneralSettings.svelte +12 -9
- package/src/client/settings/SchedulesSettings.svelte +31 -78
- package/src/contract/attendance.ts +27 -1
- package/src/contract/capabilities.ts +10 -0
- package/src/contract/events.ts +23 -19
- package/src/contract/models.ts +11 -0
- package/src/contract/router.ts +11 -3
- package/src/contract/settings.ts +11 -5
|
@@ -30,8 +30,10 @@ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-qu
|
|
|
30
30
|
// `AccrualConfig` is imported as a value on purpose — see `configOf`.
|
|
31
31
|
import {
|
|
32
32
|
AccrualConfig,
|
|
33
|
+
CarryForwardConfig,
|
|
33
34
|
type Policy,
|
|
34
35
|
type PolicyAssignment,
|
|
36
|
+
type PolicyKind,
|
|
35
37
|
type PolicySubjectKind,
|
|
36
38
|
SUBJECT_PRIORITY,
|
|
37
39
|
} from '../../contract/policies.js'
|
|
@@ -46,7 +48,7 @@ import { formatDays, formatDuration, hrKeys, isoDate, monthRange } from '../quer
|
|
|
46
48
|
*
|
|
47
49
|
* Switching `leave_accrual` on used to start an hourly job and give nobody a way to look at it: the
|
|
48
50
|
* policies existed, the ladder existed, the preview existed, and none of the three had a caller. So
|
|
49
|
-
*
|
|
51
|
+
* four things justify this screen, and everything on it serves one of them.
|
|
50
52
|
*
|
|
51
53
|
* **A policy is data, and the data has to say when leave appears.** All four frequencies are
|
|
52
54
|
* implemented and each answers a different question — a twelfth every month, the whole year on one
|
|
@@ -65,6 +67,14 @@ import { formatDays, formatDuration, hrKeys, isoDate, monthRange } from '../quer
|
|
|
65
67
|
* the run button lives inside the preview dialog and is disabled until there are numbers on screen.
|
|
66
68
|
* A credit cannot be undone from here — it is corrected with a leave adjustment — which is exactly
|
|
67
69
|
* why an admin gets to read it first.
|
|
70
|
+
*
|
|
71
|
+
* **What is earned has to survive the turn of the year, and that is a second policy.** The engine
|
|
72
|
+
* has always carried a capped balance forward and expired the rest — `carryForward`, the
|
|
73
|
+
* `carry-forward` job, and `carry_in` / `carry_out` / `expiry` in the ledger — but a
|
|
74
|
+
* `carry_forward` policy could not be written from anywhere, so the capability card promised
|
|
75
|
+
* carry-forward and expiry and an admin could reach neither. Both kinds are `policies` rows on one
|
|
76
|
+
* ladder, resolved independently of each other, so they share this screen's assign dialog and are
|
|
77
|
+
* told apart by a badge rather than by two ladders that would each hide half the answer.
|
|
68
78
|
*/
|
|
69
79
|
const api = getHrApi()
|
|
70
80
|
const queryClient = useQueryClient()
|
|
@@ -105,6 +115,16 @@ const freqLabel = (frequency: Frequency): string =>
|
|
|
105
115
|
? t('accr_freq_anniversary')
|
|
106
116
|
: t('accr_freq_per_hour')
|
|
107
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Which of the two kinds a row is.
|
|
120
|
+
*
|
|
121
|
+
* Only `accrual` and `carry_forward` ever reach this screen: both queries below name their kind, so
|
|
122
|
+
* nothing else is ever in the arrays this labels. A third kind shown here would read as "Accrual"
|
|
123
|
+
* and would have to be given its own branch on the way in.
|
|
124
|
+
*/
|
|
125
|
+
const kindLabel = (kind: PolicyKind): string =>
|
|
126
|
+
kind === 'carry_forward' ? t('policy_kind_carry_forward') : t('policy_kind_accrual')
|
|
127
|
+
|
|
108
128
|
/** What the frequency actually does. Copied from `grantForPeriod`, which is what performs it. */
|
|
109
129
|
const freqDesc = (frequency: Frequency): string =>
|
|
110
130
|
frequency === 'monthly'
|
|
@@ -175,12 +195,6 @@ const policies = $derived((policiesQuery.data ?? []) as PolicyRow[])
|
|
|
175
195
|
* the first frame offers "no policies yet" to a workspace that has six.
|
|
176
196
|
*/
|
|
177
197
|
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
198
|
|
|
185
199
|
/**
|
|
186
200
|
* The config, parsed rather than cast.
|
|
@@ -195,6 +209,40 @@ function configOf(policy: PolicyRow): AccrualConfig | null {
|
|
|
195
209
|
return parsed.success ? parsed.data : null
|
|
196
210
|
}
|
|
197
211
|
|
|
212
|
+
// ---------------------------------------------------------------- carry-forward
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The other kind of policy on this screen, asked for separately.
|
|
216
|
+
*
|
|
217
|
+
* One list per kind rather than one call filtered here: `policies.list` takes the kind, the two
|
|
218
|
+
* lists are drawn in two sections, and a single query would make the accrual tiles count
|
|
219
|
+
* carry-forward rows. `showArchived` is shared, because it is one control at the top of the page.
|
|
220
|
+
*/
|
|
221
|
+
const carryQuery = createQuery(() => ({
|
|
222
|
+
queryKey: ['hr', 'policies', workspaceId, 'carry_forward', showArchived] as const,
|
|
223
|
+
enabled: Boolean(workspaceId),
|
|
224
|
+
queryFn: () => api.policies.list({ workspaceId, kind: 'carry_forward', includeArchived: showArchived }),
|
|
225
|
+
}))
|
|
226
|
+
const carryPolicies = $derived((carryQuery.data ?? []) as PolicyRow[])
|
|
227
|
+
const carryLoading = $derived(!workspaceId || carryQuery.isLoading)
|
|
228
|
+
|
|
229
|
+
/** Same reason as `configOf`: the stored jsonb is only a carry-forward config if it parses as one. */
|
|
230
|
+
function carryConfigOf(policy: PolicyRow): CarryForwardConfig | null {
|
|
231
|
+
const parsed = CarryForwardConfig.safeParse(policy.config)
|
|
232
|
+
return parsed.success ? parsed.data : null
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A failed refetch that still has an answer, on either list.
|
|
237
|
+
*
|
|
238
|
+
* Every write here invalidates the whole module, so a refetch failing while the last good lists are
|
|
239
|
+
* on screen is the ordinary case — an error branch above the data would blank a working page. One
|
|
240
|
+
* banner for both, because a reader is being told the page is behind, not which query said so.
|
|
241
|
+
*/
|
|
242
|
+
const stale = $derived(
|
|
243
|
+
(policiesQuery.isError && policies.length > 0) || (carryQuery.isError && carryPolicies.length > 0),
|
|
244
|
+
)
|
|
245
|
+
|
|
198
246
|
const leaveTypesQuery = createQuery(() => ({
|
|
199
247
|
queryKey: hrKeys.leaveTypes(workspaceId),
|
|
200
248
|
enabled: Boolean(workspaceId) && canHr('leaveView'),
|
|
@@ -204,10 +252,30 @@ const leaveTypes = $derived(leaveTypesQuery.data ?? [])
|
|
|
204
252
|
const leaveTypeName = (key: string): string => leaveTypes.find((type) => type.key === key)?.name ?? key
|
|
205
253
|
|
|
206
254
|
const liveAssignments = $derived(policies.filter((p) => !p.archivedAt).flatMap((p) => p.assignments))
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The rung everybody with nothing nearer falls to, per kind. Its absence is the interesting case,
|
|
258
|
+
* and it is a different question for each kind: a workspace can accrue without carrying anything
|
|
259
|
+
* over, and the two gaps are said in two different places rather than in one sentence about
|
|
260
|
+
* "policies" that would be half wrong whichever kind was missing.
|
|
261
|
+
*/
|
|
262
|
+
const defaultOn = (rows: PolicyRow[]) =>
|
|
263
|
+
rows.find((p) => !p.archivedAt && p.assignments.some((a) => a.subjectKind === 'workspace')) ?? null
|
|
264
|
+
const workspaceDefault = $derived(defaultOn(policies))
|
|
265
|
+
const carryDefault = $derived(defaultOn(carryPolicies))
|
|
266
|
+
|
|
267
|
+
const livePolicies = $derived(policies.filter((policy) => !policy.archivedAt))
|
|
268
|
+
const liveCarry = $derived(carryPolicies.filter((policy) => !policy.archivedAt))
|
|
269
|
+
/**
|
|
270
|
+
* Both kinds on one ladder.
|
|
271
|
+
*
|
|
272
|
+
* They are resolved independently — a person can be answered by their office's accrual policy and
|
|
273
|
+
* the workspace's carry-forward policy at once — so this is not a list of rivals. It is drawn as
|
|
274
|
+
* one ladder because the *rungs* are the same six and the precedence is the same rule, and two
|
|
275
|
+
* ladders side by side would each hide half of what applies to somebody.
|
|
276
|
+
*/
|
|
277
|
+
const ladderPolicies = $derived([...livePolicies, ...liveCarry])
|
|
278
|
+
const ladderAssignments = $derived(ladderPolicies.flatMap((policy) => policy.assignments))
|
|
211
279
|
|
|
212
280
|
/**
|
|
213
281
|
* A policy change moves leave balances, the accrual preview and every resolution beneath it, so the
|
|
@@ -270,6 +338,19 @@ function failureText(error: unknown, fallbackKey: string): string {
|
|
|
270
338
|
|
|
271
339
|
// ---------------------------------------------------------------- the policy form
|
|
272
340
|
|
|
341
|
+
/**
|
|
342
|
+
* What an empty form starts a day at, and the only eight hours on this screen.
|
|
343
|
+
*
|
|
344
|
+
* A seed, never a conversion. `accrueForPeriod` multiplies entitlement days by the policy's own
|
|
345
|
+
* `minutesPerDay`, so anything here that turns time into days — or days into time — reads that
|
|
346
|
+
* figure instead of assuming one: five days of a seven-and-a-half-hour day is 2250 minutes, and at
|
|
347
|
+
* a hardcoded 8 × 60 the same five days read as 2400, which is a cap that never bites and a
|
|
348
|
+
* balance nobody can reconcile against the ledger.
|
|
349
|
+
*/
|
|
350
|
+
const DEFAULT_DAY_MINUTES = 8 * 60
|
|
351
|
+
/** The contract's own ceiling on `roundToMinutes`. A whole day exceeds it on any day past eight hours. */
|
|
352
|
+
const ROUND_CAP_MINUTES = 480
|
|
353
|
+
|
|
273
354
|
let policyDialog = $state<'create' | 'edit' | null>(null)
|
|
274
355
|
let policyId = $state('')
|
|
275
356
|
let policyName = $state('')
|
|
@@ -278,11 +359,30 @@ let policyTo = $state('')
|
|
|
278
359
|
let frequency = $state<Frequency>('monthly')
|
|
279
360
|
let leaveTypeKey = $state('')
|
|
280
361
|
let daysPerYear = $state('20')
|
|
281
|
-
let minutesPerDay = $state(
|
|
362
|
+
let minutesPerDay = $state(String(DEFAULT_DAY_MINUTES))
|
|
282
363
|
let waitingMonths = $state('0')
|
|
283
|
-
|
|
364
|
+
/** A token rather than a minute count — see `roundMinutes`. */
|
|
365
|
+
let roundChoice = $state('0')
|
|
284
366
|
let tiers = $state<Tier[]>([])
|
|
367
|
+
/**
|
|
368
|
+
* Carried through the form rather than shown in it.
|
|
369
|
+
*
|
|
370
|
+
* `AccrualConfig.calendar` decides where a period boundary falls — Iran accrues on Jalali months —
|
|
371
|
+
* and nothing on this screen sets it yet. Omitting it from the draft is not neutral: the schema
|
|
372
|
+
* defaults it, so saving a name change on a Persian-calendar policy would silently move it to
|
|
373
|
+
* Gregorian. It is read back and written out untouched until there is a control for it.
|
|
374
|
+
*/
|
|
375
|
+
let policyCalendar = $state<AccrualConfig['calendar']>('gregorian')
|
|
285
376
|
let policyError = $state<string | null>(null)
|
|
377
|
+
/**
|
|
378
|
+
* Whether the form was seeded from a config the schema could not read.
|
|
379
|
+
*
|
|
380
|
+
* `configOf` answering null means the stored jsonb is not an accrual config — an older release, or
|
|
381
|
+
* a row edited by hand. Every field below then holds a default, and saving writes those defaults
|
|
382
|
+
* over whatever the engine is actually using, so the dialog says so rather than presenting eight
|
|
383
|
+
* hours and twenty days as though they were the policy.
|
|
384
|
+
*/
|
|
385
|
+
let seededFromUnreadable = $state(false)
|
|
286
386
|
|
|
287
387
|
function openCreate() {
|
|
288
388
|
policyDialog = 'create'
|
|
@@ -293,27 +393,36 @@ function openCreate() {
|
|
|
293
393
|
frequency = 'monthly'
|
|
294
394
|
leaveTypeKey = leaveTypes[0]?.key ?? ''
|
|
295
395
|
daysPerYear = '20'
|
|
296
|
-
minutesPerDay =
|
|
396
|
+
minutesPerDay = String(DEFAULT_DAY_MINUTES)
|
|
297
397
|
waitingMonths = '0'
|
|
298
|
-
|
|
398
|
+
roundChoice = '0'
|
|
299
399
|
tiers = []
|
|
400
|
+
policyCalendar = 'gregorian'
|
|
300
401
|
policyError = null
|
|
402
|
+
seededFromUnreadable = false
|
|
301
403
|
}
|
|
302
404
|
|
|
303
405
|
function fillFrom(policy: PolicyRow) {
|
|
304
406
|
const config = configOf(policy)
|
|
407
|
+
seededFromUnreadable = config === null
|
|
305
408
|
policyName = policy.name
|
|
306
409
|
policyTo = policy.effectiveTo ?? ''
|
|
307
410
|
frequency = config?.frequency ?? 'monthly'
|
|
308
411
|
leaveTypeKey = config?.leaveTypeKey ?? leaveTypes[0]?.key ?? ''
|
|
309
412
|
daysPerYear = String(config?.daysPerYear ?? 20)
|
|
310
|
-
|
|
413
|
+
const day = config?.minutesPerDay ?? DEFAULT_DAY_MINUTES
|
|
414
|
+
minutesPerDay = String(day)
|
|
311
415
|
waitingMonths = String(config?.waitingPeriodMonths ?? 0)
|
|
312
|
-
|
|
416
|
+
// Back to what the step *meant* under the day it was saved with. Read as a bare number, a policy
|
|
417
|
+
// rounding to a whole 450-minute day would reopen as "450 minutes" and stop following the day the
|
|
418
|
+
// moment somebody lengthened it.
|
|
419
|
+
const step = config?.roundToMinutes ?? 0
|
|
420
|
+
roundChoice = step === 0 ? '0' : step === day ? 'day' : step === Math.round(day / 2) ? 'half' : String(step)
|
|
313
421
|
tiers = (config?.seniorityTiers ?? []).map((tier) => ({
|
|
314
422
|
afterYears: String(tier.afterYears),
|
|
315
423
|
daysPerYear: String(tier.daysPerYear),
|
|
316
424
|
}))
|
|
425
|
+
policyCalendar = config?.calendar ?? 'gregorian'
|
|
317
426
|
policyError = null
|
|
318
427
|
}
|
|
319
428
|
|
|
@@ -346,33 +455,56 @@ const clampedDays = (value: string, min: number, max: number) => {
|
|
|
346
455
|
}
|
|
347
456
|
|
|
348
457
|
/**
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
458
|
+
* The day this policy is being given, and the figure everything below converts against.
|
|
459
|
+
*
|
|
460
|
+
* The same number the server stores as `minutesPerDay` and `accrueForPeriod` multiplies by, so a
|
|
461
|
+
* length shown here is the length leave is actually earned in.
|
|
462
|
+
*/
|
|
463
|
+
const dayMinutes = $derived(clamped(minutesPerDay, 1, 1440))
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Rounding held as what the admin meant, resolved against the day beside it.
|
|
467
|
+
*
|
|
468
|
+
* "A whole day" is 450 minutes on a seven-and-a-half-hour day and 480 on an eight-hour one. Held as
|
|
469
|
+
* a bare number the step stops meaning a day the moment the day length changes — silently, because
|
|
470
|
+
* the select goes on reading "A whole day" while the engine rounds to something else. So the choice
|
|
471
|
+
* is a token and the minutes follow from it.
|
|
472
|
+
*/
|
|
473
|
+
const roundMinutes = $derived(
|
|
474
|
+
roundChoice === 'day'
|
|
475
|
+
? dayMinutes
|
|
476
|
+
: roundChoice === 'half'
|
|
477
|
+
? Math.round(dayMinutes / 2)
|
|
478
|
+
: Math.max(0, Math.round(Number(roundChoice) || 0)),
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Rounding offered as the steps somebody actually means, half and whole days included.
|
|
483
|
+
*
|
|
484
|
+
* The two day steps are kept beside the fixed ones even where they land on the same number, because
|
|
485
|
+
* they are a different choice: one follows the day length, the other stays where it was typed. A
|
|
486
|
+
* whole day past the contract's cap is offered and refused by `formProblem` with the reason next to
|
|
487
|
+
* the button, rather than dropped from the list with nothing to explain the gap.
|
|
353
488
|
*/
|
|
354
489
|
const roundOptions = $derived.by(() => {
|
|
355
|
-
const
|
|
356
|
-
const
|
|
357
|
-
{ minutes:
|
|
358
|
-
{ minutes:
|
|
359
|
-
{ minutes:
|
|
360
|
-
{
|
|
361
|
-
{ minutes:
|
|
362
|
-
|
|
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
|
-
}
|
|
490
|
+
const half = Math.round(dayMinutes / 2)
|
|
491
|
+
const options = [
|
|
492
|
+
{ value: '0', minutes: 0, label: t('accr_round_exact') },
|
|
493
|
+
{ value: '15', minutes: 15, label: t('accr_round_minutes', { count: 15 }) },
|
|
494
|
+
{ value: '30', minutes: 30, label: t('accr_round_minutes', { count: 30 }) },
|
|
495
|
+
{ value: '60', minutes: 60, label: t('accr_round_minutes', { count: 60 }) },
|
|
496
|
+
{ value: 'half', minutes: half, label: t('accr_round_half_day', { length: duration(half) }) },
|
|
497
|
+
{ value: 'day', minutes: dayMinutes, label: t('accr_round_day', { length: duration(dayMinutes) }) },
|
|
498
|
+
]
|
|
371
499
|
// A stored step none of these produce would otherwise vanish the moment somebody opened the
|
|
372
500
|
// policy to change its name.
|
|
373
|
-
if (!options.some((option) => option.value ===
|
|
374
|
-
options.push({
|
|
375
|
-
|
|
501
|
+
if (!options.some((option) => option.value === roundChoice))
|
|
502
|
+
options.push({
|
|
503
|
+
value: roundChoice,
|
|
504
|
+
minutes: roundMinutes,
|
|
505
|
+
label: t('accr_round_minutes', { count: roundMinutes }),
|
|
506
|
+
})
|
|
507
|
+
return options.sort((a, b) => a.minutes - b.minutes).map(({ value, label }) => ({ value, label }))
|
|
376
508
|
})
|
|
377
509
|
|
|
378
510
|
const tierYears = $derived(tiers.map((tier) => clamped(tier.afterYears, 0, 60)))
|
|
@@ -384,19 +516,24 @@ const formProblem = $derived.by(() => {
|
|
|
384
516
|
if (!policyFrom) return t('accr_error_from')
|
|
385
517
|
if (policyTo && policyTo < policyFrom) return t('accr_error_to_before_from')
|
|
386
518
|
if (tiersClash) return t('accr_error_tier_clash')
|
|
519
|
+
// Said rather than clamped: a step quietly cut back to eight hours is a policy rounding to
|
|
520
|
+
// something other than the day it names.
|
|
521
|
+
if (roundMinutes > ROUND_CAP_MINUTES) return t('accr_error_round_cap', { max: duration(ROUND_CAP_MINUTES) })
|
|
387
522
|
return null
|
|
388
523
|
})
|
|
389
524
|
|
|
390
525
|
const configDraft = $derived({
|
|
391
526
|
frequency,
|
|
392
527
|
daysPerYear: clampedDays(daysPerYear, 0, 365),
|
|
393
|
-
minutesPerDay:
|
|
528
|
+
minutesPerDay: dayMinutes,
|
|
394
529
|
seniorityTiers: tiers.map((tier) => ({
|
|
395
530
|
afterYears: clamped(tier.afterYears, 0, 60),
|
|
396
531
|
daysPerYear: clampedDays(tier.daysPerYear, 0, 365),
|
|
397
532
|
})),
|
|
398
533
|
waitingPeriodMonths: clamped(waitingMonths, 0, 24),
|
|
399
|
-
|
|
534
|
+
// Save is blocked above the cap, so this never silently shortens a step somebody chose.
|
|
535
|
+
roundToMinutes: Math.min(roundMinutes, ROUND_CAP_MINUTES),
|
|
536
|
+
calendar: policyCalendar,
|
|
400
537
|
leaveTypeKey,
|
|
401
538
|
})
|
|
402
539
|
|
|
@@ -468,11 +605,150 @@ function policyMenu(policy: PolicyRow): MenuItem[] {
|
|
|
468
605
|
]
|
|
469
606
|
}
|
|
470
607
|
|
|
608
|
+
// ---------------------------------------------------------------- the carry-forward form
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* What survives the turn of the year, and how long there is to use it.
|
|
612
|
+
*
|
|
613
|
+
* Three fields, and each of them is something the engine already reads: `maxDays` is the cap
|
|
614
|
+
* `carryForward` clips the closing balance to, `expiresAfterMonths` is what `carryExpiryDate`
|
|
615
|
+
* counts from 1 January, and `leaveTypeKey` is the balance both apply to. Nothing here is invented
|
|
616
|
+
* — a fourth field would be a setting nothing obeys, which is the defect this screen exists to
|
|
617
|
+
* repair rather than repeat.
|
|
618
|
+
*
|
|
619
|
+
* The deadline is held as a switch and a number rather than as a nullable number, because "never"
|
|
620
|
+
* and "one month" are the two answers an admin actually has and a blank box does not say which of
|
|
621
|
+
* them it means.
|
|
622
|
+
*/
|
|
623
|
+
let carryDialog = $state<'create' | 'edit' | null>(null)
|
|
624
|
+
let carryId = $state('')
|
|
625
|
+
let carryName = $state('')
|
|
626
|
+
let carryTypeKey = $state('')
|
|
627
|
+
let carryMaxDays = $state('5')
|
|
628
|
+
let carryExpires = $state(false)
|
|
629
|
+
let carryMonths = $state('3')
|
|
630
|
+
let carryFrom = $state(isoDate())
|
|
631
|
+
let carryTo = $state('')
|
|
632
|
+
let carryError = $state<string | null>(null)
|
|
633
|
+
/** Same trap as `seededFromUnreadable`, for the other schema. */
|
|
634
|
+
let carrySeededFromUnreadable = $state(false)
|
|
635
|
+
|
|
636
|
+
function openCarryCreate() {
|
|
637
|
+
carryDialog = 'create'
|
|
638
|
+
carryId = ''
|
|
639
|
+
carryName = ''
|
|
640
|
+
carryTypeKey = leaveTypes[0]?.key ?? ''
|
|
641
|
+
carryMaxDays = '5'
|
|
642
|
+
carryExpires = false
|
|
643
|
+
carryMonths = '3'
|
|
644
|
+
carryFrom = `${new Date().getFullYear()}-01-01`
|
|
645
|
+
carryTo = ''
|
|
646
|
+
carryError = null
|
|
647
|
+
carrySeededFromUnreadable = false
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function fillCarryFrom(policy: PolicyRow) {
|
|
651
|
+
const config = carryConfigOf(policy)
|
|
652
|
+
carrySeededFromUnreadable = config === null
|
|
653
|
+
carryName = policy.name
|
|
654
|
+
carryTo = policy.effectiveTo ?? ''
|
|
655
|
+
carryTypeKey = config?.leaveTypeKey ?? leaveTypes[0]?.key ?? ''
|
|
656
|
+
carryMaxDays = String(config?.maxDays ?? 5)
|
|
657
|
+
carryExpires = (config?.expiresAfterMonths ?? null) !== null
|
|
658
|
+
// The number keeps its last value while the switch is off, so turning it back on offers what was
|
|
659
|
+
// there rather than an empty box.
|
|
660
|
+
carryMonths = String(config?.expiresAfterMonths ?? 3)
|
|
661
|
+
carryError = null
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function openCarryEdit(policy: PolicyRow) {
|
|
665
|
+
carryDialog = 'edit'
|
|
666
|
+
carryId = policy.id
|
|
667
|
+
carryFrom = policy.effectiveFrom
|
|
668
|
+
fillCarryFrom(policy)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** The same affordance as `openDuplicate`: a rule that changes from a date is a new policy. */
|
|
672
|
+
function openCarryDuplicate(policy: PolicyRow) {
|
|
673
|
+
carryDialog = 'create'
|
|
674
|
+
carryId = ''
|
|
675
|
+
fillCarryFrom(policy)
|
|
676
|
+
carryName = t('accr_copy_of', { name: policy.name })
|
|
677
|
+
carryFrom = isoDate()
|
|
678
|
+
carryTo = ''
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const carryCap = $derived(clampedDays(carryMaxDays, 0, 365))
|
|
682
|
+
const carryExpiryMonths = $derived(clamped(carryMonths, 1, 24))
|
|
683
|
+
|
|
684
|
+
const carryProblem = $derived.by(() => {
|
|
685
|
+
if (!carryName.trim()) return t('accr_error_name')
|
|
686
|
+
if (!carryTypeKey) return t('cf_error_leave_type')
|
|
687
|
+
if (!carryFrom) return t('accr_error_from')
|
|
688
|
+
if (carryTo && carryTo < carryFrom) return t('accr_error_to_before_from')
|
|
689
|
+
return null
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
const carryConfigDraft = $derived({
|
|
693
|
+
leaveTypeKey: carryTypeKey,
|
|
694
|
+
maxDays: carryCap,
|
|
695
|
+
expiresAfterMonths: carryExpires ? carryExpiryMonths : null,
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
const saveCarry = createMutation(() => ({
|
|
699
|
+
mutationFn: () =>
|
|
700
|
+
carryDialog === 'edit'
|
|
701
|
+
? api.policies.update({
|
|
702
|
+
workspaceId,
|
|
703
|
+
policyId: carryId,
|
|
704
|
+
name: carryName.trim(),
|
|
705
|
+
config: $state.snapshot(carryConfigDraft),
|
|
706
|
+
effectiveTo: carryTo || null,
|
|
707
|
+
})
|
|
708
|
+
: api.policies.create({
|
|
709
|
+
workspaceId,
|
|
710
|
+
kind: 'carry_forward',
|
|
711
|
+
name: carryName.trim(),
|
|
712
|
+
config: $state.snapshot(carryConfigDraft),
|
|
713
|
+
effectiveFrom: carryFrom,
|
|
714
|
+
effectiveTo: carryTo || null,
|
|
715
|
+
}),
|
|
716
|
+
onSuccess: (policy: Policy) => {
|
|
717
|
+
toast.success(carryDialog === 'edit' ? t('accr_saved') : t('accr_created', { name: policy.name }))
|
|
718
|
+
carryDialog = null
|
|
719
|
+
carryError = null
|
|
720
|
+
refresh()
|
|
721
|
+
},
|
|
722
|
+
onError: (error: Error) => {
|
|
723
|
+
carryError = failureText(error, 'accr_save_error')
|
|
724
|
+
},
|
|
725
|
+
onSettled: settled,
|
|
726
|
+
}))
|
|
727
|
+
|
|
728
|
+
function carryMenu(policy: PolicyRow): MenuItem[] {
|
|
729
|
+
return [
|
|
730
|
+
{ label: t('common.edit'), icon: 'square-pen', onSelect: () => openCarryEdit(policy) },
|
|
731
|
+
{ label: t('accr_duplicate'), icon: 'copy', onSelect: () => openCarryDuplicate(policy) },
|
|
732
|
+
{ label: t('accr_assign'), icon: 'user-plus', onSelect: () => openAssign(policy.id) },
|
|
733
|
+
{ type: 'separator' },
|
|
734
|
+
{
|
|
735
|
+
label: t('common.archive'),
|
|
736
|
+
icon: 'archive',
|
|
737
|
+
danger: true,
|
|
738
|
+
disabled: Boolean(policy.archivedAt),
|
|
739
|
+
hint: policy.archivedAt ? t('accr_already_archived') : undefined,
|
|
740
|
+
onSelect: () => {
|
|
741
|
+
archiving = policy
|
|
742
|
+
},
|
|
743
|
+
},
|
|
744
|
+
]
|
|
745
|
+
}
|
|
746
|
+
|
|
471
747
|
// ---------------------------------------------------------------- the ladder
|
|
472
748
|
|
|
473
749
|
/** Every rung needs a name for its subjects, and each name costs a request — so each is asked for
|
|
474
750
|
* only when a rung carries an assignment or the assign dialog is open on it. */
|
|
475
|
-
const rungInUse = (kind: PolicySubjectKind) =>
|
|
751
|
+
const rungInUse = (kind: PolicySubjectKind) => ladderAssignments.some((a) => a.subjectKind === kind)
|
|
476
752
|
|
|
477
753
|
let assignOpen = $state(false)
|
|
478
754
|
let assignRung = $state<PolicySubjectKind>('workspace')
|
|
@@ -550,18 +826,20 @@ type LadderRow = { assignment: PolicyAssignment; policy: PolicyRow }
|
|
|
550
826
|
const ladder = $derived(
|
|
551
827
|
LADDER.map((kind) => ({
|
|
552
828
|
kind,
|
|
553
|
-
rows:
|
|
554
|
-
.filter((policy) => !policy.archivedAt)
|
|
829
|
+
rows: ladderPolicies
|
|
555
830
|
.flatMap((policy) =>
|
|
556
831
|
policy.assignments
|
|
557
832
|
.filter((assignment) => assignment.subjectKind === kind)
|
|
558
833
|
.map((assignment): LadderRow => ({ assignment, policy })),
|
|
559
834
|
)
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
835
|
+
// Subject first, then kind: one person's two policies belong next to each other, and reading
|
|
836
|
+
// down a rung is reading down a list of people rather than a list of policy kinds.
|
|
837
|
+
.sort(
|
|
838
|
+
(a, b) =>
|
|
839
|
+
subjectName(kind, a.assignment.subjectId).localeCompare(
|
|
840
|
+
subjectName(kind, b.assignment.subjectId),
|
|
841
|
+
messageLocale(),
|
|
842
|
+
) || kindLabel(a.policy.kind).localeCompare(kindLabel(b.policy.kind), messageLocale()),
|
|
565
843
|
),
|
|
566
844
|
})),
|
|
567
845
|
)
|
|
@@ -577,11 +855,9 @@ let assignFrom = $state(isoDate())
|
|
|
577
855
|
let assignTo = $state('')
|
|
578
856
|
let assignError = $state<string | null>(null)
|
|
579
857
|
|
|
580
|
-
const livePolicies = $derived(policies.filter((policy) => !policy.archivedAt))
|
|
581
|
-
|
|
582
858
|
function openAssign(preferredId?: string) {
|
|
583
859
|
assignOpen = true
|
|
584
|
-
assignPolicyId = preferredId ??
|
|
860
|
+
assignPolicyId = preferredId ?? ladderPolicies[0]?.id ?? ''
|
|
585
861
|
assignRung = 'workspace'
|
|
586
862
|
assignSubjectId = ''
|
|
587
863
|
assignFrom = isoDate()
|
|
@@ -618,23 +894,30 @@ const subjectsLoading = $derived(
|
|
|
618
894
|
* sentence, so the order reads down the ladder in every writing direction. */
|
|
619
895
|
const beatenBy = $derived(LADDER.slice(0, LADDER.indexOf(assignRung)))
|
|
620
896
|
|
|
897
|
+
/** The policy being assigned, and therefore which kind the rung is being read for. */
|
|
898
|
+
const assignKind = $derived(ladderPolicies.find((p) => p.id === assignPolicyId)?.kind ?? 'accrual')
|
|
899
|
+
|
|
621
900
|
/**
|
|
622
|
-
* Another
|
|
901
|
+
* Another policy **of the same kind** already sitting on this exact subject, open-ended.
|
|
623
902
|
*
|
|
624
903
|
* Two policies of one kind on one rung is not refused by the server — their effective ranges may
|
|
625
904
|
* well separate them — but it is the ambiguity this whole screen exists to surface, so it is named
|
|
626
|
-
* rather than blocked.
|
|
905
|
+
* rather than blocked. Across kinds there is no ambiguity at all: accrual and carry-forward are
|
|
906
|
+
* resolved separately, so warning about an accrual policy while a carry-forward one is being
|
|
907
|
+
* assigned would invent a conflict that does not exist.
|
|
627
908
|
*/
|
|
628
909
|
const rungTaken = $derived(
|
|
629
910
|
assignOpen
|
|
630
|
-
? (
|
|
631
|
-
policy
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
(assignment
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
911
|
+
? (ladderPolicies.find(
|
|
912
|
+
(policy) =>
|
|
913
|
+
policy.kind === assignKind &&
|
|
914
|
+
policy.assignments.some(
|
|
915
|
+
(assignment) =>
|
|
916
|
+
assignment.subjectKind === assignRung &&
|
|
917
|
+
(assignment.subjectId ?? '') === (assignRung === 'workspace' ? '' : assignSubjectId) &&
|
|
918
|
+
assignment.effectiveTo === null &&
|
|
919
|
+
policy.id !== assignPolicyId,
|
|
920
|
+
),
|
|
638
921
|
) ?? null)
|
|
639
922
|
: null,
|
|
640
923
|
)
|
|
@@ -678,6 +961,22 @@ const assign = createMutation(() => ({
|
|
|
678
961
|
|
|
679
962
|
let unassigning = $state<LadderRow | null>(null)
|
|
680
963
|
|
|
964
|
+
const unassignIsCarry = $derived(unassigning?.policy.kind === 'carry_forward')
|
|
965
|
+
/**
|
|
966
|
+
* What the people under this assignment fall through to, in the kind being removed.
|
|
967
|
+
*
|
|
968
|
+
* The bottom rung is only a fallback for its own kind: naming the accrual policy while a
|
|
969
|
+
* carry-forward assignment is being removed would promise that leave still carries when nothing
|
|
970
|
+
* carries it any more.
|
|
971
|
+
*/
|
|
972
|
+
const unassignFallback = $derived(
|
|
973
|
+
unassigning === null || unassigning.assignment.subjectKind === 'workspace'
|
|
974
|
+
? null
|
|
975
|
+
: unassignIsCarry
|
|
976
|
+
? carryDefault
|
|
977
|
+
: workspaceDefault,
|
|
978
|
+
)
|
|
979
|
+
|
|
681
980
|
const unassign = createMutation(() => ({
|
|
682
981
|
mutationFn: (row: LadderRow) => api.policies.unassign({ workspaceId, assignmentId: row.assignment.id }),
|
|
683
982
|
onSuccess: (_ok, row: LadderRow) => {
|
|
@@ -737,8 +1036,15 @@ const HEAD = 10
|
|
|
737
1036
|
|
|
738
1037
|
<SettingsPage title={t('settings_accrual')} description={t('accr_desc')}>
|
|
739
1038
|
{#snippet actions()}
|
|
740
|
-
|
|
741
|
-
|
|
1039
|
+
<!-- One switch for both lists below, so it sits above both rather than inside one of them and
|
|
1040
|
+
silently filtering the other. Each section keeps its own "new" button. -->
|
|
1041
|
+
{#if policies.length > 0 || carryPolicies.length > 0 || showArchived}
|
|
1042
|
+
<Switch
|
|
1043
|
+
size="sm"
|
|
1044
|
+
checked={showArchived}
|
|
1045
|
+
onCheckedChange={(on) => (showArchived = on)}
|
|
1046
|
+
label={t('accr_show_archived')}
|
|
1047
|
+
/>
|
|
742
1048
|
{/if}
|
|
743
1049
|
{/snippet}
|
|
744
1050
|
|
|
@@ -751,19 +1057,23 @@ const HEAD = 10
|
|
|
751
1057
|
{#if stale}
|
|
752
1058
|
<p class="stale" role="status">
|
|
753
1059
|
<span>{t('accr_stale')}</span>
|
|
754
|
-
<Button
|
|
1060
|
+
<Button
|
|
1061
|
+
size="sm"
|
|
1062
|
+
variant="ghost"
|
|
1063
|
+
onclick={() => {
|
|
1064
|
+
void policiesQuery.refetch()
|
|
1065
|
+
void carryQuery.refetch()
|
|
1066
|
+
}}
|
|
1067
|
+
>
|
|
1068
|
+
{t('retry')}
|
|
1069
|
+
</Button>
|
|
755
1070
|
</p>
|
|
756
1071
|
{/if}
|
|
757
1072
|
|
|
758
1073
|
<SettingsSection title={t('accr_policies')} description={t('accr_policies_desc')}>
|
|
759
1074
|
{#snippet action()}
|
|
760
|
-
{#if
|
|
761
|
-
<
|
|
762
|
-
size="sm"
|
|
763
|
-
checked={showArchived}
|
|
764
|
-
onCheckedChange={(on) => (showArchived = on)}
|
|
765
|
-
label={t('accr_show_archived')}
|
|
766
|
-
/>
|
|
1075
|
+
{#if manage}
|
|
1076
|
+
<Button size="sm" icon="plus" onclick={openCreate}>{t('accr_new')}</Button>
|
|
767
1077
|
{/if}
|
|
768
1078
|
{/snippet}
|
|
769
1079
|
|
|
@@ -815,7 +1125,11 @@ const HEAD = 10
|
|
|
815
1125
|
{#if config}
|
|
816
1126
|
<span class="num">{t('accr_days_per_year', { count: config.daysPerYear })}</span>
|
|
817
1127
|
<span class="sub">
|
|
818
|
-
|
|
1128
|
+
<!-- The day length is named rather than left as a bare duration beside a leave
|
|
1129
|
+
type, because it is the number those days per year are earned in. -->
|
|
1130
|
+
{leaveTypeName(config.leaveTypeKey)} · {t('accr_day_is', {
|
|
1131
|
+
length: duration(config.minutesPerDay),
|
|
1132
|
+
})}
|
|
819
1133
|
{#if config.seniorityTiers.length > 0}
|
|
820
1134
|
· {t('accr_tiers_count', { count: config.seniorityTiers.length })}
|
|
821
1135
|
{/if}
|
|
@@ -883,21 +1197,147 @@ const HEAD = 10
|
|
|
883
1197
|
{/if}
|
|
884
1198
|
</SettingsSection>
|
|
885
1199
|
|
|
1200
|
+
<!-- ---------------------------------------------------------------- carry-forward -->
|
|
1201
|
+
<SettingsSection title={t('cf_section')} description={t('cf_section_desc')}>
|
|
1202
|
+
{#snippet action()}
|
|
1203
|
+
{#if manage}
|
|
1204
|
+
<Button size="sm" icon="plus" onclick={openCarryCreate}>{t('accr_new')}</Button>
|
|
1205
|
+
{/if}
|
|
1206
|
+
{/snippet}
|
|
1207
|
+
|
|
1208
|
+
{#if carryLoading}
|
|
1209
|
+
<div class="rows">
|
|
1210
|
+
{#each [1, 2] as n (n)}<Skeleton height="52px" />{/each}
|
|
1211
|
+
</div>
|
|
1212
|
+
{:else if carryPolicies.length}
|
|
1213
|
+
<div class="stack">
|
|
1214
|
+
{#if !carryDefault}
|
|
1215
|
+
<!-- Same silence as the accrual ladder's missing bottom rung, and a different sentence:
|
|
1216
|
+
nothing is refused, people simply keep nothing at the turn of the year. -->
|
|
1217
|
+
<p class="note warn">{t('cf_no_default')}</p>
|
|
1218
|
+
{/if}
|
|
1219
|
+
{#if !policiesLoading && livePolicies.length === 0}
|
|
1220
|
+
<!-- The job converts a cap in days with the day length from that person's *accrual*
|
|
1221
|
+
policy, and skips them when there is none. A cap with no accrual policy anywhere is
|
|
1222
|
+
therefore a rule that cannot run at all — said only once the accrual list has
|
|
1223
|
+
actually answered, so the warning cannot flash while it loads. -->
|
|
1224
|
+
<p class="note warn">{t('cf_needs_accrual')}</p>
|
|
1225
|
+
{/if}
|
|
1226
|
+
|
|
1227
|
+
<div class="table cf" role="table" aria-label={t('cf_section')}>
|
|
1228
|
+
<div class="thead" role="row">
|
|
1229
|
+
<span role="columnheader">{t('accr_col_policy')}</span>
|
|
1230
|
+
<span role="columnheader">{t('cf_col_carries')}</span>
|
|
1231
|
+
<span role="columnheader">{t('cf_col_deadline')}</span>
|
|
1232
|
+
<span role="columnheader">{t('accr_col_applies')}</span>
|
|
1233
|
+
<span class="sr-only" role="columnheader">{t('approvals_actions')}</span>
|
|
1234
|
+
</div>
|
|
1235
|
+
{#each carryPolicies as policy (policy.id)}
|
|
1236
|
+
{@const config = carryConfigOf(policy)}
|
|
1237
|
+
<div class="trow" role="row">
|
|
1238
|
+
<span class="cell what" role="cell">
|
|
1239
|
+
<span class="strong">{policy.name}</span>
|
|
1240
|
+
<span class="chips">
|
|
1241
|
+
{#if policy.archivedAt}
|
|
1242
|
+
<Badge tone="grey">{t('accr_badge_archived')}</Badge>
|
|
1243
|
+
{/if}
|
|
1244
|
+
{#if policy.source === 'pack'}
|
|
1245
|
+
<Badge tone="grey">{t('cal_origin_pack')}</Badge>
|
|
1246
|
+
{/if}
|
|
1247
|
+
</span>
|
|
1248
|
+
<span class="sub">{rangeLabel(policy.effectiveFrom, policy.effectiveTo)}</span>
|
|
1249
|
+
</span>
|
|
1250
|
+
|
|
1251
|
+
<span class="cell" role="cell">
|
|
1252
|
+
{#if config}
|
|
1253
|
+
{#if config.maxDays > 0}
|
|
1254
|
+
<span class="num">
|
|
1255
|
+
{days(config.maxDays)}
|
|
1256
|
+
{t('days', { count: config.maxDays })}
|
|
1257
|
+
</span>
|
|
1258
|
+
{:else}
|
|
1259
|
+
<!-- A cap of zero is a real answer and a drastic one: the whole remaining
|
|
1260
|
+
balance is written off as an expiry entry. It is named, not left as "0". -->
|
|
1261
|
+
<span class="warn-text">{t('cf_carries_nothing')}</span>
|
|
1262
|
+
{/if}
|
|
1263
|
+
<span class="sub">{leaveTypeName(config.leaveTypeKey)}</span>
|
|
1264
|
+
{:else}
|
|
1265
|
+
<span class="sub danger">{t('accr_config_unreadable')}</span>
|
|
1266
|
+
{/if}
|
|
1267
|
+
</span>
|
|
1268
|
+
|
|
1269
|
+
<span class="cell" role="cell">
|
|
1270
|
+
{#if config}
|
|
1271
|
+
{#if config.expiresAfterMonths === null}
|
|
1272
|
+
<Badge tone="grey">{t('cf_no_deadline')}</Badge>
|
|
1273
|
+
{:else}
|
|
1274
|
+
<Badge tone="warning">
|
|
1275
|
+
{t('cf_deadline_short', { count: config.expiresAfterMonths })}
|
|
1276
|
+
</Badge>
|
|
1277
|
+
{/if}
|
|
1278
|
+
{/if}
|
|
1279
|
+
</span>
|
|
1280
|
+
|
|
1281
|
+
<span class="cell num" role="cell">
|
|
1282
|
+
{#if policy.assignments.length > 0}
|
|
1283
|
+
{formatCount(policy.assignments.length, 999)}
|
|
1284
|
+
{:else}
|
|
1285
|
+
<span class="sub warn-text">{t('accr_nobody')}</span>
|
|
1286
|
+
{/if}
|
|
1287
|
+
</span>
|
|
1288
|
+
|
|
1289
|
+
<span class="cell actions" role="cell">
|
|
1290
|
+
{#if manage}
|
|
1291
|
+
<DropdownMenu items={carryMenu(policy)}>
|
|
1292
|
+
{#snippet trigger(props)}
|
|
1293
|
+
<IconButton
|
|
1294
|
+
icon="ellipsis"
|
|
1295
|
+
label={t('accr_actions_for', { name: policy.name })}
|
|
1296
|
+
size={28}
|
|
1297
|
+
{...props}
|
|
1298
|
+
/>
|
|
1299
|
+
{/snippet}
|
|
1300
|
+
</DropdownMenu>
|
|
1301
|
+
{/if}
|
|
1302
|
+
</span>
|
|
1303
|
+
</div>
|
|
1304
|
+
{/each}
|
|
1305
|
+
</div>
|
|
1306
|
+
|
|
1307
|
+
<p class="note">{t('cf_job_note')}</p>
|
|
1308
|
+
</div>
|
|
1309
|
+
{:else if carryQuery.isError}
|
|
1310
|
+
<EmptyState icon="triangle-alert" title={t('cf_error')} description={t('accr_error_desc')}>
|
|
1311
|
+
{#snippet actions()}
|
|
1312
|
+
<Button variant="secondary" onclick={() => void carryQuery.refetch()}>{t('retry')}</Button>
|
|
1313
|
+
{/snippet}
|
|
1314
|
+
</EmptyState>
|
|
1315
|
+
{:else}
|
|
1316
|
+
<EmptyState icon="calendar-days" title={t('cf_none')} description={t('cf_none_desc')}>
|
|
1317
|
+
{#snippet actions()}
|
|
1318
|
+
{#if manage}
|
|
1319
|
+
<Button icon="plus" onclick={openCarryCreate}>{t('accr_new')}</Button>
|
|
1320
|
+
{/if}
|
|
1321
|
+
{/snippet}
|
|
1322
|
+
</EmptyState>
|
|
1323
|
+
{/if}
|
|
1324
|
+
</SettingsSection>
|
|
1325
|
+
|
|
886
1326
|
<!-- ---------------------------------------------------------------- the ladder -->
|
|
887
1327
|
<SettingsSection title={t('accr_ladder')} description={t('accr_ladder_desc')}>
|
|
888
1328
|
{#snippet action()}
|
|
889
|
-
{#if manage &&
|
|
1329
|
+
{#if manage && ladderPolicies.length > 0}
|
|
890
1330
|
<Button size="sm" variant="secondary" icon="user-plus" onclick={() => openAssign()}>
|
|
891
1331
|
{t('accr_assign')}
|
|
892
1332
|
</Button>
|
|
893
1333
|
{/if}
|
|
894
1334
|
{/snippet}
|
|
895
1335
|
|
|
896
|
-
{#if policiesLoading}
|
|
1336
|
+
{#if policiesLoading || carryLoading}
|
|
897
1337
|
<div class="rows">
|
|
898
1338
|
{#each [1, 2, 3, 4] as n (n)}<Skeleton height="44px" />{/each}
|
|
899
1339
|
</div>
|
|
900
|
-
{:else if
|
|
1340
|
+
{:else if ladderAssignments.length}
|
|
901
1341
|
{#if !workspaceDefault}
|
|
902
1342
|
<!-- The bottom rung is the only one that catches everybody, and its absence is silent
|
|
903
1343
|
everywhere else: people with nothing nearer simply never accrue. -->
|
|
@@ -920,7 +1360,15 @@ const HEAD = 10
|
|
|
920
1360
|
<Badge tone="grey">{rungLabel(rung.kind)}</Badge>
|
|
921
1361
|
<span class="strong">{subjectName(rung.kind, row.assignment.subjectId)}</span>
|
|
922
1362
|
</span>
|
|
923
|
-
<span class="rpolicy">
|
|
1363
|
+
<span class="rpolicy">
|
|
1364
|
+
<!-- Which kind is on this rung. Two policies on one subject is ordinary here
|
|
1365
|
+
— accrual and carry-forward are resolved separately — and without the
|
|
1366
|
+
badge the pair reads as a conflict rather than as two answers. -->
|
|
1367
|
+
<Badge tone={row.policy.kind === 'carry_forward' ? 'info' : 'accent'}>
|
|
1368
|
+
{kindLabel(row.policy.kind)}
|
|
1369
|
+
</Badge>
|
|
1370
|
+
<span class="rname">{row.policy.name}</span>
|
|
1371
|
+
</span>
|
|
924
1372
|
<span class="sub rwhen">
|
|
925
1373
|
{rangeLabel(row.assignment.effectiveFrom, row.assignment.effectiveTo)}
|
|
926
1374
|
</span>
|
|
@@ -942,20 +1390,30 @@ const HEAD = 10
|
|
|
942
1390
|
</div>
|
|
943
1391
|
{/each}
|
|
944
1392
|
</div>
|
|
945
|
-
{:else if policiesQuery.isError}
|
|
1393
|
+
{:else if policiesQuery.isError || carryQuery.isError}
|
|
946
1394
|
<EmptyState icon="triangle-alert" title={t('accr_ladder_error')}>
|
|
947
1395
|
{#snippet actions()}
|
|
948
|
-
<Button
|
|
1396
|
+
<Button
|
|
1397
|
+
variant="secondary"
|
|
1398
|
+
onclick={() => {
|
|
1399
|
+
void policiesQuery.refetch()
|
|
1400
|
+
void carryQuery.refetch()
|
|
1401
|
+
}}
|
|
1402
|
+
>
|
|
1403
|
+
{t('retry')}
|
|
1404
|
+
</Button>
|
|
949
1405
|
{/snippet}
|
|
950
1406
|
</EmptyState>
|
|
951
1407
|
{:else}
|
|
952
1408
|
<EmptyState
|
|
953
1409
|
icon="git-branch"
|
|
954
1410
|
title={t('accr_ladder_none')}
|
|
955
|
-
description={
|
|
1411
|
+
description={ladderPolicies.length > 0
|
|
1412
|
+
? t('accr_ladder_none_desc')
|
|
1413
|
+
: t('accr_ladder_needs_policy')}
|
|
956
1414
|
>
|
|
957
1415
|
{#snippet actions()}
|
|
958
|
-
{#if manage &&
|
|
1416
|
+
{#if manage && ladderPolicies.length > 0}
|
|
959
1417
|
<Button icon="user-plus" onclick={() => openAssign()}>{t('accr_assign')}</Button>
|
|
960
1418
|
{:else if manage}
|
|
961
1419
|
<Button icon="plus" onclick={openCreate}>{t('accr_new')}</Button>
|
|
@@ -1003,6 +1461,13 @@ const HEAD = 10
|
|
|
1003
1461
|
<p class="note">{t('accr_edit_retroactive')}</p>
|
|
1004
1462
|
{/if}
|
|
1005
1463
|
|
|
1464
|
+
{#if seededFromUnreadable}
|
|
1465
|
+
<!-- Every field below is a default, the day length included — and the day length is what the
|
|
1466
|
+
engine multiplies entitlement days by. Saying so is the difference between repairing a
|
|
1467
|
+
broken row and overwriting a working one with eight hours and twenty days. -->
|
|
1468
|
+
<p class="note warn">{t('accr_config_unreadable_form')}</p>
|
|
1469
|
+
{/if}
|
|
1470
|
+
|
|
1006
1471
|
<Field label={t('accr_name')} hint={t('accr_name_hint')} required>
|
|
1007
1472
|
{#snippet children(id)}
|
|
1008
1473
|
<Input {id} bind:value={policyName} maxlength={120} />
|
|
@@ -1054,10 +1519,7 @@ const HEAD = 10
|
|
|
1054
1519
|
<Input {id} type="number" min={0} max={365} step={0.5} bind:value={daysPerYear} />
|
|
1055
1520
|
{/snippet}
|
|
1056
1521
|
</Field>
|
|
1057
|
-
<Field
|
|
1058
|
-
label={t('accr_day_length')}
|
|
1059
|
-
hint={t('accr_day_length_hint', { length: duration(clamped(minutesPerDay, 1, 1440)) })}
|
|
1060
|
-
>
|
|
1522
|
+
<Field label={t('accr_day_length')} hint={t('accr_day_length_hint', { length: duration(dayMinutes) })}>
|
|
1061
1523
|
{#snippet children(id)}
|
|
1062
1524
|
<Input {id} type="number" min={1} max={1440} bind:value={minutesPerDay} />
|
|
1063
1525
|
{/snippet}
|
|
@@ -1072,7 +1534,12 @@ const HEAD = 10
|
|
|
1072
1534
|
</Field>
|
|
1073
1535
|
<Field label={t('accr_rounding')} hint={t('accr_rounding_hint')}>
|
|
1074
1536
|
{#snippet children(id)}
|
|
1075
|
-
<Select
|
|
1537
|
+
<Select
|
|
1538
|
+
{id}
|
|
1539
|
+
value={roundChoice}
|
|
1540
|
+
onValueChange={(v) => (roundChoice = v)}
|
|
1541
|
+
options={roundOptions}
|
|
1542
|
+
/>
|
|
1076
1543
|
{/snippet}
|
|
1077
1544
|
</Field>
|
|
1078
1545
|
</div>
|
|
@@ -1166,6 +1633,118 @@ const HEAD = 10
|
|
|
1166
1633
|
{/snippet}
|
|
1167
1634
|
</Dialog>
|
|
1168
1635
|
|
|
1636
|
+
<!-- ---------------------------------------------------------------- the carry-forward form -->
|
|
1637
|
+
<Dialog
|
|
1638
|
+
open={carryDialog !== null}
|
|
1639
|
+
title={carryDialog === 'edit' ? t('cf_edit_title') : t('cf_create_title')}
|
|
1640
|
+
onOpenChange={(open) => {
|
|
1641
|
+
if (!open) carryDialog = null
|
|
1642
|
+
}}
|
|
1643
|
+
>
|
|
1644
|
+
<div class="form">
|
|
1645
|
+
{#if carryDialog === 'edit'}
|
|
1646
|
+
<p class="note">{t('accr_edit_retroactive')}</p>
|
|
1647
|
+
{/if}
|
|
1648
|
+
|
|
1649
|
+
{#if carrySeededFromUnreadable}
|
|
1650
|
+
<p class="note warn">{t('accr_config_unreadable_form')}</p>
|
|
1651
|
+
{/if}
|
|
1652
|
+
|
|
1653
|
+
<Field label={t('accr_name')} hint={t('accr_name_hint')} required>
|
|
1654
|
+
{#snippet children(id)}
|
|
1655
|
+
<Input {id} bind:value={carryName} maxlength={120} />
|
|
1656
|
+
{/snippet}
|
|
1657
|
+
</Field>
|
|
1658
|
+
|
|
1659
|
+
<Field label={t('accr_leave_type')} hint={t('cf_leave_type_hint')} required>
|
|
1660
|
+
{#snippet children(id)}
|
|
1661
|
+
<Select
|
|
1662
|
+
{id}
|
|
1663
|
+
value={carryTypeKey}
|
|
1664
|
+
onValueChange={(v) => (carryTypeKey = v)}
|
|
1665
|
+
placeholder={t('accr_leave_type_pick')}
|
|
1666
|
+
ariaLabel={t('accr_leave_type')}
|
|
1667
|
+
options={leaveTypes.map((type) => ({ value: type.key, label: type.name }))}
|
|
1668
|
+
/>
|
|
1669
|
+
{/snippet}
|
|
1670
|
+
</Field>
|
|
1671
|
+
|
|
1672
|
+
{#if leaveTypes.length === 0 && !leaveTypesQuery.isLoading}
|
|
1673
|
+
<p class="note warn">
|
|
1674
|
+
{t('accr_no_leave_types')}
|
|
1675
|
+
{#if canHr('leaveManage')}
|
|
1676
|
+
<a class="link" href={`/${workspaceSlug}/settings/hr/leave`}>{t('settings_leave')}</a>
|
|
1677
|
+
{/if}
|
|
1678
|
+
</p>
|
|
1679
|
+
{/if}
|
|
1680
|
+
|
|
1681
|
+
<Field label={t('cf_cap')} hint={t('cf_cap_hint')}>
|
|
1682
|
+
{#snippet children(id)}
|
|
1683
|
+
<Input {id} type="number" min={0} max={365} step={0.5} bind:value={carryMaxDays} />
|
|
1684
|
+
{/snippet}
|
|
1685
|
+
</Field>
|
|
1686
|
+
|
|
1687
|
+
{#if carryCap === 0}
|
|
1688
|
+
<!-- Not a disabled rule: a zero cap writes the whole closing balance off as an expiry entry,
|
|
1689
|
+
which is the most drastic thing this form can save. -->
|
|
1690
|
+
<p class="note warn">{t('cf_cap_zero')}</p>
|
|
1691
|
+
{/if}
|
|
1692
|
+
|
|
1693
|
+
<!-- A switch and a number rather than an empty box meaning "never": both are real answers, and
|
|
1694
|
+
a blank field does not say which of them it is. Nothing is disabled — the months field is
|
|
1695
|
+
simply not there while there is no deadline to describe. -->
|
|
1696
|
+
<Switch
|
|
1697
|
+
checked={carryExpires}
|
|
1698
|
+
onCheckedChange={(on) => (carryExpires = on)}
|
|
1699
|
+
label={t('cf_expires')}
|
|
1700
|
+
description={t('cf_expires_hint')}
|
|
1701
|
+
/>
|
|
1702
|
+
|
|
1703
|
+
{#if carryExpires}
|
|
1704
|
+
<Field label={t('cf_months')} hint={t('cf_months_hint')}>
|
|
1705
|
+
{#snippet children(id)}
|
|
1706
|
+
<Input {id} type="number" min={1} max={24} bind:value={carryMonths} />
|
|
1707
|
+
{/snippet}
|
|
1708
|
+
</Field>
|
|
1709
|
+
{/if}
|
|
1710
|
+
|
|
1711
|
+
<div class="pair">
|
|
1712
|
+
<Field label={t('accr_effective_from')} hint={t('accr_effective_from_hint')} required>
|
|
1713
|
+
{#snippet children(id)}
|
|
1714
|
+
<Input {id} type="date" bind:value={carryFrom} disabled={carryDialog === 'edit'} />
|
|
1715
|
+
{/snippet}
|
|
1716
|
+
</Field>
|
|
1717
|
+
<Field label={t('accr_effective_to')} hint={t('accr_effective_to_hint')}>
|
|
1718
|
+
{#snippet children(id)}
|
|
1719
|
+
<Input {id} type="date" bind:value={carryTo} />
|
|
1720
|
+
{/snippet}
|
|
1721
|
+
</Field>
|
|
1722
|
+
</div>
|
|
1723
|
+
|
|
1724
|
+
<p class="note">{t('cf_needs_accrual_note')}</p>
|
|
1725
|
+
|
|
1726
|
+
{#if carryError}
|
|
1727
|
+
<p class="note danger-note" role="alert">{carryError}</p>
|
|
1728
|
+
{/if}
|
|
1729
|
+
</div>
|
|
1730
|
+
|
|
1731
|
+
{#snippet footer()}
|
|
1732
|
+
{#if carryProblem}
|
|
1733
|
+
<span class="hint problem">{carryProblem}</span>
|
|
1734
|
+
{/if}
|
|
1735
|
+
<Button variant="secondary" onclick={() => (carryDialog = null)} disabled={saveCarry.isPending}>
|
|
1736
|
+
{t('cancel')}
|
|
1737
|
+
</Button>
|
|
1738
|
+
<Button
|
|
1739
|
+
loading={saveCarry.isPending}
|
|
1740
|
+
disabled={!manage || carryProblem !== null}
|
|
1741
|
+
onclick={() => once(() => saveCarry.mutate())}
|
|
1742
|
+
>
|
|
1743
|
+
{carryDialog === 'edit' ? t('common.save') : t('common.create')}
|
|
1744
|
+
</Button>
|
|
1745
|
+
{/snippet}
|
|
1746
|
+
</Dialog>
|
|
1747
|
+
|
|
1169
1748
|
<!-- ---------------------------------------------------------------- archive a policy -->
|
|
1170
1749
|
<Dialog
|
|
1171
1750
|
open={archiving !== null}
|
|
@@ -1219,7 +1798,13 @@ const HEAD = 10
|
|
|
1219
1798
|
onValueChange={(v) => (assignPolicyId = v)}
|
|
1220
1799
|
placeholder={t('accr_assign_policy_pick')}
|
|
1221
1800
|
ariaLabel={t('accr_assign_policy')}
|
|
1222
|
-
options={
|
|
1801
|
+
options={ladderPolicies.map((policy) => ({
|
|
1802
|
+
value: policy.id,
|
|
1803
|
+
label: policy.name,
|
|
1804
|
+
// Grouped by kind rather than labelled with it: which rung a policy may sit on is the
|
|
1805
|
+
// same question for both, but "5 days" means nothing until you know which it is.
|
|
1806
|
+
group: kindLabel(policy.kind),
|
|
1807
|
+
}))}
|
|
1223
1808
|
/>
|
|
1224
1809
|
{/snippet}
|
|
1225
1810
|
</Field>
|
|
@@ -1320,7 +1905,7 @@ const HEAD = 10
|
|
|
1320
1905
|
open={unassigning !== null}
|
|
1321
1906
|
size="sm"
|
|
1322
1907
|
title={unassigning
|
|
1323
|
-
? t('accr_unassign_title', {
|
|
1908
|
+
? t(unassignIsCarry ? 'cf_unassign_title' : 'accr_unassign_title', {
|
|
1324
1909
|
subject: subjectName(unassigning.assignment.subjectKind, unassigning.assignment.subjectId),
|
|
1325
1910
|
})
|
|
1326
1911
|
: ''}
|
|
@@ -1332,9 +1917,9 @@ const HEAD = 10
|
|
|
1332
1917
|
{#if unassigning}
|
|
1333
1918
|
<!-- What happens to the people underneath is the whole question, and it has two answers. -->
|
|
1334
1919
|
<p class="body">
|
|
1335
|
-
{
|
|
1336
|
-
? t('accr_unassign_falls_back', { name:
|
|
1337
|
-
: t('accr_unassign_no_fallback')}
|
|
1920
|
+
{unassignFallback
|
|
1921
|
+
? t('accr_unassign_falls_back', { name: unassignFallback.name })
|
|
1922
|
+
: t(unassignIsCarry ? 'cf_unassign_no_fallback' : 'accr_unassign_no_fallback')}
|
|
1338
1923
|
</p>
|
|
1339
1924
|
<p class="body sub">{t('accr_unassign_keeps')}</p>
|
|
1340
1925
|
{/if}
|
|
@@ -1388,7 +1973,18 @@ const HEAD = 10
|
|
|
1388
1973
|
{:else if preview}
|
|
1389
1974
|
<div class="tiles">
|
|
1390
1975
|
<StatTile size="md" label={t('accr_run_people')} value={formatCount(creditable.length, 999)} />
|
|
1391
|
-
|
|
1976
|
+
<!--
|
|
1977
|
+
In time, not days, and deliberately so: one run covers everybody, and the people in it may
|
|
1978
|
+
sit on policies whose days are different lengths, so there is no single day this sum could
|
|
1979
|
+
honestly be divided by. Each row below *is* in days, because the server divided it by that
|
|
1980
|
+
person's own policy. The unit is on the tile rather than left to be inferred.
|
|
1981
|
+
-->
|
|
1982
|
+
<StatTile
|
|
1983
|
+
size="md"
|
|
1984
|
+
label={t('accr_run_total')}
|
|
1985
|
+
value={duration(preview.totalMinutes)}
|
|
1986
|
+
note={t('accr_run_total_note')}
|
|
1987
|
+
/>
|
|
1392
1988
|
<StatTile size="md" label={t('accr_run_skipped')} value={formatCount(preview.skipped.length, 999)} />
|
|
1393
1989
|
</div>
|
|
1394
1990
|
|
|
@@ -1481,11 +2077,22 @@ const HEAD = 10
|
|
|
1481
2077
|
margin-block-end: 18px;
|
|
1482
2078
|
}
|
|
1483
2079
|
|
|
2080
|
+
/* Notes, a table and a footnote in one section: one gap rather than a margin per element. */
|
|
2081
|
+
.stack {
|
|
2082
|
+
display: grid;
|
|
2083
|
+
gap: 12px;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
1484
2086
|
/* One grid for the header and every row, so the columns line up down the page. */
|
|
1485
2087
|
.table {
|
|
1486
2088
|
width: 100%;
|
|
1487
2089
|
--hr-accr-cols: minmax(150px, 1.4fr) minmax(120px, 1.2fr) minmax(110px, 1fr) 56px 32px;
|
|
1488
2090
|
}
|
|
2091
|
+
/* The carry-forward table is the same anatomy with a narrower deadline column: it holds one chip,
|
|
2092
|
+
where the accrual table's third column holds a badge and a line of settings under it. */
|
|
2093
|
+
.table.cf {
|
|
2094
|
+
--hr-accr-cols: minmax(150px, 1.4fr) minmax(110px, 1fr) minmax(100px, 0.9fr) 56px 32px;
|
|
2095
|
+
}
|
|
1489
2096
|
.thead,
|
|
1490
2097
|
.trow {
|
|
1491
2098
|
display: grid;
|
|
@@ -1579,7 +2186,9 @@ const HEAD = 10
|
|
|
1579
2186
|
}
|
|
1580
2187
|
.rlist li {
|
|
1581
2188
|
display: grid;
|
|
1582
|
-
|
|
2189
|
+
/* The policy column carries a kind chip as well as the name now, so it is the wider of the two
|
|
2190
|
+
text columns rather than the narrower. */
|
|
2191
|
+
grid-template-columns: minmax(140px, 1.2fr) minmax(150px, 1.3fr) minmax(110px, 0.8fr) 32px;
|
|
1583
2192
|
gap: 10px;
|
|
1584
2193
|
align-items: center;
|
|
1585
2194
|
min-height: 36px;
|
|
@@ -1597,6 +2206,12 @@ const HEAD = 10
|
|
|
1597
2206
|
min-width: 0;
|
|
1598
2207
|
}
|
|
1599
2208
|
.rpolicy {
|
|
2209
|
+
display: flex;
|
|
2210
|
+
align-items: center;
|
|
2211
|
+
gap: 8px;
|
|
2212
|
+
min-width: 0;
|
|
2213
|
+
}
|
|
2214
|
+
.rname {
|
|
1600
2215
|
min-width: 0;
|
|
1601
2216
|
overflow: hidden;
|
|
1602
2217
|
text-overflow: ellipsis;
|
|
@@ -1750,7 +2365,12 @@ const HEAD = 10
|
|
|
1750
2365
|
.table {
|
|
1751
2366
|
--hr-accr-cols: minmax(140px, 1fr) minmax(100px, 0.9fr) 32px;
|
|
1752
2367
|
}
|
|
1753
|
-
/*
|
|
2368
|
+
/* `.table.cf` outranks `.table` at any width, so the narrow layout has to be restated for it. */
|
|
2369
|
+
.table.cf {
|
|
2370
|
+
--hr-accr-cols: minmax(140px, 1fr) minmax(100px, 0.9fr) 32px;
|
|
2371
|
+
}
|
|
2372
|
+
/* The third and fourth columns of both tables go — the frequency or the deadline, and the
|
|
2373
|
+
assignment count. What a policy is called and what it is worth cannot. */
|
|
1754
2374
|
.thead > :nth-child(3),
|
|
1755
2375
|
.trow > :nth-child(3),
|
|
1756
2376
|
.thead > :nth-child(4),
|