@kernhq/module-hr 0.13.2 → 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/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 +99 -16
- 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 +241 -22
- package/src/client/pages/DirectoryPage.svelte +45 -1
- package/src/client/permissions.ts +12 -4
- package/src/client/settings/AccrualSettings.svelte +596 -57
- package/src/client/settings/GeneralSettings.svelte +12 -9
- 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
|
|
@@ -296,6 +364,15 @@ let waitingMonths = $state('0')
|
|
|
296
364
|
/** A token rather than a minute count — see `roundMinutes`. */
|
|
297
365
|
let roundChoice = $state('0')
|
|
298
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')
|
|
299
376
|
let policyError = $state<string | null>(null)
|
|
300
377
|
/**
|
|
301
378
|
* Whether the form was seeded from a config the schema could not read.
|
|
@@ -320,6 +397,7 @@ function openCreate() {
|
|
|
320
397
|
waitingMonths = '0'
|
|
321
398
|
roundChoice = '0'
|
|
322
399
|
tiers = []
|
|
400
|
+
policyCalendar = 'gregorian'
|
|
323
401
|
policyError = null
|
|
324
402
|
seededFromUnreadable = false
|
|
325
403
|
}
|
|
@@ -344,6 +422,7 @@ function fillFrom(policy: PolicyRow) {
|
|
|
344
422
|
afterYears: String(tier.afterYears),
|
|
345
423
|
daysPerYear: String(tier.daysPerYear),
|
|
346
424
|
}))
|
|
425
|
+
policyCalendar = config?.calendar ?? 'gregorian'
|
|
347
426
|
policyError = null
|
|
348
427
|
}
|
|
349
428
|
|
|
@@ -454,6 +533,7 @@ const configDraft = $derived({
|
|
|
454
533
|
waitingPeriodMonths: clamped(waitingMonths, 0, 24),
|
|
455
534
|
// Save is blocked above the cap, so this never silently shortens a step somebody chose.
|
|
456
535
|
roundToMinutes: Math.min(roundMinutes, ROUND_CAP_MINUTES),
|
|
536
|
+
calendar: policyCalendar,
|
|
457
537
|
leaveTypeKey,
|
|
458
538
|
})
|
|
459
539
|
|
|
@@ -525,11 +605,150 @@ function policyMenu(policy: PolicyRow): MenuItem[] {
|
|
|
525
605
|
]
|
|
526
606
|
}
|
|
527
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
|
+
|
|
528
747
|
// ---------------------------------------------------------------- the ladder
|
|
529
748
|
|
|
530
749
|
/** Every rung needs a name for its subjects, and each name costs a request — so each is asked for
|
|
531
750
|
* only when a rung carries an assignment or the assign dialog is open on it. */
|
|
532
|
-
const rungInUse = (kind: PolicySubjectKind) =>
|
|
751
|
+
const rungInUse = (kind: PolicySubjectKind) => ladderAssignments.some((a) => a.subjectKind === kind)
|
|
533
752
|
|
|
534
753
|
let assignOpen = $state(false)
|
|
535
754
|
let assignRung = $state<PolicySubjectKind>('workspace')
|
|
@@ -607,18 +826,20 @@ type LadderRow = { assignment: PolicyAssignment; policy: PolicyRow }
|
|
|
607
826
|
const ladder = $derived(
|
|
608
827
|
LADDER.map((kind) => ({
|
|
609
828
|
kind,
|
|
610
|
-
rows:
|
|
611
|
-
.filter((policy) => !policy.archivedAt)
|
|
829
|
+
rows: ladderPolicies
|
|
612
830
|
.flatMap((policy) =>
|
|
613
831
|
policy.assignments
|
|
614
832
|
.filter((assignment) => assignment.subjectKind === kind)
|
|
615
833
|
.map((assignment): LadderRow => ({ assignment, policy })),
|
|
616
834
|
)
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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()),
|
|
622
843
|
),
|
|
623
844
|
})),
|
|
624
845
|
)
|
|
@@ -634,11 +855,9 @@ let assignFrom = $state(isoDate())
|
|
|
634
855
|
let assignTo = $state('')
|
|
635
856
|
let assignError = $state<string | null>(null)
|
|
636
857
|
|
|
637
|
-
const livePolicies = $derived(policies.filter((policy) => !policy.archivedAt))
|
|
638
|
-
|
|
639
858
|
function openAssign(preferredId?: string) {
|
|
640
859
|
assignOpen = true
|
|
641
|
-
assignPolicyId = preferredId ??
|
|
860
|
+
assignPolicyId = preferredId ?? ladderPolicies[0]?.id ?? ''
|
|
642
861
|
assignRung = 'workspace'
|
|
643
862
|
assignSubjectId = ''
|
|
644
863
|
assignFrom = isoDate()
|
|
@@ -675,23 +894,30 @@ const subjectsLoading = $derived(
|
|
|
675
894
|
* sentence, so the order reads down the ladder in every writing direction. */
|
|
676
895
|
const beatenBy = $derived(LADDER.slice(0, LADDER.indexOf(assignRung)))
|
|
677
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
|
+
|
|
678
900
|
/**
|
|
679
|
-
* Another
|
|
901
|
+
* Another policy **of the same kind** already sitting on this exact subject, open-ended.
|
|
680
902
|
*
|
|
681
903
|
* Two policies of one kind on one rung is not refused by the server — their effective ranges may
|
|
682
904
|
* well separate them — but it is the ambiguity this whole screen exists to surface, so it is named
|
|
683
|
-
* 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.
|
|
684
908
|
*/
|
|
685
909
|
const rungTaken = $derived(
|
|
686
910
|
assignOpen
|
|
687
|
-
? (
|
|
688
|
-
policy
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
(assignment
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
+
),
|
|
695
921
|
) ?? null)
|
|
696
922
|
: null,
|
|
697
923
|
)
|
|
@@ -735,6 +961,22 @@ const assign = createMutation(() => ({
|
|
|
735
961
|
|
|
736
962
|
let unassigning = $state<LadderRow | null>(null)
|
|
737
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
|
+
|
|
738
980
|
const unassign = createMutation(() => ({
|
|
739
981
|
mutationFn: (row: LadderRow) => api.policies.unassign({ workspaceId, assignmentId: row.assignment.id }),
|
|
740
982
|
onSuccess: (_ok, row: LadderRow) => {
|
|
@@ -794,8 +1036,15 @@ const HEAD = 10
|
|
|
794
1036
|
|
|
795
1037
|
<SettingsPage title={t('settings_accrual')} description={t('accr_desc')}>
|
|
796
1038
|
{#snippet actions()}
|
|
797
|
-
|
|
798
|
-
|
|
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
|
+
/>
|
|
799
1048
|
{/if}
|
|
800
1049
|
{/snippet}
|
|
801
1050
|
|
|
@@ -808,19 +1057,23 @@ const HEAD = 10
|
|
|
808
1057
|
{#if stale}
|
|
809
1058
|
<p class="stale" role="status">
|
|
810
1059
|
<span>{t('accr_stale')}</span>
|
|
811
|
-
<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>
|
|
812
1070
|
</p>
|
|
813
1071
|
{/if}
|
|
814
1072
|
|
|
815
1073
|
<SettingsSection title={t('accr_policies')} description={t('accr_policies_desc')}>
|
|
816
1074
|
{#snippet action()}
|
|
817
|
-
{#if
|
|
818
|
-
<
|
|
819
|
-
size="sm"
|
|
820
|
-
checked={showArchived}
|
|
821
|
-
onCheckedChange={(on) => (showArchived = on)}
|
|
822
|
-
label={t('accr_show_archived')}
|
|
823
|
-
/>
|
|
1075
|
+
{#if manage}
|
|
1076
|
+
<Button size="sm" icon="plus" onclick={openCreate}>{t('accr_new')}</Button>
|
|
824
1077
|
{/if}
|
|
825
1078
|
{/snippet}
|
|
826
1079
|
|
|
@@ -944,21 +1197,147 @@ const HEAD = 10
|
|
|
944
1197
|
{/if}
|
|
945
1198
|
</SettingsSection>
|
|
946
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
|
+
|
|
947
1326
|
<!-- ---------------------------------------------------------------- the ladder -->
|
|
948
1327
|
<SettingsSection title={t('accr_ladder')} description={t('accr_ladder_desc')}>
|
|
949
1328
|
{#snippet action()}
|
|
950
|
-
{#if manage &&
|
|
1329
|
+
{#if manage && ladderPolicies.length > 0}
|
|
951
1330
|
<Button size="sm" variant="secondary" icon="user-plus" onclick={() => openAssign()}>
|
|
952
1331
|
{t('accr_assign')}
|
|
953
1332
|
</Button>
|
|
954
1333
|
{/if}
|
|
955
1334
|
{/snippet}
|
|
956
1335
|
|
|
957
|
-
{#if policiesLoading}
|
|
1336
|
+
{#if policiesLoading || carryLoading}
|
|
958
1337
|
<div class="rows">
|
|
959
1338
|
{#each [1, 2, 3, 4] as n (n)}<Skeleton height="44px" />{/each}
|
|
960
1339
|
</div>
|
|
961
|
-
{:else if
|
|
1340
|
+
{:else if ladderAssignments.length}
|
|
962
1341
|
{#if !workspaceDefault}
|
|
963
1342
|
<!-- The bottom rung is the only one that catches everybody, and its absence is silent
|
|
964
1343
|
everywhere else: people with nothing nearer simply never accrue. -->
|
|
@@ -981,7 +1360,15 @@ const HEAD = 10
|
|
|
981
1360
|
<Badge tone="grey">{rungLabel(rung.kind)}</Badge>
|
|
982
1361
|
<span class="strong">{subjectName(rung.kind, row.assignment.subjectId)}</span>
|
|
983
1362
|
</span>
|
|
984
|
-
<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>
|
|
985
1372
|
<span class="sub rwhen">
|
|
986
1373
|
{rangeLabel(row.assignment.effectiveFrom, row.assignment.effectiveTo)}
|
|
987
1374
|
</span>
|
|
@@ -1003,20 +1390,30 @@ const HEAD = 10
|
|
|
1003
1390
|
</div>
|
|
1004
1391
|
{/each}
|
|
1005
1392
|
</div>
|
|
1006
|
-
{:else if policiesQuery.isError}
|
|
1393
|
+
{:else if policiesQuery.isError || carryQuery.isError}
|
|
1007
1394
|
<EmptyState icon="triangle-alert" title={t('accr_ladder_error')}>
|
|
1008
1395
|
{#snippet actions()}
|
|
1009
|
-
<Button
|
|
1396
|
+
<Button
|
|
1397
|
+
variant="secondary"
|
|
1398
|
+
onclick={() => {
|
|
1399
|
+
void policiesQuery.refetch()
|
|
1400
|
+
void carryQuery.refetch()
|
|
1401
|
+
}}
|
|
1402
|
+
>
|
|
1403
|
+
{t('retry')}
|
|
1404
|
+
</Button>
|
|
1010
1405
|
{/snippet}
|
|
1011
1406
|
</EmptyState>
|
|
1012
1407
|
{:else}
|
|
1013
1408
|
<EmptyState
|
|
1014
1409
|
icon="git-branch"
|
|
1015
1410
|
title={t('accr_ladder_none')}
|
|
1016
|
-
description={
|
|
1411
|
+
description={ladderPolicies.length > 0
|
|
1412
|
+
? t('accr_ladder_none_desc')
|
|
1413
|
+
: t('accr_ladder_needs_policy')}
|
|
1017
1414
|
>
|
|
1018
1415
|
{#snippet actions()}
|
|
1019
|
-
{#if manage &&
|
|
1416
|
+
{#if manage && ladderPolicies.length > 0}
|
|
1020
1417
|
<Button icon="user-plus" onclick={() => openAssign()}>{t('accr_assign')}</Button>
|
|
1021
1418
|
{:else if manage}
|
|
1022
1419
|
<Button icon="plus" onclick={openCreate}>{t('accr_new')}</Button>
|
|
@@ -1236,6 +1633,118 @@ const HEAD = 10
|
|
|
1236
1633
|
{/snippet}
|
|
1237
1634
|
</Dialog>
|
|
1238
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
|
+
|
|
1239
1748
|
<!-- ---------------------------------------------------------------- archive a policy -->
|
|
1240
1749
|
<Dialog
|
|
1241
1750
|
open={archiving !== null}
|
|
@@ -1289,7 +1798,13 @@ const HEAD = 10
|
|
|
1289
1798
|
onValueChange={(v) => (assignPolicyId = v)}
|
|
1290
1799
|
placeholder={t('accr_assign_policy_pick')}
|
|
1291
1800
|
ariaLabel={t('accr_assign_policy')}
|
|
1292
|
-
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
|
+
}))}
|
|
1293
1808
|
/>
|
|
1294
1809
|
{/snippet}
|
|
1295
1810
|
</Field>
|
|
@@ -1390,7 +1905,7 @@ const HEAD = 10
|
|
|
1390
1905
|
open={unassigning !== null}
|
|
1391
1906
|
size="sm"
|
|
1392
1907
|
title={unassigning
|
|
1393
|
-
? t('accr_unassign_title', {
|
|
1908
|
+
? t(unassignIsCarry ? 'cf_unassign_title' : 'accr_unassign_title', {
|
|
1394
1909
|
subject: subjectName(unassigning.assignment.subjectKind, unassigning.assignment.subjectId),
|
|
1395
1910
|
})
|
|
1396
1911
|
: ''}
|
|
@@ -1402,9 +1917,9 @@ const HEAD = 10
|
|
|
1402
1917
|
{#if unassigning}
|
|
1403
1918
|
<!-- What happens to the people underneath is the whole question, and it has two answers. -->
|
|
1404
1919
|
<p class="body">
|
|
1405
|
-
{
|
|
1406
|
-
? t('accr_unassign_falls_back', { name:
|
|
1407
|
-
: 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')}
|
|
1408
1923
|
</p>
|
|
1409
1924
|
<p class="body sub">{t('accr_unassign_keeps')}</p>
|
|
1410
1925
|
{/if}
|
|
@@ -1562,11 +2077,22 @@ const HEAD = 10
|
|
|
1562
2077
|
margin-block-end: 18px;
|
|
1563
2078
|
}
|
|
1564
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
|
+
|
|
1565
2086
|
/* One grid for the header and every row, so the columns line up down the page. */
|
|
1566
2087
|
.table {
|
|
1567
2088
|
width: 100%;
|
|
1568
2089
|
--hr-accr-cols: minmax(150px, 1.4fr) minmax(120px, 1.2fr) minmax(110px, 1fr) 56px 32px;
|
|
1569
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
|
+
}
|
|
1570
2096
|
.thead,
|
|
1571
2097
|
.trow {
|
|
1572
2098
|
display: grid;
|
|
@@ -1660,7 +2186,9 @@ const HEAD = 10
|
|
|
1660
2186
|
}
|
|
1661
2187
|
.rlist li {
|
|
1662
2188
|
display: grid;
|
|
1663
|
-
|
|
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;
|
|
1664
2192
|
gap: 10px;
|
|
1665
2193
|
align-items: center;
|
|
1666
2194
|
min-height: 36px;
|
|
@@ -1678,6 +2206,12 @@ const HEAD = 10
|
|
|
1678
2206
|
min-width: 0;
|
|
1679
2207
|
}
|
|
1680
2208
|
.rpolicy {
|
|
2209
|
+
display: flex;
|
|
2210
|
+
align-items: center;
|
|
2211
|
+
gap: 8px;
|
|
2212
|
+
min-width: 0;
|
|
2213
|
+
}
|
|
2214
|
+
.rname {
|
|
1681
2215
|
min-width: 0;
|
|
1682
2216
|
overflow: hidden;
|
|
1683
2217
|
text-overflow: ellipsis;
|
|
@@ -1831,7 +2365,12 @@ const HEAD = 10
|
|
|
1831
2365
|
.table {
|
|
1832
2366
|
--hr-accr-cols: minmax(140px, 1fr) minmax(100px, 0.9fr) 32px;
|
|
1833
2367
|
}
|
|
1834
|
-
/*
|
|
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. */
|
|
1835
2374
|
.thead > :nth-child(3),
|
|
1836
2375
|
.trow > :nth-child(3),
|
|
1837
2376
|
.thead > :nth-child(4),
|