@kernhq/module-hr 0.9.3 → 0.10.1
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/approvals.d.ts +3 -0
- package/dist/contract/approvals.d.ts.map +1 -1
- package/dist/contract/approvals.js +13 -1
- package/dist/contract/approvals.js.map +1 -1
- package/dist/contract/capabilities.d.ts +16 -0
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +16 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/router.d.ts +9 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/policy/accrual.d.ts +35 -4
- package/dist/policy/accrual.d.ts.map +1 -1
- package/dist/policy/accrual.js +141 -15
- package/dist/policy/accrual.js.map +1 -1
- package/dist/policy/working-time.d.ts +115 -9
- package/dist/policy/working-time.d.ts.map +1 -1
- package/dist/policy/working-time.js +120 -20
- package/dist/policy/working-time.js.map +1 -1
- package/dist/server/jobs.d.ts +14 -6
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +458 -144
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/packs/index.d.ts +14 -0
- package/dist/server/packs/index.d.ts.map +1 -1
- package/dist/server/packs/index.js +19 -0
- package/dist/server/packs/index.js.map +1 -1
- package/dist/server/router.d.ts +1896 -111
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +146 -63
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +53 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +101 -2
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +14 -0
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +14 -3
- package/dist/server/services/approvals.js.map +1 -1
- package/dist/server/services/attendance.d.ts +120 -6
- package/dist/server/services/attendance.d.ts.map +1 -1
- package/dist/server/services/attendance.js +226 -13
- package/dist/server/services/attendance.js.map +1 -1
- package/dist/server/services/policies.d.ts +6 -0
- package/dist/server/services/policies.d.ts.map +1 -1
- package/dist/server/services/policies.js +6 -0
- package/dist/server/services/policies.js.map +1 -1
- package/migrations/0005_approval_requester.sql +30 -0
- package/migrations/0006_schedule_no_overlap.sql +85 -0
- package/migrations/0007_hot_path_indexes.sql +38 -0
- package/migrations/0009_beyond_cap_minutes.sql +15 -0
- package/migrations/meta/0005_snapshot.json +4047 -0
- package/migrations/meta/0007_snapshot.json +4225 -0
- package/migrations/meta/0009_snapshot.json +4231 -0
- package/migrations/meta/_journal.json +28 -0
- package/package.json +7 -7
- package/src/client/capabilities.ts +34 -0
- package/src/client/components/ClockControls.svelte +127 -20
- package/src/client/components/ClockControls.test.ts +446 -0
- package/src/client/components/DecisionDialog.svelte +90 -0
- package/src/client/components/DelegationDialog.svelte +219 -0
- package/src/client/components/LeaveRequestDialog.svelte +254 -25
- package/src/client/components/PersonFormDialog.svelte +53 -3
- package/src/client/components/PersonPanel.svelte +134 -26
- package/src/client/i18n.ts +5 -625
- package/src/client/index.ts +1 -20
- package/src/client/messages.test.ts +154 -0
- package/src/client/messages.ts +3388 -0
- package/src/client/mock.ts +1691 -162
- package/src/client/module.ts +17 -2
- package/src/client/pages/ApprovalsPage.svelte +332 -60
- package/src/client/pages/AttendancePage.svelte +106 -20
- package/src/client/pages/DirectoryPage.svelte +192 -55
- package/src/client/pages/LeavePage.svelte +280 -25
- package/src/client/pages/OfficesPage.svelte +181 -39
- package/src/client/pages/leave-and-attendance.test.ts +588 -0
- package/src/client/permissions.ts +2 -1
- package/src/client/query.ts +22 -1
- package/src/client/settings/CalendarsSettings.svelte +1363 -12
- package/src/client/settings/CapabilitiesSettings.svelte +276 -19
- package/src/client/settings/GeneralSettings.svelte +420 -0
- package/src/client/settings/LeaveSettings.svelte +884 -12
- package/src/client/settings/OfficesSettings.svelte +1182 -12
- package/src/client/settings/SchedulesSettings.svelte +1149 -12
- package/src/client/summary.ts +43 -0
- package/src/client/widgets/ApprovalsWidget.svelte +133 -20
- package/src/client/widgets/HeadcountWidget.svelte +74 -8
- package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
- package/src/client/widgets/WhosOutWidget.svelte +47 -8
- package/src/contract/approvals.ts +13 -1
- package/src/contract/capabilities.ts +17 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
coreApi,
|
|
5
|
+
Dialog,
|
|
6
|
+
EmptyState,
|
|
7
|
+
Field,
|
|
8
|
+
Icon,
|
|
9
|
+
Input,
|
|
10
|
+
keys,
|
|
11
|
+
messageLocale,
|
|
12
|
+
navigation,
|
|
13
|
+
Select,
|
|
14
|
+
type SelectOption,
|
|
15
|
+
SettingsPage,
|
|
16
|
+
SettingsSection,
|
|
17
|
+
Skeleton,
|
|
18
|
+
session,
|
|
19
|
+
toast,
|
|
20
|
+
} from '@kernhq/ui'
|
|
21
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
22
|
+
import { HrSettings } from '../../contract/settings.js'
|
|
23
|
+
import type { CoreApi } from '../core-api.js'
|
|
24
|
+
import { t } from '../i18n.js'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The two workspace-wide facts HR keeps: where it was set up, and how employee numbers are issued.
|
|
28
|
+
*
|
|
29
|
+
* Settings live in core, not in this module's own schema, so they are read from
|
|
30
|
+
* `workspaces.modules.list` and written with `workspaces.modules.updateSettings` — the same
|
|
31
|
+
* mechanism the capabilities screen uses, and the reason the module never needs a settings table.
|
|
32
|
+
*
|
|
33
|
+
* **The write carries the whole `HrSettings` object.** Core merges a partial write now, but this
|
|
34
|
+
* page owns every field in that schema and sending it whole is what makes the round trip
|
|
35
|
+
* self-evident: nothing here can be a field somebody forgot to carry forward.
|
|
36
|
+
*
|
|
37
|
+
* `directoryVisibleToMembers` is in the schema and is deliberately *not* on this page. Nothing in
|
|
38
|
+
* `src/server` reads it, so a switch for it would promise a rule the API does not enforce. It is
|
|
39
|
+
* carried through the write unchanged until it is either enforced or removed.
|
|
40
|
+
*/
|
|
41
|
+
const api = coreApi<CoreApi>()
|
|
42
|
+
const queryClient = useQueryClient()
|
|
43
|
+
|
|
44
|
+
const workspaceSlug = $derived(navigation.workspaceSlug)
|
|
45
|
+
const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
|
|
46
|
+
const workspaceId = $derived(workspace?.id ?? '')
|
|
47
|
+
|
|
48
|
+
/** The permission core actually enforces on `workspaces.modules.updateSettings`. */
|
|
49
|
+
const canManage = $derived(session.can('core.modules.manage'))
|
|
50
|
+
|
|
51
|
+
const modulesQuery = createQuery(() => ({
|
|
52
|
+
queryKey: keys.modules(workspaceId),
|
|
53
|
+
enabled: Boolean(workspaceId),
|
|
54
|
+
queryFn: () => api.workspaces.modules.list({ workspaceId }),
|
|
55
|
+
}))
|
|
56
|
+
|
|
57
|
+
const hr = $derived(modulesQuery.data?.find((entry) => entry.manifest.id === 'hr'))
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What is stored, with the schema's defaults applied and `$capabilities` stripped by the parse.
|
|
61
|
+
*
|
|
62
|
+
* `safeParse` rather than `parse`: a settings blob written before a field existed must not take the
|
|
63
|
+
* page down, and every field has a default, so the fallback is the same shape a new workspace has.
|
|
64
|
+
*/
|
|
65
|
+
const stored = $derived.by(() => {
|
|
66
|
+
const parsed = HrSettings.safeParse(hr?.state.settings ?? {})
|
|
67
|
+
return parsed.success ? parsed.data : HrSettings.parse({})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
let country = $state('')
|
|
71
|
+
let prefix = $state('')
|
|
72
|
+
/**
|
|
73
|
+
* The counter is held as text, not a number: an input somebody is half-way through clearing is
|
|
74
|
+
* empty, and `bind:value` on a number input turns that into `undefined` — which reads as "no
|
|
75
|
+
* change" and would save the old value behind their back.
|
|
76
|
+
*/
|
|
77
|
+
let nextNumber = $state('')
|
|
78
|
+
let loaded = $state(false)
|
|
79
|
+
|
|
80
|
+
$effect(() => {
|
|
81
|
+
if (loaded || !hr) return
|
|
82
|
+
country = stored.country
|
|
83
|
+
prefix = stored.employeeNumberPrefix
|
|
84
|
+
nextNumber = String(stored.employeeNumberNext)
|
|
85
|
+
loaded = true
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
/** Discard and re-seed are the same act: drop the edits and let the effect above refill them. */
|
|
89
|
+
function discard() {
|
|
90
|
+
loaded = false
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const prefixError = $derived(prefix.length > 8 ? t('general_prefix_too_long') : null)
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A Persian keyboard produces ۱۲۳ and an Arabic one ١٢٣, and `Number` reads neither — so a person
|
|
97
|
+
* typing the digits of their own language into a counter would be told their input is not a whole
|
|
98
|
+
* number. They are folded to ASCII before parsing; the field keeps what was actually typed.
|
|
99
|
+
*/
|
|
100
|
+
const toLatinDigits = (value: string) =>
|
|
101
|
+
value
|
|
102
|
+
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660))
|
|
103
|
+
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
|
|
104
|
+
|
|
105
|
+
/** Nine digits at most, so `Number` cannot silently round a pasted counter into a different one. */
|
|
106
|
+
const nextDigits = $derived(toLatinDigits(nextNumber.trim()))
|
|
107
|
+
const parsedNext = $derived(/^\d{1,9}$/.test(nextDigits) ? Number(nextDigits) : Number.NaN)
|
|
108
|
+
const nextError = $derived(parsedNext >= 1 ? null : t('general_next_invalid'))
|
|
109
|
+
const valid = $derived(!prefixError && !nextError)
|
|
110
|
+
|
|
111
|
+
const dirty = $derived(
|
|
112
|
+
loaded &&
|
|
113
|
+
(country !== stored.country ||
|
|
114
|
+
prefix !== stored.employeeNumberPrefix ||
|
|
115
|
+
parsedNext !== stored.employeeNumberNext),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* No prefix and a counter of 1 is how the server spells "off" — `nextEmployeeNo` returns null and
|
|
120
|
+
* the person is created without a number. It is a real state, not an empty form, so the page says
|
|
121
|
+
* so rather than previewing a number nobody will be given.
|
|
122
|
+
*/
|
|
123
|
+
const numberingOff = $derived(prefix === '' && parsedNext === 1)
|
|
124
|
+
const preview = $derived(numberingOff || Number.isNaN(parsedNext) ? null : `${prefix}${parsedNext}`)
|
|
125
|
+
|
|
126
|
+
/** Moving the counter backwards re-issues numbers that may already belong to somebody. */
|
|
127
|
+
const rewinding = $derived(!Number.isNaN(parsedNext) && parsedNext < stored.employeeNumberNext)
|
|
128
|
+
|
|
129
|
+
let saving = $state(false)
|
|
130
|
+
let saveError = $state<string | null>(null)
|
|
131
|
+
let confirmRewind = $state(false)
|
|
132
|
+
|
|
133
|
+
const save = createMutation(() => ({
|
|
134
|
+
mutationFn: () =>
|
|
135
|
+
api.workspaces.modules.updateSettings({
|
|
136
|
+
workspaceId,
|
|
137
|
+
moduleId: 'hr',
|
|
138
|
+
settings: {
|
|
139
|
+
country,
|
|
140
|
+
employeeNumberPrefix: prefix,
|
|
141
|
+
employeeNumberNext: parsedNext,
|
|
142
|
+
// Not editable here, and carried through rather than dropped: omitting it would leave the
|
|
143
|
+
// stored value to core's merge, which is a fact about core rather than about this page.
|
|
144
|
+
directoryVisibleToMembers: stored.directoryVisibleToMembers,
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
onSuccess: () => {
|
|
148
|
+
confirmRewind = false
|
|
149
|
+
saveError = null
|
|
150
|
+
// Re-seed from whatever the server stored: the counter moves on its own every time somebody is
|
|
151
|
+
// hired, so what came back is the truth and what is in the form is only what was sent.
|
|
152
|
+
loaded = false
|
|
153
|
+
void queryClient.invalidateQueries({ queryKey: keys.modules(workspaceId) })
|
|
154
|
+
toast.success(t('general_saved'))
|
|
155
|
+
},
|
|
156
|
+
onError: (err) => {
|
|
157
|
+
saveError = err instanceof Error && err.message ? err.message : t('general_save_error')
|
|
158
|
+
},
|
|
159
|
+
onSettled: () => {
|
|
160
|
+
saving = false
|
|
161
|
+
},
|
|
162
|
+
}))
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* `disabled={save.isPending}` reaches the button a render too late, and two quick clicks are one
|
|
166
|
+
* render apart — so the guard is a plain flag set in the same tick as the click.
|
|
167
|
+
*/
|
|
168
|
+
function runSave() {
|
|
169
|
+
if (saving) return
|
|
170
|
+
saving = true
|
|
171
|
+
saveError = null
|
|
172
|
+
save.mutate()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function requestSave() {
|
|
176
|
+
if (saving || !dirty || !valid) return
|
|
177
|
+
if (rewinding) {
|
|
178
|
+
confirmRewind = true
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
runSave()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Every country the runtime can name, in the reader's own language.
|
|
186
|
+
*
|
|
187
|
+
* The same technique the offices screen uses, and deliberately the same: there is no
|
|
188
|
+
* `Intl.supportedValuesOf('region')`, so the set comes from asking `DisplayNames` about all 676
|
|
189
|
+
* two-letter codes and keeping the ones it answers with a name. Two country lists in one module
|
|
190
|
+
* would eventually disagree with each other, and a table of codes in this file goes stale the next
|
|
191
|
+
* time a country changes its name.
|
|
192
|
+
*/
|
|
193
|
+
const COUNTRY_CACHE = new Map<string, SelectOption[]>()
|
|
194
|
+
/** Codes `DisplayNames` names that ISO 3166-1 does not: unions, outlying areas, reserved codes. */
|
|
195
|
+
const NOT_COUNTRIES = new Set(['AC', 'CP', 'DG', 'EA', 'EU', 'EZ', 'IC', 'QO', 'TA', 'UN', 'XA', 'XB', 'ZZ'])
|
|
196
|
+
|
|
197
|
+
function countryOptions(locale: string): SelectOption[] {
|
|
198
|
+
const cached = COUNTRY_CACHE.get(locale)
|
|
199
|
+
if (cached) return cached
|
|
200
|
+
const options: SelectOption[] = []
|
|
201
|
+
try {
|
|
202
|
+
const names = new Intl.DisplayNames(locale, { type: 'region' })
|
|
203
|
+
for (let first = 65; first <= 90; first++) {
|
|
204
|
+
for (let second = 65; second <= 90; second++) {
|
|
205
|
+
const code = String.fromCharCode(first, second)
|
|
206
|
+
if (NOT_COUNTRIES.has(code)) continue
|
|
207
|
+
const label = names.of(code)
|
|
208
|
+
if (!label || label === code) continue
|
|
209
|
+
options.push({ value: code, label })
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
options.sort((a, b) => a.label.localeCompare(b.label, locale))
|
|
213
|
+
} catch {
|
|
214
|
+
// A runtime without region display names must not take the form down with it; the stored code
|
|
215
|
+
// is still a valid answer, and the branch below keeps it selectable.
|
|
216
|
+
}
|
|
217
|
+
COUNTRY_CACHE.set(locale, options)
|
|
218
|
+
return options
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const countries = $derived.by((): SelectOption[] => {
|
|
222
|
+
const list = countryOptions(messageLocale())
|
|
223
|
+
// A stored country the runtime cannot name has to stay selectable, or opening this page and
|
|
224
|
+
// saving anything else would quietly change it to whatever happened to be first.
|
|
225
|
+
if (country && !list.some((o) => o.value === country)) return [{ value: country, label: country }, ...list]
|
|
226
|
+
return list
|
|
227
|
+
})
|
|
228
|
+
</script>
|
|
229
|
+
|
|
230
|
+
{#snippet saveBar()}
|
|
231
|
+
<Button size="sm" variant="secondary" onclick={discard} disabled={!dirty || saving}>
|
|
232
|
+
{t('common.discard')}
|
|
233
|
+
</Button>
|
|
234
|
+
<Button size="sm" onclick={requestSave} disabled={!dirty || !valid} loading={saving}>
|
|
235
|
+
{t('common.save')}
|
|
236
|
+
</Button>
|
|
237
|
+
{/snippet}
|
|
238
|
+
|
|
239
|
+
<SettingsPage title={t('settings_general')} description={t('settings_general_desc')}>
|
|
240
|
+
{#if modulesQuery.isLoading}
|
|
241
|
+
<Skeleton height="150px" radius="10px" />
|
|
242
|
+
<Skeleton height="260px" radius="10px" />
|
|
243
|
+
{:else if modulesQuery.isError}
|
|
244
|
+
<EmptyState icon="triangle-alert" title={t('general_error')} description={t('general_error_desc')}>
|
|
245
|
+
{#snippet actions()}
|
|
246
|
+
<Button variant="secondary" onclick={() => void modulesQuery.refetch()}>{t('retry')}</Button>
|
|
247
|
+
{/snippet}
|
|
248
|
+
</EmptyState>
|
|
249
|
+
{:else if !hr}
|
|
250
|
+
<EmptyState icon="users" title={t('general_not_enabled')} description={t('general_not_enabled_desc')} />
|
|
251
|
+
{:else}
|
|
252
|
+
{#if !canManage}
|
|
253
|
+
<p class="readonly">{t('general_readonly')}</p>
|
|
254
|
+
{/if}
|
|
255
|
+
|
|
256
|
+
<SettingsSection
|
|
257
|
+
title={t('office_country')}
|
|
258
|
+
description={t('general_country_desc')}
|
|
259
|
+
footer={canManage ? saveBar : undefined}
|
|
260
|
+
>
|
|
261
|
+
<Field inline label={t('office_country')} hint={t('general_country_hint')}>
|
|
262
|
+
{#snippet children(id)}
|
|
263
|
+
<Select
|
|
264
|
+
{id}
|
|
265
|
+
bind:value={country}
|
|
266
|
+
options={countries}
|
|
267
|
+
placeholder={t('office_country')}
|
|
268
|
+
disabled={!canManage}
|
|
269
|
+
width="280px"
|
|
270
|
+
/>
|
|
271
|
+
{/snippet}
|
|
272
|
+
</Field>
|
|
273
|
+
</SettingsSection>
|
|
274
|
+
|
|
275
|
+
<SettingsSection
|
|
276
|
+
title={t('general_numbering')}
|
|
277
|
+
description={t('general_numbering_desc')}
|
|
278
|
+
footer={canManage ? saveBar : undefined}
|
|
279
|
+
>
|
|
280
|
+
<div class="rows">
|
|
281
|
+
<Field inline label={t('general_prefix')} hint={t('general_prefix_hint')} error={prefixError}>
|
|
282
|
+
{#snippet children(id)}
|
|
283
|
+
<div class="narrow">
|
|
284
|
+
<Input {id} bind:value={prefix} maxlength={8} mono disabled={!canManage} />
|
|
285
|
+
</div>
|
|
286
|
+
{/snippet}
|
|
287
|
+
</Field>
|
|
288
|
+
|
|
289
|
+
<Field inline label={t('general_next')} hint={t('general_next_hint')} error={nextError}>
|
|
290
|
+
{#snippet children(id)}
|
|
291
|
+
<div class="narrow">
|
|
292
|
+
<Input
|
|
293
|
+
{id}
|
|
294
|
+
bind:value={nextNumber}
|
|
295
|
+
inputmode="numeric"
|
|
296
|
+
maxlength={9}
|
|
297
|
+
mono
|
|
298
|
+
disabled={!canManage}
|
|
299
|
+
/>
|
|
300
|
+
</div>
|
|
301
|
+
{/snippet}
|
|
302
|
+
</Field>
|
|
303
|
+
|
|
304
|
+
<div class="preview">
|
|
305
|
+
<span class="preview-label">{t('general_preview')}</span>
|
|
306
|
+
<!--
|
|
307
|
+
The number is interpolated as text on purpose. It is an identifier the server builds by
|
|
308
|
+
concatenation, so a Persian reader has to see the same characters their colleague's
|
|
309
|
+
badge carries — this is the one place on the page where localised digits would be wrong.
|
|
310
|
+
-->
|
|
311
|
+
<span class="preview-value">{preview ?? '—'}</span>
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
{#if numberingOff}
|
|
315
|
+
<p class="note">{t('general_numbering_off')}</p>
|
|
316
|
+
{/if}
|
|
317
|
+
{#if rewinding}
|
|
318
|
+
<p class="note warn">
|
|
319
|
+
<Icon name="triangle-alert" size={14} strokeWidth={1.7} />
|
|
320
|
+
<span>{t('general_rewind_warning', { value: String(parsedNext) })}</span>
|
|
321
|
+
</p>
|
|
322
|
+
{/if}
|
|
323
|
+
</div>
|
|
324
|
+
</SettingsSection>
|
|
325
|
+
|
|
326
|
+
{#if saveError}
|
|
327
|
+
<p class="save-error" role="alert">{saveError}</p>
|
|
328
|
+
{/if}
|
|
329
|
+
{/if}
|
|
330
|
+
</SettingsPage>
|
|
331
|
+
|
|
332
|
+
<Dialog
|
|
333
|
+
open={confirmRewind}
|
|
334
|
+
size="sm"
|
|
335
|
+
title={t('general_rewind_title')}
|
|
336
|
+
onOpenChange={(open) => {
|
|
337
|
+
if (!open) confirmRewind = false
|
|
338
|
+
}}
|
|
339
|
+
>
|
|
340
|
+
<p class="dialog-body">{t('general_rewind_body', { value: String(parsedNext) })}</p>
|
|
341
|
+
{#snippet footer()}
|
|
342
|
+
<Button size="sm" variant="secondary" onclick={() => (confirmRewind = false)} disabled={saving}>
|
|
343
|
+
{t('common.cancel')}
|
|
344
|
+
</Button>
|
|
345
|
+
<Button size="sm" variant="danger" onclick={runSave} loading={saving}>
|
|
346
|
+
{t('general_rewind_confirm')}
|
|
347
|
+
</Button>
|
|
348
|
+
{/snippet}
|
|
349
|
+
</Dialog>
|
|
350
|
+
|
|
351
|
+
<style>
|
|
352
|
+
.readonly {
|
|
353
|
+
margin: 0;
|
|
354
|
+
font-size: 12.5px;
|
|
355
|
+
/* A colour, not opacity: opacity fades text against the page whatever token it names. */
|
|
356
|
+
color: var(--kern-ink-500);
|
|
357
|
+
}
|
|
358
|
+
.rows {
|
|
359
|
+
display: grid;
|
|
360
|
+
gap: 14px;
|
|
361
|
+
}
|
|
362
|
+
.narrow {
|
|
363
|
+
max-inline-size: 180px;
|
|
364
|
+
}
|
|
365
|
+
.preview {
|
|
366
|
+
display: flex;
|
|
367
|
+
align-items: baseline;
|
|
368
|
+
gap: 10px;
|
|
369
|
+
padding: 10px 12px;
|
|
370
|
+
border-radius: var(--kern-r-md);
|
|
371
|
+
background: var(--kern-surface-chip);
|
|
372
|
+
}
|
|
373
|
+
.preview-label {
|
|
374
|
+
font-size: 12.5px;
|
|
375
|
+
color: var(--kern-ink-500);
|
|
376
|
+
}
|
|
377
|
+
.preview-value {
|
|
378
|
+
font-family: var(--kern-font-mono);
|
|
379
|
+
font-size: 14px;
|
|
380
|
+
font-weight: 500;
|
|
381
|
+
color: var(--kern-ink-900);
|
|
382
|
+
/* An employee number is read digit by digit, so the glyphs line up rather than reflow. */
|
|
383
|
+
font-variant-numeric: tabular-nums;
|
|
384
|
+
/* The identifier stays left-to-right inside a Persian or Arabic sentence. */
|
|
385
|
+
direction: ltr;
|
|
386
|
+
unicode-bidi: isolate;
|
|
387
|
+
}
|
|
388
|
+
.note {
|
|
389
|
+
margin: 0;
|
|
390
|
+
font-size: 12.5px;
|
|
391
|
+
line-height: 1.5;
|
|
392
|
+
color: var(--kern-ink-500);
|
|
393
|
+
}
|
|
394
|
+
.note.warn {
|
|
395
|
+
display: flex;
|
|
396
|
+
align-items: flex-start;
|
|
397
|
+
gap: 8px;
|
|
398
|
+
padding: 10px 12px;
|
|
399
|
+
border-radius: var(--kern-r-md);
|
|
400
|
+
/* Measured: #925e22 on #f7e9d8 is 4.58:1, #d59548 on #3a2c1c is 5.28:1. */
|
|
401
|
+
background: var(--kern-warning-tint);
|
|
402
|
+
color: var(--kern-warning);
|
|
403
|
+
}
|
|
404
|
+
/* The icon comes from a component, so the scoped selector cannot reach its svg without this. */
|
|
405
|
+
.note.warn :global(svg) {
|
|
406
|
+
flex: none;
|
|
407
|
+
margin-block-start: 1px;
|
|
408
|
+
}
|
|
409
|
+
.save-error {
|
|
410
|
+
margin: 0;
|
|
411
|
+
font-size: 12.5px;
|
|
412
|
+
color: var(--kern-danger);
|
|
413
|
+
}
|
|
414
|
+
.dialog-body {
|
|
415
|
+
margin: 0;
|
|
416
|
+
font-size: 13.5px;
|
|
417
|
+
line-height: 1.55;
|
|
418
|
+
color: var(--kern-ink-700);
|
|
419
|
+
}
|
|
420
|
+
</style>
|