@kernhq/module-hr 0.10.0 → 0.10.2
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 +1 -0
- package/dist/contract/attendance.d.ts.map +1 -1
- package/dist/contract/attendance.js +9 -0
- package/dist/contract/attendance.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 +1 -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 +489 -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 +1888 -111
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +154 -63
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +17 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +81 -2
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +0 -2
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +12 -4
- package/dist/server/services/approvals.js.map +1 -1
- package/dist/server/services/attendance.d.ts +120 -8
- package/dist/server/services/attendance.d.ts.map +1 -1
- package/dist/server/services/attendance.js +226 -14
- package/dist/server/services/attendance.js.map +1 -1
- package/dist/server/services/ledger.d.ts +0 -2
- package/dist/server/services/ledger.d.ts.map +1 -1
- package/dist/server/services/ledger.js +1 -2
- package/dist/server/services/ledger.js.map +1 -1
- package/dist/server/services/policies.d.ts +6 -2
- package/dist/server/services/policies.d.ts.map +1 -1
- package/dist/server/services/policies.js +7 -2
- package/dist/server/services/policies.js.map +1 -1
- package/migrations/0005_approval_requester.sql +11 -1
- 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 +21 -0
- package/package.json +7 -7
- package/src/client/components/ClockControls.svelte +127 -20
- package/src/client/components/ClockControls.test.ts +446 -0
- package/src/client/components/DelegationDialog.svelte +12 -3
- 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 -927
- package/src/client/messages.test.ts +154 -0
- package/src/client/messages.ts +3388 -0
- package/src/client/mock.ts +1576 -161
- package/src/client/module.ts +15 -1
- package/src/client/pages/ApprovalsPage.svelte +120 -46
- 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 +26 -8
- package/src/client/pages/leave-and-attendance.test.ts +588 -0
- package/src/client/query.ts +12 -0
- 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 +14 -7
- 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/attendance.ts +9 -0
- package/src/contract/capabilities.ts +17 -0
|
@@ -1,21 +1,1191 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
type BadgeTone,
|
|
5
|
+
Button,
|
|
6
|
+
Checkbox,
|
|
7
|
+
Dialog,
|
|
8
|
+
DropdownMenu,
|
|
9
|
+
EmptyState,
|
|
10
|
+
Field,
|
|
11
|
+
formatCount,
|
|
12
|
+
IconButton,
|
|
13
|
+
Input,
|
|
14
|
+
type MenuItem,
|
|
15
|
+
messageLocale,
|
|
16
|
+
navigation,
|
|
17
|
+
SectionLabel,
|
|
18
|
+
SegmentedControl,
|
|
19
|
+
Select,
|
|
20
|
+
type SelectOption,
|
|
21
|
+
SettingsPage,
|
|
22
|
+
SettingsSection,
|
|
23
|
+
Sheet,
|
|
24
|
+
Skeleton,
|
|
25
|
+
StatTile,
|
|
26
|
+
session,
|
|
27
|
+
toast,
|
|
28
|
+
} from '@kernhq/ui'
|
|
29
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
30
|
+
import { getHrApi } from '../api-instance.js'
|
|
31
|
+
import { HR_CAPABILITIES } from '../capabilities.js'
|
|
3
32
|
import { t } from '../i18n.js'
|
|
33
|
+
import type { Office } from '../index.js'
|
|
34
|
+
import { canHr } from '../permissions.js'
|
|
35
|
+
import { hrKeys, isoDate } from '../query.js'
|
|
4
36
|
|
|
5
37
|
/**
|
|
6
|
-
*
|
|
38
|
+
* Where the company works, and who it decides for.
|
|
7
39
|
*
|
|
8
|
-
* The
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
40
|
+
* The read-only list lives on `/hr/offices`; this is the screen that creates one, and until it
|
|
41
|
+
* existed a workspace could never have a second office — every procedure behind offices was
|
|
42
|
+
* implemented and none of them had a caller.
|
|
43
|
+
*
|
|
44
|
+
* **The rule this screen exists to keep visible:** a person may hold several offices at once, and
|
|
45
|
+
* exactly one of them is primary. Only the primary decides their holidays, their policies and the
|
|
46
|
+
* timezone their day is attributed to; the others grant presence — the directory, the local HR
|
|
47
|
+
* view, later the geofence. Somebody who assigns a second office expecting a calendar to change has
|
|
48
|
+
* been misled, so the roster labels every row with which kind it is and the assignment control says
|
|
49
|
+
* what switching it on replaces.
|
|
50
|
+
*
|
|
51
|
+
* There is no "show archived" here on purpose. The contract has no `unarchive`, so a list of
|
|
52
|
+
* archived offices would be rows nothing can act on; the archive confirmation says where the office
|
|
53
|
+
* goes instead.
|
|
54
|
+
*/
|
|
55
|
+
const api = getHrApi()
|
|
56
|
+
const queryClient = useQueryClient()
|
|
57
|
+
|
|
58
|
+
const workspace = $derived(session.workspaces.find((w) => w.slug === navigation.workspaceSlug))
|
|
59
|
+
const workspaceId = $derived(workspace?.id ?? '')
|
|
60
|
+
|
|
61
|
+
const canManage = $derived(canHr('officeManage'))
|
|
62
|
+
const canAssign = $derived(canHr('officeAssign'))
|
|
63
|
+
const hasCalendars = $derived(session.hasCapability('hr', HR_CAPABILITIES.calendars))
|
|
64
|
+
const hasEntities = $derived(session.hasCapability('hr', HR_CAPABILITIES.legalEntities))
|
|
65
|
+
|
|
66
|
+
type OfficeRow = Office & { headcount: number }
|
|
67
|
+
|
|
68
|
+
/** The six kinds the contract declares, as the option list and the label map at once. */
|
|
69
|
+
const KINDS = ['head_office', 'branch', 'site', 'warehouse', 'store', 'remote'] as const
|
|
70
|
+
type Kind = (typeof KINDS)[number]
|
|
71
|
+
const KIND_LABELS: Record<Kind, () => string> = {
|
|
72
|
+
head_office: () => t('office_kind_head_office'),
|
|
73
|
+
branch: () => t('office_kind_branch'),
|
|
74
|
+
site: () => t('office_kind_site'),
|
|
75
|
+
warehouse: () => t('office_kind_warehouse'),
|
|
76
|
+
store: () => t('office_kind_store'),
|
|
77
|
+
remote: () => t('office_kind_remote'),
|
|
78
|
+
}
|
|
79
|
+
const kindLabel = (kind: string): string => KIND_LABELS[kind as Kind]?.() ?? kind
|
|
80
|
+
const kindTone = (kind: string): BadgeTone => (kind === 'remote' ? 'info' : 'grey')
|
|
81
|
+
|
|
82
|
+
/** `formatCount` caps at 99 for badges. A headcount is a real number and must not read "99+". */
|
|
83
|
+
const count = (n: number) => formatCount(n, Number.MAX_SAFE_INTEGER)
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------- reference data
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every country the runtime can name, in the reader's own language.
|
|
89
|
+
*
|
|
90
|
+
* There is no `Intl.supportedValuesOf('region')`, so the set is found by asking `DisplayNames`
|
|
91
|
+
* about all 676 two-letter codes and keeping the ones it answers with a name rather than handing
|
|
92
|
+
* the code back. That costs a fraction of a millisecond once per locale, and it means the list is
|
|
93
|
+
* the same CLDR data the rest of the interface uses instead of a table in this file that goes
|
|
94
|
+
* stale the next time a country changes its name.
|
|
95
|
+
*/
|
|
96
|
+
const COUNTRY_CACHE = new Map<string, SelectOption[]>()
|
|
97
|
+
/** Codes `DisplayNames` names that ISO 3166-1 does not: unions, outlying areas, reserved codes. */
|
|
98
|
+
const NOT_COUNTRIES = new Set(['AC', 'CP', 'DG', 'EA', 'EU', 'EZ', 'IC', 'QO', 'TA', 'UN', 'XA', 'XB', 'ZZ'])
|
|
99
|
+
|
|
100
|
+
function countryOptions(locale: string): SelectOption[] {
|
|
101
|
+
const cached = COUNTRY_CACHE.get(locale)
|
|
102
|
+
if (cached) return cached
|
|
103
|
+
const options: SelectOption[] = []
|
|
104
|
+
try {
|
|
105
|
+
const names = new Intl.DisplayNames(locale, { type: 'region' })
|
|
106
|
+
for (let first = 65; first <= 90; first++) {
|
|
107
|
+
for (let second = 65; second <= 90; second++) {
|
|
108
|
+
const code = String.fromCharCode(first, second)
|
|
109
|
+
if (NOT_COUNTRIES.has(code)) continue
|
|
110
|
+
const label = names.of(code)
|
|
111
|
+
if (!label || label === code) continue
|
|
112
|
+
options.push({ value: code, label })
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
options.sort((a, b) => a.label.localeCompare(b.label, locale))
|
|
116
|
+
} catch {
|
|
117
|
+
// A runtime without region display names must not take the form down with it; the code itself
|
|
118
|
+
// is still a valid answer, and the office being edited keeps whatever it already had.
|
|
119
|
+
}
|
|
120
|
+
COUNTRY_CACHE.set(locale, options)
|
|
121
|
+
return options
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const countries = $derived(countryOptions(messageLocale()))
|
|
125
|
+
const countryNames = $derived(new Map(countries.map((c) => [c.value, c.label])))
|
|
126
|
+
const countryLabel = (code: string): string => countryNames.get(code) ?? code
|
|
127
|
+
|
|
128
|
+
/** The IANA zones the runtime knows. Empty on a runtime without `supportedValuesOf`. */
|
|
129
|
+
const ZONES: string[] = (() => {
|
|
130
|
+
try {
|
|
131
|
+
return [...Intl.supportedValuesOf('timeZone')]
|
|
132
|
+
} catch {
|
|
133
|
+
return []
|
|
134
|
+
}
|
|
135
|
+
})()
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The browser's own zone as the starting point for a new office.
|
|
139
|
+
*
|
|
140
|
+
* `resolvedOptions()` reads a setting rather than formatting anything, so it is not the bare `Intl`
|
|
141
|
+
* call the house rule is about — nothing here is shown to the reader without going through the
|
|
142
|
+
* option list.
|
|
143
|
+
*/
|
|
144
|
+
function browserZone(): string {
|
|
145
|
+
try {
|
|
146
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || ''
|
|
147
|
+
} catch {
|
|
148
|
+
return ''
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------- what is open
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Every piece of "what is on screen" is declared before the first `createQuery`, and that ordering
|
|
156
|
+
* is load-bearing rather than tidy.
|
|
157
|
+
*
|
|
158
|
+
* `createQuery` evaluates its options function immediately to build the observer, so an `enabled`
|
|
159
|
+
* that reads a `$state` declared further down the file throws "Cannot access 'draft' before
|
|
160
|
+
* initialization" — at runtime only, on the first render, which nothing here type-checks.
|
|
161
|
+
*/
|
|
162
|
+
interface Draft {
|
|
163
|
+
/** `null` while creating. The two procedures take different fields, so this decides which. */
|
|
164
|
+
id: string | null
|
|
165
|
+
name: string
|
|
166
|
+
code: string
|
|
167
|
+
kind: Kind
|
|
168
|
+
country: string
|
|
169
|
+
region: string
|
|
170
|
+
city: string
|
|
171
|
+
timezone: string
|
|
172
|
+
parentOfficeId: string
|
|
173
|
+
legalEntityId: string
|
|
174
|
+
calendarId: string
|
|
175
|
+
headPersonId: string
|
|
176
|
+
seedCalendarFromPack: boolean
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let draft = $state<Draft | null>(null)
|
|
180
|
+
let formError = $state<string | null>(null)
|
|
181
|
+
/**
|
|
182
|
+
* `disabled={save.isPending}` reaches the button one render late, and two quick clicks are one
|
|
183
|
+
* render apart — which on create is two offices. The guard is set in the same tick as the click.
|
|
184
|
+
*/
|
|
185
|
+
let saving = $state(false)
|
|
186
|
+
|
|
187
|
+
let rosterOfficeId = $state<string | null>(null)
|
|
188
|
+
let rosterFilter = $state('all')
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------- queries
|
|
191
|
+
|
|
192
|
+
const officesQuery = createQuery(() => ({
|
|
193
|
+
queryKey: hrKeys.offices(workspaceId),
|
|
194
|
+
enabled: Boolean(workspaceId),
|
|
195
|
+
queryFn: () => api.offices.list({ workspaceId, includeArchived: false }),
|
|
196
|
+
}))
|
|
197
|
+
const offices = $derived((officesQuery.data ?? []) as OfficeRow[])
|
|
198
|
+
|
|
199
|
+
const stats = $derived({
|
|
200
|
+
offices: offices.length,
|
|
201
|
+
countries: new Set(offices.map((o) => o.country)).size,
|
|
202
|
+
people: offices.reduce((sum, o) => sum + o.headcount, 0),
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The directory, for the two person pickers — head of office, and who to assign.
|
|
207
|
+
*
|
|
208
|
+
* Only fetched while one of them is on screen: a settings page nobody has opened a dialog on has
|
|
209
|
+
* no reason to pull two hundred people.
|
|
210
|
+
*/
|
|
211
|
+
const directoryQuery = createQuery(() => ({
|
|
212
|
+
queryKey: hrKeys.people(workspaceId, { forOffices: true }),
|
|
213
|
+
enabled: Boolean(workspaceId) && (draft !== null || rosterOfficeId !== null),
|
|
214
|
+
queryFn: () => api.people.list({ workspaceId, limit: 200, status: ['active'] }),
|
|
215
|
+
}))
|
|
216
|
+
const directory = $derived(directoryQuery.data?.items ?? [])
|
|
217
|
+
|
|
218
|
+
const calendarsQuery = createQuery(() => ({
|
|
219
|
+
queryKey: hrKeys.calendars(workspaceId),
|
|
220
|
+
enabled: Boolean(workspaceId) && hasCalendars && draft?.id != null,
|
|
221
|
+
queryFn: () => api.calendars.list({ workspaceId, includeArchived: false }),
|
|
222
|
+
}))
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* `[module, entity, …scope]`, the shape `hrKeys` uses. Spelled here rather than in `query.ts`
|
|
226
|
+
* because these two are the only screens that ask, and both belong to offices.
|
|
227
|
+
*/
|
|
228
|
+
const entitiesKey = (ws: string) => ['hr', 'entities', ws] as const
|
|
229
|
+
const officePeopleKey = (ws: string, officeId: string, primaryOnly: boolean) =>
|
|
230
|
+
['hr', 'office-people', ws, officeId, primaryOnly ? 'primary' : 'all'] as const
|
|
231
|
+
|
|
232
|
+
const entitiesQuery = createQuery(() => ({
|
|
233
|
+
queryKey: entitiesKey(workspaceId),
|
|
234
|
+
enabled: Boolean(workspaceId) && hasEntities && draft !== null,
|
|
235
|
+
queryFn: () => api.entities.list({ workspaceId, includeArchived: false }),
|
|
236
|
+
}))
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------- the form
|
|
239
|
+
|
|
240
|
+
function openCreate() {
|
|
241
|
+
formError = null
|
|
242
|
+
draft = {
|
|
243
|
+
id: null,
|
|
244
|
+
name: '',
|
|
245
|
+
code: '',
|
|
246
|
+
kind: 'branch',
|
|
247
|
+
// No country is guessed. A second office is usually in a different one from the first, and a
|
|
248
|
+
// pre-filled country is the field nobody re-reads.
|
|
249
|
+
country: '',
|
|
250
|
+
region: '',
|
|
251
|
+
city: '',
|
|
252
|
+
timezone: browserZone(),
|
|
253
|
+
parentOfficeId: '',
|
|
254
|
+
legalEntityId: '',
|
|
255
|
+
calendarId: '',
|
|
256
|
+
headPersonId: '',
|
|
257
|
+
seedCalendarFromPack: true,
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function openEdit(office: OfficeRow) {
|
|
262
|
+
formError = null
|
|
263
|
+
draft = {
|
|
264
|
+
id: office.id,
|
|
265
|
+
name: office.name,
|
|
266
|
+
code: office.code ?? '',
|
|
267
|
+
kind: office.kind as Kind,
|
|
268
|
+
country: office.country,
|
|
269
|
+
region: office.region ?? '',
|
|
270
|
+
city: office.city ?? '',
|
|
271
|
+
timezone: office.timezone,
|
|
272
|
+
parentOfficeId: office.parentOfficeId ?? '',
|
|
273
|
+
legalEntityId: office.legalEntityId ?? '',
|
|
274
|
+
calendarId: office.calendarId ?? '',
|
|
275
|
+
headPersonId: office.headPersonId ?? '',
|
|
276
|
+
// Only offered on create: the pack is what a new office's calendar is built from, and an
|
|
277
|
+
// existing one already has whatever it has.
|
|
278
|
+
seedCalendarFromPack: false,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const draftValid = $derived(
|
|
283
|
+
draft !== null && draft.name.trim().length > 0 && draft.country.length === 2 && draft.timezone !== '',
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Zones as a grouped list, with the office's own zone forced in.
|
|
288
|
+
*
|
|
289
|
+
* A zone the runtime has retired (`Asia/Calcutta`) would otherwise be dropped from the options, the
|
|
290
|
+
* field would render empty, and saving would quietly move the office to a different one.
|
|
12
291
|
*/
|
|
292
|
+
const timezones = $derived.by((): SelectOption[] => {
|
|
293
|
+
const zones = new Set(ZONES)
|
|
294
|
+
if (draft?.timezone) zones.add(draft.timezone)
|
|
295
|
+
return [...zones].sort().map((zone) => {
|
|
296
|
+
const slash = zone.indexOf('/')
|
|
297
|
+
if (slash === -1) return { value: zone, label: zone }
|
|
298
|
+
return {
|
|
299
|
+
value: zone,
|
|
300
|
+
label: zone
|
|
301
|
+
.slice(slash + 1)
|
|
302
|
+
.replaceAll('_', ' ')
|
|
303
|
+
.replaceAll('/', ' / '),
|
|
304
|
+
group: zone.slice(0, slash),
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
const kindChoices = $derived(KINDS.map((kind) => ({ value: kind, label: KIND_LABELS[kind]() })))
|
|
310
|
+
const parentChoices = $derived([
|
|
311
|
+
{ value: '', label: t('office_parent_none') },
|
|
312
|
+
...offices.filter((o) => o.id !== draft?.id).map((o) => ({ value: o.id, label: o.name })),
|
|
313
|
+
])
|
|
314
|
+
const entityChoices = $derived([
|
|
315
|
+
{ value: '', label: t('office_entity_none') },
|
|
316
|
+
...(entitiesQuery.data ?? []).map((e) => ({ value: e.id, label: e.name })),
|
|
317
|
+
])
|
|
318
|
+
const calendarChoices = $derived([
|
|
319
|
+
{ value: '', label: t('office_calendar_default') },
|
|
320
|
+
...(calendarsQuery.data ?? []).map((c) => ({ value: c.id, label: c.name })),
|
|
321
|
+
])
|
|
322
|
+
const peopleChoices = $derived(directory.map((p) => ({ value: p.id, label: p.displayName })))
|
|
323
|
+
/** Head of office is a field somebody can clear; who to assign is not, so only one carries a null. */
|
|
324
|
+
const headChoices = $derived([{ value: '', label: t('office_head_none') }, ...peopleChoices])
|
|
325
|
+
|
|
326
|
+
const save = createMutation(() => ({
|
|
327
|
+
mutationFn: (input: Draft) => {
|
|
328
|
+
const shared = {
|
|
329
|
+
workspaceId,
|
|
330
|
+
name: input.name.trim(),
|
|
331
|
+
kind: input.kind,
|
|
332
|
+
country: input.country,
|
|
333
|
+
region: input.region.trim() || null,
|
|
334
|
+
city: input.city.trim() || null,
|
|
335
|
+
timezone: input.timezone,
|
|
336
|
+
code: input.code.trim() || null,
|
|
337
|
+
legalEntityId: input.legalEntityId || null,
|
|
338
|
+
}
|
|
339
|
+
return input.id === null
|
|
340
|
+
? api.offices.create({
|
|
341
|
+
...shared,
|
|
342
|
+
parentOfficeId: input.parentOfficeId || null,
|
|
343
|
+
seedCalendarFromPack: input.seedCalendarFromPack,
|
|
344
|
+
})
|
|
345
|
+
: api.offices.update({
|
|
346
|
+
...shared,
|
|
347
|
+
officeId: input.id,
|
|
348
|
+
calendarId: input.calendarId || null,
|
|
349
|
+
headPersonId: input.headPersonId || null,
|
|
350
|
+
})
|
|
351
|
+
},
|
|
352
|
+
onSuccess: (office, input) => {
|
|
353
|
+
toast.success(input.id === null ? t('office_created', { name: office.name }) : t('office_saved'))
|
|
354
|
+
draft = null
|
|
355
|
+
formError = null
|
|
356
|
+
// An office decides holidays, policies and a timezone, so a change to one moves resolutions and
|
|
357
|
+
// day sheets as well as this list. Invalidating the module is cheaper than guessing which.
|
|
358
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
359
|
+
},
|
|
360
|
+
onError: (error: Error) => {
|
|
361
|
+
formError = error.message || t('office_save_error')
|
|
362
|
+
},
|
|
363
|
+
onSettled: () => {
|
|
364
|
+
saving = false
|
|
365
|
+
},
|
|
366
|
+
}))
|
|
367
|
+
|
|
368
|
+
function submitDraft() {
|
|
369
|
+
if (!draft || !draftValid || saving) return
|
|
370
|
+
saving = true
|
|
371
|
+
formError = null
|
|
372
|
+
save.mutate($state.snapshot(draft) as Draft)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------- default and archive
|
|
376
|
+
|
|
377
|
+
let makingDefaultId = $state<string | null>(null)
|
|
378
|
+
let archivingId = $state<string | null>(null)
|
|
379
|
+
let actionError = $state<string | null>(null)
|
|
380
|
+
let acting = $state(false)
|
|
381
|
+
|
|
382
|
+
const makingDefault = $derived(offices.find((o) => o.id === makingDefaultId) ?? null)
|
|
383
|
+
const archiving = $derived(offices.find((o) => o.id === archivingId) ?? null)
|
|
384
|
+
const currentDefault = $derived(offices.find((o) => o.isDefault) ?? null)
|
|
385
|
+
|
|
386
|
+
const setDefault = createMutation(() => ({
|
|
387
|
+
mutationFn: (vars: { officeId: string; name: string }) =>
|
|
388
|
+
api.offices.setDefault({ workspaceId, officeId: vars.officeId }),
|
|
389
|
+
onSuccess: (_office, vars) => {
|
|
390
|
+
toast.success(t('office_default_toast', { name: vars.name }))
|
|
391
|
+
makingDefaultId = null
|
|
392
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
393
|
+
},
|
|
394
|
+
onError: (error: Error) => {
|
|
395
|
+
actionError = error.message || t('office_default_error')
|
|
396
|
+
},
|
|
397
|
+
onSettled: () => {
|
|
398
|
+
acting = false
|
|
399
|
+
},
|
|
400
|
+
}))
|
|
401
|
+
|
|
402
|
+
const archive = createMutation(() => ({
|
|
403
|
+
mutationFn: (vars: { officeId: string; name: string }) =>
|
|
404
|
+
api.offices.archive({ workspaceId, officeId: vars.officeId }),
|
|
405
|
+
onSuccess: (_ok, vars) => {
|
|
406
|
+
toast.success(t('office_archived_toast', { name: vars.name }))
|
|
407
|
+
archivingId = null
|
|
408
|
+
if (rosterOfficeId === vars.officeId) rosterOfficeId = null
|
|
409
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
410
|
+
},
|
|
411
|
+
onError: (error: Error) => {
|
|
412
|
+
actionError = error.message || t('office_archive_error')
|
|
413
|
+
},
|
|
414
|
+
onSettled: () => {
|
|
415
|
+
acting = false
|
|
416
|
+
},
|
|
417
|
+
}))
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------- the roster
|
|
420
|
+
|
|
421
|
+
/** The live row, not a snapshot: the name and the headcount move while the panel is open. */
|
|
422
|
+
const rosterOffice = $derived(offices.find((o) => o.id === rosterOfficeId) ?? null)
|
|
423
|
+
|
|
424
|
+
const rosterQuery = createQuery(() => ({
|
|
425
|
+
queryKey: officePeopleKey(workspaceId, rosterOfficeId ?? '', rosterFilter === 'primary'),
|
|
426
|
+
enabled: Boolean(workspaceId) && rosterOfficeId !== null,
|
|
427
|
+
queryFn: () =>
|
|
428
|
+
api.offices.people({
|
|
429
|
+
workspaceId,
|
|
430
|
+
officeId: rosterOfficeId ?? '',
|
|
431
|
+
limit: 100,
|
|
432
|
+
primaryOnly: rosterFilter === 'primary',
|
|
433
|
+
}),
|
|
434
|
+
}))
|
|
435
|
+
const roster = $derived(rosterQuery.data?.items ?? [])
|
|
436
|
+
|
|
437
|
+
let assignPersonId = $state('')
|
|
438
|
+
let assignFrom = $state(isoDate())
|
|
439
|
+
let assignPrimary = $state(true)
|
|
440
|
+
let assignReason = $state('')
|
|
441
|
+
let assignError = $state<string | null>(null)
|
|
442
|
+
let assigning = $state(false)
|
|
443
|
+
|
|
444
|
+
/** A fresh panel starts with an empty form rather than the last office's half-typed one. */
|
|
445
|
+
$effect(() => {
|
|
446
|
+
void rosterOfficeId
|
|
447
|
+
assignPersonId = ''
|
|
448
|
+
assignFrom = isoDate()
|
|
449
|
+
assignPrimary = true
|
|
450
|
+
assignReason = ''
|
|
451
|
+
assignError = null
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
const assign = createMutation(() => ({
|
|
455
|
+
mutationFn: (vars: { officeId: string; personId: string; name: string }) =>
|
|
456
|
+
api.offices.assign({
|
|
457
|
+
workspaceId,
|
|
458
|
+
officeId: vars.officeId,
|
|
459
|
+
personId: vars.personId,
|
|
460
|
+
isPrimary: assignPrimary,
|
|
461
|
+
effectiveFrom: assignFrom,
|
|
462
|
+
reason: assignReason.trim() || null,
|
|
463
|
+
}),
|
|
464
|
+
onSuccess: (_assignments, vars) => {
|
|
465
|
+
toast.success(t('office_assigned_toast', { name: vars.name }))
|
|
466
|
+
assignPersonId = ''
|
|
467
|
+
assignReason = ''
|
|
468
|
+
assignError = null
|
|
469
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
470
|
+
},
|
|
471
|
+
onError: (error: Error) => {
|
|
472
|
+
assignError = error.message || t('office_assign_error')
|
|
473
|
+
},
|
|
474
|
+
onSettled: () => {
|
|
475
|
+
assigning = false
|
|
476
|
+
},
|
|
477
|
+
}))
|
|
478
|
+
|
|
479
|
+
function submitAssignment() {
|
|
480
|
+
const officeId = rosterOfficeId
|
|
481
|
+
if (!officeId || !assignPersonId || !assignFrom || assigning) return
|
|
482
|
+
assigning = true
|
|
483
|
+
assignError = null
|
|
484
|
+
assign.mutate({
|
|
485
|
+
officeId,
|
|
486
|
+
personId: assignPersonId,
|
|
487
|
+
name: directory.find((p) => p.id === assignPersonId)?.displayName ?? '',
|
|
488
|
+
})
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
let unassigning = $state<{ personId: string; name: string; primary: boolean } | null>(null)
|
|
492
|
+
let unassignUntil = $state(isoDate())
|
|
493
|
+
|
|
494
|
+
const unassign = createMutation(() => ({
|
|
495
|
+
mutationFn: (vars: { officeId: string; personId: string; name: string }) =>
|
|
496
|
+
api.offices.unassign({
|
|
497
|
+
workspaceId,
|
|
498
|
+
officeId: vars.officeId,
|
|
499
|
+
personId: vars.personId,
|
|
500
|
+
effectiveTo: unassignUntil,
|
|
501
|
+
}),
|
|
502
|
+
onSuccess: (_ok, vars) => {
|
|
503
|
+
toast.success(t('office_unassigned_toast', { name: vars.name }))
|
|
504
|
+
unassigning = null
|
|
505
|
+
void queryClient.invalidateQueries({ queryKey: ['hr'] })
|
|
506
|
+
},
|
|
507
|
+
onError: (error: Error) => {
|
|
508
|
+
actionError = error.message || t('office_unassign_error')
|
|
509
|
+
},
|
|
510
|
+
onSettled: () => {
|
|
511
|
+
acting = false
|
|
512
|
+
},
|
|
513
|
+
}))
|
|
514
|
+
|
|
515
|
+
function confirmUnassign() {
|
|
516
|
+
const officeId = rosterOfficeId
|
|
517
|
+
const target = unassigning
|
|
518
|
+
if (!officeId || !target || acting) return
|
|
519
|
+
acting = true
|
|
520
|
+
actionError = null
|
|
521
|
+
unassign.mutate({ officeId, personId: target.personId, name: target.name })
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ---------------------------------------------------------------- row actions
|
|
525
|
+
|
|
526
|
+
function actionsFor(office: OfficeRow): MenuItem[] {
|
|
527
|
+
const items: MenuItem[] = [
|
|
528
|
+
{ label: t('office_people_manage'), icon: 'users', onSelect: () => (rosterOfficeId = office.id) },
|
|
529
|
+
]
|
|
530
|
+
if (!canManage) return items
|
|
531
|
+
items.unshift({ label: t('common.edit'), icon: 'pencil', onSelect: () => openEdit(office) })
|
|
532
|
+
items.push({
|
|
533
|
+
label: t('office_make_default'),
|
|
534
|
+
icon: 'star',
|
|
535
|
+
disabled: office.isDefault,
|
|
536
|
+
hint: office.isDefault ? t('office_make_default_already') : undefined,
|
|
537
|
+
onSelect: () => {
|
|
538
|
+
actionError = null
|
|
539
|
+
makingDefaultId = office.id
|
|
540
|
+
},
|
|
541
|
+
})
|
|
542
|
+
items.push({ type: 'separator' })
|
|
543
|
+
items.push({
|
|
544
|
+
label: t('common.archive'),
|
|
545
|
+
icon: 'archive',
|
|
546
|
+
danger: true,
|
|
547
|
+
// The workspace must always have a default, so the server refuses this one. Saying why beats
|
|
548
|
+
// an error the person only meets after the confirmation.
|
|
549
|
+
disabled: office.isDefault,
|
|
550
|
+
hint: office.isDefault ? t('office_archive_blocked') : undefined,
|
|
551
|
+
onSelect: () => {
|
|
552
|
+
actionError = null
|
|
553
|
+
archivingId = office.id
|
|
554
|
+
},
|
|
555
|
+
})
|
|
556
|
+
return items
|
|
557
|
+
}
|
|
13
558
|
</script>
|
|
14
559
|
|
|
15
|
-
<SettingsPage title={t('settings_offices')}>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
560
|
+
<SettingsPage title={t('settings_offices')} description={t('offices_settings_desc')}>
|
|
561
|
+
{#snippet actions()}
|
|
562
|
+
{#if canManage}
|
|
563
|
+
<Button size="sm" icon="plus" onclick={openCreate}>{t('office_add')}</Button>
|
|
564
|
+
{/if}
|
|
565
|
+
{/snippet}
|
|
566
|
+
|
|
567
|
+
{#if officesQuery.isLoading}
|
|
568
|
+
<div class="tiles">
|
|
569
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="86px" />{/each}
|
|
570
|
+
</div>
|
|
571
|
+
{:else if !officesQuery.isError && offices.length > 0}
|
|
572
|
+
<div class="tiles">
|
|
573
|
+
<StatTile size="md" label={t('offices_title')} value={count(stats.offices)} />
|
|
574
|
+
<StatTile size="md" label={t('offices_countries')} value={count(stats.countries)} />
|
|
575
|
+
<StatTile size="md" label={t('office_people')} value={count(stats.people)} />
|
|
576
|
+
</div>
|
|
577
|
+
{/if}
|
|
578
|
+
|
|
579
|
+
<SettingsSection flush>
|
|
580
|
+
{#if officesQuery.isLoading}
|
|
581
|
+
<div class="pad rows">
|
|
582
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="56px" />{/each}
|
|
583
|
+
</div>
|
|
584
|
+
{:else if officesQuery.isError}
|
|
585
|
+
<div class="pad">
|
|
586
|
+
<EmptyState icon="triangle-alert" title={t('offices_error')}>
|
|
587
|
+
{#snippet actions()}
|
|
588
|
+
<Button variant="secondary" onclick={() => void officesQuery.refetch()}>{t('retry')}</Button>
|
|
589
|
+
{/snippet}
|
|
590
|
+
</EmptyState>
|
|
591
|
+
</div>
|
|
592
|
+
{:else if offices.length === 0}
|
|
593
|
+
<div class="pad">
|
|
594
|
+
<EmptyState icon="building" title={t('offices_none')} description={t('offices_none_desc')}>
|
|
595
|
+
{#snippet actions()}
|
|
596
|
+
{#if canManage}
|
|
597
|
+
<Button icon="plus" onclick={openCreate}>{t('office_add')}</Button>
|
|
598
|
+
{/if}
|
|
599
|
+
{/snippet}
|
|
600
|
+
</EmptyState>
|
|
601
|
+
</div>
|
|
602
|
+
{:else}
|
|
603
|
+
<div class="table" role="table" aria-label={t('offices_title')}>
|
|
604
|
+
<div class="thead" role="row">
|
|
605
|
+
<span role="columnheader">{t('office')}</span>
|
|
606
|
+
<span role="columnheader">{t('office_kind')}</span>
|
|
607
|
+
<span role="columnheader">{t('office_country')}</span>
|
|
608
|
+
<span class="num" role="columnheader">{t('office_people')}</span>
|
|
609
|
+
<span class="sr-only" role="columnheader">{t('approvals_actions')}</span>
|
|
610
|
+
</div>
|
|
611
|
+
{#each offices as office (office.id)}
|
|
612
|
+
<div class="trow" role="row">
|
|
613
|
+
<span class="cell stack" role="cell">
|
|
614
|
+
<span class="name">
|
|
615
|
+
{office.name}
|
|
616
|
+
{#if office.isDefault}<Badge tone="accent">{t('office_default')}</Badge>{/if}
|
|
617
|
+
</span>
|
|
618
|
+
{#if office.code}<span class="sub mono">{office.code}</span>{/if}
|
|
619
|
+
</span>
|
|
620
|
+
<span class="cell" role="cell">
|
|
621
|
+
<Badge tone={kindTone(office.kind)}>{kindLabel(office.kind)}</Badge>
|
|
622
|
+
</span>
|
|
623
|
+
<span class="cell stack" role="cell">
|
|
624
|
+
<span class="sub">{office.city ? `${office.city}, ` : ''}{countryLabel(office.country)}</span>
|
|
625
|
+
<span class="sub mono">{office.timezone}</span>
|
|
626
|
+
</span>
|
|
627
|
+
<span class="cell num" role="cell">{count(office.headcount)}</span>
|
|
628
|
+
<span class="cell end" role="cell">
|
|
629
|
+
<DropdownMenu items={actionsFor(office)} align="end">
|
|
630
|
+
{#snippet trigger(props)}
|
|
631
|
+
<IconButton
|
|
632
|
+
{...props}
|
|
633
|
+
icon="ellipsis"
|
|
634
|
+
size={28}
|
|
635
|
+
label={t('office_actions', { name: office.name })}
|
|
636
|
+
/>
|
|
637
|
+
{/snippet}
|
|
638
|
+
</DropdownMenu>
|
|
639
|
+
</span>
|
|
640
|
+
</div>
|
|
641
|
+
{/each}
|
|
642
|
+
</div>
|
|
643
|
+
{/if}
|
|
644
|
+
</SettingsSection>
|
|
21
645
|
</SettingsPage>
|
|
646
|
+
|
|
647
|
+
<!-- ------------------------------------------------------------------ create and edit -->
|
|
648
|
+
<Dialog
|
|
649
|
+
open={draft !== null}
|
|
650
|
+
size="lg"
|
|
651
|
+
title={draft?.id ? t('office_edit_title', { name: draft.name }) : t('office_new')}
|
|
652
|
+
onOpenChange={(open) => {
|
|
653
|
+
if (!open) draft = null
|
|
654
|
+
}}
|
|
655
|
+
>
|
|
656
|
+
{#if draft}
|
|
657
|
+
<form
|
|
658
|
+
class="form"
|
|
659
|
+
onsubmit={(event) => {
|
|
660
|
+
event.preventDefault()
|
|
661
|
+
submitDraft()
|
|
662
|
+
}}
|
|
663
|
+
>
|
|
664
|
+
<SectionLabel sub label={t('office_form_what')} />
|
|
665
|
+
<div class="pair">
|
|
666
|
+
<Field label={t('display_name')} required>
|
|
667
|
+
{#snippet children(id)}
|
|
668
|
+
<Input {id} bind:value={draft!.name} autocomplete="off" />
|
|
669
|
+
{/snippet}
|
|
670
|
+
</Field>
|
|
671
|
+
<Field label={t('office_kind')}>
|
|
672
|
+
{#snippet children(id)}
|
|
673
|
+
<Select {id} bind:value={draft!.kind} options={kindChoices} placeholder={t('choose')} />
|
|
674
|
+
{/snippet}
|
|
675
|
+
</Field>
|
|
676
|
+
</div>
|
|
677
|
+
<div class="pair">
|
|
678
|
+
<Field label={t('office_code')} hint={t('office_code_hint')}>
|
|
679
|
+
{#snippet children(id)}
|
|
680
|
+
<Input {id} mono bind:value={draft!.code} autocomplete="off" />
|
|
681
|
+
{/snippet}
|
|
682
|
+
</Field>
|
|
683
|
+
{#if draft.id === null}
|
|
684
|
+
<Field label={t('office_parent')} hint={t('office_parent_hint')}>
|
|
685
|
+
{#snippet children(id)}
|
|
686
|
+
<Select
|
|
687
|
+
{id}
|
|
688
|
+
bind:value={draft!.parentOfficeId}
|
|
689
|
+
options={parentChoices}
|
|
690
|
+
placeholder={t('office_parent_none')}
|
|
691
|
+
/>
|
|
692
|
+
{/snippet}
|
|
693
|
+
</Field>
|
|
694
|
+
{/if}
|
|
695
|
+
</div>
|
|
696
|
+
|
|
697
|
+
<SectionLabel sub label={t('office_form_where')} />
|
|
698
|
+
<div class="pair">
|
|
699
|
+
<Field label={t('office_country')} required>
|
|
700
|
+
{#snippet children(id)}
|
|
701
|
+
<Select {id} bind:value={draft!.country} options={countries} placeholder={t('choose')} />
|
|
702
|
+
{/snippet}
|
|
703
|
+
</Field>
|
|
704
|
+
<Field label={t('office_city')} hint={t('common.optional')}>
|
|
705
|
+
{#snippet children(id)}
|
|
706
|
+
<Input {id} bind:value={draft!.city} autocomplete="off" />
|
|
707
|
+
{/snippet}
|
|
708
|
+
</Field>
|
|
709
|
+
</div>
|
|
710
|
+
<div class="pair">
|
|
711
|
+
<Field label={t('office_region')} hint={t('office_region_hint')}>
|
|
712
|
+
{#snippet children(id)}
|
|
713
|
+
<Input {id} mono bind:value={draft!.region} autocomplete="off" />
|
|
714
|
+
{/snippet}
|
|
715
|
+
</Field>
|
|
716
|
+
<Field label={t('office_timezone')} hint={t('office_timezone_hint')} required>
|
|
717
|
+
{#snippet children(id)}
|
|
718
|
+
<Select {id} bind:value={draft!.timezone} options={timezones} placeholder={t('choose')} />
|
|
719
|
+
{/snippet}
|
|
720
|
+
</Field>
|
|
721
|
+
</div>
|
|
722
|
+
|
|
723
|
+
{#if draft.id === null || hasEntities || hasCalendars}
|
|
724
|
+
<SectionLabel sub label={t('office_form_follows')} />
|
|
725
|
+
{/if}
|
|
726
|
+
{#if draft.id === null}
|
|
727
|
+
<!--
|
|
728
|
+
The one control on this form that decides something a person cannot see: without the pack
|
|
729
|
+
the office shares the workspace calendar, and nobody would guess that from a checkbox.
|
|
730
|
+
-->
|
|
731
|
+
<Checkbox
|
|
732
|
+
bind:checked={draft!.seedCalendarFromPack}
|
|
733
|
+
label={t('office_seed')}
|
|
734
|
+
description={t('office_seed_desc')}
|
|
735
|
+
/>
|
|
736
|
+
{/if}
|
|
737
|
+
<div class="pair">
|
|
738
|
+
{#if hasEntities}
|
|
739
|
+
<Field label={t('office_entity')} hint={t('office_entity_hint')}>
|
|
740
|
+
{#snippet children(id)}
|
|
741
|
+
<Select
|
|
742
|
+
{id}
|
|
743
|
+
bind:value={draft!.legalEntityId}
|
|
744
|
+
options={entityChoices}
|
|
745
|
+
disabled={entitiesQuery.isLoading}
|
|
746
|
+
placeholder={t('office_entity_none')}
|
|
747
|
+
/>
|
|
748
|
+
{/snippet}
|
|
749
|
+
</Field>
|
|
750
|
+
{/if}
|
|
751
|
+
{#if draft.id !== null && hasCalendars}
|
|
752
|
+
<Field label={t('office_calendar')} hint={t('office_calendar_hint')}>
|
|
753
|
+
{#snippet children(id)}
|
|
754
|
+
<Select
|
|
755
|
+
{id}
|
|
756
|
+
bind:value={draft!.calendarId}
|
|
757
|
+
options={calendarChoices}
|
|
758
|
+
disabled={calendarsQuery.isLoading}
|
|
759
|
+
placeholder={t('office_calendar_default')}
|
|
760
|
+
/>
|
|
761
|
+
{/snippet}
|
|
762
|
+
</Field>
|
|
763
|
+
{/if}
|
|
764
|
+
</div>
|
|
765
|
+
{#if draft.id !== null}
|
|
766
|
+
<Field label={t('office_head')} hint={t('common.optional')}>
|
|
767
|
+
{#snippet children(id)}
|
|
768
|
+
<Select
|
|
769
|
+
{id}
|
|
770
|
+
bind:value={draft!.headPersonId}
|
|
771
|
+
options={headChoices}
|
|
772
|
+
disabled={directoryQuery.isLoading}
|
|
773
|
+
placeholder={t('office_head_none')}
|
|
774
|
+
/>
|
|
775
|
+
{/snippet}
|
|
776
|
+
</Field>
|
|
777
|
+
{/if}
|
|
778
|
+
|
|
779
|
+
{#if formError}<p class="err" role="alert">{formError}</p>{/if}
|
|
780
|
+
</form>
|
|
781
|
+
{/if}
|
|
782
|
+
|
|
783
|
+
{#snippet footer()}
|
|
784
|
+
<Button variant="ghost" onclick={() => (draft = null)}>{t('common.cancel')}</Button>
|
|
785
|
+
<Button onclick={submitDraft} disabled={!draftValid || !canManage} loading={saving}>
|
|
786
|
+
{draft?.id ? t('common.save') : t('office_add')}
|
|
787
|
+
</Button>
|
|
788
|
+
{/snippet}
|
|
789
|
+
</Dialog>
|
|
790
|
+
|
|
791
|
+
<!-- ------------------------------------------------------------------ make default -->
|
|
792
|
+
<Dialog
|
|
793
|
+
open={makingDefault !== null}
|
|
794
|
+
size="sm"
|
|
795
|
+
title={t('office_default_title', { name: makingDefault?.name ?? '' })}
|
|
796
|
+
onOpenChange={(open) => {
|
|
797
|
+
if (!open) makingDefaultId = null
|
|
798
|
+
}}
|
|
799
|
+
>
|
|
800
|
+
<p class="body">
|
|
801
|
+
{t('office_default_body', {
|
|
802
|
+
current: currentDefault?.name ?? '',
|
|
803
|
+
name: makingDefault?.name ?? '',
|
|
804
|
+
})}
|
|
805
|
+
</p>
|
|
806
|
+
{#if actionError}<p class="err" role="alert">{actionError}</p>{/if}
|
|
807
|
+
|
|
808
|
+
{#snippet footer()}
|
|
809
|
+
<Button variant="ghost" onclick={() => (makingDefaultId = null)}>{t('common.cancel')}</Button>
|
|
810
|
+
<Button
|
|
811
|
+
loading={acting}
|
|
812
|
+
onclick={() => {
|
|
813
|
+
if (!makingDefault || acting) return
|
|
814
|
+
acting = true
|
|
815
|
+
actionError = null
|
|
816
|
+
setDefault.mutate({ officeId: makingDefault.id, name: makingDefault.name })
|
|
817
|
+
}}
|
|
818
|
+
>
|
|
819
|
+
{t('office_make_default')}
|
|
820
|
+
</Button>
|
|
821
|
+
{/snippet}
|
|
822
|
+
</Dialog>
|
|
823
|
+
|
|
824
|
+
<!-- ------------------------------------------------------------------ archive -->
|
|
825
|
+
<Dialog
|
|
826
|
+
open={archiving !== null}
|
|
827
|
+
size="sm"
|
|
828
|
+
title={t('office_archive_title', { name: archiving?.name ?? '' })}
|
|
829
|
+
onOpenChange={(open) => {
|
|
830
|
+
if (!open) archivingId = null
|
|
831
|
+
}}
|
|
832
|
+
>
|
|
833
|
+
<p class="body">
|
|
834
|
+
{archiving && archiving.headcount > 0
|
|
835
|
+
? t('office_archive_body', { count: archiving.headcount })
|
|
836
|
+
: t('office_archive_body_empty')}
|
|
837
|
+
</p>
|
|
838
|
+
<p class="body muted">{t('office_archive_note')}</p>
|
|
839
|
+
{#if actionError}<p class="err" role="alert">{actionError}</p>{/if}
|
|
840
|
+
|
|
841
|
+
{#snippet footer()}
|
|
842
|
+
<Button variant="ghost" onclick={() => (archivingId = null)}>{t('common.cancel')}</Button>
|
|
843
|
+
<Button
|
|
844
|
+
variant="danger"
|
|
845
|
+
loading={acting}
|
|
846
|
+
onclick={() => {
|
|
847
|
+
if (!archiving || acting) return
|
|
848
|
+
acting = true
|
|
849
|
+
actionError = null
|
|
850
|
+
archive.mutate({ officeId: archiving.id, name: archiving.name })
|
|
851
|
+
}}
|
|
852
|
+
>
|
|
853
|
+
{t('common.archive')}
|
|
854
|
+
</Button>
|
|
855
|
+
{/snippet}
|
|
856
|
+
</Dialog>
|
|
857
|
+
|
|
858
|
+
<!-- ------------------------------------------------------------------ the roster -->
|
|
859
|
+
<Sheet
|
|
860
|
+
open={rosterOffice !== null}
|
|
861
|
+
width={480}
|
|
862
|
+
title={t('office_people_title', { name: rosterOffice?.name ?? '' })}
|
|
863
|
+
onOpenChange={(open) => {
|
|
864
|
+
if (!open) rosterOfficeId = null
|
|
865
|
+
}}
|
|
866
|
+
>
|
|
867
|
+
<p class="explainer">{t('office_primary_explainer')}</p>
|
|
868
|
+
|
|
869
|
+
<div class="filter">
|
|
870
|
+
<SegmentedControl
|
|
871
|
+
size="sm"
|
|
872
|
+
label={t('office_people')}
|
|
873
|
+
bind:value={rosterFilter}
|
|
874
|
+
items={[
|
|
875
|
+
{ value: 'all', label: t('office_people_all') },
|
|
876
|
+
{ value: 'primary', label: t('office_people_primary_only') },
|
|
877
|
+
]}
|
|
878
|
+
/>
|
|
879
|
+
</div>
|
|
880
|
+
|
|
881
|
+
{#if rosterQuery.isLoading}
|
|
882
|
+
<div class="rows">
|
|
883
|
+
{#each [1, 2, 3] as n (n)}<Skeleton height="44px" />{/each}
|
|
884
|
+
</div>
|
|
885
|
+
{:else if rosterQuery.isError}
|
|
886
|
+
<EmptyState compact icon="triangle-alert" title={t('office_people_error')}>
|
|
887
|
+
{#snippet actions()}
|
|
888
|
+
<Button size="sm" variant="secondary" onclick={() => void rosterQuery.refetch()}>{t('retry')}</Button>
|
|
889
|
+
{/snippet}
|
|
890
|
+
</EmptyState>
|
|
891
|
+
{:else if roster.length === 0}
|
|
892
|
+
<!--
|
|
893
|
+
Two different emptinesses. "Nobody works here" is wrong when the filter is on and somebody
|
|
894
|
+
does work here without this being the office that decides for them.
|
|
895
|
+
-->
|
|
896
|
+
<EmptyState
|
|
897
|
+
compact
|
|
898
|
+
icon="user"
|
|
899
|
+
title={rosterFilter === 'primary' ? t('office_people_none_primary') : t('office_people_none')}
|
|
900
|
+
description={t('office_people_none_desc')}
|
|
901
|
+
/>
|
|
902
|
+
{:else}
|
|
903
|
+
<ul class="roster">
|
|
904
|
+
{#each roster as person (person.id)}
|
|
905
|
+
<li>
|
|
906
|
+
<span class="stack">
|
|
907
|
+
<span class="name">{person.displayName}</span>
|
|
908
|
+
<span class="sub">
|
|
909
|
+
{#if person.isPrimaryHere}
|
|
910
|
+
<Badge tone="accent">{t('office_primary_here')}</Badge>
|
|
911
|
+
{:else}
|
|
912
|
+
<Badge tone="grey">{t('office_also_here')}</Badge>
|
|
913
|
+
{/if}
|
|
914
|
+
</span>
|
|
915
|
+
</span>
|
|
916
|
+
{#if canAssign}
|
|
917
|
+
<Button
|
|
918
|
+
size="sm"
|
|
919
|
+
variant="ghost"
|
|
920
|
+
aria-label={t('office_unassign_label', { name: person.displayName })}
|
|
921
|
+
onclick={() => {
|
|
922
|
+
actionError = null
|
|
923
|
+
unassignUntil = isoDate()
|
|
924
|
+
unassigning = {
|
|
925
|
+
personId: person.id,
|
|
926
|
+
name: person.displayName,
|
|
927
|
+
primary: person.isPrimaryHere,
|
|
928
|
+
}
|
|
929
|
+
}}
|
|
930
|
+
>
|
|
931
|
+
{t('common.remove')}
|
|
932
|
+
</Button>
|
|
933
|
+
{/if}
|
|
934
|
+
</li>
|
|
935
|
+
{/each}
|
|
936
|
+
</ul>
|
|
937
|
+
{/if}
|
|
938
|
+
|
|
939
|
+
{#if canAssign}
|
|
940
|
+
<form
|
|
941
|
+
class="assign"
|
|
942
|
+
onsubmit={(event) => {
|
|
943
|
+
event.preventDefault()
|
|
944
|
+
submitAssignment()
|
|
945
|
+
}}
|
|
946
|
+
>
|
|
947
|
+
<SectionLabel sub label={t('office_assign')} />
|
|
948
|
+
<Field label={t('office_assign_person')} required>
|
|
949
|
+
{#snippet children(id)}
|
|
950
|
+
<Select
|
|
951
|
+
{id}
|
|
952
|
+
bind:value={assignPersonId}
|
|
953
|
+
options={peopleChoices}
|
|
954
|
+
disabled={directoryQuery.isLoading}
|
|
955
|
+
placeholder={directory.length === 0 ? t('no_people') : t('choose')}
|
|
956
|
+
/>
|
|
957
|
+
{/snippet}
|
|
958
|
+
</Field>
|
|
959
|
+
<Field label={t('office_assign_from')} required>
|
|
960
|
+
{#snippet children(id)}
|
|
961
|
+
<Input {id} type="date" bind:value={assignFrom} />
|
|
962
|
+
{/snippet}
|
|
963
|
+
</Field>
|
|
964
|
+
<Checkbox
|
|
965
|
+
bind:checked={assignPrimary}
|
|
966
|
+
label={t('office_assign_primary')}
|
|
967
|
+
description={t('office_assign_primary_desc')}
|
|
968
|
+
/>
|
|
969
|
+
<Field label={t('office_assign_reason')} hint={t('common.optional')}>
|
|
970
|
+
{#snippet children(id)}
|
|
971
|
+
<Input {id} bind:value={assignReason} autocomplete="off" />
|
|
972
|
+
{/snippet}
|
|
973
|
+
</Field>
|
|
974
|
+
{#if assignError}<p class="err" role="alert">{assignError}</p>{/if}
|
|
975
|
+
<div class="end">
|
|
976
|
+
<Button
|
|
977
|
+
type="submit"
|
|
978
|
+
size="sm"
|
|
979
|
+
disabled={!assignPersonId || !assignFrom}
|
|
980
|
+
loading={assigning}
|
|
981
|
+
>
|
|
982
|
+
{t('office_assign_submit')}
|
|
983
|
+
</Button>
|
|
984
|
+
</div>
|
|
985
|
+
</form>
|
|
986
|
+
{/if}
|
|
987
|
+
</Sheet>
|
|
988
|
+
|
|
989
|
+
<!-- ------------------------------------------------------------------ unassign -->
|
|
990
|
+
<Dialog
|
|
991
|
+
open={unassigning !== null}
|
|
992
|
+
size="sm"
|
|
993
|
+
title={t('office_unassign_title', {
|
|
994
|
+
name: unassigning?.name ?? '',
|
|
995
|
+
office: rosterOffice?.name ?? '',
|
|
996
|
+
})}
|
|
997
|
+
onOpenChange={(open) => {
|
|
998
|
+
if (!open) unassigning = null
|
|
999
|
+
}}
|
|
1000
|
+
>
|
|
1001
|
+
<p class="body">
|
|
1002
|
+
{unassigning?.primary
|
|
1003
|
+
? t('office_unassign_body_primary')
|
|
1004
|
+
: t('office_unassign_body_other', { office: rosterOffice?.name ?? '' })}
|
|
1005
|
+
</p>
|
|
1006
|
+
<Field label={t('office_unassign_until')} required>
|
|
1007
|
+
{#snippet children(id)}
|
|
1008
|
+
<Input {id} type="date" bind:value={unassignUntil} />
|
|
1009
|
+
{/snippet}
|
|
1010
|
+
</Field>
|
|
1011
|
+
{#if actionError}<p class="err" role="alert">{actionError}</p>{/if}
|
|
1012
|
+
|
|
1013
|
+
{#snippet footer()}
|
|
1014
|
+
<Button variant="ghost" onclick={() => (unassigning = null)}>{t('common.cancel')}</Button>
|
|
1015
|
+
<Button variant="danger" loading={acting} disabled={!unassignUntil} onclick={confirmUnassign}>
|
|
1016
|
+
{t('common.remove')}
|
|
1017
|
+
</Button>
|
|
1018
|
+
{/snippet}
|
|
1019
|
+
</Dialog>
|
|
1020
|
+
|
|
1021
|
+
<style>
|
|
1022
|
+
.tiles {
|
|
1023
|
+
display: grid;
|
|
1024
|
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
1025
|
+
gap: 12px;
|
|
1026
|
+
}
|
|
1027
|
+
.rows {
|
|
1028
|
+
display: grid;
|
|
1029
|
+
gap: 4px;
|
|
1030
|
+
}
|
|
1031
|
+
.pad {
|
|
1032
|
+
padding: 4px 18px 18px;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/* One grid for the header and every row, so the columns line up down the page. */
|
|
1036
|
+
.table {
|
|
1037
|
+
--hr-office-cols: minmax(160px, 1.6fr) 116px minmax(130px, 1fr) 64px 36px;
|
|
1038
|
+
width: 100%;
|
|
1039
|
+
}
|
|
1040
|
+
.thead,
|
|
1041
|
+
.trow {
|
|
1042
|
+
display: grid;
|
|
1043
|
+
grid-template-columns: var(--hr-office-cols);
|
|
1044
|
+
gap: 12px;
|
|
1045
|
+
align-items: center;
|
|
1046
|
+
padding-inline: 18px;
|
|
1047
|
+
}
|
|
1048
|
+
.thead {
|
|
1049
|
+
height: 32px;
|
|
1050
|
+
border-block-end: 1px solid var(--kern-border);
|
|
1051
|
+
font-size: 11px;
|
|
1052
|
+
font-weight: 600;
|
|
1053
|
+
letter-spacing: 0.06em;
|
|
1054
|
+
text-transform: uppercase;
|
|
1055
|
+
color: var(--kern-ink-500);
|
|
1056
|
+
}
|
|
1057
|
+
.trow {
|
|
1058
|
+
min-height: 56px;
|
|
1059
|
+
border-block-end: 1px solid var(--kern-border-hairline);
|
|
1060
|
+
}
|
|
1061
|
+
.trow:last-child {
|
|
1062
|
+
border-block-end: 0;
|
|
1063
|
+
}
|
|
1064
|
+
.trow:hover {
|
|
1065
|
+
background: var(--kern-surface-hover);
|
|
1066
|
+
}
|
|
1067
|
+
.cell {
|
|
1068
|
+
min-width: 0;
|
|
1069
|
+
overflow: hidden;
|
|
1070
|
+
text-overflow: ellipsis;
|
|
1071
|
+
white-space: nowrap;
|
|
1072
|
+
}
|
|
1073
|
+
.stack {
|
|
1074
|
+
display: flex;
|
|
1075
|
+
flex-direction: column;
|
|
1076
|
+
gap: 2px;
|
|
1077
|
+
}
|
|
1078
|
+
.name {
|
|
1079
|
+
display: flex;
|
|
1080
|
+
align-items: center;
|
|
1081
|
+
gap: 8px;
|
|
1082
|
+
min-width: 0;
|
|
1083
|
+
font-size: 13.5px;
|
|
1084
|
+
font-weight: 500;
|
|
1085
|
+
}
|
|
1086
|
+
.sub {
|
|
1087
|
+
font-size: 12px;
|
|
1088
|
+
/* A colour, not opacity: opacity fades text against the page whatever token it names. */
|
|
1089
|
+
color: var(--kern-ink-500);
|
|
1090
|
+
}
|
|
1091
|
+
.mono {
|
|
1092
|
+
font-family: var(--kern-font-mono);
|
|
1093
|
+
}
|
|
1094
|
+
.num {
|
|
1095
|
+
text-align: end;
|
|
1096
|
+
font-size: 13px;
|
|
1097
|
+
color: var(--kern-ink-500);
|
|
1098
|
+
font-variant-numeric: tabular-nums;
|
|
1099
|
+
}
|
|
1100
|
+
.end {
|
|
1101
|
+
display: flex;
|
|
1102
|
+
justify-content: flex-end;
|
|
1103
|
+
overflow: visible;
|
|
1104
|
+
}
|
|
1105
|
+
.sr-only {
|
|
1106
|
+
position: absolute;
|
|
1107
|
+
width: 1px;
|
|
1108
|
+
height: 1px;
|
|
1109
|
+
overflow: hidden;
|
|
1110
|
+
clip-path: inset(50%);
|
|
1111
|
+
white-space: nowrap;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
.form {
|
|
1115
|
+
display: grid;
|
|
1116
|
+
gap: 12px;
|
|
1117
|
+
}
|
|
1118
|
+
.pair {
|
|
1119
|
+
display: grid;
|
|
1120
|
+
grid-template-columns: 1fr 1fr;
|
|
1121
|
+
gap: 12px;
|
|
1122
|
+
}
|
|
1123
|
+
.body {
|
|
1124
|
+
margin: 0 0 12px;
|
|
1125
|
+
font-size: 13.5px;
|
|
1126
|
+
line-height: 1.55;
|
|
1127
|
+
color: var(--kern-ink-700);
|
|
1128
|
+
text-wrap: pretty;
|
|
1129
|
+
}
|
|
1130
|
+
.body.muted {
|
|
1131
|
+
color: var(--kern-ink-500);
|
|
1132
|
+
}
|
|
1133
|
+
.err {
|
|
1134
|
+
margin: 0;
|
|
1135
|
+
font-size: 12.5px;
|
|
1136
|
+
color: var(--kern-danger);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
.explainer {
|
|
1140
|
+
margin: 0 0 14px;
|
|
1141
|
+
padding: 10px 12px;
|
|
1142
|
+
border: 1px solid var(--kern-border-hairline);
|
|
1143
|
+
border-radius: var(--kern-r-lg);
|
|
1144
|
+
background: var(--kern-surface-chip);
|
|
1145
|
+
font-size: 12.5px;
|
|
1146
|
+
line-height: 1.55;
|
|
1147
|
+
color: var(--kern-ink-700);
|
|
1148
|
+
text-wrap: pretty;
|
|
1149
|
+
}
|
|
1150
|
+
.filter {
|
|
1151
|
+
margin-block-end: 10px;
|
|
1152
|
+
}
|
|
1153
|
+
.roster {
|
|
1154
|
+
display: grid;
|
|
1155
|
+
gap: 4px;
|
|
1156
|
+
list-style: none;
|
|
1157
|
+
margin: 0;
|
|
1158
|
+
padding: 0;
|
|
1159
|
+
}
|
|
1160
|
+
.roster li {
|
|
1161
|
+
display: flex;
|
|
1162
|
+
align-items: center;
|
|
1163
|
+
justify-content: space-between;
|
|
1164
|
+
gap: 12px;
|
|
1165
|
+
min-height: 44px;
|
|
1166
|
+
padding: 6px 10px;
|
|
1167
|
+
border: 1px solid var(--kern-border-hairline);
|
|
1168
|
+
border-radius: var(--kern-r-md);
|
|
1169
|
+
}
|
|
1170
|
+
.assign {
|
|
1171
|
+
display: grid;
|
|
1172
|
+
gap: 12px;
|
|
1173
|
+
margin-block-start: 20px;
|
|
1174
|
+
padding-block-start: 16px;
|
|
1175
|
+
border-block-start: 1px solid var(--kern-border);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
@media (max-width: 720px) {
|
|
1179
|
+
.table {
|
|
1180
|
+
--hr-office-cols: minmax(140px, 1.6fr) minmax(110px, 1fr) 64px 36px;
|
|
1181
|
+
}
|
|
1182
|
+
/* The kind badge is the column a narrow screen can lose: it is the least load-bearing fact. */
|
|
1183
|
+
.thead > :nth-child(2),
|
|
1184
|
+
.trow > :nth-child(2) {
|
|
1185
|
+
display: none;
|
|
1186
|
+
}
|
|
1187
|
+
.pair {
|
|
1188
|
+
grid-template-columns: 1fr;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
</style>
|