@kernhq/module-hr 0.10.0 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract/attendance.d.ts +1 -0
- package/dist/contract/attendance.d.ts.map +1 -1
- package/dist/contract/attendance.js +9 -0
- package/dist/contract/attendance.js.map +1 -1
- package/dist/contract/capabilities.d.ts +16 -0
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +16 -0
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/router.d.ts +1 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/policy/accrual.d.ts +35 -4
- package/dist/policy/accrual.d.ts.map +1 -1
- package/dist/policy/accrual.js +141 -15
- package/dist/policy/accrual.js.map +1 -1
- package/dist/policy/working-time.d.ts +115 -9
- package/dist/policy/working-time.d.ts.map +1 -1
- package/dist/policy/working-time.js +120 -20
- package/dist/policy/working-time.js.map +1 -1
- package/dist/server/jobs.d.ts +14 -6
- package/dist/server/jobs.d.ts.map +1 -1
- package/dist/server/jobs.js +489 -144
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/packs/index.d.ts +14 -0
- package/dist/server/packs/index.d.ts.map +1 -1
- package/dist/server/packs/index.js +19 -0
- package/dist/server/packs/index.js.map +1 -1
- package/dist/server/router.d.ts +1888 -111
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +154 -63
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +17 -0
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +81 -2
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/approvals.d.ts +0 -2
- package/dist/server/services/approvals.d.ts.map +1 -1
- package/dist/server/services/approvals.js +12 -4
- package/dist/server/services/approvals.js.map +1 -1
- package/dist/server/services/attendance.d.ts +120 -8
- package/dist/server/services/attendance.d.ts.map +1 -1
- package/dist/server/services/attendance.js +226 -14
- package/dist/server/services/attendance.js.map +1 -1
- package/dist/server/services/ledger.d.ts +0 -2
- package/dist/server/services/ledger.d.ts.map +1 -1
- package/dist/server/services/ledger.js +1 -2
- package/dist/server/services/ledger.js.map +1 -1
- package/dist/server/services/policies.d.ts +6 -2
- package/dist/server/services/policies.d.ts.map +1 -1
- package/dist/server/services/policies.js +7 -2
- package/dist/server/services/policies.js.map +1 -1
- package/migrations/0005_approval_requester.sql +11 -1
- package/migrations/0006_schedule_no_overlap.sql +85 -0
- package/migrations/0007_hot_path_indexes.sql +38 -0
- package/migrations/0009_beyond_cap_minutes.sql +15 -0
- package/migrations/meta/0005_snapshot.json +4047 -0
- package/migrations/meta/0007_snapshot.json +4225 -0
- package/migrations/meta/0009_snapshot.json +4231 -0
- package/migrations/meta/_journal.json +21 -0
- package/package.json +7 -7
- package/src/client/components/ClockControls.svelte +127 -20
- package/src/client/components/ClockControls.test.ts +446 -0
- package/src/client/components/DelegationDialog.svelte +12 -3
- package/src/client/components/LeaveRequestDialog.svelte +254 -25
- package/src/client/components/PersonFormDialog.svelte +53 -3
- package/src/client/components/PersonPanel.svelte +134 -26
- package/src/client/i18n.ts +5 -927
- package/src/client/messages.test.ts +154 -0
- package/src/client/messages.ts +3388 -0
- package/src/client/mock.ts +1576 -161
- package/src/client/module.ts +15 -1
- package/src/client/pages/ApprovalsPage.svelte +120 -46
- package/src/client/pages/AttendancePage.svelte +106 -20
- package/src/client/pages/DirectoryPage.svelte +192 -55
- package/src/client/pages/LeavePage.svelte +280 -25
- package/src/client/pages/OfficesPage.svelte +26 -8
- package/src/client/pages/leave-and-attendance.test.ts +588 -0
- package/src/client/query.ts +12 -0
- package/src/client/settings/CalendarsSettings.svelte +1363 -12
- package/src/client/settings/CapabilitiesSettings.svelte +276 -19
- package/src/client/settings/GeneralSettings.svelte +420 -0
- package/src/client/settings/LeaveSettings.svelte +884 -12
- package/src/client/settings/OfficesSettings.svelte +1182 -12
- package/src/client/settings/SchedulesSettings.svelte +1149 -12
- package/src/client/summary.ts +14 -7
- package/src/client/widgets/ApprovalsWidget.svelte +133 -20
- package/src/client/widgets/HeadcountWidget.svelte +74 -8
- package/src/client/widgets/LeaveBalanceWidget.svelte +40 -5
- package/src/client/widgets/WhosOutWidget.svelte +47 -8
- package/src/contract/attendance.ts +9 -0
- package/src/contract/capabilities.ts +17 -0
package/src/client/mock.ts
CHANGED
|
@@ -4,126 +4,753 @@
|
|
|
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.
|
|
7
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.
|
|
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
|
+
|
|
80
707
|
const delegations: Array<Record<string, unknown>> = []
|
|
81
708
|
|
|
82
709
|
const approvalRequests = [
|
|
83
710
|
{
|
|
84
|
-
id: '
|
|
711
|
+
id: id('f001'),
|
|
85
712
|
workspaceId: '',
|
|
86
713
|
subjectType: 'leave' as const,
|
|
87
|
-
subjectId: '
|
|
714
|
+
subjectId: id('c001'),
|
|
88
715
|
summary: `5 day(s) from ${day(14)}`,
|
|
89
716
|
summaryParams: { days: 5, from: day(14), to: day(18) } as Record<string, string | number> | null,
|
|
90
717
|
status: 'pending' as string,
|
|
91
718
|
currentStep: 0,
|
|
92
719
|
requestedBy: null,
|
|
93
|
-
requesterPersonId:
|
|
94
|
-
requesterName:
|
|
720
|
+
requesterPersonId: people[1]!.id,
|
|
721
|
+
requesterName: people[1]!.displayName,
|
|
95
722
|
requestedAt: iso(3600_000),
|
|
96
723
|
decidedAt: null as string | null,
|
|
97
724
|
steps: [] as Array<Record<string, unknown>>,
|
|
98
725
|
},
|
|
99
726
|
{
|
|
100
|
-
id: '
|
|
727
|
+
id: id('f002'),
|
|
101
728
|
subjectType: 'regularization' as const,
|
|
102
729
|
workspaceId: '',
|
|
103
|
-
subjectId: '
|
|
730
|
+
subjectId: id('c002'),
|
|
104
731
|
summary: `Correction for ${day(-1)}`,
|
|
105
732
|
summaryParams: { date: day(-1) } as Record<string, string | number> | null,
|
|
106
733
|
status: 'pending' as string,
|
|
107
734
|
currentStep: 0,
|
|
108
735
|
requestedBy: null,
|
|
109
|
-
requesterPersonId:
|
|
110
|
-
requesterName:
|
|
736
|
+
requesterPersonId: people[2]!.id,
|
|
737
|
+
requesterName: people[2]!.displayName,
|
|
111
738
|
requestedAt: iso(7200_000),
|
|
112
739
|
decidedAt: null as string | null,
|
|
113
740
|
steps: [{ stepIndex: 0 }, { stepIndex: 1 }] as Array<Record<string, unknown>>,
|
|
114
741
|
},
|
|
115
742
|
{
|
|
116
|
-
id: '
|
|
743
|
+
id: id('f003'),
|
|
117
744
|
workspaceId: '',
|
|
118
745
|
subjectType: 'leave' as const,
|
|
119
|
-
subjectId: '
|
|
746
|
+
subjectId: id('c003'),
|
|
120
747
|
summary: `1 day(s) from ${day(-20)}`,
|
|
121
748
|
summaryParams: { days: 1, from: day(-20), to: day(-20) } as Record<string, string | number> | null,
|
|
122
749
|
status: 'approved' as string,
|
|
123
750
|
currentStep: 0,
|
|
124
751
|
requestedBy: null,
|
|
125
|
-
requesterPersonId:
|
|
126
|
-
requesterName:
|
|
752
|
+
requesterPersonId: people[0]!.id,
|
|
753
|
+
requesterName: people[0]!.displayName,
|
|
127
754
|
requestedAt: iso(20 * 86_400_000),
|
|
128
755
|
decidedAt: iso(19 * 86_400_000) as string | null,
|
|
129
756
|
steps: [] as Array<Record<string, unknown>>,
|
|
@@ -132,10 +759,10 @@ export function createMockHrApi() {
|
|
|
132
759
|
|
|
133
760
|
const leaveRequests: Array<Record<string, unknown>> = [
|
|
134
761
|
{
|
|
135
|
-
id: '
|
|
762
|
+
id: id('c001'),
|
|
136
763
|
workspaceId: '',
|
|
137
|
-
personId:
|
|
138
|
-
leaveTypeId: '
|
|
764
|
+
personId: people[0]!.id,
|
|
765
|
+
leaveTypeId: id('b001'),
|
|
139
766
|
startsOn: day(14),
|
|
140
767
|
endsOn: day(18),
|
|
141
768
|
startPart: 'full',
|
|
@@ -155,27 +782,41 @@ export function createMockHrApi() {
|
|
|
155
782
|
|
|
156
783
|
return {
|
|
157
784
|
people: {
|
|
158
|
-
list: async ({
|
|
159
|
-
|
|
160
|
-
|
|
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))
|
|
161
801
|
if (q) items = items.filter((p) => p.displayName.toLowerCase().includes(q.toLowerCase()))
|
|
162
802
|
// Carries officeName too: a mock that answers a different shape from core is how a screen
|
|
163
803
|
// works in `dev:mock` and breaks against the real API.
|
|
164
804
|
return {
|
|
165
|
-
items: items.map((p) => ({
|
|
805
|
+
items: items.slice(0, limit).map((p) => ({
|
|
166
806
|
...person(p, workspaceId),
|
|
167
|
-
officeId: p.
|
|
168
|
-
officeName:
|
|
807
|
+
officeId: primaryOfficeId(p.id),
|
|
808
|
+
officeName: officeName(primaryOfficeId(p.id)),
|
|
169
809
|
})),
|
|
170
810
|
nextCursor: null,
|
|
811
|
+
// The count before the page, because a widget asking for one row draws this number.
|
|
171
812
|
total: items.length,
|
|
172
813
|
}
|
|
173
814
|
},
|
|
174
815
|
get: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => {
|
|
175
|
-
const found =
|
|
816
|
+
const found = people.find((p) => p.id === personId) ?? people[0]!
|
|
176
817
|
return person(found, workspaceId)
|
|
177
818
|
},
|
|
178
|
-
me: async ({ workspaceId }: { workspaceId: string }) => person(
|
|
819
|
+
me: async ({ workspaceId }: { workspaceId: string }) => person(people[0]!, workspaceId),
|
|
179
820
|
create: async (input: {
|
|
180
821
|
workspaceId: string
|
|
181
822
|
displayName: string
|
|
@@ -191,10 +832,21 @@ export function createMockHrApi() {
|
|
|
191
832
|
workEmail: input.workEmail ?? '',
|
|
192
833
|
status: 'active' as const,
|
|
193
834
|
timezone: 'Europe/Istanbul',
|
|
194
|
-
|
|
195
|
-
employeeNo: input.employeeNo ?? `E-${PEOPLE.length + 1}`,
|
|
835
|
+
employeeNo: input.employeeNo ?? `E-${people.length + 1}`,
|
|
196
836
|
}
|
|
197
|
-
|
|
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
|
+
})
|
|
198
850
|
return person(added, input.workspaceId)
|
|
199
851
|
},
|
|
200
852
|
update: async (input: {
|
|
@@ -205,7 +857,7 @@ export function createMockHrApi() {
|
|
|
205
857
|
personalEmail?: string | null
|
|
206
858
|
phone?: string | null
|
|
207
859
|
}) => {
|
|
208
|
-
const found =
|
|
860
|
+
const found = people.find((p) => p.id === input.personId) ?? people[0]!
|
|
209
861
|
if (input.displayName) found.displayName = input.displayName
|
|
210
862
|
if (input.workEmail !== undefined) found.workEmail = input.workEmail ?? ''
|
|
211
863
|
return {
|
|
@@ -215,14 +867,15 @@ export function createMockHrApi() {
|
|
|
215
867
|
}
|
|
216
868
|
},
|
|
217
869
|
offboard: async (input: { workspaceId: string; personId: string; on: string }) => {
|
|
218
|
-
const found =
|
|
219
|
-
|
|
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 }
|
|
220
873
|
},
|
|
221
874
|
},
|
|
222
875
|
|
|
223
876
|
employment: {
|
|
224
877
|
current: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => ({
|
|
225
|
-
id: '
|
|
878
|
+
id: id('ee01'),
|
|
226
879
|
workspaceId,
|
|
227
880
|
personId,
|
|
228
881
|
effectiveFrom: day(-400),
|
|
@@ -231,7 +884,7 @@ export function createMockHrApi() {
|
|
|
231
884
|
positionId: null,
|
|
232
885
|
legalEntityId: null,
|
|
233
886
|
costCenterId: null,
|
|
234
|
-
managerPersonId:
|
|
887
|
+
managerPersonId: people.find((x) => x.id !== personId)?.id ?? null,
|
|
235
888
|
employmentType: 'full_time' as const,
|
|
236
889
|
fte: 1,
|
|
237
890
|
contractHoursWeek: 40,
|
|
@@ -241,83 +894,726 @@ export function createMockHrApi() {
|
|
|
241
894
|
},
|
|
242
895
|
|
|
243
896
|
offices: {
|
|
244
|
-
list: async ({
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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,
|
|
253
938
|
calendarId: null,
|
|
254
939
|
address: null,
|
|
940
|
+
// The workspace already has a default and only ever has one.
|
|
941
|
+
isDefault: false,
|
|
255
942
|
headPersonId: null,
|
|
256
943
|
archivedAt: null,
|
|
257
944
|
createdAt: iso(),
|
|
258
|
-
|
|
259
|
-
|
|
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
|
+
|
|
260
1133
|
resolveFor: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => {
|
|
261
|
-
const p =
|
|
262
|
-
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
|
|
263
1138
|
void workspaceId
|
|
264
1139
|
return {
|
|
265
1140
|
personId: p.id,
|
|
266
1141
|
on: day(0),
|
|
267
1142
|
primaryOfficeId: office.id,
|
|
268
1143
|
primaryOfficeName: office.name,
|
|
269
|
-
otherOfficeIds:
|
|
1144
|
+
otherOfficeIds: activeAssignments(p.id)
|
|
1145
|
+
.filter((a) => a.officeId !== office.id)
|
|
1146
|
+
.map((a) => a.officeId),
|
|
270
1147
|
country: office.country,
|
|
271
1148
|
timezone: office.timezone,
|
|
272
1149
|
timezoneFrom: 'office' as const,
|
|
273
|
-
calendarId:
|
|
274
|
-
calendarFrom: null,
|
|
275
|
-
workingWeek:
|
|
276
|
-
legalEntityId:
|
|
1150
|
+
calendarId: office.calendarId,
|
|
1151
|
+
calendarFrom: office.calendarId ? ('office' as const) : null,
|
|
1152
|
+
workingWeek: calendar?.workingWeek ?? { ...DEFAULT_WEEK },
|
|
1153
|
+
legalEntityId: office.legalEntityId,
|
|
277
1154
|
orgUnitId: null,
|
|
278
1155
|
orgUnitPath: null,
|
|
279
|
-
|
|
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,
|
|
280
1506
|
}
|
|
281
1507
|
},
|
|
282
1508
|
},
|
|
283
1509
|
|
|
284
1510
|
leave: {
|
|
285
1511
|
types: {
|
|
286
|
-
list: async ({
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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,
|
|
301
1551
|
archivedAt: null,
|
|
302
|
-
}
|
|
303
|
-
|
|
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
|
+
},
|
|
304
1594
|
},
|
|
305
1595
|
balance: {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
+
}),
|
|
321
1617
|
},
|
|
322
1618
|
requests: {
|
|
323
1619
|
list: async ({ workspaceId }: { workspaceId: string }) => ({
|
|
@@ -356,7 +1652,7 @@ export function createMockHrApi() {
|
|
|
356
1652
|
const row = {
|
|
357
1653
|
id: crypto.randomUUID(),
|
|
358
1654
|
workspaceId: input.workspaceId,
|
|
359
|
-
personId:
|
|
1655
|
+
personId: people[0]!.id,
|
|
360
1656
|
leaveTypeId: input.leaveTypeId,
|
|
361
1657
|
startsOn: input.startsOn,
|
|
362
1658
|
endsOn: input.endsOn,
|
|
@@ -384,16 +1680,18 @@ export function createMockHrApi() {
|
|
|
384
1680
|
},
|
|
385
1681
|
team: {
|
|
386
1682
|
calendar: async () =>
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
+
})),
|
|
397
1695
|
},
|
|
398
1696
|
},
|
|
399
1697
|
|
|
@@ -401,7 +1699,7 @@ export function createMockHrApi() {
|
|
|
401
1699
|
state: async ({ workspaceId, personId }: { workspaceId: string; personId?: string }) => {
|
|
402
1700
|
void workspaceId
|
|
403
1701
|
return {
|
|
404
|
-
personId: personId ??
|
|
1702
|
+
personId: personId ?? people[0]!.id,
|
|
405
1703
|
businessDate: day(0),
|
|
406
1704
|
clockedIn: clockedInAt !== null,
|
|
407
1705
|
onBreak,
|
|
@@ -410,34 +1708,50 @@ export function createMockHrApi() {
|
|
|
410
1708
|
timezone: 'Europe/Istanbul',
|
|
411
1709
|
}
|
|
412
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
|
+
*/
|
|
413
1719
|
clockIn: async () => {
|
|
1720
|
+
if (clockedInAt !== null) refuse('CONFLICT', 'You are already clocked in.')
|
|
414
1721
|
clockedInAt = Date.now()
|
|
415
1722
|
return mockPunch('in')
|
|
416
1723
|
},
|
|
417
1724
|
clockOut: async () => {
|
|
1725
|
+
if (clockedInAt === null) refuse('CONFLICT', 'You are not clocked in.')
|
|
418
1726
|
clockedInAt = null
|
|
419
1727
|
onBreak = false
|
|
420
1728
|
return mockPunch('out')
|
|
421
1729
|
},
|
|
422
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.')
|
|
423
1733
|
onBreak = true
|
|
424
1734
|
return mockPunch('break_start')
|
|
425
1735
|
},
|
|
426
1736
|
breakEnd: async () => {
|
|
1737
|
+
if (!onBreak) refuse('CONFLICT', 'You are not on a break.')
|
|
427
1738
|
onBreak = false
|
|
428
1739
|
return mockPunch('break_end')
|
|
429
1740
|
},
|
|
430
1741
|
days: {
|
|
431
1742
|
list: async ({ workspaceId }: { workspaceId: string }) => ({
|
|
432
1743
|
items: [0, 1, 2, 3, 4].map((n) => ({
|
|
433
|
-
id: `
|
|
1744
|
+
id: id(`a000${n}`),
|
|
434
1745
|
workspaceId,
|
|
435
|
-
personId:
|
|
1746
|
+
personId: people[0]!.id,
|
|
436
1747
|
businessDate: day(-n),
|
|
437
1748
|
scheduledMinutes: 480,
|
|
438
1749
|
workedMinutes: n === 2 ? 0 : 480 + (n === 1 ? 45 : 0),
|
|
439
1750
|
breakMinutes: 60,
|
|
440
1751
|
overtimeMinutes: n === 1 ? 45 : 0,
|
|
1752
|
+
// Null, not zero: the demo workspace has no overtime policy with an annual cap, and
|
|
1753
|
+
// "no ceiling applied" is a different fact from "one applied and nothing exceeded it".
|
|
1754
|
+
beyondCapMinutes: null,
|
|
441
1755
|
lateMinutes: 0,
|
|
442
1756
|
earlyLeaveMinutes: 0,
|
|
443
1757
|
status: n === 2 ? ('leave' as const) : ('present' as const),
|
|
@@ -452,6 +1766,107 @@ export function createMockHrApi() {
|
|
|
452
1766
|
nextCursor: null,
|
|
453
1767
|
}),
|
|
454
1768
|
},
|
|
1769
|
+
|
|
1770
|
+
schedules: {
|
|
1771
|
+
list: async ({
|
|
1772
|
+
workspaceId,
|
|
1773
|
+
includeArchived = false,
|
|
1774
|
+
}: {
|
|
1775
|
+
workspaceId: string
|
|
1776
|
+
includeArchived?: boolean
|
|
1777
|
+
}) =>
|
|
1778
|
+
schedules
|
|
1779
|
+
.filter((s) => includeArchived || s.archivedAt === null)
|
|
1780
|
+
.map((s) => ({ ...s, workspaceId })),
|
|
1781
|
+
|
|
1782
|
+
create: async (input: {
|
|
1783
|
+
workspaceId: string
|
|
1784
|
+
name: string
|
|
1785
|
+
kind?: Schedule['kind']
|
|
1786
|
+
week: Schedule['week']
|
|
1787
|
+
tzMode?: Schedule['tzMode']
|
|
1788
|
+
tz?: string | null
|
|
1789
|
+
graceInMinutes?: number
|
|
1790
|
+
graceOutMinutes?: number
|
|
1791
|
+
roundingStepMinutes?: number
|
|
1792
|
+
roundingDirection?: Schedule['roundingDirection']
|
|
1793
|
+
autoClockOutAfterMinutes?: number | null
|
|
1794
|
+
}) => {
|
|
1795
|
+
const created: Row<Schedule> = {
|
|
1796
|
+
id: crypto.randomUUID(),
|
|
1797
|
+
name: input.name,
|
|
1798
|
+
kind: input.kind ?? 'fixed',
|
|
1799
|
+
week: clone(input.week),
|
|
1800
|
+
tzMode: input.tzMode ?? 'office',
|
|
1801
|
+
tz: input.tz ?? null,
|
|
1802
|
+
graceInMinutes: input.graceInMinutes ?? 0,
|
|
1803
|
+
graceOutMinutes: input.graceOutMinutes ?? 0,
|
|
1804
|
+
roundingStepMinutes: input.roundingStepMinutes ?? 0,
|
|
1805
|
+
roundingDirection: input.roundingDirection ?? 'nearest',
|
|
1806
|
+
autoClockOutAfterMinutes: input.autoClockOutAfterMinutes ?? null,
|
|
1807
|
+
archivedAt: null,
|
|
1808
|
+
}
|
|
1809
|
+
schedules.push(created)
|
|
1810
|
+
return { ...created, workspaceId: input.workspaceId }
|
|
1811
|
+
},
|
|
1812
|
+
|
|
1813
|
+
update: async (input: {
|
|
1814
|
+
workspaceId: string
|
|
1815
|
+
scheduleId: string
|
|
1816
|
+
name?: string
|
|
1817
|
+
week?: Schedule['week']
|
|
1818
|
+
graceInMinutes?: number
|
|
1819
|
+
graceOutMinutes?: number
|
|
1820
|
+
roundingStepMinutes?: number
|
|
1821
|
+
roundingDirection?: Schedule['roundingDirection']
|
|
1822
|
+
autoClockOutAfterMinutes?: number | null
|
|
1823
|
+
}) => {
|
|
1824
|
+
const found = schedules.find((s) => s.id === input.scheduleId)
|
|
1825
|
+
if (!found) refuse('NOT_FOUND', 'Schedule not found')
|
|
1826
|
+
if (input.name !== undefined) found.name = input.name
|
|
1827
|
+
if (input.week !== undefined) found.week = clone(input.week)
|
|
1828
|
+
if (input.graceInMinutes !== undefined) found.graceInMinutes = input.graceInMinutes
|
|
1829
|
+
if (input.graceOutMinutes !== undefined) found.graceOutMinutes = input.graceOutMinutes
|
|
1830
|
+
if (input.roundingStepMinutes !== undefined) {
|
|
1831
|
+
found.roundingStepMinutes = input.roundingStepMinutes
|
|
1832
|
+
}
|
|
1833
|
+
if (input.roundingDirection !== undefined) found.roundingDirection = input.roundingDirection
|
|
1834
|
+
if (input.autoClockOutAfterMinutes !== undefined) {
|
|
1835
|
+
found.autoClockOutAfterMinutes = input.autoClockOutAfterMinutes
|
|
1836
|
+
}
|
|
1837
|
+
return { ...found, workspaceId: input.workspaceId }
|
|
1838
|
+
},
|
|
1839
|
+
|
|
1840
|
+
archive: async ({ scheduleId }: { workspaceId: string; scheduleId: string }) => {
|
|
1841
|
+
const found = schedules.find((s) => s.id === scheduleId)
|
|
1842
|
+
if (!found) refuse('NOT_FOUND', 'Schedule not found')
|
|
1843
|
+
found.archivedAt = iso()
|
|
1844
|
+
return { ok: true as const }
|
|
1845
|
+
},
|
|
1846
|
+
|
|
1847
|
+
assign: async (input: {
|
|
1848
|
+
workspaceId: string
|
|
1849
|
+
scheduleId: string
|
|
1850
|
+
personId: string
|
|
1851
|
+
effectiveFrom: string
|
|
1852
|
+
}) => {
|
|
1853
|
+
// Effective-dated, so the earlier assignment is closed rather than replaced: the days
|
|
1854
|
+
// already computed were measured against it.
|
|
1855
|
+
for (const a of scheduleAssignments) {
|
|
1856
|
+
if (a.personId === input.personId && a.effectiveTo === null) a.effectiveTo = input.effectiveFrom
|
|
1857
|
+
}
|
|
1858
|
+
scheduleAssignments.push({
|
|
1859
|
+
id: crypto.randomUUID(),
|
|
1860
|
+
personId: input.personId,
|
|
1861
|
+
scheduleId: input.scheduleId,
|
|
1862
|
+
effectiveFrom: input.effectiveFrom,
|
|
1863
|
+
effectiveTo: null,
|
|
1864
|
+
})
|
|
1865
|
+
return scheduleAssignments
|
|
1866
|
+
.filter((a) => a.personId === input.personId)
|
|
1867
|
+
.map((a) => ({ ...a, workspaceId: input.workspaceId }))
|
|
1868
|
+
},
|
|
1869
|
+
},
|
|
455
1870
|
},
|
|
456
1871
|
|
|
457
1872
|
approvals: {
|
|
@@ -476,7 +1891,7 @@ export function createMockHrApi() {
|
|
|
476
1891
|
|
|
477
1892
|
get: async ({ workspaceId, requestId }: { workspaceId: string; requestId: string }) => {
|
|
478
1893
|
const found = approvalRequests.find((r) => r.id === requestId)
|
|
479
|
-
if (!found)
|
|
1894
|
+
if (!found) refuse('NOT_FOUND', 'Approval request not found')
|
|
480
1895
|
return { ...found, workspaceId }
|
|
481
1896
|
},
|
|
482
1897
|
|
|
@@ -490,7 +1905,7 @@ export function createMockHrApi() {
|
|
|
490
1905
|
decision: 'approve' | 'reject'
|
|
491
1906
|
}) => {
|
|
492
1907
|
const found = approvalRequests.find((r) => r.id === requestId)
|
|
493
|
-
if (!found)
|
|
1908
|
+
if (!found) refuse('NOT_FOUND', 'Approval request not found')
|
|
494
1909
|
// A middle step advances rather than settling: the inbox has to be able to show that.
|
|
495
1910
|
const last = found.currentStep >= Math.max(found.steps.length - 1, 0)
|
|
496
1911
|
if (decision === 'reject' || last) found.status = decision === 'approve' ? 'approved' : 'rejected'
|
|
@@ -520,7 +1935,7 @@ export function createMockHrApi() {
|
|
|
520
1935
|
const created = {
|
|
521
1936
|
id: crypto.randomUUID(),
|
|
522
1937
|
workspaceId,
|
|
523
|
-
fromPersonId:
|
|
1938
|
+
fromPersonId: people[0]!.id,
|
|
524
1939
|
toPersonId,
|
|
525
1940
|
subjectType,
|
|
526
1941
|
startsOn,
|
|
@@ -544,7 +1959,7 @@ export function createMockHrApi() {
|
|
|
544
1959
|
return {
|
|
545
1960
|
id: crypto.randomUUID(),
|
|
546
1961
|
workspaceId: '',
|
|
547
|
-
personId:
|
|
1962
|
+
personId: people[0]!.id,
|
|
548
1963
|
direction,
|
|
549
1964
|
at: new Date().toISOString(),
|
|
550
1965
|
clientReportedAt: null,
|
|
@@ -552,7 +1967,7 @@ export function createMockHrApi() {
|
|
|
552
1967
|
businessDate: day(0),
|
|
553
1968
|
timezone: 'Europe/Istanbul',
|
|
554
1969
|
method: 'web',
|
|
555
|
-
officeId:
|
|
1970
|
+
officeId: primaryOfficeId(people[0]!.id),
|
|
556
1971
|
deviceId: null,
|
|
557
1972
|
geo: null,
|
|
558
1973
|
trust: 'trusted',
|