@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.
Files changed (90) 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/capabilities.d.ts +16 -0
  6. package/dist/contract/capabilities.d.ts.map +1 -1
  7. package/dist/contract/capabilities.js +16 -0
  8. package/dist/contract/capabilities.js.map +1 -1
  9. package/dist/contract/router.d.ts +9 -0
  10. package/dist/contract/router.d.ts.map +1 -1
  11. package/dist/policy/accrual.d.ts +35 -4
  12. package/dist/policy/accrual.d.ts.map +1 -1
  13. package/dist/policy/accrual.js +141 -15
  14. package/dist/policy/accrual.js.map +1 -1
  15. package/dist/policy/working-time.d.ts +115 -9
  16. package/dist/policy/working-time.d.ts.map +1 -1
  17. package/dist/policy/working-time.js +120 -20
  18. package/dist/policy/working-time.js.map +1 -1
  19. package/dist/server/jobs.d.ts +14 -6
  20. package/dist/server/jobs.d.ts.map +1 -1
  21. package/dist/server/jobs.js +458 -144
  22. package/dist/server/jobs.js.map +1 -1
  23. package/dist/server/packs/index.d.ts +14 -0
  24. package/dist/server/packs/index.d.ts.map +1 -1
  25. package/dist/server/packs/index.js +19 -0
  26. package/dist/server/packs/index.js.map +1 -1
  27. package/dist/server/router.d.ts +1896 -111
  28. package/dist/server/router.d.ts.map +1 -1
  29. package/dist/server/router.js +146 -63
  30. package/dist/server/router.js.map +1 -1
  31. package/dist/server/schema.d.ts +53 -0
  32. package/dist/server/schema.d.ts.map +1 -1
  33. package/dist/server/schema.js +101 -2
  34. package/dist/server/schema.js.map +1 -1
  35. package/dist/server/services/approvals.d.ts +14 -0
  36. package/dist/server/services/approvals.d.ts.map +1 -1
  37. package/dist/server/services/approvals.js +14 -3
  38. package/dist/server/services/approvals.js.map +1 -1
  39. package/dist/server/services/attendance.d.ts +120 -6
  40. package/dist/server/services/attendance.d.ts.map +1 -1
  41. package/dist/server/services/attendance.js +226 -13
  42. package/dist/server/services/attendance.js.map +1 -1
  43. package/dist/server/services/policies.d.ts +6 -0
  44. package/dist/server/services/policies.d.ts.map +1 -1
  45. package/dist/server/services/policies.js +6 -0
  46. package/dist/server/services/policies.js.map +1 -1
  47. package/migrations/0005_approval_requester.sql +30 -0
  48. package/migrations/0006_schedule_no_overlap.sql +85 -0
  49. package/migrations/0007_hot_path_indexes.sql +38 -0
  50. package/migrations/0009_beyond_cap_minutes.sql +15 -0
  51. package/migrations/meta/0005_snapshot.json +4047 -0
  52. package/migrations/meta/0007_snapshot.json +4225 -0
  53. package/migrations/meta/0009_snapshot.json +4231 -0
  54. package/migrations/meta/_journal.json +28 -0
  55. package/package.json +7 -7
  56. package/src/client/capabilities.ts +34 -0
  57. package/src/client/components/ClockControls.svelte +127 -20
  58. package/src/client/components/ClockControls.test.ts +446 -0
  59. package/src/client/components/DecisionDialog.svelte +90 -0
  60. package/src/client/components/DelegationDialog.svelte +219 -0
  61. package/src/client/components/LeaveRequestDialog.svelte +254 -25
  62. package/src/client/components/PersonFormDialog.svelte +53 -3
  63. package/src/client/components/PersonPanel.svelte +134 -26
  64. package/src/client/i18n.ts +5 -625
  65. package/src/client/index.ts +1 -20
  66. package/src/client/messages.test.ts +154 -0
  67. package/src/client/messages.ts +3388 -0
  68. package/src/client/mock.ts +1691 -162
  69. package/src/client/module.ts +17 -2
  70. package/src/client/pages/ApprovalsPage.svelte +332 -60
  71. package/src/client/pages/AttendancePage.svelte +106 -20
  72. package/src/client/pages/DirectoryPage.svelte +192 -55
  73. package/src/client/pages/LeavePage.svelte +280 -25
  74. package/src/client/pages/OfficesPage.svelte +181 -39
  75. package/src/client/pages/leave-and-attendance.test.ts +588 -0
  76. package/src/client/permissions.ts +2 -1
  77. package/src/client/query.ts +22 -1
  78. package/src/client/settings/CalendarsSettings.svelte +1363 -12
  79. package/src/client/settings/CapabilitiesSettings.svelte +276 -19
  80. package/src/client/settings/GeneralSettings.svelte +420 -0
  81. package/src/client/settings/LeaveSettings.svelte +884 -12
  82. package/src/client/settings/OfficesSettings.svelte +1182 -12
  83. package/src/client/settings/SchedulesSettings.svelte +1149 -12
  84. package/src/client/summary.ts +43 -0
  85. package/src/client/widgets/ApprovalsWidget.svelte +133 -20
  86. package/src/client/widgets/HeadcountWidget.svelte +74 -8
  87. package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
  88. package/src/client/widgets/WhosOutWidget.svelte +47 -8
  89. package/src/contract/approvals.ts +13 -1
  90. package/src/contract/capabilities.ts +17 -0
@@ -1,17 +1,35 @@
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
+ formatCount,
8
+ messageLocale,
9
+ navigation,
10
+ Page,
11
+ PageHeader,
12
+ SectionLabel,
13
+ Skeleton,
14
+ StatTile,
15
+ session,
16
+ } from '@kernhq/ui'
3
17
  import { createQuery } from '@tanstack/svelte-query'
4
18
  import { getHrApi } from '../api-instance.js'
5
19
  import { t } from '../i18n.js'
20
+ import { canHr } from '../permissions.js'
6
21
  import { hrKeys } from '../query.js'
7
22
 
8
23
  /**
9
24
  * Where the company works.
10
25
  *
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.
26
+ * A table rather than cards: an office list is read down a column which of these is in which
27
+ * country, how many people, what time is it thereand cards make every one of those a scan across
28
+ * the page instead of down it.
29
+ *
30
+ * The **current local time** is the reason this screen earns its place once there is more than one
31
+ * office: it is the answer to "can I call Amsterdam now". The default office is marked because it is
32
+ * where somebody with no assignment lands and where the resolution ladder bottoms out.
15
33
  */
16
34
  const api = getHrApi()
17
35
 
@@ -26,6 +44,14 @@ const officesQuery = createQuery(() => ({
26
44
  }))
27
45
  const offices = $derived(officesQuery.data ?? [])
28
46
 
47
+ const stats = $derived({
48
+ countries: new Set(offices.map((o) => o.country)).size,
49
+ people: offices.reduce((sum, o) => sum + o.headcount, 0),
50
+ /** Two offices in one zone is one clock to think about; two zones is a scheduling problem. */
51
+ timezones: new Set(offices.map((o) => o.timezone)).size,
52
+ })
53
+
54
+ /** Re-renders the clocks once a minute; an office list showing a stale time is worse than none. */
29
55
  let tick = $state(0)
30
56
  $effect(() => {
31
57
  const handle = setInterval(() => {
@@ -37,83 +63,199 @@ $effect(() => {
37
63
  function localTime(timezone: string, _tick: number): string {
38
64
  void _tick
39
65
  try {
40
- return new Intl.DateTimeFormat(undefined, {
66
+ // The office's zone, the reader's locale: `localTime` from @kernhq/ui cannot help here because
67
+ // it formats in the reader's own zone, and the whole point of this column is somebody else's.
68
+ // `messageLocale()` is what stops the clocks reading in Latin digits on a Persian screen.
69
+ return new Intl.DateTimeFormat(messageLocale(), {
41
70
  timeZone: timezone,
42
71
  hour: 'numeric',
43
72
  minute: '2-digit',
44
73
  }).format(new Date())
45
74
  } catch {
46
- return ''
75
+ // An unknown zone must not take the page down with it.
76
+ return '—'
47
77
  }
48
78
  }
49
79
 
50
- /** The country's own name for itself, rather than a two-letter code nobody reads. */
80
+ /** The country's own name, rather than a two-letter code nobody reads. */
51
81
  function countryName(code: string): string {
52
82
  try {
53
- return new Intl.DisplayNames(undefined, { type: 'region' }).of(code) ?? code
83
+ return new Intl.DisplayNames(messageLocale(), { type: 'region' }).of(code) ?? code
54
84
  } catch {
55
85
  return code
56
86
  }
57
87
  }
88
+
89
+ const kindLabel = (kind: string): string =>
90
+ kind === 'head_office'
91
+ ? t('office_kind_head_office')
92
+ : kind === 'branch'
93
+ ? t('office_kind_branch')
94
+ : kind === 'site'
95
+ ? t('office_kind_site')
96
+ : kind === 'warehouse'
97
+ ? t('office_kind_warehouse')
98
+ : kind === 'store'
99
+ ? t('office_kind_store')
100
+ : t('office_kind_remote')
101
+
102
+ const kindTone = (kind: string): BadgeTone => (kind === 'remote' ? 'info' : 'grey')
58
103
  </script>
59
104
 
60
105
  <PageHeader
61
106
  crumbs={[{ label: workspace?.name ?? '' }, { label: t('offices_title') }]}
62
107
  title={t('offices_title')}
63
- />
108
+ >
109
+ {#snippet actions()}
110
+ {#if canHr('officeManage')}
111
+ <Button size="sm" variant="secondary" icon="settings" href={`/${workspaceSlug}/settings/hr/offices`}>
112
+ {t('offices_manage')}
113
+ </Button>
114
+ {/if}
115
+ {/snippet}
116
+ </PageHeader>
64
117
 
65
118
  <Page>
119
+ <!--
120
+ No "Offices" tile. It said the same word and the same number as the section label directly
121
+ below it, so the page opened with "Offices / Offices / Offices 2 / OFFICES 2" down the left
122
+ edge. A tile has to add a fact the list does not already state: how many countries the company
123
+ operates in, how many people those offices hold, and how many clocks you are working across.
124
+ -->
125
+ <div class="tiles">
126
+ <StatTile
127
+ size="md"
128
+ label={t('offices_countries')}
129
+ value={formatCount(stats.countries, Number.MAX_SAFE_INTEGER)}
130
+ />
131
+ <StatTile size="md" label={t('office_people')} value={formatCount(stats.people, Number.MAX_SAFE_INTEGER)} />
132
+ <StatTile
133
+ size="md"
134
+ label={t('offices_timezones')}
135
+ value={formatCount(stats.timezones, Number.MAX_SAFE_INTEGER)}
136
+ />
137
+ </div>
138
+
139
+ <SectionLabel label={t('offices_title')} count={offices.length} />
140
+
66
141
  {#if officesQuery.isLoading}
67
- <div class="grid">{#each [1, 2, 3] as n (n)}<Skeleton height="96px" />{/each}</div>
142
+ <div class="rows">
143
+ {#each [1, 2, 3] as n (n)}<Skeleton height="48px" />{/each}
144
+ </div>
145
+ {:else if officesQuery.isError}
146
+ <EmptyState icon="triangle-alert" title={t('offices_error')}>
147
+ {#snippet actions()}
148
+ <Button variant="secondary" onclick={() => void officesQuery.refetch()}>{t('retry')}</Button>
149
+ {/snippet}
150
+ </EmptyState>
68
151
  {:else if offices.length === 0}
69
152
  <EmptyState icon="building" title={t('offices_none')} description={t('offices_none_desc')} />
70
153
  {:else}
71
- <div class="grid">
154
+ <div class="table" role="table" aria-label={t('offices_title')}>
155
+ <div class="thead" role="row">
156
+ <span role="columnheader">{t('office')}</span>
157
+ <span role="columnheader">{t('office_kind')}</span>
158
+ <span role="columnheader">{t('office_country')}</span>
159
+ <span role="columnheader">{t('office_people')}</span>
160
+ <span role="columnheader">{t('local_time')}</span>
161
+ </div>
72
162
  {#each offices as office (office.id)}
73
- <Card>
74
- <div class="head">
75
- <span class="name">{office.name}</span>
163
+ <a
164
+ class="trow"
165
+ role="row"
166
+ href={`/${workspaceSlug}/hr?officeId=${office.id}`}
167
+ aria-label={`${office.name} — ${t('office_view_people')}`}
168
+ >
169
+ <span class="cell name" role="cell">
170
+ {office.name}
76
171
  {#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>
172
+ </span>
173
+ <span class="cell" role="cell"><Badge tone={kindTone(office.kind)}>{kindLabel(office.kind)}</Badge></span>
174
+ <span class="cell muted" role="cell">{countryName(office.country)}</span>
175
+ <span class="cell num" role="cell">{formatCount(office.headcount, Number.MAX_SAFE_INTEGER)}</span>
176
+ <span class="cell num" role="cell" title={office.timezone}>{localTime(office.timezone, tick)}</span>
177
+ </a>
84
178
  {/each}
85
179
  </div>
86
180
  {/if}
87
181
  </Page>
88
182
 
89
183
  <style>
90
- .grid {
184
+ .tiles {
91
185
  display: grid;
92
- grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
186
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
93
187
  gap: 12px;
188
+ margin-block-end: 20px;
94
189
  }
95
- .head {
96
- display: flex;
190
+ .rows {
191
+ display: grid;
192
+ gap: 4px;
193
+ }
194
+
195
+ /* One grid for the header and every row, so the columns line up down the page. */
196
+ .table {
197
+ --hr-office-cols: minmax(200px, 1.4fr) 130px minmax(120px, 0.8fr) 96px 104px;
198
+ width: 100%;
199
+ }
200
+ .thead,
201
+ .trow {
202
+ display: grid;
203
+ grid-template-columns: var(--hr-office-cols);
204
+ gap: 12px;
97
205
  align-items: center;
98
- gap: 8px;
99
- justify-content: space-between;
206
+ padding-inline: 12px;
207
+ }
208
+ .thead {
209
+ height: 34px;
210
+ border-block-end: 1px solid var(--kern-border);
211
+ font-size: 11px;
212
+ font-weight: 600;
213
+ letter-spacing: 0.06em;
214
+ text-transform: uppercase;
215
+ color: var(--kern-ink-500);
216
+ }
217
+ .trow {
218
+ height: 48px;
219
+ border-block-end: 1px solid var(--kern-border-hairline);
220
+ text-decoration: none;
221
+ color: inherit;
222
+ border-radius: var(--kern-r-md);
223
+ }
224
+ .trow:hover {
225
+ background: var(--kern-surface-raised);
226
+ }
227
+ .cell {
228
+ min-width: 0;
229
+ overflow: hidden;
230
+ text-overflow: ellipsis;
231
+ white-space: nowrap;
100
232
  }
101
233
  .name {
234
+ display: flex;
235
+ align-items: center;
236
+ gap: 8px;
237
+ font-size: 13.5px;
102
238
  font-weight: 500;
103
239
  }
104
- .meta {
240
+ .muted {
241
+ font-size: 13px;
242
+ /* A colour, not opacity: opacity fades text against the page whatever token it names. */
105
243
  color: var(--kern-ink-500);
106
- font-size: 12px;
107
- margin: 4px 0 0;
108
244
  }
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;
245
+ .num {
246
+ font-size: 13px;
247
+ color: var(--kern-ink-500);
117
248
  font-variant-numeric: tabular-nums;
118
249
  }
250
+
251
+ @media (max-width: 900px) {
252
+ .table {
253
+ --hr-office-cols: minmax(140px, 1.4fr) 110px 96px 88px;
254
+ }
255
+ /* The country column is the one a narrow screen can lose: the office name implies it. */
256
+ .thead > :nth-child(3),
257
+ .trow > :nth-child(3) {
258
+ display: none;
259
+ }
260
+ }
119
261
  </style>