@t4dhg/mcp-factorial 8.2.0 → 10.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -15
- package/dist/api/attendance.d.ts +37 -13
- package/dist/api/attendance.d.ts.map +1 -1
- package/dist/api/attendance.js +161 -31
- package/dist/api/attendance.js.map +1 -1
- package/dist/api/contracts.js +4 -4
- package/dist/api/contracts.js.map +1 -1
- package/dist/api/documents.d.ts +6 -5
- package/dist/api/documents.d.ts.map +1 -1
- package/dist/api/documents.js +17 -12
- package/dist/api/documents.js.map +1 -1
- package/dist/api/employees.d.ts +5 -3
- package/dist/api/employees.d.ts.map +1 -1
- package/dist/api/employees.js +7 -5
- package/dist/api/employees.js.map +1 -1
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/index.js.map +1 -1
- package/dist/api/time-off.d.ts +6 -1
- package/dist/api/time-off.d.ts.map +1 -1
- package/dist/api/time-off.js +20 -16
- package/dist/api/time-off.js.map +1 -1
- package/dist/attendance/backfill.d.ts +36 -0
- package/dist/attendance/backfill.d.ts.map +1 -0
- package/dist/attendance/backfill.js +104 -0
- package/dist/attendance/backfill.js.map +1 -0
- package/dist/attendance/gate.d.ts +40 -0
- package/dist/attendance/gate.d.ts.map +1 -0
- package/dist/attendance/gate.js +63 -0
- package/dist/attendance/gate.js.map +1 -0
- package/dist/attendance/identity.d.ts +27 -0
- package/dist/attendance/identity.d.ts.map +1 -0
- package/dist/attendance/identity.js +73 -0
- package/dist/attendance/identity.js.map +1 -0
- package/dist/attendance/planner.d.ts +147 -0
- package/dist/attendance/planner.d.ts.map +1 -0
- package/dist/attendance/planner.js +316 -0
- package/dist/attendance/planner.js.map +1 -0
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -1
- package/dist/config.js.map +1 -1
- package/dist/endpoints.d.ts +5 -0
- package/dist/endpoints.d.ts.map +1 -1
- package/dist/endpoints.js +5 -0
- package/dist/endpoints.js.map +1 -1
- package/dist/http-client.d.ts +26 -1
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +68 -8
- package/dist/http-client.js.map +1 -1
- package/dist/schemas/ats.d.ts +204 -127
- package/dist/schemas/ats.d.ts.map +1 -1
- package/dist/schemas/ats.js +77 -39
- package/dist/schemas/ats.js.map +1 -1
- package/dist/schemas/attendance.d.ts +367 -0
- package/dist/schemas/attendance.d.ts.map +1 -0
- package/dist/schemas/attendance.js +159 -0
- package/dist/schemas/attendance.js.map +1 -0
- package/dist/schemas/documents.d.ts +75 -45
- package/dist/schemas/documents.d.ts.map +1 -1
- package/dist/schemas/documents.js +44 -15
- package/dist/schemas/documents.js.map +1 -1
- package/dist/schemas/employees.d.ts +135 -77
- package/dist/schemas/employees.d.ts.map +1 -1
- package/dist/schemas/employees.js +65 -30
- package/dist/schemas/employees.js.map +1 -1
- package/dist/schemas/index.d.ts +3 -2
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +4 -2
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/job-catalog.d.ts +39 -27
- package/dist/schemas/job-catalog.d.ts.map +1 -1
- package/dist/schemas/job-catalog.js +16 -9
- package/dist/schemas/job-catalog.js.map +1 -1
- package/dist/schemas/payroll.d.ts +89 -70
- package/dist/schemas/payroll.d.ts.map +1 -1
- package/dist/schemas/payroll.js +49 -24
- package/dist/schemas/payroll.js.map +1 -1
- package/dist/schemas/projects.d.ts +123 -97
- package/dist/schemas/projects.d.ts.map +1 -1
- package/dist/schemas/projects.js +51 -32
- package/dist/schemas/projects.js.map +1 -1
- package/dist/schemas/shared.d.ts +13 -0
- package/dist/schemas/shared.d.ts.map +1 -1
- package/dist/schemas/shared.js +13 -0
- package/dist/schemas/shared.js.map +1 -1
- package/dist/schemas/time-off.d.ts +244 -187
- package/dist/schemas/time-off.d.ts.map +1 -1
- package/dist/schemas/time-off.js +96 -63
- package/dist/schemas/time-off.js.map +1 -1
- package/dist/schemas/training.d.ts +180 -76
- package/dist/schemas/training.d.ts.map +1 -1
- package/dist/schemas/training.js +75 -25
- package/dist/schemas/training.js.map +1 -1
- package/dist/schemas/work-areas.d.ts +14 -21
- package/dist/schemas/work-areas.d.ts.map +1 -1
- package/dist/schemas/work-areas.js +9 -7
- package/dist/schemas/work-areas.js.map +1 -1
- package/dist/tools/ats.js +3 -3
- package/dist/tools/ats.js.map +1 -1
- package/dist/tools/attendance.d.ts +9 -1
- package/dist/tools/attendance.d.ts.map +1 -1
- package/dist/tools/attendance.js +314 -24
- package/dist/tools/attendance.js.map +1 -1
- package/dist/tools/documents.js +6 -6
- package/dist/tools/documents.js.map +1 -1
- package/dist/tools/index.js +2 -2
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/shared.d.ts +2 -2
- package/dist/tools/shared.d.ts.map +1 -1
- package/dist/tools/shared.js +14 -2
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/time-off.d.ts.map +1 -1
- package/dist/tools/time-off.js +15 -6
- package/dist/tools/time-off.js.map +1 -1
- package/dist/tools/work-areas.js +2 -2
- package/dist/tools/work-areas.js.map +1 -1
- package/dist/types.d.ts +29 -14
- package/dist/types.d.ts.map +1 -1
- package/dist/write-safety.d.ts +18 -0
- package/dist/write-safety.d.ts.map +1 -1
- package/dist/write-safety.js +19 -0
- package/dist/write-safety.js.map +1 -1
- package/llms.txt +6 -4
- package/package.json +1 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target-identity confirmation gate for attendance writes.
|
|
3
|
+
*
|
|
4
|
+
* The policy gate in tools/shared.ts (checkConfirmation) is static per
|
|
5
|
+
* operation and cannot express "depends on who the target is" or "depends on
|
|
6
|
+
* how many records". This gate can. A first call without a token writes
|
|
7
|
+
* nothing and returns a preview plus a token bound to a fingerprint of what
|
|
8
|
+
* would be written; the confirming call re-derives the fingerprint and
|
|
9
|
+
* refuses if it changed. A `confirm: true` boolean cannot bypass it, because
|
|
10
|
+
* there is no token to pass on a first call.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { confirmationManager } from '../confirmation.js';
|
|
14
|
+
import { ConfirmationExpiredError } from '../errors.js';
|
|
15
|
+
import { isConfiguredIdentity } from './identity.js';
|
|
16
|
+
/** Stable digest of an arbitrary payload */
|
|
17
|
+
export function payloadFingerprint(payload) {
|
|
18
|
+
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
|
19
|
+
}
|
|
20
|
+
function issue(request, prefix = '') {
|
|
21
|
+
const token = confirmationManager.createConfirmation(request.operation, { fingerprint: request.fingerprint, employeeId: request.employeeId }, {
|
|
22
|
+
operation: 'create',
|
|
23
|
+
entityType: 'shift',
|
|
24
|
+
entityId: request.employeeId,
|
|
25
|
+
entityName: request.preview.split('\n')[0],
|
|
26
|
+
warnings: [],
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
proceed: false,
|
|
30
|
+
message: `${prefix}${request.preview}\n\nNothing has been written. ` +
|
|
31
|
+
`To proceed, call this action again with the same arguments and confirmation_token: ${token}\n` +
|
|
32
|
+
'The token is valid for 5 minutes and for exactly this plan.',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Decide whether a write may proceed. Writes aimed at the configured identity
|
|
37
|
+
* pass without a token unless `always` is set; every other write, and every
|
|
38
|
+
* write when FACTORIAL_EMPLOYEE_ID is unset, needs one.
|
|
39
|
+
*/
|
|
40
|
+
export function requireTargetConfirmation(request) {
|
|
41
|
+
const gated = request.always === true || !isConfiguredIdentity(request.employeeId);
|
|
42
|
+
if (!gated)
|
|
43
|
+
return { proceed: true };
|
|
44
|
+
if (!request.token)
|
|
45
|
+
return issue(request);
|
|
46
|
+
let pending;
|
|
47
|
+
try {
|
|
48
|
+
pending = confirmationManager.confirm(request.token);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error instanceof ConfirmationExpiredError) {
|
|
52
|
+
return issue(request, 'The confirmation token was invalid or had expired. Here is the current plan.\n\n');
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
if (pending.operation !== request.operation ||
|
|
57
|
+
pending.payload.fingerprint !== request.fingerprint) {
|
|
58
|
+
return issue(request, 'The plan changed between the preview and this call (someone wrote in the meantime, or the ' +
|
|
59
|
+
'arguments differ). The old token has been discarded. Here is the current plan.\n\n');
|
|
60
|
+
}
|
|
61
|
+
return { proceed: true };
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=gate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gate.js","sourceRoot":"","sources":["../../src/attendance/gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AACxD,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAmBrD,4CAA4C;AAC5C,MAAM,UAAU,kBAAkB,CAAC,OAAgB;IACjD,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAC5E,CAAC;AAED,SAAS,KAAK,CAAC,OAAoB,EAAE,MAAM,GAAG,EAAE;IAC9C,MAAM,KAAK,GAAG,mBAAmB,CAAC,kBAAkB,CAClD,OAAO,CAAC,SAAS,EACjB,EAAE,WAAW,EAAE,OAAO,CAAC,WAAW,EAAE,UAAU,EAAE,OAAO,CAAC,UAAU,EAAE,EACpE;QACE,SAAS,EAAE,QAAQ;QACnB,UAAU,EAAE,OAAO;QACnB,QAAQ,EAAE,OAAO,CAAC,UAAU;QAC5B,UAAU,EAAE,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC1C,QAAQ,EAAE,EAAE;KACb,CACF,CAAC;IACF,OAAO;QACL,OAAO,EAAE,KAAK;QACd,OAAO,EACL,GAAG,MAAM,GAAG,OAAO,CAAC,OAAO,gCAAgC;YAC3D,sFAAsF,KAAK,IAAI;YAC/F,6DAA6D;KAChE,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,yBAAyB,CAAC,OAAoB;IAC5D,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,KAAK,IAAI,IAAI,CAAC,oBAAoB,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC;IACnF,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;IAErC,IAAI,CAAC,OAAO,CAAC,KAAK;QAAE,OAAO,KAAK,CAAC,OAAO,CAAC,CAAC;IAE1C,IAAI,OAAO,CAAC;IACZ,IAAI,CAAC;QACH,OAAO,GAAG,mBAAmB,CAAC,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IACvD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,KAAK,YAAY,wBAAwB,EAAE,CAAC;YAC9C,OAAO,KAAK,CACV,OAAO,EACP,kFAAkF,CACnF,CAAC;QACJ,CAAC;QACD,MAAM,KAAK,CAAC;IACd,CAAC;IAED,IACE,OAAO,CAAC,SAAS,KAAK,OAAO,CAAC,SAAS;QACvC,OAAO,CAAC,OAAO,CAAC,WAAW,KAAK,OAAO,CAAC,WAAW,EACnD,CAAC;QACD,OAAO,KAAK,CACV,OAAO,EACP,4FAA4F;YAC1F,oFAAoF,CACvF,CAAC;IACJ,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;AAC3B,CAAC"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who "I" am for attendance actions.
|
|
3
|
+
*
|
|
4
|
+
* A company-scoped API key carries no employee: /api_public/credentials
|
|
5
|
+
* returns employee_id null. The optional FACTORIAL_EMPLOYEE_ID variable
|
|
6
|
+
* supplies the default target for every attendance action, and every write
|
|
7
|
+
* aimed at anyone else is treated as aimed at another person by the safety
|
|
8
|
+
* gate in tools/attendance.ts.
|
|
9
|
+
*/
|
|
10
|
+
/** Read and validate FACTORIAL_EMPLOYEE_ID; undefined when unset */
|
|
11
|
+
export declare function getConfiguredEmployeeId(): number | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the employee an action targets: the explicit argument wins, then the
|
|
14
|
+
* configured default. With neither, the caller must name someone.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveTargetEmployeeId(explicit: number | undefined): number;
|
|
17
|
+
/** True when the target is the configured identity (never true when unset) */
|
|
18
|
+
export declare function isConfiguredIdentity(employeeId: number): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Resolve an employee id to a display name through the employees API, cached
|
|
21
|
+
* for the life of the process. Fails loudly when the id does not resolve, so a
|
|
22
|
+
* mistyped FACTORIAL_EMPLOYEE_ID cannot silently write onto a colleague.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveEmployeeName(employeeId: number): Promise<string>;
|
|
25
|
+
/** Forget resolved names (tests) */
|
|
26
|
+
export declare function clearResolvedNames(): void;
|
|
27
|
+
//# sourceMappingURL=identity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity.d.ts","sourceRoot":"","sources":["../../src/attendance/identity.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH,oEAAoE;AACpE,wBAAgB,uBAAuB,IAAI,MAAM,GAAG,SAAS,CAU5D;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAc5E;AAED,8EAA8E;AAC9E,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAGhE;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAiB7E;AAED,oCAAoC;AACpC,wBAAgB,kBAAkB,IAAI,IAAI,CAEzC"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who "I" am for attendance actions.
|
|
3
|
+
*
|
|
4
|
+
* A company-scoped API key carries no employee: /api_public/credentials
|
|
5
|
+
* returns employee_id null. The optional FACTORIAL_EMPLOYEE_ID variable
|
|
6
|
+
* supplies the default target for every attendance action, and every write
|
|
7
|
+
* aimed at anyone else is treated as aimed at another person by the safety
|
|
8
|
+
* gate in tools/attendance.ts.
|
|
9
|
+
*/
|
|
10
|
+
import { getEmployee } from '../api/employees.js';
|
|
11
|
+
let resolvedNames = new Map();
|
|
12
|
+
/** Read and validate FACTORIAL_EMPLOYEE_ID; undefined when unset */
|
|
13
|
+
export function getConfiguredEmployeeId() {
|
|
14
|
+
const raw = process.env.FACTORIAL_EMPLOYEE_ID;
|
|
15
|
+
if (raw === undefined || raw.trim() === '')
|
|
16
|
+
return undefined;
|
|
17
|
+
if (!/^[1-9]\d*$/.test(raw.trim())) {
|
|
18
|
+
throw new Error(`FACTORIAL_EMPLOYEE_ID must be a positive integer employee id, got "${raw}". ` +
|
|
19
|
+
'Unset it to require employee_id on every attendance action.');
|
|
20
|
+
}
|
|
21
|
+
return Number(raw.trim());
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the employee an action targets: the explicit argument wins, then the
|
|
25
|
+
* configured default. With neither, the caller must name someone.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveTargetEmployeeId(explicit) {
|
|
28
|
+
if (explicit !== undefined) {
|
|
29
|
+
if (!Number.isInteger(explicit) || explicit <= 0) {
|
|
30
|
+
throw new Error('employee_id must be a positive integer');
|
|
31
|
+
}
|
|
32
|
+
return explicit;
|
|
33
|
+
}
|
|
34
|
+
const configured = getConfiguredEmployeeId();
|
|
35
|
+
if (configured === undefined) {
|
|
36
|
+
throw new Error('employee_id is required: FACTORIAL_EMPLOYEE_ID is not set, so there is no default identity.');
|
|
37
|
+
}
|
|
38
|
+
return configured;
|
|
39
|
+
}
|
|
40
|
+
/** True when the target is the configured identity (never true when unset) */
|
|
41
|
+
export function isConfiguredIdentity(employeeId) {
|
|
42
|
+
const configured = getConfiguredEmployeeId();
|
|
43
|
+
return configured !== undefined && configured === employeeId;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve an employee id to a display name through the employees API, cached
|
|
47
|
+
* for the life of the process. Fails loudly when the id does not resolve, so a
|
|
48
|
+
* mistyped FACTORIAL_EMPLOYEE_ID cannot silently write onto a colleague.
|
|
49
|
+
*/
|
|
50
|
+
export async function resolveEmployeeName(employeeId) {
|
|
51
|
+
const cached = resolvedNames.get(employeeId);
|
|
52
|
+
if (cached)
|
|
53
|
+
return cached;
|
|
54
|
+
let name;
|
|
55
|
+
try {
|
|
56
|
+
const employee = await getEmployee(employeeId);
|
|
57
|
+
name = employee.full_name || `${employee.first_name ?? ''} ${employee.last_name ?? ''}`.trim();
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
61
|
+
throw new Error(`Employee ${employeeId} could not be resolved (${reason}). ` +
|
|
62
|
+
'Check the employee_id argument or the FACTORIAL_EMPLOYEE_ID setting.');
|
|
63
|
+
}
|
|
64
|
+
if (!name)
|
|
65
|
+
name = `employee ${employeeId}`;
|
|
66
|
+
resolvedNames.set(employeeId, name);
|
|
67
|
+
return name;
|
|
68
|
+
}
|
|
69
|
+
/** Forget resolved names (tests) */
|
|
70
|
+
export function clearResolvedNames() {
|
|
71
|
+
resolvedNames = new Map();
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=identity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity.js","sourceRoot":"","sources":["../../src/attendance/identity.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAElD,IAAI,aAAa,GAAG,IAAI,GAAG,EAAkB,CAAC;AAE9C,oEAAoE;AACpE,MAAM,UAAU,uBAAuB;IACrC,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,qBAAqB,CAAC;IAC9C,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO,SAAS,CAAC;IAC7D,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC;QACnC,MAAM,IAAI,KAAK,CACb,sEAAsE,GAAG,KAAK;YAC5E,6DAA6D,CAChE,CAAC;IACJ,CAAC;IACD,OAAO,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC;AAC5B,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,uBAAuB,CAAC,QAA4B;IAClE,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC3B,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,QAAQ,IAAI,CAAC,EAAE,CAAC;YACjD,MAAM,IAAI,KAAK,CAAC,wCAAwC,CAAC,CAAC;QAC5D,CAAC;QACD,OAAO,QAAQ,CAAC;IAClB,CAAC;IACD,MAAM,UAAU,GAAG,uBAAuB,EAAE,CAAC;IAC7C,IAAI,UAAU,KAAK,SAAS,EAAE,CAAC;QAC7B,MAAM,IAAI,KAAK,CACb,6FAA6F,CAC9F,CAAC;IACJ,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,oBAAoB,CAAC,UAAkB;IACrD,MAAM,UAAU,GAAG,uBAAuB,EAAE,CAAC;IAC7C,OAAO,UAAU,KAAK,SAAS,IAAI,UAAU,KAAK,UAAU,CAAC;AAC/D,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,UAAkB;IAC1D,MAAM,MAAM,GAAG,aAAa,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;IAC7C,IAAI,MAAM;QAAE,OAAO,MAAM,CAAC;IAC1B,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,WAAW,CAAC,UAAU,CAAC,CAAC;QAC/C,IAAI,GAAG,QAAQ,CAAC,SAAS,IAAI,GAAG,QAAQ,CAAC,UAAU,IAAI,EAAE,IAAI,QAAQ,CAAC,SAAS,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC;IACjG,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,MAAM,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACtE,MAAM,IAAI,KAAK,CACb,YAAY,UAAU,2BAA2B,MAAM,KAAK;YAC1D,sEAAsE,CACzE,CAAC;IACJ,CAAC;IACD,IAAI,CAAC,IAAI;QAAE,IAAI,GAAG,YAAY,UAAU,EAAE,CAAC;IAC3C,aAAa,CAAC,GAAG,CAAC,UAAU,EAAE,IAAI,CAAC,CAAC;IACpC,OAAO,IAAI,CAAC;AACd,CAAC;AAED,oCAAoC;AACpC,MAAM,UAAU,kBAAkB;IAChC,aAAa,GAAG,IAAI,GAAG,EAAE,CAAC;AAC5B,CAAC"}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backfill planner for attendance shifts (registro horario).
|
|
3
|
+
*
|
|
4
|
+
* Everything in this module is pure: no network, no clock, no configuration.
|
|
5
|
+
* The caller gathers facts from the API (worked_times, estimated_times,
|
|
6
|
+
* existing shifts, approved leaves) and the planner turns them plus the
|
|
7
|
+
* requested segments into an explicit list of writes and skips. Every rule is
|
|
8
|
+
* a unit test in src/__tests__/unit/attendance/planner.test.ts.
|
|
9
|
+
*/
|
|
10
|
+
/** A working segment on one day, in company local wall-clock time */
|
|
11
|
+
export interface Segment {
|
|
12
|
+
clock_in: string;
|
|
13
|
+
clock_out: string;
|
|
14
|
+
}
|
|
15
|
+
/** One planned shift record */
|
|
16
|
+
export interface PlannedWrite extends Segment {
|
|
17
|
+
date: string;
|
|
18
|
+
}
|
|
19
|
+
/** What is known about one calendar day for one employee */
|
|
20
|
+
export interface DayFacts {
|
|
21
|
+
/** worked_times.day_type: workday | saturday | sunday | bank_holiday */
|
|
22
|
+
day_type: string;
|
|
23
|
+
/** estimated_times.expected_minutes, from the contract pattern */
|
|
24
|
+
expected_minutes: number;
|
|
25
|
+
/** worked_times.tracked_minutes */
|
|
26
|
+
tracked_minutes: number;
|
|
27
|
+
}
|
|
28
|
+
/** An existing shift as the API reports it; clock_out null means still open */
|
|
29
|
+
export interface ExistingShift {
|
|
30
|
+
date: string;
|
|
31
|
+
clock_in: string;
|
|
32
|
+
clock_out: string | null;
|
|
33
|
+
}
|
|
34
|
+
/** How an approved leave covers a date */
|
|
35
|
+
export type LeaveCover = 'full' | 'beggining_of_day' | 'end_of_day';
|
|
36
|
+
/** The subset of a leave record the planner needs */
|
|
37
|
+
export interface LeaveLike {
|
|
38
|
+
start_on: string;
|
|
39
|
+
finish_on: string;
|
|
40
|
+
half_day: string | null;
|
|
41
|
+
approved: boolean | null;
|
|
42
|
+
deleted_at: string | null;
|
|
43
|
+
}
|
|
44
|
+
export interface PlanFacts {
|
|
45
|
+
/** Today's date, YYYY-MM-DD, in the zone the caller considers local */
|
|
46
|
+
today: string;
|
|
47
|
+
/** Per-date facts; a date missing from the map has no contract data */
|
|
48
|
+
days: Map<string, DayFacts>;
|
|
49
|
+
/** Existing shifts for the employee within the planned dates */
|
|
50
|
+
shifts: ExistingShift[];
|
|
51
|
+
/** Approved leave cover per date, see expandLeaves */
|
|
52
|
+
leaves: Map<string, LeaveCover>;
|
|
53
|
+
}
|
|
54
|
+
export interface RangeRequest {
|
|
55
|
+
mode: 'range';
|
|
56
|
+
employee_id: number;
|
|
57
|
+
dates: string[];
|
|
58
|
+
segments: Segment[];
|
|
59
|
+
skip_leave: boolean;
|
|
60
|
+
}
|
|
61
|
+
export interface DaysRequest {
|
|
62
|
+
mode: 'days';
|
|
63
|
+
employee_id: number;
|
|
64
|
+
days: Array<{
|
|
65
|
+
date: string;
|
|
66
|
+
segments: Segment[];
|
|
67
|
+
}>;
|
|
68
|
+
skip_leave: boolean;
|
|
69
|
+
}
|
|
70
|
+
export type PlanRequest = RangeRequest | DaysRequest;
|
|
71
|
+
export type SkipReason = 'future_date' | 'weekend' | 'bank_holiday' | 'not_workable' | 'on_leave' | 'half_day_leave';
|
|
72
|
+
export interface SkippedDay {
|
|
73
|
+
date: string;
|
|
74
|
+
reason: SkipReason;
|
|
75
|
+
detail?: string;
|
|
76
|
+
}
|
|
77
|
+
export interface SkippedSegment extends Segment {
|
|
78
|
+
date: string;
|
|
79
|
+
reason: 'overlaps_existing';
|
|
80
|
+
detail: string;
|
|
81
|
+
}
|
|
82
|
+
export interface BackfillPlan {
|
|
83
|
+
writes: PlannedWrite[];
|
|
84
|
+
skippedDays: SkippedDay[];
|
|
85
|
+
skippedSegments: SkippedSegment[];
|
|
86
|
+
totals: {
|
|
87
|
+
days: number;
|
|
88
|
+
records: number;
|
|
89
|
+
minutes: number;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export interface Gap {
|
|
93
|
+
date: string;
|
|
94
|
+
expected_minutes: number;
|
|
95
|
+
tracked_minutes: number;
|
|
96
|
+
missing_minutes: number;
|
|
97
|
+
half_day_leave: 'beggining_of_day' | 'end_of_day' | null;
|
|
98
|
+
}
|
|
99
|
+
/** Parse "HH:MM" into minutes since midnight; anything else is rejected */
|
|
100
|
+
export declare function parseHHMM(value: string): number;
|
|
101
|
+
/** True when two [start, end) intervals share any time; touching does not count */
|
|
102
|
+
export declare function intervalsOverlap(a: [number, number], b: [number, number]): boolean;
|
|
103
|
+
/** Every date from start to end inclusive */
|
|
104
|
+
export declare function enumerateDates(start: string, end: string): string[];
|
|
105
|
+
/**
|
|
106
|
+
* Validate the segments of one day: HH:MM only, clock_in strictly before
|
|
107
|
+
* clock_out, no overnight shifts, no overlap between segments.
|
|
108
|
+
*/
|
|
109
|
+
export declare function validateSegments(segments: Segment[]): void;
|
|
110
|
+
/**
|
|
111
|
+
* Turn leave records into a per-date cover map. Only approved, undeleted
|
|
112
|
+
* leaves count; a pending (approved: null) or rejected leave does not block
|
|
113
|
+
* anything. A later leave over the same date never downgrades a full cover.
|
|
114
|
+
*/
|
|
115
|
+
export declare function expandLeaves(leaves: LeaveLike[]): Map<string, LeaveCover>;
|
|
116
|
+
/**
|
|
117
|
+
* Build the plan. Skip reasons are evaluated in the documented order and the
|
|
118
|
+
* first match wins. Explicit days skip the weekday, holiday and workability
|
|
119
|
+
* rules on purpose: migration must be able to write a Saturday someone worked.
|
|
120
|
+
*/
|
|
121
|
+
export declare function buildBackfillPlan(request: PlanRequest, facts: PlanFacts): BackfillPlan;
|
|
122
|
+
/**
|
|
123
|
+
* Stable digest of who and what a plan writes, including the note stored on
|
|
124
|
+
* every record; the confirmation token is bound to it.
|
|
125
|
+
*/
|
|
126
|
+
export declare function planFingerprint(employeeId: number, writes: PlannedWrite[], observations?: string): string;
|
|
127
|
+
/**
|
|
128
|
+
* Workdays up to today where the contract expects more than was tracked and no
|
|
129
|
+
* full-day leave applies. Future dates are left out, since log_range would
|
|
130
|
+
* refuse them anyway.
|
|
131
|
+
*/
|
|
132
|
+
export declare function computeGaps(facts: PlanFacts): Gap[];
|
|
133
|
+
/** 720 -> "12h", 750 -> "12h30" */
|
|
134
|
+
export declare function hours(minutes: number): string;
|
|
135
|
+
/**
|
|
136
|
+
* Render the plan the way the confirmation step shows it. It always names the
|
|
137
|
+
* person, because the company-wide key makes writing someone else's hours the
|
|
138
|
+
* mistake worth catching.
|
|
139
|
+
*/
|
|
140
|
+
export declare function formatPlanPreview(plan: BackfillPlan, employee: {
|
|
141
|
+
id: number;
|
|
142
|
+
name: string;
|
|
143
|
+
}, range: {
|
|
144
|
+
start: string;
|
|
145
|
+
end: string;
|
|
146
|
+
}, request: PlanRequest, observations?: string): string;
|
|
147
|
+
//# sourceMappingURL=planner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"planner.d.ts","sourceRoot":"","sources":["../../src/attendance/planner.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backfill planner for attendance shifts (registro horario).
|
|
3
|
+
*
|
|
4
|
+
* Everything in this module is pure: no network, no clock, no configuration.
|
|
5
|
+
* The caller gathers facts from the API (worked_times, estimated_times,
|
|
6
|
+
* existing shifts, approved leaves) and the planner turns them plus the
|
|
7
|
+
* requested segments into an explicit list of writes and skips. Every rule is
|
|
8
|
+
* a unit test in src/__tests__/unit/attendance/planner.test.ts.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
const HHMM = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
12
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
13
|
+
const END_OF_DAY = 24 * 60;
|
|
14
|
+
/** Parse "HH:MM" into minutes since midnight; anything else is rejected */
|
|
15
|
+
export function parseHHMM(value) {
|
|
16
|
+
const match = HHMM.exec(value);
|
|
17
|
+
if (!match) {
|
|
18
|
+
throw new Error(`Time "${value}" must be HH:MM in 24-hour company local time`);
|
|
19
|
+
}
|
|
20
|
+
return Number(match[1]) * 60 + Number(match[2]);
|
|
21
|
+
}
|
|
22
|
+
/** True when two [start, end) intervals share any time; touching does not count */
|
|
23
|
+
export function intervalsOverlap(a, b) {
|
|
24
|
+
return a[0] < b[1] && b[0] < a[1];
|
|
25
|
+
}
|
|
26
|
+
function assertDate(value) {
|
|
27
|
+
if (!DATE.test(value) || Number.isNaN(Date.parse(`${value}T00:00:00Z`))) {
|
|
28
|
+
throw new Error(`Date "${value}" must be YYYY-MM-DD`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Every date from start to end inclusive */
|
|
32
|
+
export function enumerateDates(start, end) {
|
|
33
|
+
assertDate(start);
|
|
34
|
+
assertDate(end);
|
|
35
|
+
if (start > end) {
|
|
36
|
+
throw new Error(`start_on ${start} is after end_on ${end}`);
|
|
37
|
+
}
|
|
38
|
+
const dates = [];
|
|
39
|
+
const cursor = new Date(`${start}T00:00:00Z`);
|
|
40
|
+
const last = new Date(`${end}T00:00:00Z`);
|
|
41
|
+
while (cursor <= last) {
|
|
42
|
+
dates.push(cursor.toISOString().slice(0, 10));
|
|
43
|
+
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
44
|
+
}
|
|
45
|
+
return dates;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Validate the segments of one day: HH:MM only, clock_in strictly before
|
|
49
|
+
* clock_out, no overnight shifts, no overlap between segments.
|
|
50
|
+
*/
|
|
51
|
+
export function validateSegments(segments) {
|
|
52
|
+
if (segments.length === 0) {
|
|
53
|
+
throw new Error('segments must contain at least one {clock_in, clock_out} entry');
|
|
54
|
+
}
|
|
55
|
+
const intervals = segments.map(segment => {
|
|
56
|
+
const start = parseHHMM(segment.clock_in);
|
|
57
|
+
const end = parseHHMM(segment.clock_out);
|
|
58
|
+
if (end < start) {
|
|
59
|
+
throw new Error(`Segment ${segment.clock_in}-${segment.clock_out} ends before it starts. Overnight shifts ` +
|
|
60
|
+
'are not supported by the bulk actions; use the create action with reference_date instead.');
|
|
61
|
+
}
|
|
62
|
+
if (end === start) {
|
|
63
|
+
throw new Error(`Segment ${segment.clock_in}-${segment.clock_out}: clock_in must be before clock_out`);
|
|
64
|
+
}
|
|
65
|
+
return [start, end];
|
|
66
|
+
});
|
|
67
|
+
for (let i = 0; i < intervals.length; i++) {
|
|
68
|
+
for (let j = i + 1; j < intervals.length; j++) {
|
|
69
|
+
if (intervalsOverlap(intervals[i], intervals[j])) {
|
|
70
|
+
throw new Error(`Segments ${segments[i].clock_in}-${segments[i].clock_out} and ` +
|
|
71
|
+
`${segments[j].clock_in}-${segments[j].clock_out} overlap each other`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Turn leave records into a per-date cover map. Only approved, undeleted
|
|
78
|
+
* leaves count; a pending (approved: null) or rejected leave does not block
|
|
79
|
+
* anything. A later leave over the same date never downgrades a full cover.
|
|
80
|
+
*/
|
|
81
|
+
export function expandLeaves(leaves) {
|
|
82
|
+
const cover = new Map();
|
|
83
|
+
for (const leave of leaves) {
|
|
84
|
+
if (leave.approved !== true || leave.deleted_at !== null)
|
|
85
|
+
continue;
|
|
86
|
+
const kind = leave.half_day === 'beggining_of_day' || leave.half_day === 'end_of_day'
|
|
87
|
+
? leave.half_day
|
|
88
|
+
: 'full';
|
|
89
|
+
for (const date of enumerateDates(leave.start_on, leave.finish_on)) {
|
|
90
|
+
const existing = cover.get(date);
|
|
91
|
+
if (existing === 'full')
|
|
92
|
+
continue;
|
|
93
|
+
cover.set(date, kind);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return cover;
|
|
97
|
+
}
|
|
98
|
+
function daySkipReason(date, request, facts) {
|
|
99
|
+
if (date > facts.today) {
|
|
100
|
+
return {
|
|
101
|
+
date,
|
|
102
|
+
reason: 'future_date',
|
|
103
|
+
detail: `today is ${facts.today} in the zone of the machine running the server`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const day = facts.days.get(date);
|
|
107
|
+
if (request.mode === 'range') {
|
|
108
|
+
if (day?.day_type === 'saturday' || day?.day_type === 'sunday') {
|
|
109
|
+
return { date, reason: 'weekend' };
|
|
110
|
+
}
|
|
111
|
+
if (day?.day_type === 'bank_holiday') {
|
|
112
|
+
return { date, reason: 'bank_holiday' };
|
|
113
|
+
}
|
|
114
|
+
if (!day || day.expected_minutes <= 0) {
|
|
115
|
+
return {
|
|
116
|
+
date,
|
|
117
|
+
reason: 'not_workable',
|
|
118
|
+
detail: day ? 'contract expects 0 minutes' : 'no contract data for this date',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (request.skip_leave) {
|
|
123
|
+
const cover = facts.leaves.get(date);
|
|
124
|
+
if (cover === 'full') {
|
|
125
|
+
return { date, reason: 'on_leave', detail: 'approved leave' };
|
|
126
|
+
}
|
|
127
|
+
if (cover && request.mode === 'range') {
|
|
128
|
+
return {
|
|
129
|
+
date,
|
|
130
|
+
reason: 'half_day_leave',
|
|
131
|
+
detail: `approved half-day leave (${cover}); write it with log_days if the other half was worked`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
function existingIntervals(shifts, date) {
|
|
138
|
+
return shifts
|
|
139
|
+
.filter(shift => shift.date === date)
|
|
140
|
+
.map(shift => {
|
|
141
|
+
let start;
|
|
142
|
+
let end;
|
|
143
|
+
try {
|
|
144
|
+
start = parseHHMM(shift.clock_in);
|
|
145
|
+
end = shift.clock_out === null ? END_OF_DAY : parseHHMM(shift.clock_out);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
throw new Error(`An existing shift on ${date} has times the planner cannot read ` +
|
|
149
|
+
`(${shift.clock_in}-${shift.clock_out ?? 'open'}). Fix or delete that record first.`);
|
|
150
|
+
}
|
|
151
|
+
// An overnight existing shift (clock_out before clock_in) occupies to end of day.
|
|
152
|
+
if (end < start)
|
|
153
|
+
end = END_OF_DAY;
|
|
154
|
+
const label = `${shift.clock_in}-${shift.clock_out ?? 'open'}`;
|
|
155
|
+
return [start, end, label];
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Build the plan. Skip reasons are evaluated in the documented order and the
|
|
160
|
+
* first match wins. Explicit days skip the weekday, holiday and workability
|
|
161
|
+
* rules on purpose: migration must be able to write a Saturday someone worked.
|
|
162
|
+
*/
|
|
163
|
+
export function buildBackfillPlan(request, facts) {
|
|
164
|
+
const requested = request.mode === 'range'
|
|
165
|
+
? request.dates.map(date => ({ date, segments: request.segments }))
|
|
166
|
+
: request.days;
|
|
167
|
+
if (request.mode === 'range') {
|
|
168
|
+
validateSegments(request.segments);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
const seen = new Set();
|
|
172
|
+
for (const day of request.days) {
|
|
173
|
+
assertDate(day.date);
|
|
174
|
+
if (seen.has(day.date)) {
|
|
175
|
+
throw new Error(`Date ${day.date} appears more than once in days`);
|
|
176
|
+
}
|
|
177
|
+
seen.add(day.date);
|
|
178
|
+
validateSegments(day.segments);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const writes = [];
|
|
182
|
+
const skippedDays = [];
|
|
183
|
+
const skippedSegments = [];
|
|
184
|
+
const writtenDates = new Set();
|
|
185
|
+
let minutes = 0;
|
|
186
|
+
for (const { date, segments } of requested) {
|
|
187
|
+
const skip = daySkipReason(date, request, facts);
|
|
188
|
+
if (skip) {
|
|
189
|
+
skippedDays.push(skip);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const existing = existingIntervals(facts.shifts, date);
|
|
193
|
+
for (const segment of segments) {
|
|
194
|
+
const interval = [
|
|
195
|
+
parseHHMM(segment.clock_in),
|
|
196
|
+
parseHHMM(segment.clock_out),
|
|
197
|
+
];
|
|
198
|
+
const clash = existing.find(([start, end]) => intervalsOverlap(interval, [start, end]));
|
|
199
|
+
if (clash) {
|
|
200
|
+
skippedSegments.push({
|
|
201
|
+
date,
|
|
202
|
+
...segment,
|
|
203
|
+
reason: 'overlaps_existing',
|
|
204
|
+
detail: `overlaps existing ${clash[2]}`,
|
|
205
|
+
});
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
writes.push({ date, ...segment });
|
|
209
|
+
writtenDates.add(date);
|
|
210
|
+
minutes += interval[1] - interval[0];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
writes,
|
|
215
|
+
skippedDays,
|
|
216
|
+
skippedSegments,
|
|
217
|
+
totals: { days: writtenDates.size, records: writes.length, minutes },
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Stable digest of who and what a plan writes, including the note stored on
|
|
222
|
+
* every record; the confirmation token is bound to it.
|
|
223
|
+
*/
|
|
224
|
+
export function planFingerprint(employeeId, writes, observations) {
|
|
225
|
+
const canonical = writes
|
|
226
|
+
.map(w => `${w.date} ${w.clock_in} ${w.clock_out}`)
|
|
227
|
+
.sort()
|
|
228
|
+
.join('\n');
|
|
229
|
+
return createHash('sha256')
|
|
230
|
+
.update(`${employeeId}\n${observations ?? ''}\n${canonical}`)
|
|
231
|
+
.digest('hex');
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Workdays up to today where the contract expects more than was tracked and no
|
|
235
|
+
* full-day leave applies. Future dates are left out, since log_range would
|
|
236
|
+
* refuse them anyway.
|
|
237
|
+
*/
|
|
238
|
+
export function computeGaps(facts) {
|
|
239
|
+
const gaps = [];
|
|
240
|
+
for (const [date, day] of [...facts.days.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
241
|
+
if (date > facts.today)
|
|
242
|
+
continue;
|
|
243
|
+
if (day.day_type !== 'workday')
|
|
244
|
+
continue;
|
|
245
|
+
if (day.expected_minutes <= day.tracked_minutes)
|
|
246
|
+
continue;
|
|
247
|
+
const cover = facts.leaves.get(date);
|
|
248
|
+
if (cover === 'full')
|
|
249
|
+
continue;
|
|
250
|
+
gaps.push({
|
|
251
|
+
date,
|
|
252
|
+
expected_minutes: day.expected_minutes,
|
|
253
|
+
tracked_minutes: day.tracked_minutes,
|
|
254
|
+
missing_minutes: day.expected_minutes - day.tracked_minutes,
|
|
255
|
+
half_day_leave: cover ?? null,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return gaps;
|
|
259
|
+
}
|
|
260
|
+
/** 720 -> "12h", 750 -> "12h30" */
|
|
261
|
+
export function hours(minutes) {
|
|
262
|
+
const whole = Math.floor(minutes / 60);
|
|
263
|
+
const rest = minutes % 60;
|
|
264
|
+
return rest === 0 ? `${whole}h` : `${whole}h${String(rest).padStart(2, '0')}`;
|
|
265
|
+
}
|
|
266
|
+
function countBy(items) {
|
|
267
|
+
const counts = new Map();
|
|
268
|
+
for (const item of items)
|
|
269
|
+
counts.set(item.reason, (counts.get(item.reason) ?? 0) + 1);
|
|
270
|
+
return counts;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Render the plan the way the confirmation step shows it. It always names the
|
|
274
|
+
* person, because the company-wide key makes writing someone else's hours the
|
|
275
|
+
* mistake worth catching.
|
|
276
|
+
*/
|
|
277
|
+
export function formatPlanPreview(plan, employee, range, request, observations) {
|
|
278
|
+
const lines = [];
|
|
279
|
+
lines.push(`Plan for ${employee.name} (${employee.id})`);
|
|
280
|
+
lines.push(`${range.start} .. ${range.end}`);
|
|
281
|
+
lines.push('');
|
|
282
|
+
lines.push(` ${plan.totals.days} days to write, ${plan.totals.records} shift records, ${hours(plan.totals.minutes)}`);
|
|
283
|
+
if (request.mode === 'range') {
|
|
284
|
+
lines.push(` ${request.segments.map(s => `${s.clock_in}-${s.clock_out}`).join(' and ')}`);
|
|
285
|
+
}
|
|
286
|
+
if (observations) {
|
|
287
|
+
lines.push(` Note on every record: "${observations}"`);
|
|
288
|
+
}
|
|
289
|
+
if (plan.skippedDays.length > 0) {
|
|
290
|
+
lines.push('');
|
|
291
|
+
lines.push(` Skipping ${plan.skippedDays.length} days:`);
|
|
292
|
+
const byReason = countBy(plan.skippedDays);
|
|
293
|
+
const labels = {
|
|
294
|
+
future_date: 'in the future',
|
|
295
|
+
weekend: 'weekend',
|
|
296
|
+
bank_holiday: 'bank holiday',
|
|
297
|
+
not_workable: 'not workable under the contract',
|
|
298
|
+
on_leave: 'approved leave',
|
|
299
|
+
half_day_leave: 'half-day leave, write it with log_days if the other half was worked',
|
|
300
|
+
};
|
|
301
|
+
for (const [reason, count] of byReason) {
|
|
302
|
+
const dates = plan.skippedDays.filter(d => d.reason === reason).map(d => d.date);
|
|
303
|
+
const suffix = reason === 'weekend' ? '' : ` (${dates.join(', ')})`;
|
|
304
|
+
lines.push(` ${count} ${labels[reason]}${suffix}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (plan.skippedSegments.length > 0) {
|
|
308
|
+
lines.push('');
|
|
309
|
+
lines.push(` Skipping ${plan.skippedSegments.length} segments that overlap existing shifts:`);
|
|
310
|
+
for (const segment of plan.skippedSegments) {
|
|
311
|
+
lines.push(` ${segment.date} ${segment.clock_in}-${segment.clock_out} ${segment.detail}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return lines.join('\n');
|
|
315
|
+
}
|
|
316
|
+
//# sourceMappingURL=planner.js.map
|