@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
package/src/client/mock.ts
CHANGED
|
@@ -4,85 +4,765 @@
|
|
|
4
4
|
* A module missing from the mock has working pages and no way to reach them in exactly the
|
|
5
5
|
* environment used for demos and end-to-end tests — so this exists to be *reachable*, not to be a
|
|
6
6
|
* second implementation. It answers the shapes the screens ask for, with data a demo can show.
|
|
7
|
+
*
|
|
8
|
+
* The state below is mutable and every write lands in it. A create that never appears in the next
|
|
9
|
+
* list looks like a broken product rather than a missing backend, and it is the kind of broken
|
|
10
|
+
* nobody can describe well enough to report — so an archive leaves the default list, an update
|
|
11
|
+
* merges, and a suppressed pack day stays suppressed until somebody restores it.
|
|
12
|
+
*
|
|
13
|
+
* Types come from the contract rather than being retyped here. A mock that answers a shape core
|
|
14
|
+
* does not is how a screen works in `dev:mock` and breaks against the real API.
|
|
15
|
+
*/
|
|
16
|
+
import { ORPCError } from '@orpc/contract'
|
|
17
|
+
import type { Schedule, ScheduleAssignment } from '../contract/attendance.js'
|
|
18
|
+
import type { LeaveType } from '../contract/leave.js'
|
|
19
|
+
import type {
|
|
20
|
+
Calendar,
|
|
21
|
+
CalendarDay,
|
|
22
|
+
CalendarDayKind,
|
|
23
|
+
LegalEntity,
|
|
24
|
+
Office,
|
|
25
|
+
OfficeAssignment,
|
|
26
|
+
ResolvedCalendarDay,
|
|
27
|
+
WorkingWeek,
|
|
28
|
+
} from '../contract/models.js'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A refusal the client cannot tell from the server's.
|
|
32
|
+
*
|
|
33
|
+
* `kernErrorToORPC` turns every `KernError` the router throws into exactly this — same code, same
|
|
34
|
+
* status, same sentence — and the oRPC link hands the browser one back. A bare `Error` would carry
|
|
35
|
+
* the message and *not* the code, so a screen branching on `CONFLICT` takes one path against the
|
|
36
|
+
* mock and another against core, which is the difference the mock exists to erase.
|
|
37
|
+
*
|
|
38
|
+
* Every sentence below is copied from `src/server/router.ts`, because the widget renders the
|
|
39
|
+
* server's own words rather than a translated string.
|
|
7
40
|
*/
|
|
41
|
+
function refuse(code: 'CONFLICT' | 'NOT_FOUND' | 'BAD_REQUEST', message: string): never {
|
|
42
|
+
// A declaration, not a `const` arrow: TypeScript only narrows on a `never` return for one of
|
|
43
|
+
// those, so an arrow would leave every caller believing the row after the guard is still optional.
|
|
44
|
+
throw new ORPCError(code, { message })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Stored without the tenant, which every call stamps back on. */
|
|
48
|
+
type Row<T> = Omit<T, 'workspaceId'>
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A composed day, tenant included.
|
|
52
|
+
*
|
|
53
|
+
* `WorkspaceId` is a branded string in the contract and a plain one everywhere a mock is handed it,
|
|
54
|
+
* so the brand is dropped here rather than asserted at twenty call sites.
|
|
55
|
+
*/
|
|
56
|
+
type ResolvedDay = Row<ResolvedCalendarDay> & { workspaceId: string }
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A deep copy through JSON, which is what the real client does to the same value on its way out.
|
|
60
|
+
*
|
|
61
|
+
* A screen hands these procedures a `$state` proxy — `structuredClone` refuses to clone one, and
|
|
62
|
+
* keeping the proxy would let a later edit in the dialog rewrite a schedule nobody saved.
|
|
63
|
+
*/
|
|
64
|
+
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
|
65
|
+
|
|
8
66
|
const now = Date.now()
|
|
9
67
|
const iso = (msAgo = 0) => new Date(now - msAgo).toISOString()
|
|
10
68
|
const day = (offset: number) => new Date(now + offset * 86_400_000).toISOString().slice(0, 10)
|
|
11
69
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
70
|
+
/** Stable ids, so reloading a demo does not move every link in it. The suffix stays hexadecimal. */
|
|
71
|
+
const id = (suffix: string) => `01920000-0000-7000-8000-${suffix.padStart(12, '0')}`
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The seed year.
|
|
75
|
+
*
|
|
76
|
+
* A holiday list is read a year at a time and the calendar screen opens on the current one, so
|
|
77
|
+
* seeding a fixed year would hand every demo after it an empty page.
|
|
78
|
+
*/
|
|
79
|
+
const YEAR = new Date(now).getFullYear()
|
|
80
|
+
const onDay = (monthDay: string) => `${YEAR}-${monthDay}`
|
|
81
|
+
|
|
82
|
+
const CAL_TR = id('ca01')
|
|
83
|
+
const CAL_NL = id('ca02')
|
|
84
|
+
|
|
85
|
+
const DEFAULT_WEEK: WorkingWeek = { mon: 1, tue: 1, wed: 1, thu: 1, fri: 1, sat: 0, sun: 0 }
|
|
86
|
+
|
|
87
|
+
/** `getUTCDay()` counts from Sunday; the working week is named. */
|
|
88
|
+
const WEEKDAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] as const
|
|
89
|
+
|
|
90
|
+
/** Every date in an inclusive range. Stepped in UTC, so a daylight-saving hour cannot skip a day. */
|
|
91
|
+
function eachDate(from: string, to: string): string[] {
|
|
92
|
+
const out: string[] = []
|
|
93
|
+
const last = Date.parse(`${to}T00:00:00Z`)
|
|
94
|
+
for (let at = Date.parse(`${from}T00:00:00Z`); at <= last; at += 86_400_000) {
|
|
95
|
+
out.push(new Date(at).toISOString().slice(0, 10))
|
|
96
|
+
}
|
|
97
|
+
return out
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type PackDay = { monthDay: string; name: string; kind: CalendarDayKind; workingFraction: number }
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The country packs, behaving the way a pack does: a version, and a set of days per year.
|
|
104
|
+
*
|
|
105
|
+
* The Türkiye calendar is seeded from an *older* version of `tr` — a renamed day, a dropped one and
|
|
106
|
+
* two it never had — because a `pack.preview` that returns nothing proves nothing about the dialog
|
|
107
|
+
* it feeds, and the promise that dialog makes is that an upgrade cannot eat a company's own days.
|
|
108
|
+
*/
|
|
109
|
+
const PACKS: Record<string, { name: string; days: PackDay[] }> = {
|
|
110
|
+
// Keyed by the uppercase ISO code, and looked up exactly, because `COUNTRY_PACKS` on the server
|
|
111
|
+
// is. A tolerant mock here would hide the fact that a lowercase key finds nothing there.
|
|
112
|
+
TR: {
|
|
113
|
+
name: 'Türkiye',
|
|
114
|
+
days: [
|
|
115
|
+
{ monthDay: '01-01', name: "New Year's Day", kind: 'public_holiday', workingFraction: 0 },
|
|
116
|
+
{ monthDay: '03-19', name: 'Ramazan Bayramı Arifesi', kind: 'half_day', workingFraction: 0.5 },
|
|
117
|
+
{ monthDay: '03-20', name: 'Ramazan Bayramı', kind: 'religious', workingFraction: 0 },
|
|
118
|
+
{
|
|
119
|
+
monthDay: '04-23',
|
|
120
|
+
name: "National Sovereignty and Children's Day",
|
|
121
|
+
kind: 'public_holiday',
|
|
122
|
+
workingFraction: 0,
|
|
123
|
+
},
|
|
124
|
+
{ monthDay: '05-01', name: 'Labour and Solidarity Day', kind: 'public_holiday', workingFraction: 0 },
|
|
125
|
+
{
|
|
126
|
+
monthDay: '05-19',
|
|
127
|
+
name: 'Commemoration of Atatürk, Youth and Sports Day',
|
|
128
|
+
kind: 'public_holiday',
|
|
129
|
+
workingFraction: 0,
|
|
130
|
+
},
|
|
131
|
+
{ monthDay: '05-27', name: 'Kurban Bayramı', kind: 'religious', workingFraction: 0 },
|
|
132
|
+
{
|
|
133
|
+
monthDay: '07-15',
|
|
134
|
+
name: 'Democracy and National Unity Day',
|
|
135
|
+
kind: 'public_holiday',
|
|
136
|
+
workingFraction: 0,
|
|
137
|
+
},
|
|
138
|
+
{ monthDay: '08-30', name: 'Victory Day', kind: 'public_holiday', workingFraction: 0 },
|
|
139
|
+
{ monthDay: '10-29', name: 'Republic Day', kind: 'public_holiday', workingFraction: 0 },
|
|
140
|
+
],
|
|
49
141
|
},
|
|
50
|
-
{
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
142
|
+
NL: {
|
|
143
|
+
name: 'Nederland',
|
|
144
|
+
days: [
|
|
145
|
+
{ monthDay: '01-01', name: 'Nieuwjaarsdag', kind: 'public_holiday', workingFraction: 0 },
|
|
146
|
+
{ monthDay: '04-27', name: 'Koningsdag', kind: 'public_holiday', workingFraction: 0 },
|
|
147
|
+
{ monthDay: '05-05', name: 'Bevrijdingsdag', kind: 'public_holiday', workingFraction: 0 },
|
|
148
|
+
{ monthDay: '12-25', name: 'Eerste Kerstdag', kind: 'public_holiday', workingFraction: 0 },
|
|
149
|
+
{ monthDay: '12-26', name: 'Tweede Kerstdag', kind: 'public_holiday', workingFraction: 0 },
|
|
150
|
+
],
|
|
58
151
|
},
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const person = (p: (typeof PEOPLE)[number], workspaceId: string) => ({
|
|
62
|
-
...p,
|
|
63
|
-
workspaceId,
|
|
64
|
-
userId: null,
|
|
65
|
-
personalEmail: null,
|
|
66
|
-
phone: null,
|
|
67
|
-
photoFileId: null,
|
|
68
|
-
hiredOn: day(-400),
|
|
69
|
-
terminatedOn: null,
|
|
70
|
-
custom: {},
|
|
71
|
-
createdAt: iso(400 * 86_400_000),
|
|
72
|
-
updatedAt: iso(),
|
|
73
|
-
})
|
|
152
|
+
}
|
|
74
153
|
|
|
75
154
|
export function createMockHrApi() {
|
|
76
155
|
/** Clock state lives here so the widget behaves across clicks in a demo. */
|
|
77
156
|
let clockedInAt: number | null = null
|
|
78
157
|
let onBreak = false
|
|
79
158
|
|
|
159
|
+
// ---------------------------------------------------------------- offices and people
|
|
160
|
+
|
|
161
|
+
const offices: Row<Office>[] = [
|
|
162
|
+
{
|
|
163
|
+
id: id('e001'),
|
|
164
|
+
name: 'Istanbul',
|
|
165
|
+
code: 'IST',
|
|
166
|
+
kind: 'head_office',
|
|
167
|
+
parentOfficeId: null,
|
|
168
|
+
legalEntityId: id('1e01'),
|
|
169
|
+
country: 'TR',
|
|
170
|
+
region: '34',
|
|
171
|
+
city: 'Istanbul',
|
|
172
|
+
timezone: 'Europe/Istanbul',
|
|
173
|
+
calendarId: CAL_TR,
|
|
174
|
+
address: { line1: 'Büyükdere Caddesi 1', postalCode: '34394' },
|
|
175
|
+
isDefault: true,
|
|
176
|
+
headPersonId: null,
|
|
177
|
+
archivedAt: null,
|
|
178
|
+
createdAt: iso(400 * 86_400_000),
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: id('e002'),
|
|
182
|
+
name: 'Amsterdam',
|
|
183
|
+
code: 'AMS',
|
|
184
|
+
kind: 'branch',
|
|
185
|
+
parentOfficeId: null,
|
|
186
|
+
legalEntityId: id('1e02'),
|
|
187
|
+
country: 'NL',
|
|
188
|
+
region: 'NH',
|
|
189
|
+
city: 'Amsterdam',
|
|
190
|
+
timezone: 'Europe/Amsterdam',
|
|
191
|
+
calendarId: CAL_NL,
|
|
192
|
+
address: { line1: 'Keizersgracht 12', postalCode: '1015 CS' },
|
|
193
|
+
isDefault: false,
|
|
194
|
+
headPersonId: null,
|
|
195
|
+
archivedAt: null,
|
|
196
|
+
createdAt: iso(200 * 86_400_000),
|
|
197
|
+
},
|
|
198
|
+
// A third kind on screen, and the one people forget exists: remote is a place like any other,
|
|
199
|
+
// with a country, a zone and a calendar, because everything downstream inherits from an office.
|
|
200
|
+
{
|
|
201
|
+
id: id('e003'),
|
|
202
|
+
name: 'Remote',
|
|
203
|
+
code: null,
|
|
204
|
+
kind: 'remote',
|
|
205
|
+
parentOfficeId: null,
|
|
206
|
+
legalEntityId: id('1e01'),
|
|
207
|
+
country: 'DE',
|
|
208
|
+
region: null,
|
|
209
|
+
city: null,
|
|
210
|
+
timezone: 'Europe/Berlin',
|
|
211
|
+
calendarId: CAL_TR,
|
|
212
|
+
address: null,
|
|
213
|
+
isDefault: false,
|
|
214
|
+
headPersonId: null,
|
|
215
|
+
archivedAt: null,
|
|
216
|
+
createdAt: iso(90 * 86_400_000),
|
|
217
|
+
},
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
const entities: Row<LegalEntity>[] = [
|
|
221
|
+
{
|
|
222
|
+
id: id('1e01'),
|
|
223
|
+
name: 'Kern Teknoloji A.Ş.',
|
|
224
|
+
registrationNo: '123456-5',
|
|
225
|
+
taxNo: '4560123456',
|
|
226
|
+
country: 'TR',
|
|
227
|
+
currency: 'TRY',
|
|
228
|
+
archivedAt: null,
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
id: id('1e02'),
|
|
232
|
+
name: 'Kern Europe B.V.',
|
|
233
|
+
registrationNo: '81234567',
|
|
234
|
+
taxNo: 'NL812345678B01',
|
|
235
|
+
country: 'NL',
|
|
236
|
+
currency: 'EUR',
|
|
237
|
+
archivedAt: null,
|
|
238
|
+
},
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
const people = [
|
|
242
|
+
{
|
|
243
|
+
id: id('d001'),
|
|
244
|
+
displayName: 'Ayşe Yılmaz',
|
|
245
|
+
workEmail: 'ayse@example.test',
|
|
246
|
+
status: 'active',
|
|
247
|
+
timezone: 'Europe/Istanbul',
|
|
248
|
+
employeeNo: 'E-1',
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: id('d002'),
|
|
252
|
+
displayName: 'Sanne de Vries',
|
|
253
|
+
workEmail: 'sanne@example.test',
|
|
254
|
+
status: 'active',
|
|
255
|
+
timezone: 'Europe/Amsterdam',
|
|
256
|
+
employeeNo: 'E-2',
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
id: id('d003'),
|
|
260
|
+
displayName: 'Mehmet Kaya',
|
|
261
|
+
workEmail: 'mehmet@example.test',
|
|
262
|
+
status: 'on_leave',
|
|
263
|
+
timezone: 'Europe/Istanbul',
|
|
264
|
+
employeeNo: 'E-3',
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
id: id('d004'),
|
|
268
|
+
displayName: 'Jonas Weber',
|
|
269
|
+
workEmail: 'jonas@example.test',
|
|
270
|
+
status: 'active',
|
|
271
|
+
timezone: 'Europe/Berlin',
|
|
272
|
+
employeeNo: 'E-4',
|
|
273
|
+
},
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Who works where, with exactly one primary each.
|
|
278
|
+
*
|
|
279
|
+
* Sanne holds two: only the primary decides her holidays and her timezone, and the second is
|
|
280
|
+
* presence — she is in Istanbul's roster and nothing about her leave changed. That distinction is
|
|
281
|
+
* the whole reason the roster labels every row, so the seed has to contain a row to label.
|
|
282
|
+
*/
|
|
283
|
+
const assignments: Row<OfficeAssignment>[] = [
|
|
284
|
+
{
|
|
285
|
+
id: id('a5001'),
|
|
286
|
+
personId: id('d001'),
|
|
287
|
+
officeId: id('e001'),
|
|
288
|
+
isPrimary: true,
|
|
289
|
+
effectiveFrom: day(-400),
|
|
290
|
+
effectiveTo: null,
|
|
291
|
+
reason: null,
|
|
292
|
+
createdAt: iso(400 * 86_400_000),
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
id: id('a5002'),
|
|
296
|
+
personId: id('d002'),
|
|
297
|
+
officeId: id('e002'),
|
|
298
|
+
isPrimary: true,
|
|
299
|
+
effectiveFrom: day(-300),
|
|
300
|
+
effectiveTo: null,
|
|
301
|
+
reason: null,
|
|
302
|
+
createdAt: iso(300 * 86_400_000),
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
id: id('a5003'),
|
|
306
|
+
personId: id('d002'),
|
|
307
|
+
officeId: id('e001'),
|
|
308
|
+
isPrimary: false,
|
|
309
|
+
effectiveFrom: day(-120),
|
|
310
|
+
effectiveTo: null,
|
|
311
|
+
reason: 'Two days a week with the platform team',
|
|
312
|
+
createdAt: iso(120 * 86_400_000),
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
id: id('a5004'),
|
|
316
|
+
personId: id('d003'),
|
|
317
|
+
officeId: id('e001'),
|
|
318
|
+
isPrimary: true,
|
|
319
|
+
effectiveFrom: day(-250),
|
|
320
|
+
effectiveTo: null,
|
|
321
|
+
reason: null,
|
|
322
|
+
createdAt: iso(250 * 86_400_000),
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
id: id('a5005'),
|
|
326
|
+
personId: id('d004'),
|
|
327
|
+
officeId: id('e003'),
|
|
328
|
+
isPrimary: true,
|
|
329
|
+
effectiveFrom: day(-80),
|
|
330
|
+
effectiveTo: null,
|
|
331
|
+
reason: null,
|
|
332
|
+
createdAt: iso(80 * 86_400_000),
|
|
333
|
+
},
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
const activeAssignments = (personId: string) =>
|
|
337
|
+
assignments.filter((a) => a.personId === personId && a.effectiveTo === null)
|
|
338
|
+
const primaryOfficeId = (personId: string): string | null =>
|
|
339
|
+
activeAssignments(personId).find((a) => a.isPrimary)?.officeId ?? null
|
|
340
|
+
const officeName = (officeId: string | null) => offices.find((o) => o.id === officeId)?.name ?? null
|
|
341
|
+
const liveOffices = () => offices.filter((o) => o.archivedAt === null)
|
|
342
|
+
/**
|
|
343
|
+
* Primary assignments only.
|
|
344
|
+
*
|
|
345
|
+
* Somebody can hold several offices, so counting presence would make the headcounts sum to more
|
|
346
|
+
* people than the company has — and the offices screen adds them up into a "People" tile. The
|
|
347
|
+
* roster is the other half of the same decision: it lists everyone and labels which kind each
|
|
348
|
+
* row is, because presence is worth seeing and is not worth counting.
|
|
349
|
+
*/
|
|
350
|
+
const headcount = (officeId: string) =>
|
|
351
|
+
assignments.filter((a) => a.officeId === officeId && a.effectiveTo === null && a.isPrimary).length
|
|
352
|
+
|
|
353
|
+
const person = (p: (typeof people)[number], workspaceId: string) => ({
|
|
354
|
+
...p,
|
|
355
|
+
workspaceId,
|
|
356
|
+
userId: null,
|
|
357
|
+
personalEmail: null,
|
|
358
|
+
phone: null,
|
|
359
|
+
photoFileId: null,
|
|
360
|
+
hiredOn: day(-400),
|
|
361
|
+
terminatedOn: null,
|
|
362
|
+
custom: {},
|
|
363
|
+
createdAt: iso(400 * 86_400_000),
|
|
364
|
+
updatedAt: iso(),
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------- calendars
|
|
368
|
+
|
|
369
|
+
const calendars: Row<Calendar>[] = [
|
|
370
|
+
{
|
|
371
|
+
id: CAL_TR,
|
|
372
|
+
name: 'Türkiye',
|
|
373
|
+
extendsId: null,
|
|
374
|
+
country: 'TR',
|
|
375
|
+
region: null,
|
|
376
|
+
workingWeek: { ...DEFAULT_WEEK },
|
|
377
|
+
source: 'pack',
|
|
378
|
+
packKey: 'TR',
|
|
379
|
+
packVersion: String(YEAR),
|
|
380
|
+
archivedAt: null,
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
id: CAL_NL,
|
|
384
|
+
name: 'Amsterdam',
|
|
385
|
+
extendsId: CAL_TR,
|
|
386
|
+
country: 'NL',
|
|
387
|
+
region: null,
|
|
388
|
+
workingWeek: { ...DEFAULT_WEEK },
|
|
389
|
+
source: 'custom',
|
|
390
|
+
packKey: null,
|
|
391
|
+
packVersion: null,
|
|
392
|
+
archivedAt: null,
|
|
393
|
+
},
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
let dayCounter = 0
|
|
397
|
+
const nextDayId = () => id(`da${(++dayCounter).toString(16).padStart(4, '0')}`)
|
|
398
|
+
|
|
399
|
+
const calDay = (
|
|
400
|
+
calendarId: string,
|
|
401
|
+
monthDay: string,
|
|
402
|
+
name: string,
|
|
403
|
+
kind: CalendarDayKind,
|
|
404
|
+
workingFraction: number,
|
|
405
|
+
source: 'pack' | 'custom',
|
|
406
|
+
note: string | null = null,
|
|
407
|
+
): Row<CalendarDay> => ({
|
|
408
|
+
id: nextDayId(),
|
|
409
|
+
calendarId,
|
|
410
|
+
date: onDay(monthDay),
|
|
411
|
+
kind,
|
|
412
|
+
name,
|
|
413
|
+
workingFraction,
|
|
414
|
+
source,
|
|
415
|
+
paid: true,
|
|
416
|
+
note,
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The raw rows, per calendar. Composition happens on read, exactly as the server does it.
|
|
421
|
+
*
|
|
422
|
+
* The Türkiye rows below are version 1.0 of the `tr` pack: `Labour Day` was renamed in 2.0,
|
|
423
|
+
* `Atatürk Memorial Day` was dropped from it, and two days it now carries are missing here.
|
|
424
|
+
*/
|
|
425
|
+
const calendarDays: Row<CalendarDay>[] = [
|
|
426
|
+
calDay(CAL_TR, '01-01', "New Year's Day", 'public_holiday', 0, 'pack'),
|
|
427
|
+
calDay(CAL_TR, '03-19', 'Ramazan Bayramı Arifesi', 'half_day', 0.5, 'pack'),
|
|
428
|
+
calDay(CAL_TR, '04-23', "National Sovereignty and Children's Day", 'public_holiday', 0, 'pack'),
|
|
429
|
+
calDay(CAL_TR, '05-01', 'Labour Day', 'public_holiday', 0, 'pack'),
|
|
430
|
+
calDay(CAL_TR, '05-19', 'Commemoration of Atatürk, Youth and Sports Day', 'public_holiday', 0, 'pack'),
|
|
431
|
+
calDay(CAL_TR, '05-27', 'Kurban Bayramı', 'religious', 0, 'pack'),
|
|
432
|
+
calDay(CAL_TR, '08-30', 'Victory Day', 'public_holiday', 0, 'pack'),
|
|
433
|
+
calDay(CAL_TR, '10-29', 'Republic Day', 'public_holiday', 0, 'pack'),
|
|
434
|
+
calDay(CAL_TR, '11-10', 'Atatürk Memorial Day', 'public_holiday', 0, 'pack'),
|
|
435
|
+
|
|
436
|
+
// The pack gives half of the eve; this company closes the whole day. A custom row over a pack
|
|
437
|
+
// row, which is what the list draws as "changed".
|
|
438
|
+
calDay(
|
|
439
|
+
CAL_TR,
|
|
440
|
+
'03-19',
|
|
441
|
+
'Ramazan Bayramı Arifesi',
|
|
442
|
+
'company_closure',
|
|
443
|
+
0,
|
|
444
|
+
'custom',
|
|
445
|
+
'Full day, not the half the pack gives',
|
|
446
|
+
),
|
|
447
|
+
// The state the screen exists to make visible: a pack day this company works through. The pack
|
|
448
|
+
// row is still underneath it and survives the next upgrade.
|
|
449
|
+
calDay(
|
|
450
|
+
CAL_TR,
|
|
451
|
+
'05-19',
|
|
452
|
+
'Commemoration of Atatürk, Youth and Sports Day',
|
|
453
|
+
'working_override',
|
|
454
|
+
1,
|
|
455
|
+
'custom',
|
|
456
|
+
'Deadline week',
|
|
457
|
+
),
|
|
458
|
+
calDay(CAL_TR, '07-20', 'Summer shutdown', 'company_closure', 0, 'custom'),
|
|
459
|
+
// 29 October is a Thursday in the seeded year, so this really is the bridge the kind is named for.
|
|
460
|
+
calDay(CAL_TR, '10-30', 'Republic Day bridge', 'bridge', 0, 'custom'),
|
|
461
|
+
calDay(CAL_TR, '12-31', "New Year's Eve", 'half_day', 0.5, 'custom'),
|
|
462
|
+
calDay(CAL_TR, '09-05', 'Stock count', 'working_override', 1, 'custom', 'A worked Saturday'),
|
|
463
|
+
|
|
464
|
+
calDay(CAL_NL, '04-27', 'Koningsdag', 'public_holiday', 0, 'custom'),
|
|
465
|
+
calDay(CAL_NL, '05-05', 'Bevrijdingsdag', 'public_holiday', 0, 'custom'),
|
|
466
|
+
calDay(CAL_NL, '12-25', 'Eerste Kerstdag', 'public_holiday', 0, 'custom'),
|
|
467
|
+
calDay(CAL_NL, '12-26', 'Tweede Kerstdag', 'public_holiday', 0, 'custom'),
|
|
468
|
+
// Amsterdam works through two of the days it inherits from Türkiye: one masked outright, one
|
|
469
|
+
// kept as a half day. Both are the branch's own rows sitting over the base's.
|
|
470
|
+
calDay(CAL_NL, '04-23', "National Sovereignty and Children's Day", 'working_override', 1, 'custom'),
|
|
471
|
+
calDay(CAL_NL, '05-01', 'Labour Day (afternoon off)', 'half_day', 0.5, 'custom'),
|
|
472
|
+
]
|
|
473
|
+
|
|
474
|
+
/** Furthest ancestor first, so the nearer calendar's rows land on top of it. */
|
|
475
|
+
function chainOf(calendarId: string): Row<Calendar>[] {
|
|
476
|
+
const chain: Row<Calendar>[] = []
|
|
477
|
+
const seen = new Set<string>()
|
|
478
|
+
let cursor = calendars.find((c) => c.id === calendarId)
|
|
479
|
+
while (cursor && !seen.has(cursor.id)) {
|
|
480
|
+
seen.add(cursor.id)
|
|
481
|
+
chain.unshift(cursor)
|
|
482
|
+
const parent: string | null = cursor.extendsId
|
|
483
|
+
cursor = parent ? calendars.find((c) => c.id === parent) : undefined
|
|
484
|
+
}
|
|
485
|
+
return chain
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* The composed calendar, by date.
|
|
490
|
+
*
|
|
491
|
+
* Nearest calendar wins, and within one calendar a `custom` row wins over a `pack` row on the
|
|
492
|
+
* same date — which is how removing a pack day suppresses it instead of deleting something the
|
|
493
|
+
* next upgrade would only bring back.
|
|
494
|
+
*/
|
|
495
|
+
function composeDays(calendarId: string, workspaceId: string): Map<string, ResolvedDay> {
|
|
496
|
+
const byDate = new Map<string, ResolvedDay>()
|
|
497
|
+
for (const cal of chainOf(calendarId)) {
|
|
498
|
+
for (const source of ['pack', 'custom'] as const) {
|
|
499
|
+
for (const row of calendarDays.filter((r) => r.calendarId === cal.id && r.source === source)) {
|
|
500
|
+
byDate.set(row.date, {
|
|
501
|
+
...row,
|
|
502
|
+
workspaceId,
|
|
503
|
+
fromCalendarId: cal.id,
|
|
504
|
+
fromCalendarName: cal.name,
|
|
505
|
+
overrides: byDate.has(row.date),
|
|
506
|
+
})
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return byDate
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const resolveDay = (calendarId: string, workspaceId: string, date: string): ResolvedDay => {
|
|
514
|
+
const found = composeDays(calendarId, workspaceId).get(date)
|
|
515
|
+
if (!found) refuse('NOT_FOUND', 'Calendar day not found')
|
|
516
|
+
return found
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ---------------------------------------------------------------- leave, schedules
|
|
520
|
+
|
|
521
|
+
const leaveTypes: Row<LeaveType>[] = [
|
|
522
|
+
{
|
|
523
|
+
id: id('b001'),
|
|
524
|
+
key: 'annual',
|
|
525
|
+
name: 'Annual leave',
|
|
526
|
+
paid: true,
|
|
527
|
+
unit: 'day',
|
|
528
|
+
color: '#4c8bf5',
|
|
529
|
+
icon: 'tree-palm',
|
|
530
|
+
requiresDocumentAfterDays: null,
|
|
531
|
+
countsWorkingDaysOnly: true,
|
|
532
|
+
allowNegative: true,
|
|
533
|
+
maxNegativeMinutes: 5 * 480,
|
|
534
|
+
order: 0,
|
|
535
|
+
archivedAt: null,
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
id: id('b002'),
|
|
539
|
+
key: 'sick',
|
|
540
|
+
name: 'Sick leave',
|
|
541
|
+
paid: true,
|
|
542
|
+
unit: 'day',
|
|
543
|
+
color: '#dd5a5a',
|
|
544
|
+
icon: 'activity',
|
|
545
|
+
requiresDocumentAfterDays: 3,
|
|
546
|
+
countsWorkingDaysOnly: true,
|
|
547
|
+
allowNegative: false,
|
|
548
|
+
maxNegativeMinutes: 0,
|
|
549
|
+
order: 1,
|
|
550
|
+
archivedAt: null,
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
id: id('b003'),
|
|
554
|
+
key: 'unpaid',
|
|
555
|
+
name: 'Unpaid leave',
|
|
556
|
+
paid: false,
|
|
557
|
+
unit: 'day',
|
|
558
|
+
color: '#7a8794',
|
|
559
|
+
icon: 'calendar-days',
|
|
560
|
+
requiresDocumentAfterDays: null,
|
|
561
|
+
countsWorkingDaysOnly: true,
|
|
562
|
+
allowNegative: false,
|
|
563
|
+
maxNegativeMinutes: 0,
|
|
564
|
+
order: 2,
|
|
565
|
+
archivedAt: null,
|
|
566
|
+
},
|
|
567
|
+
// Archived, so the section that shows archived types has something in it and the note about
|
|
568
|
+
// there being no way back is attached to a real row.
|
|
569
|
+
{
|
|
570
|
+
id: id('b004'),
|
|
571
|
+
key: 'study',
|
|
572
|
+
name: 'Study leave',
|
|
573
|
+
paid: true,
|
|
574
|
+
unit: 'day',
|
|
575
|
+
color: '#8a6ee0',
|
|
576
|
+
icon: 'star',
|
|
577
|
+
requiresDocumentAfterDays: null,
|
|
578
|
+
countsWorkingDaysOnly: true,
|
|
579
|
+
allowNegative: false,
|
|
580
|
+
maxNegativeMinutes: 0,
|
|
581
|
+
order: 3,
|
|
582
|
+
archivedAt: iso(60 * 86_400_000),
|
|
583
|
+
},
|
|
584
|
+
]
|
|
585
|
+
|
|
586
|
+
const officeHours = { start: '09:00', end: '18:00', breakMinutes: 60 }
|
|
587
|
+
const night = { start: '22:00', end: '06:00', breakMinutes: 45 }
|
|
588
|
+
const mornings = { start: '09:00', end: '13:00', breakMinutes: 0 }
|
|
589
|
+
|
|
590
|
+
const schedules: Row<Schedule>[] = [
|
|
591
|
+
{
|
|
592
|
+
id: id('05c1'),
|
|
593
|
+
name: 'Office hours',
|
|
594
|
+
kind: 'fixed',
|
|
595
|
+
week: {
|
|
596
|
+
mon: { ...officeHours },
|
|
597
|
+
tue: { ...officeHours },
|
|
598
|
+
wed: { ...officeHours },
|
|
599
|
+
thu: { ...officeHours },
|
|
600
|
+
fri: { ...officeHours },
|
|
601
|
+
sat: null,
|
|
602
|
+
sun: null,
|
|
603
|
+
},
|
|
604
|
+
tzMode: 'office',
|
|
605
|
+
tz: null,
|
|
606
|
+
graceInMinutes: 10,
|
|
607
|
+
graceOutMinutes: 10,
|
|
608
|
+
roundingStepMinutes: 0,
|
|
609
|
+
roundingDirection: 'nearest',
|
|
610
|
+
autoClockOutAfterMinutes: null,
|
|
611
|
+
archivedAt: null,
|
|
612
|
+
},
|
|
613
|
+
// The interesting one: 22:00–06:00 is eight hours on the day it starts, not a negative number,
|
|
614
|
+
// and the week grid draws it differently. A model that only ever sees daytime shifts is untested.
|
|
615
|
+
{
|
|
616
|
+
id: id('05c2'),
|
|
617
|
+
name: 'Night shift',
|
|
618
|
+
kind: 'shift',
|
|
619
|
+
week: {
|
|
620
|
+
mon: { ...night },
|
|
621
|
+
tue: { ...night },
|
|
622
|
+
wed: { ...night },
|
|
623
|
+
thu: { ...night },
|
|
624
|
+
fri: null,
|
|
625
|
+
sat: null,
|
|
626
|
+
sun: { ...night },
|
|
627
|
+
},
|
|
628
|
+
tzMode: 'fixed',
|
|
629
|
+
tz: 'Europe/Istanbul',
|
|
630
|
+
graceInMinutes: 15,
|
|
631
|
+
graceOutMinutes: 15,
|
|
632
|
+
roundingStepMinutes: 15,
|
|
633
|
+
roundingDirection: 'nearest',
|
|
634
|
+
autoClockOutAfterMinutes: 720,
|
|
635
|
+
archivedAt: null,
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
id: id('05c3'),
|
|
639
|
+
name: 'Part-time mornings',
|
|
640
|
+
kind: 'flexible',
|
|
641
|
+
week: {
|
|
642
|
+
mon: { ...mornings },
|
|
643
|
+
tue: null,
|
|
644
|
+
wed: { ...mornings },
|
|
645
|
+
thu: null,
|
|
646
|
+
fri: { ...mornings },
|
|
647
|
+
sat: null,
|
|
648
|
+
sun: null,
|
|
649
|
+
},
|
|
650
|
+
tzMode: 'person',
|
|
651
|
+
tz: null,
|
|
652
|
+
graceInMinutes: 30,
|
|
653
|
+
graceOutMinutes: 30,
|
|
654
|
+
roundingStepMinutes: 0,
|
|
655
|
+
roundingDirection: 'employee',
|
|
656
|
+
autoClockOutAfterMinutes: null,
|
|
657
|
+
archivedAt: null,
|
|
658
|
+
},
|
|
659
|
+
{
|
|
660
|
+
id: id('05c4'),
|
|
661
|
+
name: 'Summer hours',
|
|
662
|
+
kind: 'fixed',
|
|
663
|
+
week: {
|
|
664
|
+
mon: { start: '08:00', end: '15:00', breakMinutes: 30 },
|
|
665
|
+
tue: { start: '08:00', end: '15:00', breakMinutes: 30 },
|
|
666
|
+
wed: { start: '08:00', end: '15:00', breakMinutes: 30 },
|
|
667
|
+
thu: { start: '08:00', end: '15:00', breakMinutes: 30 },
|
|
668
|
+
fri: { start: '08:00', end: '15:00', breakMinutes: 30 },
|
|
669
|
+
sat: null,
|
|
670
|
+
sun: null,
|
|
671
|
+
},
|
|
672
|
+
tzMode: 'office',
|
|
673
|
+
tz: null,
|
|
674
|
+
graceInMinutes: 0,
|
|
675
|
+
graceOutMinutes: 0,
|
|
676
|
+
roundingStepMinutes: 0,
|
|
677
|
+
roundingDirection: 'nearest',
|
|
678
|
+
autoClockOutAfterMinutes: null,
|
|
679
|
+
archivedAt: iso(30 * 86_400_000),
|
|
680
|
+
},
|
|
681
|
+
]
|
|
682
|
+
|
|
683
|
+
const scheduleAssignments: Row<ScheduleAssignment>[] = [
|
|
684
|
+
{
|
|
685
|
+
id: id('a5c1'),
|
|
686
|
+
personId: id('d001'),
|
|
687
|
+
scheduleId: id('05c1'),
|
|
688
|
+
effectiveFrom: day(-400),
|
|
689
|
+
effectiveTo: null,
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
id: id('a5c2'),
|
|
693
|
+
personId: id('d002'),
|
|
694
|
+
scheduleId: id('05c1'),
|
|
695
|
+
effectiveFrom: day(-300),
|
|
696
|
+
effectiveTo: null,
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
id: id('a5c3'),
|
|
700
|
+
personId: id('d004'),
|
|
701
|
+
scheduleId: id('05c3'),
|
|
702
|
+
effectiveFrom: day(-80),
|
|
703
|
+
effectiveTo: null,
|
|
704
|
+
},
|
|
705
|
+
]
|
|
706
|
+
|
|
707
|
+
const delegations: Array<Record<string, unknown>> = []
|
|
708
|
+
|
|
709
|
+
const approvalRequests = [
|
|
710
|
+
{
|
|
711
|
+
id: id('f001'),
|
|
712
|
+
workspaceId: '',
|
|
713
|
+
subjectType: 'leave' as const,
|
|
714
|
+
subjectId: id('c001'),
|
|
715
|
+
summary: `5 day(s) from ${day(14)}`,
|
|
716
|
+
summaryParams: { days: 5, from: day(14), to: day(18) } as Record<string, string | number> | null,
|
|
717
|
+
status: 'pending' as string,
|
|
718
|
+
currentStep: 0,
|
|
719
|
+
requestedBy: null,
|
|
720
|
+
requesterPersonId: people[1]!.id,
|
|
721
|
+
requesterName: people[1]!.displayName,
|
|
722
|
+
requestedAt: iso(3600_000),
|
|
723
|
+
decidedAt: null as string | null,
|
|
724
|
+
steps: [] as Array<Record<string, unknown>>,
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
id: id('f002'),
|
|
728
|
+
subjectType: 'regularization' as const,
|
|
729
|
+
workspaceId: '',
|
|
730
|
+
subjectId: id('c002'),
|
|
731
|
+
summary: `Correction for ${day(-1)}`,
|
|
732
|
+
summaryParams: { date: day(-1) } as Record<string, string | number> | null,
|
|
733
|
+
status: 'pending' as string,
|
|
734
|
+
currentStep: 0,
|
|
735
|
+
requestedBy: null,
|
|
736
|
+
requesterPersonId: people[2]!.id,
|
|
737
|
+
requesterName: people[2]!.displayName,
|
|
738
|
+
requestedAt: iso(7200_000),
|
|
739
|
+
decidedAt: null as string | null,
|
|
740
|
+
steps: [{ stepIndex: 0 }, { stepIndex: 1 }] as Array<Record<string, unknown>>,
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
id: id('f003'),
|
|
744
|
+
workspaceId: '',
|
|
745
|
+
subjectType: 'leave' as const,
|
|
746
|
+
subjectId: id('c003'),
|
|
747
|
+
summary: `1 day(s) from ${day(-20)}`,
|
|
748
|
+
summaryParams: { days: 1, from: day(-20), to: day(-20) } as Record<string, string | number> | null,
|
|
749
|
+
status: 'approved' as string,
|
|
750
|
+
currentStep: 0,
|
|
751
|
+
requestedBy: null,
|
|
752
|
+
requesterPersonId: people[0]!.id,
|
|
753
|
+
requesterName: people[0]!.displayName,
|
|
754
|
+
requestedAt: iso(20 * 86_400_000),
|
|
755
|
+
decidedAt: iso(19 * 86_400_000) as string | null,
|
|
756
|
+
steps: [] as Array<Record<string, unknown>>,
|
|
757
|
+
},
|
|
758
|
+
]
|
|
759
|
+
|
|
80
760
|
const leaveRequests: Array<Record<string, unknown>> = [
|
|
81
761
|
{
|
|
82
|
-
id: '
|
|
762
|
+
id: id('c001'),
|
|
83
763
|
workspaceId: '',
|
|
84
|
-
personId:
|
|
85
|
-
leaveTypeId: '
|
|
764
|
+
personId: people[0]!.id,
|
|
765
|
+
leaveTypeId: id('b001'),
|
|
86
766
|
startsOn: day(14),
|
|
87
767
|
endsOn: day(18),
|
|
88
768
|
startPart: 'full',
|
|
@@ -102,27 +782,41 @@ export function createMockHrApi() {
|
|
|
102
782
|
|
|
103
783
|
return {
|
|
104
784
|
people: {
|
|
105
|
-
list: async ({
|
|
106
|
-
|
|
107
|
-
|
|
785
|
+
list: async ({
|
|
786
|
+
workspaceId,
|
|
787
|
+
q,
|
|
788
|
+
officeId,
|
|
789
|
+
status,
|
|
790
|
+
limit = 50,
|
|
791
|
+
}: {
|
|
792
|
+
workspaceId: string
|
|
793
|
+
q?: string
|
|
794
|
+
officeId?: string
|
|
795
|
+
status?: string[]
|
|
796
|
+
limit?: number
|
|
797
|
+
}) => {
|
|
798
|
+
let items = people
|
|
799
|
+
if (officeId) items = items.filter((p) => primaryOfficeId(p.id) === officeId)
|
|
800
|
+
if (status?.length) items = items.filter((p) => status.includes(p.status))
|
|
108
801
|
if (q) items = items.filter((p) => p.displayName.toLowerCase().includes(q.toLowerCase()))
|
|
109
802
|
// Carries officeName too: a mock that answers a different shape from core is how a screen
|
|
110
803
|
// works in `dev:mock` and breaks against the real API.
|
|
111
804
|
return {
|
|
112
|
-
items: items.map((p) => ({
|
|
805
|
+
items: items.slice(0, limit).map((p) => ({
|
|
113
806
|
...person(p, workspaceId),
|
|
114
|
-
officeId: p.
|
|
115
|
-
officeName:
|
|
807
|
+
officeId: primaryOfficeId(p.id),
|
|
808
|
+
officeName: officeName(primaryOfficeId(p.id)),
|
|
116
809
|
})),
|
|
117
810
|
nextCursor: null,
|
|
811
|
+
// The count before the page, because a widget asking for one row draws this number.
|
|
118
812
|
total: items.length,
|
|
119
813
|
}
|
|
120
814
|
},
|
|
121
815
|
get: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => {
|
|
122
|
-
const found =
|
|
816
|
+
const found = people.find((p) => p.id === personId) ?? people[0]!
|
|
123
817
|
return person(found, workspaceId)
|
|
124
818
|
},
|
|
125
|
-
me: async ({ workspaceId }: { workspaceId: string }) => person(
|
|
819
|
+
me: async ({ workspaceId }: { workspaceId: string }) => person(people[0]!, workspaceId),
|
|
126
820
|
create: async (input: {
|
|
127
821
|
workspaceId: string
|
|
128
822
|
displayName: string
|
|
@@ -138,10 +832,21 @@ export function createMockHrApi() {
|
|
|
138
832
|
workEmail: input.workEmail ?? '',
|
|
139
833
|
status: 'active' as const,
|
|
140
834
|
timezone: 'Europe/Istanbul',
|
|
141
|
-
|
|
142
|
-
employeeNo: input.employeeNo ?? `E-${PEOPLE.length + 1}`,
|
|
835
|
+
employeeNo: input.employeeNo ?? `E-${people.length + 1}`,
|
|
143
836
|
}
|
|
144
|
-
|
|
837
|
+
people.push(added)
|
|
838
|
+
// A new person lands in the default office when nobody chose one, which is what the default
|
|
839
|
+
// flag is for.
|
|
840
|
+
assignments.push({
|
|
841
|
+
id: crypto.randomUUID(),
|
|
842
|
+
personId: added.id,
|
|
843
|
+
officeId: input.officeId ?? liveOffices().find((o) => o.isDefault)?.id ?? offices[0]!.id,
|
|
844
|
+
isPrimary: true,
|
|
845
|
+
effectiveFrom: input.hiredOn ?? day(0),
|
|
846
|
+
effectiveTo: null,
|
|
847
|
+
reason: null,
|
|
848
|
+
createdAt: iso(),
|
|
849
|
+
})
|
|
145
850
|
return person(added, input.workspaceId)
|
|
146
851
|
},
|
|
147
852
|
update: async (input: {
|
|
@@ -152,7 +857,7 @@ export function createMockHrApi() {
|
|
|
152
857
|
personalEmail?: string | null
|
|
153
858
|
phone?: string | null
|
|
154
859
|
}) => {
|
|
155
|
-
const found =
|
|
860
|
+
const found = people.find((p) => p.id === input.personId) ?? people[0]!
|
|
156
861
|
if (input.displayName) found.displayName = input.displayName
|
|
157
862
|
if (input.workEmail !== undefined) found.workEmail = input.workEmail ?? ''
|
|
158
863
|
return {
|
|
@@ -162,14 +867,15 @@ export function createMockHrApi() {
|
|
|
162
867
|
}
|
|
163
868
|
},
|
|
164
869
|
offboard: async (input: { workspaceId: string; personId: string; on: string }) => {
|
|
165
|
-
const found =
|
|
166
|
-
|
|
870
|
+
const found = people.find((p) => p.id === input.personId) ?? people[0]!
|
|
871
|
+
found.status = 'terminated'
|
|
872
|
+
return { ...person(found, input.workspaceId), terminatedOn: input.on }
|
|
167
873
|
},
|
|
168
874
|
},
|
|
169
875
|
|
|
170
876
|
employment: {
|
|
171
877
|
current: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => ({
|
|
172
|
-
id: '
|
|
878
|
+
id: id('ee01'),
|
|
173
879
|
workspaceId,
|
|
174
880
|
personId,
|
|
175
881
|
effectiveFrom: day(-400),
|
|
@@ -178,7 +884,7 @@ export function createMockHrApi() {
|
|
|
178
884
|
positionId: null,
|
|
179
885
|
legalEntityId: null,
|
|
180
886
|
costCenterId: null,
|
|
181
|
-
managerPersonId:
|
|
887
|
+
managerPersonId: people.find((x) => x.id !== personId)?.id ?? null,
|
|
182
888
|
employmentType: 'full_time' as const,
|
|
183
889
|
fte: 1,
|
|
184
890
|
contractHoursWeek: 40,
|
|
@@ -188,83 +894,726 @@ export function createMockHrApi() {
|
|
|
188
894
|
},
|
|
189
895
|
|
|
190
896
|
offices: {
|
|
191
|
-
list: async ({
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
897
|
+
list: async ({
|
|
898
|
+
workspaceId,
|
|
899
|
+
includeArchived = false,
|
|
900
|
+
}: {
|
|
901
|
+
workspaceId: string
|
|
902
|
+
includeArchived?: boolean
|
|
903
|
+
}) =>
|
|
904
|
+
offices
|
|
905
|
+
.filter((o) => includeArchived || o.archivedAt === null)
|
|
906
|
+
.map((o) => ({ ...o, workspaceId, headcount: headcount(o.id) })),
|
|
907
|
+
|
|
908
|
+
get: async ({ workspaceId, officeId }: { workspaceId: string; officeId: string }) => {
|
|
909
|
+
const found = offices.find((o) => o.id === officeId)
|
|
910
|
+
if (!found) refuse('NOT_FOUND', 'Office not found')
|
|
911
|
+
return { ...found, workspaceId }
|
|
912
|
+
},
|
|
913
|
+
|
|
914
|
+
create: async (input: {
|
|
915
|
+
workspaceId: string
|
|
916
|
+
name: string
|
|
917
|
+
kind?: Office['kind']
|
|
918
|
+
country: string
|
|
919
|
+
region?: string | null
|
|
920
|
+
city?: string | null
|
|
921
|
+
timezone: string
|
|
922
|
+
code?: string | null
|
|
923
|
+
parentOfficeId?: string | null
|
|
924
|
+
legalEntityId?: string | null
|
|
925
|
+
seedCalendarFromPack?: boolean
|
|
926
|
+
}) => {
|
|
927
|
+
const created: Row<Office> = {
|
|
928
|
+
id: crypto.randomUUID(),
|
|
929
|
+
name: input.name,
|
|
930
|
+
code: input.code ?? null,
|
|
931
|
+
kind: input.kind ?? 'branch',
|
|
932
|
+
parentOfficeId: input.parentOfficeId ?? null,
|
|
933
|
+
legalEntityId: input.legalEntityId ?? null,
|
|
934
|
+
country: input.country,
|
|
935
|
+
region: input.region ?? null,
|
|
936
|
+
city: input.city ?? null,
|
|
937
|
+
timezone: input.timezone,
|
|
200
938
|
calendarId: null,
|
|
201
939
|
address: null,
|
|
940
|
+
// The workspace already has a default and only ever has one.
|
|
941
|
+
isDefault: false,
|
|
202
942
|
headPersonId: null,
|
|
203
943
|
archivedAt: null,
|
|
204
944
|
createdAt: iso(),
|
|
205
|
-
|
|
206
|
-
|
|
945
|
+
}
|
|
946
|
+
// The pack is copied as a *base* the office's calendar extends, never inlined — so seeding
|
|
947
|
+
// makes a calendar rather than a heap of days.
|
|
948
|
+
// The office's own calendar *extends* the country pack rather than copying it, so a pack
|
|
949
|
+
// refresh reaches this office without reconciling a copy. It is made even when no pack
|
|
950
|
+
// exists for the country — an office with no calendar at all has no working week to resolve,
|
|
951
|
+
// and the router does not leave one in that state.
|
|
952
|
+
if (input.seedCalendarFromPack !== false) {
|
|
953
|
+
const key = input.country
|
|
954
|
+
let base = calendars.find((c) => c.source === 'pack' && c.packKey === key)
|
|
955
|
+
if (!base && PACKS[key]) {
|
|
956
|
+
base = {
|
|
957
|
+
id: crypto.randomUUID(),
|
|
958
|
+
name: PACKS[key]!.name,
|
|
959
|
+
extendsId: null,
|
|
960
|
+
country: input.country,
|
|
961
|
+
region: null,
|
|
962
|
+
workingWeek: { ...DEFAULT_WEEK },
|
|
963
|
+
source: 'pack',
|
|
964
|
+
packKey: key,
|
|
965
|
+
packVersion: String(YEAR),
|
|
966
|
+
archivedAt: null,
|
|
967
|
+
}
|
|
968
|
+
calendars.push(base)
|
|
969
|
+
for (const packDay of PACKS[key]!.days) {
|
|
970
|
+
calendarDays.push(
|
|
971
|
+
calDay(
|
|
972
|
+
base.id,
|
|
973
|
+
packDay.monthDay,
|
|
974
|
+
packDay.name,
|
|
975
|
+
packDay.kind,
|
|
976
|
+
packDay.workingFraction,
|
|
977
|
+
'pack',
|
|
978
|
+
),
|
|
979
|
+
)
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
const own: Row<Calendar> = {
|
|
983
|
+
id: crypto.randomUUID(),
|
|
984
|
+
name: input.name,
|
|
985
|
+
extendsId: base?.id ?? null,
|
|
986
|
+
country: input.country,
|
|
987
|
+
region: input.region ?? null,
|
|
988
|
+
workingWeek: base ? { ...base.workingWeek } : { ...DEFAULT_WEEK },
|
|
989
|
+
source: 'custom',
|
|
990
|
+
packKey: null,
|
|
991
|
+
packVersion: null,
|
|
992
|
+
archivedAt: null,
|
|
993
|
+
}
|
|
994
|
+
calendars.push(own)
|
|
995
|
+
created.calendarId = own.id
|
|
996
|
+
}
|
|
997
|
+
offices.push(created)
|
|
998
|
+
return { ...created, workspaceId: input.workspaceId }
|
|
999
|
+
},
|
|
1000
|
+
|
|
1001
|
+
update: async (input: {
|
|
1002
|
+
workspaceId: string
|
|
1003
|
+
officeId: string
|
|
1004
|
+
name?: string
|
|
1005
|
+
kind?: Office['kind']
|
|
1006
|
+
country?: string
|
|
1007
|
+
region?: string | null
|
|
1008
|
+
city?: string | null
|
|
1009
|
+
timezone?: string
|
|
1010
|
+
calendarId?: string | null
|
|
1011
|
+
legalEntityId?: string | null
|
|
1012
|
+
headPersonId?: string | null
|
|
1013
|
+
code?: string | null
|
|
1014
|
+
}) => {
|
|
1015
|
+
const found = offices.find((o) => o.id === input.officeId)
|
|
1016
|
+
if (!found) refuse('NOT_FOUND', 'Office not found')
|
|
1017
|
+
if (input.name !== undefined) found.name = input.name
|
|
1018
|
+
if (input.kind !== undefined) found.kind = input.kind
|
|
1019
|
+
if (input.country !== undefined) found.country = input.country
|
|
1020
|
+
if (input.region !== undefined) found.region = input.region
|
|
1021
|
+
if (input.city !== undefined) found.city = input.city
|
|
1022
|
+
if (input.timezone !== undefined) found.timezone = input.timezone
|
|
1023
|
+
if (input.calendarId !== undefined) found.calendarId = input.calendarId
|
|
1024
|
+
if (input.legalEntityId !== undefined) found.legalEntityId = input.legalEntityId
|
|
1025
|
+
if (input.headPersonId !== undefined) found.headPersonId = input.headPersonId
|
|
1026
|
+
if (input.code !== undefined) found.code = input.code
|
|
1027
|
+
return { ...found, workspaceId: input.workspaceId }
|
|
1028
|
+
},
|
|
1029
|
+
|
|
1030
|
+
archive: async ({ officeId }: { workspaceId: string; officeId: string }) => {
|
|
1031
|
+
const found = offices.find((o) => o.id === officeId)
|
|
1032
|
+
if (!found) refuse('NOT_FOUND', 'Office not found')
|
|
1033
|
+
// The default office is where everyone without an assignment lands and where the resolution
|
|
1034
|
+
// ladder bottoms out, so it has to be handed over before it can go.
|
|
1035
|
+
if (found.isDefault) {
|
|
1036
|
+
refuse(
|
|
1037
|
+
'CONFLICT',
|
|
1038
|
+
'This is the default office. Make another office the default before archiving it.',
|
|
1039
|
+
)
|
|
1040
|
+
}
|
|
1041
|
+
// Presence counts here, not only primaries: archiving an office out from under somebody who
|
|
1042
|
+
// still appears in its directory is the same problem either way.
|
|
1043
|
+
const held = assignments.filter((a) => a.officeId === officeId && a.effectiveTo === null).length
|
|
1044
|
+
if (held > 0) refuse('CONFLICT', `${held} people still work here. Move them first.`)
|
|
1045
|
+
found.archivedAt = iso()
|
|
1046
|
+
return { ok: true as const }
|
|
1047
|
+
},
|
|
1048
|
+
|
|
1049
|
+
/** Moves the flag rather than adding a second one: exactly one office is ever the default. */
|
|
1050
|
+
setDefault: async ({ workspaceId, officeId }: { workspaceId: string; officeId: string }) => {
|
|
1051
|
+
const found = offices.find((o) => o.id === officeId)
|
|
1052
|
+
if (!found) refuse('NOT_FOUND', 'Office not found')
|
|
1053
|
+
for (const office of offices) office.isDefault = office.id === officeId
|
|
1054
|
+
return { ...found, workspaceId }
|
|
1055
|
+
},
|
|
1056
|
+
|
|
1057
|
+
people: async ({
|
|
1058
|
+
workspaceId,
|
|
1059
|
+
officeId,
|
|
1060
|
+
primaryOnly = false,
|
|
1061
|
+
limit = 50,
|
|
1062
|
+
}: {
|
|
1063
|
+
workspaceId: string
|
|
1064
|
+
officeId: string
|
|
1065
|
+
primaryOnly?: boolean
|
|
1066
|
+
limit?: number
|
|
1067
|
+
}) => {
|
|
1068
|
+
const rows = assignments.filter(
|
|
1069
|
+
(a) => a.officeId === officeId && a.effectiveTo === null && (!primaryOnly || a.isPrimary),
|
|
1070
|
+
)
|
|
1071
|
+
const items = rows.flatMap((a) => {
|
|
1072
|
+
const found = people.find((p) => p.id === a.personId)
|
|
1073
|
+
return found ? [{ ...person(found, workspaceId), isPrimaryHere: a.isPrimary }] : []
|
|
1074
|
+
})
|
|
1075
|
+
return { items: items.slice(0, limit), nextCursor: null, total: items.length }
|
|
1076
|
+
},
|
|
1077
|
+
|
|
1078
|
+
assign: async (input: {
|
|
1079
|
+
workspaceId: string
|
|
1080
|
+
officeId: string
|
|
1081
|
+
personId: string
|
|
1082
|
+
isPrimary?: boolean
|
|
1083
|
+
effectiveFrom: string
|
|
1084
|
+
reason?: string | null
|
|
1085
|
+
}) => {
|
|
1086
|
+
const isPrimary = input.isPrimary ?? true
|
|
1087
|
+
// One primary at a time. Somebody who assigns a second office expecting a calendar to
|
|
1088
|
+
// change has been misled, and the roster says so — so the state behind it has to match.
|
|
1089
|
+
if (isPrimary) {
|
|
1090
|
+
for (const a of activeAssignments(input.personId)) a.isPrimary = false
|
|
1091
|
+
}
|
|
1092
|
+
const existing = activeAssignments(input.personId).find((a) => a.officeId === input.officeId)
|
|
1093
|
+
if (existing) {
|
|
1094
|
+
existing.isPrimary = isPrimary
|
|
1095
|
+
existing.effectiveFrom = input.effectiveFrom
|
|
1096
|
+
existing.reason = input.reason ?? null
|
|
1097
|
+
} else {
|
|
1098
|
+
assignments.push({
|
|
1099
|
+
id: crypto.randomUUID(),
|
|
1100
|
+
personId: input.personId,
|
|
1101
|
+
officeId: input.officeId,
|
|
1102
|
+
isPrimary,
|
|
1103
|
+
effectiveFrom: input.effectiveFrom,
|
|
1104
|
+
effectiveTo: null,
|
|
1105
|
+
reason: input.reason ?? null,
|
|
1106
|
+
createdAt: iso(),
|
|
1107
|
+
})
|
|
1108
|
+
}
|
|
1109
|
+
return activeAssignments(input.personId).map((a) => ({ ...a, workspaceId: input.workspaceId }))
|
|
1110
|
+
},
|
|
1111
|
+
|
|
1112
|
+
unassign: async ({
|
|
1113
|
+
officeId,
|
|
1114
|
+
personId,
|
|
1115
|
+
effectiveTo,
|
|
1116
|
+
}: {
|
|
1117
|
+
workspaceId: string
|
|
1118
|
+
officeId: string
|
|
1119
|
+
personId: string
|
|
1120
|
+
effectiveTo: string
|
|
1121
|
+
}) => {
|
|
1122
|
+
const found = activeAssignments(personId).find((a) => a.officeId === officeId)
|
|
1123
|
+
if (!found) refuse('NOT_FOUND', 'Office assignment not found')
|
|
1124
|
+
// The router will not let the primary go rather than picking a replacement on somebody's
|
|
1125
|
+
// behalf: which office decides their holidays is a decision, not a fallback.
|
|
1126
|
+
if (found.isPrimary) {
|
|
1127
|
+
refuse('CONFLICT', 'This is their primary office. Assign another office as primary first.')
|
|
1128
|
+
}
|
|
1129
|
+
found.effectiveTo = effectiveTo
|
|
1130
|
+
return { ok: true as const }
|
|
1131
|
+
},
|
|
1132
|
+
|
|
207
1133
|
resolveFor: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => {
|
|
208
|
-
const p =
|
|
209
|
-
const
|
|
1134
|
+
const p = people.find((x) => x.id === personId) ?? people[0]!
|
|
1135
|
+
const primary = primaryOfficeId(p.id)
|
|
1136
|
+
const office = offices.find((o) => o.id === primary) ?? offices[0]!
|
|
1137
|
+
const calendar = calendars.find((c) => c.id === office.calendarId) ?? null
|
|
210
1138
|
void workspaceId
|
|
211
1139
|
return {
|
|
212
1140
|
personId: p.id,
|
|
213
1141
|
on: day(0),
|
|
214
1142
|
primaryOfficeId: office.id,
|
|
215
1143
|
primaryOfficeName: office.name,
|
|
216
|
-
otherOfficeIds:
|
|
1144
|
+
otherOfficeIds: activeAssignments(p.id)
|
|
1145
|
+
.filter((a) => a.officeId !== office.id)
|
|
1146
|
+
.map((a) => a.officeId),
|
|
217
1147
|
country: office.country,
|
|
218
1148
|
timezone: office.timezone,
|
|
219
1149
|
timezoneFrom: 'office' as const,
|
|
220
|
-
calendarId:
|
|
221
|
-
calendarFrom: null,
|
|
222
|
-
workingWeek:
|
|
223
|
-
legalEntityId:
|
|
1150
|
+
calendarId: office.calendarId,
|
|
1151
|
+
calendarFrom: office.calendarId ? ('office' as const) : null,
|
|
1152
|
+
workingWeek: calendar?.workingWeek ?? { ...DEFAULT_WEEK },
|
|
1153
|
+
legalEntityId: office.legalEntityId,
|
|
224
1154
|
orgUnitId: null,
|
|
225
1155
|
orgUnitPath: null,
|
|
226
|
-
|
|
1156
|
+
// Anybody but the subject: a resolution panel saying somebody reports to themselves reads
|
|
1157
|
+
// as a bug in the ladder rather than as seed data.
|
|
1158
|
+
managerPersonId: people.find((x) => x.id !== p.id)?.id ?? null,
|
|
1159
|
+
}
|
|
1160
|
+
},
|
|
1161
|
+
},
|
|
1162
|
+
|
|
1163
|
+
entities: {
|
|
1164
|
+
list: async ({
|
|
1165
|
+
workspaceId,
|
|
1166
|
+
includeArchived = false,
|
|
1167
|
+
}: {
|
|
1168
|
+
workspaceId: string
|
|
1169
|
+
includeArchived?: boolean
|
|
1170
|
+
}) =>
|
|
1171
|
+
entities.filter((e) => includeArchived || e.archivedAt === null).map((e) => ({ ...e, workspaceId })),
|
|
1172
|
+
},
|
|
1173
|
+
|
|
1174
|
+
calendars: {
|
|
1175
|
+
list: async ({
|
|
1176
|
+
workspaceId,
|
|
1177
|
+
includeArchived = false,
|
|
1178
|
+
}: {
|
|
1179
|
+
workspaceId: string
|
|
1180
|
+
includeArchived?: boolean
|
|
1181
|
+
}) =>
|
|
1182
|
+
calendars
|
|
1183
|
+
.filter((c) => includeArchived || c.archivedAt === null)
|
|
1184
|
+
.map((c) => ({
|
|
1185
|
+
...c,
|
|
1186
|
+
workspaceId,
|
|
1187
|
+
// What makes archiving a calendar an office's problem rather than a free action.
|
|
1188
|
+
officeIds: liveOffices()
|
|
1189
|
+
.filter((o) => o.calendarId === c.id)
|
|
1190
|
+
.map((o) => o.id),
|
|
1191
|
+
})),
|
|
1192
|
+
|
|
1193
|
+
get: async ({ workspaceId, calendarId }: { workspaceId: string; calendarId: string }) => {
|
|
1194
|
+
const found = calendars.find((c) => c.id === calendarId)
|
|
1195
|
+
if (!found) refuse('NOT_FOUND', 'Calendar not found')
|
|
1196
|
+
return { ...found, workspaceId }
|
|
1197
|
+
},
|
|
1198
|
+
|
|
1199
|
+
create: async (input: {
|
|
1200
|
+
workspaceId: string
|
|
1201
|
+
name: string
|
|
1202
|
+
extendsId?: string | null
|
|
1203
|
+
country?: string | null
|
|
1204
|
+
region?: string | null
|
|
1205
|
+
workingWeek?: WorkingWeek
|
|
1206
|
+
}) => {
|
|
1207
|
+
const created: Row<Calendar> = {
|
|
1208
|
+
id: crypto.randomUUID(),
|
|
1209
|
+
name: input.name,
|
|
1210
|
+
extendsId: input.extendsId ?? null,
|
|
1211
|
+
country: input.country ?? null,
|
|
1212
|
+
region: input.region ?? null,
|
|
1213
|
+
workingWeek: input.workingWeek ? clone(input.workingWeek) : { ...DEFAULT_WEEK },
|
|
1214
|
+
source: 'custom',
|
|
1215
|
+
packKey: null,
|
|
1216
|
+
packVersion: null,
|
|
1217
|
+
archivedAt: null,
|
|
1218
|
+
}
|
|
1219
|
+
calendars.push(created)
|
|
1220
|
+
return { ...created, workspaceId: input.workspaceId }
|
|
1221
|
+
},
|
|
1222
|
+
|
|
1223
|
+
update: async (input: {
|
|
1224
|
+
workspaceId: string
|
|
1225
|
+
calendarId: string
|
|
1226
|
+
name?: string
|
|
1227
|
+
workingWeek?: WorkingWeek
|
|
1228
|
+
extendsId?: string | null
|
|
1229
|
+
}) => {
|
|
1230
|
+
const found = calendars.find((c) => c.id === input.calendarId)
|
|
1231
|
+
if (!found) refuse('NOT_FOUND', 'Calendar not found')
|
|
1232
|
+
if (input.name !== undefined) found.name = input.name
|
|
1233
|
+
if (input.workingWeek !== undefined) found.workingWeek = clone(input.workingWeek)
|
|
1234
|
+
if (input.extendsId !== undefined) {
|
|
1235
|
+
// Composing would never terminate, so this is refused rather than quietly ignored.
|
|
1236
|
+
if (input.extendsId === found.id) refuse('BAD_REQUEST', 'A calendar cannot extend itself.')
|
|
1237
|
+
found.extendsId = input.extendsId
|
|
1238
|
+
}
|
|
1239
|
+
return { ...found, workspaceId: input.workspaceId }
|
|
1240
|
+
},
|
|
1241
|
+
|
|
1242
|
+
archive: async ({ calendarId }: { workspaceId: string; calendarId: string }) => {
|
|
1243
|
+
const found = calendars.find((c) => c.id === calendarId)
|
|
1244
|
+
if (!found) refuse('NOT_FOUND', 'Calendar not found')
|
|
1245
|
+
const used = liveOffices().filter((o) => o.calendarId === calendarId).length
|
|
1246
|
+
if (used > 0) {
|
|
1247
|
+
refuse('CONFLICT', `${used} offices use this calendar. Point them at another one first.`)
|
|
1248
|
+
}
|
|
1249
|
+
found.archivedAt = iso()
|
|
1250
|
+
return { ok: true as const }
|
|
1251
|
+
},
|
|
1252
|
+
|
|
1253
|
+
days: {
|
|
1254
|
+
list: async ({
|
|
1255
|
+
workspaceId,
|
|
1256
|
+
calendarId,
|
|
1257
|
+
from,
|
|
1258
|
+
to,
|
|
1259
|
+
}: {
|
|
1260
|
+
workspaceId: string
|
|
1261
|
+
calendarId: string
|
|
1262
|
+
from: string
|
|
1263
|
+
to: string
|
|
1264
|
+
}) =>
|
|
1265
|
+
[...composeDays(calendarId, workspaceId).values()]
|
|
1266
|
+
.filter((d) => d.date >= from && d.date <= to)
|
|
1267
|
+
.sort((a, b) => a.date.localeCompare(b.date)),
|
|
1268
|
+
|
|
1269
|
+
add: async (input: {
|
|
1270
|
+
workspaceId: string
|
|
1271
|
+
calendarId: string
|
|
1272
|
+
date: string
|
|
1273
|
+
name: string
|
|
1274
|
+
kind?: CalendarDayKind
|
|
1275
|
+
workingFraction?: number
|
|
1276
|
+
paid?: boolean
|
|
1277
|
+
note?: string | null
|
|
1278
|
+
}) => {
|
|
1279
|
+
const row: Row<CalendarDay> = {
|
|
1280
|
+
id: nextDayId(),
|
|
1281
|
+
calendarId: input.calendarId,
|
|
1282
|
+
date: input.date,
|
|
1283
|
+
kind: input.kind ?? 'company_closure',
|
|
1284
|
+
name: input.name,
|
|
1285
|
+
workingFraction: input.workingFraction ?? 0,
|
|
1286
|
+
source: 'custom',
|
|
1287
|
+
paid: input.paid ?? true,
|
|
1288
|
+
note: input.note ?? null,
|
|
1289
|
+
}
|
|
1290
|
+
// One custom row per date per calendar. A second would be invisible — composition keeps
|
|
1291
|
+
// the nearest — so adding a day that already exists would look like doing nothing.
|
|
1292
|
+
const at = calendarDays.findIndex(
|
|
1293
|
+
(r) => r.calendarId === input.calendarId && r.date === input.date && r.source === 'custom',
|
|
1294
|
+
)
|
|
1295
|
+
if (at >= 0) calendarDays.splice(at, 1, { ...row, id: calendarDays[at]!.id })
|
|
1296
|
+
else calendarDays.push(row)
|
|
1297
|
+
return resolveDay(input.calendarId, input.workspaceId, input.date)
|
|
1298
|
+
},
|
|
1299
|
+
|
|
1300
|
+
update: async (input: {
|
|
1301
|
+
workspaceId: string
|
|
1302
|
+
calendarId: string
|
|
1303
|
+
dayId: string
|
|
1304
|
+
name?: string
|
|
1305
|
+
kind?: CalendarDayKind
|
|
1306
|
+
workingFraction?: number
|
|
1307
|
+
paid?: boolean
|
|
1308
|
+
note?: string | null
|
|
1309
|
+
}) => {
|
|
1310
|
+
const target = calendarDays.find((r) => r.id === input.dayId)
|
|
1311
|
+
if (!target) refuse('NOT_FOUND', 'Calendar day not found')
|
|
1312
|
+
const merged = {
|
|
1313
|
+
name: input.name ?? target.name,
|
|
1314
|
+
kind: input.kind ?? target.kind,
|
|
1315
|
+
workingFraction: input.workingFraction ?? target.workingFraction,
|
|
1316
|
+
paid: input.paid ?? target.paid,
|
|
1317
|
+
note: input.note !== undefined ? input.note : target.note,
|
|
1318
|
+
}
|
|
1319
|
+
// A pack row belongs to the pack, and so does a row inherited from a base calendar:
|
|
1320
|
+
// editing either writes this calendar's own row over it rather than changing something
|
|
1321
|
+
// the next upgrade would overwrite.
|
|
1322
|
+
if (target.source === 'pack' || target.calendarId !== input.calendarId) {
|
|
1323
|
+
const existing = calendarDays.find(
|
|
1324
|
+
(r) => r.calendarId === input.calendarId && r.date === target.date && r.source === 'custom',
|
|
1325
|
+
)
|
|
1326
|
+
if (existing) Object.assign(existing, merged)
|
|
1327
|
+
else {
|
|
1328
|
+
calendarDays.push({
|
|
1329
|
+
id: nextDayId(),
|
|
1330
|
+
calendarId: input.calendarId,
|
|
1331
|
+
date: target.date,
|
|
1332
|
+
source: 'custom',
|
|
1333
|
+
...merged,
|
|
1334
|
+
})
|
|
1335
|
+
}
|
|
1336
|
+
} else {
|
|
1337
|
+
Object.assign(target, merged)
|
|
1338
|
+
}
|
|
1339
|
+
return resolveDay(input.calendarId, input.workspaceId, target.date)
|
|
1340
|
+
},
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* A custom row is deleted; a pack row is masked.
|
|
1344
|
+
*
|
|
1345
|
+
* Deleting a pack day would only bring it back on the next upgrade, so the mask is a custom
|
|
1346
|
+
* row over it — which is also why deleting *that* row is what gives the pack's day back.
|
|
1347
|
+
*/
|
|
1348
|
+
remove: async ({ calendarId, dayId }: { workspaceId: string; calendarId: string; dayId: string }) => {
|
|
1349
|
+
const target = calendarDays.find((r) => r.id === dayId)
|
|
1350
|
+
if (!target) refuse('NOT_FOUND', 'Calendar day not found')
|
|
1351
|
+
if (target.source === 'custom' && target.calendarId === calendarId) {
|
|
1352
|
+
calendarDays.splice(calendarDays.indexOf(target), 1)
|
|
1353
|
+
return { ok: true as const, suppressed: false }
|
|
1354
|
+
}
|
|
1355
|
+
calendarDays.push({
|
|
1356
|
+
id: nextDayId(),
|
|
1357
|
+
calendarId,
|
|
1358
|
+
date: target.date,
|
|
1359
|
+
kind: 'working_override',
|
|
1360
|
+
name: target.name,
|
|
1361
|
+
workingFraction: 1,
|
|
1362
|
+
source: 'custom',
|
|
1363
|
+
paid: target.paid,
|
|
1364
|
+
note: null,
|
|
1365
|
+
})
|
|
1366
|
+
return { ok: true as const, suppressed: true }
|
|
1367
|
+
},
|
|
1368
|
+
},
|
|
1369
|
+
|
|
1370
|
+
pack: {
|
|
1371
|
+
preview: async ({
|
|
1372
|
+
calendarId,
|
|
1373
|
+
packKey,
|
|
1374
|
+
year,
|
|
1375
|
+
}: {
|
|
1376
|
+
workspaceId: string
|
|
1377
|
+
calendarId: string
|
|
1378
|
+
packKey: string
|
|
1379
|
+
year: number
|
|
1380
|
+
}) => {
|
|
1381
|
+
// An unknown key is an empty incoming set rather than an error, because that is what
|
|
1382
|
+
// `packDays` returns on the server — so the diff then proposes dropping every pack day
|
|
1383
|
+
// the calendar has. See the note in the report: that is the server's behaviour, not a
|
|
1384
|
+
// kindness the mock invents, and it is worth somebody looking at.
|
|
1385
|
+
const wanted = new Map((PACKS[packKey]?.days ?? []).map((d) => [`${year}-${d.monthDay}`, d]))
|
|
1386
|
+
const installed = calendarDays.filter(
|
|
1387
|
+
(r) => r.calendarId === calendarId && r.source === 'pack' && r.date.startsWith(`${year}-`),
|
|
1388
|
+
)
|
|
1389
|
+
const byDate = new Map(installed.map((r) => [r.date, r]))
|
|
1390
|
+
return {
|
|
1391
|
+
packKey,
|
|
1392
|
+
packVersion: String(year),
|
|
1393
|
+
added: [...wanted.entries()]
|
|
1394
|
+
.filter(([date]) => !byDate.has(date))
|
|
1395
|
+
.map(([date, d]) => ({ date, name: d.name })),
|
|
1396
|
+
changed: [...wanted.entries()].flatMap(([date, d]) => {
|
|
1397
|
+
const had = byDate.get(date)
|
|
1398
|
+
return had && had.name !== d.name ? [{ date, name: d.name, was: had.name }] : []
|
|
1399
|
+
}),
|
|
1400
|
+
removed: installed
|
|
1401
|
+
.filter((r) => !wanted.has(r.date))
|
|
1402
|
+
.map((r) => ({ date: r.date, name: r.name })),
|
|
1403
|
+
// Always untouched, and listed precisely so the dialog can say so out loud.
|
|
1404
|
+
keptCustom: calendarDays
|
|
1405
|
+
.filter(
|
|
1406
|
+
(r) => r.calendarId === calendarId && r.source === 'custom' && r.date.startsWith(`${year}-`),
|
|
1407
|
+
)
|
|
1408
|
+
.map((r) => ({ date: r.date, name: r.name })),
|
|
1409
|
+
}
|
|
1410
|
+
},
|
|
1411
|
+
|
|
1412
|
+
apply: async ({
|
|
1413
|
+
calendarId,
|
|
1414
|
+
packKey,
|
|
1415
|
+
year,
|
|
1416
|
+
}: {
|
|
1417
|
+
workspaceId: string
|
|
1418
|
+
calendarId: string
|
|
1419
|
+
packKey: string
|
|
1420
|
+
year: number
|
|
1421
|
+
}) => {
|
|
1422
|
+
const wanted = new Map((PACKS[packKey]?.days ?? []).map((d) => [`${year}-${d.monthDay}`, d]))
|
|
1423
|
+
let added = 0
|
|
1424
|
+
let changed = 0
|
|
1425
|
+
let removed = 0
|
|
1426
|
+
for (const row of [...calendarDays]) {
|
|
1427
|
+
if (row.calendarId !== calendarId || row.source !== 'pack' || !row.date.startsWith(`${year}-`)) {
|
|
1428
|
+
continue
|
|
1429
|
+
}
|
|
1430
|
+
const want = wanted.get(row.date)
|
|
1431
|
+
if (!want) {
|
|
1432
|
+
calendarDays.splice(calendarDays.indexOf(row), 1)
|
|
1433
|
+
removed += 1
|
|
1434
|
+
continue
|
|
1435
|
+
}
|
|
1436
|
+
if (
|
|
1437
|
+
row.name !== want.name ||
|
|
1438
|
+
row.kind !== want.kind ||
|
|
1439
|
+
row.workingFraction !== want.workingFraction
|
|
1440
|
+
) {
|
|
1441
|
+
row.name = want.name
|
|
1442
|
+
row.kind = want.kind
|
|
1443
|
+
row.workingFraction = want.workingFraction
|
|
1444
|
+
changed += 1
|
|
1445
|
+
}
|
|
1446
|
+
wanted.delete(row.date)
|
|
1447
|
+
}
|
|
1448
|
+
for (const [date, want] of wanted) {
|
|
1449
|
+
calendarDays.push({
|
|
1450
|
+
id: nextDayId(),
|
|
1451
|
+
calendarId,
|
|
1452
|
+
date,
|
|
1453
|
+
kind: want.kind,
|
|
1454
|
+
name: want.name,
|
|
1455
|
+
workingFraction: want.workingFraction,
|
|
1456
|
+
source: 'pack',
|
|
1457
|
+
paid: true,
|
|
1458
|
+
note: null,
|
|
1459
|
+
})
|
|
1460
|
+
added += 1
|
|
1461
|
+
}
|
|
1462
|
+
const calendar = calendars.find((c) => c.id === calendarId)
|
|
1463
|
+
if (calendar) {
|
|
1464
|
+
calendar.packKey = packKey
|
|
1465
|
+
calendar.packVersion = String(year)
|
|
1466
|
+
}
|
|
1467
|
+
return { ok: true as const, added, changed, removed }
|
|
1468
|
+
},
|
|
1469
|
+
},
|
|
1470
|
+
|
|
1471
|
+
workingDays: async ({
|
|
1472
|
+
workspaceId,
|
|
1473
|
+
personId,
|
|
1474
|
+
calendarId,
|
|
1475
|
+
from,
|
|
1476
|
+
to,
|
|
1477
|
+
}: {
|
|
1478
|
+
workspaceId: string
|
|
1479
|
+
personId?: string
|
|
1480
|
+
calendarId?: string
|
|
1481
|
+
from: string
|
|
1482
|
+
to: string
|
|
1483
|
+
}) => {
|
|
1484
|
+
const resolved =
|
|
1485
|
+
calendarId ??
|
|
1486
|
+
(personId ? (offices.find((o) => o.id === primaryOfficeId(personId))?.calendarId ?? null) : null) ??
|
|
1487
|
+
calendars[0]?.id ??
|
|
1488
|
+
''
|
|
1489
|
+
const week = calendars.find((c) => c.id === resolved)?.workingWeek ?? DEFAULT_WEEK
|
|
1490
|
+
const byDate = composeDays(resolved, workspaceId)
|
|
1491
|
+
const breakdown = eachDate(from, to).map((date) => {
|
|
1492
|
+
const weekday = WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]!
|
|
1493
|
+
const base = week[weekday]
|
|
1494
|
+
const hit = byDate.get(date)
|
|
1495
|
+
// The calendar day wins outright rather than being capped by the week: a working override
|
|
1496
|
+
// on a Saturday is exactly the case the kind exists for.
|
|
1497
|
+
return {
|
|
1498
|
+
date,
|
|
1499
|
+
fraction: hit ? hit.workingFraction : base,
|
|
1500
|
+
reason: hit ? hit.name : base === 0 ? 'weekend' : null,
|
|
1501
|
+
}
|
|
1502
|
+
})
|
|
1503
|
+
return {
|
|
1504
|
+
days: Math.round(breakdown.reduce((sum, d) => sum + d.fraction, 0) * 100) / 100,
|
|
1505
|
+
breakdown,
|
|
227
1506
|
}
|
|
228
1507
|
},
|
|
229
1508
|
},
|
|
230
1509
|
|
|
231
1510
|
leave: {
|
|
232
1511
|
types: {
|
|
233
|
-
list: async ({
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
1512
|
+
list: async ({
|
|
1513
|
+
workspaceId,
|
|
1514
|
+
includeArchived = false,
|
|
1515
|
+
}: {
|
|
1516
|
+
workspaceId: string
|
|
1517
|
+
includeArchived?: boolean
|
|
1518
|
+
}) =>
|
|
1519
|
+
leaveTypes
|
|
1520
|
+
.filter((lt) => includeArchived || lt.archivedAt === null)
|
|
1521
|
+
.slice()
|
|
1522
|
+
.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
|
|
1523
|
+
.map((lt) => ({ ...lt, workspaceId })),
|
|
1524
|
+
|
|
1525
|
+
create: async (input: {
|
|
1526
|
+
workspaceId: string
|
|
1527
|
+
key: string
|
|
1528
|
+
name: string
|
|
1529
|
+
paid?: boolean
|
|
1530
|
+
unit?: LeaveType['unit']
|
|
1531
|
+
color?: string | null
|
|
1532
|
+
icon?: string | null
|
|
1533
|
+
requiresDocumentAfterDays?: number | null
|
|
1534
|
+
countsWorkingDaysOnly?: boolean
|
|
1535
|
+
allowNegative?: boolean
|
|
1536
|
+
maxNegativeMinutes?: number
|
|
1537
|
+
}) => {
|
|
1538
|
+
const created: Row<LeaveType> = {
|
|
1539
|
+
id: crypto.randomUUID(),
|
|
1540
|
+
key: input.key,
|
|
1541
|
+
name: input.name,
|
|
1542
|
+
paid: input.paid ?? true,
|
|
1543
|
+
unit: input.unit ?? 'day',
|
|
1544
|
+
color: input.color ?? null,
|
|
1545
|
+
icon: input.icon ?? null,
|
|
1546
|
+
requiresDocumentAfterDays: input.requiresDocumentAfterDays ?? null,
|
|
1547
|
+
countsWorkingDaysOnly: input.countsWorkingDaysOnly ?? true,
|
|
1548
|
+
allowNegative: input.allowNegative ?? false,
|
|
1549
|
+
maxNegativeMinutes: input.maxNegativeMinutes ?? 0,
|
|
1550
|
+
order: leaveTypes.length,
|
|
248
1551
|
archivedAt: null,
|
|
249
|
-
}
|
|
250
|
-
|
|
1552
|
+
}
|
|
1553
|
+
leaveTypes.push(created)
|
|
1554
|
+
return { ...created, workspaceId: input.workspaceId }
|
|
1555
|
+
},
|
|
1556
|
+
|
|
1557
|
+
update: async (input: {
|
|
1558
|
+
workspaceId: string
|
|
1559
|
+
leaveTypeId: string
|
|
1560
|
+
name?: string
|
|
1561
|
+
paid?: boolean
|
|
1562
|
+
color?: string | null
|
|
1563
|
+
icon?: string | null
|
|
1564
|
+
requiresDocumentAfterDays?: number | null
|
|
1565
|
+
countsWorkingDaysOnly?: boolean
|
|
1566
|
+
allowNegative?: boolean
|
|
1567
|
+
maxNegativeMinutes?: number
|
|
1568
|
+
order?: number
|
|
1569
|
+
}) => {
|
|
1570
|
+
const found = leaveTypes.find((lt) => lt.id === input.leaveTypeId)
|
|
1571
|
+
if (!found) refuse('NOT_FOUND', 'Leave type not found')
|
|
1572
|
+
if (input.name !== undefined) found.name = input.name
|
|
1573
|
+
if (input.paid !== undefined) found.paid = input.paid
|
|
1574
|
+
if (input.color !== undefined) found.color = input.color
|
|
1575
|
+
if (input.icon !== undefined) found.icon = input.icon
|
|
1576
|
+
if (input.requiresDocumentAfterDays !== undefined) {
|
|
1577
|
+
found.requiresDocumentAfterDays = input.requiresDocumentAfterDays
|
|
1578
|
+
}
|
|
1579
|
+
if (input.countsWorkingDaysOnly !== undefined) {
|
|
1580
|
+
found.countsWorkingDaysOnly = input.countsWorkingDaysOnly
|
|
1581
|
+
}
|
|
1582
|
+
if (input.allowNegative !== undefined) found.allowNegative = input.allowNegative
|
|
1583
|
+
if (input.maxNegativeMinutes !== undefined) found.maxNegativeMinutes = input.maxNegativeMinutes
|
|
1584
|
+
if (input.order !== undefined) found.order = input.order
|
|
1585
|
+
return { ...found, workspaceId: input.workspaceId }
|
|
1586
|
+
},
|
|
1587
|
+
|
|
1588
|
+
archive: async ({ leaveTypeId }: { workspaceId: string; leaveTypeId: string }) => {
|
|
1589
|
+
const found = leaveTypes.find((lt) => lt.id === leaveTypeId)
|
|
1590
|
+
if (!found) refuse('NOT_FOUND', 'Leave type not found')
|
|
1591
|
+
found.archivedAt = iso()
|
|
1592
|
+
return { ok: true as const }
|
|
1593
|
+
},
|
|
251
1594
|
},
|
|
252
1595
|
balance: {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
1596
|
+
/** One row per type somebody can still book, so the tiles say as much as the picker offers. */
|
|
1597
|
+
get: async ({ personId }: { personId?: string }) =>
|
|
1598
|
+
leaveTypes
|
|
1599
|
+
.filter((lt) => lt.archivedAt === null)
|
|
1600
|
+
.map((lt, index) => {
|
|
1601
|
+
const balanceMinutes = [20, 10, 0][index] ?? 0
|
|
1602
|
+
const pending = index === 0 ? 5 : 0
|
|
1603
|
+
return {
|
|
1604
|
+
personId: personId ?? people[0]!.id,
|
|
1605
|
+
leaveTypeId: lt.id,
|
|
1606
|
+
leaveTypeName: lt.name,
|
|
1607
|
+
unit: lt.unit,
|
|
1608
|
+
periodYear: YEAR,
|
|
1609
|
+
balanceMinutes: balanceMinutes * 480,
|
|
1610
|
+
bookedMinutes: 0,
|
|
1611
|
+
pendingMinutes: pending * 480,
|
|
1612
|
+
availableMinutes: (balanceMinutes - pending) * 480,
|
|
1613
|
+
balance: balanceMinutes,
|
|
1614
|
+
available: balanceMinutes - pending,
|
|
1615
|
+
}
|
|
1616
|
+
}),
|
|
268
1617
|
},
|
|
269
1618
|
requests: {
|
|
270
1619
|
list: async ({ workspaceId }: { workspaceId: string }) => ({
|
|
@@ -303,7 +1652,7 @@ export function createMockHrApi() {
|
|
|
303
1652
|
const row = {
|
|
304
1653
|
id: crypto.randomUUID(),
|
|
305
1654
|
workspaceId: input.workspaceId,
|
|
306
|
-
personId:
|
|
1655
|
+
personId: people[0]!.id,
|
|
307
1656
|
leaveTypeId: input.leaveTypeId,
|
|
308
1657
|
startsOn: input.startsOn,
|
|
309
1658
|
endsOn: input.endsOn,
|
|
@@ -331,16 +1680,18 @@ export function createMockHrApi() {
|
|
|
331
1680
|
},
|
|
332
1681
|
team: {
|
|
333
1682
|
calendar: async () =>
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
1683
|
+
people
|
|
1684
|
+
.filter((p) => p.status === 'on_leave')
|
|
1685
|
+
.map((p) => ({
|
|
1686
|
+
personId: p.id,
|
|
1687
|
+
displayName: p.displayName,
|
|
1688
|
+
requestId: id('c002'),
|
|
1689
|
+
startsOn: day(-1),
|
|
1690
|
+
endsOn: day(3),
|
|
1691
|
+
status: 'approved' as const,
|
|
1692
|
+
leaveTypeName: 'Annual leave',
|
|
1693
|
+
color: '#4c8bf5',
|
|
1694
|
+
})),
|
|
344
1695
|
},
|
|
345
1696
|
},
|
|
346
1697
|
|
|
@@ -348,7 +1699,7 @@ export function createMockHrApi() {
|
|
|
348
1699
|
state: async ({ workspaceId, personId }: { workspaceId: string; personId?: string }) => {
|
|
349
1700
|
void workspaceId
|
|
350
1701
|
return {
|
|
351
|
-
personId: personId ??
|
|
1702
|
+
personId: personId ?? people[0]!.id,
|
|
352
1703
|
businessDate: day(0),
|
|
353
1704
|
clockedIn: clockedInAt !== null,
|
|
354
1705
|
onBreak,
|
|
@@ -357,29 +1708,42 @@ export function createMockHrApi() {
|
|
|
357
1708
|
timezone: 'Europe/Istanbul',
|
|
358
1709
|
}
|
|
359
1710
|
},
|
|
1711
|
+
/**
|
|
1712
|
+
* The transitions the router refuses, refused here too, in its order and its words.
|
|
1713
|
+
*
|
|
1714
|
+
* `punch()` answers a double-tapped button with a CONFLICT carrying a sentence rather than a
|
|
1715
|
+
* constraint error, and the widget renders that sentence. A mock that accepts all four
|
|
1716
|
+
* leaves a probe edited into a component as the only way to reach that branch — which is
|
|
1717
|
+
* exactly what happened, in a file nobody meant to ship.
|
|
1718
|
+
*/
|
|
360
1719
|
clockIn: async () => {
|
|
1720
|
+
if (clockedInAt !== null) refuse('CONFLICT', 'You are already clocked in.')
|
|
361
1721
|
clockedInAt = Date.now()
|
|
362
1722
|
return mockPunch('in')
|
|
363
1723
|
},
|
|
364
1724
|
clockOut: async () => {
|
|
1725
|
+
if (clockedInAt === null) refuse('CONFLICT', 'You are not clocked in.')
|
|
365
1726
|
clockedInAt = null
|
|
366
1727
|
onBreak = false
|
|
367
1728
|
return mockPunch('out')
|
|
368
1729
|
},
|
|
369
1730
|
breakStart: async () => {
|
|
1731
|
+
if (clockedInAt === null) refuse('CONFLICT', 'Clock in before starting a break.')
|
|
1732
|
+
if (onBreak) refuse('CONFLICT', 'You are already on a break.')
|
|
370
1733
|
onBreak = true
|
|
371
1734
|
return mockPunch('break_start')
|
|
372
1735
|
},
|
|
373
1736
|
breakEnd: async () => {
|
|
1737
|
+
if (!onBreak) refuse('CONFLICT', 'You are not on a break.')
|
|
374
1738
|
onBreak = false
|
|
375
1739
|
return mockPunch('break_end')
|
|
376
1740
|
},
|
|
377
1741
|
days: {
|
|
378
1742
|
list: async ({ workspaceId }: { workspaceId: string }) => ({
|
|
379
1743
|
items: [0, 1, 2, 3, 4].map((n) => ({
|
|
380
|
-
id: `
|
|
1744
|
+
id: id(`a000${n}`),
|
|
381
1745
|
workspaceId,
|
|
382
|
-
personId:
|
|
1746
|
+
personId: people[0]!.id,
|
|
383
1747
|
businessDate: day(-n),
|
|
384
1748
|
scheduledMinutes: 480,
|
|
385
1749
|
workedMinutes: n === 2 ? 0 : 480 + (n === 1 ? 45 : 0),
|
|
@@ -399,27 +1763,192 @@ export function createMockHrApi() {
|
|
|
399
1763
|
nextCursor: null,
|
|
400
1764
|
}),
|
|
401
1765
|
},
|
|
1766
|
+
|
|
1767
|
+
schedules: {
|
|
1768
|
+
list: async ({
|
|
1769
|
+
workspaceId,
|
|
1770
|
+
includeArchived = false,
|
|
1771
|
+
}: {
|
|
1772
|
+
workspaceId: string
|
|
1773
|
+
includeArchived?: boolean
|
|
1774
|
+
}) =>
|
|
1775
|
+
schedules
|
|
1776
|
+
.filter((s) => includeArchived || s.archivedAt === null)
|
|
1777
|
+
.map((s) => ({ ...s, workspaceId })),
|
|
1778
|
+
|
|
1779
|
+
create: async (input: {
|
|
1780
|
+
workspaceId: string
|
|
1781
|
+
name: string
|
|
1782
|
+
kind?: Schedule['kind']
|
|
1783
|
+
week: Schedule['week']
|
|
1784
|
+
tzMode?: Schedule['tzMode']
|
|
1785
|
+
tz?: string | null
|
|
1786
|
+
graceInMinutes?: number
|
|
1787
|
+
graceOutMinutes?: number
|
|
1788
|
+
roundingStepMinutes?: number
|
|
1789
|
+
roundingDirection?: Schedule['roundingDirection']
|
|
1790
|
+
autoClockOutAfterMinutes?: number | null
|
|
1791
|
+
}) => {
|
|
1792
|
+
const created: Row<Schedule> = {
|
|
1793
|
+
id: crypto.randomUUID(),
|
|
1794
|
+
name: input.name,
|
|
1795
|
+
kind: input.kind ?? 'fixed',
|
|
1796
|
+
week: clone(input.week),
|
|
1797
|
+
tzMode: input.tzMode ?? 'office',
|
|
1798
|
+
tz: input.tz ?? null,
|
|
1799
|
+
graceInMinutes: input.graceInMinutes ?? 0,
|
|
1800
|
+
graceOutMinutes: input.graceOutMinutes ?? 0,
|
|
1801
|
+
roundingStepMinutes: input.roundingStepMinutes ?? 0,
|
|
1802
|
+
roundingDirection: input.roundingDirection ?? 'nearest',
|
|
1803
|
+
autoClockOutAfterMinutes: input.autoClockOutAfterMinutes ?? null,
|
|
1804
|
+
archivedAt: null,
|
|
1805
|
+
}
|
|
1806
|
+
schedules.push(created)
|
|
1807
|
+
return { ...created, workspaceId: input.workspaceId }
|
|
1808
|
+
},
|
|
1809
|
+
|
|
1810
|
+
update: async (input: {
|
|
1811
|
+
workspaceId: string
|
|
1812
|
+
scheduleId: string
|
|
1813
|
+
name?: string
|
|
1814
|
+
week?: Schedule['week']
|
|
1815
|
+
graceInMinutes?: number
|
|
1816
|
+
graceOutMinutes?: number
|
|
1817
|
+
roundingStepMinutes?: number
|
|
1818
|
+
roundingDirection?: Schedule['roundingDirection']
|
|
1819
|
+
autoClockOutAfterMinutes?: number | null
|
|
1820
|
+
}) => {
|
|
1821
|
+
const found = schedules.find((s) => s.id === input.scheduleId)
|
|
1822
|
+
if (!found) refuse('NOT_FOUND', 'Schedule not found')
|
|
1823
|
+
if (input.name !== undefined) found.name = input.name
|
|
1824
|
+
if (input.week !== undefined) found.week = clone(input.week)
|
|
1825
|
+
if (input.graceInMinutes !== undefined) found.graceInMinutes = input.graceInMinutes
|
|
1826
|
+
if (input.graceOutMinutes !== undefined) found.graceOutMinutes = input.graceOutMinutes
|
|
1827
|
+
if (input.roundingStepMinutes !== undefined) {
|
|
1828
|
+
found.roundingStepMinutes = input.roundingStepMinutes
|
|
1829
|
+
}
|
|
1830
|
+
if (input.roundingDirection !== undefined) found.roundingDirection = input.roundingDirection
|
|
1831
|
+
if (input.autoClockOutAfterMinutes !== undefined) {
|
|
1832
|
+
found.autoClockOutAfterMinutes = input.autoClockOutAfterMinutes
|
|
1833
|
+
}
|
|
1834
|
+
return { ...found, workspaceId: input.workspaceId }
|
|
1835
|
+
},
|
|
1836
|
+
|
|
1837
|
+
archive: async ({ scheduleId }: { workspaceId: string; scheduleId: string }) => {
|
|
1838
|
+
const found = schedules.find((s) => s.id === scheduleId)
|
|
1839
|
+
if (!found) refuse('NOT_FOUND', 'Schedule not found')
|
|
1840
|
+
found.archivedAt = iso()
|
|
1841
|
+
return { ok: true as const }
|
|
1842
|
+
},
|
|
1843
|
+
|
|
1844
|
+
assign: async (input: {
|
|
1845
|
+
workspaceId: string
|
|
1846
|
+
scheduleId: string
|
|
1847
|
+
personId: string
|
|
1848
|
+
effectiveFrom: string
|
|
1849
|
+
}) => {
|
|
1850
|
+
// Effective-dated, so the earlier assignment is closed rather than replaced: the days
|
|
1851
|
+
// already computed were measured against it.
|
|
1852
|
+
for (const a of scheduleAssignments) {
|
|
1853
|
+
if (a.personId === input.personId && a.effectiveTo === null) a.effectiveTo = input.effectiveFrom
|
|
1854
|
+
}
|
|
1855
|
+
scheduleAssignments.push({
|
|
1856
|
+
id: crypto.randomUUID(),
|
|
1857
|
+
personId: input.personId,
|
|
1858
|
+
scheduleId: input.scheduleId,
|
|
1859
|
+
effectiveFrom: input.effectiveFrom,
|
|
1860
|
+
effectiveTo: null,
|
|
1861
|
+
})
|
|
1862
|
+
return scheduleAssignments
|
|
1863
|
+
.filter((a) => a.personId === input.personId)
|
|
1864
|
+
.map((a) => ({ ...a, workspaceId: input.workspaceId }))
|
|
1865
|
+
},
|
|
1866
|
+
},
|
|
402
1867
|
},
|
|
403
1868
|
|
|
404
1869
|
approvals: {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
1870
|
+
/**
|
|
1871
|
+
* Both tabs have something in them on purpose.
|
|
1872
|
+
*
|
|
1873
|
+
* A demo whose "Decided" tab is empty looks like a broken filter rather than an empty
|
|
1874
|
+
* history, and the two-step request is what shows the step counter at all.
|
|
1875
|
+
*/
|
|
1876
|
+
inbox: async ({
|
|
1877
|
+
workspaceId,
|
|
1878
|
+
includeDecided = false,
|
|
1879
|
+
}: {
|
|
1880
|
+
workspaceId: string
|
|
1881
|
+
includeDecided?: boolean
|
|
1882
|
+
}) => ({
|
|
1883
|
+
items: approvalRequests
|
|
1884
|
+
.filter((r) => (includeDecided ? r.status !== 'pending' : r.status === 'pending'))
|
|
1885
|
+
.map((r) => ({ ...r, workspaceId })),
|
|
421
1886
|
nextCursor: null,
|
|
422
1887
|
}),
|
|
1888
|
+
|
|
1889
|
+
get: async ({ workspaceId, requestId }: { workspaceId: string; requestId: string }) => {
|
|
1890
|
+
const found = approvalRequests.find((r) => r.id === requestId)
|
|
1891
|
+
if (!found) refuse('NOT_FOUND', 'Approval request not found')
|
|
1892
|
+
return { ...found, workspaceId }
|
|
1893
|
+
},
|
|
1894
|
+
|
|
1895
|
+
decide: async ({
|
|
1896
|
+
workspaceId,
|
|
1897
|
+
requestId,
|
|
1898
|
+
decision,
|
|
1899
|
+
}: {
|
|
1900
|
+
workspaceId: string
|
|
1901
|
+
requestId: string
|
|
1902
|
+
decision: 'approve' | 'reject'
|
|
1903
|
+
}) => {
|
|
1904
|
+
const found = approvalRequests.find((r) => r.id === requestId)
|
|
1905
|
+
if (!found) refuse('NOT_FOUND', 'Approval request not found')
|
|
1906
|
+
// A middle step advances rather than settling: the inbox has to be able to show that.
|
|
1907
|
+
const last = found.currentStep >= Math.max(found.steps.length - 1, 0)
|
|
1908
|
+
if (decision === 'reject' || last) found.status = decision === 'approve' ? 'approved' : 'rejected'
|
|
1909
|
+
else found.currentStep += 1
|
|
1910
|
+
found.decidedAt = found.status === 'pending' ? null : iso()
|
|
1911
|
+
return { ...found, workspaceId }
|
|
1912
|
+
},
|
|
1913
|
+
|
|
1914
|
+
delegations: async ({ workspaceId }: { workspaceId: string }) =>
|
|
1915
|
+
delegations.map((d) => ({ ...d, workspaceId })),
|
|
1916
|
+
|
|
1917
|
+
delegate: async ({
|
|
1918
|
+
workspaceId,
|
|
1919
|
+
toPersonId,
|
|
1920
|
+
startsOn,
|
|
1921
|
+
endsOn,
|
|
1922
|
+
subjectType = null,
|
|
1923
|
+
reason = null,
|
|
1924
|
+
}: {
|
|
1925
|
+
workspaceId: string
|
|
1926
|
+
toPersonId: string
|
|
1927
|
+
startsOn: string
|
|
1928
|
+
endsOn: string
|
|
1929
|
+
subjectType?: string | null
|
|
1930
|
+
reason?: string | null
|
|
1931
|
+
}) => {
|
|
1932
|
+
const created = {
|
|
1933
|
+
id: crypto.randomUUID(),
|
|
1934
|
+
workspaceId,
|
|
1935
|
+
fromPersonId: people[0]!.id,
|
|
1936
|
+
toPersonId,
|
|
1937
|
+
subjectType,
|
|
1938
|
+
startsOn,
|
|
1939
|
+
endsOn,
|
|
1940
|
+
reason,
|
|
1941
|
+
createdAt: iso(),
|
|
1942
|
+
}
|
|
1943
|
+
delegations.push(created)
|
|
1944
|
+
return created
|
|
1945
|
+
},
|
|
1946
|
+
|
|
1947
|
+
revokeDelegation: async ({ delegationId }: { delegationId: string }) => {
|
|
1948
|
+
const at = delegations.findIndex((d) => d.id === delegationId)
|
|
1949
|
+
if (at >= 0) delegations.splice(at, 1)
|
|
1950
|
+
return { ok: true }
|
|
1951
|
+
},
|
|
423
1952
|
},
|
|
424
1953
|
}
|
|
425
1954
|
|
|
@@ -427,7 +1956,7 @@ export function createMockHrApi() {
|
|
|
427
1956
|
return {
|
|
428
1957
|
id: crypto.randomUUID(),
|
|
429
1958
|
workspaceId: '',
|
|
430
|
-
personId:
|
|
1959
|
+
personId: people[0]!.id,
|
|
431
1960
|
direction,
|
|
432
1961
|
at: new Date().toISOString(),
|
|
433
1962
|
clientReportedAt: null,
|
|
@@ -435,7 +1964,7 @@ export function createMockHrApi() {
|
|
|
435
1964
|
businessDate: day(0),
|
|
436
1965
|
timezone: 'Europe/Istanbul',
|
|
437
1966
|
method: 'web',
|
|
438
|
-
officeId:
|
|
1967
|
+
officeId: primaryOfficeId(people[0]!.id),
|
|
439
1968
|
deviceId: null,
|
|
440
1969
|
geo: null,
|
|
441
1970
|
trust: 'trusted',
|