@kernhq/module-hr 0.9.2 → 0.10.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.
Files changed (34) hide show
  1. package/dist/contract/approvals.d.ts +3 -0
  2. package/dist/contract/approvals.d.ts.map +1 -1
  3. package/dist/contract/approvals.js +13 -1
  4. package/dist/contract/approvals.js.map +1 -1
  5. package/dist/contract/router.d.ts +9 -0
  6. package/dist/contract/router.d.ts.map +1 -1
  7. package/dist/server/router.d.ts +9 -0
  8. package/dist/server/router.d.ts.map +1 -1
  9. package/dist/server/router.js +17 -0
  10. package/dist/server/router.js.map +1 -1
  11. package/dist/server/schema.d.ts +36 -0
  12. package/dist/server/schema.d.ts.map +1 -1
  13. package/dist/server/schema.js +20 -0
  14. package/dist/server/schema.js.map +1 -1
  15. package/dist/server/services/approvals.d.ts +14 -0
  16. package/dist/server/services/approvals.d.ts.map +1 -1
  17. package/dist/server/services/approvals.js +2 -0
  18. package/dist/server/services/approvals.js.map +1 -1
  19. package/migrations/0005_approval_requester.sql +20 -0
  20. package/migrations/meta/_journal.json +7 -0
  21. package/package.json +6 -4
  22. package/src/client/capabilities.ts +34 -0
  23. package/src/client/components/DecisionDialog.svelte +90 -0
  24. package/src/client/components/DelegationDialog.svelte +210 -0
  25. package/src/client/i18n.ts +302 -0
  26. package/src/client/index.ts +1 -20
  27. package/src/client/mock.ts +133 -16
  28. package/src/client/module.ts +2 -1
  29. package/src/client/pages/ApprovalsPage.svelte +252 -54
  30. package/src/client/pages/OfficesPage.svelte +161 -37
  31. package/src/client/permissions.ts +2 -1
  32. package/src/client/query.ts +10 -1
  33. package/src/client/summary.ts +36 -0
  34. package/src/contract/approvals.ts +13 -1
@@ -1,17 +1,33 @@
1
1
  <script lang="ts">
2
- import { Badge, Card, EmptyState, navigation, Page, PageHeader, Skeleton, session } from '@kernhq/ui'
2
+ import {
3
+ Badge,
4
+ type BadgeTone,
5
+ Button,
6
+ EmptyState,
7
+ navigation,
8
+ Page,
9
+ PageHeader,
10
+ SectionLabel,
11
+ Skeleton,
12
+ StatTile,
13
+ session,
14
+ } from '@kernhq/ui'
3
15
  import { createQuery } from '@tanstack/svelte-query'
4
16
  import { getHrApi } from '../api-instance.js'
5
17
  import { t } from '../i18n.js'
18
+ import { canHr } from '../permissions.js'
6
19
  import { hrKeys } from '../query.js'
7
20
 
8
21
  /**
9
22
  * Where the company works.
10
23
  *
11
- * Each card shows the office's **current local time** alongside its country, because that is what an
12
- * office list is for once there is more than one knowing whether Amsterdam is awake. The default
13
- * office is marked, since it is where somebody with no assignment lands and where the resolution
14
- * ladder bottoms out.
24
+ * A table rather than cards: an office list is read down a column which of these is in which
25
+ * country, how many people, what time is it thereand cards make every one of those a scan across
26
+ * the page instead of down it.
27
+ *
28
+ * The **current local time** is the reason this screen earns its place once there is more than one
29
+ * office: it is the answer to "can I call Amsterdam now". The default office is marked because it is
30
+ * where somebody with no assignment lands and where the resolution ladder bottoms out.
15
31
  */
16
32
  const api = getHrApi()
17
33
 
@@ -26,6 +42,13 @@ const officesQuery = createQuery(() => ({
26
42
  }))
27
43
  const offices = $derived(officesQuery.data ?? [])
28
44
 
45
+ const stats = $derived({
46
+ offices: offices.length,
47
+ countries: new Set(offices.map((o) => o.country)).size,
48
+ people: offices.reduce((sum, o) => sum + o.headcount, 0),
49
+ })
50
+
51
+ /** Re-renders the clocks once a minute; an office list showing a stale time is worse than none. */
29
52
  let tick = $state(0)
30
53
  $effect(() => {
31
54
  const handle = setInterval(() => {
@@ -43,11 +66,12 @@ function localTime(timezone: string, _tick: number): string {
43
66
  minute: '2-digit',
44
67
  }).format(new Date())
45
68
  } catch {
46
- return ''
69
+ // An unknown zone must not take the page down with it.
70
+ return '—'
47
71
  }
48
72
  }
49
73
 
50
- /** The country's own name for itself, rather than a two-letter code nobody reads. */
74
+ /** The country's own name, rather than a two-letter code nobody reads. */
51
75
  function countryName(code: string): string {
52
76
  try {
53
77
  return new Intl.DisplayNames(undefined, { type: 'region' }).of(code) ?? code
@@ -55,65 +79,165 @@ function countryName(code: string): string {
55
79
  return code
56
80
  }
57
81
  }
82
+
83
+ const kindLabel = (kind: string): string =>
84
+ kind === 'head_office'
85
+ ? t('office_kind_head_office')
86
+ : kind === 'branch'
87
+ ? t('office_kind_branch')
88
+ : kind === 'site'
89
+ ? t('office_kind_site')
90
+ : kind === 'warehouse'
91
+ ? t('office_kind_warehouse')
92
+ : kind === 'store'
93
+ ? t('office_kind_store')
94
+ : t('office_kind_remote')
95
+
96
+ const kindTone = (kind: string): BadgeTone => (kind === 'remote' ? 'info' : 'grey')
58
97
  </script>
59
98
 
60
99
  <PageHeader
61
100
  crumbs={[{ label: workspace?.name ?? '' }, { label: t('offices_title') }]}
62
101
  title={t('offices_title')}
63
- />
102
+ >
103
+ {#snippet actions()}
104
+ {#if canHr('officeManage')}
105
+ <Button size="sm" href={`/${workspaceSlug}/settings/hr/offices`}>{t('settings_offices')}</Button>
106
+ {/if}
107
+ {/snippet}
108
+ </PageHeader>
64
109
 
65
110
  <Page>
111
+ <div class="tiles">
112
+ <StatTile size="md" label={t('offices_total')} value={new Intl.NumberFormat().format(stats.offices)} />
113
+ <StatTile
114
+ size="md"
115
+ label={t('offices_countries')}
116
+ value={new Intl.NumberFormat().format(stats.countries)}
117
+ />
118
+ <StatTile size="md" label={t('office_people')} value={new Intl.NumberFormat().format(stats.people)} />
119
+ </div>
120
+
121
+ <SectionLabel label={t('offices_title')} count={offices.length} />
122
+
66
123
  {#if officesQuery.isLoading}
67
- <div class="grid">{#each [1, 2, 3] as n (n)}<Skeleton height="96px" />{/each}</div>
124
+ <div class="rows">
125
+ {#each [1, 2, 3] as n (n)}<Skeleton height="48px" />{/each}
126
+ </div>
127
+ {:else if officesQuery.isError}
128
+ <EmptyState icon="triangle-alert" title={t('offices_error')}>
129
+ {#snippet actions()}
130
+ <Button variant="secondary" onclick={() => void officesQuery.refetch()}>{t('retry')}</Button>
131
+ {/snippet}
132
+ </EmptyState>
68
133
  {:else if offices.length === 0}
69
134
  <EmptyState icon="building" title={t('offices_none')} description={t('offices_none_desc')} />
70
135
  {:else}
71
- <div class="grid">
136
+ <div class="table" role="table" aria-label={t('offices_title')}>
137
+ <div class="thead" role="row">
138
+ <span role="columnheader">{t('office')}</span>
139
+ <span role="columnheader">{t('office_kind')}</span>
140
+ <span role="columnheader">{t('office_country')}</span>
141
+ <span role="columnheader">{t('office_people')}</span>
142
+ <span role="columnheader">{t('local_time')}</span>
143
+ </div>
72
144
  {#each offices as office (office.id)}
73
- <Card>
74
- <div class="head">
75
- <span class="name">{office.name}</span>
145
+ <a
146
+ class="trow"
147
+ role="row"
148
+ href={`/${workspaceSlug}/hr?officeId=${office.id}`}
149
+ aria-label={`${office.name} — ${t('office_view_people')}`}
150
+ >
151
+ <span class="cell name" role="cell">
152
+ {office.name}
76
153
  {#if office.isDefault}<Badge tone="accent">{t('office_default')}</Badge>{/if}
77
- </div>
78
- <p class="meta">{countryName(office.country)}</p>
79
- <div class="foot">
80
- <span class="time">{localTime(office.timezone, tick)}</span>
81
- <span class="meta">{t('headcount', { count: String(office.headcount) })}</span>
82
- </div>
83
- </Card>
154
+ </span>
155
+ <span class="cell" role="cell"><Badge tone={kindTone(office.kind)}>{kindLabel(office.kind)}</Badge></span>
156
+ <span class="cell muted" role="cell">{countryName(office.country)}</span>
157
+ <span class="cell num" role="cell">{new Intl.NumberFormat().format(office.headcount)}</span>
158
+ <span class="cell num" role="cell" title={office.timezone}>{localTime(office.timezone, tick)}</span>
159
+ </a>
84
160
  {/each}
85
161
  </div>
86
162
  {/if}
87
163
  </Page>
88
164
 
89
165
  <style>
90
- .grid {
166
+ .tiles {
91
167
  display: grid;
92
- grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
168
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
93
169
  gap: 12px;
170
+ margin-block-end: 20px;
94
171
  }
95
- .head {
96
- display: flex;
172
+ .rows {
173
+ display: grid;
174
+ gap: 4px;
175
+ }
176
+
177
+ /* One grid for the header and every row, so the columns line up down the page. */
178
+ .table {
179
+ --hr-office-cols: minmax(200px, 1.4fr) 130px minmax(120px, 0.8fr) 96px 104px;
180
+ width: 100%;
181
+ }
182
+ .thead,
183
+ .trow {
184
+ display: grid;
185
+ grid-template-columns: var(--hr-office-cols);
186
+ gap: 12px;
97
187
  align-items: center;
98
- gap: 8px;
99
- justify-content: space-between;
188
+ padding-inline: 12px;
189
+ }
190
+ .thead {
191
+ height: 34px;
192
+ border-block-end: 1px solid var(--kern-border);
193
+ font-size: 11px;
194
+ font-weight: 600;
195
+ letter-spacing: 0.06em;
196
+ text-transform: uppercase;
197
+ color: var(--kern-ink-500);
198
+ }
199
+ .trow {
200
+ height: 48px;
201
+ border-block-end: 1px solid var(--kern-border-hairline);
202
+ text-decoration: none;
203
+ color: inherit;
204
+ border-radius: var(--kern-r-md);
205
+ }
206
+ .trow:hover {
207
+ background: var(--kern-surface-raised);
208
+ }
209
+ .cell {
210
+ min-width: 0;
211
+ overflow: hidden;
212
+ text-overflow: ellipsis;
213
+ white-space: nowrap;
100
214
  }
101
215
  .name {
216
+ display: flex;
217
+ align-items: center;
218
+ gap: 8px;
219
+ font-size: 13.5px;
102
220
  font-weight: 500;
103
221
  }
104
- .meta {
222
+ .muted {
223
+ font-size: 13px;
224
+ /* A colour, not opacity: opacity fades text against the page whatever token it names. */
105
225
  color: var(--kern-ink-500);
106
- font-size: 12px;
107
- margin: 4px 0 0;
108
226
  }
109
- .foot {
110
- display: flex;
111
- align-items: baseline;
112
- justify-content: space-between;
113
- margin-block-start: 12px;
114
- }
115
- .time {
116
- font-size: 15px;
227
+ .num {
228
+ font-size: 13px;
229
+ color: var(--kern-ink-500);
117
230
  font-variant-numeric: tabular-nums;
118
231
  }
232
+
233
+ @media (max-width: 900px) {
234
+ .table {
235
+ --hr-office-cols: minmax(140px, 1.4fr) 110px 96px 88px;
236
+ }
237
+ /* The country column is the one a narrow screen can lose: the office name implies it. */
238
+ .thead > :nth-child(3),
239
+ .trow > :nth-child(3) {
240
+ display: none;
241
+ }
242
+ }
119
243
  </style>
@@ -1,5 +1,6 @@
1
1
  import { session } from '@kernhq/ui'
2
- import { HR_CAPABILITIES, HR_PERMISSIONS } from './index.js'
2
+ import { HR_PERMISSIONS } from '../contract/permissions.js'
3
+ import { HR_CAPABILITIES } from './capabilities.js'
3
4
 
4
5
  /**
5
6
  * What this module lets somebody do, and what this workspace has switched on.
@@ -28,7 +28,16 @@ export const hrKeys = {
28
28
  attendanceDays: (ws: string, personId: string | undefined, from: string, to: string) =>
29
29
  ['hr', 'attendance-days', ws, personId ?? 'me', from, to] as const,
30
30
  schedules: (ws: string) => ['hr', 'schedules', ws] as const,
31
- approvalInbox: (ws: string) => ['hr', 'approvals', ws] as const,
31
+ /**
32
+ * `includeDecided` is part of the key, not a filter over one cached list.
33
+ *
34
+ * The two answers are different rows from the server — the waiting list is what a step is
35
+ * pending on, the decided one is history — so sharing a key would show one tab the other's
36
+ * contents for as long as the refetch takes.
37
+ */
38
+ approvalInbox: (ws: string, includeDecided = false) =>
39
+ ['hr', 'approvals', ws, includeDecided ? 'decided' : 'waiting'] as const,
40
+ delegations: (ws: string) => ['hr', 'delegations', ws] as const,
32
41
  } as const
33
42
 
34
43
  /** `YYYY-MM-DD` for a date, in the viewer's own zone rather than UTC. */
@@ -0,0 +1,36 @@
1
+ import type { ApprovalRequest } from '../contract/index.js'
2
+ import { t } from './i18n.js'
3
+
4
+ /**
5
+ * The request line, in the reader's language.
6
+ *
7
+ * `summaryParams` is the sentence as data; `summary` is the English the server composed before that
8
+ * existed and is the fallback for those rows. Rendering the fallback is better than rendering
9
+ * nothing — an inbox row with no subject cannot be decided at all.
10
+ *
11
+ * Lives beside neither page that uses it: an inbox row and its confirmation dialog must say the
12
+ * same sentence, or approving looks like an action on something else.
13
+ */
14
+ export function summarise(request: ApprovalRequest): string {
15
+ const p = request.summaryParams
16
+ if (!p) return request.summary
17
+ if (request.subjectType === 'leave' && p.from && p.to)
18
+ return t('approval_summary_leave', {
19
+ days: String(p.days ?? ''),
20
+ range: dateRange(String(p.from), String(p.to)),
21
+ })
22
+ if (request.subjectType === 'regularization' && p.date)
23
+ return t('approval_summary_regularization', { date: day(String(p.date)) })
24
+ if (request.subjectType === 'overtime' && p.date)
25
+ return t('approval_summary_overtime', { date: day(String(p.date)) })
26
+ return request.summary
27
+ }
28
+
29
+ export const day = (iso: string) =>
30
+ new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(iso))
31
+
32
+ /** One range, not two dates and a dash — the separator is the locale's, and RTL needs its own. */
33
+ export const dateRange = (from: string, to: string) =>
34
+ from === to
35
+ ? day(from)
36
+ : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).formatRange(new Date(from), new Date(to))
@@ -117,11 +117,23 @@ export const ApprovalRequest = z.object({
117
117
  ...ws,
118
118
  subjectType: ApprovalSubjectType,
119
119
  subjectId: z.uuid(),
120
- /** A one-line description of what is being approved, so an inbox is readable without joins. */
120
+ /**
121
+ * The English one-liner a request was raised with, kept as the fallback.
122
+ *
123
+ * Prefer `summaryParams`: a sentence composed on the server is English for every reader, which
124
+ * is how half a screen ends up untranslated while every label around it is not.
125
+ */
121
126
  summary: z.string().max(200),
127
+ /** The same sentence as data — `{ days, from, to }` for leave, `{ date }` for a correction. */
128
+ summaryParams: z.record(z.string(), z.union([z.string(), z.number()])).nullable(),
122
129
  status: ApprovalStatus,
123
130
  currentStep: z.number().int(),
131
+ /** The account that submitted it, which may be nobody: an import has no user behind it. */
124
132
  requestedBy: z.uuid().nullable(),
133
+ /** Whose request it is, as an employee. Not the same as `requestedBy` — see the schema. */
134
+ requesterPersonId: z.uuid().nullable(),
135
+ /** Resolved on read so an inbox needs one call, not one call plus a directory lookup. */
136
+ requesterName: z.string().nullable(),
125
137
  requestedAt: Timestamp,
126
138
  decidedAt: Timestamp.nullable(),
127
139
  steps: z.array(ApprovalStep),