From 230dbb0e0ce7267d63cd440df900c56193736094 Mon Sep 17 00:00:00 2001 From: Patrick Schiller <39913537+patrickschiller@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:32:10 +0100 Subject: [PATCH 1/7] fix: edit and resubmit returned requests (#42) Reopen returned drafts with their values and revision reason. Add owner-only versioned editing/resubmission, shared validation, balance/overlap self-exclusion, transactional concurrency guards, employee names, synchronized API contracts and regression/manual acceptance coverage. Co-authored-by: patrickschiller <39913537+patrickschiller@users.noreply.github.com> Signed-off-by: Patrick Schiller <39913537+patrickschiller@users.noreply.github.com> --- .../src/api/request-revision.e2e.spec.ts | 452 ++++++++++++++++++ apps/api/openapi.json | 386 ++++++++++++++- .../app/accounts/vacation-balance.service.ts | 13 +- .../leave-allowances.service.ts | 16 +- .../src/app/requests/requests.controller.ts | 45 +- apps/api/src/app/requests/requests.dto.ts | 121 +++-- apps/api/src/app/requests/requests.service.ts | 378 +++++++++------ apps/web/src/api/client.ts | 19 + apps/web/src/api/generated.ts | 199 +++++++- apps/web/src/app/i18n.tsx | 18 + .../web/src/routes/AdminRequestsPage.spec.tsx | 52 ++ apps/web/src/routes/AdminRequestsPage.tsx | 4 +- apps/web/src/routes/RequestsPage.spec.tsx | 138 +++++- apps/web/src/routes/RequestsPage.tsx | 194 +++++++- docs/ISSUE_FIXES_MANUAL_TEST.de.md | 209 ++++++++ 15 files changed, 1994 insertions(+), 250 deletions(-) create mode 100644 apps/api-e2e/src/api/request-revision.e2e.spec.ts create mode 100644 apps/web/src/routes/AdminRequestsPage.spec.tsx create mode 100644 docs/ISSUE_FIXES_MANUAL_TEST.de.md diff --git a/apps/api-e2e/src/api/request-revision.e2e.spec.ts b/apps/api-e2e/src/api/request-revision.e2e.spec.ts new file mode 100644 index 0000000..177ea93 --- /dev/null +++ b/apps/api-e2e/src/api/request-revision.e2e.spec.ts @@ -0,0 +1,452 @@ +import { EmployeesService } from '../../../api/src/app/employees/employees.service'; +import { + createTestApp, + login, + seedEmployee, + seedLeaveAllowance, + type TestContext, +} from '../support/test-app'; + +const YEAR = 2026; +const FROM = '2026-05-04T00:00:00.000Z'; +const TO = '2026-05-08T00:00:00.000Z'; + +describe('Returned requests — owner revision and resubmission (#42)', () => { + let ctx: TestContext; + let owner: { id: string; token: string }; + let manager: { id: string; token: string }; + let other: { id: string; token: string }; + let hr: { id: string; token: string }; + + beforeAll(async () => { + ctx = await createTestApp(); + }); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.reset(); + async function person( + personalNo: string, + role: 'Employee' | 'Manager' | 'HRAdmin', + managerId?: string, + ) { + const e = await seedEmployee(ctx.prisma, { + personalNo, + firstName: personalNo, + lastName: 'Example', + email: `${personalNo}@test.local`, + role, + managerId, + }); + return { id: e.id, token: await login(ctx.http, e.email) }; + } + manager = await person('manager', 'Manager'); + owner = await person('owner', 'Employee', manager.id); + other = await person('other', 'Employee', manager.id); + hr = await person('hr', 'HRAdmin'); + await seedLeaveAllowance(ctx.prisma, owner.id, YEAR, 5); + }); + + async function create(type = 'Vacation', substituteId?: string) { + const r = await ctx.http + .post(type === 'Vacation' ? '/api/requests/vacation' : '/api/requests') + .set('Authorization', `Bearer ${owner.token}`) + .send({ + employeeId: owner.id, + type, + from: FROM, + to: TO, + substituteId, + reason: 'Original', + }) + .expect(201); + return r.body; + } + async function returned(type = 'Vacation') { + const r = await create(type); + const response = await ctx.http + .post(`/api/requests/${r.id}/return`) + .set('Authorization', `Bearer ${manager.token}`) + .send({ actorId: manager.id, note: 'Bitte kürzen' }) + .expect(201); + return response.body; + } + function values(r: { updatedAt: string }, extra = {}) { + return { + from: FROM, + to: TO, + reason: 'Korrigiert', + expectedUpdatedAt: r.updatedAt, + ...extra, + }; + } + function patch(id: string, token = owner.token) { + return ctx.http + .patch(`/api/requests/${id}`) + .set('Authorization', `Bearer ${token}`); + } + function submit(id: string, token = owner.token) { + return ctx.http + .post(`/api/requests/${id}/resubmit`) + .set('Authorization', `Bearer ${token}`); + } + + it('manager return → save → resubmit retains ID/history, excludes self, recalculates halves and resets assignments', async () => { + const r = await returned(); + expect(r).toMatchObject({ + workflowState: 'Draft', + status: 'Submitted', + employeeName: 'owner Example', + decisionNote: 'Bitte kürzen', + }); + const saved = await patch(r.id) + .send(values(r, { halfDayStart: true })) + .expect(200); + expect(saved.body).toMatchObject({ + id: r.id, + workflowState: 'Draft', + currentApproverId: null, + approverId: null, + calculatedDays: 4.5, + }); + expect(saved.body.updatedAt).not.toBe(r.updatedAt); + // Old decision data cannot leak into the new approval cycle. + await ctx.prisma.request.update({ + where: { id: r.id }, + data: { + approverId: manager.id, + hrConfirmedAt: new Date(), + decidedAt: new Date(), + cancelledAt: new Date(), + substituteAcceptedAt: new Date(), + }, + }); + const fresh = await ctx.http + .get(`/api/requests/${r.id}`) + .set('Authorization', `Bearer ${owner.token}`) + .expect(200); + const result = await submit(r.id) + .send(values(fresh.body, { halfDayStart: true })) + .expect(201); + expect(result.body).toMatchObject({ + id: r.id, + workflowState: 'PendingManager', + currentApproverId: manager.id, + calculatedDays: 4.5, + approverId: null, + substituteAcceptedAt: null, + hrConfirmedAt: null, + cancelledAt: null, + decidedAt: null, + decisionNote: null, + }); + expect(await ctx.prisma.request.count()).toBe(1); + const events = await ctx.prisma.requestEvent.findMany({ + where: { requestId: r.id }, + orderBy: { occurredAt: 'asc' }, + }); + expect(events.map((e) => e.kind)).toEqual([ + 'Submitted', + 'Returned', + 'Resubmitted', + ]); + expect(events[1].note).toBe('Bitte kürzen'); + const listed = await ctx.http + .get(`/api/requests?currentApproverId=${manager.id}`) + .set('Authorization', `Bearer ${manager.token}`) + .expect(200); + expect(listed.body[0].employeeName).toBe('owner Example'); + }); + + it('initializes a missing allowance inside the request transaction without blocking itself', async () => { + await ctx.prisma.employeeLeaveAllowance.deleteMany(); + const r = await create(); + expect(r.calculatedDays).toBe(5); + expect(await ctx.prisma.employeeLeaveAllowance.count()).toBe(1); + }); + + it('substitute decline → edit → resubmit restarts substitute approval and preserves the decline reason', async () => { + const r = await create('Vacation', other.id); + const declined = await ctx.http + .post(`/api/requests/${r.id}/substitute/decline`) + .set('Authorization', `Bearer ${other.token}`) + .send({ actorId: other.id, note: 'Bin selbst weg' }) + .expect(201); + expect(declined.body).toMatchObject({ + workflowState: 'Draft', + decisionNote: 'Bin selbst weg', + }); + const result = await submit(r.id) + .send(values(declined.body, { substituteId: other.id, halfDayEnd: true })) + .expect(201); + expect(result.body).toMatchObject({ + id: r.id, + workflowState: 'PendingSubstitute', + currentApproverId: null, + substituteId: other.id, + substituteAcceptedAt: null, + calculatedDays: 4.5, + }); + await ctx.http + .post(`/api/requests/${r.id}/substitute/accept`) + .set('Authorization', `Bearer ${other.token}`) + .send({ actorId: other.id }) + .expect(201); + const events = await ctx.prisma.requestEvent.findMany({ + where: { requestId: r.id }, + orderBy: { occurredAt: 'asc' }, + }); + expect(events.map((e) => e.kind)).toEqual([ + 'Submitted', + 'SubstituteDeclined', + 'Resubmitted', + 'SubstituteAccepted', + ]); + }); + + it('forbids other employees, manager and HR; ignores spoofed owner identity', async () => { + const r = await returned(); + for (const actor of [other, manager, hr]) { + await patch(r.id, actor.token) + .send(values(r, { employeeId: owner.id })) + .expect(403); + await submit(r.id, actor.token) + .send(values(r, { employeeId: owner.id })) + .expect(403); + } + await ctx.http.patch(`/api/requests/${r.id}`).send(values(r)).expect(401); + expect(await ctx.prisma.requestEvent.count()).toBe(2); + }); + + it.each([ + 'Submitted', + 'PendingSubstitute', + 'PendingManager', + 'PendingHr', + 'Approved', + 'Rejected', + 'Cancelled', + ] as const)( + 'refuses editing/resubmitting %s even with legacy status Submitted', + async (state) => { + const r = await returned(); + const current = await ctx.prisma.request.update({ + where: { id: r.id }, + data: { workflowState: state, status: 'Submitted' }, + }); + await patch(r.id) + .send(values({ updatedAt: current.updatedAt.toISOString() })) + .expect(409); + await submit(r.id) + .send(values({ updatedAt: current.updatedAt.toISOString() })) + .expect(409); + }, + ); + + it('rechecks vacation balance on both save and resubmit; does not spend the allowance twice', async () => { + const r = await returned(); + await seedLeaveAllowance(ctx.prisma, owner.id, YEAR, 0.5); + await patch(r.id).send(values(r)).expect(409); + await submit(r.id).send(values(r)).expect(409); + const result = await submit(r.id) + .send(values(r, { to: FROM, halfDayStart: true })) + .expect(201); + expect(result.body.calculatedDays).toBe(0.5); + }); + + it('excludes only its own overlap reservation, not another draft/active request', async () => { + const r = await returned(); + await ctx.prisma.request.create({ + data: { + employeeId: owner.id, + type: 'HomeOffice', + from: new Date(FROM), + to: new Date(FROM), + workflowState: 'Draft', + status: 'Submitted', + }, + }); + await patch(r.id).send(values(r)).expect(409); + await submit(r.id).send(values(r)).expect(409); + expect( + (await ctx.prisma.request.findUniqueOrThrow({ where: { id: r.id } })) + .workflowState, + ).toBe('Draft'); + }); + + it('revalidates dates, zero workdays, substitute and required version', async () => { + const r = await returned(); + for (const extra of [ + { from: TO, to: FROM }, + { to: '2027-01-01T00:00:00.000Z' }, + { from: '2026-05-09T00:00:00.000Z', to: '2026-05-10T00:00:00.000Z' }, + { substituteId: owner.id }, + { substituteId: '00000000-0000-0000-0000-000000000001' }, + { from: 'invalid' }, + { expectedUpdatedAt: undefined }, + ]) { + await submit(r.id).send(values(r, extra)).expect(400); + } + await ctx.prisma.employee.update({ + where: { id: other.id }, + data: { isActive: false }, + }); + await submit(r.id) + .send(values(r, { substituteId: other.id })) + .expect(400); + }); + + it('preserves SpecialLeave attachments and immutable type/owner', async () => { + const r = await returned('SpecialLeave'); + const attachment = await ctx.prisma.requestAttachment.create({ + data: { + requestId: r.id, + fileName: 'synthetic.pdf', + mimeType: 'application/pdf', + sizeBytes: 10, + storageKey: 'synthetic-only', + uploadedById: owner.id, + }, + }); + const result = await submit(r.id) + .send(values(r, { type: 'Vacation', employeeId: other.id })) + .expect(201); + expect(result.body).toMatchObject({ + id: r.id, + type: 'SpecialLeave', + employeeId: owner.id, + }); + expect( + await ctx.prisma.requestAttachment.findUnique({ + where: { id: attachment.id }, + }), + ).toMatchObject({ requestId: r.id, storageKey: 'synthetic-only' }); + }); + + it('revalidates TimeAdjustment overlap and off-hours approval', async () => { + const r = await returned('TimeAdjustment'); + const from = '2026-05-04T04:00:00.000Z'; // before the frame in UTC/Berlin + const to = '2026-05-04T08:00:00.000Z'; + await ctx.prisma.timeEntry.create({ + data: { + employeeId: owner.id, + clockIn: new Date(from), + clockOut: new Date(to), + source: 'Manual', + status: 'Approved', + }, + }); + await submit(r.id).send(values(r, { from, to })).expect(409); + await ctx.prisma.timeEntry.deleteMany(); + await submit(r.id) + .send(values(r, { from, to: from })) + .expect(400); + const result = await submit(r.id).send(values(r, { from, to })).expect(201); + expect(result.body.requiresApproval).toBe(true); + }); + + it('rejects stale edits and simultaneous resubmissions with no duplicate event', async () => { + const r = await returned(); + const changed = await patch(r.id) + .send(values(r, { reason: 'First tab' })) + .expect(200); + await patch(r.id) + .send(values(r, { reason: 'Stale tab' })) + .expect(409); + await submit(r.id).send(values(r)).expect(409); + const results = await Promise.all([ + submit(r.id).send(values(changed.body)), + submit(r.id).send(values(changed.body)), + ]); + expect(results.map((res) => res.status).sort()).toEqual([201, 409]); + expect( + await ctx.prisma.requestEvent.count({ + where: { requestId: r.id, kind: 'Resubmitted' }, + }), + ).toBe(1); + }); + + it('does not let an in-flight decision overwrite a returned and resubmitted version (ABA)', async () => { + const r = await create(); + const employees = ctx.app.get(EmployeesService); + const original = employees.getById.bind(employees); + let release!: () => void; + let arrived!: () => void; + const waiting = new Promise((resolve) => { + arrived = resolve; + }); + const barrier = new Promise((resolve) => { + release = resolve; + }); + let delayOnce = true; + const spy = jest + .spyOn(employees, 'getById') + .mockImplementation(async (id) => { + const employee = await original(id); + if (id === manager.id && delayOnce) { + delayOnce = false; + arrived(); + await barrier; + } + return employee; + }); + const oldDecision = ctx.http + .post(`/api/requests/${r.id}/manager-approve`) + .set('Authorization', `Bearer ${manager.token}`) + .send({ actorId: manager.id }) + .then((res) => res); + try { + await waiting; + const revised = await ctx.http + .post(`/api/requests/${r.id}/return`) + .set('Authorization', `Bearer ${hr.token}`) + .send({ actorId: hr.id, note: 'Neue Angaben nötig' }) + .expect(201); + await submit(r.id).send(values(revised.body)).expect(201); + release(); + expect((await oldDecision).status).toBe(409); + expect( + (await ctx.prisma.request.findUniqueOrThrow({ where: { id: r.id } })) + .workflowState, + ).toBe('PendingManager'); + expect( + await ctx.prisma.requestEvent.count({ + where: { requestId: r.id, kind: 'ManagerApproved' }, + }), + ).toBe(0); + } finally { + release(); + spy.mockRestore(); + } + }); + + it('serializes competing drafts so parallel submissions cannot overspend vacation', async () => { + const a = await returned(); + const b = await ctx.prisma.request.create({ + data: { + employeeId: owner.id, + type: 'Vacation', + from: new Date('2026-05-11'), + to: new Date('2026-05-15'), + workflowState: 'Draft', + calculatedDays: 5, + }, + }); + const results = await Promise.all([ + submit(a.id).send(values(a)), + submit(b.id).send( + values( + { updatedAt: b.updatedAt.toISOString() }, + { from: b.from.toISOString(), to: b.to.toISOString() }, + ), + ), + ]); + expect(results.map((res) => res.status).sort()).toEqual([201, 409]); + expect( + await ctx.prisma.request.count({ + where: { workflowState: 'PendingManager' }, + }), + ).toBe(1); + }); +}); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 1a60945..960390a 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -3394,7 +3394,17 @@ ], "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RequestDto" + } + } + } + } } }, "security": [ @@ -3419,7 +3429,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3445,7 +3462,55 @@ ], "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["requests"] + }, + "patch": { + "operationId": "RequestsController_updateDraft", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDraftRequestDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3498,7 +3563,57 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["requests"] + } + }, + "/api/requests/{id}/resubmit": { + "post": { + "operationId": "RequestsController_resubmit", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDraftRequestDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3534,7 +3649,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3570,7 +3692,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3606,7 +3735,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3642,7 +3778,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3678,7 +3821,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3714,7 +3864,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3750,7 +3907,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3786,7 +3950,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3822,7 +3993,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -3858,7 +4036,14 @@ }, "responses": { "201": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestDto" + } + } + } } }, "security": [ @@ -7656,6 +7841,128 @@ }, "required": ["baseDays", "carryOverDays", "adjustmentDays"] }, + "RequestDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "employeeId": { + "type": "string", + "format": "uuid" + }, + "employeeName": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "type": { + "type": "string", + "enum": ["Vacation", "HomeOffice", "SpecialLeave", "TimeAdjustment"] + }, + "status": { + "type": "string" + }, + "workflowState": { + "type": "string" + }, + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + }, + "reason": { + "type": "string", + "nullable": true + }, + "requiresApproval": { + "type": "boolean" + }, + "approverId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "currentApproverId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "substituteId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "substituteAcceptedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "hrConfirmedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "cancelledAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "calculatedDays": { + "type": "number" + }, + "halfDayStart": { + "type": "boolean" + }, + "halfDayEnd": { + "type": "boolean" + }, + "decidedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "decisionNote": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "employeeId", + "employeeName", + "updatedAt", + "type", + "status", + "workflowState", + "from", + "to", + "reason", + "requiresApproval", + "approverId", + "currentApproverId", + "substituteId", + "substituteAcceptedAt", + "hrConfirmedAt", + "cancelledAt", + "calculatedDays", + "halfDayStart", + "halfDayEnd", + "decidedAt", + "decisionNote", + "createdAt" + ] + }, "CreateRequestDto": { "type": "object", "properties": { @@ -7678,7 +7985,7 @@ "example": "2026-08-07T00:00:00.000Z" }, "reason": { - "type": "object", + "type": "string", "nullable": true, "maxLength": 2000 } @@ -7701,12 +8008,12 @@ "format": "date-time" }, "substituteId": { - "type": "object", + "type": "string", "nullable": true, "format": "uuid" }, "reason": { - "type": "object", + "type": "string", "nullable": true, "maxLength": 2000 }, @@ -7723,6 +8030,45 @@ }, "required": ["employeeId", "from", "to"] }, + "UpdateDraftRequestDto": { + "type": "object", + "properties": { + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + }, + "substituteId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "reason": { + "type": "string", + "nullable": true, + "maxLength": 2000 + }, + "halfDayStart": { + "type": "boolean", + "default": false, + "description": "Take the first day as a half-day" + }, + "halfDayEnd": { + "type": "boolean", + "default": false, + "description": "Take the last day as a half-day" + }, + "expectedUpdatedAt": { + "type": "string", + "format": "date-time", + "description": "updatedAt from the version being edited; stale writes return 409" + } + }, + "required": ["from", "to", "expectedUpdatedAt"] + }, "TransitionDto": { "type": "object", "properties": { @@ -7731,7 +8077,7 @@ "format": "uuid" }, "note": { - "type": "object", + "type": "string", "nullable": true, "maxLength": 2000 } @@ -7746,7 +8092,7 @@ "format": "uuid" }, "note": { - "type": "object", + "type": "string", "nullable": true, "maxLength": 2000 }, @@ -7786,7 +8132,7 @@ } }, "note": { - "type": "object", + "type": "string", "nullable": true, "maxLength": 2000 }, diff --git a/apps/api/src/app/accounts/vacation-balance.service.ts b/apps/api/src/app/accounts/vacation-balance.service.ts index 61bc2b7..d50b4fb 100644 --- a/apps/api/src/app/accounts/vacation-balance.service.ts +++ b/apps/api/src/app/accounts/vacation-balance.service.ts @@ -1,3 +1,4 @@ +import type { Prisma } from '@prisma/client'; import { Injectable } from '@nestjs/common'; import { calculateWorkingDays } from 'shared'; import { LeaveAllowancesService } from '../leave-allowances/leave-allowances.service'; @@ -20,16 +21,22 @@ export class VacationBalanceService { private readonly schedules: WorkSchedulesService, ) {} - async compute(employeeId: string, year: number): Promise { - const allowance = await this.allowances.getOrDefault(employeeId, year); + async compute( + employeeId: string, + year: number, + excludeRequestId?: string, + db: Prisma.TransactionClient = this.prisma, + ): Promise { + const allowance = await this.allowances.getOrDefault(employeeId, year, db); const schedule = await this.schedules.resolveForEmployee(employeeId); const yearStart = new Date(Date.UTC(year, 0, 1)); const yearEnd = new Date(Date.UTC(year, 11, 31, 23, 59, 59)); - const requests = await this.prisma.request.findMany({ + const requests = await db.request.findMany({ where: { employeeId, type: 'Vacation', + ...(excludeRequestId ? { id: { not: excludeRequestId } } : {}), from: { lte: yearEnd }, to: { gte: yearStart }, }, diff --git a/apps/api/src/app/leave-allowances/leave-allowances.service.ts b/apps/api/src/app/leave-allowances/leave-allowances.service.ts index 76c1f78..4c4046c 100644 --- a/apps/api/src/app/leave-allowances/leave-allowances.service.ts +++ b/apps/api/src/app/leave-allowances/leave-allowances.service.ts @@ -1,5 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import type { EmployeeLeaveAllowance } from '@prisma/client'; +import type { EmployeeLeaveAllowance, Prisma } from '@prisma/client'; import type { JwtUser } from '../auth/jwt.strategy'; import { EmployeesService } from '../employees/employees.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -29,13 +29,14 @@ export class LeaveAllowancesService { async getOrDefault( employeeId: string, year: number, + db: Prisma.TransactionClient = this.prisma, ): Promise { - const existing = await this.prisma.employeeLeaveAllowance.findUnique({ + const existing = await db.employeeLeaveAllowance.findUnique({ where: { employeeId_year: { employeeId, year } }, }); if (existing) return existing; - const employee = await this.assertEmployee(employeeId); - return this.prisma.employeeLeaveAllowance.create({ + const employee = await this.assertEmployee(employeeId, db); + return db.employeeLeaveAllowance.create({ data: { employeeId, year, @@ -113,8 +114,11 @@ export class LeaveAllowancesService { return { scanned: candidates.length, expired }; } - private async assertEmployee(employeeId: string) { - const employee = await this.prisma.employee.findUnique({ + private async assertEmployee( + employeeId: string, + db: Prisma.TransactionClient = this.prisma, + ) { + const employee = await db.employee.findUnique({ where: { id: employeeId }, }); if (!employee) diff --git a/apps/api/src/app/requests/requests.controller.ts b/apps/api/src/app/requests/requests.controller.ts index ab41a63..40c6910 100644 --- a/apps/api/src/app/requests/requests.controller.ts +++ b/apps/api/src/app/requests/requests.controller.ts @@ -3,12 +3,18 @@ import { Controller, Get, Param, + Patch, ParseUUIDPipe, Post, Query, UseGuards, } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiCreatedResponse, + ApiOkResponse, + ApiTags, +} from '@nestjs/swagger'; import { CurrentUser } from '../auth/current-user.decorator'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import type { JwtUser } from '../auth/jwt.strategy'; @@ -20,9 +26,10 @@ import { CreateVacationDto, ManagerApproveDto, TransitionDto, + UpdateDraftRequestDto, TransitionWithRequiredNoteDto, type BulkResult, - type RequestDto, + RequestDto, type RequestEventDto, } from './requests.dto'; @@ -34,6 +41,7 @@ export class RequestsController { constructor(private readonly service: RequestsService) {} @Get() + @ApiOkResponse({ type: RequestDto, isArray: true }) list( @CurrentUser() user: JwtUser, @Query('employeeId') employeeId?: string, @@ -57,6 +65,7 @@ export class RequestsController { } @Get(':id') + @ApiOkResponse({ type: RequestDto }) get( @Param('id', new ParseUUIDPipe()) id: string, @CurrentUser() user: JwtUser, @@ -73,6 +82,7 @@ export class RequestsController { } @Post() + @ApiCreatedResponse({ type: RequestDto }) create( @Body() dto: CreateRequestDto, @CurrentUser() user: JwtUser, @@ -81,6 +91,7 @@ export class RequestsController { } @Post('vacation') + @ApiCreatedResponse({ type: RequestDto }) createVacation( @Body() dto: CreateVacationDto, @CurrentUser() user: JwtUser, @@ -88,9 +99,30 @@ export class RequestsController { return this.service.createVacation({ ...dto, employeeId: user.id }); } + @Patch(':id') + @ApiOkResponse({ type: RequestDto }) + updateDraft( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: UpdateDraftRequestDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.updateDraft(id, user.id, dto, false); + } + + @Post(':id/resubmit') + @ApiCreatedResponse({ type: RequestDto }) + resubmit( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: UpdateDraftRequestDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.updateDraft(id, user.id, dto, true); + } + // ----- Generic approve / reject (legacy convenience) ----- @Post(':id/approve') + @ApiCreatedResponse({ type: RequestDto }) approve( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionDto, @@ -100,6 +132,7 @@ export class RequestsController { } @Post(':id/reject') + @ApiCreatedResponse({ type: RequestDto }) reject( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionDto, @@ -111,6 +144,7 @@ export class RequestsController { // ----- Vacation workflow ----- @Post(':id/manager-approve') + @ApiCreatedResponse({ type: RequestDto }) managerApprove( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: ManagerApproveDto, @@ -125,6 +159,7 @@ export class RequestsController { } @Post(':id/manager-reject') + @ApiCreatedResponse({ type: RequestDto }) managerReject( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionDto, @@ -134,6 +169,7 @@ export class RequestsController { } @Post(':id/hr-confirm') + @ApiCreatedResponse({ type: RequestDto }) hrConfirm( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionDto, @@ -143,6 +179,7 @@ export class RequestsController { } @Post(':id/hr-reject') + @ApiCreatedResponse({ type: RequestDto }) hrReject( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionWithRequiredNoteDto, @@ -152,6 +189,7 @@ export class RequestsController { } @Post(':id/substitute/accept') + @ApiCreatedResponse({ type: RequestDto }) substituteAccept( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionDto, @@ -161,6 +199,7 @@ export class RequestsController { } @Post(':id/substitute/decline') + @ApiCreatedResponse({ type: RequestDto }) substituteDecline( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionWithRequiredNoteDto, @@ -170,6 +209,7 @@ export class RequestsController { } @Post(':id/return') + @ApiCreatedResponse({ type: RequestDto }) returnForRevision( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionWithRequiredNoteDto, @@ -179,6 +219,7 @@ export class RequestsController { } @Post(':id/cancel') + @ApiCreatedResponse({ type: RequestDto }) cancel( @Param('id', new ParseUUIDPipe()) id: string, @Body() body: TransitionDto, diff --git a/apps/api/src/app/requests/requests.dto.ts b/apps/api/src/app/requests/requests.dto.ts index 40e78f5..546ff70 100644 --- a/apps/api/src/app/requests/requests.dto.ts +++ b/apps/api/src/app/requests/requests.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger'; import { ArrayNotEmpty, IsArray, @@ -12,7 +12,12 @@ import { } from 'class-validator'; import type { Request } from '@prisma/client'; -const REQUEST_TYPES = ['Vacation', 'HomeOffice', 'SpecialLeave', 'TimeAdjustment'] as const; +const REQUEST_TYPES = [ + 'Vacation', + 'HomeOffice', + 'SpecialLeave', + 'TimeAdjustment', +] as const; export class CreateRequestDto { @ApiProperty({ format: 'uuid' }) @@ -31,7 +36,7 @@ export class CreateRequestDto { @IsISO8601() to!: string; - @ApiPropertyOptional({ nullable: true, maxLength: 2000 }) + @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 }) @IsOptional() @IsString() @MaxLength(2000) @@ -51,34 +56,53 @@ export class CreateVacationDto { @IsISO8601() to!: string; - @ApiPropertyOptional({ nullable: true, format: 'uuid' }) + @ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' }) @IsOptional() @IsUUID() substituteId?: string | null; - @ApiPropertyOptional({ nullable: true, maxLength: 2000 }) + @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 }) @IsOptional() @IsString() @MaxLength(2000) reason?: string | null; - @ApiPropertyOptional({ default: false, description: 'Take the first day as a half-day' }) + @ApiPropertyOptional({ + default: false, + description: 'Take the first day as a half-day', + }) @IsOptional() @IsBoolean() halfDayStart?: boolean; - @ApiPropertyOptional({ default: false, description: 'Take the last day as a half-day' }) + @ApiPropertyOptional({ + default: false, + description: 'Take the last day as a half-day', + }) @IsOptional() @IsBoolean() halfDayEnd?: boolean; } +/** Complete editable values. Request type and owner are immutable. */ +export class UpdateDraftRequestDto extends OmitType(CreateVacationDto, [ + 'employeeId', +] as const) { + @ApiProperty({ + format: 'date-time', + description: + 'updatedAt from the version being edited; stale writes return 409', + }) + @IsISO8601() + expectedUpdatedAt!: string; +} + export class TransitionDto { @ApiProperty({ format: 'uuid' }) @IsUUID() actorId!: string; - @ApiPropertyOptional({ nullable: true, maxLength: 2000 }) + @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 }) @IsOptional() @IsString() @MaxLength(2000) @@ -114,7 +138,7 @@ export class BulkApproveDto { @IsUUID('all', { each: true }) ids!: string[]; - @ApiPropertyOptional({ nullable: true, maxLength: 2000 }) + @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 }) @IsOptional() @IsString() @MaxLength(2000) @@ -151,34 +175,63 @@ export interface BulkResult { error?: string; } -export interface RequestDto { - id: string; - employeeId: string; - type: string; - status: string; - workflowState: string; - from: string; - to: string; - reason: string | null; - requiresApproval: boolean; - approverId: string | null; - currentApproverId: string | null; - substituteId: string | null; - substituteAcceptedAt: string | null; - hrConfirmedAt: string | null; - cancelledAt: string | null; - calculatedDays: number; - halfDayStart: boolean; - halfDayEnd: boolean; - decidedAt: string | null; - decisionNote: string | null; - createdAt: string; +export class RequestDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + @ApiProperty({ format: 'uuid' }) + employeeId!: string; + @ApiProperty() + employeeName!: string; + @ApiProperty({ format: 'date-time' }) + updatedAt!: string; + @ApiProperty({ enum: REQUEST_TYPES }) + type!: string; + @ApiProperty() + status!: string; + @ApiProperty() + workflowState!: string; + @ApiProperty({ format: 'date-time' }) + from!: string; + @ApiProperty({ format: 'date-time' }) + to!: string; + @ApiProperty({ type: String, nullable: true }) + reason!: string | null; + @ApiProperty() + requiresApproval!: boolean; + @ApiProperty({ type: String, nullable: true, format: 'uuid' }) + approverId!: string | null; + @ApiProperty({ type: String, nullable: true, format: 'uuid' }) + currentApproverId!: string | null; + @ApiProperty({ type: String, nullable: true, format: 'uuid' }) + substituteId!: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + substituteAcceptedAt!: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + hrConfirmedAt!: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + cancelledAt!: string | null; + @ApiProperty() + calculatedDays!: number; + @ApiProperty() + halfDayStart!: boolean; + @ApiProperty() + halfDayEnd!: boolean; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + decidedAt!: string | null; + @ApiProperty({ type: String, nullable: true }) + decisionNote!: string | null; + @ApiProperty({ format: 'date-time' }) + createdAt!: string; } -export function toRequestDto(r: Request): RequestDto { +export function toRequestDto( + r: Request & { employee: { firstName: string; lastName: string } }, +): RequestDto { return { id: r.id, employeeId: r.employeeId, + employeeName: `${r.employee.firstName} ${r.employee.lastName}`, + updatedAt: r.updatedAt.toISOString(), type: r.type, status: r.status, workflowState: r.workflowState, @@ -189,7 +242,9 @@ export function toRequestDto(r: Request): RequestDto { approverId: r.approverId, currentApproverId: r.currentApproverId, substituteId: r.substituteId, - substituteAcceptedAt: r.substituteAcceptedAt ? r.substituteAcceptedAt.toISOString() : null, + substituteAcceptedAt: r.substituteAcceptedAt + ? r.substituteAcceptedAt.toISOString() + : null, hrConfirmedAt: r.hrConfirmedAt ? r.hrConfirmedAt.toISOString() : null, cancelledAt: r.cancelledAt ? r.cancelledAt.toISOString() : null, calculatedDays: Number(r.calculatedDays), diff --git a/apps/api/src/app/requests/requests.service.ts b/apps/api/src/app/requests/requests.service.ts index 95e562c..2a19d43 100644 --- a/apps/api/src/app/requests/requests.service.ts +++ b/apps/api/src/app/requests/requests.service.ts @@ -10,7 +10,6 @@ import type { Request, RequestEventKind, RequestType, - WorkflowState, } from '@prisma/client'; import { calculateVacationDays, @@ -32,6 +31,7 @@ import { type CreateVacationDto, type RequestDto, type RequestEventDto, + type UpdateDraftRequestDto, } from './requests.dto'; export interface ListRequestsFilter { @@ -43,6 +43,13 @@ export interface ListRequestsFilter { substituteId?: string; } +const employeeInclude = { + employee: { select: { firstName: true, lastName: true } }, +} as const; +type RequestWithEmployee = Prisma.RequestGetPayload<{ + include: typeof employeeInclude; +}>; + @Injectable() export class RequestsService { constructor( @@ -72,6 +79,7 @@ export class RequestsService { where: { AND: [where, await this.visibilityWhere(actorId)] }, orderBy: { createdAt: 'desc' }, take: 200, + include: employeeInclude, }); return rows.map(toRequestDto); } @@ -99,143 +107,234 @@ export class RequestsService { })); } - // ----- Generic create (HomeOffice, SpecialLeave, TimeAdjustment, Vacation-as-fallback) ----- - + // Creation and revision deliberately share validation. Serializing per employee + // keeps simultaneous resubmissions/creations from spending the same balance or + // passing the same overlap check. No request is deleted/recreated on revision. async create(dto: CreateRequestDto): Promise { - if (dto.type === 'Vacation') { - // Vacation has its own validated path with leave balance check. - return this.createVacation({ - employeeId: dto.employeeId, - from: dto.from, - to: dto.to, - substituteId: null, - reason: dto.reason ?? null, - }); - } - const employee = await this.employees.getById(dto.employeeId); - const from = new Date(dto.from); - const to = new Date(dto.to); - if (to.getTime() < from.getTime()) { - throw new BadRequestException('"to" must be on/after "from"'); - } - await this.assertNoActiveOverlap(employee.id, from, to); - const schedule = await this.schedules.resolveForEmployee(employee.id); - let requires = false; - if (dto.type === 'TimeAdjustment') { - if (to.getTime() <= from.getTime()) { - throw new BadRequestException( - 'TimeAdjustment "to" must be after "from"', - ); - } - await this.assertNoTimeEntryOverlap(employee.id, from, to); - requires = requiresSpecialApproval(from, to, schedule.frame); - } - const calculatedDays = calculateWorkingDays(from, to, { - workingDays: schedule.workingDays, - holidayProvider: schedule.holidayProvider, - }); + return this.createValidated(dto.type, dto); + } + + async createVacation(dto: CreateVacationDto): Promise { + return this.createValidated('Vacation', dto); + } + + private async lockEmployee( + tx: Prisma.TransactionClient, + employeeId: string, + ): Promise { + await tx.$queryRaw`SELECT "id" FROM "Employee" WHERE "id" = ${employeeId}::uuid FOR NO KEY UPDATE`; + } + + private async createValidated( + type: RequestType, + dto: CreateVacationDto, + ): Promise { const created = await this.prisma.$transaction(async (tx) => { + await this.lockEmployee(tx, dto.employeeId); + const values = await this.validateValues(type, dto, tx); const request = await tx.request.create({ data: { - employeeId: employee.id, - type: dto.type as RequestType, + ...values, + employeeId: dto.employeeId, + type, status: 'Submitted', - workflowState: 'PendingManager', - from, - to, - reason: dto.reason ?? null, - requiresApproval: requires, - calculatedDays, - currentApproverId: employee.managerId, + workflowState: values.substituteId + ? 'PendingSubstitute' + : 'PendingManager', }, + include: employeeInclude, }); await tx.requestEvent.create({ data: { requestId: request.id, kind: 'Submitted', - actorId: employee.id, + actorId: dto.employeeId, }, }); return request; }); - this.notifications.notifyTransitioned(created, 'Submitted', employee.id); + this.notifications.notifyTransitioned(created, 'Submitted', dto.employeeId); return toRequestDto(created); } - // ----- Vacation creation with balance check ----- + async updateDraft( + id: string, + actorId: string, + dto: UpdateDraftRequestDto, + resubmit: boolean, + ): Promise { + const updated = await this.prisma.$transaction(async (tx) => { + await this.lockEmployee(tx, actorId); + const request = await tx.request.findUnique({ where: { id } }); + if (!request) throw new NotFoundException(`Request ${id} not found`); + if (request.employeeId !== actorId) { + throw new ForbiddenException('Only the requester may edit or resubmit'); + } + if ( + request.workflowState !== 'Draft' || + request.updatedAt.toISOString() !== + new Date(dto.expectedUpdatedAt).toISOString() + ) { + throw new ConflictException( + 'Request changed or is no longer a draft; reload before editing', + ); + } + const values = await this.validateValues( + request.type, + { ...dto, employeeId: actorId }, + tx, + id, + ); + const data: Prisma.RequestUncheckedUpdateInput = { + ...values, + // A saved draft is not assigned until it is actually submitted. + currentApproverId: resubmit ? values.currentApproverId : null, + workflowState: resubmit + ? nextState('Draft', 'submit', { + hasSubstitute: !!values.substituteId, + }) + : 'Draft', + status: 'Submitted', // legacy display status; workflowState gates writes + approverId: null, + substituteAcceptedAt: null, + hrConfirmedAt: null, + cancelledAt: null, + decidedAt: null, + // Keep the revision reason visible until submission; history always keeps it. + decisionNote: resubmit ? null : request.decisionNote, + }; + const saved = await this.updateVersion(tx, request, data); + if (resubmit) { + await tx.requestEvent.create({ + data: { requestId: id, kind: 'Resubmitted', actorId }, + }); + } + return saved; + }); + if (resubmit) + this.notifications.notifyTransitioned(updated, 'Resubmitted', actorId); + return toRequestDto(updated); + } - async createVacation(dto: CreateVacationDto): Promise { + private async validateValues( + type: RequestType, + dto: CreateVacationDto, + tx: Prisma.TransactionClient, + excludeRequestId?: string, + ) { const employee = await this.employees.getById(dto.employeeId); const from = new Date(dto.from); const to = new Date(dto.to); - if (to.getTime() < from.getTime()) { - throw new BadRequestException('"to" must be on/after "from"'); + if ( + !Number.isFinite(from.getTime()) || + !Number.isFinite(to.getTime()) || + to < from + ) { + throw new BadRequestException('"to" must be on/after a valid "from"'); } - if (from.getUTCFullYear() !== to.getUTCFullYear()) { + const vacation = type === 'Vacation'; + if (vacation && from.getUTCFullYear() !== to.getUTCFullYear()) { throw new BadRequestException( 'Vacation must lie within a single calendar year — split into two requests', ); } - if (dto.substituteId && dto.substituteId === employee.id) { - throw new BadRequestException('Substitute must be a different employee'); + if (!vacation && (dto.substituteId || dto.halfDayStart || dto.halfDayEnd)) { + throw new BadRequestException( + 'Substitute and half-days are only supported for Vacation', + ); + } + const substituteId = vacation ? (dto.substituteId ?? null) : null; + if (substituteId) { + const substitute = await tx.employee.findUnique({ + where: { id: substituteId }, + }); + if (substituteId === employee.id || !substitute?.isActive) { + throw new BadRequestException( + 'Substitute must be a different active employee', + ); + } } const schedule = await this.schedules.resolveForEmployee(employee.id); - const halfDayStart = !!dto.halfDayStart; - const halfDayEnd = !!dto.halfDayEnd; - const calculatedDays = calculateVacationDays(from, to, { + const halfDayStart = vacation && !!dto.halfDayStart; + const halfDayEnd = vacation && !!dto.halfDayEnd; + const options = { workingDays: schedule.workingDays, holidayProvider: schedule.holidayProvider, halfDayStart, halfDayEnd, - }); - if (calculatedDays <= 0) { + }; + const calculatedDays = vacation + ? calculateVacationDays(from, to, options) + : calculateWorkingDays(from, to, options); + if (vacation && calculatedDays <= 0) throw new BadRequestException('Vacation range covers no working days'); - } - await this.assertNoActiveOverlap(employee.id, from, to); - - const balance = await this.vacationBalance.compute( + await this.assertNoActiveOverlap( employee.id, - from.getUTCFullYear(), + from, + to, + tx, + excludeRequestId, ); - if (balance.remainingDays < calculatedDays) { - throw new ConflictException( - `Not enough vacation days remaining (${balance.remainingDays} < ${calculatedDays})`, + let requiresApproval = false; + if (type === 'TimeAdjustment') { + if (to <= from) + throw new BadRequestException( + 'TimeAdjustment "to" must be after "from"', + ); + await this.assertNoTimeEntryOverlap(employee.id, from, to, tx); + requiresApproval = requiresSpecialApproval(from, to, schedule.frame); + } + if (vacation) { + const balance = await this.vacationBalance.compute( + employee.id, + from.getUTCFullYear(), + excludeRequestId, + tx, ); + if (balance.remainingDays < calculatedDays) { + throw new ConflictException( + `Not enough vacation days remaining (${balance.remainingDays} < ${calculatedDays})`, + ); + } } + return { + from, + to, + reason: dto.reason ?? null, + calculatedDays, + halfDayStart, + halfDayEnd, + substituteId, + requiresApproval, + currentApproverId: substituteId ? null : employee.managerId, + }; + } - const hasSubstitute = !!dto.substituteId; - const initialState: WorkflowState = hasSubstitute - ? 'PendingSubstitute' - : 'PendingManager'; - - const created = await this.prisma.$transaction(async (tx) => { - const request = await tx.request.create({ - data: { - employeeId: employee.id, - type: 'Vacation', - status: 'Submitted', - workflowState: initialState, - from, - to, - reason: dto.reason ?? null, - calculatedDays, - halfDayStart, - halfDayEnd, - substituteId: dto.substituteId ?? null, - currentApproverId: hasSubstitute ? null : employee.managerId, - }, - }); - await tx.requestEvent.create({ - data: { - requestId: request.id, - kind: 'Submitted', - actorId: employee.id, - }, - }); - return request; + /** Compare-and-swap protects transitions too, including Draft → pending → Draft ABA races. */ + private async updateVersion( + tx: Prisma.TransactionClient, + request: Request, + data: Prisma.RequestUncheckedUpdateInput, + ): Promise { + const changed = await tx.request.updateMany({ + where: { + id: request.id, + workflowState: request.workflowState, + updatedAt: request.updatedAt, + }, + data: { + ...data, + updatedAt: new Date( + Math.max(Date.now(), request.updatedAt.getTime() + 1), + ), + }, + }); + if (changed.count !== 1) + throw new ConflictException('Request changed; reload before continuing'); + return tx.request.findUniqueOrThrow({ + where: { id: request.id }, + include: employeeInclude, }); - this.notifications.notifyTransitioned(created, 'Submitted', employee.id); - return toRequestDto(created); } // ----- Generic approve / reject (legacy path for non-Vacation) ----- @@ -263,23 +362,21 @@ export class RequestsService { ); } await this.assertApprover(request, actorId); - const alreadyApproved = request.workflowState === 'Approved'; + if (request.workflowState !== 'PendingManager') + throw new ConflictException('Request is not pending manager approval'); const updated = await this.prisma.$transaction(async (tx) => { - const updated = await tx.request.update({ - where: { id: request.id }, - data: { - status: 'Approved', - workflowState: 'Approved', - approverId: actorId, - currentApproverId: null, - decidedAt: new Date(), - decisionNote: note, - }, + const updated = await this.updateVersion(tx, request, { + status: 'Approved', + workflowState: 'Approved', + approverId: actorId, + currentApproverId: null, + decidedAt: new Date(), + decisionNote: note, }); await tx.requestEvent.create({ data: { requestId: id, kind: 'ManagerApproved', actorId, note }, }); - if (request.type === 'TimeAdjustment' && !alreadyApproved) { + if (request.type === 'TimeAdjustment') { await this.materializeTimeAdjustment(tx, request); } return updated; @@ -299,17 +396,16 @@ export class RequestsService { return this.transitionVacation(request, 'manager_reject', actorId, note); } await this.assertApprover(request, actorId); + if (request.workflowState !== 'PendingManager') + throw new ConflictException('Request is not pending manager approval'); const updated = await this.prisma.$transaction(async (tx) => { - const updated = await tx.request.update({ - where: { id: request.id }, - data: { - status: 'Rejected', - workflowState: 'Rejected', - approverId: actorId, - currentApproverId: null, - decidedAt: new Date(), - decisionNote: note, - }, + const updated = await this.updateVersion(tx, request, { + status: 'Rejected', + workflowState: 'Rejected', + approverId: actorId, + currentApproverId: null, + decidedAt: new Date(), + decisionNote: note, }); await tx.requestEvent.create({ data: { requestId: id, kind: 'ManagerRejected', actorId, note }, @@ -578,7 +674,7 @@ export class RequestsService { }); const status = deriveStatus(target); - const data: Prisma.RequestUpdateInput = { + const data: Prisma.RequestUncheckedUpdateInput = { workflowState: target, status, }; @@ -588,48 +684,47 @@ export class RequestsService { // Side effects per transition. if (event === 'submit') { - // No-op here — submit is done at create time. + // Creation and resubmission have their own validated paths. } if (event === 'substitute_accept') { data.substituteAcceptedAt = new Date(); // Move approver to manager. const employee = await this.employees.getById(employeeId); - data.currentApprover = employee.managerId - ? { connect: { id: employee.managerId } } - : { disconnect: true }; + data.currentApproverId = employee.managerId; } if (event === 'substitute_decline') { - data.currentApprover = { disconnect: true }; + data.decisionNote = note; + data.currentApproverId = null; } if (event === 'manager_approve' || event === 'manager_approve_with_hr') { - data.approver = { connect: { id: actorId } }; + data.approverId = actorId; data.decidedAt = event === 'manager_approve' ? new Date() : null; data.decisionNote = event === 'manager_approve' ? note : null; if (event === 'manager_approve_with_hr') { - data.currentApprover = { disconnect: true }; + data.currentApproverId = null; } else { - data.currentApprover = { disconnect: true }; + data.currentApproverId = null; } } if (event === 'manager_reject' || event === 'manager_return') { - data.approver = { connect: { id: actorId } }; + data.approverId = actorId; data.decidedAt = event === 'manager_reject' ? new Date() : null; data.decisionNote = note; - data.currentApprover = { disconnect: true }; + data.currentApproverId = null; } if (event === 'hr_confirm' || event === 'hr_reject') { data.hrConfirmedAt = event === 'hr_confirm' ? new Date() : null; data.decidedAt = new Date(); data.decisionNote = note; - data.currentApprover = { disconnect: true }; + data.currentApproverId = null; } if (event === 'cancel') { data.cancelledAt = new Date(); - data.currentApprover = { disconnect: true }; + data.currentApproverId = null; } const updated = await this.prisma.$transaction(async (tx) => { - const u = await tx.request.update({ where: { id: request.id }, data }); + const u = await this.updateVersion(tx, request, data); await tx.requestEvent.create({ data: { requestId: request.id, kind: eventKind, actorId, note }, }); @@ -672,8 +767,11 @@ export class RequestsService { }); } - private async assertRequest(id: string): Promise { - const request = await this.prisma.request.findUnique({ where: { id } }); + private async assertRequest(id: string): Promise { + const request = await this.prisma.request.findUnique({ + where: { id }, + include: employeeInclude, + }); if (!request) throw new NotFoundException(`Request ${id} not found`); return request; } @@ -737,11 +835,14 @@ export class RequestsService { employeeId: string, from: Date, to: Date, + db: Prisma.TransactionClient = this.prisma, + excludeRequestId?: string, ): Promise { - const overlap = await this.prisma.request.findFirst({ + const overlap = await db.request.findFirst({ where: { employeeId, status: { notIn: ['Rejected', 'Cancelled'] }, + ...(excludeRequestId ? { id: { not: excludeRequestId } } : {}), from: { lte: to }, to: { gte: from }, }, @@ -762,8 +863,9 @@ export class RequestsService { employeeId: string, from: Date, to: Date, + db: Prisma.TransactionClient = this.prisma, ): Promise { - const overlap = await this.prisma.timeEntry.findFirst({ + const overlap = await db.timeEntry.findFirst({ where: { employeeId, status: { not: 'Rejected' }, diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 037c1a3..d47a9e0 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -367,6 +367,8 @@ export interface AccountDto { export interface RequestDto { id: string; employeeId: string; + employeeName: string; + updatedAt: string; type: RequestType; status: RequestStatus; workflowState: WorkflowState; @@ -546,6 +548,13 @@ export interface CreateVacationPayload { halfDayEnd?: boolean; } +export interface UpdateDraftRequestPayload extends Omit< + CreateVacationPayload, + 'employeeId' +> { + expectedUpdatedAt: string; +} + export interface UpsertLeaveAllowancePayload { baseDays: number; carryOverDays: number; @@ -1055,6 +1064,16 @@ export const api = { method: 'POST', body: JSON.stringify(payload), }), + updateDraftRequest: (id: string, payload: UpdateDraftRequestPayload) => + request(`/api/requests/${id}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }), + resubmitRequest: (id: string, payload: UpdateDraftRequestPayload) => + request(`/api/requests/${id}/resubmit`, { + method: 'POST', + body: JSON.stringify(payload), + }), approveRequest: (id: string, actorId: string, note?: string) => request(`/api/requests/${id}/approve`, { method: 'POST', diff --git a/apps/web/src/api/generated.ts b/apps/web/src/api/generated.ts index 4f7538e..41d8884 100644 --- a/apps/web/src/api/generated.ts +++ b/apps/web/src/api/generated.ts @@ -1281,7 +1281,7 @@ export interface paths { delete?: never; options?: never; head?: never; - patch?: never; + patch: operations['RequestsController_updateDraft']; trace?: never; }; '/api/requests/{id}/events': { @@ -1316,6 +1316,22 @@ export interface paths { patch?: never; trace?: never; }; + '/api/requests/{id}/resubmit': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations['RequestsController_resubmit']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/requests/{id}/approve': { parameters: { query?: never; @@ -2813,6 +2829,45 @@ export interface components { adjustmentDays: number; adjustmentReason?: Record | null; }; + RequestDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + employeeId: string; + employeeName: string; + /** Format: date-time */ + updatedAt: string; + /** @enum {string} */ + type: 'Vacation' | 'HomeOffice' | 'SpecialLeave' | 'TimeAdjustment'; + status: string; + workflowState: string; + /** Format: date-time */ + from: string; + /** Format: date-time */ + to: string; + reason: string | null; + requiresApproval: boolean; + /** Format: uuid */ + approverId: string | null; + /** Format: uuid */ + currentApproverId: string | null; + /** Format: uuid */ + substituteId: string | null; + /** Format: date-time */ + substituteAcceptedAt: string | null; + /** Format: date-time */ + hrConfirmedAt: string | null; + /** Format: date-time */ + cancelledAt: string | null; + calculatedDays: number; + halfDayStart: boolean; + halfDayEnd: boolean; + /** Format: date-time */ + decidedAt: string | null; + decisionNote: string | null; + /** Format: date-time */ + createdAt: string; + }; CreateRequestDto: { /** Format: uuid */ employeeId: string; @@ -2828,7 +2883,7 @@ export interface components { * @example 2026-08-07T00:00:00.000Z */ to: string; - reason?: Record | null; + reason?: string | null; }; CreateVacationDto: { /** Format: uuid */ @@ -2838,8 +2893,8 @@ export interface components { /** Format: date-time */ to: string; /** Format: uuid */ - substituteId?: Record | null; - reason?: Record | null; + substituteId?: string | null; + reason?: string | null; /** * @description Take the first day as a half-day * @default false @@ -2851,15 +2906,39 @@ export interface components { */ halfDayEnd: boolean; }; + UpdateDraftRequestDto: { + /** Format: date-time */ + from: string; + /** Format: date-time */ + to: string; + /** Format: uuid */ + substituteId?: string | null; + reason?: string | null; + /** + * @description Take the first day as a half-day + * @default false + */ + halfDayStart: boolean; + /** + * @description Take the last day as a half-day + * @default false + */ + halfDayEnd: boolean; + /** + * Format: date-time + * @description updatedAt from the version being edited; stale writes return 409 + */ + expectedUpdatedAt: string; + }; TransitionDto: { /** Format: uuid */ actorId: string; - note?: Record | null; + note?: string | null; }; ManagerApproveDto: { /** Format: uuid */ actorId: string; - note?: Record | null; + note?: string | null; /** @default false */ requiresHrConfirmation: boolean; }; @@ -2872,7 +2951,7 @@ export interface components { /** Format: uuid */ actorId: string; ids: string[]; - note?: Record | null; + note?: string | null; /** @default false */ requiresHrConfirmation: boolean; }; @@ -5059,7 +5138,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto'][]; + }; }; }; }; @@ -5080,7 +5161,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5099,7 +5182,34 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; + }; + }; + }; + RequestsController_updateDraft: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdateDraftRequestDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5139,7 +5249,34 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; + }; + }; + }; + RequestsController_resubmit: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdateDraftRequestDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5162,7 +5299,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5185,7 +5324,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5208,7 +5349,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5231,7 +5374,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5254,7 +5399,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5277,7 +5424,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5300,7 +5449,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5323,7 +5474,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5346,7 +5499,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; @@ -5369,7 +5524,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + 'application/json': components['schemas']['RequestDto']; + }; }; }; }; diff --git a/apps/web/src/app/i18n.tsx b/apps/web/src/app/i18n.tsx index bb83a16..eeea78d 100644 --- a/apps/web/src/app/i18n.tsx +++ b/apps/web/src/app/i18n.tsx @@ -347,6 +347,15 @@ const de: Catalog = { 'Für den ausgewählten Tag wurde bereits ein Tagesblock gebucht.', 'requests.title': 'Anträge', 'requests.description': 'Urlaub, Home-Office, Sonderurlaub, Zeitkorrekturen', + 'requests.edit': 'Antrag bearbeiten', + 'requests.resubmit': 'Erneut einreichen', + 'enum.Resubmitted': 'Erneut eingereicht', + 'requests.saveDraft': 'Entwurf speichern', + 'requests.employee': 'Mitarbeiter/in', + 'requests.revisionReason': 'Rückgabegrund', + 'requests.notDraft': + 'Dieser Antrag ist kein Entwurf mehr. Bitte Übersicht neu laden.', + 'requests.loadFailed': 'Antrag konnte nicht geladen werden.', 'requests.new': 'Neuer Antrag', 'requests.own': 'Eigene Anträge', 'requests.none': 'Noch keine Anträge.', @@ -1018,6 +1027,15 @@ const en: Catalog = { 'requests.title': 'Requests', 'requests.description': 'Vacation, home office, special leave, time adjustments', + 'requests.edit': 'Edit request', + 'requests.resubmit': 'Resubmit', + 'enum.Resubmitted': 'Resubmitted', + 'requests.saveDraft': 'Save draft', + 'requests.employee': 'Employee', + 'requests.revisionReason': 'Reason for revision', + 'requests.notDraft': + 'This request is no longer a draft. Please reload the list.', + 'requests.loadFailed': 'Could not load request.', 'requests.new': 'New request', 'requests.own': 'My requests', 'requests.none': 'No requests yet.', diff --git a/apps/web/src/routes/AdminRequestsPage.spec.tsx b/apps/web/src/routes/AdminRequestsPage.spec.tsx new file mode 100644 index 0000000..30274bf --- /dev/null +++ b/apps/web/src/routes/AdminRequestsPage.spec.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, screen } from '@testing-library/react'; +import { renderWithProviders } from '../test-utils'; +import { AdminRequestsPage } from './AdminRequestsPage'; + +const request = { + id: 'request-42', + employeeId: 'owner', + employeeName: 'Anna Example', + type: 'Vacation', + workflowState: 'PendingManager', + status: 'Submitted', + currentApproverId: 'manager', + from: '2026-05-04T00:00:00.000Z', + to: '2026-05-08T00:00:00.000Z', + calculatedDays: 5, +}; +vi.mock('../api/client', () => ({ + api: { + listRequests: vi + .fn() + .mockImplementation(async (filter) => + filter.currentApproverId ? [request] : [], + ), + getRequest: vi.fn().mockImplementation(async () => request), + getRequestEvents: vi + .fn() + .mockResolvedValue([ + { id: 'event', kind: 'Resubmitted', at: '2026-05-01T12:00:00.000Z' }, + ]), + }, +})); +vi.mock('../app/auth', () => ({ + useCurrentUser: () => ({ + id: 'manager', + firstName: 'Marc', + lastName: 'Example', + role: 'Manager', + }), +})); + +describe('request identity in approval context (#42)', () => { + it('shows the employee name in the inbox and retained audit trail', async () => { + renderWithProviders(); + expect(await screen.findByText(/Anna Example · Urlaub/)).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'Verlauf' })); + expect( + await screen.findByText(/Anna Example · Urlaub · 4.5.2026/), + ).toBeDefined(); + expect(await screen.findByText('Erneut eingereicht')).toBeDefined(); + }); +}); diff --git a/apps/web/src/routes/AdminRequestsPage.tsx b/apps/web/src/routes/AdminRequestsPage.tsx index d8fc2df..e7ef36f 100644 --- a/apps/web/src/routes/AdminRequestsPage.tsx +++ b/apps/web/src/routes/AdminRequestsPage.tsx @@ -502,7 +502,7 @@ function RequestRow({ )}

- {enumLabel(request.type)} ·{' '} + {request.employeeName} · {enumLabel(request.type)} ·{' '} {isTimeAdjustment ? `${formatDateTime(request.from)} – ${formatDateTime(request.to)}` : `${formatDate(request.from.slice(0, 10))} – ${formatDate(request.to.slice(0, 10))}`} @@ -624,7 +624,7 @@ function AuditTrail({ requestId }: { requestId: string }) { {t('requests.auditHistory')} {requestQuery.data - ? `${enumLabel(requestQuery.data.type)} · ${formatDate(requestQuery.data.type === 'TimeAdjustment' ? requestQuery.data.from : requestQuery.data.from.slice(0, 10))} – ${formatDate(requestQuery.data.type === 'TimeAdjustment' ? requestQuery.data.to : requestQuery.data.to.slice(0, 10))}` + ? `${requestQuery.data.employeeName} · ${enumLabel(requestQuery.data.type)} · ${formatDate(requestQuery.data.type === 'TimeAdjustment' ? requestQuery.data.from : requestQuery.data.from.slice(0, 10))} – ${formatDate(requestQuery.data.type === 'TimeAdjustment' ? requestQuery.data.to : requestQuery.data.to.slice(0, 10))}` : '…'} diff --git a/apps/web/src/routes/RequestsPage.spec.tsx b/apps/web/src/routes/RequestsPage.spec.tsx index 933cab4..6479619 100644 --- a/apps/web/src/routes/RequestsPage.spec.tsx +++ b/apps/web/src/routes/RequestsPage.spec.tsx @@ -3,13 +3,22 @@ import { fireEvent, screen, waitFor, within } from '@testing-library/react'; import { renderWithProviders } from '../test-utils'; import { RequestsPage } from './RequestsPage'; +const listRequestsMock = vi.fn(); +const getRequestMock = vi.fn(); +const getRequestEventsMock = vi.fn(); +const updateDraftRequestMock = vi.fn(); +const resubmitRequestMock = vi.fn(); const employeeDirectoryMock = vi.fn(); const employeesMock = vi.fn(); const createVacationRequestMock = vi.fn(); vi.mock('../api/client', () => ({ api: { - listRequests: vi.fn().mockResolvedValue([]), + listRequests: (...args: unknown[]) => listRequestsMock(...args), + getRequest: (...args: unknown[]) => getRequestMock(...args), + getRequestEvents: (...args: unknown[]) => getRequestEventsMock(...args), + updateDraftRequest: (...args: unknown[]) => updateDraftRequestMock(...args), + resubmitRequest: (...args: unknown[]) => resubmitRequestMock(...args), employeeDirectory: (...args: unknown[]) => employeeDirectoryMock(...args), employees: (...args: unknown[]) => employeesMock(...args), vacationBalance: vi.fn().mockResolvedValue({ @@ -34,6 +43,11 @@ vi.mock('../app/auth', () => ({ describe('vacation substitute selection', () => { beforeEach(() => { + listRequestsMock.mockReset().mockResolvedValue([]); + getRequestMock.mockReset(); + getRequestEventsMock.mockReset().mockResolvedValue([]); + updateDraftRequestMock.mockReset().mockResolvedValue({}); + resubmitRequestMock.mockReset().mockResolvedValue({}); employeesMock.mockClear(); employeeDirectoryMock.mockClear().mockResolvedValue([ { id: 'requester', firstName: 'Anna', lastName: 'Example' }, @@ -72,4 +86,126 @@ describe('vacation substitute selection', () => { ); }); }); + const draft = { + id: 'same-request', + employeeId: 'requester', + employeeName: 'Anna Example', + type: 'Vacation', + workflowState: 'Draft', + status: 'Submitted', + from: '2026-05-04T00:00:00.000Z', + to: '2026-05-08T00:00:00.000Z', + reason: 'Original reason', + decisionNote: 'Bitte kürzen', + substituteId: 'substitute', + halfDayStart: true, + halfDayEnd: false, + calculatedDays: 4.5, + updatedAt: '2026-05-01T12:00:00.001Z', + }; + + it('opens a draft by keyboard-accessible action with current values/name/reason and resubmits the same ID', async () => { + listRequestsMock.mockResolvedValue([draft]); + getRequestMock.mockResolvedValue(draft); + renderWithProviders(); + fireEvent.click( + await screen.findByRole('button', { + name: 'Entwurf · Antrag bearbeiten', + }), + ); + expect( + await screen.findByText('Rückgabegrund: Bitte kürzen'), + ).toBeDefined(); + expect(screen.getByText('Mitarbeiter/in: Anna Example')).toBeDefined(); + expect((screen.getByLabelText('Von') as HTMLInputElement).value).toBe( + '2026-05-04', + ); + expect((screen.getByLabelText('Bis') as HTMLInputElement).value).toBe( + '2026-05-08', + ); + expect( + ( + (await screen.findByLabelText( + 'Vertretung (optional)', + )) as HTMLSelectElement + ).value, + ).toBe('substitute'); + expect( + (screen.getByLabelText('Erster Tag halb') as HTMLInputElement).checked, + ).toBe(true); + fireEvent.change(screen.getByLabelText('Bis'), { + target: { value: '2026-05-07' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Erneut einreichen' })); + await waitFor(() => + expect(resubmitRequestMock).toHaveBeenCalledWith( + 'same-request', + expect.objectContaining({ + expectedUpdatedAt: draft.updatedAt, + reason: 'Original reason', + to: '2026-05-07T00:00:00.000Z', + substituteId: 'substitute', + halfDayStart: true, + }), + ), + ); + expect(createVacationRequestMock).not.toHaveBeenCalled(); + }); + + it('keeps stale/failed edits visible and never creates a replacement request', async () => { + listRequestsMock.mockResolvedValue([draft]); + getRequestMock.mockResolvedValue(draft); + resubmitRequestMock.mockRejectedValue( + new Error('Request changed; reload before continuing'), + ); + renderWithProviders(); + fireEvent.click( + await screen.findByRole('button', { + name: 'Entwurf · Antrag bearbeiten', + }), + ); + await screen.findByText('Rückgabegrund: Bitte kürzen'); + fireEvent.click(screen.getByRole('button', { name: 'Erneut einreichen' })); + expect( + await screen.findByText('Request changed; reload before continuing'), + ).toBeDefined(); + expect(screen.getByRole('dialog')).toBeDefined(); + expect(createVacationRequestMock).not.toHaveBeenCalled(); + }); + + it('can save a draft without submission and reads legacy decline reasons from the retained history', async () => { + listRequestsMock.mockResolvedValue([draft]); + getRequestMock.mockResolvedValue({ ...draft, decisionNote: null }); + getRequestEventsMock.mockResolvedValue([ + { kind: 'SubstituteDeclined', note: 'Bin selbst weg' }, + ]); + renderWithProviders(); + fireEvent.click( + await screen.findByRole('button', { + name: 'Entwurf · Antrag bearbeiten', + }), + ); + expect( + await screen.findByText('Rückgabegrund: Bin selbst weg'), + ).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'Entwurf speichern' })); + await waitFor(() => + expect(updateDraftRequestMock).toHaveBeenCalledWith( + 'same-request', + expect.objectContaining({ expectedUpdatedAt: draft.updatedAt }), + ), + ); + expect(resubmitRequestMock).not.toHaveBeenCalled(); + }); + + it('does not offer editing for pending requests', async () => { + listRequestsMock.mockResolvedValue([ + { ...draft, workflowState: 'PendingManager' }, + ]); + renderWithProviders(); + await screen.findByText(/Anna Example · Urlaub/); + expect( + screen.queryByRole('button', { name: /Antrag bearbeiten/ }), + ).toBeNull(); + }); }); diff --git a/apps/web/src/routes/RequestsPage.tsx b/apps/web/src/routes/RequestsPage.tsx index ce0534b..f893024 100644 --- a/apps/web/src/routes/RequestsPage.tsx +++ b/apps/web/src/routes/RequestsPage.tsx @@ -15,7 +15,7 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { api, type RequestType } from '../api/client'; +import { api, type RequestDto, type RequestType } from '../api/client'; import { useCurrentUser } from '../app/auth'; import { useI18n } from '../app/i18n'; @@ -55,6 +55,7 @@ export function RequestsPage() { const employeeId = user.id; const qc = useQueryClient(); const [dialogOpen, setDialogOpen] = useState(false); + const [editingId, setEditingId] = useState(null); const requests = useQuery({ queryKey: ['requests', { employeeId }], @@ -76,17 +77,30 @@ export function RequestsPage() { - + { setDialogOpen(false); qc.invalidateQueries({ queryKey: ['requests'] }); + qc.invalidateQueries({ queryKey: ['vacation-balance'] }); }} />

+ {editingId && ( + { + setEditingId(null); + qc.invalidateQueries({ queryKey: ['requests'] }); + qc.invalidateQueries({ queryKey: ['request'] }); + qc.invalidateQueries({ queryKey: ['request-events'] }); + qc.invalidateQueries({ queryKey: ['vacation-balance'] }); + }} + /> + )} {t('requests.own')} @@ -101,6 +115,7 @@ export function RequestsPage() { >

+ {r.employeeName || `${user.firstName} ${user.lastName}`} ·{' '} {enumLabel(r.type)} ·{' '} {formatRange( r.type as RequestType, @@ -122,9 +137,19 @@ export function RequestsPage() { {t('requests.specialApproval')} )} - - {enumLabel(r.workflowState)} - + {r.workflowState === 'Draft' ? ( + + ) : ( + + {enumLabel(r.workflowState)} + + )}

{r.type === 'SpecialLeave' && ( @@ -143,31 +168,74 @@ export function RequestsPage() { ); } +function EditRequestDialog({ + requestId, + onClose, +}: { + requestId: string; + onClose: () => void; +}) { + const { t } = useI18n(); + const request = useQuery({ + queryKey: ['request', requestId], + queryFn: () => api.getRequest(requestId), + staleTime: 0, + refetchOnWindowFocus: false, + }); + return ( + !open && onClose()}> + + {request.data && !request.isFetching ? ( + + ) : ( + + {t('requests.edit')} + + {request.isError ? t('requests.loadFailed') : t('common.loading')} + + + )} + + + ); +} + interface NewRequestFormProps { onClose: () => void; + request?: RequestDto; } -function isoDateTimeLocalNow(): string { - const d = new Date(); +function isoDateTimeLocalNow(iso?: string): string { + const d = iso ? new Date(iso) : new Date(); // datetime-local needs YYYY-MM-DDTHH:mm in *local* time const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } -function NewRequestForm({ onClose }: NewRequestFormProps) { +function NewRequestForm({ onClose, request }: NewRequestFormProps) { const user = useCurrentUser(); const { t, enumLabel } = useI18n(); const employeeId = user.id; const today = isoDateOnly(new Date()); - const [type, setType] = useState('Vacation'); - const [from, setFrom] = useState(today); - const [to, setTo] = useState(today); - const [fromDt, setFromDt] = useState(isoDateTimeLocalNow); - const [toDt, setToDt] = useState(isoDateTimeLocalNow); - const [reason, setReason] = useState(''); - const [substituteId, setSubstituteId] = useState(''); - const [halfDayStart, setHalfDayStart] = useState(false); - const [halfDayEnd, setHalfDayEnd] = useState(false); + const [type, setType] = useState(request?.type ?? 'Vacation'); + const [from, setFrom] = useState(request?.from.slice(0, 10) ?? today); + const [to, setTo] = useState(request?.to.slice(0, 10) ?? today); + const [fromDt, setFromDt] = useState(() => + isoDateTimeLocalNow(request?.from), + ); + const [toDt, setToDt] = useState(() => isoDateTimeLocalNow(request?.to)); + const [reason, setReason] = useState(request?.reason ?? ''); + const [substituteId, setSubstituteId] = useState( + request?.substituteId ?? '', + ); + const [halfDayStart, setHalfDayStart] = useState( + request?.halfDayStart ?? false, + ); + const [halfDayEnd, setHalfDayEnd] = useState(request?.halfDayEnd ?? false); const [attachment, setAttachment] = useState(null); const [error, setError] = useState(null); @@ -176,6 +244,21 @@ function NewRequestForm({ onClose }: NewRequestFormProps) { queryFn: () => api.employeeDirectory(), }); + const events = useQuery({ + queryKey: ['request-events', request?.id], + queryFn: () => + request ? api.getRequestEvents(request.id) : Promise.resolve([]), + enabled: !!request, + }); + const revisionNote = + request?.decisionNote ?? + events.data + ?.filter( + (event) => + event.kind === 'Returned' || event.kind === 'SubstituteDeclined', + ) + .at(-1)?.note; + const fromDate = useMemo(() => new Date(`${from}T00:00:00.000Z`), [from]); const year = fromDate.getUTCFullYear(); const balance = useQuery({ @@ -193,7 +276,25 @@ function NewRequestForm({ onClose }: NewRequestFormProps) { }, [isTimeAdjustment, from, to, fromDt, toDt]); const create = useMutation({ - mutationFn: async () => { + mutationFn: async (saveOnly: boolean) => { + if (request) { + const payload = { + expectedUpdatedAt: request.updatedAt, + from: isTimeAdjustment + ? new Date(fromDt).toISOString() + : new Date(`${from}T00:00:00.000Z`).toISOString(), + to: isTimeAdjustment + ? new Date(toDt).toISOString() + : new Date(`${to}T00:00:00.000Z`).toISOString(), + reason: reason || null, + substituteId: type === 'Vacation' ? substituteId || null : null, + halfDayStart: type === 'Vacation' && halfDayStart, + halfDayEnd: type === 'Vacation' && halfDayEnd, + }; + return saveOnly + ? api.updateDraftRequest(request.id, payload) + : api.resubmitRequest(request.id, payload); + } if (type === 'Vacation') { return api.createVacationRequest({ employeeId, @@ -233,19 +334,41 @@ function NewRequestForm({ onClose }: NewRequestFormProps) { const insufficientVacation = type === 'Vacation' && balance.data !== undefined && - balance.data.remainingDays < 1; + balance.data.remainingDays < 0.5; return ( <> - {t('requests.new')} + + {t(request ? 'requests.edit' : 'requests.new')} + {t('requests.chooseTypeRange')}
+

+ {t('requests.employee')}:{' '} + {request?.employeeName || `${user.firstName} ${user.lastName}`} +

+ {request && revisionNote && ( + + + {t('requests.revisionReason')}: {revisionNote} + + + )} + {request && request.workflowState !== 'Draft' && ( + + {t('requests.notDraft')} + + )} + {request?.type === 'SpecialLeave' && ( + + )}
{t('common.cancel')} + {request && ( + + )} diff --git a/docs/ISSUE_FIXES_MANUAL_TEST.de.md b/docs/ISSUE_FIXES_MANUAL_TEST.de.md new file mode 100644 index 0000000..1a10b92 --- /dev/null +++ b/docs/ISSUE_FIXES_MANUAL_TEST.de.md @@ -0,0 +1,209 @@ +# Nachtest der Issue-Pakete + +## Paket #42 – zurückgegebene Anträge bearbeiten + +- Issue: +- Branch: `fix/42-returned-request-edit`, Basis: `9cfbc989cce97bde362821d1e2922951b2abb498`. +- Implementierungs-Worktree: `/Users/patrickschiller/openclockwork-issue-42`. +- Keine Releases, Tags, Versionsänderungen, Merges oder Deployments. Keine neue + Migration erforderlich: Die vorhandenen Felder `updatedAt` und `workflowState` + sichern Schreibzugriffe ab; das vorhandene Ereignis `Resubmitted` wird verwendet. + +### Isoliertes lokales Setup + +Die Grundlage ist [LOCAL_DOCKER_TEST.de.md](LOCAL_DOCKER_TEST.de.md). +Für diesen Nachtest **nicht** die bestehenden Solo-/Team-Projekte wiederverwenden. +Eigene Projekt-, Image-Namen und Port vermeiden Änderungen an anderen Teststacks. +Nur synthetische Daten verwenden; weder Produktivdaten importieren noch eine +vorhandene `.env` laden. + +Vom Implementierungs-Worktree aus arbeiten, oder einen weiteren Test-Worktree +anlegen (der Hauptcheckout mit lokalen Dateien bleibt unberührt): + +```bash +git fetch origin +git worktree add ../openclockwork-42-manual origin/fix/42-returned-request-edit +cd ../openclockwork-42-manual +``` + +Docker und Compose müssen laufen. Der lokale Override liegt außerhalb des Repos: + +```bash +cat > /tmp/openclockwork-42-compose.yml <<'YAML' +services: + api: + image: openclockwork-api:issue42-local + web: + image: openclockwork-web:issue42-local +YAML + +TEST_WEB_PORT=18042 docker compose --env-file /dev/null \ + -p openclockwork-issue42 -f docker-compose.test.yml \ + -f /tmp/openclockwork-42-compose.yml build + +TEST_WEB_PORT=18042 docker compose --env-file /dev/null \ + -p openclockwork-issue42 -f docker-compose.test.yml \ + -f /tmp/openclockwork-42-compose.yml up -d --wait + +TEST_WEB_PORT=18042 docker compose --env-file /dev/null \ + -p openclockwork-issue42 -f docker-compose.test.yml \ + -f /tmp/openclockwork-42-compose.yml exec -T \ + -e NODE_ENV=development \ + -e OPENCLOCKWORK_SEED_CONFIRM_DATABASE=openclockwork_test \ + api node --import tsx prisma/seed.ts +``` + +Seed nur einmal auf dieser leeren Team-Testdatenbank ausführen. Oberfläche: +, Healthcheck: . + +| Rolle | Synthetisches Konto | Verwendung | +| ----------------------- | --------------------------------- | ---------------------------------- | +| Beschäftigte | `anna.mueller@openclockwork.test` | Antragstellerin | +| Führungskraft | `marc.becker@openclockwork.test` | Rückgabe/Genehmigung | +| Vertretung/Fremdzugriff | `bernd.schulz@openclockwork.test` | Ablehnen/Annehmen, kein Eigentümer | +| HR | `hannah.roth@openclockwork.test` | Urlaubskonto und Fremdzugriff | + +Das initiale lokale Seed-Passwort ist `openclockwork`, siehe Basisanleitung. +Separate Browserprofile/private Fenster je Rolle verwenden. Einen freien +Mo–Fr-Zeitraum desselben Jahres ohne Feiertage wählen; vorhandene Seed-Anträge und +Zeitbuchungen nicht überlappen. Für exakte 5/4,5-Tage-Erwartungen müssen die +Arbeitszeitregelung und das Urlaubskonto zu diesem Zeitraum passen. + +### A. Führungskraft gibt zurück → bearbeiten → erneut einreichen + +1. Als Anna unter **Anträge → Neuer Antrag** Urlaub ohne Vertretung für fünf + Arbeitstage beantragen, Grund „Nachtest #42“ eintragen. Annas Name steht in der + Maske und der Liste. In den Browser-Netzwerkdetails die Antwort-ID festhalten. +2. Als Marc unter **Genehmigungen** denselben Antrag prüfen: Annas Name steht im + Eintrag und im **Verlauf**. Notiz „Bitte letzten Tag kürzen“ eingeben und + **Zur Korrektur** wählen. +3. Als Anna Übersicht aktualisieren. **Entwurf · Antrag bearbeiten** mit Maus + oder Tab/Enter öffnen. Erwartet: ursprüngliche Daten, Grund, Halbtagswerte und + Vertretung (falls vorhanden) sind vorbelegt; Annas Name und der Rückgabegrund + erscheinen. Der Antragstyp ist unveränderlich. +4. Letzten Tag oder Halbtagsauswahl ändern. **Entwurf speichern** wählen. + Erwartet: weiterhin Entwurf, kein neuer Antrag, noch kein Genehmigungseingang. + Erneut öffnen: geänderte Werte und Rückgabegrund bleiben vorhanden. +5. **Erneut einreichen** wählen. Erwartet: dieselbe ID wie in Schritt 1; + `workflowState=PendingManager`, aktueller Manager neu zugeordnet; Tageszahl + erneut berechnet (ein halber erster Tag bei fünf Arbeitstagen: 4,5 Tage). + Alte Freigabe-, Entscheidungs-, HR- und Vertretungsannahmefelder sind leer. +6. Als Marc aktualisieren: genau ein Antrag mit neuen Werten. Im **Verlauf** + bleiben Einreichung und Rückgabenotiz erhalten, zusätzlich „Erneut eingereicht“. + Normal genehmigen; Annas Urlaubskonto darf den Antrag nicht doppelt zählen. + +### B. Vertretung lehnt ab → bearbeiten → erneut einreichen + +1. Als Anna neuen Urlaub in einem anderen freien Zeitraum mit Bernd als Vertretung + beantragen. Als Bernd unter **Vertretungen** mit Notiz „Bin selbst weg“ ablehnen. +2. Als Anna den Entwurf öffnen: Werte und Ablehnungsgrund müssen sichtbar sein. + Grund/Zeitraum/Halbtage ändern, Bernd erneut auswählen und erneut einreichen. +3. Erwartet: gleiche ID, `PendingSubstitute`, `substituteAcceptedAt=null`, noch + kein aktueller Manager-Freigabeschritt. Bernd muss erneut ausdrücklich annehmen; + danach folgt Marc. Alternativ Vertretung entfernen: direkt `PendingManager`. +4. Bei einem zurückgegebenen **Sonderurlaub** mit synthetischem PDF dasselbe + durchführen: vorhandener Beleg muss vor/nach der Bearbeitung herunterladbar + bleiben; Historie und Beleg-ID dürfen nicht verloren gehen. +5. Bei **Zeitkorrektur** Uhrzeiten prüfen: gespeicherte lokale Uhrzeiten werden + vorbelegt. Neue Buchungsüberschneidung wird abgewiesen; eine Änderung außerhalb + der Rahmenzeit erfordert weiterhin Sondergenehmigung und HR-Bestätigung. +6. Sprache auf Englisch umstellen: Bearbeiten/Speichern/Erneut einreichen, + Mitarbeiterbeschriftung, Rückgabegrund und Wiedereinreichungsereignis sind + übersetzt, keine neuen rohen i18n-Schlüssel. + +### C. Negative und parallele Fälle + +Die API-Aufrufe lassen sich über die Browser-Netzwerkdetails **des isolierten +Teststacks** wiederholen. Keine echten Bearer-Token in Dokumente/Issues kopieren. + +`GET /api/requests/:id` liefert `updatedAt`. Bearbeiten: +`PATCH /api/requests/:id`; erneut einreichen: `POST /api/requests/:id/resubmit`. +Beide erwarten vollständige editierbare Werte, z. B.: + +```json +{ + "expectedUpdatedAt": "", + "from": "2026-05-04T00:00:00.000Z", + "to": "2026-05-08T00:00:00.000Z", + "reason": "Nachtest", + "substituteId": null, + "halfDayStart": true, + "halfDayEnd": false +} +``` + +Die Beispieldaten an den freien Testzeitraum anpassen. + +| Fall | Erwartung | +| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| Bernd, Marc oder HR ändern/reichen Annas Entwurf ein | 403; Eigentümer wird ausschließlich aus Bearer-Identität geprüft, nicht aus Body-Feldern | +| Kein Login | 401 | +| Noch ausstehend, genehmigt, abgelehnt oder storniert statt `Draft` | 409; `status=Submitted` allein berechtigt nicht | +| Enddatum vor Anfang, Urlaub über Jahresgrenze, nur Wochenende, ungültige Daten | 400; unveränderter Entwurf | +| Selbstvertretung, nicht vorhandene oder deaktivierte Vertretung | 400 | +| HR reduziert Annas Urlaub unter den neu berechneten Bedarf | 409 bei Speichern und Einreichen; 0,5 verfügbar erlaubt einen einzelnen halben Arbeitstag | +| Überschneidung mit anderem aktivem Antrag oder anderem Entwurf | 409; ausschließlich die eigene ID ist aus der Prüfung ausgenommen | +| Unveränderten zurückgegebenen Urlaub bei exakt ausreichendem Konto einreichen | Erfolg; eigene Reservierung wird weder bei Überschneidung noch beim Saldo doppelt gezählt | +| Entwurf in zwei Tabs öffnen, in A speichern, in B mit altem `updatedAt` speichern/einreichen | 409; kein stilles Überschreiben, Fehler bleibt in der Maske, neu laden und erneut bearbeiten | +| Zweimal gleichzeitig denselben Entwurf einreichen | genau ein Erfolg, ein 409, nur ein `Resubmitted`-Ereignis | +| Zwei verschiedene Entwürfe parallel einreichen, Urlaub reicht nur für einen | genau einer erfolgreich; keine Überbuchung des Kontos | +| Bereits laufende alte Managerentscheidung während Rückgabe + Wiedereinreichung | alte Entscheidung scheitert mit 409, neue Werte/neuer Freigabezyklus bleiben bestehen | + +### Automatisch ausgeführte Gates + +Stand: 07.11.2026. Tests verwenden eine eigene synthetische PostgreSQL-16-Instanz +(`openclockwork_test_42`, Loopback-Port 15442); keine vorhandene Datenbank wird +zurückgesetzt. Die UI-Regressionsprüfungen sind React-Testing-Library-Tests; +der oben beschriebene manuelle Docker-/Browser-Nachtest wurde nicht als bestanden +behauptet. + +Ausgeführt mit Node `v26.12.1`, pnpm `9.15.9` (für diesen Worktree über +`/tmp/openclockwork-42-tools/node_modules/.bin` im `PATH`). + +| Gate | Ergebnis | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `pnpm nx e2e api-e2e --testPathPatterns='request-revision\|vacation-workflow\|half-day-vacation\|attachments\|bulk\|carry-over-expiry\|employee-account-access\|work-schedules'` | **75 Tests, 8 Suites bestanden**, einschließlich 20 neuer Revisionsfälle | +| `pnpm nx run-many -t test -p web,shared --run` | **Web: 190 Tests / 33 Dateien**, **Shared: 88 Tests / 7 Dateien bestanden** | +| API-, Web- und Shared-Builds im Lauf `pnpm nx run-many -t build,typecheck,lint -p api,web,shared,api-e2e` | **Alle vorhandenen Build-Targets erfolgreich** | +| Lint im selben Lauf für API, Web, Shared und API-E2E | **0 Fehler**; 13 bestehende Warnungen in unveränderten Dateien | +| `pnpm nx run web:typecheck --force --skip-nx-cache`, danach `pnpm nx run-many -t typecheck -p api,web,shared,api-e2e` | **Alle vier Typechecks erfolgreich** | +| `pnpm verify:api` mit der isolierten Test-`DATABASE_URL` | **Erfolgreich**, OpenAPI und generierter Client identisch zur gestagten Fassung | +| `git diff --check` | **Erfolgreich** | + +Beim kombinierten parallelen Build-/Typecheck-Zwischenlauf entfernte Vite das +von TypeScript ebenfalls verwendete Web-`dist`-Verzeichnis (`TS6305`). Der +anschließende erzwungene, separat ausgeführte Web-Typecheck und der separate +Vier-Projekt-Typecheck bestanden. Deshalb Build und Typecheck für einen Nachlauf +nacheinander starten. Es wurde dafür keine Projektkonfiguration geändert. +Weitere nicht blockierende Ausgaben: React-Router-Future-Hinweise, +Node-localStorage-Hinweis und veraltete Browserslist-Daten. + +Reproduzierbare API-Testumgebung (ausschließlich neue synthetische Testinstanz): + +```bash +docker run --name openclockwork-issue-42-db \ + -e POSTGRES_USER=openclockwork -e POSTGRES_PASSWORD=openclockwork \ + -e POSTGRES_DB=openclockwork_test_42 \ + -p 127.0.0.1:15442:5432 -d postgres:16-alpine + +E2E_DATABASE_URL='postgresql://openclockwork:openclockwork@localhost:15442/openclockwork_test_42?schema=public' \ +TZ=Europe/Berlin pnpm nx e2e api-e2e \ + --testPathPatterns='request-revision|vacation-workflow|half-day-vacation|attachments|bulk|carry-over-expiry|employee-account-access|work-schedules' +``` + +Wenn diese konkrete Testinstanz bereits existiert, nicht ein zweites Mal anlegen. +Die Test-Suite prüft den Testdatenbanknamen vor Migration/Reset. Ein vorhandenes +`DATABASE_URL` muss entfernt oder bewusst auf dieselbe isolierte Instanz gesetzt +werden; widersprüchliche Ziele werden abgewiesen. + +### Stoppen, Daten erhalten + +```bash +TEST_WEB_PORT=18042 docker compose --env-file /dev/null \ + -p openclockwork-issue42 -f docker-compose.test.yml \ + -f /tmp/openclockwork-42-compose.yml stop +``` + +Zum Fortsetzen denselben `up -d --wait`-Befehl verwenden, **nicht** erneut seeden. +Kein `down -v`: Das würde die Nachtestdaten löschen. Folgepakete #41, #43, #45 +und #44 ergänzen diese Anleitung erst bei ihrer jeweiligen Implementierung. From 80c8622a906cff2b28148f83becdbf6bb4c7e9ab Mon Sep 17 00:00:00 2001 From: Patrick Schiller <39913537+patrickschiller@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:51:22 +0100 Subject: [PATCH 2/7] fix: preserve correction instants across DST edits (#42) Keep persisted timestamps unchanged until their time field is edited. Resolve wall-clock validation and payloads to identical instants, require explicit ambiguous-time choices, reject clock-change gaps, and cover seconds, both fold occurrences and reason-only save/resubmit cycles. Co-authored-by: patrickschiller <39913537+patrickschiller@users.noreply.github.com> Signed-off-by: Patrick Schiller <39913537+patrickschiller@users.noreply.github.com> --- apps/web/src/app/i18n.tsx | 14 ++ apps/web/src/lib/local-date-time.spec.ts | 66 ++++++ apps/web/src/lib/local-date-time.ts | 45 ++++ apps/web/src/routes/RequestsPage.spec.tsx | 269 +++++++++++++++++++++- apps/web/src/routes/RequestsPage.tsx | 167 +++++++++++--- docs/ISSUE_FIXES_MANUAL_TEST.de.md | 85 +++++++ 6 files changed, 608 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/lib/local-date-time.spec.ts create mode 100644 apps/web/src/lib/local-date-time.ts diff --git a/apps/web/src/app/i18n.tsx b/apps/web/src/app/i18n.tsx index eeea78d..2d67e1c 100644 --- a/apps/web/src/app/i18n.tsx +++ b/apps/web/src/app/i18n.tsx @@ -356,6 +356,13 @@ const de: Catalog = { 'requests.notDraft': 'Dieser Antrag ist kein Entwurf mehr. Bitte Übersicht neu laden.', 'requests.loadFailed': 'Antrag konnte nicht geladen werden.', + 'requests.ambiguousTime': + 'Diese Ortszeit kommt bei der Zeitumstellung in {zone} zweimal vor. Bitte das gewünschte Vorkommen wählen; unveränderte Werte behalten das gespeicherte Vorkommen.', + 'requests.timeOccurrence': '{field} – Vorkommen', + 'requests.chooseOccurrence': 'Vorkommen wählen', + 'requests.timeOccurrenceOption': '{number}. Vorkommen ({offset})', + 'requests.nonexistentTime': + 'Bitte eine gültige Ortszeit eingeben. Zeiten in der Lücke der Zeitumstellung existieren nicht.', 'requests.new': 'Neuer Antrag', 'requests.own': 'Eigene Anträge', 'requests.none': 'Noch keine Anträge.', @@ -1036,6 +1043,13 @@ const en: Catalog = { 'requests.notDraft': 'This request is no longer a draft. Please reload the list.', 'requests.loadFailed': 'Could not load request.', + 'requests.ambiguousTime': + 'This local time occurs twice when the clocks change in {zone}. Choose the intended occurrence; unchanged values keep the saved occurrence.', + 'requests.timeOccurrence': '{field} – occurrence', + 'requests.chooseOccurrence': 'Choose occurrence', + 'requests.timeOccurrenceOption': 'Occurrence {number} ({offset})', + 'requests.nonexistentTime': + 'Enter a valid local time. Times in the clock-change gap do not exist.', 'requests.new': 'New request', 'requests.own': 'My requests', 'requests.none': 'No requests yet.', diff --git a/apps/web/src/lib/local-date-time.spec.ts b/apps/web/src/lib/local-date-time.spec.ts new file mode 100644 index 0000000..3d97805 --- /dev/null +++ b/apps/web/src/lib/local-date-time.spec.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + formatLocalDateTime, + localDateTimeCandidates, + utcOffsetLabel, +} from './local-date-time'; + +describe('local date/time resolution', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('preserves seconds and enumerates both Berlin autumn occurrences chronologically', () => { + vi.stubEnv('TZ', 'Europe/Berlin'); + expect(formatLocalDateTime('2026-10-25T01:30:12.345Z')).toBe( + '2026-10-25T02:30:12.345', + ); + expect(localDateTimeCandidates('2026-10-25T02:30:12.345')).toEqual([ + '2026-10-25T00:30:12.345Z', + '2026-10-25T01:30:12.345Z', + ]); + expect(utcOffsetLabel('2026-10-25T00:30:00.000Z')).toBe('UTC+02:00'); + expect(utcOffsetLabel('2026-10-25T01:30:00.000Z')).toBe('UTC+01:00'); + }); + + it.each(['2026-03-29T02:30', '2026-02-30T12:00', '', 'not a date'])( + 'rejects nonexistent or invalid local time %s', + (value) => { + vi.stubEnv('TZ', 'Europe/Berlin'); + expect(localDateTimeCandidates(value)).toEqual([]); + }, + ); + + it('accepts minute, second and fractional precision without rounding', () => { + vi.stubEnv('TZ', 'Europe/Berlin'); + expect(localDateTimeCandidates('2026-06-15T08:10')).toEqual([ + '2026-06-15T06:10:00.000Z', + ]); + expect(localDateTimeCandidates('2026-06-15T08:10:23')).toEqual([ + '2026-06-15T06:10:23.000Z', + ]); + expect(localDateTimeCandidates('2026-06-15T08:10:23.4')).toEqual([ + '2026-06-15T06:10:23.400Z', + ]); + }); + + it('also resolves non-hour folds and gaps', () => { + vi.stubEnv('TZ', 'Australia/Lord_Howe'); + expect(localDateTimeCandidates('2026-04-05T01:45')).toEqual([ + '2026-04-04T14:45:00.000Z', + '2026-04-04T15:15:00.000Z', + ]); + expect(localDateTimeCandidates('2026-10-04T02:15')).toEqual([]); + }); + + it('does not invent ambiguity in UTC and supports negative UTC offsets', () => { + vi.stubEnv('TZ', 'UTC'); + expect(localDateTimeCandidates('2026-10-25T02:30')).toEqual([ + '2026-10-25T02:30:00.000Z', + ]); + vi.stubEnv('TZ', 'America/New_York'); + expect(localDateTimeCandidates('2026-11-01T01:30')).toEqual([ + '2026-11-01T05:30:00.000Z', + '2026-11-01T06:30:00.000Z', + ]); + expect(utcOffsetLabel('2026-11-01T06:30:00.000Z')).toBe('UTC-05:00'); + }); +}); diff --git a/apps/web/src/lib/local-date-time.ts b/apps/web/src/lib/local-date-time.ts new file mode 100644 index 0000000..58b10e8 --- /dev/null +++ b/apps/web/src/lib/local-date-time.ts @@ -0,0 +1,45 @@ +/** datetime-local has no UTC offset. Keep seconds/milliseconds visible and + * enumerate matching instants rather than silently accepting Date's earlier + * occurrence (or its normalization of a nonexistent spring-forward time). */ +export function formatLocalDateTime(iso: string): string { + const d = new Date(iso); + const pad = (n: number, digits = 2) => String(n).padStart(digits, '0'); + return `${pad(d.getFullYear(), 4)}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`; +} + +export function localDateTimeCandidates(value: string): string[] { + const parts = + /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/.exec( + value, + ); + if (!parts) return []; + const normalized = `${parts[1]}:${parts[2] ?? '00'}.${(parts[3] ?? '').padEnd(3, '0')}`; + const parsed = new Date(value); + if ( + !Number.isFinite(parsed.getTime()) || + formatLocalDateTime(parsed.toISOString()) !== normalized + ) + return []; + + // The offsets on the surrounding days include both sides of a clock + // change, including non-hour changes such as Australia/Lord_Howe. + const offsets = new Set( + [-1, 0, 1].map((day) => + new Date(parsed.getTime() + day * 86_400_000).getTimezoneOffset(), + ), + ); + return [...offsets] + .map((offset) => + new Date( + parsed.getTime() + (offset - parsed.getTimezoneOffset()) * 60_000, + ).toISOString(), + ) + .filter((iso) => formatLocalDateTime(iso) === normalized) + .sort(); +} + +export function utcOffsetLabel(iso: string): string { + const minutes = -new Date(iso).getTimezoneOffset(); + const absolute = Math.abs(minutes); + return `UTC${minutes < 0 ? '-' : '+'}${String(Math.floor(absolute / 60)).padStart(2, '0')}:${String(absolute % 60).padStart(2, '0')}`; +} diff --git a/apps/web/src/routes/RequestsPage.spec.tsx b/apps/web/src/routes/RequestsPage.spec.tsx index 6479619..3398af6 100644 --- a/apps/web/src/routes/RequestsPage.spec.tsx +++ b/apps/web/src/routes/RequestsPage.spec.tsx @@ -1,4 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; import { fireEvent, screen, waitFor, within } from '@testing-library/react'; import { renderWithProviders } from '../test-utils'; import { RequestsPage } from './RequestsPage'; @@ -41,7 +49,15 @@ vi.mock('../app/auth', () => ({ }), })); -describe('vacation substitute selection', () => { +describe('request creation and draft revision', () => { + const originalTimezone = process.env.TZ; + beforeAll(() => { + process.env.TZ = 'Europe/Berlin'; + }); + afterAll(() => { + if (originalTimezone === undefined) delete process.env.TZ; + else process.env.TZ = originalTimezone; + }); beforeEach(() => { listRequestsMock.mockReset().mockResolvedValue([]); getRequestMock.mockReset(); @@ -208,4 +224,253 @@ describe('vacation substitute selection', () => { screen.queryByRole('button', { name: /Antrag bearbeiten/ }), ).toBeNull(); }); + async function openTimeDraft(from: string, to: string) { + const correction = { ...draft, type: 'TimeAdjustment', from, to }; + listRequestsMock.mockResolvedValue([correction]); + getRequestMock.mockResolvedValue(correction); + renderWithProviders(); + fireEvent.click( + await screen.findByRole('button', { + name: 'Entwurf · Antrag bearbeiten', + }), + ); + await screen.findByLabelText('Von'); + return correction; + } + + it.each([ + ['summer seconds', '2026-06-15T06:10:23.456Z', '2026-06-15T07:20:45.789Z'], + [ + 'first autumn occurrence', + '2026-10-25T00:10:23.456Z', + '2026-10-25T00:30:45.789Z', + ], + [ + 'second autumn occurrence', + '2026-10-25T01:10:23.456Z', + '2026-10-25T01:30:45.789Z', + ], + [ + 'fold crossing with locally earlier end', + '2026-10-25T00:50:23.456Z', + '2026-10-25T01:10:45.789Z', + ], + ])( + 'preserves exact instants on reason-only save and resubmit: %s', + async (_name, from, to) => { + const correction = await openTimeDraft(from, to); + fireEvent.change(screen.getByLabelText('Begründung (optional)'), { + target: { value: 'Reason only' }, + }); + const saved = { + ...correction, + reason: 'Reason only', + updatedAt: '2026-05-01T12:00:00.002Z', + }; + updateDraftRequestMock.mockResolvedValue(saved); + getRequestMock.mockResolvedValue(saved); + const save = screen.getByRole('button', { name: 'Entwurf speichern' }); + expect((save as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(save); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(updateDraftRequestMock).toHaveBeenCalledWith( + 'same-request', + expect.objectContaining({ from, to, reason: 'Reason only' }), + ); + fireEvent.click( + screen.getByRole('button', { name: 'Entwurf · Antrag bearbeiten' }), + ); + await screen.findByLabelText('Von'); + const submit = screen.getByRole('button', { name: 'Erneut einreichen' }); + expect((submit as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(submit); + await waitFor(() => + expect(resubmitRequestMock).toHaveBeenCalledWith( + 'same-request', + expect.objectContaining({ + from, + to, + reason: 'Reason only', + expectedUpdatedAt: saved.updatedAt, + }), + ), + ); + }, + ); + + it('shows seconds and the saved occurrence, and changing one field preserves the other', async () => { + await openTimeDraft('2026-10-25T01:10:23.456Z', '2026-10-25T02:30:45.789Z'); + expect((screen.getByLabelText('Von') as HTMLInputElement).value).toBe( + '2026-10-25T02:10:23.456', + ); + expect( + (screen.getByLabelText('Von – Vorkommen') as HTMLSelectElement).value, + ).toBe('2026-10-25T01:10:23.456Z'); + fireEvent.change(screen.getByLabelText('Bis'), { + target: { value: '2026-10-25T03:45' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Erneut einreichen' })); + await waitFor(() => + expect(resubmitRequestMock).toHaveBeenCalledWith( + 'same-request', + expect.objectContaining({ + from: '2026-10-25T01:10:23.456Z', + to: '2026-10-25T02:45:00.000Z', + }), + ), + ); + }); + + it.each([ + ['2026-10-25T00:30:00.000Z', '1. Vorkommen (UTC+02:00)'], + ['2026-10-25T01:30:00.000Z', '2. Vorkommen (UTC+01:00)'], + ])( + 'requires an explicit occurrence for a changed ambiguous time: %s', + async (from, label) => { + await openTimeDraft( + '2026-10-25T00:10:23.456Z', + '2026-10-25T02:30:45.789Z', + ); + fireEvent.change(screen.getByLabelText('Von'), { + target: { value: '2026-10-25T02:30' }, + }); + const select = screen.getByLabelText('Von – Vorkommen'); + expect((select as HTMLSelectElement).value).toBe(''); + expect( + screen.getByText( + /Diese Ortszeit kommt bei der Zeitumstellung in Europe\/Berlin/, + ), + ).toBeDefined(); + for (const name of ['Entwurf speichern', 'Erneut einreichen']) { + const button = screen.getByRole('button', { name }); + expect((button as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(button); + } + expect(updateDraftRequestMock).not.toHaveBeenCalled(); + expect(resubmitRequestMock).not.toHaveBeenCalled(); + expect(within(select).getByRole('option', { name: label })).toBeDefined(); + fireEvent.change(select, { target: { value: from } }); + fireEvent.click( + screen.getByRole('button', { name: 'Erneut einreichen' }), + ); + await waitFor(() => + expect(resubmitRequestMock).toHaveBeenCalledWith( + 'same-request', + expect.objectContaining({ from, to: '2026-10-25T02:30:45.789Z' }), + ), + ); + }, + ); + + it('validates the chosen instants, allows a fold crossing and resets a stale occurrence after another edit', async () => { + await openTimeDraft('2026-10-25T00:30:00.000Z', '2026-10-25T01:40:00.000Z'); + fireEvent.change(screen.getByLabelText('Von'), { + target: { value: '2026-10-25T02:50' }, + }); + fireEvent.change(screen.getByLabelText('Von – Vorkommen'), { + target: { value: '2026-10-25T01:50:00.000Z' }, + }); + const submit = screen.getByRole('button', { name: 'Erneut einreichen' }); + expect((submit as HTMLButtonElement).disabled).toBe(true); + // End 02:40 CET is after start 02:50 CEST, although the wall clock is earlier. + fireEvent.change(screen.getByLabelText('Von – Vorkommen'), { + target: { value: '2026-10-25T00:50:00.000Z' }, + }); + expect((submit as HTMLButtonElement).disabled).toBe(false); + fireEvent.change(screen.getByLabelText('Von'), { + target: { value: '2026-10-25T02:55' }, + }); + expect( + (screen.getByLabelText('Von – Vorkommen') as HTMLSelectElement).value, + ).toBe(''); + expect((submit as HTMLButtonElement).disabled).toBe(true); + fireEvent.change(screen.getByLabelText('Von – Vorkommen'), { + target: { value: '2026-10-25T00:55:00.000Z' }, + }); + fireEvent.click(submit); + await waitFor(() => + expect(resubmitRequestMock).toHaveBeenCalledWith( + 'same-request', + expect.objectContaining({ + from: '2026-10-25T00:55:00.000Z', + to: '2026-10-25T01:40:00.000Z', + }), + ), + ); + }); + + it('rejects nonexistent spring-forward and empty times without a request', async () => { + await openTimeDraft('2026-03-29T00:10:23.456Z', '2026-03-29T02:30:45.789Z'); + for (const value of ['2026-03-29T02:30', '']) { + fireEvent.change(screen.getByLabelText('Bis'), { target: { value } }); + expect( + screen.getByText(/Bitte eine gültige Ortszeit eingeben/), + ).toBeDefined(); + const save = screen.getByRole('button', { name: 'Entwurf speichern' }); + const submit = screen.getByRole('button', { name: 'Erneut einreichen' }); + expect((save as HTMLButtonElement).disabled).toBe(true); + expect((submit as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(save); + fireEvent.click(submit); + } + expect(updateDraftRequestMock).not.toHaveBeenCalled(); + expect(resubmitRequestMock).not.toHaveBeenCalled(); + }); + it('offers an accessible English occurrence choice for a changed end time and saves the chosen instant', async () => { + const languageSpy = vi + .spyOn(navigator, 'languages', 'get') + .mockReturnValue(['en-GB']); + try { + const correction = { + ...draft, + type: 'TimeAdjustment', + from: '2026-10-25T00:50:23.456Z', + to: '2026-10-25T02:30:45.789Z', + }; + listRequestsMock.mockResolvedValue([correction]); + getRequestMock.mockResolvedValue(correction); + renderWithProviders(); + fireEvent.click( + await screen.findByRole('button', { name: 'Draft · Edit request' }), + ); + fireEvent.change(await screen.findByLabelText('To'), { + target: { value: '2026-10-25T02:10' }, + }); + expect(screen.getAllByText(/This local time occurs twice/)).toHaveLength( + 2, + ); + const select = screen.getByRole('combobox', { name: 'To – occurrence' }); + expect( + within(select).getByRole('option', { name: 'Choose occurrence' }), + ).toBeDefined(); + expect( + within(select).getByRole('option', { + name: 'Occurrence 1 (UTC+02:00)', + }), + ).toBeDefined(); + expect( + within(select).getByRole('option', { + name: 'Occurrence 2 (UTC+01:00)', + }), + ).toBeDefined(); + const save = screen.getByRole('button', { name: 'Save draft' }); + expect((save as HTMLButtonElement).disabled).toBe(true); + fireEvent.change(select, { + target: { value: '2026-10-25T01:10:00.000Z' }, + }); + expect((save as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(save); + await waitFor(() => + expect(updateDraftRequestMock).toHaveBeenCalledWith( + 'same-request', + expect.objectContaining({ + from: correction.from, + to: '2026-10-25T01:10:00.000Z', + }), + ), + ); + } finally { + languageSpy.mockRestore(); + } + }); }); diff --git a/apps/web/src/routes/RequestsPage.tsx b/apps/web/src/routes/RequestsPage.tsx index f893024..2a26a39 100644 --- a/apps/web/src/routes/RequestsPage.tsx +++ b/apps/web/src/routes/RequestsPage.tsx @@ -18,6 +18,11 @@ import { Label } from '@/components/ui/label'; import { api, type RequestDto, type RequestType } from '../api/client'; import { useCurrentUser } from '../app/auth'; import { useI18n } from '../app/i18n'; +import { + formatLocalDateTime, + localDateTimeCandidates, + utcOffsetLabel, +} from '../lib/local-date-time'; const TYPES: RequestType[] = [ 'Vacation', @@ -209,11 +214,95 @@ interface NewRequestFormProps { request?: RequestDto; } -function isoDateTimeLocalNow(iso?: string): string { - const d = iso ? new Date(iso) : new Date(); - // datetime-local needs YYYY-MM-DDTHH:mm in *local* time - const pad = (n: number) => String(n).padStart(2, '0'); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +function useLocalDateTime(originalIso?: string) { + const [value, setValue] = useState(() => { + const now = new Date(); + now.setSeconds(0, 0); + return formatLocalDateTime(originalIso ?? now.toISOString()); + }); + const [chosenIso, setChosenIso] = useState(originalIso ?? ''); + const candidates = useMemo(() => localDateTimeCandidates(value), [value]); + const chosen = candidates.find( + (iso) => new Date(iso).getTime() === new Date(chosenIso).getTime(), + ); + // Preserve the exact persisted value until this field is changed, not just + // its minute-level local representation. Validation and payload share iso. + const iso = chosen + ? chosenIso + : candidates.length === 1 + ? candidates[0] + : null; + return { + value, + candidates, + iso, + setChosenIso, + change: (next: string) => { + setValue(next); + setChosenIso(''); + }, + }; +} + +function LocalDateTimeField({ + id, + label, + time, +}: { + id: string; + label: string; + time: ReturnType; +}) { + const { t } = useI18n(); + const ambiguous = time.candidates.length > 1; + const invalid = !time.iso; + return ( + <> + time.change(e.target.value)} + aria-invalid={invalid} + aria-describedby={ambiguous || invalid ? `${id}-time-help` : undefined} + /> + {ambiguous ? ( +
+

+ {t('requests.ambiguousTime', { + zone: Intl.DateTimeFormat().resolvedOptions().timeZone, + })} +

+ + +
+ ) : invalid ? ( +

+ {t('requests.nonexistentTime')} +

+ ) : null} + + ); } function NewRequestForm({ onClose, request }: NewRequestFormProps) { @@ -224,10 +313,12 @@ function NewRequestForm({ onClose, request }: NewRequestFormProps) { const [type, setType] = useState(request?.type ?? 'Vacation'); const [from, setFrom] = useState(request?.from.slice(0, 10) ?? today); const [to, setTo] = useState(request?.to.slice(0, 10) ?? today); - const [fromDt, setFromDt] = useState(() => - isoDateTimeLocalNow(request?.from), + const fromTime = useLocalDateTime( + request?.type === 'TimeAdjustment' ? request.from : undefined, + ); + const toTime = useLocalDateTime( + request?.type === 'TimeAdjustment' ? request.to : undefined, ); - const [toDt, setToDt] = useState(() => isoDateTimeLocalNow(request?.to)); const [reason, setReason] = useState(request?.reason ?? ''); const [substituteId, setSubstituteId] = useState( request?.substituteId ?? '', @@ -269,23 +360,35 @@ function NewRequestForm({ onClose, request }: NewRequestFormProps) { const isTimeAdjustment = type === 'TimeAdjustment'; - const invalidRange = useMemo(() => { - if (isTimeAdjustment) - return new Date(toDt).getTime() <= new Date(fromDt).getTime(); - return new Date(to).getTime() < new Date(from).getTime(); - }, [isTimeAdjustment, from, to, fromDt, toDt]); + const unresolvedTime = isTimeAdjustment && (!fromTime.iso || !toTime.iso); + const invalidRange = isTimeAdjustment + ? !fromTime.iso || + !toTime.iso || + new Date(toTime.iso).getTime() <= new Date(fromTime.iso).getTime() + : new Date(to).getTime() < new Date(from).getTime(); const create = useMutation({ mutationFn: async (saveOnly: boolean) => { + const fromIso = isTimeAdjustment + ? fromTime.iso + : new Date(`${from}T00:00:00.000Z`).toISOString(); + const toIso = isTimeAdjustment + ? toTime.iso + : new Date(`${to}T00:00:00.000Z`).toISOString(); + if (!fromIso || !toIso || invalidRange) { + throw new Error( + t( + isTimeAdjustment + ? 'requests.invalidTimeRange' + : 'requests.invalidDateRange', + ), + ); + } if (request) { const payload = { expectedUpdatedAt: request.updatedAt, - from: isTimeAdjustment - ? new Date(fromDt).toISOString() - : new Date(`${from}T00:00:00.000Z`).toISOString(), - to: isTimeAdjustment - ? new Date(toDt).toISOString() - : new Date(`${to}T00:00:00.000Z`).toISOString(), + from: fromIso, + to: toIso, reason: reason || null, substituteId: type === 'Vacation' ? substituteId || null : null, halfDayStart: type === 'Vacation' && halfDayStart, @@ -298,20 +401,14 @@ function NewRequestForm({ onClose, request }: NewRequestFormProps) { if (type === 'Vacation') { return api.createVacationRequest({ employeeId, - from: new Date(`${from}T00:00:00.000Z`).toISOString(), - to: new Date(`${to}T00:00:00.000Z`).toISOString(), + from: fromIso, + to: toIso, substituteId: substituteId || null, reason: reason || null, halfDayStart, halfDayEnd, }); } - const fromIso = isTimeAdjustment - ? new Date(fromDt).toISOString() - : new Date(`${from}T00:00:00.000Z`).toISOString(); - const toIso = isTimeAdjustment - ? new Date(toDt).toISOString() - : new Date(`${to}T00:00:00.000Z`).toISOString(); const created = await api.createRequest({ employeeId, type, @@ -384,11 +481,10 @@ function NewRequestForm({ onClose, request }: NewRequestFormProps) {
{isTimeAdjustment ? ( - setFromDt(e.target.value)} + label={t('common.from')} + time={fromTime} /> ) : ( {isTimeAdjustment ? ( - setToDt(e.target.value)} + label={t('common.to')} + time={toTime} /> ) : ( )} - {invalidRange && ( + {invalidRange && !unresolvedTime && ( {isTimeAdjustment diff --git a/docs/ISSUE_FIXES_MANUAL_TEST.de.md b/docs/ISSUE_FIXES_MANUAL_TEST.de.md index 1a10b92..b79387e 100644 --- a/docs/ISSUE_FIXES_MANUAL_TEST.de.md +++ b/docs/ISSUE_FIXES_MANUAL_TEST.de.md @@ -149,6 +149,59 @@ Die Beispieldaten an den freien Testzeitraum anpassen. | Zwei verschiedene Entwürfe parallel einreichen, Urlaub reicht nur für einen | genau einer erfolgreich; keine Überbuchung des Kontos | | Bereits laufende alte Managerentscheidung während Rückgabe + Wiedereinreichung | alte Entscheidung scheitert mit 409, neue Werte/neuer Freigabezyklus bleiben bestehen | +### D. Zeitkorrekturen: Sekunden und Herbst-Zeitumstellung (#42-Nachkorrektur) + +Den Browser bzw. die Browser-Testumgebung ausdrücklich auf **Europe/Berlin** +stellen (z. B. DevTools → Sensors → Location/Timezone). Die Maske verwendet wie +bisher die lokale Browser-Zeitzone. Nur den isolierten synthetischen Teststack +nutzen, keine vorhandenen Zeitbuchungen überlappen und die Szenarien einzeln +ausführen; anschließend den jeweiligen Testantrag stornieren oder getrennte +synthetische Beschäftigte verwenden. + +Für präzise Ausgangswerte als Anna über die Netzwerkdetails einen synthetischen +`POST /api/requests` mit Annas `employeeId`, `type=TimeAdjustment`, einem +Testgrund und den unten angegebenen `from`-/`to`-Werten anlegen. Der Server +erwartet ISO-Zeitpunkte, nicht offsetlose Ortszeiten. Als Marc zur Korrektur +zurückgeben (nicht genehmigen; die Sondergenehmigungs-/HR-Regeln gelten weiter). + +| Ausgangsfall | ISO-Anfang → ISO-Ende | Lokale Anzeige Europe/Berlin | +| --- | --- | --- | +| Normale Sekunden/Millisekunden | `2026-06-15T06:10:23.456Z` → `2026-06-15T07:20:45.789Z` | 08:10:23.456 → 09:20:45.789 | +| Erstes Herbst-Vorkommen | `2026-10-25T00:10:23.456Z` → `2026-10-25T00:30:45.789Z` | 02:10:23.456 → 02:30:45.789, jeweils UTC+02:00 | +| Zweites Herbst-Vorkommen | `2026-10-25T01:10:23.456Z` → `2026-10-25T01:30:45.789Z` | dieselben Ortszeiten, jeweils UTC+01:00 | +| Über die Rückstellung | `2026-10-25T00:50:23.456Z` → `2026-10-25T01:10:45.789Z` | 02:50:23.456 UTC+02:00 → 02:10:45.789 UTC+01:00 | + +1. Je Ausgangsfall Entwurf öffnen, **nur die Begründung ändern**, speichern, + wieder öffnen und erneut einreichen. Netzwerk-Payload und Antwort für + `PATCH /api/requests/:id` und `POST /api/requests/:id/resubmit` prüfen: + `from`/`to` bleiben **exakt** wie in der Tabelle, einschließlich Sekunden, + Millisekunden und Vorkommen; gleiche ID. Das rückstellungsübergreifende + Intervall bleibt zulässig, obwohl die lokale Enduhrzeit kleiner ist. +2. Zum Vergleich den konkreten Fehlerfall mit + `2026-10-25T01:30:00.000Z` als Anfang und z. B. + `2026-10-25T02:30:00.000Z` als Ende prüfen: Bei einer reinen + Begründungsänderung darf der Anfang **nicht** nach + `2026-10-25T00:30:00.000Z` wechseln. +3. In einem zurückgegebenen Zeitkorrektur-Entwurf den Anfang auf + **25.10.2026 02:30** ändern (Ende z. B. 03:30). Erwartet: verständlicher + Zeitzonenhinweis und beschriftetes Feld **Von – Vorkommen**; kein stiller + Standardwert. Speichern und Wiedereinreichen bleiben gesperrt, bis explizit + gewählt wurde. **1. Vorkommen (UTC+02:00)** sendet + `2026-10-25T00:30:00.000Z`, **2. Vorkommen (UTC+01:00)** sendet + `2026-10-25T01:30:00.000Z`. Das unveränderte Ende bleibt exakt erhalten. +4. Dasselbe am Ende prüfen. Anfang 02:50 UTC+02:00, Ende 02:10 UTC+01:00: + zulässig. Anfang auf das zweite Vorkommen umstellen: Ende liegt davor, + deshalb gesperrt. Erneute Änderung des Ortszeitfelds verwirft eine vorherige + Vorkommenswahl und verlangt bei Mehrdeutigkeit eine neue Auswahl. +5. Ortszeit **29.03.2026 02:30** oder ein leeres Zeitfeld eingeben: + verständlicher Fehler; kein API-Aufruf und keine automatische Verschiebung + auf 03:30. Ein normaler gültiger Zeitpunkt hebt die Sperre wieder auf, + sofern das Ende nach dem Anfang liegt. +6. Auf Englisch wiederholen: **From/To – occurrence**, **Choose occurrence**, + **Occurrence 1 (UTC+02:00)** / **Occurrence 2 (UTC+01:00)** und Erklärung sind + übersetzt. Auswahl mit Tab und Pfeiltasten bedienen; Zeitfeld und Auswahl + sind mit ihren jeweiligen Labels/Hinweisen verknüpft. + ### Automatisch ausgeführte Gates Stand: 07.11.2026. Tests verwenden eine eigene synthetische PostgreSQL-16-Instanz @@ -170,6 +223,38 @@ Ausgeführt mit Node `v26.12.1`, pnpm `9.15.9` (für diesen Worktree über | `pnpm verify:api` mit der isolierten Test-`DATABASE_URL` | **Erfolgreich**, OpenAPI und generierter Client identisch zur gestagten Fassung | | `git diff --check` | **Erfolgreich** | +#### Nachkorrektur des unabhängigen P2-Befunds: Zeit-Roundtrip + +Ergänzt am 07.11.2026, gleicher Branch/Worktree. **18 zusätzliche Regressionen** +(10 UI-Fälle und 8 Zeitauflösungsfälle). Die betroffenen Tests setzen ihre +Zeitzonen selbst und stellen sie wieder her; die Web-Läufe wurden zusätzlich +mit `TZ=UTC` gestartet, damit die Berlin-Fälle nicht zufällig von der +Host-Zeitzone abhängen. + +| Gate | Ergebnis | +| --- | --- | +| Vier neue reine Begründungs-Roundtrips gegen die unveränderte `RequestsPage.tsx` aus `230dbb0` | **4/4 erwartungsgemäß fehlgeschlagen**: Sekundenverlust, zweites Vorkommen um eine Stunde verschoben bzw. gültiges rückstellungsübergreifendes Intervall gesperrt; danach korrigierte Datei wiederhergestellt | +| `TZ=UTC pnpm nx test web --run src/routes/RequestsPage.spec.tsx src/lib/local-date-time.spec.ts src/app/i18n.spec.tsx` | **29/29 bestanden** | +| `TZ=UTC pnpm nx test web --run` (finaler Code) | **208/208 Tests, 34 Dateien bestanden** | +| `TZ=UTC pnpm nx run-many -t test -p web,shared --run` | Web erfolgreich; **Shared 88/88 aus unverändertem Nx-Cache** | +| `pnpm nx run-many -t typecheck,lint -p web` | **Erfolgreich; 0 Fehler, nur 2 bestehende Warnungen** in `ThemeProvider.tsx` und `components/ui/alert.tsx`, keine neue Warnung | +| `pnpm nx build web` (nach beendetem Typecheck) | **Erfolgreich**, einschließlich PWA-Build | +| `git diff --check` | **Erfolgreich** | + +Abgedeckt: erfolgreicher Ablauf Speichern → Wiederöffnen → Wiedereinreichen mit +neuem Versionswert, exakte Sekunden/Millisekunden, beide Herbst-Vorkommen, +unverändertes Gegenfeld, explizite DE/EN-Auswahl beider UTC-Offsets, identische +Validierungs-/Payload-Zeitpunkte, lokal früheres aber tatsächlich späteres Ende, +veraltete Auswahl nach weiterer Zeitänderung, leere Eingabe und Frühjahrslücke. +Die Hilfsfunktion wird zusätzlich für UTC, New York und eine halbstündige +Lord-Howe-Zeitumstellung geprüft. + +Nur Web und Anleitung geändert: keine API-, OpenAPI-, Client-Vertrags- oder +Datenbankänderung. Die bisherigen API-Gates bleiben oben dokumentiert; die +reguläre PR-CI prüft auch auf diesem Head erneut API-Synchronität und API-E2E. +Der manuelle Browser-Durchlauf aus Abschnitt D ist eine Anleitung, **kein** +behauptetes Testergebnis. + Beim kombinierten parallelen Build-/Typecheck-Zwischenlauf entfernte Vite das von TypeScript ebenfalls verwendete Web-`dist`-Verzeichnis (`TS6305`). Der anschließende erzwungene, separat ausgeführte Web-Typecheck und der separate From 4ff247bc18adfc0b245d8dbb044f7968cf2a4678 Mon Sep 17 00:00:00 2001 From: Patrick Schiller <39913537+patrickschiller@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:55:13 +0100 Subject: [PATCH 3/7] test: isolate correction editor language preference (#42) Keep the English DST selection regression independent of jsdom persisted locale on Node 20. Verify all 208 web tests with Node 20.20.2 and document the CI follow-up. Co-authored-by: patrickschiller <39913537+patrickschiller@users.noreply.github.com> Signed-off-by: Patrick Schiller <39913537+patrickschiller@users.noreply.github.com> --- apps/web/src/routes/RequestsPage.spec.tsx | 12 ++++++++++++ docs/ISSUE_FIXES_MANUAL_TEST.de.md | 10 ++++++++++ 2 files changed, 22 insertions(+) diff --git a/apps/web/src/routes/RequestsPage.spec.tsx b/apps/web/src/routes/RequestsPage.spec.tsx index 3398af6..57004ea 100644 --- a/apps/web/src/routes/RequestsPage.spec.tsx +++ b/apps/web/src/routes/RequestsPage.spec.tsx @@ -1,5 +1,6 @@ import { afterAll, + afterEach, beforeAll, beforeEach, describe, @@ -58,7 +59,18 @@ describe('request creation and draft revision', () => { if (originalTimezone === undefined) delete process.env.TZ; else process.env.TZ = originalTimezone; }); + afterEach(() => vi.restoreAllMocks()); beforeEach(() => { + // Persisted locale wins over navigator.languages. Isolate it on Node 20 + // (jsdom storage) as well as newer Node versions (native localStorage). + vi.spyOn(window, 'localStorage', 'get').mockReturnValue({ + length: 0, + clear: vi.fn(), + key: () => null, + removeItem: vi.fn(), + getItem: () => null, + setItem: vi.fn(), + }); listRequestsMock.mockReset().mockResolvedValue([]); getRequestMock.mockReset(); getRequestEventsMock.mockReset().mockResolvedValue([]); diff --git a/docs/ISSUE_FIXES_MANUAL_TEST.de.md b/docs/ISSUE_FIXES_MANUAL_TEST.de.md index b79387e..073b92d 100644 --- a/docs/ISSUE_FIXES_MANUAL_TEST.de.md +++ b/docs/ISSUE_FIXES_MANUAL_TEST.de.md @@ -249,6 +249,16 @@ veraltete Auswahl nach weiterer Zeitänderung, leere Eingabe und Frühjahrslück Die Hilfsfunktion wird zusätzlich für UTC, New York und eine halbstündige Lord-Howe-Zeitumstellung geprüft. +Der erste CI-Nachlauf bestand 207/208 Web-Tests: Der neue Englisch-Test +übernahm unter Node 20 die in jsdom gespeicherte deutsche Sprachpräferenz, die +absichtlich Vorrang vor der Browsersprache hat. Die Testfixture isoliert den +Storage jetzt explizit wie die vorhandenen i18n-Tests; kein Anwendungscode +musste dafür geändert werden. Der folgende lokale Nachlauf bestand mit +Node **20.20.2** (nur per temporärem Tool-PATH, keine Repository-Konfiguration +geändert): `TZ=UTC pnpm nx test web --run --skip-nx-cache` — +**208/208 Tests in 34 Dateien**. Auch unter Node 26 wurden danach erneut +208/208 bestanden; Lint ist unverändert grün, Typecheck aus unverändertem Cache. + Nur Web und Anleitung geändert: keine API-, OpenAPI-, Client-Vertrags- oder Datenbankänderung. Die bisherigen API-Gates bleiben oben dokumentiert; die reguläre PR-CI prüft auch auf diesem Head erneut API-Synchronität und API-E2E. From dbe68f57a93ba26bdc315d31fd4b313586a4337b Mon Sep 17 00:00:00 2001 From: Patrick Schiller <39913537+patrickschiller@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:25:10 +0100 Subject: [PATCH 4/7] fix: approve outside-frame clocked time through manager and HR (#41) Add scoped, versioned TimeEntry review with distinct current-manager and HR stages, durable decision audit, legacy pending support and conservative edit invalidation. Surface employee guidance, reviewer actions and history in German/English, synchronize API contracts, and verify additive database upgrades and security/concurrency regressions. Based on accepted #42; no release or merge. Co-authored-by: patrickschiller <39913537+patrickschiller@users.noreply.github.com> Signed-off-by: Patrick Schiller <39913537+patrickschiller@users.noreply.github.com> --- .../src/api/time-entry-approvals.e2e.spec.ts | 572 +++++++++++++++++ apps/api/openapi.json | 222 +++++++ .../app/terminals/terminal-booking.service.ts | 2 + .../time-entries/time-entries.controller.ts | 47 +- .../app/time-entries/time-entries.module.ts | 3 +- .../app/time-entries/time-entries.service.ts | 64 +- .../time-entries/time-entry-approvals.dto.ts | 71 +++ .../time-entry-approvals.service.ts | 347 +++++++++++ apps/web/src/api/client.ts | 48 ++ apps/web/src/api/generated.ts | 151 +++++ apps/web/src/app/i18n.tsx | 121 ++++ apps/web/src/app/realtime.ts | 2 + .../timeentries/TimeEntryApprovals.spec.tsx | 588 ++++++++++++++++++ .../timeentries/TimeEntryApprovals.tsx | 319 ++++++++++ .../web/src/routes/AdminRequestsPage.spec.tsx | 4 + apps/web/src/routes/AdminRequestsPage.tsx | 3 + apps/web/src/routes/BookingPage.spec.tsx | 50 ++ apps/web/src/routes/BookingPage.tsx | 31 +- docs/ISSUE_FIXES_MANUAL_TEST.de.md | 238 ++++++- ops/upgrade-smoke.mjs | 35 ++ .../migration.sql | 10 + prisma/schema.prisma | 5 + 22 files changed, 2915 insertions(+), 18 deletions(-) create mode 100644 apps/api-e2e/src/api/time-entry-approvals.e2e.spec.ts create mode 100644 apps/api/src/app/time-entries/time-entry-approvals.dto.ts create mode 100644 apps/api/src/app/time-entries/time-entry-approvals.service.ts create mode 100644 apps/web/src/components/timeentries/TimeEntryApprovals.spec.tsx create mode 100644 apps/web/src/components/timeentries/TimeEntryApprovals.tsx create mode 100644 prisma/migrations/20261107031000_time_entry_frame_approval/migration.sql diff --git a/apps/api-e2e/src/api/time-entry-approvals.e2e.spec.ts b/apps/api-e2e/src/api/time-entry-approvals.e2e.spec.ts new file mode 100644 index 0000000..6b8e8a3 --- /dev/null +++ b/apps/api-e2e/src/api/time-entry-approvals.e2e.spec.ts @@ -0,0 +1,572 @@ +import { TimeEntriesService } from '../../../api/src/app/time-entries/time-entries.service'; +import { + createTestApp, + login, + seedEmployee, + type TestContext, +} from '../support/test-app'; + +describe('Outside-frame TimeEntry approvals (#41)', () => { + let ctx: TestContext; + type Person = { id: string; token: string }; + let manager: Person, + otherManager: Person, + owner: Person, + other: Person, + hr: Person, + secondHr: Person; + + beforeAll(async () => { + ctx = await createTestApp(); + }); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.reset(); + async function person( + name: string, + role: 'Employee' | 'Manager' | 'HRAdmin', + managerId?: string, + ) { + const e = await seedEmployee(ctx.prisma, { + personalNo: name, + firstName: name, + lastName: 'Example', + email: `${name}@test.local`, + role, + managerId, + }); + return { id: e.id, token: await login(ctx.http, e.email) }; + } + manager = await person('manager', 'Manager'); + otherManager = await person('other-manager', 'Manager'); + owner = await person('owner', 'Employee', manager.id); + other = await person('other', 'Employee', otherManager.id); + hr = await person('hr', 'HRAdmin', manager.id); + secondHr = await person('second-hr', 'HRAdmin'); + }); + + async function entry(employeeId = owner.id, extra = {}) { + return ctx.prisma.timeEntry.create({ + data: { + employeeId, + clockIn: new Date('2026-05-04T04:00:00Z'), + clockOut: new Date('2026-05-04T06:00:00Z'), + status: 'Pending', + requiresApproval: true, + source: 'Pwa', + ...extra, + }, + }); + } + function list(who: Person, scope = 'review') { + return ctx.http + .get(`/api/timeentries/approvals?scope=${scope}`) + .set('Authorization', `Bearer ${who.token}`); + } + function decide( + id: string, + who: Person, + revision = 0, + stage = 'Manager', + outcome = 'Approve', + extra = {}, + ) { + return ctx.http + .post(`/api/timeentries/${id}/approval`) + .set('Authorization', `Bearer ${who.token}`) + .send({ revision, stage, outcome, ...extra }); + } + function history(id: string, who: Person) { + return ctx.http + .get(`/api/timeentries/${id}/approval-history`) + .set('Authorization', `Bearer ${who.token}`); + } + async function managerApproved(id: string) { + return (await decide(id, manager).expect(201)).body; + } + + it('reviews legacy Pending/null mode entries through manager then HR with versioned audit and same ID', async () => { + const row = await entry(); + expect(row.approvalMode).toBeNull(); + expect((await list(manager).expect(200)).body[0]).toMatchObject({ + revision: 0, + state: 'PendingManager', + canDecide: true, + managerId: manager.id, + employeeName: 'owner Example', + }); + const managed = await managerApproved(row.id); + expect(managed).toMatchObject({ + revision: 1, + state: 'PendingHr', + canDecide: false, + entry: { id: row.id, status: 'Pending' }, + }); + expect((await list(hr).expect(200)).body[0]).toMatchObject({ + state: 'PendingHr', + canDecide: true, + }); + const final = await decide(row.id, hr, 1, 'HR', 'Approve', { + note: 'HR checked', + }).expect(201); + expect(final.body).toMatchObject({ + revision: 2, + state: 'Approved', + entry: { id: row.id, status: 'Approved', requiresApproval: true }, + }); + expect(await ctx.prisma.timeEntry.count()).toBe(1); + const events = (await history(row.id, owner).expect(200)).body; + expect(events).toMatchObject([ + { + actorId: manager.id, + actorName: 'manager Example', + action: 'FrameManagerApproved', + }, + { actorId: hr.id, action: 'FrameHrConfirmed', reason: 'HR checked' }, + ]); + expect( + events.every( + (e: { occurredAt: string }) => !Number.isNaN(Date.parse(e.occurredAt)), + ), + ).toBe(true); + }); + + it.each(['Manager', 'HR'])( + 'supports %s rejection with mandatory reason and no approval afterward', + async (stage) => { + const row = await entry(); + const actor = stage === 'Manager' ? manager : hr; + const revision = + stage === 'Manager' ? 0 : (await managerApproved(row.id)).revision; + await decide(row.id, actor, revision, stage, 'Reject', { + note: ' ', + }).expect(400); + const rejected = await decide(row.id, actor, revision, stage, 'Reject', { + note: 'Not authorized work', + }).expect(201); + expect(rejected.body).toMatchObject({ + state: 'Rejected', + canDecide: false, + entry: { status: 'Rejected' }, + }); + await decide(row.id, actor, revision + 1, stage).expect(409); + const events = (await history(row.id, owner).expect(200)).body; + expect(events.at(-1)).toMatchObject({ + action: + stage === 'Manager' ? 'FrameManagerRejected' : 'FrameHrRejected', + reason: 'Not authorized work', + }); + }, + ); + + it('scopes review to direct team and own list to bearer identity; employeeId/actorId cannot forge ownership', async () => { + const own = await entry(); + const foreign = await entry(other.id); + expect( + (await list(manager).expect(200)).body.map( + (r: { entry: { id: string } }) => r.entry.id, + ), + ).toEqual([own.id]); + const response = await ctx.http + .get(`/api/timeentries/approvals?scope=own&employeeId=${other.id}`) + .set('Authorization', `Bearer ${owner.token}`) + .expect(200); + expect( + response.body.map((r: { entry: { id: string } }) => r.entry.id), + ).toEqual([own.id]); + await list(owner).expect(403); + await decide(own.id, otherManager, 0, 'Manager', 'Approve', { + actorId: manager.id, + employeeId: owner.id, + }).expect(403); + await decide(foreign.id, manager).expect(403); + await history(foreign.id, manager).expect(403); + await history(own.id, other).expect(403); + await decide(own.id, owner).expect(403); + await list(owner, 'invalid').expect(400); + await ctx.http.get('/api/timeentries/approvals').expect(401); + await ctx.http + .post(`/api/timeentries/${own.id}/approval`) + .send({ revision: 0, stage: 'Manager', outcome: 'Approve' }) + .expect(401); + }); + + it('does not let HR skip manager stage, managers act as HR, or owners self-authorize through a broad role', async () => { + const row = await entry(); + await decide(row.id, hr, 0, 'HR').expect(409); + await decide(row.id, hr, 0, 'Manager').expect(403); + await decide(row.id, manager, 0, 'HR').expect(403); + await managerApproved(row.id); + await decide(row.id, manager, 1, 'HR').expect(403); + const ownManager = await entry(manager.id); + await ctx.prisma.employee.update({ + where: { id: manager.id }, + data: { managerId: manager.id }, + }); + await decide(ownManager.id, manager).expect(403); + const ownHr = await entry(hr.id); + await decide(ownHr.id, manager).expect(201); + await decide(ownHr.id, hr, 1, 'HR').expect(403); + await decide(ownHr.id, secondHr, 1, 'HR').expect(201); + expect( + (await list(hr).expect(200)).body.some( + (r: { entry: { id: string } }) => r.entry.id === ownHr.id, + ), + ).toBe(false); + }); + + it.each(['missing', 'inactive', 'wrong-role', 'self'])( + 'blocks %s manager configuration until HR assigns a valid manager', + async (kind) => { + const row = await entry(); + if (kind === 'missing') + await ctx.prisma.employee.update({ + where: { id: owner.id }, + data: { managerId: null }, + }); + if (kind === 'inactive') + await ctx.prisma.employee.update({ + where: { id: manager.id }, + data: { isActive: false }, + }); + if (kind === 'wrong-role') + await ctx.prisma.employee.update({ + where: { id: owner.id }, + data: { managerId: hr.id }, + }); + if (kind === 'self') + await ctx.prisma.employee.update({ + where: { id: owner.id }, + data: { managerId: owner.id }, + }); + expect((await list(owner, 'own').expect(200)).body[0]).toMatchObject({ + state: 'BlockedManager', + canDecide: false, + }); + await decide(row.id, hr, 0, 'HR').expect(409); + await ctx.prisma.employee.update({ + where: { id: owner.id }, + data: { managerId: otherManager.id }, + }); + const response = await decide(row.id, otherManager).expect(201); + expect(response.body.state).toBe('PendingHr'); + await decide(row.id, hr, 1, 'HR').expect(201); + }, + ); + + it('manager reassignment invalidates the pending HR stage and denies the old manager and stale HR tab', async () => { + const row = await entry(); + await managerApproved(row.id); + await ctx.prisma.employee.update({ + where: { id: owner.id }, + data: { managerId: otherManager.id }, + }); + expect((await list(hr).expect(200)).body[0]).toMatchObject({ + state: 'PendingManager', + canDecide: false, + }); + await decide(row.id, hr, 1, 'HR').expect(409); + await decide(row.id, manager, 1).expect(403); + await decide(row.id, otherManager, 1).expect(201); + await decide(row.id, hr, 2, 'HR').expect(201); + }); + + it('manager role change invalidates their sign-off and cannot promote the same person to HR confirmer', async () => { + const row = await entry(); + await managerApproved(row.id); + await ctx.prisma.employee.update({ + where: { id: manager.id }, + data: { role: 'HRAdmin' }, + }); + await decide(row.id, manager, 1, 'HR').expect(409); + expect((await list(owner, 'own').expect(200)).body[0].state).toBe( + 'BlockedManager', + ); + }); + + it.each(['Open', 'Approved', 'Rejected', 'Voided', 'InFrame', 'Solo'])( + 'cannot approve %s entries', + async (kind) => { + const row = await entry(owner.id, { + ...(kind === 'Open' ? { clockOut: null, status: 'Open' } : {}), + ...(kind === 'Approved' || kind === 'Rejected' ? { status: kind } : {}), + ...(kind === 'Voided' ? { voidedAt: new Date() } : {}), + ...(kind === 'InFrame' + ? { requiresApproval: false, status: 'Approved' } + : {}), + ...(kind === 'Solo' ? { approvalMode: 'Solo' } : {}), + }); + await decide(row.id, manager).expect(409); + if (kind === 'InFrame' || kind === 'Solo') + expect((await list(manager).expect(200)).body).toEqual([]); + }, + ); + + it('requires exact revision/stage and only one parallel manager decision succeeds', async () => { + const row = await entry(); + const replies = await Promise.all([ + decide(row.id, manager), + decide(row.id, manager), + ]); + expect(replies.map((r) => r.status).sort()).toEqual([201, 409]); + await decide(row.id, manager, 0).expect(409); + expect( + await ctx.prisma.timeEntryAudit.count({ where: { timeEntryId: row.id } }), + ).toBe(1); + const hrs = await Promise.all([ + decide(row.id, hr, 1, 'HR'), + decide(row.id, secondHr, 1, 'HR'), + ]); + expect(hrs.map((r) => r.status).sort()).toEqual([201, 409]); + expect( + await ctx.prisma.timeEntryAudit.count({ where: { timeEntryId: row.id } }), + ).toBe(2); + }); + + it('validates missing/bad versions and enums without modifying the entry', async () => { + const row = await entry(); + for (const body of [ + { stage: 'Manager', outcome: 'Approve' }, + { revision: -1, stage: 'Manager', outcome: 'Approve' }, + { revision: 0, stage: 'Employee', outcome: 'Approve' }, + ]) { + await ctx.http + .post(`/api/timeentries/${row.id}/approval`) + .set('Authorization', `Bearer ${manager.token}`) + .send(body) + .expect(400); + } + expect( + (await ctx.prisma.timeEntry.findUniqueOrThrow({ where: { id: row.id } })) + .revision, + ).toBe(0); + }); + + it('editing manager-approved or HR-confirmed booking restarts manager review and audits invalidation', async () => { + const row = await entry(); + await managerApproved(row.id); + const edit = () => + ctx.http + .patch(`/api/timeentries/${row.id}`) + .set('Authorization', `Bearer ${owner.token}`) + .send({ activity: 'Corrected description' }) + .expect(200); + await edit(); + await decide(row.id, hr, 1, 'HR').expect(409); + expect((await list(owner, 'own').expect(200)).body[0]).toMatchObject({ + state: 'PendingManager', + revision: 2, + }); + await decide(row.id, manager, 2).expect(201); + await decide(row.id, hr, 3, 'HR').expect(201); + await edit(); + expect((await list(owner, 'own').expect(200)).body[0]).toMatchObject({ + state: 'PendingManager', + revision: 5, + entry: { status: 'Pending' }, + }); + expect( + (await history(row.id, owner).expect(200)).body.filter( + (r: { action: string }) => r.action === 'FrameApprovalInvalidated', + ), + ).toHaveLength(2); + }); + + it('serializes an edit against approval without allowing stale decisions to corrupt the result', async () => { + const row = await entry(); + const replies = await Promise.all([ + decide(row.id, manager), + ctx.http + .patch(`/api/timeentries/${row.id}`) + .set('Authorization', `Bearer ${owner.token}`) + .send({ activity: 'New activity', revision: 0 }), + ]); + expect(replies.filter((r) => r.status === 409)).toHaveLength(1); + expect( + replies.filter((r) => r.status === 200 || r.status === 201), + ).toHaveLength(1); + const current = await ctx.prisma.timeEntry.findUniqueOrThrow({ + where: { id: row.id }, + }); + expect(current.revision).toBe(1); + expect(await ctx.prisma.timeEntryAudit.count()).toBe(1); + }); + + it('split invalidates prior sign-off, recalculates frame per segment, and never launders a rejection', async () => { + const row = await entry(); + await managerApproved(row.id); + const split = await ctx.http + .post(`/api/timeentries/${row.id}/split`) + .set('Authorization', `Bearer ${owner.token}`) + .send({ at: '2026-05-04T05:00:00Z' }) + .expect(201); + expect(split.body.first).toMatchObject({ + requiresApproval: true, + status: 'Pending', + }); + expect(split.body.second).toMatchObject({ + requiresApproval: false, + status: 'Approved', + }); + expect( + (await list(manager).expect(200)).body.find( + (item: { entry: { id: string } }) => item.entry.id === row.id, + ).state, + ).toBe('PendingManager'); + await decide(row.id, manager, 2, 'Manager', 'Reject', { + note: 'No', + }).expect(201); + const again = await ctx.http + .post(`/api/timeentries/${row.id}/split`) + .set('Authorization', `Bearer ${owner.token}`) + .send({ at: '2026-05-04T04:30:00Z' }) + .expect(201); + expect(again.body.first.status).toBe('Rejected'); + expect(again.body.second.status).toBe('Rejected'); + }); + + it('closed PWA booking computes actual frame and enters manager review', async () => { + // Controlled clock-out via the service; production controller calls same method. + const service = ctx.app.get(TimeEntriesService); + const schedule = await ctx.prisma.workSchedule.create({ + data: { name: 'Narrow', frameStart: '07:00', frameEnd: '08:00' }, + }); + await ctx.prisma.employee.update({ + where: { id: owner.id }, + data: { workScheduleId: schedule.id }, + }); + const start = new Date(); + start.setDate(start.getDate() - 1); + start.setHours(6, 0, 0, 0); + const open = await entry(owner.id, { + clockIn: start, + clockOut: null, + status: 'Open', + requiresApproval: true, + }); + expect((await list(manager).expect(200)).body[0]).toMatchObject({ + state: 'Open', + canDecide: false, + }); + const closed = await service.clockOut(owner.id); + expect(closed).toMatchObject({ + id: open.id, + requiresApproval: true, + status: 'Pending', + }); + expect((await list(manager).expect(200)).body[0].state).toBe( + 'PendingManager', + ); + }); + + it('leaves ordinary in-frame and Solo creation unchanged and does not list them for review', async () => { + await entry(owner.id, { + clockIn: new Date('2026-05-04T06:00:00Z'), + clockOut: new Date('2026-05-04T14:00:00Z'), + status: 'Approved', + requiresApproval: false, + }); + await entry(owner.id, { + approvalMode: 'Solo', + status: 'Approved', + requiresApproval: false, + }); + expect((await list(manager).expect(200)).body).toEqual([]); + expect((await list(owner, 'own').expect(200)).body).toEqual([]); + }); + + it('project-range segmentation invalidates a prior manager sign-off for every outside-frame segment', async () => { + const row = await entry(); + await managerApproved(row.id); + const project = await ctx.prisma.project.create({ + data: { code: 'FRAME', name: 'Synthetic project' }, + }); + await ctx.prisma.projectAssignment.create({ + data: { projectId: project.id, employeeId: owner.id }, + }); + await ctx.http + .post('/api/timeentries/book-project') + .set('Authorization', `Bearer ${owner.token}`) + .send({ + employeeId: owner.id, + from: '2026-05-04T04:15:00Z', + to: '2026-05-04T05:30:00Z', + projectId: project.id, + }) + .expect(201); + const rows = (await list(manager).expect(200)).body; + expect(rows).toHaveLength(3); + const pending = rows.filter( + (item: { entry: { requiresApproval: boolean } }) => + item.entry.requiresApproval, + ); + expect(pending).toHaveLength(2); + expect( + pending.every( + (item: { state: string }) => item.state === 'PendingManager', + ), + ).toBe(true); + expect( + rows.filter((item: { state: string }) => item.state === 'Approved'), + ).toHaveLength(1); + await decide(row.id, hr, 1, 'HR').expect(409); + const invalidations = await ctx.prisma.timeEntryAudit.findMany({ + where: { action: { startsWith: 'Frame' } }, + }); + expect( + invalidations.some( + (event) => event.action === 'FrameApprovalInvalidated', + ), + ).toBe(true); + }); + + it('retains discoverable decision history when a split moves the original ID wholly inside the frame', async () => { + const row = await entry(owner.id, { + clockIn: new Date('2026-05-04T06:00:00Z'), + clockOut: new Date('2026-05-04T22:00:00Z'), + }); + await managerApproved(row.id); + await ctx.http + .post(`/api/timeentries/${row.id}/split`) + .set('Authorization', `Bearer ${owner.token}`) + .send({ at: '2026-05-04T20:00:00Z' }) + .expect(201); + const views = (await list(owner, 'own').expect(200)).body; + expect(views).toHaveLength(2); + expect( + views.find((item: { entry: { id: string } }) => item.entry.id === row.id), + ).toMatchObject({ + state: 'Approved', + canDecide: false, + entry: { requiresApproval: false }, + }); + const events = (await history(row.id, owner).expect(200)).body; + expect(events.map((event: { action: string }) => event.action)).toEqual([ + 'FrameManagerApproved', + 'FrameApprovalInvalidated', + ]); + await decide(row.id, manager, 2).expect(409); + }); + + it('Solo installation cannot expose or approve archived Team entries', async () => { + const row = await entry(); + await ctx.prisma.installationSettings.create({ + data: { + id: 1, + mode: 'Solo', + ownerEmployeeId: hr.id, + setupCompleted: true, + }, + }); + await list(hr).expect(403); + await list(hr, 'own').expect(403); + await decide(row.id, hr, 0, 'HR').expect(403); + await history(row.id, hr).expect(403); + expect( + (await ctx.prisma.timeEntry.findUniqueOrThrow({ where: { id: row.id } })) + .status, + ).toBe('Pending'); + }); +}); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 960390a..0b2fcdb 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2027,6 +2027,122 @@ "tags": ["time-entries"] } }, + "/api/timeentries/approvals": { + "get": { + "operationId": "TimeEntriesController_approvalList", + "parameters": [ + { + "name": "scope", + "required": false, + "in": "query", + "schema": { + "enum": ["own", "review"], + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TimeEntryApprovalDto" + } + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["time-entries"] + } + }, + "/api/timeentries/{id}/approval-history": { + "get": { + "operationId": "TimeEntriesController_approvalHistory", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TimeEntryApprovalEventDto" + } + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["time-entries"] + } + }, + "/api/timeentries/{id}/approval": { + "post": { + "operationId": "TimeEntriesController_decideApproval", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DecideTimeEntryApprovalDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TimeEntryApprovalDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["time-entries"] + } + }, "/api/timeentries/clock-in": { "post": { "operationId": "TimeEntriesController_clockIn", @@ -6523,6 +6639,112 @@ "summary" ] }, + "TimeEntryApprovalDto": { + "type": "object", + "properties": { + "entry": { + "$ref": "#/components/schemas/TimeEntryDto" + }, + "revision": { + "type": "number", + "minimum": 0 + }, + "employeeName": { + "type": "string" + }, + "managerId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "managerName": { + "type": "string", + "nullable": true + }, + "state": { + "type": "string", + "enum": [ + "Open", + "BlockedManager", + "PendingManager", + "PendingHr", + "Approved", + "Rejected", + "Voided" + ] + }, + "canDecide": { + "type": "boolean" + } + }, + "required": [ + "entry", + "revision", + "employeeName", + "managerId", + "managerName", + "state", + "canDecide" + ] + }, + "TimeEntryApprovalEventDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "actorId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "actorName": { + "type": "string", + "nullable": true + }, + "action": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "occurredAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "actorId", + "actorName", + "action", + "reason", + "occurredAt" + ] + }, + "DecideTimeEntryApprovalDto": { + "type": "object", + "properties": { + "revision": { + "type": "number", + "minimum": 0 + }, + "stage": { + "type": "string", + "enum": ["Manager", "HR"] + }, + "outcome": { + "type": "string", + "enum": ["Approve", "Reject"] + }, + "note": { + "type": "string", + "maxLength": 2000 + } + }, + "required": ["revision", "stage", "outcome"] + }, "ClockInDto": { "type": "object", "properties": { diff --git a/apps/api/src/app/terminals/terminal-booking.service.ts b/apps/api/src/app/terminals/terminal-booking.service.ts index f91e7b7..a67f7b7 100644 --- a/apps/api/src/app/terminals/terminal-booking.service.ts +++ b/apps/api/src/app/terminals/terminal-booking.service.ts @@ -105,6 +105,8 @@ export async function bookTerminalTime( revision: { increment: 1 }, status: requires ? 'Pending' : 'Approved', requiresApproval: requires, + frameApproverId: null, + frameApprovedRevision: null, clockOutLatitude: position?.latitude ?? null, clockOutLongitude: position?.longitude ?? null, clockOutAccuracyMeters: position?.accuracyMeters ?? null, diff --git a/apps/api/src/app/time-entries/time-entries.controller.ts b/apps/api/src/app/time-entries/time-entries.controller.ts index 3965574..11cb3bb 100644 --- a/apps/api/src/app/time-entries/time-entries.controller.ts +++ b/apps/api/src/app/time-entries/time-entries.controller.ts @@ -14,10 +14,17 @@ import { ApiOkResponse, ApiCreatedResponse, ApiTags, + ApiQuery, } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { JwtUser } from '../auth/jwt.strategy'; +import { TimeEntryApprovalsService } from './time-entry-approvals.service'; +import { + DecideTimeEntryApprovalDto, + TimeEntryApprovalDto, + TimeEntryApprovalEventDto, +} from './time-entry-approvals.dto'; import { TimeEntriesService } from './time-entries.service'; import { BookProjectRangeDto, @@ -40,7 +47,10 @@ import { @ApiTags('time-entries') @Controller('timeentries') export class TimeEntriesController { - constructor(private readonly entries: TimeEntriesService) {} + constructor( + private readonly entries: TimeEntriesService, + private readonly approvals: TimeEntryApprovalsService, + ) {} @Get() @ApiOkResponse({ type: TimeEntryDto, isArray: true }) @@ -60,6 +70,41 @@ export class TimeEntriesController { ); } + @Get('approvals') + @ApiQuery({ name: 'scope', required: false, enum: ['own', 'review'] }) + @ApiOkResponse({ type: TimeEntryApprovalDto, isArray: true }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + approvalList( + @CurrentUser() user: JwtUser, + @Query('scope') scope?: string, + ): Promise { + return this.approvals.list(user.id, scope); + } + + @Get(':id/approval-history') + @ApiOkResponse({ type: TimeEntryApprovalEventDto, isArray: true }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + approvalHistory( + @Param('id', new ParseUUIDPipe()) id: string, + @CurrentUser() user: JwtUser, + ): Promise { + return this.approvals.history(id, user); + } + + @Post(':id/approval') + @ApiCreatedResponse({ type: TimeEntryApprovalDto }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + decideApproval( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: DecideTimeEntryApprovalDto, + @CurrentUser() user: JwtUser, + ): Promise { + return this.approvals.decide(id, dto, user); + } + @Post('clock-in') @ApiCreatedResponse({ type: TimeEntryDto }) @ApiBearerAuth() diff --git a/apps/api/src/app/time-entries/time-entries.module.ts b/apps/api/src/app/time-entries/time-entries.module.ts index 903233e..18f4328 100644 --- a/apps/api/src/app/time-entries/time-entries.module.ts +++ b/apps/api/src/app/time-entries/time-entries.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { TimeEntriesController } from './time-entries.controller'; +import { TimeEntryApprovalsService } from './time-entry-approvals.service'; import { TimeEntriesService } from './time-entries.service'; @Module({ imports: [AuthModule], controllers: [TimeEntriesController], - providers: [TimeEntriesService], + providers: [TimeEntriesService, TimeEntryApprovalsService], exports: [TimeEntriesService], }) export class TimeEntriesModule {} diff --git a/apps/api/src/app/time-entries/time-entries.service.ts b/apps/api/src/app/time-entries/time-entries.service.ts index 27a201f..5e5cbf2 100644 --- a/apps/api/src/app/time-entries/time-entries.service.ts +++ b/apps/api/src/app/time-entries/time-entries.service.ts @@ -681,6 +681,8 @@ export class TimeEntriesService { clockOut: bookingNow, status: requires ? 'Pending' : 'Approved', requiresApproval: requires, + frameApproverId: null, + frameApprovedRevision: null, revision: { increment: 1 }, clockOutLatitude: policy.isSolo && !policy.gpsEnabled @@ -808,9 +810,32 @@ export class TimeEntriesService { const before = await this.auditSnapshot(tx, current); const row = await tx.timeEntry.update({ where: { id }, - data: { ...data, revision: { increment: 1 } }, + data: { + ...data, + revision: { increment: 1 }, + ...(current.approvalMode !== 'Solo' && current.requiresApproval + ? { + frameApproverId: null, + frameApprovedRevision: null, + ...(!current.voidedAt && + current.clockOut && + current.status !== 'Rejected' + ? { status: 'Pending' as const } + : {}), + } + : {}), + }, include: ENTRY_INCLUDE, }); + if (current.approvalMode !== 'Solo' && current.requiresApproval) + await this.writeAudit( + tx, + row, + user.id, + 'FrameApprovalInvalidated', + before, + null, + ); if (isSolo || current.approvalMode === 'Solo') await this.writeAudit(tx, row, user.id, 'BookingUpdated', before, null); return row; @@ -911,6 +936,8 @@ export class TimeEntriesService { captureGroupId, revision: { increment: 1 }, requiresApproval: firstRequires, + frameApproverId: null, + frameApprovedRevision: null, status: statusFor(firstRequires), clockOutLatitude: null, clockOutLongitude: null, @@ -956,6 +983,24 @@ export class TimeEntriesService { where: { timeEntryId: entry.id, action: 'clock-out' }, data: { timeEntryId: secondSegment.id }, }); + if (entry.approvalMode !== 'Solo' && entry.requiresApproval) { + await this.writeAudit( + tx, + firstSegment, + user.id, + 'FrameApprovalInvalidated', + before, + null, + ); + await this.writeAudit( + tx, + secondSegment, + user.id, + 'FrameSegmentCreated', + null, + null, + ); + } if (isSolo || entry.approvalMode === 'Solo') { await this.writeAudit( tx, @@ -1097,6 +1142,8 @@ export class TimeEntriesService { clockOut, captureGroupId, requiresApproval, + frameApproverId: null, + frameApprovedRevision: null, status: requiresApproval ? ('Pending' as const) : ('Approved' as const), @@ -1132,6 +1179,19 @@ export class TimeEntriesService { where: { timeEntryId: entry.id, action: 'clock-out' }, data: { timeEntryId: last.id }, }); + if (entry.approvalMode !== 'Solo' && entry.requiresApproval) { + for (const segment of segments) + await this.writeAudit( + tx, + segment, + user.id, + segment.id === entry.id + ? 'FrameApprovalInvalidated' + : 'FrameSegmentCreated', + segment.id === entry.id ? before : null, + null, + ); + } if (isSolo || entry.approvalMode === 'Solo') { for (const segment of segments) await this.writeAudit( @@ -1725,6 +1785,8 @@ export class TimeEntriesService { status: entry.status, requiresApproval: entry.requiresApproval, approvalMode: entry.approvalMode, + frameApproverId: entry.frameApproverId, + frameApprovedRevision: entry.frameApprovedRevision, captureGroupId: entry.captureGroupId, breakRules: parseBreakRules(entry.breakRules), voidedAt: entry.voidedAt?.toISOString() ?? null, diff --git a/apps/api/src/app/time-entries/time-entry-approvals.dto.ts b/apps/api/src/app/time-entries/time-entry-approvals.dto.ts new file mode 100644 index 0000000..2decd4c --- /dev/null +++ b/apps/api/src/app/time-entries/time-entry-approvals.dto.ts @@ -0,0 +1,71 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsIn, + IsInt, + IsOptional, + IsString, + MaxLength, + Min, +} from 'class-validator'; +import { TimeEntryDto } from './time-entries.dto'; + +export const ENTRY_APPROVAL_STATES = [ + 'Open', + 'BlockedManager', + 'PendingManager', + 'PendingHr', + 'Approved', + 'Rejected', + 'Voided', +] as const; +export type EntryApprovalState = (typeof ENTRY_APPROVAL_STATES)[number]; + +export class TimeEntryApprovalDto { + @ApiProperty({ type: TimeEntryDto }) + entry!: TimeEntryDto; + @ApiProperty({ minimum: 0 }) + revision!: number; + @ApiProperty() + employeeName!: string; + @ApiProperty({ type: String, nullable: true, format: 'uuid' }) + managerId!: string | null; + @ApiProperty({ type: String, nullable: true }) + managerName!: string | null; + @ApiProperty({ enum: ENTRY_APPROVAL_STATES }) + state!: EntryApprovalState; + @ApiProperty() + canDecide!: boolean; +} + +export class DecideTimeEntryApprovalDto { + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + revision!: number; + @ApiProperty({ enum: ['Manager', 'HR'] }) + @IsIn(['Manager', 'HR']) + stage!: 'Manager' | 'HR'; + @ApiProperty({ enum: ['Approve', 'Reject'] }) + @IsIn(['Approve', 'Reject']) + outcome!: 'Approve' | 'Reject'; + @ApiPropertyOptional({ maxLength: 2000 }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; +} + +export class TimeEntryApprovalEventDto { + @ApiProperty() + id!: string; + @ApiProperty({ type: String, nullable: true, format: 'uuid' }) + actorId!: string | null; + @ApiProperty({ type: String, nullable: true }) + actorName!: string | null; + @ApiProperty() + action!: string; + @ApiProperty({ type: String, nullable: true }) + reason!: string | null; + @ApiProperty({ format: 'date-time' }) + occurredAt!: string; +} diff --git a/apps/api/src/app/time-entries/time-entry-approvals.service.ts b/apps/api/src/app/time-entries/time-entry-approvals.service.ts new file mode 100644 index 0000000..a0ab33c --- /dev/null +++ b/apps/api/src/app/time-entries/time-entry-approvals.service.ts @@ -0,0 +1,347 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma, type Employee } from '@prisma/client'; +import type { JwtUser } from '../auth/jwt.strategy'; +import { PrismaService } from '../prisma/prisma.service'; +import { EventsGateway } from '../events/events.gateway'; +import { toTimeEntryDto } from './time-entries.dto'; +import { + type DecideTimeEntryApprovalDto, + type EntryApprovalState, + type TimeEntryApprovalDto, + type TimeEntryApprovalEventDto, +} from './time-entry-approvals.dto'; + +const APPROVAL_INCLUDE = { + project: { select: { code: true, name: true } }, + serviceOrder: { select: { orderNo: true, title: true } }, + employee: { + select: { + id: true, + firstName: true, + lastName: true, + isActive: true, + managerId: true, + manager: { + select: { + id: true, + firstName: true, + lastName: true, + role: true, + isActive: true, + }, + }, + }, + }, +} as const; +type ReviewEntry = Prisma.TimeEntryGetPayload<{ + include: typeof APPROVAL_INCLUDE; +}>; + +function validManager(row: ReviewEntry): boolean { + const manager = row.employee.manager; + return ( + !!manager?.isActive && + manager.role === 'Manager' && + manager.id !== row.employeeId + ); +} + +export function frameApprovalState(row: ReviewEntry): EntryApprovalState { + if (row.voidedAt) return 'Voided'; + if (!row.clockOut || row.status === 'Open') return 'Open'; + if (row.status === 'Approved') return 'Approved'; + if (row.status === 'Rejected') return 'Rejected'; + if (!validManager(row)) return 'BlockedManager'; + return row.frameApprovedRevision === row.revision && + row.frameApproverId === row.employee.managerId + ? 'PendingHr' + : 'PendingManager'; +} + +@Injectable() +export class TimeEntryApprovalsService { + constructor( + private readonly prisma: PrismaService, + private readonly events: EventsGateway, + ) {} + + private async assertTeam(tx: Prisma.TransactionClient): Promise { + const installation = await tx.installationSettings.findUnique({ + where: { id: 1 }, + }); + if (installation?.mode === 'Solo') + throw new ForbiddenException( + 'Team time-entry approvals are disabled in Solo mode', + ); + } + + private async actor( + tx: Prisma.TransactionClient, + id: string, + ): Promise { + const actor = await tx.employee.findUnique({ where: { id } }); + if (!actor?.isActive) + throw new ForbiddenException('Active employee required'); + return actor; + } + + private assertReviewAccess( + row: ReviewEntry, + actor: Employee, + allowOwner: boolean, + ): void { + if (allowOwner && actor.id === row.employeeId) return; + if (actor.id === row.employeeId) + throw new ForbiddenException('You cannot review your own time entry'); + if (actor.role === 'HRAdmin') return; + if (actor.role === 'Manager' && row.employee.managerId === actor.id) return; + throw new ForbiddenException( + 'Only the assigned manager or HR may review this time entry', + ); + } + + private assertApplicable(row: ReviewEntry): void { + if (row.approvalMode === 'Solo' || !row.requiresApproval) + throw new ConflictException( + 'This time entry does not require Team frame approval', + ); + } + + private dto(row: ReviewEntry, actor: Employee): TimeEntryApprovalDto { + const state = frameApprovalState(row); + return { + entry: toTimeEntryDto(row), + revision: row.revision, + employeeName: `${row.employee.firstName} ${row.employee.lastName}`, + managerId: row.employee.managerId, + managerName: row.employee.manager + ? `${row.employee.manager.firstName} ${row.employee.manager.lastName}` + : null, + state, + canDecide: + row.employee.isActive && + actor.id !== row.employeeId && + ((state === 'PendingManager' && + actor.role === 'Manager' && + row.employee.managerId === actor.id) || + (state === 'PendingHr' && + actor.role === 'HRAdmin' && + row.frameApproverId !== actor.id)), + }; + } + + async list( + actorId: string, + scope = 'review', + ): Promise { + if (scope !== 'review' && scope !== 'own') + throw new BadRequestException('Invalid approval scope'); + return this.prisma.$transaction(async (tx) => { + await this.assertTeam(tx); + const actor = await this.actor(tx, actorId); + if ( + scope === 'review' && + actor.role !== 'Manager' && + actor.role !== 'HRAdmin' + ) + throw new ForbiddenException('Manager or HR access required'); + const rows = await tx.timeEntry.findMany({ + where: { + AND: [ + { OR: [{ approvalMode: null }, { approvalMode: { not: 'Solo' } }] }, + { + OR: [ + { requiresApproval: true }, + { auditEvents: { some: { action: { startsWith: 'Frame' } } } }, + ], + }, + ], + ...(scope === 'own' + ? { employeeId: actor.id } + : { + employeeId: { not: actor.id }, + employee: { + isActive: true, + ...(actor.role === 'Manager' ? { managerId: actor.id } : {}), + }, + }), + }, + include: APPROVAL_INCLUDE, + orderBy: [{ clockIn: 'desc' }, { id: 'asc' }], + }); + return rows.map((row) => this.dto(row, actor)); + }); + } + + async history( + id: string, + user: JwtUser, + ): Promise { + return this.prisma.$transaction(async (tx) => { + await this.assertTeam(tx); + const actor = await this.actor(tx, user.id); + const row = await tx.timeEntry.findUnique({ + where: { id }, + include: APPROVAL_INCLUDE, + }); + if (!row) throw new NotFoundException('Time entry not found'); + this.assertReviewAccess(row, actor, true); + // A split may move the original ID wholly inside the frame. Its old + // decisions and invalidation remain reviewable, even though it no + // longer needs approval. Solo archives must remain inaccessible. + if (row.approvalMode === 'Solo') + throw new ForbiddenException( + 'Solo entries have no Team approval history', + ); + const events = await tx.timeEntryAudit.findMany({ + where: { timeEntryId: id, action: { startsWith: 'Frame' } }, + include: { actor: { select: { firstName: true, lastName: true } } }, + orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }], + }); + return events.map((event) => ({ + id: event.id, + actorId: event.actorId, + action: event.action, + reason: event.reason, + actorName: event.actor + ? `${event.actor.firstName} ${event.actor.lastName}` + : null, + occurredAt: event.occurredAt.toISOString(), + })); + }); + } + + async decide( + id: string, + dto: DecideTimeEntryApprovalDto, + user: JwtUser, + ): Promise { + const result = await this.prisma.$transaction(async (tx) => { + // Same serialization order as every booking/correction and installation + // mode change. Row locks also serialize concurrent role/manager changes. + await tx.$executeRaw`SELECT pg_advisory_xact_lock(7261500)`; + await this.assertTeam(tx); + const initial = await tx.timeEntry.findUnique({ where: { id } }); + if (!initial) throw new NotFoundException('Time entry not found'); + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${initial.employeeId}, 0))`; + await tx.$queryRaw`SELECT "id" FROM "Employee" WHERE "id" = ${initial.employeeId}::uuid FOR SHARE`; + const employee = await tx.employee.findUniqueOrThrow({ + where: { id: initial.employeeId }, + }); + const ids = [ + ...new Set( + [user.id, employee.id, employee.managerId].filter( + (value): value is string => !!value, + ), + ), + ].sort(); + await tx.$queryRaw( + Prisma.sql`SELECT "id" FROM "Employee" WHERE "id" IN (${Prisma.join(ids.map((value) => Prisma.sql`${value}::uuid`))}) ORDER BY "id" FOR SHARE`, + ); + const actor = await this.actor(tx, user.id); + const row = await tx.timeEntry.findUniqueOrThrow({ + where: { id }, + include: APPROVAL_INCLUDE, + }); + this.assertReviewAccess(row, actor, false); + this.assertApplicable(row); + if (!row.employee.isActive) + throw new ForbiddenException('Employee account is inactive'); + if (row.revision !== dto.revision) + throw new ConflictException( + 'Time entry changed; reload before deciding', + ); + const view = this.dto(row, actor); + if ( + (dto.stage === 'Manager' && actor.role !== 'Manager') || + (dto.stage === 'HR' && actor.role !== 'HRAdmin') + ) + throw new ForbiddenException('Wrong approval role'); + if ( + !view.canDecide || + view.state !== + (dto.stage === 'Manager' ? 'PendingManager' : 'PendingHr') + ) + throw new ConflictException( + 'Time entry is not awaiting this approval stage', + ); + const note = dto.note?.trim() || null; + if (dto.outcome === 'Reject' && !note) + throw new BadRequestException('A rejection reason is required'); + const approveManager = + dto.stage === 'Manager' && dto.outcome === 'Approve'; + const data: Prisma.TimeEntryUncheckedUpdateInput = { + revision: { increment: 1 }, + status: + dto.outcome === 'Reject' + ? 'Rejected' + : dto.stage === 'HR' + ? 'Approved' + : 'Pending', + ...(approveManager + ? { + frameApproverId: actor.id, + frameApprovedRevision: row.revision + 1, + } + : {}), + }; + const changed = await tx.timeEntry.updateMany({ + where: { + id, + revision: dto.revision, + status: 'Pending', + clockOut: { not: null }, + voidedAt: null, + }, + data, + }); + if (changed.count !== 1) + throw new ConflictException( + 'Time entry changed; reload before deciding', + ); + const updated = await tx.timeEntry.findUniqueOrThrow({ + where: { id }, + include: APPROVAL_INCLUDE, + }); + const action = + dto.stage === 'Manager' + ? dto.outcome === 'Approve' + ? 'FrameManagerApproved' + : 'FrameManagerRejected' + : dto.outcome === 'Approve' + ? 'FrameHrConfirmed' + : 'FrameHrRejected'; + await tx.timeEntryAudit.create({ + data: { + timeEntryId: id, + actorId: actor.id, + action, + reason: note, + before: { + revision: row.revision, + state: view.state, + status: row.status, + }, + after: { + revision: updated.revision, + state: frameApprovalState(updated), + status: updated.status, + }, + }, + }); + return this.dto(updated, actor); + }); + this.events.broadcast('time-entry:updated', { + id, + employeeId: result.entry.employeeId, + clockOut: result.entry.clockOut, + }); + return result; + } +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index d47a9e0..9607e03 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -191,6 +191,41 @@ export interface TimeEntryDto { summary: TimeSummaryDto | null; } +export type TimeEntryApprovalState = + | 'Open' + | 'BlockedManager' + | 'PendingManager' + | 'PendingHr' + | 'Approved' + | 'Rejected' + | 'Voided'; + +export interface TimeEntryApprovalDto { + entry: TimeEntryDto; + revision: number; + employeeName: string; + managerId: string | null; + managerName: string | null; + state: TimeEntryApprovalState; + canDecide: boolean; +} + +export interface TimeEntryApprovalDecision { + revision: number; + stage: 'Manager' | 'HR'; + outcome: 'Approve' | 'Reject'; + note?: string; +} + +export interface TimeEntryApprovalEventDto { + id: string; + actorId: string | null; + actorName: string | null; + action: string; + reason: string | null; + occurredAt: string; +} + export interface SplitTimeEntryResult { first: TimeEntryDto; second: TimeEntryDto; @@ -922,6 +957,19 @@ export const api = { if (to) params.set('to', to); return request(`/api/timeentries?${params.toString()}`); }, + timeEntryApprovals: (scope: 'own' | 'review' = 'review') => + request( + `/api/timeentries/approvals?scope=${scope}`, + ), + decideTimeEntryApproval: (id: string, payload: TimeEntryApprovalDecision) => + request(`/api/timeentries/${id}/approval`, { + method: 'POST', + body: JSON.stringify(payload), + }), + timeEntryApprovalHistory: (id: string) => + request( + `/api/timeentries/${id}/approval-history`, + ), clockIn: (payload: ClockInPayload) => request('/api/timeentries/clock-in', { method: 'POST', diff --git a/apps/web/src/api/generated.ts b/apps/web/src/api/generated.ts index 41d8884..ae14830 100644 --- a/apps/web/src/api/generated.ts +++ b/apps/web/src/api/generated.ts @@ -676,6 +676,54 @@ export interface paths { patch?: never; trace?: never; }; + '/api/timeentries/approvals': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['TimeEntriesController_approvalList']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/timeentries/{id}/approval-history': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['TimeEntriesController_approvalHistory']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/timeentries/{id}/approval': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations['TimeEntriesController_decideApproval']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/timeentries/clock-in': { parameters: { query?: never; @@ -2348,6 +2396,42 @@ export interface components { activity: string | null; summary: components['schemas']['TimeSummaryDto'] | null; }; + TimeEntryApprovalDto: { + entry: components['schemas']['TimeEntryDto']; + revision: number; + employeeName: string; + /** Format: uuid */ + managerId: string | null; + managerName: string | null; + /** @enum {string} */ + state: + | 'Open' + | 'BlockedManager' + | 'PendingManager' + | 'PendingHr' + | 'Approved' + | 'Rejected' + | 'Voided'; + canDecide: boolean; + }; + TimeEntryApprovalEventDto: { + id: string; + /** Format: uuid */ + actorId: string | null; + actorName: string | null; + action: string; + reason: string | null; + /** Format: date-time */ + occurredAt: string; + }; + DecideTimeEntryApprovalDto: { + revision: number; + /** @enum {string} */ + stage: 'Manager' | 'HR'; + /** @enum {string} */ + outcome: 'Approve' | 'Reject'; + note?: string; + }; ClockInDto: { note?: string | null; billable?: boolean; @@ -4269,6 +4353,73 @@ export interface operations { }; }; }; + TimeEntriesController_approvalList: { + parameters: { + query?: { + scope?: 'own' | 'review'; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['TimeEntryApprovalDto'][]; + }; + }; + }; + }; + TimeEntriesController_approvalHistory: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['TimeEntryApprovalEventDto'][]; + }; + }; + }; + }; + TimeEntriesController_decideApproval: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['DecideTimeEntryApprovalDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['TimeEntryApprovalDto']; + }; + }; + }; + }; TimeEntriesController_clockIn: { parameters: { query?: never; diff --git a/apps/web/src/app/i18n.tsx b/apps/web/src/app/i18n.tsx index 2d67e1c..76a2b8a 100644 --- a/apps/web/src/app/i18n.tsx +++ b/apps/web/src/app/i18n.tsx @@ -16,6 +16,69 @@ type Catalog = Record; const STORAGE_KEY = 'openclockwork.locale'; const de: Catalog = { + 'frameApproval.ownTitle': 'Rahmenzeit-Genehmigungen für meine Zeitbuchungen', + 'frameApproval.title': 'Zeitbuchungen', + 'frameApproval.chain': + 'Zeitbuchungen außerhalb der Rahmenzeit benötigen zuerst die Genehmigung der zugewiesenen Führungskraft und danach die Bestätigung durch HR. Beide bearbeiten sie unter Genehmigungen → Zeitbuchungen.', + 'frameApproval.managerChanged': + 'Solange HR noch nicht bestätigt hat, muss nach einer Änderung der zugewiesenen Führungskraft die neue Führungskraft erneut genehmigen.', + 'frameApproval.refresh': 'Genehmigungen aktualisieren', + 'frameApproval.empty': + 'Keine genehmigungspflichtigen Zeitbuchungen vorhanden.', + 'frameApproval.loadFailed': + 'Zeitbuchungs-Genehmigungen konnten nicht geladen werden.', + 'frameApproval.historyFailed': + 'Der Genehmigungsverlauf konnte nicht geladen werden.', + 'frameApproval.decisionFailed': 'Die Entscheidung wurde nicht gespeichert.', + 'frameApproval.refreshHint': + 'Bitte aktualisieren und den aktuellen Stand prüfen, bevor du erneut entscheidest.', + 'frameApproval.notAllowed': 'Diese Entscheidung ist nicht zulässig.', + 'frameApproval.note': 'Notiz (bei Ablehnung erforderlich)', + 'frameApproval.reasonRequired': + 'Für eine Ablehnung ist eine Begründung erforderlich.', + 'frameApproval.assignedManager': 'zugewiesene Führungskraft', + 'frameApproval.history': 'Genehmigungsverlauf', + 'frameApproval.noHistory': + 'Noch keine Entscheidungen oder Änderungen vorhanden.', + 'frameApproval.system': 'System', + 'frameApproval.managerResult': + 'Führungskraft-Genehmigung gespeichert. Als Nächstes bestätigt HR unter Genehmigungen → Zeitbuchungen.', + 'frameApproval.hrResult': + 'HR-Bestätigung gespeichert. Die Zeitbuchung ist genehmigt.', + 'frameApproval.rejectedResult': 'Ablehnung mit Begründung gespeichert.', + 'frameApproval.approve.Manager': 'Als Führungskraft genehmigen', + 'frameApproval.approve.HR': 'Als HR bestätigen', + 'frameApproval.reject.Manager': 'Als Führungskraft ablehnen', + 'frameApproval.reject.HR': 'Als HR ablehnen', + 'frameApproval.state.Open': 'Offen – zuerst ausstempeln', + 'frameApproval.state.BlockedManager': 'Blockiert – Führungskraft fehlt', + 'frameApproval.state.PendingManager': 'Wartet auf Führungskraft', + 'frameApproval.state.PendingHr': 'Wartet auf HR', + 'frameApproval.state.Approved': 'Genehmigt', + 'frameApproval.state.Rejected': 'Abgelehnt', + 'frameApproval.state.Voided': 'Ungültig', + 'frameApproval.help.Open': + 'Bitte zuerst ausstempeln. Erst die abgeschlossene Zeitbuchung kann von der zugewiesenen Führungskraft und danach von HR unter Genehmigungen → Zeitbuchungen geprüft werden.', + 'frameApproval.help.BlockedManager': + 'Bitte HR bitten, eine aktive Führungskraft zuzuweisen. Danach genehmigt diese Führungskraft zuerst, anschließend bestätigt HR unter Genehmigungen → Zeitbuchungen. Es gibt keine direkte HR-Freigabe ohne Führungskraft-Genehmigung.', + 'frameApproval.help.PendingManager': + 'Nächster Schritt: {manager} prüft als zugewiesene Führungskraft unter Genehmigungen → Zeitbuchungen. Danach bestätigt HR am selben Ort.', + 'frameApproval.help.PendingHr': + 'Die Führungskraft hat genehmigt. Nächster Schritt: HR bestätigt unter Genehmigungen → Zeitbuchungen.', + 'frameApproval.help.Approved': + 'Die Zeitbuchung ist genehmigt. Dokumentierte Entscheidungen stehen mit Person und Zeitpunkt im Genehmigungsverlauf.', + 'frameApproval.help.Rejected': + 'Die Zeitbuchung wurde abgelehnt. Begründung und entscheidende Person stehen im Genehmigungsverlauf.', + 'frameApproval.help.Voided': + 'Diese Zeitbuchung ist nicht mehr gültig und kann nicht genehmigt werden. Der Verlauf bleibt einsehbar.', + 'frameApproval.event.FrameSegmentCreated': + 'Buchungssegment nach Änderung neu angelegt', + 'frameApproval.event.FrameManagerApproved': 'Von Führungskraft genehmigt', + 'frameApproval.event.FrameManagerRejected': 'Von Führungskraft abgelehnt', + 'frameApproval.event.FrameHrConfirmed': 'Von HR bestätigt', + 'frameApproval.event.FrameHrRejected': 'Von HR abgelehnt', + 'frameApproval.event.FrameApprovalInvalidated': + 'Bisherige Genehmigung durch Änderung aufgehoben', ...soloDe, ...terminalPinDe, 'projects.deleteFailed': 'Projekt konnte nicht gelöscht werden', @@ -706,6 +769,64 @@ const de: Catalog = { }; const en: Catalog = { + 'frameApproval.ownTitle': 'Approvals for my out-of-frame time entries', + 'frameApproval.title': 'Time entries', + 'frameApproval.chain': + 'Out-of-frame time entries first need approval by the assigned manager, then confirmation by HR. Both review them under Approvals → Time entries.', + 'frameApproval.managerChanged': + 'Before HR has confirmed, changing the assigned manager requires the new manager to approve again.', + 'frameApproval.refresh': 'Refresh approvals', + 'frameApproval.empty': 'No time entries requiring approval.', + 'frameApproval.loadFailed': 'Could not load time entry approvals.', + 'frameApproval.historyFailed': 'Could not load approval history.', + 'frameApproval.decisionFailed': 'The decision was not saved.', + 'frameApproval.refreshHint': + 'Refresh and check the current status before deciding again.', + 'frameApproval.notAllowed': 'This decision is not permitted.', + 'frameApproval.note': 'Note (required for rejection)', + 'frameApproval.reasonRequired': 'A reason is required to reject an entry.', + 'frameApproval.assignedManager': 'assigned manager', + 'frameApproval.history': 'Approval history', + 'frameApproval.noHistory': 'No decisions or changes yet.', + 'frameApproval.system': 'System', + 'frameApproval.managerResult': + 'Manager approval saved. HR confirms next under Approvals → Time entries.', + 'frameApproval.hrResult': + 'HR confirmation saved. The time entry is approved.', + 'frameApproval.rejectedResult': 'Rejection and reason saved.', + 'frameApproval.approve.Manager': 'Approve as manager', + 'frameApproval.approve.HR': 'Confirm as HR', + 'frameApproval.reject.Manager': 'Reject as manager', + 'frameApproval.reject.HR': 'Reject as HR', + 'frameApproval.state.Open': 'Open – clock out first', + 'frameApproval.state.BlockedManager': 'Blocked – no active manager', + 'frameApproval.state.PendingManager': 'Awaiting manager approval', + 'frameApproval.state.PendingHr': 'Awaiting HR confirmation', + 'frameApproval.state.Approved': 'Approved', + 'frameApproval.state.Rejected': 'Rejected', + 'frameApproval.state.Voided': 'Voided', + 'frameApproval.help.Open': + 'Clock out first. Only a closed time entry can be reviewed by the assigned manager and then HR under Approvals → Time entries.', + 'frameApproval.help.BlockedManager': + 'Ask HR to assign an active manager. That manager must approve first, followed by HR confirmation under Approvals → Time entries. HR cannot bypass manager approval.', + 'frameApproval.help.PendingManager': + 'Next: {manager}, the assigned manager, reviews under Approvals → Time entries. HR then confirms in the same place.', + 'frameApproval.help.PendingHr': + 'The manager has approved. Next: HR confirms under Approvals → Time entries.', + 'frameApproval.help.Approved': + 'This time entry is approved. Recorded decisions, reviewers and timestamps are available in approval history.', + 'frameApproval.help.Rejected': + 'The time entry was rejected. See approval history for the reason and reviewer.', + 'frameApproval.help.Voided': + 'This time entry is no longer valid and cannot be approved. Its history remains available.', + 'frameApproval.event.FrameSegmentCreated': + 'Entry segment created after a change', + 'frameApproval.event.FrameManagerApproved': 'Approved by manager', + 'frameApproval.event.FrameManagerRejected': 'Rejected by manager', + 'frameApproval.event.FrameHrConfirmed': 'Confirmed by HR', + 'frameApproval.event.FrameHrRejected': 'Rejected by HR', + 'frameApproval.event.FrameApprovalInvalidated': + 'Previous approval invalidated by a change', ...soloEn, ...terminalPinEn, 'projects.deleteFailed': 'Could not delete project', diff --git a/apps/web/src/app/realtime.ts b/apps/web/src/app/realtime.ts index 0ff985d..ad4971d 100644 --- a/apps/web/src/app/realtime.ts +++ b/apps/web/src/app/realtime.ts @@ -73,6 +73,8 @@ export function useRealtimeInvalidation() { qc.invalidateQueries({ queryKey: ['solo-summary'] }); qc.invalidateQueries({ queryKey: ['solo-hints'] }); qc.invalidateQueries({ queryKey: ['time-entries'] }); + qc.invalidateQueries({ queryKey: ['time-entry-approvals'] }); + qc.invalidateQueries({ queryKey: ['time-entry-approval-history'] }); qc.invalidateQueries({ queryKey: ['account'] }); qc.invalidateQueries({ queryKey: ['violations'] }); }; diff --git a/apps/web/src/components/timeentries/TimeEntryApprovals.spec.tsx b/apps/web/src/components/timeentries/TimeEntryApprovals.spec.tsx new file mode 100644 index 0000000..5487f0e --- /dev/null +++ b/apps/web/src/components/timeentries/TimeEntryApprovals.spec.tsx @@ -0,0 +1,588 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../../test-utils'; +import type { TimeEntryApprovalDto, TimeEntryDto } from '../../api/client'; +import { TimeEntryApprovals } from './TimeEntryApprovals'; + +const mocks = vi.hoisted(() => ({ + user: { id: 'manager-1', role: 'Manager' }, + list: vi.fn(), + decide: vi.fn(), + history: vi.fn(), +})); +vi.mock('../../api/client', () => ({ + api: { + timeEntryApprovals: mocks.list, + decideTimeEntryApproval: mocks.decide, + timeEntryApprovalHistory: mocks.history, + }, +})); +vi.mock('../../app/auth', () => ({ useCurrentUser: () => mocks.user })); + +const entry: TimeEntryDto = { + id: 'entry-1', + employeeId: 'employee-1', + clockIn: '2026-05-04T05:00:00Z', + clockOut: '2026-05-04T17:00:00Z', + source: 'web', + status: 'Pending', + requiresApproval: true, + latitude: null, + longitude: null, + accuracyMeters: null, + terminalDistanceMeters: null, + terminalRadiusMeters: null, + terminalMaxAccuracyMeters: null, + positionTimestamp: null, + clockOutLatitude: null, + clockOutLongitude: null, + clockOutAccuracyMeters: null, + clockOutTerminalDistanceMeters: null, + clockOutTerminalRadiusMeters: null, + clockOutTerminalMaxAccuracyMeters: null, + clockOutPositionTimestamp: null, + terminalId: null, + clockOutTerminalId: null, + clockInChallengeId: null, + clockOutChallengeId: null, + projectId: null, + projectCode: null, + projectName: null, + serviceOrderId: null, + serviceOrderNo: null, + serviceOrderTitle: null, + activity: null, + summary: { grossMinutes: 720, breakMinutes: 45, netMinutes: 675 }, +}; +function approval( + overrides: Partial = {}, +): TimeEntryApprovalDto { + return { + entry, + revision: 7, + employeeName: 'Anna Example', + managerId: 'manager-1', + managerName: 'Marc Manager', + state: 'PendingManager', + canDecide: true, + ...overrides, + }; +} +function renderPanel(scope: 'own' | 'review' = 'review') { + return renderWithProviders(); +} + +let storedLocale: string | null; +beforeEach(() => { + storedLocale = 'de'; + vi.spyOn(window, 'localStorage', 'get').mockReturnValue({ + length: 1, + clear: () => { + storedLocale = null; + }, + key: () => 'openclockwork.locale', + removeItem: () => { + storedLocale = null; + }, + getItem: () => storedLocale, + setItem: (_key: string, value: string) => { + storedLocale = value; + }, + }); + mocks.user.id = 'manager-1'; + mocks.user.role = 'Manager'; + mocks.list.mockReset().mockResolvedValue([approval()]); + mocks.decide + .mockReset() + .mockResolvedValue(approval({ state: 'PendingHr', canDecide: false })); + mocks.history.mockReset().mockResolvedValue([]); +}); + +afterEach(() => vi.restoreAllMocks()); + +describe('out-of-frame time entry approvals', () => { + it('shows the manager the correct stage, sends the revision and refetches lists, history and totals', async () => { + const { queryClient } = renderPanel(); + const invalidate = vi.spyOn(queryClient, 'invalidateQueries'); + expect(await screen.findByText('Wartet auf Führungskraft')).toBeDefined(); + expect( + screen.getByText(/Nächster Schritt: Marc Manager/).textContent, + ).toContain('Genehmigungen → Zeitbuchungen'); + expect(mocks.list).toHaveBeenCalledWith('review'); + expect( + screen.queryByRole('button', { name: 'Als HR bestätigen' }), + ).toBeNull(); + fireEvent.click( + screen.getByRole('button', { name: 'Genehmigungsverlauf' }), + ); + await screen.findByText( + 'Noch keine Entscheidungen oder Änderungen vorhanden.', + ); + mocks.list.mockResolvedValue([ + approval({ state: 'PendingHr', canDecide: false }), + ]); + fireEvent.click( + screen.getByRole('button', { name: 'Als Führungskraft genehmigen' }), + ); + await waitFor(() => + expect(mocks.decide).toHaveBeenCalledWith('entry-1', { + revision: 7, + stage: 'Manager', + outcome: 'Approve', + }), + ); + expect(await screen.findByText('Wartet auf HR')).toBeDefined(); + expect(screen.getByRole('status').textContent).toContain( + 'Als Nächstes bestätigt HR', + ); + expect( + screen.queryByRole('button', { name: 'Als Führungskraft genehmigen' }), + ).toBeNull(); + expect(mocks.list.mock.calls.length).toBeGreaterThan(1); + expect(mocks.history.mock.calls.length).toBeGreaterThan(1); + for (const key of [ + 'time-entry-approvals', + 'time-entry-approval-history', + 'time-entries', + 'account', + 'violations', + 'working-time-report', + 'project-report', + ]) { + expect(invalidate).toHaveBeenCalledWith({ queryKey: [key] }); + } + }); + + it.each([ + { + role: 'Manager', + id: 'manager-1', + state: 'PendingManager' as const, + stage: 'Manager', + label: 'Als Führungskraft ablehnen', + }, + { + role: 'HRAdmin', + id: 'hr-1', + state: 'PendingHr' as const, + stage: 'HR', + label: 'Als HR ablehnen', + }, + ])( + 'requires a nonblank reason when $role rejects', + async ({ role, id, state, stage, label }) => { + mocks.user.role = role; + mocks.user.id = id; + mocks.list.mockResolvedValue([approval({ state })]); + renderPanel(); + const reject = await screen.findByRole('button', { name: label }); + expect(reject.hasAttribute('disabled')).toBe(true); + const note = screen.getByLabelText('Notiz (bei Ablehnung erforderlich)'); + fireEvent.change(note, { target: { value: ' ' } }); + expect(reject.hasAttribute('disabled')).toBe(true); + fireEvent.click(reject); + expect(mocks.decide).not.toHaveBeenCalled(); + fireEvent.change(note, { + target: { value: ' Bitte Zeitraum prüfen ' }, + }); + mocks.list.mockResolvedValue([ + approval({ state: 'Rejected', canDecide: false }), + ]); + fireEvent.click(reject); + await waitFor(() => + expect(mocks.decide).toHaveBeenCalledWith('entry-1', { + revision: 7, + stage, + outcome: 'Reject', + note: 'Bitte Zeitraum prüfen', + }), + ); + expect( + await screen.findByText('Ablehnung mit Begründung gespeichert.'), + ).toBeDefined(); + }, + ); + + it('lets HR confirm only the HR stage with the current revision', async () => { + mocks.user.role = 'HRAdmin'; + mocks.user.id = 'hr-1'; + mocks.list.mockResolvedValue([ + approval({ state: 'PendingHr', revision: 12 }), + ]); + renderPanel(); + const confirm = await screen.findByRole('button', { + name: 'Als HR bestätigen', + }); + expect( + screen.queryByRole('button', { name: 'Als Führungskraft genehmigen' }), + ).toBeNull(); + fireEvent.change( + screen.getByLabelText('Notiz (bei Ablehnung erforderlich)'), + { target: { value: 'Geprüft' } }, + ); + mocks.list.mockResolvedValue([ + approval({ state: 'Approved', canDecide: false }), + ]); + fireEvent.click(confirm); + await waitFor(() => + expect(mocks.decide).toHaveBeenCalledWith('entry-1', { + revision: 12, + stage: 'HR', + outcome: 'Approve', + note: 'Geprüft', + }), + ); + expect(await screen.findByText('Genehmigt')).toBeDefined(); + }); + + it('does not let HR bypass the assigned manager', async () => { + mocks.user.role = 'HRAdmin'; + mocks.user.id = 'hr-1'; + renderPanel(); + await screen.findByText('Wartet auf Führungskraft'); + expect( + screen.queryByLabelText('Notiz (bei Ablehnung erforderlich)'), + ).toBeNull(); + expect( + screen.queryByRole('button', { name: 'Als HR bestätigen' }), + ).toBeNull(); + }); + + it.each([ + 'Open', + 'BlockedManager', + 'Approved', + 'Rejected', + 'Voided', + 'PendingHr', + ] as const)( + 'shows %s without manager actions even with an inconsistent canDecide flag', + async (state) => { + mocks.list.mockResolvedValue([approval({ state })]); + renderPanel(); + await screen.findByText(/Anna Example/); + expect( + screen.queryByLabelText('Notiz (bei Ablehnung erforderlich)'), + ).toBeNull(); + expect( + screen.getByRole('button', { name: 'Genehmigungsverlauf' }), + ).toBeDefined(); + }, + ); + + it('respects server permissions and manager assignment', async () => { + mocks.list.mockResolvedValue([ + approval({ canDecide: false }), + approval({ + entry: { ...entry, id: 'entry-2' }, + managerId: 'other-manager', + }), + ]); + renderPanel(); + await screen.findAllByText('Wartet auf Führungskraft'); + expect( + screen.queryByLabelText('Notiz (bei Ablehnung erforderlich)'), + ).toBeNull(); + }); + + it.each(['Employee', 'Manager', 'HRAdmin'])( + 'shows own status/history but no decisions for an owner with role %s', + async (role) => { + mocks.user.role = role; + mocks.user.id = entry.employeeId; + mocks.list.mockResolvedValue([approval({ managerId: entry.employeeId })]); + renderPanel('own'); + await screen.findByText('Wartet auf Führungskraft'); + expect( + screen.getByText('Rahmenzeit-Genehmigungen für meine Zeitbuchungen'), + ).toBeDefined(); + expect(mocks.list).toHaveBeenCalledWith('own'); + expect( + screen.queryByLabelText('Notiz (bei Ablehnung erforderlich)'), + ).toBeNull(); + fireEvent.click( + screen.getByRole('button', { name: 'Genehmigungsverlauf' }), + ); + expect( + await screen.findByText( + 'Noch keine Entscheidungen oder Änderungen vorhanden.', + ), + ).toBeDefined(); + }, + ); + + it('never offers manager-stage actions to HR even with an inconsistent server flag and assignment', async () => { + mocks.user.role = 'HRAdmin'; + mocks.list.mockResolvedValue([ + approval({ managerId: mocks.user.id, canDecide: true }), + ]); + renderPanel(); + await screen.findByText('Wartet auf Führungskraft'); + expect( + screen.queryByLabelText('Notiz (bei Ablehnung erforderlich)'), + ).toBeNull(); + }); + + it('prevents self-approval even in a broad reviewer role', async () => { + mocks.user.role = 'HRAdmin'; + mocks.user.id = entry.employeeId; + mocks.list.mockResolvedValue([approval({ state: 'PendingHr' })]); + renderPanel(); + await screen.findByText('Wartet auf HR'); + expect( + screen.queryByLabelText('Notiz (bei Ablehnung erforderlich)'), + ).toBeNull(); + }); + + it('does not query the reviewer list for an employee', async () => { + mocks.user.role = 'Employee'; + renderPanel(); + expect(mocks.list).not.toHaveBeenCalled(); + expect(screen.queryByText('Zeitbuchungen')).toBeNull(); + }); + + it('keeps stale errors and the entered reason visible, then uses the refreshed revision', async () => { + mocks.decide.mockRejectedValueOnce( + new Error('Stale revision: entry changed'), + ); + renderPanel(); + const reject = await screen.findByRole('button', { + name: 'Als Führungskraft ablehnen', + }); + const note = screen.getByLabelText( + 'Notiz (bei Ablehnung erforderlich)', + ) as HTMLInputElement; + fireEvent.change(note, { target: { value: 'Zeitraum falsch' } }); + fireEvent.click(reject); + expect(await screen.findByRole('alert')).toBeDefined(); + expect(screen.getByRole('alert').textContent).toContain( + 'Stale revision: entry changed', + ); + expect(note.value).toBe('Zeitraum falsch'); + mocks.list.mockResolvedValue([approval({ revision: 9 })]); + fireEvent.click( + screen.getByRole('button', { name: 'Genehmigungen aktualisieren' }), + ); + await waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(2)); + await waitFor(() => + expect( + screen + .getByRole('button', { name: 'Genehmigungen aktualisieren' }) + .hasAttribute('disabled'), + ).toBe(false), + ); + expect(screen.getByRole('alert').textContent).toContain( + 'Stale revision: entry changed', + ); + mocks.decide.mockResolvedValueOnce( + approval({ state: 'Rejected', canDecide: false }), + ); + fireEvent.click( + screen.getByRole('button', { name: 'Als Führungskraft ablehnen' }), + ); + await waitFor(() => + expect(mocks.decide).toHaveBeenLastCalledWith('entry-1', { + revision: 9, + stage: 'Manager', + outcome: 'Reject', + note: 'Zeitraum falsch', + }), + ); + }); + + it('renders every decision and invalidation with actor, timestamp and reason', async () => { + mocks.history.mockResolvedValue([ + { + id: '1', + actorId: 'manager-1', + actorName: 'Marc Manager', + action: 'FrameManagerApproved', + reason: 'Kundentermin', + occurredAt: '2026-05-04T17:30:00Z', + }, + { + id: '2', + actorId: 'hr-1', + actorName: 'Hannah HR', + action: 'FrameHrConfirmed', + reason: null, + occurredAt: '2026-05-04T18:00:00Z', + }, + { + id: '3', + actorId: null, + actorName: null, + action: 'FrameApprovalInvalidated', + reason: 'Führungskraft geändert', + occurredAt: '2026-05-05T08:00:00Z', + }, + { + id: '4', + actorId: 'manager-2', + actorName: 'New Manager', + action: 'FrameManagerRejected', + reason: 'Unklarer Zeitraum', + occurredAt: '2026-05-05T09:00:00Z', + }, + { + id: '5', + actorId: 'hr-1', + actorName: 'Hannah HR', + action: 'FrameHrRejected', + reason: 'Fehlender Nachweis', + occurredAt: '2026-05-05T10:00:00Z', + }, + { + id: '6', + actorId: 'owner-1', + actorName: 'Anna Example', + action: 'FrameSegmentCreated', + reason: null, + occurredAt: '2026-05-05T11:00:00Z', + }, + ]); + const { container } = renderPanel('own'); + fireEvent.click( + await screen.findByRole('button', { name: 'Genehmigungsverlauf' }), + ); + expect(await screen.findByText('Kundentermin')).toBeDefined(); + for (const label of [ + 'Von Führungskraft genehmigt', + 'Von HR bestätigt', + 'Bisherige Genehmigung durch Änderung aufgehoben', + 'Von Führungskraft abgelehnt', + 'Von HR abgelehnt', + 'Buchungssegment nach Änderung neu angelegt', + 'Führungskraft geändert', + 'Unklarer Zeitraum', + 'Fehlender Nachweis', + ]) { + expect(screen.getByText(label)).toBeDefined(); + } + expect(screen.getByText(/Marc Manager ·/)).toBeDefined(); + expect(screen.getByText(/System ·/)).toBeDefined(); + expect(container.querySelector('time')?.getAttribute('datetime')).toBe( + '2026-05-04T17:30:00Z', + ); + }); + + it('shows loading, query errors and recoverable empty states', async () => { + let rejectList!: (reason: Error) => void; + mocks.list.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectList = reject; + }), + ); + renderPanel(); + expect(screen.getByRole('status').textContent).toBe('Lädt …'); + rejectList(new Error('Service unavailable')); + expect((await screen.findByRole('alert')).textContent).toContain( + 'Service unavailable', + ); + mocks.list.mockResolvedValue([]); + fireEvent.click( + screen.getByRole('button', { name: 'Genehmigungen aktualisieren' }), + ); + expect( + await screen.findByText( + 'Keine genehmigungspflichtigen Zeitbuchungen vorhanden.', + ), + ).toBeDefined(); + }); + + it('surfaces history errors and allows retry', async () => { + mocks.history.mockRejectedValueOnce(new Error('History unavailable')); + renderPanel('own'); + fireEvent.click( + await screen.findByRole('button', { name: 'Genehmigungsverlauf' }), + ); + expect((await screen.findByRole('alert')).textContent).toContain( + 'History unavailable', + ); + mocks.history.mockResolvedValue([]); + fireEvent.click( + screen.getAllByRole('button', { name: 'Genehmigungen aktualisieren' })[1], + ); + expect( + await screen.findByText( + 'Noch keine Entscheidungen oder Änderungen vorhanden.', + ), + ).toBeDefined(); + }); + + it.each([ + { + locale: 'de', + title: 'Zeitbuchungen', + state: 'Blockiert – Führungskraft fehlt', + manager: 'aktive Führungskraft zuzuweisen', + where: 'Genehmigungen → Zeitbuchungen', + }, + { + locale: 'en', + title: 'Time entries', + state: 'Blocked – no active manager', + manager: 'assign an active manager', + where: 'Approvals → Time entries', + }, + ])( + 'explains the chain and missing-manager recovery in $locale', + async ({ locale, title, state, manager, where }) => { + storedLocale = locale; + mocks.list.mockResolvedValue([ + approval({ + state: 'BlockedManager', + managerId: null, + managerName: null, + canDecide: false, + }), + ]); + renderPanel(); + expect(await screen.findByText(state)).toBeDefined(); + expect(screen.getByText(title)).toBeDefined(); + const help = screen.getByText(new RegExp(manager)); + expect(help.textContent).toContain(where); + expect(screen.queryByLabelText(/Note|Notiz/)).toBeNull(); + }, + ); + + it('provides English actions, error guidance and history labels', async () => { + storedLocale = 'en'; + mocks.user.role = 'HRAdmin'; + mocks.user.id = 'hr-1'; + mocks.list.mockResolvedValue([approval({ state: 'PendingHr' })]); + mocks.decide.mockRejectedValueOnce(new Error('Revision conflict')); + mocks.history.mockResolvedValue([ + { + id: '1', + actorId: 'manager-1', + actorName: 'Marc Manager', + action: 'FrameManagerApproved', + reason: 'Client visit', + occurredAt: '2026-05-04T17:30:00Z', + }, + ]); + renderPanel(); + fireEvent.click( + await screen.findByRole('button', { name: 'Confirm as HR' }), + ); + expect((await screen.findByRole('alert')).textContent).toContain( + 'The decision was not saved.', + ); + expect(screen.getByRole('alert').textContent).toContain( + 'Refresh and check the current status', + ); + expect( + screen.getByLabelText('Note (required for rejection)'), + ).toBeDefined(); + expect( + screen + .getByRole('button', { name: 'Reject as HR' }) + .hasAttribute('disabled'), + ).toBe(true); + fireEvent.click(screen.getByRole('button', { name: 'Approval history' })); + expect(await screen.findByText('Approved by manager')).toBeDefined(); + expect(screen.getByText('Client visit')).toBeDefined(); + }); +}); diff --git a/apps/web/src/components/timeentries/TimeEntryApprovals.tsx b/apps/web/src/components/timeentries/TimeEntryApprovals.tsx new file mode 100644 index 0000000..9b20dcf --- /dev/null +++ b/apps/web/src/components/timeentries/TimeEntryApprovals.tsx @@ -0,0 +1,319 @@ +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + api, + type TimeEntryApprovalDecision, + type TimeEntryApprovalDto, +} from '../../api/client'; +import { useCurrentUser } from '../../app/auth'; +import { useI18n } from '../../app/i18n'; + +/** All lists and downstream totals must reflect decisions and edited entries. */ +export async function refreshTimeEntryApprovals( + qc: ReturnType, +) { + await Promise.all( + [ + 'time-entry-approvals', + 'time-entry-approval-history', + 'time-entries', + 'account', + 'violations', + 'working-time-report', + 'project-report', + ].map((key) => qc.invalidateQueries({ queryKey: [key] })), + ); +} + +export function TimeEntryApprovals({ scope }: { scope: 'own' | 'review' }) { + const user = useCurrentUser(); + const { t } = useI18n(); + const allowed = + scope === 'own' || user.role === 'Manager' || user.role === 'HRAdmin'; + const query = useQuery({ + queryKey: ['time-entry-approvals', scope, user.id, user.role], + queryFn: () => api.timeEntryApprovals(scope), + enabled: allowed, + }); + if (!allowed) return null; + + return ( + + + + {t( + scope === 'own' ? 'frameApproval.ownTitle' : 'frameApproval.title', + )} + +

+ {t('frameApproval.chain')} +

+

+ {t('frameApproval.managerChanged')} +

+
+ + {query.isPending &&

{t('common.loading')}

} + {query.isError && ( + + + {t('frameApproval.loadFailed')}{' '} + {query.error instanceof Error ? query.error.message : ''} + + + )} + + {query.isSuccess && query.data.length === 0 && ( +

+ {t('frameApproval.empty')} +

+ )} + {query.data && query.data.length > 0 && ( +
    + {query.data.map((approval) => ( + + ))} +
+ )} +
+
+ ); +} + +function TimeEntryApprovalRow({ + approval, + scope, +}: { + approval: TimeEntryApprovalDto; + scope: 'own' | 'review'; +}) { + const user = useCurrentUser(); + const { t, formatDateTime } = useI18n(); + const qc = useQueryClient(); + const [note, setNote] = useState(''); + const [historyOpen, setHistoryOpen] = useState(false); + const [result, setResult] = useState(null); + const { entry, state } = approval; + const stage = + state === 'PendingManager' + ? 'Manager' + : state === 'PendingHr' + ? 'HR' + : null; + // canDecide is server-authoritative; also guard role, stage and self-approval. + const canDecide = + scope === 'review' && + approval.canDecide && + entry.employeeId !== user.id && + !!entry.clockOut && + ((stage === 'Manager' && + user.role === 'Manager' && + approval.managerId === user.id) || + (stage === 'HR' && user.role === 'HRAdmin')); + const decision = useMutation({ + mutationFn: (outcome: TimeEntryApprovalDecision['outcome']) => { + // Do not allow a stale UI event to bypass the currently displayed stage. + if (!canDecide || !stage) throw new Error(t('frameApproval.notAllowed')); + if (outcome === 'Reject' && !note.trim()) { + throw new Error(t('frameApproval.reasonRequired')); + } + return api.decideTimeEntryApproval(entry.id, { + revision: approval.revision, + stage, + outcome, + ...(note.trim() ? { note: note.trim() } : {}), + }); + }, + onSuccess: async (_updated, outcome) => { + setResult( + t( + outcome === 'Reject' + ? 'frameApproval.rejectedResult' + : stage === 'Manager' + ? 'frameApproval.managerResult' + : 'frameApproval.hrResult', + ), + ); + setNote(''); + await refreshTimeEntryApprovals(qc); + }, + }); + const labelId = `frame-approval-${entry.id}`; + + return ( +
  • +
    +
    +

    + {approval.employeeName} · {formatDateTime(entry.clockIn)} –{' '} + {entry.clockOut + ? formatDateTime(entry.clockOut) + : t('booking.open')} +

    + {entry.summary && ( +

    + {t('booking.summary', { + gross: `${entry.summary.grossMinutes}min`, + break: entry.summary.breakMinutes, + net: `${entry.summary.netMinutes}min`, + })} +

    + )} + {entry.projectCode && ( +

    + {entry.projectCode} + {entry.serviceOrderNo ? ` · ${entry.serviceOrderNo}` : ''} +

    + )} + {entry.activity &&

    {entry.activity}

    } +
    + + {t(`frameApproval.state.${state}`)} + +
    +

    + {t(`frameApproval.help.${state}`, { + manager: approval.managerName ?? t('frameApproval.assignedManager'), + })} +

    + {result && ( +

    + {result} +

    + )} + {decision.isError && ( + + + {t('frameApproval.decisionFailed')}{' '} + {decision.error instanceof Error ? decision.error.message : ''}{' '} + {t('frameApproval.refreshHint')} + + + )} + {canDecide && ( +
    + + setNote(event.target.value)} + /> +

    + {t('frameApproval.reasonRequired')} +

    +
    + + +
    +
    + )} + + {historyOpen && } +
  • + ); +} + +function ApprovalHistory({ entryId }: { entryId: string }) { + const user = useCurrentUser(); + const { t, formatDateTime } = useI18n(); + const history = useQuery({ + queryKey: ['time-entry-approval-history', entryId, user.id], + queryFn: () => api.timeEntryApprovalHistory(entryId), + }); + return ( +
    + {history.isPending &&

    {t('common.loading')}

    } + {history.isError && ( + + + {t('frameApproval.historyFailed')}{' '} + {history.error instanceof Error ? history.error.message : ''} + + + )} + {history.isSuccess && history.data.length === 0 && ( +

    {t('frameApproval.noHistory')}

    + )} + {history.data && ( +
      + {history.data.map((event) => ( +
    1. +

      + {t(`frameApproval.event.${event.action}`)} +

      +

      + {event.actorName ?? event.actorId ?? t('frameApproval.system')}{' '} + ·{' '} + +

      + {event.reason && ( +

      {event.reason}

      + )} +
    2. + ))} +
    + )} + {history.isError && ( + + )} +
    + ); +} diff --git a/apps/web/src/routes/AdminRequestsPage.spec.tsx b/apps/web/src/routes/AdminRequestsPage.spec.tsx index 30274bf..ba0ae87 100644 --- a/apps/web/src/routes/AdminRequestsPage.spec.tsx +++ b/apps/web/src/routes/AdminRequestsPage.spec.tsx @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { fireEvent, screen } from '@testing-library/react'; import { renderWithProviders } from '../test-utils'; import { AdminRequestsPage } from './AdminRequestsPage'; +import { api } from '../api/client'; const request = { id: 'request-42', @@ -17,6 +18,7 @@ const request = { }; vi.mock('../api/client', () => ({ api: { + timeEntryApprovals: vi.fn().mockResolvedValue([]), listRequests: vi .fn() .mockImplementation(async (filter) => @@ -43,6 +45,8 @@ describe('request identity in approval context (#42)', () => { it('shows the employee name in the inbox and retained audit trail', async () => { renderWithProviders(); expect(await screen.findByText(/Anna Example · Urlaub/)).toBeDefined(); + expect(await screen.findByText('Zeitbuchungen')).toBeDefined(); + expect(api.timeEntryApprovals).toHaveBeenCalledWith('review'); fireEvent.click(screen.getByRole('button', { name: 'Verlauf' })); expect( await screen.findByText(/Anna Example · Urlaub · 4.5.2026/), diff --git a/apps/web/src/routes/AdminRequestsPage.tsx b/apps/web/src/routes/AdminRequestsPage.tsx index e7ef36f..819cc25 100644 --- a/apps/web/src/routes/AdminRequestsPage.tsx +++ b/apps/web/src/routes/AdminRequestsPage.tsx @@ -25,6 +25,7 @@ import { api, type BulkResult, type RequestDto } from '../api/client'; import { useCurrentUser } from '../app/auth'; import { cn } from '@/lib/utils'; import { useI18n } from '../app/i18n'; +import { TimeEntryApprovals } from '../components/timeentries/TimeEntryApprovals'; type BulkDialog = | { mode: 'approve'; ids: string[] } @@ -107,6 +108,8 @@ export function AdminRequestsPage() {

    + + {bulkOutcome && ( ({ api: { timeEntries: (...args: unknown[]) => timeEntriesMock(...args), + timeEntryApprovals: (...args: unknown[]) => timeEntryApprovalsMock(...args), + timeEntryApprovalHistory: (...args: unknown[]) => + approvalHistoryMock(...args), clockIn: (...args: unknown[]) => clockInMock(...args), clockOut: (...args: unknown[]) => clockOutMock(...args), dailyBlockOption: (...args: unknown[]) => dailyBlockOptionMock(...args), @@ -89,6 +94,8 @@ const PROJECTS = [ describe('BookingPage', () => { beforeEach(() => { timeEntriesMock.mockClear().mockResolvedValue([]); + timeEntryApprovalsMock.mockClear().mockResolvedValue([]); + approvalHistoryMock.mockClear().mockResolvedValue([]); clockInMock .mockClear() .mockResolvedValue(entryFixture({ clockOut: null, status: 'Open' })); @@ -113,6 +120,49 @@ describe('BookingPage', () => { onlineState = true; }); + it('makes own approval status, next reviewer and history discoverable beside bookings', async () => { + timeEntryApprovalsMock.mockResolvedValue([ + { + entry: entryFixture({ requiresApproval: true }), + revision: 4, + employeeName: 'Test User', + managerId: 'manager-1', + managerName: 'Maria Manager', + state: 'PendingManager', + canDecide: false, + }, + ]); + approvalHistoryMock.mockResolvedValue([ + { + id: 'history-1', + actorId: 'manager-1', + actorName: 'Maria Manager', + action: 'FrameApprovalInvalidated', + reason: 'Zeitraum geändert', + occurredAt: '2026-05-04T16:00:00Z', + }, + ]); + const { BookingPage } = await import('./BookingPage'); + renderWithProviders(); + expect( + await screen.findByText( + 'Rahmenzeit-Genehmigungen für meine Zeitbuchungen', + ), + ).toBeDefined(); + expect(await screen.findByText('Wartet auf Führungskraft')).toBeDefined(); + expect( + screen.getByText(/Nächster Schritt: Maria Manager/).textContent, + ).toContain('Genehmigungen → Zeitbuchungen'); + expect(timeEntryApprovalsMock).toHaveBeenCalledWith('own'); + expect( + screen.queryByRole('button', { name: 'Als Führungskraft genehmigen' }), + ).toBeNull(); + fireEvent.click( + screen.getByRole('button', { name: 'Genehmigungsverlauf' }), + ); + expect(await screen.findByText('Zeitraum geändert')).toBeDefined(); + }); + it('renders the "not clocked in" state when there is no open entry', async () => { const { BookingPage } = await import('./BookingPage'); renderWithProviders(); diff --git a/apps/web/src/routes/BookingPage.tsx b/apps/web/src/routes/BookingPage.tsx index c7102e1..ad17be8 100644 --- a/apps/web/src/routes/BookingPage.tsx +++ b/apps/web/src/routes/BookingPage.tsx @@ -30,6 +30,10 @@ import { import { useCurrentUser } from '../app/auth'; import { useOnline } from '../app/use-online'; import { useI18n } from '../app/i18n'; +import { + refreshTimeEntryApprovals, + TimeEntryApprovals, +} from '../components/timeentries/TimeEntryApprovals'; function captureGps(): Promise<{ latitude: number; @@ -342,7 +346,7 @@ export function BookingPage() { if (context?.previous) qc.setQueryData(entriesKey, context.previous); setError(e instanceof Error ? e.message : t('booking.failed')); }, - onSettled: () => qc.invalidateQueries({ queryKey: entriesKey }), + onSettled: () => refreshTimeEntryApprovals(qc), }); const clockOutMutation = useMutation< @@ -365,7 +369,7 @@ export function BookingPage() { if (context?.previous) qc.setQueryData(entriesKey, context.previous); setError(e instanceof Error ? e.message : t('booking.clockOutFailed')); }, - onSettled: () => qc.invalidateQueries({ queryKey: entriesKey }), + onSettled: () => refreshTimeEntryApprovals(qc), }); return ( @@ -530,6 +534,8 @@ export function BookingPage() { + + {t('booking.lastEntries')} @@ -589,11 +595,12 @@ export function BookingPage() { {t('booking.dailyBlockSource')} )} - {e.requiresApproval && ( - - {t('booking.approval')} - - )} + {e.requiresApproval && + (e.status === 'Pending' || e.status === 'Open') && ( + + {t('booking.approval')} + + )} setEditDialogEntry(null)} onSaved={() => { setEditDialogEntry(null); - qc.invalidateQueries({ queryKey: entriesKey }); + void refreshTimeEntryApprovals(qc); }} /> )} @@ -651,7 +658,7 @@ export function BookingPage() { onClose={() => setSplitDialogEntry(null)} onSaved={() => { setSplitDialogEntry(null); - qc.invalidateQueries({ queryKey: entriesKey }); + void refreshTimeEntryApprovals(qc); }} /> )} @@ -662,7 +669,7 @@ export function BookingPage() { onClose={() => setBookRangeOpen(false)} onSaved={() => { setBookRangeOpen(false); - qc.invalidateQueries({ queryKey: entriesKey }); + void refreshTimeEntryApprovals(qc); }} /> )} @@ -673,9 +680,7 @@ export function BookingPage() { onClose={() => setDailyBlockOpen(false)} onSaved={() => { setDailyBlockOpen(false); - qc.invalidateQueries({ queryKey: entriesKey }); - qc.invalidateQueries({ queryKey: ['account', employeeId] }); - qc.invalidateQueries({ queryKey: ['violations', employeeId] }); + void refreshTimeEntryApprovals(qc); }} /> )} diff --git a/docs/ISSUE_FIXES_MANUAL_TEST.de.md b/docs/ISSUE_FIXES_MANUAL_TEST.de.md index 073b92d..8a66cb0 100644 --- a/docs/ISSUE_FIXES_MANUAL_TEST.de.md +++ b/docs/ISSUE_FIXES_MANUAL_TEST.de.md @@ -300,5 +300,239 @@ TEST_WEB_PORT=18042 docker compose --env-file /dev/null \ ``` Zum Fortsetzen denselben `up -d --wait`-Befehl verwenden, **nicht** erneut seeden. -Kein `down -v`: Das würde die Nachtestdaten löschen. Folgepakete #41, #43, #45 -und #44 ergänzen diese Anleitung erst bei ihrer jeweiligen Implementierung. +Kein `down -v`: Das würde die Nachtestdaten löschen. Weitere Pakete ergänzen +diese Anleitung erst bei ihrer jeweiligen Implementierung. + +## Paket #41 – Freigabe normaler Buchungen außerhalb der Rahmenzeit + +### Branch, Abhängigkeit und isoliertes Setup + +- Branch: `fix/41-outside-frame-approval`. +- Worktree: `/Users/patrickschiller/openclockwork-issue-41`. +- Basis ist der akzeptierte #42-Head `4ff247bc18adfc0b245d8dbb044f7968cf2a4678`. +- Der PR zielt für die vorhandene vollständige CI auf `main`, enthält deshalb + **kumulativ #42 + #41** und hängt von PR #46 ab. Vorgesehene Reihenfolge: + zuerst #46, danach #41; hier wird nichts gemergt. Der aktuelle obere Branch + kann ohne Merge bereits beide Pakete gemeinsam testen. +- Additive Migration `20261107031000_time_entry_frame_approval`: zwei nullable + Signaturfelder. Bestehende Buchungswerte, Revisionen und Historien bleiben + erhalten; alte ausstehende Buchungen beginnen bei der Managerprüfung. + +Grundlage weiterhin [LOCAL_DOCKER_TEST.de.md](LOCAL_DOCKER_TEST.de.md). +Einen separaten Stack verwenden; keine bestehenden Projekte/Volumes löschen. +Im #41-Worktree oder einem neuen Checkout dieses Branches: + +```bash +git fetch origin +git worktree add ../openclockwork-41-manual origin/fix/41-outside-frame-approval +cd ../openclockwork-41-manual + +cat > /tmp/openclockwork-41-compose.yml <<'YAML' +services: + api: + image: openclockwork-api:issue41-local + web: + image: openclockwork-web:issue41-local +YAML + +TEST_WEB_PORT=18041 docker compose --env-file /dev/null \ + -p openclockwork-issue41 -f docker-compose.test.yml \ + -f /tmp/openclockwork-41-compose.yml build + +TEST_WEB_PORT=18041 docker compose --env-file /dev/null \ + -p openclockwork-issue41 -f docker-compose.test.yml \ + -f /tmp/openclockwork-41-compose.yml up -d --wait + +TEST_WEB_PORT=18041 docker compose --env-file /dev/null \ + -p openclockwork-issue41 -f docker-compose.test.yml \ + -f /tmp/openclockwork-41-compose.yml exec -T \ + -e NODE_ENV=development \ + -e OPENCLOCKWORK_SEED_CONFIRM_DATABASE=openclockwork_test \ + api node --import tsx prisma/seed.ts +``` + +Seed nur auf der **neuen leeren Datenbank** ausführen, beim Wiederaufnehmen +nicht erneut. Oberfläche: . API-Zeitzone im bestehenden +Test-Compose ist Europe/Berlin. Die synthetischen Rollen und das lokale Passwort +stehen im #42-Setup oben: Anna = Beschäftigte, Marc = zugewiesener Manager, +Hannah = HR, Bernd = anderer Beschäftigter. Separate Browserprofile verwenden. +Anna muss einem aktiven Manager mit Rolle **Manager** zugeordnet sein. +Rolle HRAdmin ersetzt diese erste Stufe ausdrücklich nicht. + +### A. Tatsächliches Stempeln und Auffindbarkeit + +1. Als Hannah unter Mitarbeitenden/Arbeitszeitplänen für Anna einen Plan wählen, + dessen Rahmen die **aktuelle Server-Ortszeit nicht einschließt** (z. B. + 07:00–08:00, wenn gerade nachmittags getestet wird). Vorherige Zuordnung + notieren. Keine laufende Session und keinen Tagesblock für den Testtag nutzen. +2. Als Anna **Buchen → Kommen**, kurz darauf **Gehen**. Es muss ein positives, + abgeschlossenes Intervall entstehen. Während die Uhr läuft: kein Entscheiden + möglich, auch nicht per direktem Freigabe-API-Aufruf. +3. In **Buchen** ist der eigene Freigabebereich sichtbar. Er benennt die + Managerprüfung, Marc und den Ort **Genehmigungen → Zeitbuchungen** sowie + die danach zwingende HR-Bestätigung. Es handelt sich um eine normale + PWA-Zeitbuchung, nicht um einen neu anzulegenden Zeitkorrekturantrag. +4. Als Marc **Genehmigungen** öffnen: Im Bereich für Zeitbuchungen steht Anna + mit Zeitraum und Status. Managerfreigabe ausführen. Erwartet: + `PendingHr`, weiterhin Buchungsstatus `Pending`; noch kein HR-Endergebnis. +5. Als Hannah dieselbe Seite öffnen, HR bestätigen. Erwartet: dieselbe + Buchungs-ID, `Approved`; Anna und Marc sehen nach Aktualisierung das Ergebnis. + Der Verlauf zeigt beide tatsächlichen Akteure, Zeitpunkte, Ergebnisse und + gegebenenfalls Notizen. Es entsteht kein zusätzlicher Antrag. +6. Auf Englisch wiederholen. Alle neuen Stufen, Hilfen, Aktionen, Fehler und + Historienereignisse sind übersetzt; mit Tab/Enter bedienbar. + +### B. Reproduzierbare Legacy-Ausgangsbuchungen + +Zum gezielten Nachtest alter ausstehender Einträge können auf **diesem neuen +synthetischen Stack** drei feste Intervalle angelegt werden. Die Seed-Zeiten +liegen relativ zum aktuellen Datum; vorab prüfen, dass die folgenden historischen +Oktobertage frei sind. Bei Konflikt keine vorhandenen Daten ändern, sondern +freie Tage im SQL wählen. Die neuen Spalten absichtlich nicht setzen: +Das bildet die Altbestands-Situation nach der Migration ab. + +```bash +TEST_WEB_PORT=18041 docker compose --env-file /dev/null \ + -p openclockwork-issue41 -f docker-compose.test.yml \ + -f /tmp/openclockwork-41-compose.yml exec -T db \ + psql -U openclockwork -d openclockwork_test -v ON_ERROR_STOP=1 <<'SQL' +INSERT INTO "TimeEntry" ( + "id", "employeeId", "clockIn", "clockOut", "source", "status", + "requiresApproval", "approvalMode", "note", "updatedAt" +) +SELECT fixture.id::uuid, employee.id, fixture.start_at::timestamptz, + fixture.end_at::timestamptz, 'Pwa', 'Pending', true, NULL, + 'Synthetischer Nachtest #41', CURRENT_TIMESTAMP +FROM "Employee" employee +CROSS JOIN (VALUES + ('00000000-0000-4000-8000-000000004101', '2026-10-05T03:00:00Z', '2026-10-05T05:00:00Z'), + ('00000000-0000-4000-8000-000000004102', '2026-10-06T03:00:00Z', '2026-10-06T05:00:00Z'), + ('00000000-0000-4000-8000-000000004103', '2026-10-07T03:00:00Z', '2026-10-07T05:00:00Z') +) AS fixture(id, start_at, end_at) +WHERE employee.email = 'anna.mueller@openclockwork.test' +ON CONFLICT ("id") DO NOTHING; +SQL +``` + +1. Ersten Eintrag durch Marc und Hannah wie oben freigeben. Erwartet: + unveränderte ID/Zeiten, je eine Entscheidung, kein Bypass wegen + `approvalMode=null`. +2. Zweiten Eintrag mit einer echten Notiz ablehnen; beide Ablehnungsstufen + getrennt prüfen (bei Bedarf einen weiteren freien synthetischen Eintrag + anlegen). Ohne nichtleere Begründung ist Ablehnen gesperrt. Nach Ablehnung + ist weder Managerfreigabe noch HR-Bestätigung nachträglich erlaubt. +3. Beim dritten Eintrag als Hannah vorübergehend Annas Managerzuordnung + entfernen oder einen nicht als Manager berechtigten Account zuweisen. + Erwartet: erklärter blockierter Zustand mit Aufforderung, dass HR eine + aktive Führungskraft zuordnet. Keine direkte HR-Bestätigung möglich. + Gültige Zuordnung wiederherstellen: erst Manager, dann HR. +4. Nach Managerfreigabe eine **andere** aktive Führungskraft zuordnen: + alte Führungskraft verliert Zugriff; alter HR-Tab darf nicht bestätigen. + Der neue Manager muss den aktuellen Stand freigeben, anschließend HR. + +### C. Negative Fälle, Änderungen und Konkurrenz + +Netzwerkdetails ausschließlich im isolierten Stack verwenden. Keine Bearer-Token +in Dokumente oder Issues übernehmen. + +`GET /api/timeentries/approvals?scope=own` liefert nur eigene Einträge; +`scope=review` liefert berechtigte Team-/HR-Sicht. Jeder Datensatz enthält +`revision`, `state`, `canDecide` und die Buchung. Entscheidung: + +```http +POST /api/timeentries//approval +Content-Type: application/json + +{ + "revision": 0, + "stage": "Manager", + "outcome": "Approve", + "note": "Synthetisch geprüft" +} +``` + +Für HR die **neu gelesene** Revision und `stage=HR` verwenden. +Ablehnung: `outcome=Reject`, nichtleere Notiz. Verlauf: +`GET /api/timeentries//approval-history`. + +| Versuch | Erwartung | +| --- | --- | +| Ohne Login | 401 | +| Beschäftigte lesen Review-Liste | 403; eigene Status-/Verlaufssicht bleibt möglich | +| Fremde Beschäftigte oder teamfremder Manager entscheiden/lesen Verlauf | 403 | +| `actorId` oder `employeeId` im Entscheidungsbody fälschen | Keine Berechtigungsänderung; Identität ausschließlich aus JWT | +| HR als Manager entscheiden / Manager als HR entscheiden | 403 | +| HR-Stufe vor Managerfreigabe | 409 | +| Eigene Buchung als Manager oder HR selbst freigeben | 403; andere zuständige Personen erforderlich | +| Offene, verworfene, bereits genehmigte/abgelehnte oder nicht genehmigungspflichtige Buchung entscheiden | 409 | +| Fehlende/negative Revision, ungültige Stufe | 400 | +| Veraltete Revision oder wiederholte Entscheidung | 409; keine zweite erfolgreiche Entscheidung, kein doppeltes Auditereignis | +| Zwei Managerentscheidungen oder zwei HR-Entscheidungen gleichzeitig | Genau ein Erfolg, einmal 409 | +| Buchungsänderung gleichzeitig mit Entscheidung auf derselben alten Revision | Genau ein Schreibvorgang erfolgreich; anderer 409 | +| Managerfreigabe, danach Projekt/Tätigkeit/Notiz ändern | Zurück zur Managerprüfung; alter HR-Stand 409, Verlauf enthält Invalidierung | +| Vollständig freigegebene Außenrahmenbuchung nachträglich ändern | Erneut Manager → HR, keine übernommene Endfreigabe | +| Freigegebene Außenrahmenbuchung aufteilen / Projektzeitbereich nachtragen | Betroffene Außenrahmensegmente erneut Managerprüfung; echte Rahmenzeitsegmente behalten normales Verhalten, alte Entscheidungen bleiben im Verlauf auffindbar | +| Abgelehnte Buchung aufteilen | Kein Reinwaschen: beide Segmente bleiben abgelehnt | +| Normale geschlossene Buchung innerhalb des Rahmens | Weiterhin direkt genehmigt; kein neuer Freigabeschritt | +| Separater Solo-Stack: Stempeln, manuell buchen, korrigieren, aufteilen | Unveränderte Solo-Regeln; keine Team-Freigabe alter/aktueller Daten möglich | + +Hannah kann eigene Außenrahmenbuchungen nicht selbst bestätigen; dafür eine +zweite synthetische HR-Person anlegen. Ebenso braucht eine Führungskraft bei +eigenen Buchungen eine andere zugewiesene Führungskraft. Wenn diese Rollen fehlen, +ist zunächst die Organisationszuordnung zu vervollständigen, nicht eine Stufe zu +überspringen. Danach ursprüngliche Plan-/Managerzuordnungen wiederherstellen. + +### Automatische Verifikation #41 + +Ausgeführt am 07.11.2026. Isolierter PostgreSQL-16-Container +`openclockwork-issue-41-db`, Loopback-Port `15441`, ausschließlich synthetische +Datenbanken `openclockwork_test_41` und `openclockwork_upgrade_41`. Keine +bestehende Datenbank zurückgesetzt. Toolchain: pnpm 9.15.9; API-/Web-Gesamttests +zusätzlich unter Node 20.20.2 (CI-Version), übrige lokale Gates unter Node 26.12.1. + +| Gate | Tatsächliches Ergebnis | +| --- | --- | +| `TZ=Europe/Berlin pnpm nx e2e api-e2e --testPathPatterns=time-entry-approvals` | **27/27 neue API-/Service-Regressionen bestanden** | +| `TZ=Europe/Berlin pnpm nx e2e api-e2e` unter Node 20.20.2 | **331/331 Tests, 38 Suites bestanden** | +| `TZ=UTC pnpm nx run-many -t test -p web,shared --run --skip-nx-cache` unter Node 20.20.2 | **Web 234/234 in 35 Dateien**, **Shared 88/88 in 7 Dateien bestanden**, ohne Cache | +| `pnpm nx run-many -t typecheck,lint -p api,web,shared,api-e2e` | **Alle vier Typechecks und Lint erfolgreich**; 0 Fehler, 13 bestehende Warnungen in unveränderten Dateien, keine neue Warnung | +| `pnpm nx run-many -t build -p api,web,shared` getrennt von Typechecks | **Alle drei Builds erfolgreich**, einschließlich Web/PWA; unveränderter Shared-Build aus Nx-Cache | +| `pnpm verify:api` mit isolierter Test-`DATABASE_URL` | **Erfolgreich**; finale OpenAPI-Datei und generierter Client bleiben identisch zur gestagten Fassung | +| Baseline-Migrationen aus akzeptiertem #42 → `node ops/upgrade-smoke.mjs seed` → neue Migration → `node ops/upgrade-smoke.mjs verify` | **Erfolgreich**; echte historische Pending-Buchung bleibt mit Revision 7 und null Signaturmarkern erhalten, keine erfundene Freigabe | +| `git diff --check` | **Erfolgreich** | + +Der Upgrade-Sentinel wurde in einer eigenen frischen Datenbank angelegt: +`PRISMA_BASE_DIR=/Users/patrickschiller/openclockwork-issue-42 pnpm prisma migrate deploy --config ops/prisma-upgrade-baseline.config.ts`, +danach Seed, Migration und Verifikation auf derselben isolierten Upgrade-URL. +Der erweiterte Upgrade-Smoke wird auch von der regulären PR-CI ausgeführt. + +Abgedeckt sind reale Manager→HR-Entscheidungen und Ablehnungen, Pflichtnotiz, +Tokenidentität statt Body-Identität, Team-/Rollen-/Selbstfreigabegrenzen, +fehlende/inaktive/falsch berechtigte Manager, Neuzuordnung, Altbestand, +offene/terminale/in-frame/Solo-Einträge, doppelte/stale/parallele Entscheidungen, +Änderungsrennen, Änderung nach Endfreigabe, Split-/Projektbereichs-Invalidierung +und auffindbarer Verlauf selbst nach Verschieben eines alten Segments in den +Rahmen. Bestehende Solo-/Terminal-/Tagesblock-/Antragsfälle liefen vollständig mit. +Die Oberfläche wird mit React Testing Library geprüft, einschließlich DE/EN, +Mitarbeiterhinweisen, Rollenaktionen, Fehlern und Verlauf. + +Die zusätzlichen historischen Listeneinträge erforderten ID-basierte statt +positionsabhängige Assertions in zwei neuen Tests. Ein Zwischenlauf unter +Node 26 zeigte außerdem einen einzelnen lokalen Socket-`ETIMEDOUT`; der +abschließende vollständige Node-20-Lauf bestand ohne Wiederholungslogik oder +Produktänderung dafür. + +Der manuelle Docker-/Browser-Durchlauf aus dieser Anleitung wurde **nicht** +als durchgeführt oder bestanden gewertet. Keine Release-, Tag-, Versions-, +Merge- oder Deployment-Aktion. + +Zum Stoppen mit Datenerhalt: + +```bash +TEST_WEB_PORT=18041 docker compose --env-file /dev/null \ + -p openclockwork-issue41 -f docker-compose.test.yml \ + -f /tmp/openclockwork-41-compose.yml stop +``` + +Kein `down -v`, kein erneutes Seeden beim Fortsetzen. #43, #45 und #44 sind +noch nicht Bestandteil dieses Pakets. diff --git a/ops/upgrade-smoke.mjs b/ops/upgrade-smoke.mjs index 87abafb..5a99435 100644 --- a/ops/upgrade-smoke.mjs +++ b/ops/upgrade-smoke.mjs @@ -14,6 +14,7 @@ if (!databaseUrl || !['seed', 'verify'].includes(mode)) { const employeeId = '00000000-0000-4000-8000-000000000014'; const timeEntryId = '00000000-0000-4000-8000-000000000114'; +const pendingEntryId = '00000000-0000-4000-8000-000000000115'; const scheduleId = '00000000-0000-4000-8000-000000000214'; const terminalId = '00000000-0000-4000-8000-000000000314'; const fallbackEmployeeId = '00000000-0000-4000-8000-000000000414'; @@ -109,12 +110,46 @@ try { [JSON.stringify(legacyBreakRules), timeEntryId], ); } + // This row exists before the new approval columns and must remain pending, + // with no fabricated manager sign-off after migration. + await client.query( + `INSERT INTO "TimeEntry" ( + "id", "employeeId", "clockIn", "clockOut", "source", "status", + "requiresApproval", "revision", "approvalMode", "updatedAt" + ) VALUES ($1, $2, TIMESTAMP '2026-01-05 06:00:00', + TIMESTAMP '2026-01-05 08:00:00', 'Pwa', 'Pending', true, 7, NULL, CURRENT_TIMESTAMP)`, + [pendingEntryId, fallbackEmployeeId], + ); await client.query('COMMIT'); console.log( 'Inserted upgrade sentinels for employees, policies, time entry, and terminal.', ); } else { + const pending = await client.query( + `SELECT "status"::text, "requiresApproval", "revision", "approvalMode", + "frameApproverId", "frameApprovedRevision", + "clockIn" = TIMESTAMP '2026-01-05 06:00:00' AS "clockInPreserved", + "clockOut" = TIMESTAMP '2026-01-05 08:00:00' AS "clockOutPreserved" + FROM "TimeEntry" WHERE "id" = $1`, + [pendingEntryId], + ); + assert.deepEqual( + pending.rows, + [ + { + status: 'Pending', + requiresApproval: true, + revision: 7, + approvalMode: null, + frameApproverId: null, + frameApprovedRevision: null, + clockInPreserved: true, + clockOutPreserved: true, + }, + ], + 'Legacy pending entries must start at manager review without altering working time.', + ); const sentinel = await client.query( `SELECT e."personalNo", diff --git a/prisma/migrations/20261107031000_time_entry_frame_approval/migration.sql b/prisma/migrations/20261107031000_time_entry_frame_approval/migration.sql new file mode 100644 index 0000000..51a1fe9 --- /dev/null +++ b/prisma/migrations/20261107031000_time_entry_frame_approval/migration.sql @@ -0,0 +1,10 @@ +-- Existing pending Team entries intentionally keep NULL markers: they start +-- at manager review, never at HR or Approved. Historical Solo/in-frame entries +-- and their status, revision, timestamps and audit records remain unchanged. +ALTER TABLE "TimeEntry" + ADD COLUMN "frameApprovedRevision" INTEGER, + ADD COLUMN "frameApproverId" UUID; + +ALTER TABLE "TimeEntry" ADD CONSTRAINT "TimeEntry_frameApproverId_fkey" + FOREIGN KEY ("frameApproverId") REFERENCES "Employee"("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dbf4114..f797c96 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -152,6 +152,7 @@ model Employee { soloPolicies SoloPolicy[] personalDays PersonalDay[] timeEntryAudits TimeEntryAudit[] + frameApprovedEntries TimeEntry[] @relation("FrameApprover") installationEvents InstallationEvent[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -204,6 +205,10 @@ model TimeEntry { source EntrySource @default(Manual) status EntryStatus @default(Open) requiresApproval Boolean @default(false) + /// Manager sign-off is valid only for the exact revision and current manager. + frameApprovedRevision Int? + frameApproverId String? @db.Uuid + frameApprover Employee? @relation("FrameApprover", fields: [frameApproverId], references: [id], onDelete: SetNull) billable Boolean @default(false) revision Int @default(0) voidedAt DateTime? From 34a9b5094568fffd990fa0827e6e9f8c28c13209 Mon Sep 17 00:00:00 2001 From: Patrick Schiller <39913537+patrickschiller@users.noreply.github.com> Date: Sat, 7 Nov 2026 04:04:09 +0100 Subject: [PATCH 5/7] fix: correct existing time entries through versioned requests (#43) Bind correction requests to an owned eligible entry ID and original revision with immutable server snapshots. Revalidate targets, overlaps and required manager/HR stages, then update the same entry and linked audit atomically at final approval. Retain draft precision, metadata and break policies; add target selection/diffs, upgrade safety and focused rollback/concurrency regressions. Cumulative on accepted #42 and #41; no release or merge. Co-authored-by: patrickschiller <39913537+patrickschiller@users.noreply.github.com> Signed-off-by: Patrick Schiller <39913537+patrickschiller@users.noreply.github.com> --- .../api/targeted-time-corrections.e2e.spec.ts | 1224 +++++++++++++++++ apps/api/openapi.json | 145 ++ .../src/app/requests/requests.controller.ts | 27 + apps/api/src/app/requests/requests.dto.ts | 42 +- apps/api/src/app/requests/requests.module.ts | 3 +- apps/api/src/app/requests/requests.service.ts | 315 +++-- .../app/requests/time-adjustments.service.ts | 299 ++++ .../src/app/time-entries/time-entries.dto.ts | 4 + .../time-entries/time-entry-approvals.dto.ts | 2 + .../time-entry-approvals.service.ts | 1 + apps/web/src/api/client.ts | 44 + apps/web/src/api/generated.ts | 96 ++ apps/web/src/app/i18n.tsx | 61 + apps/web/src/app/realtime.ts | 2 + .../timeentries/TimeEntryApprovals.spec.tsx | 36 + .../timeentries/TimeEntryApprovals.tsx | 3 + .../web/src/routes/AdminRequestsPage.spec.tsx | 66 +- apps/web/src/routes/AdminRequestsPage.tsx | 9 + apps/web/src/routes/RequestsPage.spec.tsx | 349 +++++ apps/web/src/routes/RequestsPage.tsx | 415 +++++- docs/ISSUE_FIXES_MANUAL_TEST.de.md | 259 ++++ ops/upgrade-smoke.mjs | 56 + .../migration.sql | 18 + prisma/schema.prisma | 9 + 24 files changed, 3402 insertions(+), 83 deletions(-) create mode 100644 apps/api-e2e/src/api/targeted-time-corrections.e2e.spec.ts create mode 100644 apps/api/src/app/requests/time-adjustments.service.ts create mode 100644 prisma/migrations/20261107040000_targeted_time_corrections/migration.sql diff --git a/apps/api-e2e/src/api/targeted-time-corrections.e2e.spec.ts b/apps/api-e2e/src/api/targeted-time-corrections.e2e.spec.ts new file mode 100644 index 0000000..321d15e --- /dev/null +++ b/apps/api-e2e/src/api/targeted-time-corrections.e2e.spec.ts @@ -0,0 +1,1224 @@ +import { randomUUID } from 'node:crypto'; +import type { Prisma, TimeEntry } from '@prisma/client'; +import { EmployeesService } from '../../../api/src/app/employees/employees.service'; +import type { RequestDto } from '../../../api/src/app/requests/requests.dto'; +import { TimeAdjustmentsService } from '../../../api/src/app/requests/time-adjustments.service'; +import { + createTestApp, + login, + seedEmployee, + seedProject, + type TestContext, +} from '../support/test-app'; + +const IN = '2026-05-04T06:00:00.000Z'; // 08:00 Europe/Berlin +const OUT = '2026-05-04T14:00:00.000Z'; // 16:00 Europe/Berlin +const EXTENDED = '2026-05-04T15:00:00.000Z'; +const LATER = '2026-05-04T07:00:00.000Z'; +const EARLY = '2026-05-04T04:00:00.000Z'; // outside the explicit 07–18 frame +const RULES = [ + { afterMinutes: 360, breakMinutes: 30 }, + { afterMinutes: 540, breakMinutes: 45 }, +]; +type Person = { id: string; token: string }; +type Body = Record; +type EntryExtra = Partial; + +describe('Targeted TimeAdjustment corrections (#43)', () => { + let ctx: TestContext; + let owner: Person, manager: Person, other: Person, hr: Person; + let scheduleId: string; + + beforeAll(async () => { + ctx = await createTestApp(); + }); + afterAll(async () => { + await ctx.close(); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + beforeEach(async () => { + await ctx.reset(); + const schedule = await ctx.prisma.workSchedule.create({ + data: { + name: 'Correction frame', + frameStart: '07:00', + frameEnd: '18:00', + breakRules: RULES, + }, + }); + scheduleId = schedule.id; + async function person( + name: string, + role: 'Employee' | 'Manager' | 'HRAdmin', + managerId?: string, + ): Promise { + const row = await seedEmployee(ctx.prisma, { + personalNo: name, + firstName: name, + lastName: 'Example', + email: `${name}@test.local`, + role, + managerId, + workScheduleId: scheduleId, + }); + return { id: row.id, token: await login(ctx.http, row.email) }; + } + manager = await person('correction-manager', 'Manager'); + owner = await person('correction-owner', 'Employee', manager.id); + other = await person('correction-other', 'Employee', manager.id); + hr = await person('correction-hr', 'HRAdmin'); + await ctx.prisma.installationSettings.create({ + data: { id: 1, mode: 'Team', setupCompleted: true }, + }); + }); + + function entry(extra: EntryExtra = {}): Promise { + return ctx.prisma.timeEntry.create({ + data: { + employeeId: owner.id, + clockIn: new Date(IN), + clockOut: new Date(OUT), + source: 'Pwa', + status: 'Approved', + approvalMode: 'Team', + breakRules: RULES, + ...extra, + }, + }); + } + function postRequest(extra: Body = {}, who = owner) { + return ctx.http + .post('/api/requests') + .set('Authorization', `Bearer ${who.token}`) + .send({ + employeeId: who.id, + type: 'TimeAdjustment', + from: IN, + to: EXTENDED, + reason: 'Correct recorded working time', + ...extra, + }); + } + async function correction(row: TimeEntry, extra: Body = {}) { + const response = await postRequest({ + targetTimeEntryId: row.id, + expectedTargetRevision: row.revision, + ...extra, + }).expect(201); + return response.body as RequestDto; + } + function decide( + id: string, + route = 'manager-approve', + who = manager, + extra: Body = {}, + ) { + return ctx.http + .post(`/api/requests/${id}/${route}`) + .set('Authorization', `Bearer ${who.token}`) + .send({ actorId: who.id, note: 'Checked correction', ...extra }); + } + function frameDecision(id: string, revision = 0, stage = 'Manager') { + const who = stage === 'HR' ? hr : manager; + return ctx.http + .post(`/api/timeentries/${id}/approval`) + .set('Authorization', `Bearer ${who.token}`) + .send({ revision, stage, outcome: 'Approve' }); + } + function targets(who = owner) { + return ctx.http + .get('/api/requests/time-adjustment-targets') + .set('Authorization', `Bearer ${who.token}`); + } + function linkedAudit(id: string, who = owner) { + return ctx.http + .get(`/api/requests/${id}/time-entry-audit`) + .set('Authorization', `Bearer ${who.token}`); + } + function listEntries() { + return ctx.http + .get(`/api/timeentries?employeeId=${owner.id}`) + .set('Authorization', `Bearer ${owner.token}`); + } + function account() { + return ctx.http + .get(`/api/accounts/${owner.id}`) + .set('Authorization', `Bearer ${owner.token}`); + } + function currentEntry(id: string) { + return ctx.prisma.timeEntry.findUniqueOrThrow({ where: { id } }); + } + function currentRequest(id: string) { + return ctx.prisma.request.findUniqueOrThrow({ where: { id } }); + } + function events(id: string) { + return ctx.prisma.requestEvent.findMany({ + where: { requestId: id }, + orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }], + }); + } + function audits(id: string) { + return ctx.prisma.timeEntryAudit.findMany({ + where: { timeEntryId: id }, + orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }], + }); + } + async function returned(row: TimeEntry, extra: Body = {}) { + const request = await correction(row, extra); + const response = await decide(request.id, 'return', manager, { + note: 'Please explain the change', + }).expect(201); + expect(response.body.workflowState).toBe('Draft'); + return response.body as RequestDto; + } + function draftValues(request: RequestDto, extra: Body = {}) { + return { + from: request.from, + to: request.to, + reason: 'Reason clarified without rebinding', + expectedUpdatedAt: request.updatedAt, + ...extra, + }; + } + function save(request: RequestDto, extra: Body = {}) { + return ctx.http + .patch(`/api/requests/${request.id}`) + .set('Authorization', `Bearer ${owner.token}`) + .send(draftValues(request, extra)); + } + function resubmit(request: RequestDto, extra: Body = {}) { + return ctx.http + .post(`/api/requests/${request.id}/resubmit`) + .set('Authorization', `Bearer ${owner.token}`) + .send(draftValues(request, extra)); + } + async function unchangedDecisionState(id: string, timeEntryId: string) { + return { + request: await currentRequest(id), + entry: await currentEntry(timeEntryId), + events: await events(id), + audits: await audits(timeEntryId), + }; + } + + it.each([ + { + label: '08–16 → 08–17', + from: IN, + to: EXTENDED, + gross: 540, + breaks: 45, + net: 495, + route: 'approve', + }, + { + label: '08–16 → 09–16', + from: LATER, + to: OUT, + gross: 420, + breaks: 30, + net: 390, + route: 'manager-approve', + }, + ])( + '$label updates the same ID once, preserves metadata/policy/history and recalculates views', + async ({ from, to, gross, breaks, net, route }) => { + const project = await seedProject(ctx.prisma, { + code: 'CORRECT', + assigneeIds: [owner.id], + serviceOrders: [{ orderNo: 'C-1', title: 'Existing order' }], + }); + const group = randomUUID(); + const row = await entry({ + projectId: project.id, + serviceOrderId: project.serviceOrders[0].id, + status: route === 'manager-approve' ? 'Pending' : 'Approved', + approvalMode: route === 'manager-approve' ? null : 'Team', + activity: 'Original activity', + note: 'Original private note', + billable: true, + captureGroupId: group, + }); + const originalAudit = await ctx.prisma.timeEntryAudit.create({ + data: { + timeEntryId: row.id, + actorId: owner.id, + action: 'BookingCreated', + after: { clockIn: IN, clockOut: OUT }, + }, + }); + const beforeAccount = (await account().expect(200)).body; + const request = await correction(row, { from, to }); + expect(request).toMatchObject({ + targetTimeEntryId: row.id, + expectedTargetRevision: 0, + requiresApproval: false, + workflowState: 'PendingManager', + targetTimeEntryBefore: { + id: row.id, + revision: 0, + clockIn: IN, + clockOut: OUT, + summary: { grossMinutes: 480, breakMinutes: 30, netMinutes: 450 }, + }, + }); + expect(await currentEntry(row.id)).toEqual(row); + expect((await linkedAudit(request.id).expect(200)).body).toEqual([]); + // Current schedule changes must not replace the booked break-policy snapshot. + await ctx.prisma.workSchedule.update({ + where: { id: scheduleId }, + data: { breakRules: [] }, + }); + const started = Date.now(); + const final = await decide(request.id, route).expect(201); + expect(final.body).toMatchObject({ + id: request.id, + status: 'Approved', + workflowState: 'Approved', + hrConfirmedAt: null, + approverId: manager.id, + }); + expect(final.body.targetTimeEntryBefore).toEqual( + request.targetTimeEntryBefore, + ); + expect(await ctx.prisma.timeEntry.count()).toBe(1); + expect(await currentEntry(row.id)).toMatchObject({ + id: row.id, + revision: 1, + clockIn: new Date(from), + clockOut: new Date(to), + projectId: row.projectId, + serviceOrderId: row.serviceOrderId, + activity: row.activity, + note: row.note, + billable: true, + captureGroupId: group, + breakRules: RULES, + source: 'Pwa', + approvalMode: row.approvalMode, + status: 'Approved', + }); + const listed = (await listEntries().expect(200)).body; + expect(listed).toHaveLength(1); + expect(listed[0]).toMatchObject({ + id: row.id, + summary: { grossMinutes: gross, breakMinutes: breaks, netMinutes: net }, + }); + const report = await ctx.http + .get( + `/api/reports/working-times?from=2026-05-04&to=2026-05-04&employeeId=${owner.id}`, + ) + .set('Authorization', `Bearer ${hr.token}`) + .expect(200); + expect(report.body.rows).toHaveLength(1); + expect(report.body.rows[0].id).toBe(row.id); + expect(report.body.totals).toEqual({ + grossMinutes: gross, + breakMinutes: breaks, + netMinutes: net, + }); + const afterAccount = (await account().expect(200)).body; + expect(afterAccount.overtimeMinutes - beforeAccount.overtimeMinutes).toBe( + net - 450, + ); + const history = await audits(row.id); + expect(history).toHaveLength(2); + expect(history.find((event) => event.id === originalAudit.id)).toEqual( + originalAudit, + ); + const linked = (await linkedAudit(request.id).expect(200)).body; + expect(linked).toHaveLength(1); + expect(linked[0]).toMatchObject({ + requestId: request.id, + timeEntryId: row.id, + actorId: manager.id, + action: 'FrameRequestCorrected', + reason: request.reason, + before: request.targetTimeEntryBefore, + after: { + id: row.id, + revision: 1, + clockIn: from, + clockOut: to, + summary: { + grossMinutes: gross, + breakMinutes: breaks, + netMinutes: net, + }, + }, + }); + expect(Date.parse(linked[0].occurredAt)).toBeGreaterThanOrEqual(started); + expect(Date.parse(linked[0].occurredAt)).toBeLessThanOrEqual(Date.now()); + expect((await events(request.id)).map((event) => event.kind)).toEqual([ + 'Submitted', + 'ManagerApproved', + ]); + }, + ); + + it('captures the server original, ignores forged before/owner, and authorizes linked audit by JWT', async () => { + const row = await entry(); + const request = await correction(row, { + employeeId: other.id, + targetTimeEntryBefore: { id: other.id, revision: 999, clockIn: EARLY }, + }); + expect(request).toMatchObject({ + employeeId: owner.id, + targetTimeEntryBefore: { + id: row.id, + revision: 0, + clockIn: IN, + clockOut: OUT, + }, + }); + expect((await currentRequest(request.id)).targetTimeEntryBefore).toEqual( + request.targetTimeEntryBefore, + ); + await decide(request.id, 'manager-approve', manager, { + actorId: hr.id, + }).expect(201); + const linked = (await linkedAudit(request.id, manager).expect(200)).body; + expect(linked[0].actorId).toBe(manager.id); + expect((await linkedAudit(request.id, hr).expect(200)).body).toEqual( + linked, + ); + await linkedAudit(request.id, other).expect(403); + await ctx.http + .get(`/api/requests/${request.id}/time-entry-audit`) + .expect(401); + }); + + it('retains the omitted-pair missing-booking flow and links its newly added entry audit', async () => { + const response = await postRequest({ to: OUT }).expect(201); + const request = response.body as RequestDto; + expect(request).toMatchObject({ + targetTimeEntryId: null, + expectedTargetRevision: null, + targetTimeEntryBefore: null, + }); + expect(await ctx.prisma.timeEntry.count()).toBe(0); + await decide(request.id, 'approve').expect(201); + expect(await ctx.prisma.timeEntry.count()).toBe(1); + const rows = await ctx.prisma.timeEntry.findMany(); + expect(rows[0]).toMatchObject({ + employeeId: owner.id, + clockIn: new Date(IN), + clockOut: new Date(OUT), + source: 'Manual', + status: 'Approved', + approvalMode: 'Team', + breakRules: RULES, + }); + expect((await linkedAudit(request.id).expect(200)).body).toEqual([ + expect.objectContaining({ + requestId: request.id, + timeEntryId: rows[0].id, + actorId: manager.id, + action: 'RequestMissingBookingAdded', + before: null, + after: expect.objectContaining({ clockIn: IN, clockOut: OUT }), + }), + ]); + }); + + it('lists only own closed nonvoided Pending/Approved Team or legacy-null targets', async () => { + const eligible = [ + await entry(), + await entry({ + status: 'Pending', + requiresApproval: true, + clockIn: new Date('2026-05-05T06:00:00Z'), + clockOut: new Date('2026-05-05T14:00:00Z'), + }), + await entry({ + approvalMode: null, + clockIn: new Date('2026-05-06T06:00:00Z'), + clockOut: new Date('2026-05-06T14:00:00Z'), + }), + ]; + await entry({ employeeId: other.id }); + await entry({ clockOut: null, status: 'Open' }); + await entry({ voidedAt: new Date() }); + await entry({ status: 'Rejected' }); + await entry({ + approvalMode: 'Solo', + clockIn: new Date('2026-05-07T06:00:00Z'), + clockOut: new Date('2026-05-07T14:00:00Z'), + }); + const expected = eligible.map((row) => row.id).sort(); + for (const legacyInstallation of [false, true]) { + if (legacyInstallation) + await ctx.prisma.installationSettings.deleteMany(); + const response = await targets().expect(200); + expect(response.body.map((row: { id: string }) => row.id).sort()).toEqual( + expected, + ); + expect(response.body[0].summary).toEqual({ + grossMinutes: 480, + breakMinutes: 30, + netMinutes: 450, + }); + } + }); + + it('uses a static GET, takes listing identity only from JWT, and validates the date filter', async () => { + const own = await entry(); + await entry({ employeeId: other.id }); + const next = await entry({ + clockIn: new Date('2026-05-05T06:00:00Z'), + clockOut: new Date('2026-05-05T14:00:00Z'), + }); + await ctx.http.get('/api/requests/time-adjustment-targets').expect(401); + const listed = await targets() + .query({ employeeId: other.id, from: IN, to: OUT }) + .send({ employeeId: other.id }) + .expect(200); + expect(listed.body.map((row: { id: string }) => row.id)).toEqual([own.id]); + expect( + (await targets().query({ from: OUT }).expect(200)).body.map( + (row: { id: string }) => row.id, + ), + ).toEqual([next.id]); + await targets().query({ from: 'not-a-date' }).expect(400); + await targets().query({ from: OUT, to: IN }).expect(400); + await ctx.http + .post('/api/requests/time-adjustment-targets') + .set('Authorization', `Bearer ${owner.token}`) + .send({}) + .expect(404); + }); + + it('keeps Solo direct owner correction separate and never exposes Team requests/targets in Solo', async () => { + const row = await entry({ approvalMode: 'Solo' }); + const archived = await entry({ + clockIn: new Date('2026-05-05T06:00:00Z'), + clockOut: new Date('2026-05-05T14:00:00Z'), + }); + const pending = await correction(archived, { + from: '2026-05-05T06:00:00.000Z', + to: '2026-05-05T15:00:00.000Z', + }); + await ctx.prisma.employee.update({ + where: { id: owner.id }, + data: { role: 'HRAdmin' }, + }); + await ctx.prisma.installationSettings.update({ + where: { id: 1 }, + data: { mode: 'Solo', ownerEmployeeId: owner.id }, + }); + await targets().expect(403); + await targets(hr).expect(403); + await postRequest().expect(403); + await decide(pending.id).expect(403); + await ctx.http + .patch(`/api/timeentries/${row.id}/correct`) + .set('Authorization', `Bearer ${owner.token}`) + .send({ + clockIn: IN, + clockOut: EXTENDED, + revision: 0, + reason: 'Solo owner fixes a forgotten hour', + }) + .expect(200); + expect(await currentEntry(row.id)).toMatchObject({ + id: row.id, + revision: 1, + clockOut: new Date(EXTENDED), + status: 'Approved', + approvalMode: 'Solo', + requiresApproval: false, + }); + expect((await audits(row.id)).map((event) => event.action)).toEqual([ + 'Corrected', + ]); + expect(await currentEntry(archived.id)).toEqual(archived); + expect((await currentRequest(pending.id)).workflowState).toBe( + 'PendingManager', + ); + }); + + it('rejects foreign and nonexistent targets even if employeeId is forged', async () => { + const foreign = await entry({ employeeId: other.id }); + for (const targetTimeEntryId of [foreign.id, randomUUID()]) { + await postRequest({ + targetTimeEntryId, + expectedTargetRevision: 0, + employeeId: other.id, + }).expect(403); + } + expect(await currentEntry(foreign.id)).toEqual(foreign); + expect(await ctx.prisma.request.count()).toBe(0); + expect(await ctx.prisma.timeEntryAudit.count()).toBe(0); + }); + + it('rejects open, voided, rejected and Solo target bindings without a request/event/audit', async () => { + const rows = [ + await entry({ status: 'Open', clockOut: null }), + await entry({ voidedAt: new Date() }), + await entry({ status: 'Rejected' }), + await entry({ approvalMode: 'Solo' }), + ]; + for (const row of rows) { + await postRequest({ + targetTimeEntryId: row.id, + expectedTargetRevision: 0, + }).expect(409); + expect(await currentEntry(row.id)).toEqual(row); + } + expect(await ctx.prisma.request.count()).toBe(0); + expect(await ctx.prisma.requestEvent.count()).toBe(0); + expect(await ctx.prisma.timeEntryAudit.count()).toBe(0); + }); + + it('requires a complete target/version pair, validates versions, and rejects stale originals', async () => { + const row = await entry(); + for (const binding of [ + { targetTimeEntryId: row.id }, + { expectedTargetRevision: 0 }, + { targetTimeEntryId: row.id, expectedTargetRevision: null }, + { targetTimeEntryId: null, expectedTargetRevision: 0 }, + { targetTimeEntryId: row.id, expectedTargetRevision: -1 }, + { targetTimeEntryId: row.id, expectedTargetRevision: 0.5 }, + { targetTimeEntryId: row.id, expectedTargetRevision: 'invalid' }, + { targetTimeEntryId: 'not-a-uuid', expectedTargetRevision: 0 }, + ]) + await postRequest(binding).expect(400); + await postRequest({ + targetTimeEntryId: row.id, + expectedTargetRevision: 1, + }).expect(409); + expect(await currentEntry(row.id)).toEqual(row); + expect(await ctx.prisma.request.count()).toBe(0); + }); + + it('forbids correction fields on non-TimeAdjustment requests, including the vacation endpoint', async () => { + const row = await entry(); + for (const type of ['HomeOffice', 'SpecialLeave', 'Vacation']) { + await postRequest({ + type, + targetTimeEntryId: row.id, + expectedTargetRevision: 0, + }).expect(400); + } + await ctx.http + .post('/api/requests/vacation') + .set('Authorization', `Bearer ${owner.token}`) + .send({ + employeeId: owner.id, + from: IN, + to: OUT, + targetTimeEntryId: row.id, + expectedTargetRevision: 0, + }) + .expect(400); + expect(await ctx.prisma.request.count()).toBe(0); + }); + + it('exempts precisely the unchanged target ID, never another same-day booking', async () => { + const row = await entry(); + const adjacent = await entry({ + clockIn: new Date(OUT), + clockOut: new Date(EXTENDED), + }); + await postRequest({ + targetTimeEntryId: row.id, + expectedTargetRevision: 0, + }).expect(409); + // Keeping the original interval is legal; the adjacent booking is not overlapping. + const request = await correction(row, { to: OUT }); + await decide(request.id).expect(201); + expect(await currentEntry(adjacent.id)).toEqual(adjacent); + expect(await ctx.prisma.timeEntry.count()).toBe(2); + expect(await currentEntry(row.id)).toMatchObject({ + revision: 1, + clockOut: new Date(OUT), + }); + }); + + it('does not exempt overlaps for missing-booking requests with omitted or explicitly null binding', async () => { + const row = await entry(); + for (const binding of [ + {}, + { targetTimeEntryId: null, expectedTargetRevision: null }, + ]) { + await postRequest(binding).expect(409); + } + expect(await currentEntry(row.id)).toEqual(row); + expect(await ctx.prisma.request.count()).toBe(0); + }); + + it.each(['approve', 'manager-approve'])( + '%s forces outside-frame proposed values through manager → HR and materializes only at the final stage', + async (route) => { + const row = await entry(); + const from = route === 'approve' ? IN : EARLY; + const request = await correction(row, { from }); + if (route === 'approve') { + expect(request.requiresApproval).toBe(false); + // Re-evaluate proposed values against the current frame, not only the + // stored request flag or the target's old approval state. + await ctx.prisma.workSchedule.update({ + where: { id: scheduleId }, + data: { frameEnd: '16:00' }, + }); + } else expect(request.requiresApproval).toBe(true); + await decide(request.id, 'hr-confirm', hr).expect(409); + const managed = await decide(request.id, route, manager, { + requiresHrConfirmation: false, + }).expect(201); + expect(managed.body).toMatchObject({ + status: 'Submitted', + workflowState: 'PendingHr', + requiresApproval: true, + approverId: manager.id, + hrConfirmedAt: null, + }); + expect(await currentEntry(row.id)).toEqual(row); + expect(await audits(row.id)).toEqual([]); + await decide(request.id, 'hr-confirm', owner).expect(403); + await decide(request.id, 'hr-confirm', manager).expect(403); + const final = await decide(request.id, 'hr-confirm', hr).expect(201); + expect(final.body).toMatchObject({ + status: 'Approved', + workflowState: 'Approved', + }); + expect(final.body.hrConfirmedAt).not.toBeNull(); + expect(await currentEntry(row.id)).toMatchObject({ + id: row.id, + revision: 1, + clockIn: new Date(from), + clockOut: new Date(EXTENDED), + status: 'Approved', + requiresApproval: true, + frameApproverId: null, + frameApprovedRevision: null, + }); + expect(await ctx.prisma.timeEntry.count()).toBe(1); + expect((await events(request.id)).map((event) => event.kind)).toEqual([ + 'Submitted', + 'ManagerApproved', + 'HrConfirmed', + ]); + expect((await linkedAudit(request.id).expect(200)).body).toEqual([ + expect.objectContaining({ + requestId: request.id, + actorId: hr.id, + before: expect.objectContaining({ clockIn: IN, clockOut: OUT }), + after: expect.objectContaining({ clockIn: from, clockOut: EXTENDED }), + }), + ]); + }, + ); + + it('never reuses #41 approval of old values to authorize a changed outside-frame interval', async () => { + const row = await entry({ + clockIn: new Date(EARLY), + status: 'Pending', + requiresApproval: true, + }); + await frameDecision(row.id).expect(201); + await frameDecision(row.id, 1, 'HR').expect(201); + const approved = await currentEntry(row.id); + const oldAudits = await audits(row.id); + expect(approved).toMatchObject({ revision: 2, status: 'Approved' }); + const changedFrom = '2026-05-04T03:00:00.000Z'; + const request = await correction(approved, { from: changedFrom }); + expect(request.requiresApproval).toBe(true); + await decide(request.id, 'hr-confirm', hr).expect(409); + await decide(request.id).expect(201); + expect((await currentRequest(request.id)).workflowState).toBe('PendingHr'); + expect(await currentEntry(row.id)).toEqual(approved); + expect(await audits(row.id)).toEqual(oldAudits); + await decide(request.id, 'hr-confirm', hr).expect(201); + expect(await currentEntry(row.id)).toMatchObject({ + revision: 3, + clockIn: new Date(changedFrom), + frameApproverId: null, + frameApprovedRevision: null, + }); + for (const audit of oldAudits) { + expect( + (await audits(row.id)).find((event) => event.id === audit.id), + ).toEqual(audit); + } + await frameDecision(row.id, 2, 'HR').expect(409); + const history = await ctx.http + .get(`/api/timeentries/${row.id}/approval-history`) + .set('Authorization', `Bearer ${owner.token}`) + .expect(200); + expect(history.body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + requestId: request.id, + action: 'FrameRequestCorrected', + }), + ]), + ); + }); + + it('return → reason-only save/resubmit retains binding, version, exact before snapshot and ID', async () => { + const row = await entry(); + const request = await returned(row); + const saved = await save(request, { + targetTimeEntryBefore: { clockIn: EARLY, revision: 99 }, + }).expect(200); + expect(saved.body).toMatchObject({ + id: request.id, + workflowState: 'Draft', + targetTimeEntryId: row.id, + expectedTargetRevision: 0, + targetTimeEntryBefore: request.targetTimeEntryBefore, + }); + const submitted = await resubmit(saved.body).expect(201); + expect(submitted.body).toMatchObject({ + id: request.id, + workflowState: 'PendingManager', + targetTimeEntryId: row.id, + expectedTargetRevision: 0, + targetTimeEntryBefore: request.targetTimeEntryBefore, + }); + expect(await currentEntry(row.id)).toEqual(row); + await decide(request.id).expect(201); + expect(await ctx.prisma.request.count()).toBe(1); + expect((await events(request.id)).map((event) => event.kind)).toEqual([ + 'Submitted', + 'Returned', + 'Resubmitted', + 'ManagerApproved', + ]); + }); + + it('denies rebinding, removing the target, or changing the expected revision on both draft paths', async () => { + const row = await entry(); + const alternative = await entry({ + clockIn: new Date('2026-05-05T06:00:00Z'), + clockOut: new Date('2026-05-05T14:00:00Z'), + }); + const request = await returned(row); + for (const extra of [ + { targetTimeEntryId: alternative.id, expectedTargetRevision: 0 }, + { targetTimeEntryId: null, expectedTargetRevision: null }, + { expectedTargetRevision: 1 }, + ]) { + await save(request, extra).expect(400); + await resubmit(request, extra).expect(400); + } + expect(await currentRequest(request.id)).toMatchObject({ + workflowState: 'Draft', + targetTimeEntryId: row.id, + expectedTargetRevision: 0, + targetTimeEntryBefore: request.targetTimeEntryBefore, + }); + expect((await events(request.id)).map((event) => event.kind)).toEqual([ + 'Submitted', + 'Returned', + ]); + }); + + it('rejects a stale target after return with 409 instead of silently refreshing the original snapshot', async () => { + const row = await entry(); + const request = await returned(row); + await ctx.http + .patch(`/api/timeentries/${row.id}`) + .set('Authorization', `Bearer ${owner.token}`) + .send({ revision: 0, note: 'Changed after return' }) + .expect(200); + const before = await unchangedDecisionState(request.id, row.id); + await save(request).expect(409); + await resubmit(request).expect(409); + expect(await unchangedDecisionState(request.id, row.id)).toEqual(before); + expect(before.request.targetTimeEntryBefore).toEqual( + request.targetTimeEntryBefore, + ); + }); + + it('round-trips original/proposed DST spring-gap and repeated-fold instants with seconds through #42 revision and final correction', async () => { + const cases = [ + { + originalIn: '2026-03-29T01:59:17.123+01:00', + originalOut: '2026-03-29T03:00:01.456+02:00', + from: '2026-03-29T01:59:37.123+01:00', + to: '2026-03-29T03:00:41.456+02:00', + }, + { + originalIn: '2026-10-25T02:15:17.123+02:00', + originalOut: '2026-10-25T02:45:41.456+01:00', + from: '2026-10-25T02:20:31.987+02:00', + to: '2026-10-25T02:55:51.654+01:00', + }, + ]; + for (const values of cases) { + const row = await entry({ + clockIn: new Date(values.originalIn), + clockOut: new Date(values.originalOut), + }); + const original = { + clockIn: new Date(values.originalIn).toISOString(), + clockOut: new Date(values.originalOut).toISOString(), + }; + const proposed = { + from: new Date(values.from).toISOString(), + to: new Date(values.to).toISOString(), + }; + const request = await returned(row, { from: values.from, to: values.to }); + expect(request.targetTimeEntryBefore).toMatchObject(original); + const saved = await save(request).expect(200); + expect(saved.body).toMatchObject(proposed); + const submitted = await resubmit(saved.body).expect(201); + expect(submitted.body).toMatchObject({ + ...proposed, + targetTimeEntryBefore: request.targetTimeEntryBefore, + }); + await decide(request.id).expect(201); + expect(await currentEntry(row.id)).toEqual(row); + await decide(request.id, 'hr-confirm', hr).expect(201); + const persisted = await currentEntry(row.id); + expect(persisted.clockIn.toISOString()).toBe(proposed.from); + expect(persisted.clockOut?.toISOString()).toBe(proposed.to); + const linked = (await linkedAudit(request.id).expect(200)).body[0]; + expect(linked.before).toMatchObject(original); + expect(linked.after).toMatchObject({ + clockIn: proposed.from, + clockOut: proposed.to, + }); + // Existing ungrouped Team summaries bill/report whole minutes. + // Persisted timestamps above must still retain every millisecond. + expect(linked.after.summary.grossMinutes).toBe( + Math.floor( + (Date.parse(proposed.to) - Date.parse(proposed.from)) / 60_000, + ), + ); + } + }); + + it.each(['Manager', 'HR'])( + 'a target changed before final %s approval leaves pending request, entry, events and audits untouched', + async (stage) => { + const row = await entry(); + const request = await correction( + row, + stage === 'HR' ? { from: EARLY } : {}, + ); + if (stage === 'HR') await decide(request.id).expect(201); + await ctx.http + .patch(`/api/timeentries/${row.id}`) + .set('Authorization', `Bearer ${owner.token}`) + .send({ revision: 0, activity: 'New target revision' }) + .expect(200); + const before = await unchangedDecisionState(request.id, row.id); + await decide( + request.id, + stage === 'HR' ? 'hr-confirm' : 'manager-approve', + stage === 'HR' ? hr : manager, + ).expect(409); + expect(await unchangedDecisionState(request.id, row.id)).toEqual(before); + expect(before.request.workflowState).toBe( + stage === 'HR' ? 'PendingHr' : 'PendingManager', + ); + }, + ); + + it.each(['metadata', 'split', 'frame approval'])( + '%s increments the target revision and invalidates an outstanding correction', + async (operation) => { + const row = await entry({ + clockIn: new Date(EARLY), + status: 'Pending', + requiresApproval: true, + }); + const request = await correction(row, { from: EARLY }); + if (operation === 'metadata') { + await ctx.http + .patch(`/api/timeentries/${row.id}`) + .set('Authorization', `Bearer ${owner.token}`) + .send({ revision: 0, activity: 'Revised metadata' }) + .expect(200); + } else if (operation === 'split') { + await ctx.http + .post(`/api/timeentries/${row.id}/split`) + .set('Authorization', `Bearer ${owner.token}`) + .send({ revision: 0, at: '2026-05-04T10:00:00Z' }) + .expect(201); + } else await frameDecision(row.id).expect(201); + expect((await currentEntry(row.id)).revision).toBe(1); + const before = await unchangedDecisionState(request.id, row.id); + await decide(request.id).expect(409); + expect(await unchangedDecisionState(request.id, row.id)).toEqual(before); + }, + ); + + it('serializes two disjoint correction requests for revision 0: exactly one final approval wins', async () => { + const row = await entry(); + // Disjoint proposals deliberately avoid the independent request-overlap guard. + const a = await correction(row, { + from: '2026-05-05T06:00:00.000Z', + to: '2026-05-05T14:00:00.000Z', + }); + const b = await correction(row, { + from: '2026-05-06T06:00:00.000Z', + to: '2026-05-06T14:00:00.000Z', + }); + expect(a.expectedTargetRevision).toBe(0); + expect(b.expectedTargetRevision).toBe(0); + const replies = await Promise.all([decide(a.id), decide(b.id)]); + expect(replies.map((reply) => reply.status).sort()).toEqual([201, 409]); + const winner = replies[0].status === 201 ? a : b; + const loser = winner.id === a.id ? b : a; + expect(await currentEntry(row.id)).toMatchObject({ + id: row.id, + revision: 1, + clockIn: new Date(winner.from), + clockOut: new Date(winner.to), + }); + expect((await currentRequest(winner.id)).workflowState).toBe('Approved'); + expect((await currentRequest(loser.id)).workflowState).toBe( + 'PendingManager', + ); + expect((await events(loser.id)).map((event) => event.kind)).toEqual([ + 'Submitted', + ]); + expect(await ctx.prisma.timeEntry.count()).toBe(1); + expect(await audits(row.id)).toEqual([ + expect.objectContaining({ requestId: winner.id }), + ]); + }); + + it('rejects repeated final approvals without duplicate entry changes, events or linked audits', async () => { + const row = await entry(); + const request = await correction(row); + await decide(request.id, 'approve').expect(201); + const before = await unchangedDecisionState(request.id, row.id); + // Legacy assignment guards may return 403 before the state guard returns 409. + for (const route of ['approve', 'manager-approve']) { + const response = await decide(request.id, route); + expect([403, 409]).toContain(response.status); + } + await decide(request.id, 'cancel', owner).expect(409); + expect(await unchangedDecisionState(request.id, row.id)).toEqual(before); + expect( + before.events.filter((event) => event.kind === 'ManagerApproved'), + ).toHaveLength(1); + expect(before.audits).toHaveLength(1); + expect(await ctx.prisma.timeEntry.count()).toBe(1); + }); + + it.each(['metadata', 'split', 'frame approval'])( + 'an in-flight final correction cannot overwrite a concurrent %s mutation', + async (operation) => { + const row = await entry({ + clockIn: new Date(EARLY), + status: 'Pending', + requiresApproval: true, + }); + // Move proposed values into the frame so manager approval would otherwise be final. + const request = await correction(row); + expect(request.requiresApproval).toBe(false); + const employees = ctx.app.get(EmployeesService); + const getById = employees.getById.bind(employees); + let release!: () => void; + let arrived!: () => void; + const waiting = new Promise((resolve) => { + arrived = resolve; + }); + const barrier = new Promise((resolve) => { + release = resolve; + }); + let delayOnce = true; + const spy = jest + .spyOn(employees, 'getById') + .mockImplementation(async (id) => { + const result = await getById(id); + if (id === manager.id && delayOnce) { + delayOnce = false; + arrived(); + await barrier; + } + return result; + }); + // Start HTTP immediately; the barrier pauses after its original request was read. + const inflight = decide(request.id).then((response) => response); + try { + await waiting; + if (operation === 'metadata') { + await ctx.http + .patch(`/api/timeentries/${row.id}`) + .set('Authorization', `Bearer ${owner.token}`) + .send({ revision: 0, note: 'Concurrent owner edit' }) + .expect(200); + } else if (operation === 'split') { + await ctx.http + .post(`/api/timeentries/${row.id}/split`) + .set('Authorization', `Bearer ${owner.token}`) + .send({ revision: 0, at: '2026-05-04T10:00:00Z' }) + .expect(201); + } else await frameDecision(row.id).expect(201); + const before = await unchangedDecisionState(request.id, row.id); + const allEntries = await ctx.prisma.timeEntry.findMany({ + orderBy: { id: 'asc' }, + }); + release(); + expect((await inflight).status).toBe(409); + expect(await unchangedDecisionState(request.id, row.id)).toEqual( + before, + ); + expect( + await ctx.prisma.timeEntry.findMany({ orderBy: { id: 'asc' } }), + ).toEqual(allEntries); + expect(before.request.workflowState).toBe('PendingManager'); + expect(before.entry.revision).toBe(1); + } finally { + release(); + await inflight; + spy.mockRestore(); + } + }, + ); + + it.each(['approve', 'manager-approve', 'hr-confirm'])( + '%s rolls back request state/event and entry/audit if application fails after the entry changed inside the transaction', + async (route) => { + const row = await entry(); + const request = await correction( + row, + route === 'hr-confirm' ? { from: EARLY } : {}, + ); + if (route === 'hr-confirm') await decide(request.id).expect(201); + const before = await unchangedDecisionState(request.id, row.id); + const service = ctx.app.get(TimeAdjustmentsService); + const apply = service.apply.bind(service); + let changedInsideTransaction = false; + const spy = jest + .spyOn(service, 'apply') + .mockImplementation(async (tx, value, actorId, requiresApproval) => { + const changed = await apply(tx, value, actorId, requiresApproval); + expect(changed.revision).toBe(row.revision + 1); + expect(changed.clockOut?.toISOString()).toBe(EXTENDED); + expect( + await tx.timeEntryAudit.count({ where: { requestId: request.id } }), + ).toBe(1); + changedInsideTransaction = true; + throw new Error( + 'Deliberate regression-test failure after entry and audit writes', + ); + }); + try { + // createTestApp uses logger:false; this intentional error must reach HTTP as 500. + await decide( + request.id, + route, + route === 'hr-confirm' ? hr : manager, + ).expect(500); + expect(changedInsideTransaction).toBe(true); + } finally { + spy.mockRestore(); + } + expect(await unchangedDecisionState(request.id, row.id)).toEqual(before); + expect(await ctx.prisma.timeEntry.count()).toBe(1); + expect((await linkedAudit(request.id).expect(200)).body).toEqual([]); + // The same request/version remains usable after the failed transaction. + await decide( + request.id, + route, + route === 'hr-confirm' ? hr : manager, + ).expect(201); + expect(await audits(row.id)).toHaveLength(1); + expect((await currentEntry(row.id)).revision).toBe(1); + }, + ); + + it('preserves a grouped capture policy and snapshots the full capture summary, not just the target segment', async () => { + const captureGroupId = randomUUID(); + const row = await entry({ + clockOut: new Date('2026-05-04T10:00:00Z'), + captureGroupId, + }); + const sibling = await entry({ + clockIn: new Date('2026-05-04T10:00:00Z'), + captureGroupId, + }); + const request = await correction(row, { + from: '2026-05-04T05:00:00.000Z', + to: '2026-05-04T10:00:00.000Z', + }); + expect(request.targetTimeEntryBefore).toMatchObject({ + captureGroupId, + breakRules: RULES, + summary: { grossMinutes: 240, breakMinutes: 15, netMinutes: 225 }, + captureSummary: { grossMinutes: 480, breakMinutes: 30, netMinutes: 450 }, + }); + await decide(request.id).expect(201); // 07:00 local is exactly in-frame + expect(await currentEntry(sibling.id)).toEqual(sibling); + expect(await ctx.prisma.timeEntry.count()).toBe(2); + const linked = (await linkedAudit(request.id).expect(200)).body[0]; + expect(linked.before).toEqual(request.targetTimeEntryBefore); + expect(linked.after).toMatchObject({ + captureGroupId, + breakRules: RULES, + summary: { grossMinutes: 300, breakMinutes: 25, netMinutes: 275 }, + captureSummary: { grossMinutes: 540, breakMinutes: 45, netMinutes: 495 }, + }); + const listed = (await listEntries().expect(200)).body as Array<{ + summary: { + grossMinutes: number; + breakMinutes: number; + netMinutes: number; + }; + }>; + const total = listed.reduce( + (sum, item) => ({ + grossMinutes: sum.grossMinutes + item.summary.grossMinutes, + breakMinutes: sum.breakMinutes + item.summary.breakMinutes, + netMinutes: sum.netMinutes + item.summary.netMinutes, + }), + { grossMinutes: 0, breakMinutes: 0, netMinutes: 0 }, + ); + expect(total).toEqual({ + grossMinutes: 540, + breakMinutes: 45, + netMinutes: 495, + }); + }); + + it('requires an active real assigned Manager and revalidates their assignment/role at decision time', async () => { + const row = await entry(); + for (const managerId of [null, owner.id, hr.id]) { + await ctx.prisma.employee.update({ + where: { id: owner.id }, + data: { managerId }, + }); + await postRequest({ + targetTimeEntryId: row.id, + expectedTargetRevision: 0, + }).expect(409); + } + await ctx.prisma.employee.update({ + where: { id: owner.id }, + data: { managerId: manager.id }, + }); + await ctx.prisma.employee.update({ + where: { id: manager.id }, + data: { isActive: false }, + }); + await postRequest({ + targetTimeEntryId: row.id, + expectedTargetRevision: 0, + }).expect(409); + await ctx.prisma.employee.update({ + where: { id: manager.id }, + data: { isActive: true }, + }); + const request = await correction(row); + const before = await unchangedDecisionState(request.id, row.id); + await decide(request.id, 'manager-approve', hr, { + actorId: manager.id, + }).expect(403); + await ctx.prisma.employee.update({ + where: { id: manager.id }, + data: { role: 'Employee' }, + }); + await decide(request.id).expect(403); // JWT's old role must not authorize the action. + expect(await unchangedDecisionState(request.id, row.id)).toEqual(before); + }); +}); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 0b2fcdb..59a9d53 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -3563,6 +3563,86 @@ "tags": ["requests"] } }, + "/api/requests/time-adjustment-targets": { + "get": { + "operationId": "RequestsController_timeAdjustmentTargets", + "parameters": [ + { + "name": "from", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "to", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TimeEntryDto" + } + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["requests"] + } + }, + "/api/requests/{id}/time-entry-audit": { + "get": { + "operationId": "RequestsController_timeEntryAudit", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TimeEntryAuditDto" + } + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["requests"] + } + }, "/api/requests/{id}": { "get": { "operationId": "RequestsController_get", @@ -6690,6 +6770,11 @@ "TimeEntryApprovalEventDto": { "type": "object", "properties": { + "requestId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, "id": { "type": "string" }, @@ -6715,6 +6800,7 @@ } }, "required": [ + "requestId", "id", "actorId", "actorName", @@ -7187,6 +7273,15 @@ "TimeEntryAuditDto": { "type": "object", "properties": { + "actorName": { + "type": "string", + "nullable": true + }, + "requestId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, "id": { "type": "string", "format": "uuid" @@ -7221,6 +7316,7 @@ } }, "required": [ + "requestId", "id", "timeEntryId", "actorId", @@ -8066,6 +8162,22 @@ "RequestDto": { "type": "object", "properties": { + "targetTimeEntryId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "expectedTargetRevision": { + "type": "number", + "nullable": true, + "minimum": 0 + }, + "targetTimeEntryBefore": { + "type": "object", + "nullable": true, + "additionalProperties": true, + "description": "Immutable server-captured original time-entry values, including id, revision, clockIn, clockOut and break-policy snapshot." + }, "id": { "type": "string", "format": "uuid" @@ -8160,6 +8272,9 @@ } }, "required": [ + "targetTimeEntryId", + "expectedTargetRevision", + "targetTimeEntryBefore", "id", "employeeId", "employeeName", @@ -8188,6 +8303,16 @@ "CreateRequestDto": { "type": "object", "properties": { + "targetTimeEntryId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "expectedTargetRevision": { + "type": "number", + "nullable": true, + "minimum": 0 + }, "employeeId": { "type": "string", "format": "uuid" @@ -8217,6 +8342,16 @@ "CreateVacationDto": { "type": "object", "properties": { + "targetTimeEntryId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "expectedTargetRevision": { + "type": "number", + "nullable": true, + "minimum": 0 + }, "employeeId": { "type": "string", "format": "uuid" @@ -8255,6 +8390,16 @@ "UpdateDraftRequestDto": { "type": "object", "properties": { + "targetTimeEntryId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "expectedTargetRevision": { + "type": "number", + "nullable": true, + "minimum": 0 + }, "from": { "type": "string", "format": "date-time" diff --git a/apps/api/src/app/requests/requests.controller.ts b/apps/api/src/app/requests/requests.controller.ts index 40c6910..7b44913 100644 --- a/apps/api/src/app/requests/requests.controller.ts +++ b/apps/api/src/app/requests/requests.controller.ts @@ -18,6 +18,10 @@ import { import { CurrentUser } from '../auth/current-user.decorator'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import type { JwtUser } from '../auth/jwt.strategy'; +import { + TimeEntryDto, + TimeEntryAuditDto, +} from '../time-entries/time-entries.dto'; import { RequestsService } from './requests.service'; import { BulkApproveDto, @@ -64,6 +68,29 @@ export class RequestsController { ); } + @Get('time-adjustment-targets') + @ApiOkResponse({ type: TimeEntryDto, isArray: true }) + timeAdjustmentTargets( + @CurrentUser() user: JwtUser, + @Query('from') from?: string, + @Query('to') to?: string, + ): Promise { + return this.service.timeAdjustmentTargets( + user.id, + from ? new Date(from) : undefined, + to ? new Date(to) : undefined, + ); + } + + @Get(':id/time-entry-audit') + @ApiOkResponse({ type: TimeEntryAuditDto, isArray: true }) + timeEntryAudit( + @Param('id', new ParseUUIDPipe()) id: string, + @CurrentUser() user: JwtUser, + ): Promise { + return this.service.timeEntryAudit(id, user.id); + } + @Get(':id') @ApiOkResponse({ type: RequestDto }) get( diff --git a/apps/api/src/app/requests/requests.dto.ts b/apps/api/src/app/requests/requests.dto.ts index 546ff70..c556192 100644 --- a/apps/api/src/app/requests/requests.dto.ts +++ b/apps/api/src/app/requests/requests.dto.ts @@ -3,6 +3,8 @@ import { ArrayNotEmpty, IsArray, IsBoolean, + IsInt, + Min, IsEnum, IsISO8601, IsOptional, @@ -10,7 +12,7 @@ import { IsUUID, MaxLength, } from 'class-validator'; -import type { Request } from '@prisma/client'; +import type { Request, Prisma } from '@prisma/client'; const REQUEST_TYPES = [ 'Vacation', @@ -20,6 +22,17 @@ const REQUEST_TYPES = [ ] as const; export class CreateRequestDto { + @ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' }) + @IsOptional() + @IsUUID() + targetTimeEntryId?: string | null; + + @ApiPropertyOptional({ type: Number, nullable: true, minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + expectedTargetRevision?: number | null; + @ApiProperty({ format: 'uuid' }) @IsUUID() employeeId!: string; @@ -44,6 +57,17 @@ export class CreateRequestDto { } export class CreateVacationDto { + @ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' }) + @IsOptional() + @IsUUID() + targetTimeEntryId?: string | null; + + @ApiPropertyOptional({ type: Number, nullable: true, minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + expectedTargetRevision?: number | null; + @ApiProperty({ format: 'uuid' }) @IsUUID() employeeId!: string; @@ -176,6 +200,19 @@ export interface BulkResult { } export class RequestDto { + @ApiProperty({ type: String, nullable: true, format: 'uuid' }) + targetTimeEntryId!: string | null; + @ApiProperty({ type: Number, nullable: true, minimum: 0 }) + expectedTargetRevision!: number | null; + @ApiProperty({ + type: 'object', + nullable: true, + additionalProperties: true, + description: + 'Immutable server-captured original time-entry values, including id, revision, clockIn, clockOut and break-policy snapshot.', + }) + targetTimeEntryBefore!: Prisma.JsonValue | null; + @ApiProperty({ format: 'uuid' }) id!: string; @ApiProperty({ format: 'uuid' }) @@ -229,6 +266,9 @@ export function toRequestDto( ): RequestDto { return { id: r.id, + targetTimeEntryId: r.targetTimeEntryId, + expectedTargetRevision: r.expectedTargetRevision, + targetTimeEntryBefore: r.targetTimeEntryBefore, employeeId: r.employeeId, employeeName: `${r.employee.firstName} ${r.employee.lastName}`, updatedAt: r.updatedAt.toISOString(), diff --git a/apps/api/src/app/requests/requests.module.ts b/apps/api/src/app/requests/requests.module.ts index 53c7728..6c0feca 100644 --- a/apps/api/src/app/requests/requests.module.ts +++ b/apps/api/src/app/requests/requests.module.ts @@ -3,11 +3,12 @@ import { AccountsModule } from '../accounts/accounts.module'; import { EmployeesModule } from '../employees/employees.module'; import { LeaveAllowancesModule } from '../leave-allowances/leave-allowances.module'; import { RequestsController } from './requests.controller'; +import { TimeAdjustmentsService } from './time-adjustments.service'; import { RequestsService } from './requests.service'; @Module({ imports: [EmployeesModule, AccountsModule, LeaveAllowancesModule], controllers: [RequestsController], - providers: [RequestsService], + providers: [RequestsService, TimeAdjustmentsService], }) export class RequestsModule {} diff --git a/apps/api/src/app/requests/requests.service.ts b/apps/api/src/app/requests/requests.service.ts index 2a19d43..e3fedaa 100644 --- a/apps/api/src/app/requests/requests.service.ts +++ b/apps/api/src/app/requests/requests.service.ts @@ -5,24 +5,29 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import type { +import { Prisma, - Request, - RequestEventKind, - RequestType, + type Request, + type RequestEventKind, + type RequestType, } from '@prisma/client'; import { calculateVacationDays, calculateWorkingDays, deriveStatus, nextState, - requiresSpecialApproval, type WorkflowEvent, } from 'shared'; import { VacationBalanceService } from '../accounts/vacation-balance.service'; import { EmployeesService } from '../employees/employees.service'; import { PrismaService } from '../prisma/prisma.service'; import { RequestNotificationService } from '../notifications/request-notification.service'; +import { EventsGateway } from '../events/events.gateway'; +import { TimeAdjustmentsService } from './time-adjustments.service'; +import type { + TimeEntryAuditDto, + TimeEntryDto, +} from '../time-entries/time-entries.dto'; import { WorkSchedulesService } from '../work-schedules/work-schedules.service'; import { toRequestDto, @@ -58,6 +63,8 @@ export class RequestsService { private readonly notifications: RequestNotificationService, private readonly vacationBalance: VacationBalanceService, private readonly schedules: WorkSchedulesService, + private readonly adjustments: TimeAdjustmentsService, + private readonly eventsGateway: EventsGateway, ) {} async list( @@ -122,6 +129,10 @@ export class RequestsService { tx: Prisma.TransactionClient, employeeId: string, ): Promise { + // Same order as TimeEntriesService, terminal booking, #41 decisions and + // installation mode changes: global → employee advisory → employee row. + await tx.$executeRaw`SELECT pg_advisory_xact_lock(7261500)`; + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${employeeId}, 0))`; await tx.$queryRaw`SELECT "id" FROM "Employee" WHERE "id" = ${employeeId}::uuid FOR NO KEY UPDATE`; } @@ -181,9 +192,21 @@ export class RequestsService { } const values = await this.validateValues( request.type, - { ...dto, employeeId: actorId }, + { + ...dto, + employeeId: actorId, + targetTimeEntryId: + dto.targetTimeEntryId === undefined + ? request.targetTimeEntryId + : dto.targetTimeEntryId, + expectedTargetRevision: + dto.expectedTargetRevision === undefined + ? request.expectedTargetRevision + : dto.expectedTargetRevision, + }, tx, id, + request, ); const data: Prisma.RequestUncheckedUpdateInput = { ...values, @@ -221,6 +244,7 @@ export class RequestsService { dto: CreateVacationDto, tx: Prisma.TransactionClient, excludeRequestId?: string, + original?: Request, ) { const employee = await this.employees.getById(dto.employeeId); const from = new Date(dto.from); @@ -233,6 +257,13 @@ export class RequestsService { throw new BadRequestException('"to" must be on/after a valid "from"'); } const vacation = type === 'Vacation'; + if ( + type !== 'TimeAdjustment' && + (dto.targetTimeEntryId != null || dto.expectedTargetRevision != null) + ) + throw new BadRequestException( + 'Only TimeAdjustment supports a correction target', + ); if (vacation && from.getUTCFullYear() !== to.getUTCFullYear()) { throw new BadRequestException( 'Vacation must lie within a single calendar year — split into two requests', @@ -274,15 +305,26 @@ export class RequestsService { to, tx, excludeRequestId, + type === 'TimeAdjustment', ); let requiresApproval = false; + let targetValues: + | Awaited> + | undefined; if (type === 'TimeAdjustment') { if (to <= from) throw new BadRequestException( 'TimeAdjustment "to" must be after "from"', ); - await this.assertNoTimeEntryOverlap(employee.id, from, to, tx); - requiresApproval = requiresSpecialApproval(from, to, schedule.frame); + targetValues = await this.adjustments.validate( + tx, + employee.id, + from, + to, + dto, + original, + ); + requiresApproval = targetValues.requiresApproval; } if (vacation) { const balance = await this.vacationBalance.compute( @@ -298,6 +340,11 @@ export class RequestsService { } } return { + ...(targetValues ?? { + targetTimeEntryId: null, + expectedTargetRevision: null, + targetTimeEntryBefore: Prisma.DbNull, + }), from, to, reason: dto.reason ?? null, @@ -345,22 +392,22 @@ export class RequestsService { note: string | null, ): Promise { const request = await this.assertRequest(id); - if (request.type === 'Vacation') { - // Vacation must use the multi-stage workflow. + if (request.type === 'TimeAdjustment') { await this.assertApprover(request, actorId); - return this.transitionVacation(request, 'manager_approve', actorId, note); - } - if (requiresTwoStageApproval(request)) { - // Off-hours TimeAdjustment: manager approves the off-hours allowance, - // then HR finalises the actual time correction. - await this.assertApprover(request, actorId); - return this.transitionVacation( + return this.approveTimeAdjustment( request, - 'manager_approve_with_hr', + requiresTwoStageApproval(request) + ? 'manager_approve_with_hr' + : 'manager_approve', actorId, note, ); } + if (request.type === 'Vacation') { + // Vacation must use the multi-stage workflow. + await this.assertApprover(request, actorId); + return this.transitionVacation(request, 'manager_approve', actorId, note); + } await this.assertApprover(request, actorId); if (request.workflowState !== 'PendingManager') throw new ConflictException('Request is not pending manager approval'); @@ -376,9 +423,6 @@ export class RequestsService { await tx.requestEvent.create({ data: { requestId: id, kind: 'ManagerApproved', actorId, note }, }); - if (request.type === 'TimeAdjustment') { - await this.materializeTimeAdjustment(tx, request); - } return updated; }); this.notifications.notifyTransitioned(updated, 'ManagerApproved', actorId); @@ -658,6 +702,10 @@ export class RequestsService { ); } } + if (request.targetTimeEntryId && request.workflowState === 'Approved') + throw new ConflictException( + 'An applied correction cannot be undone by cancelling its request; submit a new correction', + ); return this.transitionVacation(request, 'cancel', actorId, note); } @@ -669,6 +717,13 @@ export class RequestsService { actorId: string, note: string | null, ): Promise { + if ( + request.type === 'TimeAdjustment' && + (event === 'manager_approve' || + event === 'manager_approve_with_hr' || + event === 'hr_confirm') + ) + return this.approveTimeAdjustment(request, event, actorId, note); const target = nextState(request.workflowState, event, { hasSubstitute: !!request.substituteId, }); @@ -728,43 +783,171 @@ export class RequestsService { await tx.requestEvent.create({ data: { requestId: request.id, kind: eventKind, actorId, note }, }); - // Reaching Approved via the multi-stage path (off-hours TimeAdjustment: - // manager → HR) must materialise the correction just like the direct - // approve() path does. - if (target === 'Approved' && request.type === 'TimeAdjustment') { - await this.materializeTimeAdjustment(tx, request); - } return u; }); this.notifications.notifyTransitioned(updated, eventKind, actorId); return toRequestDto(updated); } - /** - * Approving a TimeAdjustment is a correction of *booked time*, so it has - * to land as a real TimeEntry — otherwise the day stays empty and the - * overtime account shows a phantom deficit. For TimeAdjustment requests - * `from` / `to` carry the corrected clock-in / clock-out timestamps - * (full date-time, not just a date), set by the request form. - */ - private async materializeTimeAdjustment( - tx: Prisma.TransactionClient, - request: Request, - ): Promise { - const schedule = await this.schedules.resolveForEmployee( - request.employeeId, - ); - await tx.timeEntry.create({ - data: { - employeeId: request.employeeId, - clockIn: request.from, - clockOut: request.to, - source: 'Manual', - breakRules: schedule.breakRules, - status: 'Approved', - requiresApproval: false, - }, + async timeAdjustmentTargets( + actorId: string, + from?: Date, + to?: Date, + ): Promise { + return this.adjustments.targets(actorId, from, to); + } + + async timeEntryAudit( + id: string, + actorId: string, + ): Promise { + const request = await this.assertRequest(id); + await this.assertCanView(request, actorId); + const rows = await this.prisma.timeEntryAudit.findMany({ + where: { requestId: id }, + include: { actor: { select: { firstName: true, lastName: true } } }, + orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }], }); + return rows.map(({ actor, ...row }) => ({ + ...row, + actorName: actor ? `${actor.firstName} ${actor.lastName}` : null, + occurredAt: row.occurredAt.toISOString(), + })); + } + + private async approveTimeAdjustment( + expected: Request, + event: WorkflowEvent, + actorId: string, + note: string | null, + ): Promise { + const result = await this.prisma.$transaction(async (tx) => { + await this.lockEmployee(tx, expected.employeeId); + await this.adjustments.assertTeam(tx); + const request = await tx.request.findUniqueOrThrow({ + where: { id: expected.id }, + }); + if ( + request.updatedAt.getTime() !== expected.updatedAt.getTime() || + request.workflowState !== expected.workflowState + ) + throw new ConflictException( + 'Request changed; reload before continuing', + ); + const employee = await tx.employee.findUniqueOrThrow({ + where: { id: request.employeeId }, + }); + const ids = [ + ...new Set( + [actorId, employee.managerId].filter((id): id is string => !!id), + ), + ].sort(); + if (ids.length) + await tx.$queryRaw( + Prisma.sql`SELECT "id" FROM "Employee" WHERE "id" IN (${Prisma.join(ids.map((id) => Prisma.sql`${id}::uuid`))}) ORDER BY "id" FOR SHARE`, + ); + const actor = await tx.employee.findUniqueOrThrow({ + where: { id: actorId }, + }); + if (!actor.isActive || !employee.isActive) + throw new ForbiddenException('Active employee and reviewer required'); + const hr = event === 'hr_confirm'; + if (request.workflowState !== (hr ? 'PendingHr' : 'PendingManager')) + throw new ConflictException( + 'Request is not awaiting this approval stage', + ); + if (hr) { + if (actor.role !== 'HRAdmin') + throw new ForbiddenException('HR confirmation required'); + } else if (actor.role !== 'Manager' && actor.role !== 'HRAdmin') { + throw new ForbiddenException('Manager approval required'); + } + if (request.targetTimeEntryId) { + if (actorId === request.employeeId) + throw new ForbiddenException( + 'Self approval of a correction is forbidden', + ); + if ( + !hr && + (actor.role !== 'Manager' || + actorId !== employee.managerId || + request.currentApproverId !== employee.managerId) + ) + throw new ForbiddenException( + 'Only the current assigned manager may approve this correction', + ); + if (hr && request.approverId !== employee.managerId) + throw new ConflictException( + 'Assigned manager changed; cancel and create a new correction request', + ); + } else if ( + !hr && + actor.role === 'Manager' && + request.currentApproverId !== actorId + ) { + throw new ForbiddenException( + 'Managers may only approve assigned requests', + ); + } + const values = await this.adjustments.validate( + tx, + request.employeeId, + request.from, + request.to, + request, + request, + ); + await this.assertNoActiveOverlap( + request.employeeId, + request.from, + request.to, + tx, + request.id, + true, + ); + const needsHr = + !hr && + (values.requiresApproval || + request.requiresApproval || + event === 'manager_approve_with_hr'); + const target = needsHr ? 'PendingHr' : 'Approved'; + const u = await this.updateVersion(tx, request, { + status: needsHr ? 'Submitted' : 'Approved', + workflowState: target, + requiresApproval: values.requiresApproval || request.requiresApproval, + ...(hr ? { hrConfirmedAt: new Date() } : { approverId: actorId }), + decidedAt: needsHr ? null : new Date(), + decisionNote: needsHr ? null : note, + currentApproverId: null, + }); + const kind = hr ? 'HrConfirmed' : 'ManagerApproved'; + await tx.requestEvent.create({ + data: { requestId: request.id, kind, actorId, note }, + }); + const entry = + target === 'Approved' + ? await this.adjustments.apply( + tx, + request, + actorId, + values.requiresApproval, + ) + : null; + return { request: u, entry, kind }; + }); + this.notifications.notifyTransitioned(result.request, result.kind, actorId); + if (result.entry) + this.eventsGateway.broadcast( + expected.targetTimeEntryId + ? 'time-entry:updated' + : 'time-entry:created', + { + id: result.entry.id, + employeeId: result.entry.employeeId, + clockOut: result.entry.clockOut?.toISOString() ?? null, + }, + ); + return toRequestDto(result.request); } private async assertRequest(id: string): Promise { @@ -837,11 +1020,15 @@ export class RequestsService { to: Date, db: Prisma.TransactionClient = this.prisma, excludeRequestId?: string, + timeAdjustment = false, ): Promise { const overlap = await db.request.findFirst({ where: { employeeId, status: { notIn: ['Rejected', 'Cancelled'] }, + ...(timeAdjustment + ? { NOT: { type: 'TimeAdjustment', status: 'Approved' } } + : {}), ...(excludeRequestId ? { id: { not: excludeRequestId } } : {}), from: { lte: to }, to: { gte: from }, @@ -854,32 +1041,6 @@ export class RequestsService { ); } } - - /** - * TimeAdjustment currently materialises a new entry on approval. Rejecting - * overlaps prevents a correction (or daily block) from being counted twice. - */ - private async assertNoTimeEntryOverlap( - employeeId: string, - from: Date, - to: Date, - db: Prisma.TransactionClient = this.prisma, - ): Promise { - const overlap = await db.timeEntry.findFirst({ - where: { - employeeId, - status: { not: 'Rejected' }, - clockIn: { lt: to }, - OR: [{ clockOut: null }, { clockOut: { gt: from } }], - }, - select: { id: true }, - }); - if (overlap) { - throw new ConflictException( - 'Time adjustment overlaps an existing time entry for this employee', - ); - } - } } function requiresTwoStageApproval(request: Request): boolean { diff --git a/apps/api/src/app/requests/time-adjustments.service.ts b/apps/api/src/app/requests/time-adjustments.service.ts new file mode 100644 index 0000000..365d813 --- /dev/null +++ b/apps/api/src/app/requests/time-adjustments.service.ts @@ -0,0 +1,299 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Prisma, type Request, type TimeEntry } from '@prisma/client'; +import { parseBreakRules, requiresSpecialApproval } from 'shared'; +import { PrismaService } from '../prisma/prisma.service'; +import { WorkSchedulesService } from '../work-schedules/work-schedules.service'; +import { calculateCaptureSummaries } from '../time-entries/capture-summary'; +import { + toTimeEntryDto, + type TimeEntryDto, +} from '../time-entries/time-entries.dto'; + +interface TargetBinding { + targetTimeEntryId?: string | null; + expectedTargetRevision?: number | null; +} + +@Injectable() +export class TimeAdjustmentsService { + constructor( + private readonly prisma: PrismaService, + private readonly schedules: WorkSchedulesService, + ) {} + + async assertTeam(tx: Prisma.TransactionClient): Promise { + const settings = await tx.installationSettings.findUnique({ + where: { id: 1 }, + }); + if (settings?.mode === 'Solo') + throw new ForbiddenException( + 'Team correction requests are unavailable in Solo mode', + ); + } + + async targets( + employeeId: string, + from?: Date, + to?: Date, + ): Promise { + if ( + (from && !Number.isFinite(+from)) || + (to && !Number.isFinite(+to)) || + (from && to && from > to) + ) + throw new BadRequestException('Invalid target date range'); + await this.assertTeam(this.prisma); + const rows = await this.prisma.timeEntry.findMany({ + where: { + employeeId, + voidedAt: null, + clockOut: { not: null }, + status: { in: ['Pending', 'Approved'] }, + OR: [{ approvalMode: null }, { approvalMode: { not: 'Solo' } }], + }, + include: { + project: { select: { code: true, name: true } }, + serviceOrder: { select: { orderNo: true, title: true } }, + }, + orderBy: [{ clockIn: 'desc' }, { id: 'asc' }], + }); + const summaries = calculateCaptureSummaries(rows); + return rows + .filter( + (row) => + (!from || (row.clockOut && row.clockOut > from)) && + (!to || row.clockIn < to), + ) + .map((row) => ({ + ...toTimeEntryDto(row), + summary: summaries.get(row.id) ?? null, + })); + } + + async snapshot( + tx: Prisma.TransactionClient, + entry: TimeEntry, + ): Promise { + const group = entry.captureGroupId + ? await tx.timeEntry.findMany({ + where: { + employeeId: entry.employeeId, + captureGroupId: entry.captureGroupId, + }, + }) + : [entry]; + const summaries = calculateCaptureSummaries(group); + const summary = summaries.get(entry.id); + const captureSummary = [...summaries.values()].reduce( + (sum, item) => ({ + grossMinutes: sum.grossMinutes + item.grossMinutes, + breakMinutes: sum.breakMinutes + item.breakMinutes, + netMinutes: sum.netMinutes + item.netMinutes, + }), + { grossMinutes: 0, breakMinutes: 0, netMinutes: 0 }, + ); + return { + id: entry.id, + revision: entry.revision, + clockIn: entry.clockIn.toISOString(), + clockOut: entry.clockOut?.toISOString() ?? null, + status: entry.status, + source: entry.source, + projectId: entry.projectId, + serviceOrderId: entry.serviceOrderId, + activity: entry.activity, + note: entry.note, + billable: entry.billable, + breakRules: parseBreakRules(entry.breakRules), + captureGroupId: entry.captureGroupId, + approvalMode: entry.approvalMode, + requiresApproval: entry.requiresApproval, + frameApproverId: entry.frameApproverId, + frameApprovedRevision: entry.frameApprovedRevision, + bookingDate: entry.bookingDate?.toISOString() ?? null, + summary: summary ? { ...summary } : null, + captureSummary: { ...captureSummary }, + }; + } + + async validate( + tx: Prisma.TransactionClient, + employeeId: string, + from: Date, + to: Date, + binding: TargetBinding, + original?: Request, + ) { + await this.assertTeam(tx); + const targetId = binding.targetTimeEntryId ?? null; + const revision = binding.expectedTargetRevision ?? null; + if ( + (targetId === null) !== (revision === null) || + (revision !== null && (!Number.isInteger(revision) || revision < 0)) + ) + throw new BadRequestException( + 'Correction target and expected revision must be supplied together', + ); + if ( + original && + (targetId !== original.targetTimeEntryId || + revision !== original.expectedTargetRevision) + ) + throw new BadRequestException( + 'The correction target and original revision are immutable; cancel and create a new request', + ); + let target: TimeEntry | null = null; + let before: Prisma.InputJsonValue | typeof Prisma.DbNull = Prisma.DbNull; + if (targetId) { + target = await tx.timeEntry.findUnique({ where: { id: targetId } }); + if (!target || target.employeeId !== employeeId) + throw new ForbiddenException( + 'The correction target must be your own time entry', + ); + if ( + target.voidedAt || + !target.clockOut || + target.status === 'Open' || + target.status === 'Rejected' || + target.approvalMode === 'Solo' + ) + throw new ConflictException( + 'Target must be a closed, non-voided Team entry that is not rejected', + ); + if (target.revision !== revision) + throw new ConflictException( + 'Correction target changed; cancel and create a new request from its current version', + ); + if (to.getTime() > Date.now()) + throw new BadRequestException( + 'Corrected working time cannot be in the future', + ); + if (original) { + if ( + !original.targetTimeEntryBefore || + typeof original.targetTimeEntryBefore !== 'object' || + Array.isArray(original.targetTimeEntryBefore) + ) + throw new ConflictException( + 'Original correction snapshot is unavailable', + ); + before = original.targetTimeEntryBefore as Prisma.InputJsonObject; + } else before = await this.snapshot(tx, target); + const employee = await tx.employee.findUniqueOrThrow({ + where: { id: employeeId }, + include: { manager: true }, + }); + if ( + !employee.isActive || + !employee.manager?.isActive || + employee.manager.role !== 'Manager' || + employee.managerId === employeeId + ) + throw new ConflictException( + 'An active assigned manager is required; ask HR to correct the manager assignment', + ); + } + if (!Number.isFinite(+from) || !Number.isFinite(+to) || to <= from) + throw new BadRequestException( + 'TimeAdjustment "to" must be after a valid "from"', + ); + const overlap = await tx.timeEntry.findFirst({ + where: { + employeeId, + voidedAt: null, + status: { not: 'Rejected' }, + ...(targetId ? { id: { not: targetId } } : {}), + clockIn: { lt: to }, + OR: [{ clockOut: null }, { clockOut: { gt: from } }], + }, + select: { id: true }, + }); + if (overlap) + throw new ConflictException( + 'Time adjustment overlaps another time entry for this employee', + ); + const schedule = await this.schedules.resolveForEmployee(employeeId); + return { + targetTimeEntryId: targetId, + expectedTargetRevision: revision, + targetTimeEntryBefore: before, + requiresApproval: requiresSpecialApproval(from, to, schedule.frame), + }; + } + + async apply( + tx: Prisma.TransactionClient, + request: Request, + actorId: string, + requiresApproval: boolean, + ): Promise { + let row: TimeEntry; + let before: Prisma.InputJsonObject | null = null; + if (request.targetTimeEntryId) { + const target = await tx.timeEntry.findUniqueOrThrow({ + where: { id: request.targetTimeEntryId }, + }); + before = await this.snapshot(tx, target); + const changed = await tx.timeEntry.updateMany({ + where: { + id: target.id, + employeeId: request.employeeId, + revision: request.expectedTargetRevision ?? -1, + voidedAt: null, + clockOut: { not: null }, + status: { in: ['Pending', 'Approved'] }, + }, + data: { + clockIn: request.from, + clockOut: request.to, + revision: { increment: 1 }, + status: 'Approved', + requiresApproval, + frameApproverId: null, + frameApprovedRevision: null, + ...(target.source === 'DailyBlock' ? { bookingDate: null } : {}), + }, + }); + if (changed.count !== 1) + throw new ConflictException( + 'Correction target changed before application', + ); + row = await tx.timeEntry.findUniqueOrThrow({ where: { id: target.id } }); + } else { + const schedule = await this.schedules.resolveForEmployee( + request.employeeId, + ); + row = await tx.timeEntry.create({ + data: { + employeeId: request.employeeId, + clockIn: request.from, + clockOut: request.to, + source: 'Manual', + approvalMode: 'Team', + breakRules: schedule.breakRules, + status: 'Approved', + requiresApproval: false, + }, + }); + } + await tx.timeEntryAudit.create({ + data: { + timeEntryId: row.id, + requestId: request.id, + actorId, + action: request.targetTimeEntryId + ? 'FrameRequestCorrected' + : 'RequestMissingBookingAdded', + before: before ?? Prisma.DbNull, + after: await this.snapshot(tx, row), + reason: request.reason, + }, + }); + return row; + } +} diff --git a/apps/api/src/app/time-entries/time-entries.dto.ts b/apps/api/src/app/time-entries/time-entries.dto.ts index 60a1187..b3d60b7 100644 --- a/apps/api/src/app/time-entries/time-entries.dto.ts +++ b/apps/api/src/app/time-entries/time-entries.dto.ts @@ -649,6 +649,10 @@ export class SwitchProjectDto extends ClockInDto { } export class TimeEntryAuditDto { + @ApiPropertyOptional({ type: String, nullable: true }) + actorName?: string | null; + @ApiProperty({ type: String, nullable: true, format: 'uuid' }) + requestId!: string | null; @ApiProperty({ format: 'uuid' }) id!: string; diff --git a/apps/api/src/app/time-entries/time-entry-approvals.dto.ts b/apps/api/src/app/time-entries/time-entry-approvals.dto.ts index 2decd4c..13f6aac 100644 --- a/apps/api/src/app/time-entries/time-entry-approvals.dto.ts +++ b/apps/api/src/app/time-entries/time-entry-approvals.dto.ts @@ -56,6 +56,8 @@ export class DecideTimeEntryApprovalDto { } export class TimeEntryApprovalEventDto { + @ApiProperty({ type: String, nullable: true, format: 'uuid' }) + requestId!: string | null; @ApiProperty() id!: string; @ApiProperty({ type: String, nullable: true, format: 'uuid' }) diff --git a/apps/api/src/app/time-entries/time-entry-approvals.service.ts b/apps/api/src/app/time-entries/time-entry-approvals.service.ts index a0ab33c..530f1bb 100644 --- a/apps/api/src/app/time-entries/time-entry-approvals.service.ts +++ b/apps/api/src/app/time-entries/time-entry-approvals.service.ts @@ -206,6 +206,7 @@ export class TimeEntryApprovalsService { }); return events.map((event) => ({ id: event.id, + requestId: event.requestId, actorId: event.actorId, action: event.action, reason: event.reason, diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 9607e03..9d6aaad 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -191,6 +191,38 @@ export interface TimeEntryDto { summary: TimeSummaryDto | null; } +/** Eligibility and revision are server-authoritative; this endpoint is own-only. */ +export interface TimeAdjustmentTargetDto extends TimeEntryDto { + revision: number; +} + +export interface TimeEntryBeforeSnapshot { + id: string; + revision: number; + clockIn: string; + clockOut: string; + status: string; + projectId: string | null; + serviceOrderId: string | null; + activity: string | null; + breakRules: unknown; + captureGroupId: string | null; + approvalMode: string | null; +} + +export interface TimeEntryAuditDto { + actorName?: string | null; + id: string; + timeEntryId: string; + actorId: string | null; + action: string; + before: unknown; + after: unknown; + reason: string | null; + occurredAt: string; + requestId: string | null; +} + export type TimeEntryApprovalState = | 'Open' | 'BlockedManager' @@ -218,6 +250,7 @@ export interface TimeEntryApprovalDecision { } export interface TimeEntryApprovalEventDto { + requestId?: string | null; id: string; actorId: string | null; actorName: string | null; @@ -400,6 +433,9 @@ export interface AccountDto { } export interface RequestDto { + targetTimeEntryId?: string | null; + expectedTargetRevision?: number | null; + targetTimeEntryBefore?: TimeEntryBeforeSnapshot | null; id: string; employeeId: string; employeeName: string; @@ -566,6 +602,8 @@ export interface UpsertWorkSchedulePayload { } export interface CreateRequestPayload { + targetTimeEntryId?: string | null; + expectedTargetRevision?: number | null; employeeId: string; type: RequestType; from: string; @@ -588,6 +626,8 @@ export interface UpdateDraftRequestPayload extends Omit< 'employeeId' > { expectedUpdatedAt: string; + targetTimeEntryId?: string | null; + expectedTargetRevision?: number | null; } export interface UpsertLeaveAllowancePayload { @@ -1099,6 +1139,10 @@ export const api = { const qs = params.toString(); return request(`/api/requests${qs ? `?${qs}` : ''}`); }, + timeAdjustmentTargets: () => + request('/api/requests/time-adjustment-targets'), + requestTimeEntryAudit: (id: string) => + request(`/api/requests/${id}/time-entry-audit`), getRequest: (id: string) => request(`/api/requests/${id}`), getRequestEvents: (id: string) => request(`/api/requests/${id}/events`), diff --git a/apps/web/src/api/generated.ts b/apps/web/src/api/generated.ts index ae14830..1972404 100644 --- a/apps/web/src/api/generated.ts +++ b/apps/web/src/api/generated.ts @@ -1316,6 +1316,38 @@ export interface paths { patch?: never; trace?: never; }; + '/api/requests/time-adjustment-targets': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['RequestsController_timeAdjustmentTargets']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/requests/{id}/time-entry-audit': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['RequestsController_timeEntryAudit']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/requests/{id}': { parameters: { query?: never; @@ -2415,6 +2447,8 @@ export interface components { canDecide: boolean; }; TimeEntryApprovalEventDto: { + /** Format: uuid */ + requestId: string | null; id: string; /** Format: uuid */ actorId: string | null; @@ -2600,6 +2634,9 @@ export interface components { second: components['schemas']['TimeEntryDto']; }; TimeEntryAuditDto: { + actorName?: string | null; + /** Format: uuid */ + requestId: string | null; /** Format: uuid */ id: string; /** Format: uuid */ @@ -2914,6 +2951,13 @@ export interface components { adjustmentReason?: Record | null; }; RequestDto: { + /** Format: uuid */ + targetTimeEntryId: string | null; + expectedTargetRevision: number | null; + /** @description Immutable server-captured original time-entry values, including id, revision, clockIn, clockOut and break-policy snapshot. */ + targetTimeEntryBefore: { + [key: string]: unknown; + } | null; /** Format: uuid */ id: string; /** Format: uuid */ @@ -2953,6 +2997,9 @@ export interface components { createdAt: string; }; CreateRequestDto: { + /** Format: uuid */ + targetTimeEntryId?: string | null; + expectedTargetRevision?: number | null; /** Format: uuid */ employeeId: string; /** @enum {string} */ @@ -2970,6 +3017,9 @@ export interface components { reason?: string | null; }; CreateVacationDto: { + /** Format: uuid */ + targetTimeEntryId?: string | null; + expectedTargetRevision?: number | null; /** Format: uuid */ employeeId: string; /** Format: date-time */ @@ -2991,6 +3041,9 @@ export interface components { halfDayEnd: boolean; }; UpdateDraftRequestDto: { + /** Format: uuid */ + targetTimeEntryId?: string | null; + expectedTargetRevision?: number | null; /** Format: date-time */ from: string; /** Format: date-time */ @@ -5318,6 +5371,49 @@ export interface operations { }; }; }; + RequestsController_timeAdjustmentTargets: { + parameters: { + query: { + from: string; + to: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['TimeEntryDto'][]; + }; + }; + }; + }; + RequestsController_timeEntryAudit: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['TimeEntryAuditDto'][]; + }; + }; + }; + }; RequestsController_get: { parameters: { query?: never; diff --git a/apps/web/src/app/i18n.tsx b/apps/web/src/app/i18n.tsx index 76a2b8a..3724bec 100644 --- a/apps/web/src/app/i18n.tsx +++ b/apps/web/src/app/i18n.tsx @@ -16,6 +16,8 @@ type Catalog = Record; const STORAGE_KEY = 'openclockwork.locale'; const de: Catalog = { + 'requests.appliedBy': 'Angewendet von {actor}', + 'requests.unknownActor': 'Nicht mehr verfügbare Person', 'frameApproval.ownTitle': 'Rahmenzeit-Genehmigungen für meine Zeitbuchungen', 'frameApproval.title': 'Zeitbuchungen', 'frameApproval.chain': @@ -73,6 +75,7 @@ const de: Catalog = { 'Diese Zeitbuchung ist nicht mehr gültig und kann nicht genehmigt werden. Der Verlauf bleibt einsehbar.', 'frameApproval.event.FrameSegmentCreated': 'Buchungssegment nach Änderung neu angelegt', + 'frameApproval.event.FrameRequestCorrected': 'Durch Korrekturantrag geändert', 'frameApproval.event.FrameManagerApproved': 'Von Führungskraft genehmigt', 'frameApproval.event.FrameManagerRejected': 'Von Führungskraft abgelehnt', 'frameApproval.event.FrameHrConfirmed': 'Von HR bestätigt', @@ -133,6 +136,33 @@ const de: Catalog = { 'requests.attachmentLabel': 'Beleg (optional, max 10 MB)', 'requests.balanceHint': '{remaining} Tage verfügbar ({total} gesamt − {approved} genehmigt − {pending} eingereicht).', + 'requests.cancelStale': 'Diesen Antrag stornieren', + 'requests.adjustmentMode': 'Art der Zeitkorrektur', + 'requests.missingBooking': 'Fehlende Buchung nachtragen', + 'requests.correctBooking': 'Bestehende Buchung korrigieren', + 'requests.targetBooking': 'Zu korrigierende Buchung', + 'requests.chooseTarget': 'Buchung auswählen', + 'requests.targetsEmpty': + 'Keine abgeschlossenen Team-Buchungen für eine Korrektur verfügbar.', + 'requests.targetsFailed': 'Buchungen konnten nicht geladen werden.', + 'requests.targetRequired': 'Bitte eine zu korrigierende Buchung auswählen.', + 'requests.correctionHint': + 'Korrigiert nur diese Buchung mit derselben ID. Es entsteht keine doppelte Buchung; andere Buchungen des Tages bleiben unverändert.', + 'requests.targetReference': 'Buchung {id} · Revision {revision}', + 'requests.before': 'Vorher', + 'requests.proposed': 'Vorgeschlagen', + 'requests.grossDuration': 'Bruttodauer: {before} s → {after} s (Δ {delta} s)', + 'requests.grossProposed': 'Vorgeschlagene Bruttodauer: {duration} s', + 'requests.staleTargetHint': + 'Ist die Buchung inzwischen geändert, diesen Antrag abbrechen und einen neuen Korrekturantrag mit der aktuellen Buchung erstellen. Ziel und Revision dieses Entwurfs bleiben unverändert.', + 'requests.auditFailed': 'Buchungsverlauf konnte nicht geladen werden.', + 'requests.auditEmpty': 'Noch keine Buchungsänderung durch diesen Antrag.', + 'requests.auditApplied': 'Buchung {id} durch Antrag {requestId} korrigiert', + 'requests.auditMissingAdded': + 'Fehlende Buchung {id} durch Antrag {requestId} ergänzt', + 'requests.relatedRequest': 'Zugehöriger Antrag: {id}', + 'requests.retry': 'Erneut laden', + 'requests.bookingHistory': 'Buchungsänderung im Verlauf', 'requests.timeAdjustmentPolicy': 'Genehmigungen richten sich nach deinem Arbeitszeitplan. Zeiten außerhalb des konfigurierten Rahmens benötigen eine zusätzliche Genehmigung vor der HR-Bestätigung.', 'requests.invalidTimeRange': '"Bis" muss nach "Von" liegen.', @@ -769,6 +799,8 @@ const de: Catalog = { }; const en: Catalog = { + 'requests.appliedBy': 'Applied by {actor}', + 'requests.unknownActor': 'Reviewer no longer available', 'frameApproval.ownTitle': 'Approvals for my out-of-frame time entries', 'frameApproval.title': 'Time entries', 'frameApproval.chain': @@ -821,6 +853,7 @@ const en: Catalog = { 'This time entry is no longer valid and cannot be approved. Its history remains available.', 'frameApproval.event.FrameSegmentCreated': 'Entry segment created after a change', + 'frameApproval.event.FrameRequestCorrected': 'Changed by correction request', 'frameApproval.event.FrameManagerApproved': 'Approved by manager', 'frameApproval.event.FrameManagerRejected': 'Rejected by manager', 'frameApproval.event.FrameHrConfirmed': 'Confirmed by HR', @@ -881,6 +914,34 @@ const en: Catalog = { 'requests.attachmentLabel': 'Attachment (optional, max 10 MB)', 'requests.balanceHint': '{remaining} days available ({total} total − {approved} approved − {pending} submitted).', + 'requests.cancelStale': 'Cancel this request', + 'requests.adjustmentMode': 'Time adjustment mode', + 'requests.missingBooking': 'Add missing booking', + 'requests.correctBooking': 'Correct existing booking', + 'requests.targetBooking': 'Booking to correct', + 'requests.chooseTarget': 'Choose booking', + 'requests.targetsEmpty': + 'No closed Team bookings are available for correction.', + 'requests.targetsFailed': 'Bookings could not be loaded.', + 'requests.targetRequired': 'Choose a booking to correct.', + 'requests.correctionHint': + 'Corrects only this booking, retaining its ID. No duplicate is created; other bookings on that day remain unchanged.', + 'requests.targetReference': 'Booking {id} · revision {revision}', + 'requests.before': 'Before', + 'requests.proposed': 'Proposed', + 'requests.grossDuration': + 'Gross duration: {before} s → {after} s (Δ {delta} s)', + 'requests.grossProposed': 'Proposed gross duration: {duration} s', + 'requests.staleTargetHint': + 'If the booking has changed, cancel this request and create a new correction request using the current booking. This draft’s target and revision remain unchanged.', + 'requests.auditFailed': 'Booking history could not be loaded.', + 'requests.auditEmpty': 'No booking change from this request yet.', + 'requests.auditApplied': 'Booking {id} corrected by request {requestId}', + 'requests.auditMissingAdded': + 'Missing booking {id} added by request {requestId}', + 'requests.relatedRequest': 'Related request: {id}', + 'requests.retry': 'Retry', + 'requests.bookingHistory': 'Booking change history', 'requests.timeAdjustmentPolicy': 'Approvals follow your work schedule. Time outside its configured frame requires additional approval before HR confirmation.', 'requests.invalidTimeRange': '"To" must be after "From".', diff --git a/apps/web/src/app/realtime.ts b/apps/web/src/app/realtime.ts index ad4971d..d477623 100644 --- a/apps/web/src/app/realtime.ts +++ b/apps/web/src/app/realtime.ts @@ -64,6 +64,7 @@ export function useRealtimeInvalidation() { const onTransition = () => { qc.invalidateQueries({ queryKey: ['requests'] }); + qc.invalidateQueries({ queryKey: ['request-time-entry-audit'] }); qc.invalidateQueries({ queryKey: ['vacation-balance'] }); qc.invalidateQueries({ queryKey: ['account'] }); }; @@ -73,6 +74,7 @@ export function useRealtimeInvalidation() { qc.invalidateQueries({ queryKey: ['solo-summary'] }); qc.invalidateQueries({ queryKey: ['solo-hints'] }); qc.invalidateQueries({ queryKey: ['time-entries'] }); + qc.invalidateQueries({ queryKey: ['time-adjustment-targets'] }); qc.invalidateQueries({ queryKey: ['time-entry-approvals'] }); qc.invalidateQueries({ queryKey: ['time-entry-approval-history'] }); qc.invalidateQueries({ queryKey: ['account'] }); diff --git a/apps/web/src/components/timeentries/TimeEntryApprovals.spec.tsx b/apps/web/src/components/timeentries/TimeEntryApprovals.spec.tsx index 5487f0e..0f2c271 100644 --- a/apps/web/src/components/timeentries/TimeEntryApprovals.spec.tsx +++ b/apps/web/src/components/timeentries/TimeEntryApprovals.spec.tsx @@ -585,4 +585,40 @@ describe('out-of-frame time entry approvals', () => { expect(await screen.findByText('Approved by manager')).toBeDefined(); expect(screen.getByText('Client visit')).toBeDefined(); }); + it.each([ + [ + 'de', + 'Durch Korrekturantrag geändert', + 'Zugehöriger Antrag: correction-request', + ], + [ + 'en', + 'Changed by correction request', + 'Related request: correction-request', + ], + ])( + 'labels linked request corrections without presenting them as an old approval (%s)', + async (locale, label, reference) => { + storedLocale = locale; + mocks.history.mockResolvedValue([ + { + id: 'correction-event', + actorId: 'hr', + actorName: 'HR Example', + action: 'FrameRequestCorrected', + reason: 'Changed booking', + occurredAt: '2026-05-05T12:00:00Z', + requestId: 'correction-request', + }, + ]); + renderPanel(); + fireEvent.click( + await screen.findByRole('button', { + name: locale === 'de' ? 'Genehmigungsverlauf' : 'Approval history', + }), + ); + expect(await screen.findByText(label)).toBeDefined(); + expect(screen.getByText(reference)).toBeDefined(); + }, + ); }); diff --git a/apps/web/src/components/timeentries/TimeEntryApprovals.tsx b/apps/web/src/components/timeentries/TimeEntryApprovals.tsx index 9b20dcf..229c1a5 100644 --- a/apps/web/src/components/timeentries/TimeEntryApprovals.tsx +++ b/apps/web/src/components/timeentries/TimeEntryApprovals.tsx @@ -298,6 +298,9 @@ function ApprovalHistory({ entryId }: { entryId: string }) { {formatDateTime(event.occurredAt)}

    + {event.requestId && ( +

    {t('requests.relatedRequest', { id: event.requestId })}

    + )} {event.reason && (

    {event.reason}

    )} diff --git a/apps/web/src/routes/AdminRequestsPage.spec.tsx b/apps/web/src/routes/AdminRequestsPage.spec.tsx index ba0ae87..10fe097 100644 --- a/apps/web/src/routes/AdminRequestsPage.spec.tsx +++ b/apps/web/src/routes/AdminRequestsPage.spec.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, screen } from '@testing-library/react'; import { renderWithProviders } from '../test-utils'; import { AdminRequestsPage } from './AdminRequestsPage'; @@ -24,6 +24,7 @@ vi.mock('../api/client', () => ({ .mockImplementation(async (filter) => filter.currentApproverId ? [request] : [], ), + requestTimeEntryAudit: vi.fn().mockResolvedValue([]), getRequest: vi.fn().mockImplementation(async () => request), getRequestEvents: vi .fn() @@ -41,6 +42,11 @@ vi.mock('../app/auth', () => ({ }), })); +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + describe('request identity in approval context (#42)', () => { it('shows the employee name in the inbox and retained audit trail', async () => { renderWithProviders(); @@ -53,4 +59,62 @@ describe('request identity in approval context (#42)', () => { ).toBeDefined(); expect(await screen.findByText('Erneut eingereicht')).toBeDefined(); }); + it('shows the immutable correction before/proposed diff in both inbox and audit trail, with request-scoped final audit', async () => { + vi.stubEnv('TZ', 'Europe/Berlin'); + const before = { + id: 'original-entry', + revision: 4, + clockIn: '2026-10-25T00:50:23.456Z', + clockOut: '2026-10-25T01:10:45.789Z', + }; + const correction = { + ...request, + type: 'TimeAdjustment', + targetTimeEntryId: before.id, + expectedTargetRevision: 4, + targetTimeEntryBefore: before, + from: before.clockIn, + to: '2026-10-25T02:10:45.789Z', + }; + vi.mocked(api.listRequests).mockImplementation( + async (filter) => + (filter?.currentApproverId ? [correction] : []) as never, + ); + vi.mocked(api.getRequest).mockResolvedValue(correction as never); + vi.mocked(api.requestTimeEntryAudit).mockResolvedValue([ + { + id: 'audit', + timeEntryId: before.id, + requestId: request.id, + action: 'FrameRequestCorrected', + before, + after: { ...before, clockOut: correction.to }, + reason: 'Correction', + occurredAt: '2026-10-26T12:00:00Z', + actorId: 'hr', + actorName: 'Hannah Reviewer', + }, + ]); + renderWithProviders(); + expect( + await screen.findByText('Buchung original-entry · Revision 4'), + ).toBeDefined(); + expect( + screen.getByText('Bruttodauer: 1.222,333 s → 4.822,333 s (Δ 3.600 s)'), + ).toBeDefined(); + expect(screen.getAllByTitle(before.clockIn)).toHaveLength(2); + expect(screen.getAllByText(/02:50:23[,.]456.*UTC\+02:00/)).toHaveLength(2); + expect(screen.getByTitle(before.clockOut)).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'Verlauf' })); + expect( + await screen.findByText( + 'Buchung original-entry durch Antrag request-42 korrigiert', + ), + ).toBeDefined(); + expect(api.requestTimeEntryAudit).toHaveBeenCalledWith('request-42'); + expect(screen.getByText('Angewendet von Hannah Reviewer')).toBeDefined(); + expect( + screen.getAllByText('Bruttodauer: 1.222,333 s → 4.822,333 s (Δ 3.600 s)'), + ).toHaveLength(3); + }); }); diff --git a/apps/web/src/routes/AdminRequestsPage.tsx b/apps/web/src/routes/AdminRequestsPage.tsx index 819cc25..21ac833 100644 --- a/apps/web/src/routes/AdminRequestsPage.tsx +++ b/apps/web/src/routes/AdminRequestsPage.tsx @@ -1,3 +1,4 @@ +import { TimeAdjustmentDiff, RequestTimeEntryAudit } from './RequestsPage'; import { useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Alert, AlertDescription } from '@/components/ui/alert'; @@ -529,6 +530,8 @@ function RequestRow({
    + {isTimeAdjustment && } + {((role === 'Manager' && inManagerInbox) || (role === 'HRAdmin' && (inHrInbox || inManagerInbox))) && (
    @@ -631,6 +634,12 @@ function AuditTrail({ requestId }: { requestId: string }) { : '…'} + {requestQuery.data?.type === 'TimeAdjustment' && ( +
    + + +
    + )}
    {eventsQuery.data && eventsQuery.data.length > 0 ? (
      diff --git a/apps/web/src/routes/RequestsPage.spec.tsx b/apps/web/src/routes/RequestsPage.spec.tsx index 57004ea..d107e82 100644 --- a/apps/web/src/routes/RequestsPage.spec.tsx +++ b/apps/web/src/routes/RequestsPage.spec.tsx @@ -20,9 +20,17 @@ const resubmitRequestMock = vi.fn(); const employeeDirectoryMock = vi.fn(); const employeesMock = vi.fn(); const createVacationRequestMock = vi.fn(); +const createRequestMock = vi.fn(); +const targetsMock = vi.fn(); +const requestAuditMock = vi.fn(); +const cancelRequestMock = vi.fn(); vi.mock('../api/client', () => ({ api: { + createRequest: (...args: unknown[]) => createRequestMock(...args), + timeAdjustmentTargets: (...args: unknown[]) => targetsMock(...args), + requestTimeEntryAudit: (...args: unknown[]) => requestAuditMock(...args), + cancelRequest: (...args: unknown[]) => cancelRequestMock(...args), listRequests: (...args: unknown[]) => listRequestsMock(...args), getRequest: (...args: unknown[]) => getRequestMock(...args), getRequestEvents: (...args: unknown[]) => getRequestEventsMock(...args), @@ -72,6 +80,10 @@ describe('request creation and draft revision', () => { setItem: vi.fn(), }); listRequestsMock.mockReset().mockResolvedValue([]); + createRequestMock.mockReset().mockResolvedValue({}); + targetsMock.mockReset().mockResolvedValue([]); + requestAuditMock.mockReset().mockResolvedValue([]); + cancelRequestMock.mockReset().mockResolvedValue({}); getRequestMock.mockReset(); getRequestEventsMock.mockReset().mockResolvedValue([]); updateDraftRequestMock.mockReset().mockResolvedValue({}); @@ -485,4 +497,341 @@ describe('request creation and draft revision', () => { languageSpy.mockRestore(); } }); + const target = { + id: 'entry-to-correct', + employeeId: 'requester', + revision: 7, + clockIn: '2026-10-25T00:50:23.456Z', + clockOut: '2026-10-25T01:10:45.789Z', + status: 'Approved', + approvalMode: 'Team', + }; + + async function openNewCorrection() { + renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: 'Neuer Antrag' })); + fireEvent.change(screen.getByLabelText('Typ'), { + target: { value: 'TimeAdjustment' }, + }); + fireEvent.change(screen.getByLabelText('Art der Zeitkorrektur'), { + target: { value: 'correction' }, + }); + return screen.findByLabelText('Zu korrigierende Buchung'); + } + + it('requires a selected target, prefills exact fold-crossing instants, shows the gross diff, and sends only target ID/revision plus proposed times', async () => { + targetsMock.mockResolvedValue([target]); + const select = await openNewCorrection(); + await within(select).findByRole('option', { name: /entry-to-correct/ }); + // A valid proposal alone must not allow an untargeted correction. + fireEvent.change(screen.getByLabelText('Von'), { + target: { value: '2026-06-15T08:00:00.000' }, + }); + fireEvent.change(screen.getByLabelText('Bis'), { + target: { value: '2026-06-15T09:00:00.000' }, + }); + expect( + ( + screen.getByRole('button', { + name: 'Antrag stellen', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + fireEvent.change(select, { target: { value: target.id } }); + expect((screen.getByLabelText('Von') as HTMLInputElement).value).toBe( + '2026-10-25T02:50:23.456', + ); + expect( + (screen.getByLabelText('Von – Vorkommen') as HTMLSelectElement).value, + ).toBe(target.clockIn); + expect( + (screen.getByLabelText('Bis – Vorkommen') as HTMLSelectElement).value, + ).toBe(target.clockOut); + fireEvent.change(screen.getByLabelText('Bis'), { + target: { value: '2026-10-25T03:10:45.789' }, + }); + expect( + screen.getByText('Bruttodauer: 1.222,333 s → 4.822,333 s (Δ 3.600 s)'), + ).toBeDefined(); + expect( + screen.getByText(/Es entsteht keine doppelte Buchung/), + ).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'Antrag stellen' })); + await waitFor(() => + expect(createRequestMock).toHaveBeenCalledWith({ + employeeId: 'requester', + type: 'TimeAdjustment', + from: target.clockIn, + to: '2026-10-25T02:10:45.789Z', + reason: null, + targetTimeEntryId: target.id, + expectedTargetRevision: 7, + }), + ); + expect(targetsMock).toHaveBeenCalledWith(); + }); + + it('resets both values and occurrence choices when selecting another target', async () => { + const later = { + ...target, + id: 'later', + revision: 9, + clockIn: '2026-10-25T01:20:11.123Z', + clockOut: '2026-10-25T02:40:55.987Z', + }; + targetsMock.mockResolvedValue([target, later]); + const select = await openNewCorrection(); + await within(select).findByRole('option', { name: /entry-to-correct/ }); + fireEvent.change(select, { target: { value: target.id } }); + fireEvent.change(select, { target: { value: later.id } }); + expect( + (screen.getByLabelText('Von – Vorkommen') as HTMLSelectElement).value, + ).toBe(later.clockIn); + expect((screen.getByLabelText('Bis') as HTMLInputElement).value).toBe( + '2026-10-25T03:40:55.987', + ); + fireEvent.click(screen.getByRole('button', { name: 'Antrag stellen' })); + await waitFor(() => + expect(createRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + from: later.clockIn, + to: later.clockOut, + targetTimeEntryId: later.id, + expectedTargetRevision: 9, + }), + ), + ); + }); + + it('leaves missing-booking creation independent from target eligibility and omits target fields', async () => { + renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: 'Neuer Antrag' })); + fireEvent.change(screen.getByLabelText('Typ'), { + target: { value: 'TimeAdjustment' }, + }); + expect( + (screen.getByLabelText('Art der Zeitkorrektur') as HTMLSelectElement) + .value, + ).toBe('missing'); + fireEvent.change(screen.getByLabelText('Von'), { + target: { value: '2026-06-15T08:10:23.456' }, + }); + fireEvent.change(screen.getByLabelText('Bis'), { + target: { value: '2026-06-15T09:20:45.789' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Antrag stellen' })); + await waitFor(() => + expect(createRequestMock).toHaveBeenCalledWith({ + employeeId: 'requester', + type: 'TimeAdjustment', + from: '2026-06-15T06:10:23.456Z', + to: '2026-06-15T07:20:45.789Z', + reason: null, + }), + ); + expect(targetsMock).not.toHaveBeenCalled(); + }); + + it('shows target loading and does not permit correction submission while eligibility is unknown', async () => { + targetsMock.mockReturnValue(new Promise(() => undefined)); + await openNewCorrection(); + expect(screen.getByRole('status').textContent).toBe('Lädt …'); + expect( + (screen.getByLabelText('Zu korrigierende Buchung') as HTMLSelectElement) + .disabled, + ).toBe(true); + expect( + ( + screen.getByRole('button', { + name: 'Antrag stellen', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + }); + + it.each(['empty', 'error'] as const)( + 'shows actionable target loading outcomes and prevents untargeted correction: %s', + async (outcome) => { + if (outcome === 'error') + targetsMock.mockRejectedValue(new Error('Unavailable')); + await openNewCorrection(); + await screen.findByText( + outcome === 'empty' + ? 'Keine abgeschlossenen Team-Buchungen für eine Korrektur verfügbar.' + : 'Buchungen konnten nicht geladen werden.', + ); + if (outcome === 'error') + expect( + screen.getByRole('button', { name: 'Erneut laden' }), + ).toBeDefined(); + expect( + ( + screen.getByRole('button', { + name: 'Antrag stellen', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + expect(createRequestMock).not.toHaveBeenCalled(); + }, + ); + + it('retains immutable draft target/revision and before snapshot even with newer cached target data, and offers cancellation after a stale rejection', async () => { + const correction = { + ...draft, + type: 'TimeAdjustment', + from: target.clockIn, + to: target.clockOut, + targetTimeEntryId: target.id, + expectedTargetRevision: target.revision, + targetTimeEntryBefore: target, + }; + listRequestsMock.mockResolvedValue([correction]); + getRequestMock.mockResolvedValue(correction); + resubmitRequestMock.mockRejectedValue( + new Error('Target time entry has changed'), + ); + const { queryClient } = renderWithProviders(); + queryClient.setQueryData( + ['time-adjustment-targets'], + [{ ...target, revision: 8, clockIn: '2026-10-25T01:00:00.000Z' }], + ); + fireEvent.click( + await screen.findByRole('button', { + name: 'Entwurf · Antrag bearbeiten', + }), + ); + await screen.findByLabelText('Art der Zeitkorrektur'); + expect( + (screen.getByLabelText('Art der Zeitkorrektur') as HTMLSelectElement) + .disabled, + ).toBe(true); + expect( + (screen.getByLabelText('Zu korrigierende Buchung') as HTMLSelectElement) + .disabled, + ).toBe(true); + fireEvent.change(screen.getByLabelText('Begründung (optional)'), { + target: { value: 'Reason only' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Erneut einreichen' })); + await screen.findByText('Target time entry has changed'); + expect(resubmitRequestMock).toHaveBeenCalledWith( + draft.id, + expect.objectContaining({ + from: target.clockIn, + to: target.clockOut, + reason: 'Reason only', + targetTimeEntryId: target.id, + expectedTargetRevision: 7, + }), + ); + expect( + within(screen.getByRole('dialog')).getByText( + /Ziel und Revision dieses Entwurfs bleiben unverändert/, + ), + ).toBeDefined(); + expect(targetsMock).not.toHaveBeenCalled(); + expect(createRequestMock).not.toHaveBeenCalled(); + fireEvent.click( + screen.getByRole('button', { name: 'Diesen Antrag stornieren' }), + ); + await waitFor(() => + expect(cancelRequestMock).toHaveBeenCalledWith(draft.id, 'requester'), + ); + }); + + it('saves the persisted targeted draft revision with the unchanged exact proposal', async () => { + const correction = { + ...draft, + type: 'TimeAdjustment', + from: target.clockIn, + to: target.clockOut, + targetTimeEntryId: target.id, + expectedTargetRevision: 7, + targetTimeEntryBefore: target, + }; + listRequestsMock.mockResolvedValue([correction]); + getRequestMock.mockResolvedValue(correction); + renderWithProviders(); + fireEvent.click( + await screen.findByRole('button', { + name: 'Entwurf · Antrag bearbeiten', + }), + ); + await screen.findByLabelText('Von'); + fireEvent.click(screen.getByRole('button', { name: 'Entwurf speichern' })); + await waitFor(() => + expect(updateDraftRequestMock).toHaveBeenCalledWith( + draft.id, + expect.objectContaining({ + from: target.clockIn, + to: target.clockOut, + targetTimeEntryId: target.id, + expectedTargetRevision: 7, + }), + ), + ); + }); + + it('shows employee before/proposed and request-scoped applied audit after final approval', async () => { + listRequestsMock.mockResolvedValue([ + { + ...draft, + type: 'TimeAdjustment', + workflowState: 'Approved', + from: target.clockIn, + to: '2026-10-25T02:10:45.789Z', + targetTimeEntryId: target.id, + expectedTargetRevision: 7, + targetTimeEntryBefore: target, + }, + ]); + requestAuditMock.mockResolvedValue([ + { + id: 'audit', + requestId: draft.id, + timeEntryId: target.id, + action: 'FrameRequestCorrected', + before: target, + after: { ...target, clockOut: '2026-10-25T02:10:45.789Z' }, + occurredAt: '2026-10-26T12:00:00.000Z', + reason: 'Corrected', + }, + ]); + renderWithProviders(); + expect( + await screen.findByText( + 'Bruttodauer: 1.222,333 s → 4.822,333 s (Δ 3.600 s)', + ), + ).toBeDefined(); + fireEvent.click( + screen.getByRole('button', { name: 'Buchungsänderung im Verlauf' }), + ); + expect( + await screen.findByText( + 'Buchung entry-to-correct durch Antrag same-request korrigiert', + ), + ).toBeDefined(); + expect(requestAuditMock).toHaveBeenCalledWith(draft.id); + }); + + it('translates correction selection and exact before/proposed diff into English', async () => { + vi.spyOn(navigator, 'languages', 'get').mockReturnValue(['en-GB']); + targetsMock.mockResolvedValue([target]); + renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: 'New request' })); + fireEvent.change(screen.getByLabelText('Type'), { + target: { value: 'TimeAdjustment' }, + }); + fireEvent.change(screen.getByLabelText('Time adjustment mode'), { + target: { value: 'correction' }, + }); + const select = await screen.findByLabelText('Booking to correct'); + await within(select).findByRole('option', { name: /entry-to-correct/ }); + fireEvent.change(select, { target: { value: target.id } }); + expect(screen.getByText('Before:')).toBeDefined(); + expect(screen.getByText('Proposed:')).toBeDefined(); + expect( + screen.getByText('Gross duration: 1,222.333 s → 1,222.333 s (Δ 0 s)'), + ).toBeDefined(); + }); }); diff --git a/apps/web/src/routes/RequestsPage.tsx b/apps/web/src/routes/RequestsPage.tsx index 2a26a39..1c69d63 100644 --- a/apps/web/src/routes/RequestsPage.tsx +++ b/apps/web/src/routes/RequestsPage.tsx @@ -15,7 +15,12 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { api, type RequestDto, type RequestType } from '../api/client'; +import { + api, + type RequestDto, + type RequestType, + type TimeAdjustmentTargetDto, +} from '../api/client'; import { useCurrentUser } from '../app/auth'; import { useI18n } from '../app/i18n'; import { @@ -49,6 +54,22 @@ function formatRange( return `${f.toLocaleDateString(locale, { timeZone: 'UTC' })} – ${t.toLocaleDateString(locale, { timeZone: 'UTC' })}`; } +function correctionInstant(iso: string, locale: string): string { + // Supported by our browsers/Node 20, but absent from the app's older + // Intl library declarations. Keep the extension local, not a config change. + const options: Intl.DateTimeFormatOptions & { fractionalSecondDigits: 3 } = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3, + hourCycle: 'h23', + }; + return `${new Date(iso).toLocaleString(locale, options)} (${utcOffsetLabel(iso)})`; +} + function minutesBetween(fromIso: string, toIso: string): number { const ms = new Date(toIso).getTime() - new Date(fromIso).getTime(); return Math.max(0, Math.round(ms / 60_000)); @@ -61,6 +82,7 @@ export function RequestsPage() { const qc = useQueryClient(); const [dialogOpen, setDialogOpen] = useState(false); const [editingId, setEditingId] = useState(null); + const [historyId, setHistoryId] = useState(null); const requests = useQuery({ queryKey: ['requests', { employeeId }], @@ -156,6 +178,24 @@ export function RequestsPage() { )}
    + {r.type === 'TimeAdjustment' && ( +
    + + + {historyId === r.id && ( + + )} +
    + )} {r.type === 'SpecialLeave' && ( )} @@ -237,6 +277,10 @@ function useLocalDateTime(originalIso?: string) { candidates, iso, setChosenIso, + reset: (iso: string) => { + setValue(formatLocalDateTime(iso)); + setChosenIso(iso); + }, change: (next: string) => { setValue(next); setChosenIso(''); @@ -307,7 +351,7 @@ function LocalDateTimeField({ function NewRequestForm({ onClose, request }: NewRequestFormProps) { const user = useCurrentUser(); - const { t, enumLabel } = useI18n(); + const { t, enumLabel, languageTag } = useI18n(); const employeeId = user.id; const today = isoDateOnly(new Date()); const [type, setType] = useState(request?.type ?? 'Vacation'); @@ -319,6 +363,28 @@ function NewRequestForm({ onClose, request }: NewRequestFormProps) { const toTime = useLocalDateTime( request?.type === 'TimeAdjustment' ? request.to : undefined, ); + const [adjustmentMode, setAdjustmentMode] = useState( + request?.targetTimeEntryId ? 'correction' : 'missing', + ); + // Capture the selected version, not a pointer into a refetchable query. + // Draft requests always retain their immutable persisted ID and revision. + const [selectedTarget, setSelectedTarget] = + useState(null); + const targetId = request ? request.targetTimeEntryId : selectedTarget?.id; + const targetRevision = request + ? request.expectedTargetRevision + : selectedTarget?.revision; + const targetBefore = request ? request.targetTimeEntryBefore : selectedTarget; + const isCorrection = + type === 'TimeAdjustment' && adjustmentMode === 'correction'; + const targets = useQuery({ + queryKey: ['time-adjustment-targets', employeeId], + queryFn: () => api.timeAdjustmentTargets(), + enabled: isCorrection && !request, + }); + const missingTarget = + isCorrection && + (!targetId || targetRevision == null || !targetBefore?.clockOut); const [reason, setReason] = useState(request?.reason ?? ''); const [substituteId, setSubstituteId] = useState( request?.substituteId ?? '', @@ -375,6 +441,14 @@ function NewRequestForm({ onClose, request }: NewRequestFormProps) { const toIso = isTimeAdjustment ? toTime.iso : new Date(`${to}T00:00:00.000Z`).toISOString(); + if (missingTarget) throw new Error(t('requests.targetRequired')); + const targetFields = + isCorrection && targetId && targetRevision != null + ? { + targetTimeEntryId: targetId, + expectedTargetRevision: targetRevision, + } + : {}; if (!fromIso || !toIso || invalidRange) { throw new Error( t( @@ -387,6 +461,12 @@ function NewRequestForm({ onClose, request }: NewRequestFormProps) { if (request) { const payload = { expectedUpdatedAt: request.updatedAt, + ...(isTimeAdjustment + ? { + targetTimeEntryId: request.targetTimeEntryId ?? null, + expectedTargetRevision: request.expectedTargetRevision ?? null, + } + : {}), from: fromIso, to: toIso, reason: reason || null, @@ -410,6 +490,7 @@ function NewRequestForm({ onClose, request }: NewRequestFormProps) { }); } const created = await api.createRequest({ + ...targetFields, employeeId, type, from: fromIso, @@ -428,6 +509,16 @@ function NewRequestForm({ onClose, request }: NewRequestFormProps) { setError(e instanceof Error ? e.message : t('requests.submitFailed')), }); + const cancelStaleRequest = useMutation({ + mutationFn: () => { + if (!request) throw new Error(t('requests.loadFailed')); + return api.cancelRequest(request.id, user.id); + }, + onSuccess: onClose, + onError: (e) => + setError(e instanceof Error ? e.message : t('requests.submitFailed')), + }); + const insufficientVacation = type === 'Vacation' && balance.data !== undefined && @@ -477,6 +568,95 @@ function NewRequestForm({ onClose, request }: NewRequestFormProps) { ))}
    + {isTimeAdjustment && ( +
    + + + {isCorrection && ( + <> + + + {!request && targets.isPending && ( +

    {t('common.loading')}

    + )} + {!request && targets.isError && ( + + + {t('requests.targetsFailed')} + + + + )} + {!request && targets.isSuccess && targets.data.length === 0 && ( +

    {t('requests.targetsEmpty')}

    + )} + {missingTarget && ( +

    + {t('requests.targetRequired')} +

    + )} + + )} +
    + )}
    @@ -513,6 +693,23 @@ function NewRequestForm({ onClose, request }: NewRequestFormProps) { )}
    + {isTimeAdjustment && (!isCorrection || targetId) && ( + + )}
    - {error} + + {error} + {isCorrection && request && ( +
    +

    {t('requests.staleTargetHint')}

    + +
    + )} +
    )}
    @@ -631,6 +843,7 @@ function NewRequestForm({ onClose, request }: NewRequestFormProps) { disabled={ create.isPending || invalidRange || + missingTarget || request.workflowState !== 'Draft' } onClick={() => { @@ -646,6 +859,7 @@ function NewRequestForm({ onClose, request }: NewRequestFormProps) { create.isPending || insufficientVacation || invalidRange || + missingTarget || (!!request && request.workflowState !== 'Draft') } onClick={() => { @@ -700,3 +914,198 @@ function AttachmentsList({ requestId }: { requestId: string }) { ); } + +/** Always use the trusted request snapshot, never the latest target entry. */ +export function TimeAdjustmentDiff({ + request, +}: { + request: { + targetTimeEntryId?: string | null; + expectedTargetRevision?: number | null; + targetTimeEntryBefore?: { clockIn: string; clockOut: string } | null; + from: string | null; + to: string | null; + }; +}) { + const { t, languageTag } = useI18n(); + const before = request.targetTimeEntryBefore; + const valid = + !!request.from && + !!request.to && + new Date(request.to).getTime() > new Date(request.from).getTime(); + const seconds = (from: string, to: string) => + (new Date(to).getTime() - new Date(from).getTime()) / 1000; + const number = (n: number) => + new Intl.NumberFormat(languageTag, { + maximumFractionDigits: 3, + }).format(n); + const range = (from: string, to: string) => ( + <> + {' '} + →{' '} + + + ); + return ( +
    +

    + {t( + request.targetTimeEntryId + ? 'requests.correctBooking' + : 'requests.missingBooking', + )} +

    + {request.targetTimeEntryId && ( + <> +

    + {t('requests.targetReference', { + id: request.targetTimeEntryId, + revision: request.expectedTargetRevision ?? '—', + })} +

    +

    + {t('requests.correctionHint')} +

    + {before && ( +

    + {t('requests.before')}: + {range(before.clockIn, before.clockOut)} +

    + )} + + )} + {request.from && request.to && ( +

    + {t('requests.proposed')}: + {range(request.from, request.to)} +

    + )} + {valid && + request.from && + request.to && + (before ? ( +

    + {t('requests.grossDuration', { + before: number(seconds(before.clockIn, before.clockOut)), + after: number(seconds(request.from, request.to)), + delta: number( + seconds(request.from, request.to) - + seconds(before.clockIn, before.clockOut), + ), + })} +

    + ) : ( +

    + {t('requests.grossProposed', { + duration: number(seconds(request.from, request.to)), + })} +

    + ))} +
    + ); +} + +export function RequestTimeEntryAudit({ requestId }: { requestId: string }) { + const { id: actorId } = useCurrentUser(); + const { t, formatDateTime } = useI18n(); + const audit = useQuery({ + queryKey: ['request-time-entry-audit', requestId, actorId], + queryFn: () => api.requestTimeEntryAudit(requestId), + }); + return ( +
    + {audit.isPending &&

    {t('common.loading')}

    } + {audit.isError && ( + + {t('requests.auditFailed')} + + + )} + {audit.isSuccess && audit.data.length === 0 && ( +

    {t('requests.auditEmpty')}

    + )} + {audit.data && ( +
      + {audit.data.map((event) => { + const before = auditRange(event.before); + const after = auditRange(event.after); + return ( +
    1. +

      + {t( + event.action === 'RequestMissingBookingAdded' + ? 'requests.auditMissingAdded' + : 'requests.auditApplied', + { + id: event.timeEntryId, + requestId: event.requestId ?? requestId, + }, + )} +

      +

      + {t('requests.appliedBy', { + actor: + event.actorName ?? + event.actorId ?? + t('requests.unknownActor'), + })} +

      + + {after && ( + + )} + {event.reason &&

      {event.reason}

      } +
    2. + ); + })} +
    + )} +
    + ); +} + +function auditRange( + value: unknown, +): { clockIn: string; clockOut: string; revision?: number } | null { + if (!value || typeof value !== 'object') return null; + const snapshot = value as Record; + return typeof snapshot.clockIn === 'string' && + typeof snapshot.clockOut === 'string' + ? { + clockIn: snapshot.clockIn, + clockOut: snapshot.clockOut, + revision: + typeof snapshot.revision === 'number' ? snapshot.revision : undefined, + } + : null; +} diff --git a/docs/ISSUE_FIXES_MANUAL_TEST.de.md b/docs/ISSUE_FIXES_MANUAL_TEST.de.md index 8a66cb0..43f2f86 100644 --- a/docs/ISSUE_FIXES_MANUAL_TEST.de.md +++ b/docs/ISSUE_FIXES_MANUAL_TEST.de.md @@ -536,3 +536,262 @@ TEST_WEB_PORT=18041 docker compose --env-file /dev/null \ Kein `down -v`, kein erneutes Seeden beim Fortsetzen. #43, #45 und #44 sind noch nicht Bestandteil dieses Pakets. + +## Paket #43 – bestehende Zeitbuchung gezielt korrigieren + +### Branch, Abhängigkeiten und isolierter Teststack + +- Branch: `fix/43-existing-entry-correction`. +- Worktree: `/Users/patrickschiller/openclockwork-issue-43`. +- Basis: akzeptierter #41-Head `dbe68f57a93ba26bdc315d31fd4b313586a4337b`. +- Kumulativ enthält der neue PR **#42 + #41 + #43**, Ziel weiterhin `main` + für sämtliche CI-Jobs. Abhängigkeit und vorgesehene Reihenfolge: + **PR #46 → PR #47 → #43-PR**. Keine dieser Änderungen wird hier gemergt. +- Additive Migration `20261107040000_targeted_time_corrections`: optionale + Ziel-ID/Originalrevision/Originalsnapshot und ein Request-Link im Buchungsaudit. + Bestehende fehlende-Buchung-Anträge bleiben ohne erfundene Zielbindung. + +Weiterhin [LOCAL_DOCKER_TEST.de.md](LOCAL_DOCKER_TEST.de.md) verwenden, aber +separates Compose-Projekt, Images und Port. Keine vorhandenen Volumes löschen: + +```bash +git fetch origin +git worktree add ../openclockwork-43-manual origin/fix/43-existing-entry-correction +cd ../openclockwork-43-manual + +cat > /tmp/openclockwork-43-compose.yml <<'YAML' +services: + api: + image: openclockwork-api:issue43-local + web: + image: openclockwork-web:issue43-local +YAML + +TEST_WEB_PORT=18043 docker compose --env-file /dev/null \ + -p openclockwork-issue43 -f docker-compose.test.yml \ + -f /tmp/openclockwork-43-compose.yml build + +TEST_WEB_PORT=18043 docker compose --env-file /dev/null \ + -p openclockwork-issue43 -f docker-compose.test.yml \ + -f /tmp/openclockwork-43-compose.yml up -d --wait + +TEST_WEB_PORT=18043 docker compose --env-file /dev/null \ + -p openclockwork-issue43 -f docker-compose.test.yml \ + -f /tmp/openclockwork-43-compose.yml exec -T \ + -e NODE_ENV=development \ + -e OPENCLOCKWORK_SEED_CONFIRM_DATABASE=openclockwork_test \ + api node --import tsx prisma/seed.ts +``` + +Seed nur einmal auf diesem **leeren synthetischen Stack** ausführen. +Oberfläche: ; Server und Browser für die Beispiele auf +Europe/Berlin stellen. Synthetische Rollen aus den vorigen Paketen: +Anna beantragt, Marc ist zugewiesener Manager, Hannah bestätigt als HR, +Bernd prüft Fremdzugriff. Lokales initiales Seed-Passwort: `openclockwork`. +Keine echten Daten oder Tokens verwenden. + +### A. Reproduzierbare bestehende 08:00–16:00-Buchungen + +Zwei unterschiedliche, freie Tage verwenden, damit beide ursprünglichen +Issue-Beispiele separat durchspielbar sind. Die relative Seed-Zeiterfassung liegt +nahe dem Ausführungsdatum; vorab prüfen, dass die folgenden historischen Tage +frei sind. Bei Konflikt einen anderen freien Tag wählen, keine bestehenden +Buchungen löschen. + +```bash +TEST_WEB_PORT=18043 docker compose --env-file /dev/null \ + -p openclockwork-issue43 -f docker-compose.test.yml \ + -f /tmp/openclockwork-43-compose.yml exec -T db \ + psql -U openclockwork -d openclockwork_test -v ON_ERROR_STOP=1 <<'SQL' +INSERT INTO "TimeEntry" ( + "id", "employeeId", "clockIn", "clockOut", "source", "status", + "approvalMode", "breakRules", "note", "updatedAt" +) +SELECT fixture.id::uuid, employee.id, fixture.start_at::timestamptz, + fixture.end_at::timestamptz, 'Pwa', 'Approved', 'Team', + '[{"afterMinutes":360,"breakMinutes":30},{"afterMinutes":540,"breakMinutes":45}]'::jsonb, + 'Synthetische Originalbuchung #43', CURRENT_TIMESTAMP +FROM "Employee" employee +CROSS JOIN (VALUES + ('00000000-0000-4000-8000-000000004301', '2026-10-12T06:00:00Z', '2026-10-12T14:00:00Z'), + ('00000000-0000-4000-8000-000000004302', '2026-10-13T06:00:00Z', '2026-10-13T14:00:00Z') +) AS fixture(id, start_at, end_at) +WHERE employee.email = 'anna.mueller@openclockwork.test' +ON CONFLICT ("id") DO NOTHING; +SQL +``` + +1. Als Anna die Buchungen unter **Buchen** ansehen, IDs, Zeiten, Anzahl und + Saldo notieren. In den Oktoberbeispielen entsprechen 06:00Z–14:00Z lokal + 08:00–16:00. Mit dem gespeicherten Pausenschema sind das 480 Minuten brutto, + 30 Minuten Pause, 450 Minuten netto. +2. **Anträge → Neuer Antrag → Zeitkorrektur** öffnen. Explizit + **bestehende Buchung korrigieren** statt **fehlende Buchung** wählen. + Eine eigene vorhandene Buchung auswählen. Erwartet: Zeiten exakt vorbelegt, + Original-ID und Originalrevision gebunden, verständliche + **Vorher → Vorgeschlagen**-Gegenüberstellung. +3. Erste Buchung von **08:00–16:00 auf 08:00–17:00** ändern und einreichen. + Bis zur finalen Genehmigung bleibt die Originalbuchung unverändert. + Marc sieht Original-/Vorschlagswerte im Genehmigungskontext. +4. Als Marc innerhalb des normalen Rahmens genehmigen. Erwartet: **dieselbe + Buchungs-ID**, keine zusätzliche Buchung, Revision um eins erhöht. + Projekt/Auftrag, Tätigkeit, Notiz, Abrechenbarkeit und ursprüngliche + Pausenregel bleiben erhalten. Bei diesem Pausenschema: 540 Minuten brutto, + 45 Pause, 495 netto; Saldoänderung **+45**, nicht eine doppelte 9-Stunden-Buchung. +5. Zweite Buchung separat auf **09:00–16:00** korrigieren und genehmigen. + Erwartet: gleiche ID, 420 brutto, 30 Pause, 390 netto; Delta **−60**. + Die erste Korrektur und alle anderen Tagesbuchungen bleiben unberührt. +6. Im Antrag/Verlauf stehen ursprünglicher und vorgeschlagener Stand, tatsächliche + Entscheidung und verknüpftes Buchungsaudit. Das neue Audit enthält echten + Akteur, Zeitpunkt, Vorher/Nachher und Request-ID; alte Auditereignisse bleiben. +7. Die Auswahl einer **fehlenden Buchung** separat in einem wirklich freien + Intervall testen: kein Ziel erforderlich, nach endgültiger Freigabe entsteht + genau eine neue Buchung wie bisher. + +### B. Außenrahmen, Draft, Version und Datenschutz + +1. Einen freien Vorschlag außerhalb Annas konfigurierten Rahmens wählen. + Marc genehmigt zunächst nur den Antrag: **PendingHr**; die Buchung bleibt + bis zu Hannahs Bestätigung unverändert. Erst HR aktualisiert dieselbe ID. + Weder vorhandene alte #41-Genehmigungen noch eine deaktivierte HR-Checkbox + dürfen die Prüfung der **neuen Werte** ersetzen. +2. Marc gibt einen gezielten Korrekturantrag zurück. Anna öffnet den Entwurf: + Vorschlag, ursprünglicher Vergleich, Ziel-ID und Originalrevision bleiben + erhalten. Nur Grund ändern, speichern, wieder öffnen und erneut einreichen. + Ziel und gebundene Revision dürfen sich dabei nicht still auf einen + inzwischen neuen Serverstand verschieben. +3. Das Ziel in einem anderen Tab nachträglich ändern/aufteilen oder über #41 + entscheiden, sodass seine Revision steigt. Alter Korrekturantrag: + **409**, keine Änderung an Buchung, Antrag oder Audit durch diesen Versuch. + Aktuellen Stand prüfen, alten Antrag stornieren und einen neuen mit der + aktuellen Zielversion anlegen. Ziel/Originalrevision sind absichtlich auch + im Entwurf unveränderlich. +4. Mit Bernds Token Annas Ziel-ID verwenden; oder `employeeId` im Body fälschen: + kein Zugriff. Auswahl liefert ausschließlich eigene berechtigte Ziele. + Offene, verworfene, abgelehnte und Solo-Buchungen sind keine zulässigen Ziele. +5. In einem getrennten Solo-Teststack die bestehenden direkten Owner-Korrekturen + prüfen: unverändert. Team-Anträge erlauben keinen Umweg an den Solo-Owner- + Berechtigungen vorbei. + +### C. API-Negativ-/Konkurrenzfälle + +Nur im isolierten Teststack aus den Browser-Netzwerkdetails wiederholen. +Keine echten Bearer-Token in Dokumente/Issues kopieren. + +`GET /api/requests/time-adjustment-targets` liefert berechtigte eigene Ziele +einschließlich `revision`. Gezielter Antrag, z. B. für das erste Oktoberbeispiel: + +```json +{ + "employeeId": "", + "type": "TimeAdjustment", + "from": "2026-10-12T06:00:00.000Z", + "to": "2026-10-12T15:00:00.000Z", + "targetTimeEntryId": "00000000-0000-4000-8000-000000004301", + "expectedTargetRevision": 0, + "reason": "Synthetischer Nachtest #43" +} +``` + +Die aktuelle Zielrevision verwenden, nicht blind die Beispiel-Null. Der Server +erfasst den Originalsnapshot selbst; ein mitgesendeter gefälschter +`targetTimeEntryBefore` ersetzt ihn nicht. Draft-Speichern/Wiedereinreichen aus +#42 erwartet weiterhin `expectedUpdatedAt`; Ziel und Zielrevision bleiben +gleich. Buchungsaudit zum Antrag: +`GET /api/requests/:id/time-entry-audit`, nur mit Antrags-Leseberechtigung. + +| Fall | Erwartung | +| --- | --- | +| Kein Login | 401 | +| Fremde Ziel-ID / gefälschte Mitarbeiteridentität | 403; keine Buchungsänderung | +| Offenes, verworfenes, abgelehntes oder Solo-Ziel | 409; nicht auswählbar | +| Ziel-ID ohne Revision oder umgekehrt / negative Revision | 400 | +| Zielbindung bei Urlaub/Homeoffice/Sonderurlaub | 400 | +| Anderes Ziel oder andere Originalrevision im Draft unterschieben | 400; kein Retargeting unter alter Genehmigung | +| Nur unveränderte eigene Zielbuchung überlappt | Zulässig; exakt diese ID ist ausgenommen | +| Andere Zeitbuchung desselben Tages überlappt den Vorschlag | 409; kein pauschales Tagesüberschreiben | +| Ziel fehlt, aber Vorschlag überlappt bestehende Buchung | Weiterhin 409 | +| Zielrevision zwischen Antrag und finaler Entscheidung verändert | 409; Antrag bleibt ausstehend, Ziel und Audit unverändert | +| HR vor erforderlicher Managerfreigabe / Selbstfreigabe / teamfremder Manager | Keine Materialisierung; 403/409 je nach Rolle/Stufe | +| Managerzuordnung vor finaler HR-Bestätigung geändert | Keine Nutzung der alten Managerfreigabe | +| Zweimal denselben Antrag genehmigen | Ein Erfolg, danach 403/409; eine Buchungsänderung/ein Anwendungsaudit | +| Bereits angewendeten gezielten Korrekturantrag stornieren | 409; Rückänderung nur durch einen neuen geprüften Korrekturantrag, nicht durch irreführendes Request-Storno | +| Zwei verschiedene, nicht überlappende Vorschläge für dieselbe Originalrevision parallel final genehmigen | Genau ein Ziel-Update; anderer Antrag 409, nicht still überschrieben | +| Parallel Clock-/Split-/Metadaten-/#41-Entscheidung | Gemeinsame Sperr-/Revisionsregeln; keine verlorene Änderung | +| Fehler während finaler Anwendung/Audit | Antrag, Request-Ereignis, Buchungsänderung und Buchungsaudit gemeinsam zurückgerollt | + +### D. Sekunden, DST, Pausen und Nachvollziehbarkeit + +Die #42-Tests aus Abschnitt D weiterhin ausführen. Zusätzlich bei einer gezielten +Korrektur eine eigene gültige Originalbuchung mit Sekunden/Millisekunden und +Herbst-Zeitumstellung verwenden: reine Grundänderung behält Zielrevision und +ISO-Zeitpunkte exakt; Vorher/Vorschlag zeigen die unterschiedlichen Instants. +Beide Vorkommen der Ortszeit müssen korrekt bleiben; geänderte mehrdeutige +Ortszeiten verlangen weiterhin eine ausdrückliche Auswahl. Ein gültiges +rückstellungsübergreifendes Intervall darf nicht wegen lokal früherem Ende +abgelehnt werden. + +Pausen werden anhand der gespeicherten Buchungs-/Capture-Group-Regel neu +berechnet, nicht durch Überschreiben mit dem aktuellen Standardplan. +Andere Gruppenmitglieder/Projekte/Aufträge/Originalaudits bleiben erhalten. +DE/EN und Tab/Enter auf Moduswahl, Zielauswahl, Vergleich und Aktionen prüfen. +Der tatsächliche manuelle Browserdurchlauf wird nicht allein aufgrund dieser +Anleitung als bestanden gewertet. + +### Automatische Verifikation #43 + +Ausgeführt am 07.11.2026 mit pnpm 9.15.9. Die neuen API-/UI-Regressionen +prüfen reale Services bzw. React Testing Library; die vollständigen Suites +wurden zusätzlich unter **Node 20.20.2** (CI-Version) ausgeführt. Sonstige lokale +Build-/Typgates: Node 26.12.1. + +Isolierter PostgreSQL-16-Container `openclockwork-issue-43-db`, +Loopback-Port `15443`, neue synthetische Datenbanken +`openclockwork_test_43` und `openclockwork_upgrade_43`. Keine vorhandene +Datenbank zurückgesetzt. + +| Gate | Tatsächliches Ergebnis | +| --- | --- | +| `pnpm nx e2e api-e2e --testPathPatterns=targeted-time-corrections` mit `TZ=Europe/Berlin` | **35/35 neue API-/Service-Fälle bestanden** | +| `TZ=Europe/Berlin pnpm nx e2e api-e2e` unter Node 20.20.2, einschließlich finaler Stornosicherung | **366/366 Tests, 39 Suites bestanden** | +| `TZ=UTC pnpm nx test web --run src/routes/RequestsPage.spec.tsx src/routes/AdminRequestsPage.spec.tsx src/components/timeentries/TimeEntryApprovals.spec.tsx src/app/i18n.spec.tsx src/lib/local-date-time.spec.ts` | **68/68 gezielte UI-/DST-Tests bestanden** | +| `TZ=UTC pnpm nx run-many -t test -p web,shared --run --skip-nx-cache` unter Node 20.20.2 | **Web 247/247 in 35 Dateien**, **Shared 88/88 in 7 Dateien bestanden**, ohne Cache | +| `pnpm nx run-many -t typecheck,lint -p api,web,shared,api-e2e` | **Alle vier Typechecks und Lint erfolgreich**; 0 Fehler, 13 bestehende Warnungen in unveränderten Dateien, keine neue Warnung | +| `pnpm nx run-many -t build -p api,web,shared`, erst nach abgeschlossenen Typechecks | **Alle drei Builds erfolgreich**, einschließlich Web/PWA; unveränderter Shared-Build aus Nx-Cache | +| `pnpm verify:api` mit isolierter Test-`DATABASE_URL` | **Erfolgreich**; finale OpenAPI-/Client-Dateien identisch zur gestagten Fassung | +| Akzeptiertes #41-Schema → erweiterte Upgrade-Sentinels → neue Migration → `node ops/upgrade-smoke.mjs verify` | **Erfolgreich**; alter Missing-Booking-Antrag bleibt ohne Ziel/Revision/Snapshot, bestehendes Buchungsaudit behält Akteur und Vorher/Nachher ohne erfundenen Request-Link | +| `git diff --check` | **Erfolgreich** | + +Die vollständigen API-Tests umfassen #42-Draft/ABA-/Urlaubsregeln, #41-Freigaben, +Solo-/Terminal-/Split-/Bereichs-/Tagesblock-/Report-Flows. Die 35 neuen Fälle +belegen insbesondere Same-ID und stabile Anzahl für beide Issue-Beispiele, +Netto-/Saldo-Delta, Projekt-/Auftrags-/Pausen-/Capture-Snapshots, ausschließliche +Zielausnahme, Rollen/Eigentümer/Modus, unveränderliche Draft-Bindung, +stale/parallele Entscheidungen und echten Rollback nach bereits ausgeführtem +Buchungsupdate in sämtlichen finalen Approval-Routen. Reine Grundänderungen +behalten Originalversion und Sekunden/DST-Instants. Bereits angewendete gezielte +Korrekturen lassen sich nicht durch bloßes Request-Storno irreführend rückgängig +machen. + +Die anfänglichen neuen Tests wurden an zwei bestehende Tatsachen angepasst: +Fixtures dürfen die PostgreSQL-Overlap-Constraint nicht verletzen; ungruppierte +Team-Summaries runden weiterhin auf ganze Minuten, während gespeicherte +ISO-Zeitpunkte unverändert millisekundengenau bleiben. Die Reviewer-UI-Testzone +ist explizit Europe/Berlin, unabhängig vom Host. Eine lokale Intl-Typerweiterung +ermöglicht die bereits von Browsern/Node 20 unterstützte Millisekundenanzeige, +ohne Projektkonfiguration oder Fachrundung zu ändern. + +Der ausführliche manuelle Docker-/Browser-Nachtest oben wurde **nicht** +als durchgeführt oder bestanden behauptet. Keine Releases, Tags, Versionsbumps, +Merges, Deployments oder Release-Workflows. + +Zum Stoppen mit Datenerhalt: + +```bash +TEST_WEB_PORT=18043 docker compose --env-file /dev/null \ + -p openclockwork-issue43 -f docker-compose.test.yml \ + -f /tmp/openclockwork-43-compose.yml stop +``` + +Kein `down -v`, kein erneutes Seeden beim Fortsetzen. #45 und #44 folgen erst +nach unabhängiger Abnahme; hier noch nicht begonnen. diff --git a/ops/upgrade-smoke.mjs b/ops/upgrade-smoke.mjs index 5a99435..d002cc3 100644 --- a/ops/upgrade-smoke.mjs +++ b/ops/upgrade-smoke.mjs @@ -14,6 +14,8 @@ if (!databaseUrl || !['seed', 'verify'].includes(mode)) { const employeeId = '00000000-0000-4000-8000-000000000014'; const timeEntryId = '00000000-0000-4000-8000-000000000114'; +const correctionRequestId = '00000000-0000-4000-8000-000000000143'; +const legacyAuditId = '00000000-0000-4000-8000-000000000243'; const pendingEntryId = '00000000-0000-4000-8000-000000000115'; const scheduleId = '00000000-0000-4000-8000-000000000214'; const terminalId = '00000000-0000-4000-8000-000000000314'; @@ -120,12 +122,66 @@ try { TIMESTAMP '2026-01-05 08:00:00', 'Pwa', 'Pending', true, 7, NULL, CURRENT_TIMESTAMP)`, [pendingEntryId, fallbackEmployeeId], ); + // Missing-booking requests and existing audit payloads predate target + // binding and must not acquire invented target/revision/request links. + await client.query( + `INSERT INTO "Request" ( + "id", "employeeId", "type", "status", "workflowState", "from", "to", "updatedAt" + ) VALUES ($1, $2, 'TimeAdjustment', 'Submitted', 'PendingManager', + TIMESTAMP '2026-01-06 08:00:00', TIMESTAMP '2026-01-06 16:00:00', CURRENT_TIMESTAMP)`, + [correctionRequestId, fallbackEmployeeId], + ); + await client.query( + `INSERT INTO "TimeEntryAudit" ("id", "timeEntryId", "actorId", "action", "before", "after", "reason") + VALUES ($1, $2, $3, 'UpgradeSentinel', '{"revision":0}'::jsonb, '{"revision":1}'::jsonb, 'Synthetic retained audit')`, + [legacyAuditId, timeEntryId, employeeId], + ); await client.query('COMMIT'); console.log( 'Inserted upgrade sentinels for employees, policies, time entry, and terminal.', ); } else { + const legacyCorrection = await client.query( + `SELECT "targetTimeEntryId", "expectedTargetRevision", "targetTimeEntryBefore", + "workflowState"::text, "type"::text, + "from" = TIMESTAMP '2026-01-06 08:00:00' AS "fromPreserved" + FROM "Request" WHERE "id" = $1`, + [correctionRequestId], + ); + assert.deepEqual( + legacyCorrection.rows, + [ + { + targetTimeEntryId: null, + expectedTargetRevision: null, + targetTimeEntryBefore: null, + workflowState: 'PendingManager', + type: 'TimeAdjustment', + fromPreserved: true, + }, + ], + 'Legacy missing-booking requests must remain targetless.', + ); + const legacyAudit = await client.query( + `SELECT "requestId", "action", "before", "after", "reason", "actorId" + FROM "TimeEntryAudit" WHERE "id" = $1`, + [legacyAuditId], + ); + assert.deepEqual( + legacyAudit.rows, + [ + { + requestId: null, + action: 'UpgradeSentinel', + before: { revision: 0 }, + after: { revision: 1 }, + reason: 'Synthetic retained audit', + actorId: employeeId, + }, + ], + 'Existing audits must retain values and actor without inventing a request link.', + ); const pending = await client.query( `SELECT "status"::text, "requiresApproval", "revision", "approvalMode", "frameApproverId", "frameApprovedRevision", diff --git a/prisma/migrations/20261107040000_targeted_time_corrections/migration.sql b/prisma/migrations/20261107040000_targeted_time_corrections/migration.sql new file mode 100644 index 0000000..0b147de --- /dev/null +++ b/prisma/migrations/20261107040000_targeted_time_corrections/migration.sql @@ -0,0 +1,18 @@ +-- Null target retains existing missing-booking requests, without fabricating +-- a previous revision or snapshot for historical records. +ALTER TABLE "Request" + ADD COLUMN "targetTimeEntryId" UUID, + ADD COLUMN "expectedTargetRevision" INTEGER, + ADD COLUMN "targetTimeEntryBefore" JSONB; +ALTER TABLE "Request" ADD CONSTRAINT "Request_targetTimeEntryId_fkey" + FOREIGN KEY ("targetTimeEntryId") REFERENCES "TimeEntry"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Request" ADD CONSTRAINT "Request_correction_target_complete" + CHECK ( + ("targetTimeEntryId" IS NULL AND "expectedTargetRevision" IS NULL AND "targetTimeEntryBefore" IS NULL) + OR ("type" = 'TimeAdjustment' AND "targetTimeEntryId" IS NOT NULL + AND "expectedTargetRevision" IS NOT NULL AND "expectedTargetRevision" >= 0 AND "targetTimeEntryBefore" IS NOT NULL) + ); +ALTER TABLE "TimeEntryAudit" ADD COLUMN "requestId" UUID; +ALTER TABLE "TimeEntryAudit" ADD CONSTRAINT "TimeEntryAudit_requestId_fkey" + FOREIGN KEY ("requestId") REFERENCES "Request"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +CREATE INDEX "TimeEntryAudit_requestId_idx" ON "TimeEntryAudit"("requestId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f797c96..02e1667 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -217,6 +217,7 @@ model TimeEntry { /// Historical null values retain Team approval semantics. approvalMode String? auditEvents TimeEntryAudit[] + correctionRequests Request[] @relation("CorrectionTarget") /// Policy snapshot captured at booking; later schedule changes do not rewrite history. breakRules Json @default("[]") latitude Decimal? @db.Decimal(9, 6) @@ -381,6 +382,11 @@ model Request { to DateTime reason String? requiresApproval Boolean @default(false) + targetTimeEntryId String? @db.Uuid + targetTimeEntry TimeEntry? @relation("CorrectionTarget", fields: [targetTimeEntryId], references: [id], onDelete: Restrict) + expectedTargetRevision Int? + targetTimeEntryBefore Json? + timeEntryAudits TimeEntryAudit[] calculatedDays Decimal @default(0) @db.Decimal(6, 2) halfDayStart Boolean @default(false) halfDayEnd Boolean @default(false) @@ -586,6 +592,8 @@ model Customer { } model TimeEntryAudit { + requestId String? @db.Uuid + request Request? @relation(fields: [requestId], references: [id], onDelete: Restrict) id String @id @default(uuid()) @db.Uuid timeEntryId String @db.Uuid timeEntry TimeEntry @relation(fields: [timeEntryId], references: [id], onDelete: Restrict) @@ -598,6 +606,7 @@ model TimeEntryAudit { occurredAt DateTime @default(now()) @@index([timeEntryId, occurredAt]) + @@index([requestId]) } model PersonalDay { From 53f961164245db69ac91912d3f123961ce846dd8 Mon Sep 17 00:00:00 2001 From: Patrick Schiller <39913537+patrickschiller@users.noreply.github.com> Date: Sat, 7 Nov 2026 04:15:19 +0100 Subject: [PATCH 6/7] docs: clarify JWT identity expectations for correction tests (#43) Distinguish forbidden foreign targets from ignored body employee identities; eligible own targets remain valid under the authenticated identity. Co-authored-by: patrickschiller <39913537+patrickschiller@users.noreply.github.com> Signed-off-by: Patrick Schiller <39913537+patrickschiller@users.noreply.github.com> --- docs/ISSUE_FIXES_MANUAL_TEST.de.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/ISSUE_FIXES_MANUAL_TEST.de.md b/docs/ISSUE_FIXES_MANUAL_TEST.de.md index 43f2f86..b68b328 100644 --- a/docs/ISSUE_FIXES_MANUAL_TEST.de.md +++ b/docs/ISSUE_FIXES_MANUAL_TEST.de.md @@ -666,8 +666,11 @@ SQL Aktuellen Stand prüfen, alten Antrag stornieren und einen neuen mit der aktuellen Zielversion anlegen. Ziel/Originalrevision sind absichtlich auch im Entwurf unveränderlich. -4. Mit Bernds Token Annas Ziel-ID verwenden; oder `employeeId` im Body fälschen: - kein Zugriff. Auswahl liefert ausschließlich eigene berechtigte Ziele. +4. Mit Bernds Token Annas fremde Ziel-ID verwenden: **403**, kein Zugriff. + Ein gefälschtes `employeeId` im Body wird hingegen ignoriert: Maßgeblich ist + ausschließlich die JWT-Identität. Mit einem zulässigen eigenen Ziel kann der + Antrag deshalb erfolgreich sein; er gehört weiterhin der angemeldeten Person. + Auswahl liefert ausschließlich eigene berechtigte Ziele. Offene, verworfene, abgelehnte und Solo-Buchungen sind keine zulässigen Ziele. 5. In einem getrennten Solo-Teststack die bestehenden direkten Owner-Korrekturen prüfen: unverändert. Team-Anträge erlauben keinen Umweg an den Solo-Owner- @@ -703,7 +706,8 @@ gleich. Buchungsaudit zum Antrag: | Fall | Erwartung | | --- | --- | | Kein Login | 401 | -| Fremde Ziel-ID / gefälschte Mitarbeiteridentität | 403; keine Buchungsänderung | +| Fremde Ziel-ID | 403; keine Buchungsänderung | +| Gefälschtes `employeeId` im Body | Ignoriert; JWT-Identität ist maßgeblich. Mit zulässigem eigenem Ziel kann der Antrag erfolgreich sein, bleibt aber der angemeldeten Person zugeordnet | | Offenes, verworfenes, abgelehntes oder Solo-Ziel | 409; nicht auswählbar | | Ziel-ID ohne Revision oder umgekehrt / negative Revision | 400 | | Zielbindung bei Urlaub/Homeoffice/Sonderurlaub | 400 | From 3a2361d0d2d25f23ce030ce751cf67873aedbf8f Mon Sep 17 00:00:00 2001 From: Patrick Schiller <39913537+patrickschiller@users.noreply.github.com> Date: Sat, 7 Nov 2026 04:47:23 +0100 Subject: [PATCH 7/7] feat: compare contractual and actual working time by confirmed period (#45) Add HR-confirmed immutable contract snapshots, scoped period calculations, accessible DE/EN reports and complete regression/upgrade evidence. Co-authored-by: patrickschiller <39913537+patrickschiller@users.noreply.github.com> Signed-off-by: Patrick Schiller <39913537+patrickschiller@users.noreply.github.com> --- .../src/api/contract-periods.e2e.spec.ts | 396 +++++++++ .../api/international-policies.e2e.spec.ts | 14 +- .../api/working-time-comparisons.e2e.spec.ts | 784 ++++++++++++++++++ .../src/api/working-time-reports.e2e.spec.ts | 23 +- apps/api-e2e/src/support/test-app.ts | 7 +- apps/api/openapi.json | 375 ++++++++- .../employees/contract-periods.controller.ts | 51 ++ .../src/app/employees/contract-periods.dto.ts | 129 +++ .../app/employees/contract-periods.service.ts | 236 ++++++ .../api/src/app/employees/employees.module.ts | 6 +- .../api/src/app/reports/reports.controller.ts | 14 +- apps/api/src/app/reports/reports.dto.ts | 63 +- apps/api/src/app/reports/reports.service.ts | 300 +++++-- .../app/reports/working-time-comparison.ts | 132 +++ apps/web/src/api/client.ts | 71 ++ apps/web/src/api/generated.ts | 183 +++- apps/web/src/app/app.tsx | 10 +- apps/web/src/app/i18n.tsx | 3 + apps/web/src/app/navigation.spec.ts | 7 +- apps/web/src/app/navigation.ts | 2 +- apps/web/src/app/working-time-i18n.ts | 171 ++++ .../web/src/app/working-time-routing.spec.tsx | 46 + .../src/routes/AdminEmployeesPage.spec.tsx | 20 +- apps/web/src/routes/AdminEmployeesPage.tsx | 4 + .../src/routes/AdminWorkingTimesPage.spec.tsx | 295 ++++++- apps/web/src/routes/AdminWorkingTimesPage.tsx | 280 ++++++- .../src/routes/ContractPeriodsPanel.spec.tsx | 469 +++++++++++ apps/web/src/routes/ContractPeriodsPanel.tsx | 555 +++++++++++++ docs/ISSUE_FIXES_MANUAL_TEST.de.md | 276 ++++++ ops/upgrade-smoke.mjs | 18 + .../migration.sql | 45 + prisma/schema.prisma | 44 + prisma/seed.ts | 42 + 33 files changed, 4948 insertions(+), 123 deletions(-) create mode 100644 apps/api-e2e/src/api/contract-periods.e2e.spec.ts create mode 100644 apps/api-e2e/src/api/working-time-comparisons.e2e.spec.ts create mode 100644 apps/api/src/app/employees/contract-periods.controller.ts create mode 100644 apps/api/src/app/employees/contract-periods.dto.ts create mode 100644 apps/api/src/app/employees/contract-periods.service.ts create mode 100644 apps/api/src/app/reports/working-time-comparison.ts create mode 100644 apps/web/src/app/working-time-i18n.ts create mode 100644 apps/web/src/app/working-time-routing.spec.tsx create mode 100644 apps/web/src/routes/ContractPeriodsPanel.spec.tsx create mode 100644 apps/web/src/routes/ContractPeriodsPanel.tsx create mode 100644 prisma/migrations/20261107050000_employee_contract_periods/migration.sql diff --git a/apps/api-e2e/src/api/contract-periods.e2e.spec.ts b/apps/api-e2e/src/api/contract-periods.e2e.spec.ts new file mode 100644 index 0000000..ef6061b --- /dev/null +++ b/apps/api-e2e/src/api/contract-periods.e2e.spec.ts @@ -0,0 +1,396 @@ +import { ContractPeriodsService } from '../../../api/src/app/employees/contract-periods.service'; +import type { ConfirmContractPeriodDto } from '../../../api/src/app/employees/contract-periods.dto'; +import { JwtService } from '@nestjs/jwt'; +import { + createTestApp, + login, + seedEmployee, + type TestContext, +} from '../support/test-app'; + +const facts = { + effectiveFrom: '2026-01-01', + effectiveTo: null, + employmentStart: '2020-01-01', + employmentEnd: null, + weeklyHours: 40, + timeModel: 'Vollzeit', + workingDays: 31, + holidayCalendar: 'NONE', + holidayDates: ['2026-05-04'], + scheduleLabel: 'Synthetic Mon–Fri', + confirmationNote: 'Synthetic known contract, confirmed by HR', + confirmed: true, +}; +describe('HR-confirmed contract history (#45)', () => { + let ctx: TestContext; + let hr: Awaited>; + let employee: Awaited>; + let manager: Awaited>; + let hrToken: string; + const path = () => `/api/employees/${employee.id}/contract-periods`; + beforeAll(async () => { + ctx = await createTestApp(); + }); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.reset(); + hr = await seedEmployee(ctx.prisma, { + personalNo: 'CON-HR', + firstName: 'Hanna', + lastName: 'HR', + email: 'contract.hr@test.local', + role: 'HRAdmin', + }); + manager = await seedEmployee(ctx.prisma, { + personalNo: 'CON-M', + firstName: 'Marc', + lastName: 'Manager', + email: 'contract.manager@test.local', + role: 'Manager', + }); + employee = await seedEmployee(ctx.prisma, { + personalNo: 'CON-E', + firstName: 'Emil', + lastName: 'Employee', + email: 'contract.employee@test.local', + managerId: manager.id, + }); + hrToken = await login(ctx.http, hr.email); + }); + const post = (token: string, body: object) => + ctx.http.post(path()).set('Authorization', `Bearer ${token}`).send(body); + it('does not invent history from legacy mutable profile values', async () => { + const response = await ctx.http + .get(path()) + .set('Authorization', `Bearer ${hrToken}`) + .expect(200); + expect(response.body).toEqual([]); + expect(await ctx.prisma.employeeContractPeriodAudit.count()).toBe(0); + }); + it('confirms self-contained dated facts and records authoritative HR actor', async () => { + const response = await post(hrToken, { + ...facts, + confirmedById: employee.id, + employeeId: manager.id, + }).expect(201); + const persistedFacts = Object.fromEntries( + Object.entries(facts).filter(([key]) => key !== 'confirmed'), + ); + expect(response.body).toMatchObject({ + ...persistedFacts, + employeeId: employee.id, + confirmedById: hr.id, + revision: 0, + weeklyHours: 40, + }); + expect(response.body.confirmed).toBeUndefined(); + const audit = + await ctx.prisma.employeeContractPeriodAudit.findFirstOrThrow(); + expect(audit).toMatchObject({ + actorId: hr.id, + action: 'Confirmed', + before: null, + after: { id: response.body.id, employeeId: employee.id, weeklyHours: 40 }, + }); + expect(audit.occurredAt).toBeInstanceOf(Date); + }); + it.each(['manager', 'employee'] as const)( + 'denies %s reading or confirming contracts', + async (role) => { + const actor = role === 'manager' ? manager : employee; + const token = await login(ctx.http, actor.email); + await ctx.http + .get(path()) + .set('Authorization', `Bearer ${token}`) + .expect(403); + await post(token, facts).expect(403); + expect(await ctx.prisma.employeeContractPeriod.count()).toBe(0); + }, + ); + it('denies a forged/stale HR claim after the actor role changed', async () => { + const forged = ctx.app.get(JwtService).sign({ + sub: manager.id, + role: 'HRAdmin', + email: manager.email, + authVersion: manager.authVersion, + }); + await post(forged, facts).expect(403); + await ctx.prisma.employee.update({ + where: { id: hr.id }, + data: { role: 'Employee' }, + }); + await post(hrToken, facts).expect(403); + }); + it.each(['revoked', 'demoted', 'inactive', 'Solo'] as const)( + 'revalidates in-flight handler authority after %s state changed', + async (change) => { + const service = ctx.app.get(ContractPeriodsService); + const identity = { + id: hr.id, + email: hr.email, + role: 'HRAdmin' as const, + authVersion: 0, + }; + if (change === 'Solo') { + await ctx.prisma.installationSettings.create({ + data: { + id: 1, + mode: 'Solo', + ownerEmployeeId: hr.id, + setupCompleted: true, + }, + }); + } else { + await ctx.prisma.employee.update({ + where: { id: hr.id }, + data: + change === 'revoked' + ? { authVersion: 1 } + : change === 'demoted' + ? { role: 'Employee' } + : { isActive: false }, + }); + } + await expect( + service.confirm( + employee.id, + facts as ConfirmContractPeriodDto, + identity, + ), + ).rejects.toMatchObject({ status: 403 }); + await expect(service.list(employee.id, identity)).rejects.toMatchObject({ + status: 403, + }); + expect(await ctx.prisma.employeeContractPeriod.count()).toBe(0); + expect(await ctx.prisma.employeeContractPeriodAudit.count()).toBe(0); + }, + ); + it.each([ + { confirmed: false }, + { confirmed: 'true' }, + { confirmationNote: ' ' }, + { effectiveFrom: '2026-02-30' }, + { effectiveTo: '2025-12-31' }, + { employmentEnd: '2019-12-31' }, + { holidayDates: ['2026-02-30'] }, + { workingDays: 0 }, + { workingDays: 128 }, + { weeklyHours: -1 }, + { weeklyHours: 169 }, + { weeklyHours: 40.001 }, + { workingDays: 1, weeklyHours: 25 }, + { holidayCalendar: 'FAKE' }, + { expectedRevision: 0 }, + { supersedesPeriodId: '00000000-0000-4000-8000-000000000999' }, + ])('rejects invalid confirmation %j', async (patch) => { + await post(hrToken, { ...facts, ...patch }).expect(400); + expect(await ctx.prisma.employeeContractPeriod.count()).toBe(0); + }); + it('supports zero-hour contracts and explicit employment boundaries without mutating the profile', async () => { + const response = await post(hrToken, { + ...facts, + weeklyHours: 0, + workingDays: 0, + effectiveTo: '2026-12-31', + employmentStart: '2026-03-01', + employmentEnd: '2026-10-31', + }).expect(201); + expect(response.body).toMatchObject({ + weeklyHours: 0, + workingDays: 0, + employmentStart: '2026-03-01', + employmentEnd: '2026-10-31', + }); + expect( + ( + await ctx.prisma.employee.findUniqueOrThrow({ + where: { id: employee.id }, + }) + ).weeklyHours.toNumber(), + ).toBe(40); + }); + it('rejects overlap including inclusive boundaries, without changing either period or audit', async () => { + await post(hrToken, { ...facts, effectiveTo: '2026-06-30' }).expect(201); + await post(hrToken, { ...facts, effectiveFrom: '2026-06-30' }).expect(409); + await post(hrToken, facts).expect(409); + expect(await ctx.prisma.employeeContractPeriod.count()).toBe(1); + expect(await ctx.prisma.employeeContractPeriodAudit.count()).toBe(1); + await post(hrToken, { ...facts, effectiveFrom: '2026-07-01' }).expect(201); + }); + it('explicitly supersedes only the covering period with revision and before/after audit', async () => { + const old = (await post(hrToken, facts).expect(201)).body; + const next = await post(hrToken, { + ...facts, + effectiveFrom: '2026-07-01', + weeklyHours: 20, + timeModel: 'Teilzeit', + supersedesPeriodId: old.id, + expectedRevision: 0, + }).expect(201); + const previous = await ctx.prisma.employeeContractPeriod.findUniqueOrThrow({ + where: { id: old.id }, + }); + expect(previous.effectiveTo?.toISOString()).toBe( + '2026-06-30T00:00:00.000Z', + ); + expect(previous.weeklyHours.toNumber()).toBe(40); + expect(previous.revision).toBe(1); + expect(next.body).toMatchObject({ + weeklyHours: 20, + revision: 0, + confirmedById: hr.id, + }); + const audits = await ctx.prisma.employeeContractPeriodAudit.findMany({ + where: { periodId: old.id }, + orderBy: { occurredAt: 'asc' }, + }); + expect(audits).toHaveLength(2); + expect(audits[1]).toMatchObject({ + action: 'Superseded', + actorId: hr.id, + before: { effectiveTo: null, weeklyHours: 40, revision: 0 }, + after: { effectiveTo: '2026-06-30', weeklyHours: 40, revision: 1 }, + }); + await post(hrToken, { + ...facts, + effectiveFrom: '2026-08-01', + supersedesPeriodId: old.id, + expectedRevision: 0, + }).expect(409); + expect(await ctx.prisma.employeeContractPeriod.count()).toBe(2); + }); + it('does not use a foreign employee contract as a supersession token', async () => { + const foreign = ( + await ctx.http + .post(`/api/employees/${manager.id}/contract-periods`) + .set('Authorization', `Bearer ${hrToken}`) + .send(facts) + .expect(201) + ).body; + await post(hrToken, { + ...facts, + effectiveFrom: '2026-07-01', + supersedesPeriodId: foreign.id, + expectedRevision: 0, + }).expect(409); + expect( + await ctx.prisma.employeeContractPeriod.count({ + where: { employeeId: employee.id }, + }), + ).toBe(0); + }); + it('rolls back supersession when the proposed period overlaps another known period', async () => { + const old = ( + await post(hrToken, { ...facts, effectiveTo: '2026-06-30' }).expect(201) + ).body; + await post(hrToken, { ...facts, effectiveFrom: '2026-09-01' }).expect(201); + await post(hrToken, { + ...facts, + effectiveFrom: '2026-05-01', + supersedesPeriodId: old.id, + expectedRevision: 0, + }).expect(409); + expect( + ( + await ctx.prisma.employeeContractPeriod.findUniqueOrThrow({ + where: { id: old.id }, + }) + ).revision, + ).toBe(0); + expect(await ctx.prisma.employeeContractPeriodAudit.count()).toBe(2); + }); + it('serializes competing confirmations and stale supersessions across concurrent requests', async () => { + const competing = await Promise.all([ + post(hrToken, facts), + post(hrToken, { ...facts, weeklyHours: 20 }), + ]); + expect(competing.map((r) => r.status).sort()).toEqual([201, 409]); + const old = competing.find((r) => r.status === 201)?.body; + const competingChanges = await Promise.all( + [20, 30].map((weeklyHours) => + post(hrToken, { + ...facts, + effectiveFrom: '2026-07-01', + weeklyHours, + supersedesPeriodId: old.id, + expectedRevision: 0, + }), + ), + ); + expect(competingChanges.map((r) => r.status).sort()).toEqual([201, 409]); + expect(await ctx.prisma.employeeContractPeriod.count()).toBe(2); + expect(await ctx.prisma.employeeContractPeriodAudit.count()).toBe(3); + }); + it('rolls back both period boundaries and facts if audit persistence fails', async () => { + const old = (await post(hrToken, facts).expect(201)).body; + await ctx.prisma + .$executeRawUnsafe(`CREATE FUNCTION "test45FailContractAudit"() RETURNS trigger AS $$ + BEGIN + IF NEW."action" = 'Confirmed' AND NEW."after"->>'weeklyHours' = '13' THEN + RAISE EXCEPTION 'Synthetic audit storage failure'; + END IF; + RETURN NEW; + END; $$ LANGUAGE plpgsql`); + try { + await ctx.prisma.$executeRawUnsafe( + `CREATE TRIGGER "test45FailContractAudit" BEFORE INSERT ON "EmployeeContractPeriodAudit" FOR EACH ROW EXECUTE FUNCTION "test45FailContractAudit"()`, + ); + await post(hrToken, { + ...facts, + effectiveFrom: '2026-07-01', + weeklyHours: 13, + supersedesPeriodId: old.id, + expectedRevision: 0, + }).expect(500); + const retained = + await ctx.prisma.employeeContractPeriod.findUniqueOrThrow({ + where: { id: old.id }, + }); + expect(retained).toMatchObject({ effectiveTo: null, revision: 0 }); + expect(retained.weeklyHours.toNumber()).toBe(40); + expect(await ctx.prisma.employeeContractPeriod.count()).toBe(1); + expect(await ctx.prisma.employeeContractPeriodAudit.count()).toBe(1); + } finally { + await ctx.prisma.$executeRawUnsafe( + `DROP TRIGGER IF EXISTS "test45FailContractAudit" ON "EmployeeContractPeriodAudit"`, + ); + await ctx.prisma.$executeRawUnsafe( + `DROP FUNCTION "test45FailContractAudit"()`, + ); + } + }); + it('retains immutable schedule and contract facts after operational profile/schedule updates', async () => { + const schedule = await ctx.prisma.workSchedule.create({ + data: { + name: 'Mutable schedule', + frameStart: '07:00', + frameEnd: '18:00', + workingDays: 31, + }, + }); + const saved = (await post(hrToken, facts).expect(201)).body; + await ctx.prisma.employee.update({ + where: { id: employee.id }, + data: { + weeklyHours: 12, + timeModel: 'Teilzeit', + startDate: new Date('2026-09-01'), + workScheduleId: schedule.id, + holidayCalendar: 'DE-BY', + holidayDates: ['2026-05-05'], + }, + }); + await ctx.prisma.workSchedule.update({ + where: { id: schedule.id }, + data: { workingDays: 15, name: 'Now a different schedule' }, + }); + const response = await ctx.http + .get(path()) + .set('Authorization', `Bearer ${hrToken}`) + .expect(200); + expect(response.body).toEqual([saved]); + }); +}); diff --git a/apps/api-e2e/src/api/international-policies.e2e.spec.ts b/apps/api-e2e/src/api/international-policies.e2e.spec.ts index b8aa6e1..a6b08d3 100644 --- a/apps/api-e2e/src/api/international-policies.e2e.spec.ts +++ b/apps/api-e2e/src/api/international-policies.e2e.spec.ts @@ -225,11 +225,15 @@ describe('International calendars and configurable break policies', () => { // their historical whole-minute policy. Both use the captured deduction. const reportGrossMinutes = ((persisted.clockOut as Date).getTime() - clockIn.getTime()) / 60_000; - expect(report.body.totals).toEqual({ - grossMinutes: reportGrossMinutes, - breakMinutes: 20, - netMinutes: reportGrossMinutes - 20, - }); + // The period report now splits an overnight entry at local midnight. + // Summing proportional fragments can differ by one floating-point ULP; + // verify sub-microsecond precision, not an incidental addition order. + expect(report.body.totals.grossMinutes).toBeCloseTo(reportGrossMinutes, 8); + expect(report.body.totals.breakMinutes).toBeCloseTo(20, 8); + expect(report.body.totals.netMinutes).toBeCloseTo( + reportGrossMinutes - 20, + 8, + ); const exported = await ctx.http .get('/api/erp/timeentries') .set('X-API-Key', process.env.ERP_API_KEY ?? 'e2e-erp-key') diff --git a/apps/api-e2e/src/api/working-time-comparisons.e2e.spec.ts b/apps/api-e2e/src/api/working-time-comparisons.e2e.spec.ts new file mode 100644 index 0000000..2f7e5dc --- /dev/null +++ b/apps/api-e2e/src/api/working-time-comparisons.e2e.spec.ts @@ -0,0 +1,784 @@ +import { ReportsService } from '../../../api/src/app/reports/reports.service'; +import { randomUUID } from 'node:crypto'; +import type { Prisma } from '@prisma/client'; +import type { WorkingTimeReportDto } from '../../../api/src/app/reports/reports.dto'; +import { + createTestApp, + login, + seedEmployee, + type TestContext, +} from '../support/test-app'; + +/** These fixtures intentionally create confirmed snapshots, never legacy defaults. */ +describe('Scoped working-time period comparisons', () => { + let ctx: TestContext; + beforeAll(async () => { + ctx = await createTestApp(); + }); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.reset(); + }); + + async function fixture() { + const employee = ( + personalNo: string, + role: 'HRAdmin' | 'Manager' | 'Employee' = 'Employee', + managerId?: string, + ) => + seedEmployee(ctx.prisma, { + personalNo, + firstName: personalNo, + lastName: 'Comparison', + email: `${personalNo.toLowerCase()}@test.local`, + holidayCalendar: 'NONE', + role, + managerId, + }); + const hr = await employee('HR', 'HRAdmin'); + const manager = await employee('MANAGER', 'Manager'); + const direct = await employee('DIRECT', 'Employee', manager.id); + const foreign = await employee('FOREIGN'); + const authorization = `Bearer ${await login(ctx.http, hr.email)}`; + const snapshot = ( + employeeId: string, + values: Partial = {}, + ) => + ctx.prisma.employeeContractPeriod.create({ + data: { + employeeId, + effectiveFrom: new Date('2026-01-01'), + employmentStart: new Date('2020-01-01'), + weeklyHours: 40, + timeModel: 'Vollzeit', + workingDays: 31, + holidayCalendar: 'NONE', + holidayDates: [], + confirmationNote: 'Synthetic HR-confirmed contractual facts', + confirmedById: hr.id, + ...values, + }, + }); + const entry = ( + employeeId: string, + from: string, + to: string | null, + values: Partial = {}, + ) => + ctx.prisma.timeEntry.create({ + data: { + employeeId, + clockIn: new Date(from), + clockOut: to ? new Date(to) : null, + status: 'Approved', + breakRules: [], + ...values, + }, + }); + const report = async (from: string, to = from, employeeId?: string) => + ( + await ctx.http + .get( + `/api/reports/working-times?from=${from}&to=${to}${employeeId ? `&employeeId=${employeeId}` : ''}`, + ) + .set('Authorization', authorization) + .expect(200) + ).body as WorkingTimeReportDto; + return { + hr, + manager, + direct, + foreign, + authorization, + snapshot, + entry, + report, + }; + } + + it('derives full-time, part-time, workmask and inclusive contract-change targets, including zero actuals', async () => { + const { hr, manager, direct, foreign, snapshot, report } = await fixture(); + await snapshot(hr.id); + await snapshot(manager.id, { weeklyHours: 33, workingDays: 15 }); + await snapshot(foreign.id, { weeklyHours: 20, timeModel: 'Teilzeit' }); + await snapshot(direct.id, { effectiveTo: new Date('2026-08-11') }); + await snapshot(direct.id, { + effectiveFrom: new Date('2026-08-12'), + weeklyHours: 20, + timeModel: 'Teilzeit', + }); + const result = await report('2026-08-10', '2026-08-16'); + expect(result).toMatchObject({ + timeZone: 'Europe/Berlin', + timeDefinition: 'net_working_time', + excessThresholdMinutes: 600, + rows: [], + totals: { grossMinutes: 0, breakMinutes: 0, netMinutes: 0 }, + }); + expect(result.comparisons).toHaveLength(4); + for (const [employeeId, target] of [ + [hr.id, 2400], + [manager.id, 1980], + [direct.id, 1680], + [foreign.id, 1200], + ] as const) { + expect( + result.comparisons.find((row) => row.employeeId === employeeId), + ).toMatchObject({ + targetMinutes: target, + actualMinutes: 0, + differenceMinutes: -target, + targetStatus: 'Known', + unknownDates: [], + reason: null, + indicator: 'Negative', + openEntryCount: 0, + }); + } + }); + + it('uses employment bounds only inside confirmed coverage; never invents a prehire/legacy/weekend baseline', async () => { + const { direct, foreign, snapshot, report } = await fixture(); + await snapshot(direct.id, { + effectiveFrom: new Date('2026-08-01'), + effectiveTo: new Date('2026-08-31'), + employmentStart: new Date('2026-08-11'), + employmentEnd: new Date('2026-08-12'), + }); + expect( + (await report('2026-08-10', '2026-08-14', direct.id)).comparisons[0], + ).toMatchObject({ + targetMinutes: 960, + targetStatus: 'Known', + unknownDates: [], + }); + expect( + (await report('2026-08-10', '2026-08-10', direct.id)).comparisons[0] + .targetMinutes, + ).toBe(0); + expect( + (await report('2026-08-13', '2026-08-14', direct.id)).comparisons[0] + .targetMinutes, + ).toBe(0); + // An old employee without history is unknown, even when the live start date + // or weekend would otherwise suggest that their target is zero. + await ctx.prisma.employee.update({ + where: { id: foreign.id }, + data: { startDate: new Date('2027-01-01') }, + }); + expect( + (await report('2026-08-15', '2026-08-16', foreign.id)).comparisons[0], + ).toMatchObject({ + targetMinutes: null, + differenceMinutes: null, + targetStatus: 'Unknown', + unknownDates: ['2026-08-15', '2026-08-16'], + reason: 'CONTRACT_HISTORY_GAP', + indicator: 'Unknown', + }); + await snapshot(foreign.id, { effectiveFrom: new Date('2026-08-18') }); + expect( + (await report('2026-08-17', '2026-08-18', foreign.id)).comparisons[0], + ).toMatchObject({ + targetMinutes: null, + differenceMinutes: null, + unknownDates: ['2026-08-17'], + }); + }); + + it('nulls the whole target/difference on any daily gap but retains actual minutes and known date coverage', async () => { + const { direct, snapshot, entry, report } = await fixture(); + await snapshot(direct.id, { effectiveTo: new Date('2026-08-11') }); + await snapshot(direct.id, { effectiveFrom: new Date('2026-08-13') }); + await entry(direct.id, '2026-08-12T06:00:00Z', '2026-08-12T08:00:00Z'); + const result = await report('2026-08-10', '2026-08-14', direct.id); + expect(result.comparisons[0]).toMatchObject({ + targetMinutes: null, + actualMinutes: 120, + differenceMinutes: null, + targetStatus: 'Unknown', + unknownDates: ['2026-08-12'], + reason: 'CONTRACT_HISTORY_GAP', + indicator: 'Unknown', + }); + expect(result.totals.netMinutes).toBe(120); + expect( + (await report('2026-08-10', '2026-08-11', direct.id)).comparisons[0] + .targetMinutes, + ).toBe(960); + }); + + it('keeps historical schedule, hours, employment and holiday facts immutable when current employee defaults change', async () => { + const { direct, snapshot, report } = await fixture(); + const confirmed = await snapshot(direct.id, { + weeklyHours: 33, + workingDays: 15, + holidayDates: ['2026-08-11'], + scheduleLabel: 'Historic four-day week', + }); + const before = (await report('2026-08-10', '2026-08-14', direct.id)) + .comparisons[0]; + expect(before.targetMinutes).toBe(1485); + const schedule = await ctx.prisma.workSchedule.create({ + data: { + name: 'Changed live schedule', + workingDays: 127, + frameStart: '00:00', + frameEnd: '23:59', + }, + }); + await ctx.prisma.employee.update({ + where: { id: direct.id }, + data: { + weeklyHours: 10, + timeModel: 'Teilzeit', + startDate: new Date('2027-01-01'), + workScheduleId: schedule.id, + holidayCalendar: 'DE-BY', + holidayDates: ['2026-08-10', '2026-08-12', '2026-08-13'], + }, + }); + expect( + (await report('2026-08-10', '2026-08-14', direct.id)).comparisons[0], + ).toEqual(before); + expect( + await ctx.prisma.employeeContractPeriod.findUnique({ + where: { id: confirmed.id }, + }), + ).toEqual(confirmed); + }); + + it('uses snapshot preset/custom holidays and only recorded sickness/training as non-request excusals', async () => { + const { direct, snapshot, report } = await fixture(); + await snapshot(direct.id, { + holidayCalendar: 'DE-BY', + holidayDates: ['2026-01-07'], + }); + await ctx.prisma.absence.createMany({ + data: [ + { + employeeId: direct.id, + kind: 'Training', + from: new Date('2026-01-08'), + to: new Date('2026-01-08'), + }, + { + employeeId: direct.id, + kind: 'Sickness', + from: new Date('2026-01-09'), + to: new Date('2026-01-09'), + }, + { + employeeId: direct.id, + kind: 'Flextime', + from: new Date('2026-01-05'), + to: new Date('2026-01-05'), + }, + ], + }); + // Jan 6 is a Bavarian preset holiday; Jan 7 is a custom snapshot holiday. + expect( + (await report('2026-01-05', '2026-01-09', direct.id)).comparisons[0], + ).toMatchObject({ targetMinutes: 480, actualMinutes: 0 }); + }); + + it('reduces targets only for approved leave, unions duplicate half-days and never double-credits work', async () => { + const { direct, snapshot, entry, report } = await fixture(); + await snapshot(direct.id); + const leave = ( + from: string, + to = from, + values: Partial = {}, + ) => + ctx.prisma.request.create({ + data: { + employeeId: direct.id, + type: 'Vacation', + status: 'Approved', + workflowState: 'Approved', + from: new Date(from), + to: new Date(to), + ...values, + }, + }); + await leave('2026-08-10', undefined, { + halfDayStart: true, + halfDayEnd: true, + }); + await leave('2026-08-10', undefined, { + type: 'SpecialLeave', + halfDayStart: true, + }); + await leave('2026-08-10', undefined, { type: 'HomeOffice' }); + await leave('2026-08-11'); + await leave('2026-08-12', undefined, { type: 'SpecialLeave' }); + await leave('2026-08-13', undefined, { + status: 'Submitted', + workflowState: 'PendingManager', + }); + await leave('2026-08-13', undefined, { cancelledAt: new Date() }); + await leave('2026-08-14', undefined, { + status: 'Rejected', + workflowState: 'Rejected', + }); + await leave('2026-08-14', undefined, { type: 'HomeOffice' }); + await leave('2026-08-17', '2026-08-19', { + halfDayStart: true, + halfDayEnd: true, + }); + await ctx.prisma.absence.createMany({ + data: [ + { + employeeId: direct.id, + kind: 'Flextime', + from: new Date('2026-08-13'), + to: new Date('2026-08-13'), + }, + { + employeeId: direct.id, + kind: 'Training', + from: new Date('2026-08-11'), + to: new Date('2026-08-11'), + }, + ], + }); + await entry(direct.id, '2026-08-11T06:00:00Z', '2026-08-11T07:00:00Z'); + const result = await report('2026-08-10', '2026-08-21', direct.id); + // 240 + 0 + 0 + 480 + 480 + 240 + 0 + 240 + 480 + 480. + expect(result.comparisons[0]).toMatchObject({ + targetMinutes: 2640, + actualMinutes: 60, + differenceMinutes: -2580, + }); + expect(result.totals.netMinutes).toBe(60); + expect( + (await report('2026-08-11', '2026-08-11', direct.id)).comparisons[0], + ).toMatchObject({ + targetMinutes: 0, + actualMinutes: 60, + differenceMinutes: 60, + }); + }); + + it('includes only closed Pending/Approved entries and counts open timers separately', async () => { + const { direct, snapshot, entry, report } = await fixture(); + await snapshot(direct.id); + const accepted = await entry( + direct.id, + '2026-08-10T06:00:00Z', + '2026-08-10T14:00:00Z', + { + breakRules: [{ afterMinutes: 360, breakMinutes: 30 }], + }, + ); + const pending = await entry( + direct.id, + '2026-08-10T15:00:00Z', + '2026-08-10T16:00:00Z', + { status: 'Pending' }, + ); + for (const values of [ + { status: 'Open' as const }, + { status: 'Rejected' as const }, + { voidedAt: new Date() }, + ]) { + await entry( + direct.id, + '2026-08-10T17:00:00Z', + '2026-08-10T18:00:00Z', + values, + ); + } + // The database intentionally permits only one non-voided running row per + // employee (including rejected legacy rows), so test exclusion separately. + const oldOpen = await entry(direct.id, '2026-08-09T12:00:00Z', null, { + status: 'Open', + }); + expect( + (await report('2026-08-10', '2026-08-10', direct.id)).comparisons[0] + .openEntryCount, + ).toBe(1); + await ctx.prisma.timeEntry.update({ + where: { id: oldOpen.id }, + data: { voidedAt: new Date() }, + }); + const rejectedOpen = await entry(direct.id, '2026-08-10T12:00:00Z', null, { + status: 'Rejected', + }); + expect( + (await report('2026-08-10', '2026-08-10', direct.id)).comparisons[0] + .openEntryCount, + ).toBe(0); + await ctx.prisma.timeEntry.update({ + where: { id: rejectedOpen.id }, + data: { voidedAt: new Date() }, + }); + const futureOpen = await entry(direct.id, '2026-08-11T12:00:00Z', null, { + status: 'Open', + }); + expect( + (await report('2026-08-10', '2026-08-10', direct.id)).comparisons[0] + .openEntryCount, + ).toBe(0); + await ctx.prisma.timeEntry.update({ + where: { id: futureOpen.id }, + data: { voidedAt: new Date() }, + }); + await entry(direct.id, '2026-08-10T12:00:00Z', null, { status: 'Open' }); + const result = await report('2026-08-10', '2026-08-10', direct.id); + expect(result.rows.map((row) => row.id)).toEqual([accepted.id, pending.id]); + expect(result.totals).toEqual({ + grossMinutes: 540, + breakMinutes: 30, + netMinutes: 510, + }); + expect(result.comparisons[0]).toMatchObject({ + targetMinutes: 480, + actualMinutes: 510, + differenceMinutes: 30, + indicator: 'Positive', + openEntryCount: 1, + }); + }); + + it('allocates complete capture-group breaks before local clipping and excludes invalid/foreign siblings', async () => { + const { direct, foreign, snapshot, entry, report } = await fixture(); + await snapshot(direct.id); + const captureGroupId = randomUUID(); + const group = { + captureGroupId, + breakRules: [{ afterMinutes: 360, breakMinutes: 30 }], + }; + // First four-hour member is half inside the requested Berlin local day. + const first = await entry( + direct.id, + '2026-08-09T20:00:00Z', + '2026-08-10T00:00:00Z', + group, + ); + await entry( + direct.id, + '2026-08-11T06:00:00Z', + '2026-08-11T10:00:00Z', + group, + ); + await entry(direct.id, '2026-08-12T06:00:00Z', '2026-08-12T10:00:00Z', { + ...group, + status: 'Rejected', + }); + await entry(direct.id, '2026-08-13T06:00:00Z', '2026-08-13T10:00:00Z', { + ...group, + voidedAt: new Date(), + }); + await entry(direct.id, '2026-08-14T06:00:00Z', '2026-08-14T10:00:00Z', { + ...group, + status: 'Open', + }); + await entry( + foreign.id, + '2026-08-10T06:00:00Z', + '2026-08-10T10:00:00Z', + group, + ); + const clipped = await report('2026-08-10', '2026-08-10', direct.id); + expect(clipped.rows).toHaveLength(1); + expect(clipped.rows[0]).toMatchObject({ + id: first.id, + date: '2026-08-10', + clockIn: '2026-08-09T22:00:00.000Z', + clockOut: '2026-08-10T00:00:00.000Z', + grossMinutes: 120, + breakMinutes: 7.5, + netMinutes: 112.5, + }); + expect(clipped.totals).toEqual({ + grossMinutes: 120, + breakMinutes: 7.5, + netMinutes: 112.5, + }); + expect(clipped.comparisons[0].actualMinutes).toBe(112.5); + const full = await report('2026-08-09', '2026-08-11', direct.id); + expect(full.rows).toHaveLength(3); + expect(full.totals).toEqual({ + grossMinutes: 480, + breakMinutes: 30, + netMinutes: 450, + }); + expect(full.comparisons[0].actualMinutes).toBe(450); + }); + + it.each([ + [ + 'spring-forward', + '2026-03-28T22:30:00Z', + '2026-03-29T02:30:00Z', + '2026-03-29', + 210, + '2026-03-28T23:00:00.000Z', + ], + [ + 'fall-back', + '2026-10-24T21:30:00Z', + '2026-10-25T03:30:00Z', + '2026-10-25', + 330, + '2026-10-24T22:00:00.000Z', + ], + ] as const)( + 'clips across local midnight and %s without pretending every local day is 24 hours', + async (_label, from, to, date, minutes, expectedStart) => { + const { direct, snapshot, entry, report } = await fixture(); + await snapshot(direct.id, { workingDays: 127, weeklyHours: 7 }); + await entry(direct.id, from, to, { captureGroupId: randomUUID() }); + const result = await report(date, date, direct.id); + expect(result.rows).toHaveLength(1); + expect(result.rows[0]).toMatchObject({ + date, + clockIn: expectedStart, + clockOut: new Date(to).toISOString(), + grossMinutes: minutes, + netMinutes: minutes, + }); + expect(result.totals.netMinutes).toBe(minutes); + expect(result.comparisons[0]).toMatchObject({ + targetMinutes: 60, + actualMinutes: minutes, + differenceMinutes: Number(minutes) - 60, + }); + }, + ); + + it('splits a whole local day through both DST transitions using elapsed minutes, with seconds retained', async () => { + const { direct, snapshot, entry, report } = await fixture(); + await snapshot(direct.id, { weeklyHours: 0 }); + await entry(direct.id, '2026-03-28T23:00:00Z', '2026-03-29T22:00:00Z'); + await entry(direct.id, '2026-10-24T22:00:00Z', '2026-10-25T23:00:00Z'); + expect( + (await report('2026-03-29', '2026-03-29', direct.id)).totals.netMinutes, + ).toBe(1380); + expect( + (await report('2026-10-25', '2026-10-25', direct.id)).totals.netMinutes, + ).toBe(1500); + await entry(direct.id, '2026-08-10T07:00:01Z', '2026-08-10T15:00:59Z', { + breakRules: [{ afterMinutes: 360, breakMinutes: 30 }], + }); + const seconds = await report('2026-08-10', '2026-08-10', direct.id); + expect(seconds.rows[0].grossMinutes).toBeCloseTo(480 + 58 / 60, 8); + expect(seconds.comparisons[0].actualMinutes).toBeCloseTo(450 + 58 / 60, 8); + expect(seconds.comparisons[0].actualMinutes).toBe( + seconds.totals.netMinutes, + ); + }); + + it('uses selected-period indicator boundaries, not opening or carried balances', async () => { + const { direct, snapshot, entry, report } = await fixture(); + await snapshot(direct.id, { weeklyHours: 0 }); + await ctx.prisma.employee.update({ + where: { id: direct.id }, + data: { overtimeOpeningBalanceMinutes: -9999 }, + }); + for (const [date, minutes, indicator] of [ + ['2026-08-10', 0, 'Balanced'], + ['2026-08-11', 599, 'Positive'], + ['2026-08-12', 600, 'Excess'], + ['2026-08-13', 601, 'Excess'], + ] as const) { + if (minutes) + await entry( + direct.id, + `${date}T06:00:00Z`, + new Date( + new Date(`${date}T06:00:00Z`).getTime() + minutes * 60_000, + ).toISOString(), + ); + expect( + (await report(date, date, direct.id)).comparisons[0], + ).toMatchObject({ + targetMinutes: 0, + actualMinutes: minutes, + differenceMinutes: minutes, + indicator, + }); + } + }); + + it('scopes manager lists/reports to self and direct reports and forbids foreign IDs even without entries', async () => { + const { hr, manager, direct, foreign, authorization } = await fixture(); + const indirect = await seedEmployee(ctx.prisma, { + personalNo: 'INDIRECT', + firstName: 'Indirect', + lastName: 'Report', + email: 'indirect@test.local', + managerId: direct.id, + }); + const managerAuth = `Bearer ${await login(ctx.http, manager.email)}`; + const employees = await ctx.http + .get('/api/reports/working-times/employees') + .set('Authorization', managerAuth) + .expect(200); + expect( + new Set(employees.body.map((employee: { id: string }) => employee.id)), + ).toEqual(new Set([manager.id, direct.id])); + const result = await ctx.http + .get('/api/reports/working-times?from=2026-08-10&to=2026-08-10') + .set('Authorization', managerAuth) + .expect(200); + expect( + new Set( + result.body.comparisons.map( + (row: { employeeId: string }) => row.employeeId, + ), + ), + ).toEqual(new Set([manager.id, direct.id])); + for (const forbidden of [hr.id, foreign.id, indirect.id, randomUUID()]) { + for (const filter of [ + `employeeId=${forbidden}`, + `employeeIds=${direct.id}&employeeIds=${forbidden}`, + ]) { + await ctx.http + .get( + `/api/reports/working-times?from=2026-08-10&to=2026-08-10&${filter}`, + ) + .set('Authorization', managerAuth) + .expect(403); + } + } + const hrEmployees = await ctx.http + .get('/api/reports/working-times/employees') + .set('Authorization', authorization) + .expect(200); + expect(hrEmployees.body).toHaveLength(5); + await ctx.http + .get( + `/api/reports/working-times?from=2026-08-10&to=2026-08-10&employeeId=${foreign.id}`, + ) + .set('Authorization', authorization) + .expect(200); + // A manager's scope follows the current server relationship immediately. + await ctx.prisma.employee.update({ + where: { id: direct.id }, + data: { managerId: null }, + }); + await ctx.http + .get( + `/api/reports/working-times?from=2026-08-10&to=2026-08-10&employeeId=${direct.id}`, + ) + .set('Authorization', managerAuth) + .expect(403); + }); + + it('uses fresh active actor roles for existing tokens; employees are denied and PDF remains HR-only', async () => { + const { hr, manager, direct, authorization } = await fixture(); + const managerAuth = `Bearer ${await login(ctx.http, manager.email)}`; + const employeeAuth = `Bearer ${await login(ctx.http, direct.email)}`; + const reportUrl = + '/api/reports/working-times?from=2026-08-10&to=2026-08-10'; + const employeesUrl = '/api/reports/working-times/employees'; + const pdfUrl = + '/api/reports/working-times.pdf?from=2026-08-10&to=2026-08-10'; + for (const url of [reportUrl, employeesUrl, pdfUrl]) { + await ctx.http.get(url).expect(401); + await ctx.http.get(url).set('Authorization', employeeAuth).expect(403); + } + await ctx.http.get(pdfUrl).set('Authorization', managerAuth).expect(403); + await ctx.http.get(pdfUrl).set('Authorization', authorization).expect(200); + await ctx.prisma.employee.update({ + where: { id: hr.id }, + data: { role: 'Employee' }, + }); + await ctx.prisma.employee.update({ + where: { id: manager.id }, + data: { role: 'Employee' }, + }); + for (const url of [reportUrl, employeesUrl, pdfUrl]) { + await ctx.http.get(url).set('Authorization', authorization).expect(403); + await ctx.http.get(url).set('Authorization', managerAuth).expect(403); + } + await ctx.prisma.employee.update({ + where: { id: direct.id }, + data: { role: 'Manager' }, + }); + await ctx.http + .get(reportUrl) + .set('Authorization', employeeAuth) + .expect(200); + await ctx.prisma.employee.update({ + where: { id: direct.id }, + data: { isActive: false }, + }); + for (const url of [reportUrl, employeesUrl, pdfUrl]) { + await ctx.http.get(url).set('Authorization', employeeAuth).expect(401); + } + }); + it('does not expose archived Team report data after an in-flight switch to Solo', async () => { + const { hr, direct, snapshot, entry } = await fixture(); + await snapshot(direct.id); + await entry(direct.id, '2026-08-10T06:00:00Z', '2026-08-10T14:00:00Z'); + await ctx.prisma.installationSettings.create({ + data: { + id: 1, + mode: 'Solo', + ownerEmployeeId: hr.id, + setupCompleted: true, + }, + }); + const service = ctx.app.get(ReportsService); + await expect(service.workingTimeEmployees(hr.id)).rejects.toMatchObject({ + status: 403, + }); + await expect( + service.workingTimes(hr.id, { from: '2026-08-10', to: '2026-08-10' }), + ).rejects.toMatchObject({ status: 403 }); + }); + it('keeps physical location snapshots only on original booking edges, not synthetic midnight clips', async () => { + const { direct, entry, authorization } = await fixture(); + await entry(direct.id, '2026-08-09T21:00:00Z', '2026-08-10T00:00:00Z', { + terminalLocationLabel: 'Synthetic entrance', + latitude: 0, + longitude: 0, + accuracyMeters: 5, + clockOutTerminalLocationLabel: 'Synthetic exit', + clockOutLatitude: 1, + clockOutLongitude: 1, + clockOutAccuracyMeters: 6, + }); + const response = await ctx.http + .get( + `/api/reports/working-times?from=2026-08-09&to=2026-08-10&employeeId=${direct.id}&includeLocations=true`, + ) + .set('Authorization', authorization) + .expect(200); + expect(response.body.rows).toHaveLength(2); + expect(response.body.rows[0]).toMatchObject({ + date: '2026-08-09', + clockInLocation: { + label: 'Synthetic entrance', + latitude: 0, + longitude: 0, + accuracyMeters: 5, + }, + clockOutLocation: null, + }); + expect(response.body.rows[1]).toMatchObject({ + date: '2026-08-10', + clockInLocation: null, + clockOutLocation: { + label: 'Synthetic exit', + latitude: 1, + longitude: 1, + accuracyMeters: 6, + }, + }); + const clipped = await ctx.http + .get( + `/api/reports/working-times?from=2026-08-10&to=2026-08-10&employeeId=${direct.id}&includeLocations=true`, + ) + .set('Authorization', authorization) + .expect(200); + expect(clipped.body.rows).toHaveLength(1); + expect(clipped.body.rows[0].clockInLocation).toBeNull(); + expect(clipped.body.totals.netMinutes).toBe(120); + }); +}); diff --git a/apps/api-e2e/src/api/working-time-reports.e2e.spec.ts b/apps/api-e2e/src/api/working-time-reports.e2e.spec.ts index 27efd7f..fc6e474 100644 --- a/apps/api-e2e/src/api/working-time-reports.e2e.spec.ts +++ b/apps/api-e2e/src/api/working-time-reports.e2e.spec.ts @@ -51,7 +51,7 @@ describe('Project-independent working-time reports', () => { return { hr, manager, report, foreign }; } - it('reports closed non-rejected entries regardless of project assignment', async () => { + it('reports closed Pending/Approved entries regardless of project assignment', async () => { const { hr, manager, report, foreign } = await fixture(); const hrToken = await login(ctx.http, hr.email); const managerToken = await login(ctx.http, manager.email); @@ -157,14 +157,27 @@ describe('Project-independent working-time reports', () => { expect(onlyDirect.body.rows).toHaveLength(1); expect(onlyDirect.body.rows[0].employeeId).toBe(report.id); - await ctx.http + const managerEmployees = await ctx.http .get('/api/reports/working-times/employees') .set('Authorization', `Bearer ${managerToken}`) - .expect(403); - await ctx.http + .expect(200); + expect( + new Set( + managerEmployees.body.map((employee: { id: string }) => employee.id), + ), + ).toEqual(new Set([manager.id, report.id])); + const managerReport = await ctx.http .get('/api/reports/working-times?from=2026-08-01&to=2026-08-31') .set('Authorization', `Bearer ${managerToken}`) - .expect(403); + .expect(200); + expect( + new Set( + managerReport.body.rows.map( + (row: { employeeId: string }) => row.employeeId, + ), + ), + ).toEqual(new Set([manager.id, report.id])); + expect(managerReport.body.totals.netMinutes).toBe(510); }); it('validates calendar dates, ordering, range length and roles', async () => { diff --git a/apps/api-e2e/src/support/test-app.ts b/apps/api-e2e/src/support/test-app.ts index f7a5bb0..c132338 100644 --- a/apps/api-e2e/src/support/test-app.ts +++ b/apps/api-e2e/src/support/test-app.ts @@ -23,6 +23,8 @@ const RESET_SQL = ` "SoloPolicy", "PersonalDay", "TimeEntryAudit", + "EmployeeContractPeriodAudit", + "EmployeeContractPeriod", "Customer", "TerminalAuditEvent", "TerminalBookingGrant", @@ -63,7 +65,10 @@ export async function createTestApp(): Promise { transformOptions: { enableImplicitConversion: true }, }), ); - await app.init(); + // Keep one loopback listener for the suite instead of opening/closing an + // ephemeral server for every supertest call (including concurrent requests). + // app.close() below owns its lifetime; all HTTP assertions remain unchanged. + await app.listen(0, '127.0.0.1'); const prisma = app.get(PrismaService); const http = request.agent(app.getHttpServer()); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 59a9d53..fa04afc 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -988,6 +988,83 @@ "tags": ["employees"] } }, + "/api/employees/{id}/contract-periods": { + "get": { + "operationId": "ContractPeriodsController_list", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EmployeeContractPeriodDto" + } + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["employees"] + }, + "post": { + "operationId": "ContractPeriodsController_confirm", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfirmContractPeriodDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmployeeContractPeriodDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": ["employees"] + } + }, "/api/work-schedules": { "get": { "operationId": "WorkSchedulesController_list", @@ -1860,7 +1937,7 @@ "name": "employeeIds", "required": false, "in": "query", - "description": "Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees; cannot be combined with employeeId.", + "description": "Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees in the actor’s scope (HR: all; Manager: self and direct reports); cannot be combined with employeeId.", "schema": { "type": "array", "items": { @@ -1935,7 +2012,7 @@ "name": "employeeIds", "required": false, "in": "query", - "description": "Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees; cannot be combined with employeeId.", + "description": "Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees in the actor’s scope (HR: all; Manager: self and direct reports); cannot be combined with employeeId.", "schema": { "type": "array", "items": { @@ -5764,6 +5841,206 @@ }, "required": ["password"] }, + "EmployeeContractPeriodDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "employeeId": { + "type": "string", + "format": "uuid" + }, + "effectiveFrom": { + "type": "string", + "format": "date" + }, + "effectiveTo": { + "type": "string", + "format": "date", + "nullable": true + }, + "employmentStart": { + "type": "string", + "format": "date" + }, + "employmentEnd": { + "type": "string", + "format": "date", + "nullable": true + }, + "weeklyHours": { + "type": "number" + }, + "timeModel": { + "type": "string", + "enum": [ + "Teilzeit", + "Vollzeit", + "Vertrauensarbeitszeit", + "Gleitzeit" + ] + }, + "workingDays": { + "type": "number" + }, + "holidayCalendar": { + "type": "string" + }, + "holidayDates": { + "type": "array", + "items": { + "type": "string" + } + }, + "scheduleLabel": { + "type": "string", + "nullable": true + }, + "confirmationNote": { + "type": "string" + }, + "confirmedById": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "revision": { + "type": "number" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "employeeId", + "effectiveFrom", + "effectiveTo", + "employmentStart", + "employmentEnd", + "weeklyHours", + "timeModel", + "workingDays", + "holidayCalendar", + "holidayDates", + "scheduleLabel", + "confirmationNote", + "confirmedById", + "revision", + "createdAt" + ] + }, + "ConfirmContractPeriodDto": { + "type": "object", + "properties": { + "effectiveFrom": { + "type": "string", + "format": "date" + }, + "effectiveTo": { + "type": "string", + "format": "date", + "nullable": true + }, + "employmentStart": { + "type": "string", + "format": "date" + }, + "employmentEnd": { + "type": "string", + "format": "date", + "nullable": true + }, + "weeklyHours": { + "type": "number", + "minimum": 0, + "maximum": 168 + }, + "timeModel": { + "type": "string", + "enum": [ + "Teilzeit", + "Vollzeit", + "Vertrauensarbeitszeit", + "Gleitzeit" + ] + }, + "workingDays": { + "type": "number", + "minimum": 0, + "maximum": 127, + "description": "Immutable weekday mask: Mon=1, Tue=2, ..., Sun=64. Equal daily target, rounded to whole minutes." + }, + "holidayCalendar": { + "type": "string", + "enum": [ + "NONE", + "DE-BW", + "DE-BY", + "DE-BE", + "DE-BB", + "DE-HB", + "DE-HH", + "DE-HE", + "DE-MV", + "DE-NI", + "DE-NW", + "DE-RP", + "DE-SL", + "DE-SN", + "DE-ST", + "DE-SH", + "DE-TH" + ] + }, + "holidayDates": { + "description": "Explicit non-working dates, supplementing the snapshot calendar.", + "type": "array", + "items": { + "type": "string" + } + }, + "scheduleLabel": { + "type": "string", + "nullable": true, + "maxLength": 120 + }, + "confirmationNote": { + "type": "string", + "minLength": 5, + "maxLength": 1000, + "description": "HR source/confirmation explanation. No automatic historical inference." + }, + "confirmed": { + "type": "boolean", + "enum": [true], + "description": "Explicit confirmation that these dated facts are known, not inferred from the current employee profile." + }, + "supersedesPeriodId": { + "type": "string", + "format": "uuid", + "description": "Explicitly close this prior period on the day before effectiveFrom; no other facts are changed." + }, + "expectedRevision": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "effectiveFrom", + "employmentStart", + "weeklyHours", + "timeModel", + "workingDays", + "holidayCalendar", + "holidayDates", + "confirmationNote", + "confirmed" + ] + }, "BreakRuleDto": { "type": "object", "properties": { @@ -6321,6 +6598,69 @@ }, "required": ["id", "firstName", "lastName"] }, + "WorkingTimeComparisonDto": { + "type": "object", + "properties": { + "employeeId": { + "type": "string", + "format": "uuid" + }, + "employeeName": { + "type": "string" + }, + "targetMinutes": { + "type": "number", + "nullable": true, + "minimum": 0 + }, + "actualMinutes": { + "type": "number", + "minimum": 0 + }, + "differenceMinutes": { + "type": "number", + "nullable": true + }, + "targetStatus": { + "type": "string", + "enum": ["Known", "Unknown"] + }, + "unknownDates": { + "description": "Calendar dates lacking a confirmed contract snapshot, including non-working dates.", + "type": "array", + "items": { + "type": "string" + } + }, + "reason": { + "type": "string", + "enum": ["CONTRACT_HISTORY_GAP"], + "nullable": true + }, + "indicator": { + "type": "string", + "enum": ["Unknown", "Negative", "Balanced", "Positive", "Excess"], + "description": "Selected-period review indicator only; not a legal limit or a carried overtime balance. Excess means difference >= 600 minutes." + }, + "openEntryCount": { + "type": "number", + "minimum": 0, + "description": "Non-voided, non-rejected open entries started before the period end; excluded from actual minutes." + } + }, + "required": [ + "employeeId", + "employeeName", + "targetMinutes", + "actualMinutes", + "differenceMinutes", + "targetStatus", + "unknownDates", + "reason", + "indicator", + "openEntryCount" + ] + }, "WorkingTimeReportLocationDto": { "type": "object", "properties": { @@ -6376,7 +6716,7 @@ }, "status": { "type": "string", - "enum": ["Open", "Pending", "Approved"] + "enum": ["Pending", "Approved"] }, "grossMinutes": { "type": "number", @@ -6451,6 +6791,24 @@ "type": "string", "format": "date" }, + "timeZone": { + "type": "string" + }, + "timeDefinition": { + "type": "string", + "enum": ["net_working_time"] + }, + "excessThresholdMinutes": { + "type": "number", + "enum": [600], + "description": "Period-review threshold, not a statutory working-time limit." + }, + "comparisons": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkingTimeComparisonDto" + } + }, "rows": { "type": "array", "items": { @@ -6461,7 +6819,16 @@ "$ref": "#/components/schemas/WorkingTimeReportTotalsDto" } }, - "required": ["from", "to", "rows", "totals"] + "required": [ + "from", + "to", + "timeZone", + "timeDefinition", + "excessThresholdMinutes", + "comparisons", + "rows", + "totals" + ] }, "TimeSummaryDto": { "type": "object", diff --git a/apps/api/src/app/employees/contract-periods.controller.ts b/apps/api/src/app/employees/contract-periods.controller.ts new file mode 100644 index 0000000..6d779f8 --- /dev/null +++ b/apps/api/src/app/employees/contract-periods.controller.ts @@ -0,0 +1,51 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiCreatedResponse, + ApiOkResponse, + ApiTags, +} from '@nestjs/swagger'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import type { JwtUser } from '../auth/jwt.strategy'; +import { Roles } from '../auth/roles.decorator'; +import { RolesGuard } from '../auth/roles.guard'; +import { + ConfirmContractPeriodDto, + EmployeeContractPeriodDto, +} from './contract-periods.dto'; +import { ContractPeriodsService } from './contract-periods.service'; + +@ApiTags('employees') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('HRAdmin') +@Controller('employees/:id/contract-periods') +export class ContractPeriodsController { + constructor(private readonly contracts: ContractPeriodsService) {} + @Get() + @ApiOkResponse({ type: [EmployeeContractPeriodDto] }) + list( + @Param('id', new ParseUUIDPipe()) id: string, + @CurrentUser() actor: JwtUser, + ) { + return this.contracts.list(id, actor); + } + @Post() + @ApiCreatedResponse({ type: EmployeeContractPeriodDto }) + confirm( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: ConfirmContractPeriodDto, + @CurrentUser() actor: JwtUser, + ) { + return this.contracts.confirm(id, dto, actor); + } +} diff --git a/apps/api/src/app/employees/contract-periods.dto.ts b/apps/api/src/app/employees/contract-periods.dto.ts new file mode 100644 index 0000000..9707558 --- /dev/null +++ b/apps/api/src/app/employees/contract-periods.dto.ts @@ -0,0 +1,129 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayUnique, + Equals, + IsArray, + IsEnum, + IsInt, + IsNumber, + IsOptional, + ValidateIf, + IsString, + IsUUID, + Matches, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { TimeModel } from '@prisma/client'; +import { HOLIDAY_CALENDARS } from 'shared'; + +export class ConfirmContractPeriodDto { + @ApiProperty({ format: 'date' }) + @Matches(/^\d{4}-\d{2}-\d{2}$/) + effectiveFrom!: string; + @ApiPropertyOptional({ type: String, format: 'date', nullable: true }) + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + effectiveTo?: string | null; + @ApiProperty({ format: 'date' }) + @Matches(/^\d{4}-\d{2}-\d{2}$/) + employmentStart!: string; + @ApiPropertyOptional({ type: String, format: 'date', nullable: true }) + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + employmentEnd?: string | null; + @ApiProperty({ minimum: 0, maximum: 168 }) + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(168) + weeklyHours!: number; + @ApiProperty({ enum: TimeModel }) + @IsEnum(TimeModel) + timeModel!: TimeModel; + @ApiProperty({ + minimum: 0, + maximum: 127, + description: + 'Immutable weekday mask: Mon=1, Tue=2, ..., Sun=64. Equal daily target, rounded to whole minutes.', + }) + @IsInt() + @Min(0) + @Max(127) + workingDays!: number; + @ApiProperty({ enum: HOLIDAY_CALENDARS }) + @IsEnum(HOLIDAY_CALENDARS) + holidayCalendar!: string; + @ApiProperty({ + type: [String], + description: + 'Explicit non-working dates, supplementing the snapshot calendar.', + }) + @IsArray() + @ArrayMaxSize(3660) + @ArrayUnique() + @Matches(/^\d{4}-\d{2}-\d{2}$/, { each: true }) + holidayDates!: string[]; + @ApiPropertyOptional({ type: String, nullable: true, maxLength: 120 }) + @IsOptional() + @IsString() + @MaxLength(120) + scheduleLabel?: string | null; + @ApiProperty({ + minLength: 5, + maxLength: 1000, + description: + 'HR source/confirmation explanation. No automatic historical inference.', + }) + @IsString() + @MinLength(5) + @MaxLength(1000) + confirmationNote!: string; + @ApiProperty({ + enum: [true], + description: + 'Explicit confirmation that these dated facts are known, not inferred from the current employee profile.', + }) + @Transform(({ obj }) => obj.confirmed) + @Equals(true) + confirmed!: true; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Explicitly close this prior period on the day before effectiveFrom; no other facts are changed.', + }) + @ValidateIf((_object, value) => value !== undefined) + @IsUUID() + supersedesPeriodId?: string; + @ApiPropertyOptional({ minimum: 0 }) + @ValidateIf((_object, value) => value !== undefined) + @IsInt() + @Min(0) + expectedRevision?: number; +} + +export class EmployeeContractPeriodDto { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ format: 'uuid' }) employeeId!: string; + @ApiProperty({ format: 'date' }) effectiveFrom!: string; + @ApiProperty({ type: String, format: 'date', nullable: true }) effectiveTo!: + | string + | null; + @ApiProperty({ format: 'date' }) employmentStart!: string; + @ApiProperty({ type: String, format: 'date', nullable: true }) + employmentEnd!: string | null; + @ApiProperty() weeklyHours!: number; + @ApiProperty({ enum: TimeModel }) timeModel!: TimeModel; + @ApiProperty() workingDays!: number; + @ApiProperty() holidayCalendar!: string; + @ApiProperty({ type: [String] }) holidayDates!: string[]; + @ApiProperty({ type: String, nullable: true }) scheduleLabel!: string | null; + @ApiProperty() confirmationNote!: string; + @ApiProperty({ type: String, format: 'uuid', nullable: true }) + confirmedById!: string | null; + @ApiProperty() revision!: number; + @ApiProperty({ format: 'date-time' }) createdAt!: string; +} diff --git a/apps/api/src/app/employees/contract-periods.service.ts b/apps/api/src/app/employees/contract-periods.service.ts new file mode 100644 index 0000000..5b7c8e3 --- /dev/null +++ b/apps/api/src/app/employees/contract-periods.service.ts @@ -0,0 +1,236 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { EmployeeContractPeriod, Prisma } from '@prisma/client'; +import type { JwtUser } from '../auth/jwt.strategy'; +import { PrismaService } from '../prisma/prisma.service'; +import { + ConfirmContractPeriodDto, + EmployeeContractPeriodDto, +} from './contract-periods.dto'; + +const DAY_MS = 86_400_000; +function dateOnly(value: string): Date { + const date = new Date(`${value}T00:00:00.000Z`); + if ( + !/^\d{4}-\d{2}-\d{2}$/.test(value) || + !Number.isFinite(date.getTime()) || + date.toISOString().slice(0, 10) !== value || + value < '0001-01-01' + ) + throw new BadRequestException('Invalid contract calendar date'); + return date; +} +export function toContractPeriodDto( + period: EmployeeContractPeriod, +): EmployeeContractPeriodDto { + return { + id: period.id, + employeeId: period.employeeId, + effectiveFrom: period.effectiveFrom.toISOString().slice(0, 10), + effectiveTo: period.effectiveTo?.toISOString().slice(0, 10) ?? null, + employmentStart: period.employmentStart.toISOString().slice(0, 10), + employmentEnd: period.employmentEnd?.toISOString().slice(0, 10) ?? null, + weeklyHours: Number(period.weeklyHours), + timeModel: period.timeModel, + workingDays: period.workingDays, + holidayCalendar: period.holidayCalendar, + holidayDates: period.holidayDates, + scheduleLabel: period.scheduleLabel, + confirmationNote: period.confirmationNote, + confirmedById: period.confirmedById, + revision: period.revision, + createdAt: period.createdAt.toISOString(), + }; +} + +@Injectable() +export class ContractPeriodsService { + constructor(private readonly prisma: PrismaService) {} + + async list( + employeeId: string, + actor: JwtUser, + ): Promise { + return this.prisma.$transaction( + async (tx) => { + await this.assertHr(tx, actor); + if ( + !(await tx.employee.findUnique({ + where: { id: employeeId }, + select: { id: true }, + })) + ) + throw new NotFoundException('Employee not found'); + return ( + await tx.employeeContractPeriod.findMany({ + where: { employeeId }, + orderBy: { effectiveFrom: 'asc' }, + }) + ).map(toContractPeriodDto); + }, + { isolationLevel: 'RepeatableRead' }, + ); + } + + async confirm( + employeeId: string, + dto: ConfirmContractPeriodDto, + actor: JwtUser, + ): Promise { + const effectiveFrom = dateOnly(dto.effectiveFrom); + const effectiveTo = + dto.effectiveTo == null ? null : dateOnly(dto.effectiveTo); + const employmentStart = dateOnly(dto.employmentStart); + const employmentEnd = + dto.employmentEnd == null ? null : dateOnly(dto.employmentEnd); + dto.holidayDates.forEach(dateOnly); + if (dto.confirmed !== true || dto.confirmationNote.trim().length < 5) + throw new BadRequestException( + 'Explicit HR confirmation and source note are required', + ); + if ( + (effectiveTo && effectiveTo < effectiveFrom) || + (employmentEnd && employmentEnd < employmentStart) + ) + throw new BadRequestException('Contract date bounds are reversed'); + const workingDayCount = Array.from({ length: 7 }, (_, bit) => + Number(Boolean(dto.workingDays & (1 << bit))), + ).reduce((a, b) => a + b, 0); + if ( + dto.weeklyHours > 0 && + (!workingDayCount || dto.weeklyHours / workingDayCount > 24) + ) + throw new BadRequestException( + 'Weekly hours cannot exceed 24 hours per working day', + ); + if ( + (dto.supersedesPeriodId !== undefined) !== + (dto.expectedRevision !== undefined) + ) + throw new BadRequestException( + 'Supersession requires both period and expected revision', + ); + return this.prisma.$transaction(async (tx) => { + // Same cross-instance lock order as time capture and profile changes. + await tx.$executeRaw`SELECT pg_advisory_xact_lock(7261500)`; + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${employeeId}, 0))`; + await tx.$queryRaw`SELECT id FROM "Employee" WHERE id = ${employeeId}::uuid FOR UPDATE`; + await this.assertHr(tx, actor); + if ( + !(await tx.employee.findUnique({ + where: { id: employeeId }, + select: { id: true }, + })) + ) + throw new NotFoundException('Employee not found'); + const periods = await tx.employeeContractPeriod.findMany({ + where: { employeeId }, + }); + const previous = dto.supersedesPeriodId + ? periods.find((period) => period.id === dto.supersedesPeriodId) + : undefined; + if ( + dto.supersedesPeriodId && + (!previous || + previous.revision !== dto.expectedRevision || + previous.effectiveFrom >= effectiveFrom || + (previous.effectiveTo && previous.effectiveTo < effectiveFrom)) + ) + throw new ConflictException( + 'The period changed or cannot be superseded at this date; reload the history', + ); + if ( + periods.some( + (period) => + period.id !== previous?.id && + (!effectiveTo || period.effectiveFrom <= effectiveTo) && + (!period.effectiveTo || period.effectiveTo >= effectiveFrom), + ) + ) + throw new ConflictException( + 'Contract periods must not overlap; explicitly supersede the covering period', + ); + if (previous) { + const result = await tx.employeeContractPeriod.updateMany({ + where: { id: previous.id, revision: dto.expectedRevision }, + data: { + effectiveTo: new Date(effectiveFrom.getTime() - DAY_MS), + revision: { increment: 1 }, + }, + }); + if (result.count !== 1) + throw new ConflictException('Contract history changed; reload'); + const after = await tx.employeeContractPeriod.findUniqueOrThrow({ + where: { id: previous.id }, + }); + await tx.employeeContractPeriodAudit.create({ + data: { + periodId: previous.id, + actorId: actor.id, + action: 'Superseded', + before: toContractPeriodDto( + previous, + ) as unknown as Prisma.InputJsonObject, + after: toContractPeriodDto( + after, + ) as unknown as Prisma.InputJsonObject, + }, + }); + } + const period = await tx.employeeContractPeriod.create({ + data: { + employeeId, + effectiveFrom, + effectiveTo, + employmentStart, + employmentEnd, + weeklyHours: dto.weeklyHours, + timeModel: dto.timeModel, + workingDays: dto.workingDays, + holidayCalendar: dto.holidayCalendar, + holidayDates: [...dto.holidayDates].sort(), + scheduleLabel: dto.scheduleLabel?.trim() || null, + confirmationNote: dto.confirmationNote.trim(), + confirmedById: actor.id, + }, + }); + const after = toContractPeriodDto(period); + await tx.employeeContractPeriodAudit.create({ + data: { + periodId: period.id, + actorId: actor.id, + action: 'Confirmed', + after: after as unknown as Prisma.InputJsonObject, + }, + }); + return after; + }); + } + + private async assertHr(tx: Prisma.TransactionClient, identity: JwtUser) { + const settings = await tx.installationSettings.findUnique({ + where: { id: 1 }, + }); + if (settings?.mode === 'Solo') + throw new ForbiddenException( + 'Contract history management is a Team capability', + ); + const actor = await tx.employee.findUnique({ + where: { id: identity.id }, + select: { role: true, isActive: true, authVersion: true }, + }); + if ( + !actor?.isActive || + actor.role !== 'HRAdmin' || + actor.authVersion !== (identity.authVersion ?? 0) + ) + throw new ForbiddenException( + 'Only active HR may confirm contract history', + ); + } +} diff --git a/apps/api/src/app/employees/employees.module.ts b/apps/api/src/app/employees/employees.module.ts index b13164d..11b200c 100644 --- a/apps/api/src/app/employees/employees.module.ts +++ b/apps/api/src/app/employees/employees.module.ts @@ -1,3 +1,5 @@ +import { ContractPeriodsController } from './contract-periods.controller'; +import { ContractPeriodsService } from './contract-periods.service'; import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { EmployeesController } from './employees.controller'; @@ -5,8 +7,8 @@ import { EmployeesService } from './employees.service'; @Module({ imports: [AuthModule], - controllers: [EmployeesController], - providers: [EmployeesService], + controllers: [EmployeesController, ContractPeriodsController], + providers: [EmployeesService, ContractPeriodsService], exports: [EmployeesService], }) export class EmployeesModule {} diff --git a/apps/api/src/app/reports/reports.controller.ts b/apps/api/src/app/reports/reports.controller.ts index 4cf0e10..7dba8a4 100644 --- a/apps/api/src/app/reports/reports.controller.ts +++ b/apps/api/src/app/reports/reports.controller.ts @@ -54,17 +54,22 @@ export class ReportsController { } @Get('working-times/employees') + @Roles('Manager', 'HRAdmin') @ApiOkResponse({ type: [WorkingTimeReportEmployeeDto] }) - workingTimeEmployees(): Promise { - return this.reports.workingTimeEmployees(); + workingTimeEmployees( + @CurrentUser() user: JwtUser, + ): Promise { + return this.reports.workingTimeEmployees(user.id); } @Get('working-times') + @Roles('Manager', 'HRAdmin') @ApiOkResponse({ type: WorkingTimeReportDto }) workingTimes( + @CurrentUser() user: JwtUser, @Query() query: WorkingTimeReportQueryDto, ): Promise { - return this.reports.workingTimes(query); + return this.reports.workingTimes(user.id, query); } @Get('working-times.pdf') @@ -76,10 +81,11 @@ export class ReportsController { }, }) async workingTimesPdf( + @CurrentUser() user: JwtUser, @Query() query: WorkingTimePdfQueryDto, @Res() response: Response, ): Promise { - const pdf = await this.reports.workingTimesPdf(query); + const pdf = await this.reports.workingTimesPdf(user.id, query); response.setHeader('Content-Type', 'application/pdf'); response.setHeader( 'Content-Disposition', diff --git a/apps/api/src/app/reports/reports.dto.ts b/apps/api/src/app/reports/reports.dto.ts index d953fcf..e191381 100644 --- a/apps/api/src/app/reports/reports.dto.ts +++ b/apps/api/src/app/reports/reports.dto.ts @@ -30,7 +30,7 @@ export class WorkingTimeReportQueryDto { @ApiPropertyOptional({ type: [String], description: - 'Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees; cannot be combined with employeeId.', + 'Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees in the actor’s scope (HR: all; Manager: self and direct reports); cannot be combined with employeeId.', }) @IsOptional() @Transform(({ value }) => (Array.isArray(value) ? value : [value])) @@ -101,7 +101,7 @@ export class WorkingTimeReportRowDto { @ApiProperty({ format: 'date-time' }) clockOut!: string; - @ApiProperty({ enum: ['Open', 'Pending', 'Approved'] }) + @ApiProperty({ enum: ['Pending', 'Approved'] }) status!: string; @ApiProperty({ minimum: 0 }) @@ -131,6 +131,50 @@ export class WorkingTimeReportTotalsDto { netMinutes!: number; } +export class WorkingTimeComparisonDto { + @ApiProperty({ format: 'uuid' }) + employeeId!: string; + + @ApiProperty() + employeeName!: string; + + @ApiProperty({ type: Number, nullable: true, minimum: 0 }) + targetMinutes!: number | null; + + @ApiProperty({ minimum: 0 }) + actualMinutes!: number; + + @ApiProperty({ type: Number, nullable: true }) + differenceMinutes!: number | null; + + @ApiProperty({ enum: ['Known', 'Unknown'] }) + targetStatus!: 'Known' | 'Unknown'; + + @ApiProperty({ + type: [String], + description: + 'Calendar dates lacking a confirmed contract snapshot, including non-working dates.', + }) + unknownDates!: string[]; + + @ApiProperty({ type: String, enum: ['CONTRACT_HISTORY_GAP'], nullable: true }) + reason!: 'CONTRACT_HISTORY_GAP' | null; + + @ApiProperty({ + enum: ['Unknown', 'Negative', 'Balanced', 'Positive', 'Excess'], + description: + 'Selected-period review indicator only; not a legal limit or a carried overtime balance. Excess means difference >= 600 minutes.', + }) + indicator!: 'Unknown' | 'Negative' | 'Balanced' | 'Positive' | 'Excess'; + + @ApiProperty({ + minimum: 0, + description: + 'Non-voided, non-rejected open entries started before the period end; excluded from actual minutes.', + }) + openEntryCount!: number; +} + export class WorkingTimeReportDto { @ApiProperty({ format: 'date' }) from!: string; @@ -138,6 +182,21 @@ export class WorkingTimeReportDto { @ApiProperty({ format: 'date' }) to!: string; + @ApiProperty() + timeZone!: string; + + @ApiProperty({ enum: ['net_working_time'] }) + timeDefinition!: 'net_working_time'; + + @ApiProperty({ + enum: [600], + description: 'Period-review threshold, not a statutory working-time limit.', + }) + excessThresholdMinutes!: 600; + + @ApiProperty({ type: [WorkingTimeComparisonDto] }) + comparisons!: WorkingTimeComparisonDto[]; + @ApiProperty({ type: [WorkingTimeReportRowDto] }) rows!: WorkingTimeReportRowDto[]; diff --git a/apps/api/src/app/reports/reports.service.ts b/apps/api/src/app/reports/reports.service.ts index f60d2d8..dc1a5d9 100644 --- a/apps/api/src/app/reports/reports.service.ts +++ b/apps/api/src/app/reports/reports.service.ts @@ -1,10 +1,15 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + Injectable, +} from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { Prisma } from '@prisma/client'; import { InstallationService } from '../installation/installation.service'; import { calculateCaptureSummaries } from '../time-entries/capture-summary'; import { type WorkingTimeReportEmployeeDto, + type WorkingTimeReportRowDto, type WorkingTimeReportDto, type WorkingTimeReportQueryDto, type WorkingTimePdfQueryDto, @@ -13,6 +18,10 @@ import { type SoloReportRowDto, } from './reports.dto'; import { renderWorkingTimePdf } from './working-time-pdf'; +import { + compareWorkingTime, + EXCESS_THRESHOLD_MINUTES, +} from './working-time-comparison'; const DAY_MS = 86_400_000; const MAX_REPORT_DAYS = 366; @@ -271,25 +280,82 @@ export class ReportsService { ); } - async workingTimeEmployees(): Promise { - return this.prisma.employee.findMany({ - select: { id: true, firstName: true, lastName: true }, - orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }], - }); + async workingTimeEmployees( + actorId: string, + ): Promise { + return this.prisma.$transaction( + (tx) => this.scopedEmployees(actorId, undefined, tx), + { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead }, + ); } async workingTimes( + actorId: string, query: WorkingTimeReportQueryDto, ): Promise { - return this.prisma.$transaction((tx) => this.buildWorkingTimes(query, tx), { - isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, + return this.prisma.$transaction( + (tx) => this.buildWorkingTimes(actorId, query, tx), + { + isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, + }, + ); + } + + /** Re-read the active server-side actor inside the report snapshot. */ + private async scopedEmployees( + actorId: string, + selectedIds: string[] | undefined, + tx: Prisma.TransactionClient, + hrOnly = false, + ): Promise { + const settings = await tx.installationSettings.findUnique({ + where: { id: 1 }, + }); + if (settings?.mode === 'Solo') + throw new ForbiddenException('Team reports are disabled in Solo mode'); + const actor = await tx.employee.findUnique({ + where: { id: actorId }, + select: { id: true, role: true, isActive: true }, }); + if ( + !actor?.isActive || + !['HRAdmin', 'Manager'].includes(actor.role) || + (hrOnly && actor.role !== 'HRAdmin') + ) { + throw new ForbiddenException( + 'Working-time reports require an active authorized actor', + ); + } + const scope: Prisma.EmployeeWhereInput = + actor.role === 'HRAdmin' + ? {} + : { OR: [{ id: actor.id }, { managerId: actor.id }] }; + const employees = await tx.employee.findMany({ + where: { ...scope, ...(selectedIds ? { id: { in: selectedIds } } : {}) }, + select: { id: true, firstName: true, lastName: true }, + orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }, { id: 'asc' }], + }); + // Validate IDs independently of time entries: even an empty foreign report + // is forbidden, and mixtures never silently drop inaccessible employees. + if (selectedIds && employees.length !== selectedIds.length) { + if (actor.role === 'Manager') + throw new ForbiddenException( + 'Employee selection is outside your report scope', + ); + throw new BadRequestException('One or more employees do not exist'); + } + return employees; } - async workingTimesPdf(query: WorkingTimePdfQueryDto): Promise { + async workingTimesPdf( + actorId: string, + query: WorkingTimePdfQueryDto, + ): Promise { const input = await this.prisma.$transaction( async (tx) => { + await this.scopedEmployees(actorId, undefined, tx, true); const report = await this.buildWorkingTimes( + actorId, { ...query, includeLocations: 'false' }, tx, ); @@ -300,7 +366,8 @@ export class ReportsService { ? [query.employeeId] : query.employeeIds; const periodEntries: Prisma.TimeEntryWhereInput = { - clockIn: { gte: from, lt: to }, + clockIn: { lt: to }, + OR: [{ clockOut: { gt: from } }, { clockOut: null }], voidedAt: null, status: { not: 'Rejected' }, }; @@ -345,6 +412,7 @@ export class ReportsService { } private async buildWorkingTimes( + actorId: string, query: WorkingTimeReportQueryDto, tx: Prisma.TransactionClient, ): Promise { @@ -373,19 +441,83 @@ export class ReportsService { to.date.getDate() + 1, ); - const entries = await tx.timeEntry.findMany({ - where: { - employeeId: - query.employeeId ?? - (query.employeeIds ? { in: query.employeeIds } : undefined), - clockIn: { gte: from.date, lt: toExclusive }, - clockOut: { not: null }, - status: { not: 'Rejected' }, - voidedAt: null, - }, - orderBy: [{ clockIn: 'asc' }, { employeeId: 'asc' }], - include: { employee: { select: { firstName: true, lastName: true } } }, - }); + const employees = await this.scopedEmployees( + actorId, + query.employeeId ? [query.employeeId] : query.employeeIds, + tx, + ); + const employeeIds = employees.map((employee) => employee.id); + // Query by interval overlap, not booking start date. Open timers never + // contribute provisional minutes to this closed-entry period comparison. + const [entries, contracts, requests, absences, openCounts] = + await Promise.all([ + tx.timeEntry.findMany({ + where: { + employeeId: { in: employeeIds }, + clockIn: { lt: toExclusive }, + clockOut: { gt: from.date }, + status: { in: ['Pending', 'Approved'] }, + voidedAt: null, + }, + orderBy: [{ clockIn: 'asc' }, { employeeId: 'asc' }, { id: 'asc' }], + include: { + employee: { select: { firstName: true, lastName: true } }, + }, + }), + tx.employeeContractPeriod.findMany({ + where: { + employeeId: { in: employeeIds }, + effectiveFrom: { lte: new Date(`${query.to}T00:00:00.000Z`) }, + OR: [ + { effectiveTo: null }, + { effectiveTo: { gte: new Date(`${query.from}T00:00:00.000Z`) } }, + ], + }, + orderBy: [ + { effectiveFrom: 'desc' }, + { revision: 'desc' }, + { createdAt: 'desc' }, + { id: 'desc' }, + ], + }), + tx.request.findMany({ + where: { + employeeId: { in: employeeIds }, + type: { in: ['Vacation', 'SpecialLeave'] }, + workflowState: 'Approved', + cancelledAt: null, + from: { + lt: new Date( + Date.UTC( + to.date.getFullYear(), + to.date.getMonth(), + to.date.getDate() + 1, + ), + ), + }, + to: { gte: new Date(`${query.from}T00:00:00.000Z`) }, + }, + }), + tx.absence.findMany({ + where: { + employeeId: { in: employeeIds }, + kind: { in: ['Sickness', 'Training'] }, + from: { lte: new Date(`${query.to}T00:00:00.000Z`) }, + to: { gte: new Date(`${query.from}T00:00:00.000Z`) }, + }, + }), + tx.timeEntry.groupBy({ + by: ['employeeId'], + where: { + employeeId: { in: employeeIds }, + clockIn: { lt: toExclusive }, + clockOut: null, + status: { not: 'Rejected' }, + voidedAt: null, + }, + _count: { _all: true }, + }), + ]); const groupIds = [ ...new Set( @@ -402,7 +534,7 @@ export class ReportsService { in: [...new Set(entries.map((entry) => entry.employeeId))], }, voidedAt: null, - status: { not: 'Rejected' }, + status: { in: ['Pending', 'Approved'] }, clockOut: { not: null }, }, }) @@ -412,48 +544,112 @@ export class ReportsService { [...entries, ...siblings].map((entry) => [entry.id, entry]), ).values(), ]); - const rows = entries.flatMap((entry) => { + const rows: WorkingTimeReportRowDto[] = []; + for (const entry of entries) { const summary = summaries.get(entry.id); - if (!summary) return []; - // Reports retain the precision of their timestamps while keeping the - // captured break rules and capture-group allocation used by accounts. - const grossMinutes = - ((entry.clockOut as Date).getTime() - entry.clockIn.getTime()) / 60_000; - return [ - { + if (!entry.clockOut || !summary) continue; + const duration = entry.clockOut.getTime() - entry.clockIn.getTime(); + if (duration <= 0) continue; + const clippedEnd = Math.min( + entry.clockOut.getTime(), + toExclusive.getTime(), + ); + let cursor = new Date( + Math.max(entry.clockIn.getTime(), from.date.getTime()), + ); + while (cursor.getTime() < clippedEnd) { + const midnight = new Date( + cursor.getFullYear(), + cursor.getMonth(), + cursor.getDate() + 1, + ); + const rowEnd = Math.min(clippedEnd, midnight.getTime()); + const ratio = (rowEnd - cursor.getTime()) / duration; + // Keep exact timestamp minutes, including historical ungrouped entries, + // while taking captured break deductions from the complete group. + const grossMinutes = (rowEnd - cursor.getTime()) / 60_000; + const breakMinutes = summary.breakMinutes * ratio; + rows.push({ id: entry.id, employeeId: entry.employeeId, employeeName: `${entry.employee.firstName} ${entry.employee.lastName}`, - date: localDate(entry.clockIn), - clockIn: entry.clockIn.toISOString(), - clockOut: (entry.clockOut as Date).toISOString(), + date: localDate(cursor), + clockIn: cursor.toISOString(), + clockOut: new Date(rowEnd).toISOString(), status: entry.status, ...(includeLocations ? { - clockInLocation: reportLocation( - entry.terminalLocationLabel, - entry.latitude, - entry.longitude, - entry.accuracyMeters, - ), - clockOutLocation: reportLocation( - entry.clockOutTerminalLocationLabel, - entry.clockOutLatitude, - entry.clockOutLongitude, - entry.clockOutAccuracyMeters, - ), + // Synthetic midnight/period edges are not physical clock + // events and must not inherit an unrelated GPS observation. + clockInLocation: + cursor.getTime() === entry.clockIn.getTime() + ? reportLocation( + entry.terminalLocationLabel, + entry.latitude, + entry.longitude, + entry.accuracyMeters, + ) + : null, + clockOutLocation: + rowEnd === entry.clockOut.getTime() + ? reportLocation( + entry.clockOutTerminalLocationLabel, + entry.clockOutLatitude, + entry.clockOutLongitude, + entry.clockOutAccuracyMeters, + ) + : null, } : {}), - ...summary, grossMinutes, - netMinutes: grossMinutes - summary.breakMinutes, - }, - ]; - }); + breakMinutes, + netMinutes: grossMinutes - breakMinutes, + }); + cursor = new Date(rowEnd); + } + } + // Splitting a long entry must not move its later days ahead of another + // employee's earlier booking rows. + rows.sort( + (left, right) => + left.clockIn.localeCompare(right.clockIn) || + left.employeeId.localeCompare(right.employeeId) || + left.id.localeCompare(right.id), + ); + const actualByEmployee = new Map(); + for (const row of rows) { + actualByEmployee.set( + row.employeeId, + (actualByEmployee.get(row.employeeId) ?? 0) + row.netMinutes, + ); + } + const openByEmployee = new Map( + openCounts.map((row) => [row.employeeId, row._count._all]), + ); + const comparisons = employees.map((employee) => + compareWorkingTime({ + employeeId: employee.id, + employeeName: `${employee.firstName} ${employee.lastName}`, + from: query.from, + to: query.to, + snapshots: contracts.filter( + (period) => period.employeeId === employee.id, + ), + excused: [...requests, ...absences].filter( + (range) => range.employeeId === employee.id, + ), + actualMinutes: actualByEmployee.get(employee.id) ?? 0, + openEntryCount: openByEmployee.get(employee.id) ?? 0, + }), + ); return { from: query.from, to: query.to, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + timeDefinition: 'net_working_time', + excessThresholdMinutes: EXCESS_THRESHOLD_MINUTES, + comparisons, rows, totals: rows.reduce( (totals, row) => ({ diff --git a/apps/api/src/app/reports/working-time-comparison.ts b/apps/api/src/app/reports/working-time-comparison.ts new file mode 100644 index 0000000..832ce4d --- /dev/null +++ b/apps/api/src/app/reports/working-time-comparison.ts @@ -0,0 +1,132 @@ +import { + calculateDailyTargetMinutes, + holidayProviderForCalendar, +} from 'shared'; +import type { WorkingTimeComparisonDto } from './reports.dto'; + +export const EXCESS_THRESHOLD_MINUTES = 600; +const DAY_MS = 86_400_000; + +/** Only persisted, explicitly confirmed facts belong here, never live employee defaults. */ +interface ContractSnapshot { + effectiveFrom: Date; + effectiveTo: Date | null; + employmentStart: Date; + employmentEnd: Date | null; + weeklyHours: { toString(): string } | number; + workingDays: number; + holidayCalendar: string; + holidayDates: string[]; +} + +interface ExcusedRange { + from: Date; + to: Date; + halfDayStart?: boolean; + halfDayEnd?: boolean; +} + +/** Date-valued requests and absences use UTC calendar keys, not local instants. */ +function dateKey(value: Date): string { + return value.toISOString().slice(0, 10); +} + +function coverage(range: ExcusedRange, key: string): number { + const from = dateKey(range.from); + const to = dateKey(range.to); + if (key < from || key > to) return 0; + // A one-day request with both endpoint flags still excuses just half a day. + return (key === from && range.halfDayStart) || + (key === to && range.halfDayEnd) + ? 0.5 + : 1; +} + +export function compareWorkingTime(input: { + employeeId: string; + employeeName: string; + from: string; + to: string; + /** Most recent effectiveFrom/revision first. Effective end dates are inclusive. */ + snapshots: readonly ContractSnapshot[]; + /** Approved Vacation/SpecialLeave and recorded Sickness/Training only. */ + excused: readonly ExcusedRange[]; + actualMinutes: number; + openEntryCount: number; +}): WorkingTimeComparisonDto { + const unknownDates: string[] = []; + let knownTarget = 0; + const holidays = new Map< + ContractSnapshot, + ReturnType + >(); + for ( + let day = new Date(`${input.from}T00:00:00.000Z`); + dateKey(day) <= input.to; + day = new Date(day.getTime() + DAY_MS) + ) { + const key = dateKey(day); + const snapshot = input.snapshots.find( + (period) => + dateKey(period.effectiveFrom) <= key && + (!period.effectiveTo || dateKey(period.effectiveTo) >= key), + ); + // No inference from the current employee, even for weekends or prehire dates. + if (!snapshot) { + unknownDates.push(key); + continue; + } + if ( + key < dateKey(snapshot.employmentStart) || + (snapshot.employmentEnd && key > dateKey(snapshot.employmentEnd)) + ) + continue; + if ((snapshot.workingDays & (1 << ((day.getUTCDay() + 6) % 7))) === 0) + continue; + let calendar = holidays.get(snapshot); + if (!calendar) { + calendar = holidayProviderForCalendar( + snapshot.holidayCalendar, + snapshot.holidayDates, + ); + holidays.set(snapshot, calendar); + } + if (calendar.isHoliday(day)) continue; + // The same day cannot be excused twice. Overlapping half-day records use the + // PersonalSummary max/union rule, not additive synthetic half-day credits. + const excused = Math.min( + 1, + Math.max(0, ...input.excused.map((range) => coverage(range, key))), + ); + knownTarget += + calculateDailyTargetMinutes( + Number(snapshot.weeklyHours), + snapshot.workingDays, + ) * + (1 - excused); + } + const targetMinutes = unknownDates.length ? null : knownTarget; + const differenceMinutes = + targetMinutes === null ? null : input.actualMinutes - targetMinutes; + return { + employeeId: input.employeeId, + employeeName: input.employeeName, + targetMinutes, + actualMinutes: input.actualMinutes, + differenceMinutes, + targetStatus: targetMinutes === null ? 'Unknown' : 'Known', + unknownDates, + reason: targetMinutes === null ? 'CONTRACT_HISTORY_GAP' : null, + indicator: + differenceMinutes === null + ? 'Unknown' + : differenceMinutes < 0 + ? 'Negative' + : differenceMinutes === 0 + ? 'Balanced' + : differenceMinutes >= EXCESS_THRESHOLD_MINUTES + ? 'Excess' + : 'Positive', + openEntryCount: input.openEntryCount, + }; +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 9d6aaad..5fd9611 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -109,6 +109,45 @@ export interface EmployeeDto { isActive: boolean; } +/** Confirmed, self-contained historical facts; never a live schedule reference. */ +export interface ContractPeriodDto { + id: string; + employeeId: string; + effectiveFrom: string; + effectiveTo: string | null; + employmentStart: string; + employmentEnd: string | null; + weeklyHours: number; + timeModel: TimeModel; + workingDays: number; + holidayCalendar: HolidayCalendar; + holidayDates: string[]; + scheduleLabel: string | null; + confirmationNote: string; + confirmedById: string | null; + revision: number; + createdAt: string; +} + +type ContractPeriodSupersession = + | { supersedesPeriodId: string; expectedRevision: number } + | { supersedesPeriodId?: never; expectedRevision?: never }; + +export type CreateContractPeriodPayload = Pick< + ContractPeriodDto, + | 'effectiveFrom' + | 'effectiveTo' + | 'employmentStart' + | 'employmentEnd' + | 'weeklyHours' + | 'timeModel' + | 'workingDays' + | 'holidayCalendar' + | 'holidayDates' + | 'scheduleLabel' + | 'confirmationNote' +> & { confirmed: true } & ContractPeriodSupersession; + export interface CreateEmployeePayload { personalNo: string; firstName: string; @@ -401,11 +440,28 @@ export interface WorkingTimeReportTotalsDto { netMinutes: number; } +export interface WorkingTimeComparisonDto { + employeeId: string; + employeeName: string; + targetMinutes: number | null; + actualMinutes: number; + differenceMinutes: number | null; + targetStatus: 'Known' | 'Unknown'; + unknownDates: string[]; + reason: 'CONTRACT_HISTORY_GAP' | null; + indicator: 'Unknown' | 'Negative' | 'Balanced' | 'Positive' | 'Excess'; + openEntryCount: number; +} + export interface WorkingTimeReportDto { from: string; to: string; rows: WorkingTimeReportRowDto[]; totals: WorkingTimeReportTotalsDto; + comparisons: WorkingTimeComparisonDto[]; + timeZone: string; + timeDefinition: 'net_working_time'; + excessThresholdMinutes: 600; } export interface UpsertProjectPayload { @@ -980,6 +1036,21 @@ export const api = { method: 'PUT', body: JSON.stringify(payload), }), + contractPeriods: (employeeId: string) => + request( + `/api/employees/${employeeId}/contract-periods`, + ), + createContractPeriod: ( + employeeId: string, + payload: CreateContractPeriodPayload, + ) => + request( + `/api/employees/${employeeId}/contract-periods`, + { + method: 'POST', + body: JSON.stringify(payload), + }, + ), setEmployeePassword: (id: string, password: string) => request(`/api/employees/${id}/password`, { method: 'POST', diff --git a/apps/web/src/api/generated.ts b/apps/web/src/api/generated.ts index 1972404..8a3fa11 100644 --- a/apps/web/src/api/generated.ts +++ b/apps/web/src/api/generated.ts @@ -388,6 +388,22 @@ export interface paths { patch?: never; trace?: never; }; + '/api/employees/{id}/contract-periods': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['ContractPeriodsController_list']; + put?: never; + post: operations['ContractPeriodsController_confirm']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/work-schedules': { parameters: { query?: never; @@ -2142,6 +2158,91 @@ export interface components { SetPasswordDto: { password: string; }; + EmployeeContractPeriodDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + employeeId: string; + /** Format: date */ + effectiveFrom: string; + /** Format: date */ + effectiveTo: string | null; + /** Format: date */ + employmentStart: string; + /** Format: date */ + employmentEnd: string | null; + weeklyHours: number; + /** @enum {string} */ + timeModel: + | 'Teilzeit' + | 'Vollzeit' + | 'Vertrauensarbeitszeit' + | 'Gleitzeit'; + workingDays: number; + holidayCalendar: string; + holidayDates: string[]; + scheduleLabel: string | null; + confirmationNote: string; + /** Format: uuid */ + confirmedById: string | null; + revision: number; + /** Format: date-time */ + createdAt: string; + }; + ConfirmContractPeriodDto: { + /** Format: date */ + effectiveFrom: string; + /** Format: date */ + effectiveTo?: string | null; + /** Format: date */ + employmentStart: string; + /** Format: date */ + employmentEnd?: string | null; + weeklyHours: number; + /** @enum {string} */ + timeModel: + | 'Teilzeit' + | 'Vollzeit' + | 'Vertrauensarbeitszeit' + | 'Gleitzeit'; + /** @description Immutable weekday mask: Mon=1, Tue=2, ..., Sun=64. Equal daily target, rounded to whole minutes. */ + workingDays: number; + /** @enum {string} */ + holidayCalendar: + | 'NONE' + | 'DE-BW' + | 'DE-BY' + | 'DE-BE' + | 'DE-BB' + | 'DE-HB' + | 'DE-HH' + | 'DE-HE' + | 'DE-MV' + | 'DE-NI' + | 'DE-NW' + | 'DE-RP' + | 'DE-SL' + | 'DE-SN' + | 'DE-ST' + | 'DE-SH' + | 'DE-TH'; + /** @description Explicit non-working dates, supplementing the snapshot calendar. */ + holidayDates: string[]; + scheduleLabel?: string | null; + /** @description HR source/confirmation explanation. No automatic historical inference. */ + confirmationNote: string; + /** + * @description Explicit confirmation that these dated facts are known, not inferred from the current employee profile. + * @enum {boolean} + */ + confirmed: true; + /** + * Format: uuid + * @description Explicitly close this prior period on the day before effectiveFrom; no other facts are changed. + */ + supersedesPeriodId?: string; + expectedRevision?: number; + }; BreakRuleDto: { /** @description Inclusive attendance threshold in minutes. */ afterMinutes: number; @@ -2318,6 +2419,27 @@ export interface components { firstName: string; lastName: string; }; + WorkingTimeComparisonDto: { + /** Format: uuid */ + employeeId: string; + employeeName: string; + targetMinutes: number | null; + actualMinutes: number; + differenceMinutes: number | null; + /** @enum {string} */ + targetStatus: 'Known' | 'Unknown'; + /** @description Calendar dates lacking a confirmed contract snapshot, including non-working dates. */ + unknownDates: string[]; + /** @enum {string|null} */ + reason: 'CONTRACT_HISTORY_GAP' | null; + /** + * @description Selected-period review indicator only; not a legal limit or a carried overtime balance. Excess means difference >= 600 minutes. + * @enum {string} + */ + indicator: 'Unknown' | 'Negative' | 'Balanced' | 'Positive' | 'Excess'; + /** @description Non-voided, non-rejected open entries started before the period end; excluded from actual minutes. */ + openEntryCount: number; + }; WorkingTimeReportLocationDto: { label: string | null; latitude: number | null; @@ -2337,7 +2459,7 @@ export interface components { /** Format: date-time */ clockOut: string; /** @enum {string} */ - status: 'Open' | 'Pending' | 'Approved'; + status: 'Pending' | 'Approved'; grossMinutes: number; breakMinutes: number; netMinutes: number; @@ -2358,6 +2480,15 @@ export interface components { from: string; /** Format: date */ to: string; + timeZone: string; + /** @enum {string} */ + timeDefinition: 'net_working_time'; + /** + * @description Period-review threshold, not a statutory working-time limit. + * @enum {number} + */ + excessThresholdMinutes: 600; + comparisons: components['schemas']['WorkingTimeComparisonDto'][]; rows: components['schemas']['WorkingTimeReportRowDto'][]; totals: components['schemas']['WorkingTimeReportTotalsDto']; }; @@ -3829,6 +3960,52 @@ export interface operations { }; }; }; + ContractPeriodsController_list: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['EmployeeContractPeriodDto'][]; + }; + }; + }; + }; + ContractPeriodsController_confirm: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ConfirmContractPeriodDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['EmployeeContractPeriodDto']; + }; + }; + }; + }; WorkSchedulesController_list: { parameters: { query?: never; @@ -4336,7 +4513,7 @@ export interface operations { from: string; to: string; employeeId?: string; - /** @description Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees; cannot be combined with employeeId. */ + /** @description Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees in the actor’s scope (HR: all; Manager: self and direct reports); cannot be combined with employeeId. */ employeeIds?: string[]; includeLocations?: boolean; }; @@ -4362,7 +4539,7 @@ export interface operations { from: string; to: string; employeeId?: string; - /** @description Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees; cannot be combined with employeeId. */ + /** @description Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees in the actor’s scope (HR: all; Manager: self and direct reports); cannot be combined with employeeId. */ employeeIds?: string[]; locale?: 'de' | 'en'; }; diff --git a/apps/web/src/app/app.tsx b/apps/web/src/app/app.tsx index a15e58f..1485a46 100644 --- a/apps/web/src/app/app.tsx +++ b/apps/web/src/app/app.tsx @@ -195,10 +195,12 @@ export function App() { } /> )} } /> - } - /> + {(user.role === 'Manager' || user.role === 'HRAdmin') && ( + } + /> + )} } /> } /> ; @@ -16,6 +17,7 @@ type Catalog = Record; const STORAGE_KEY = 'openclockwork.locale'; const de: Catalog = { + ...workingTimeDe, 'requests.appliedBy': 'Angewendet von {actor}', 'requests.unknownActor': 'Nicht mehr verfügbare Person', 'frameApproval.ownTitle': 'Rahmenzeit-Genehmigungen für meine Zeitbuchungen', @@ -799,6 +801,7 @@ const de: Catalog = { }; const en: Catalog = { + ...workingTimeEn, 'requests.appliedBy': 'Applied by {actor}', 'requests.unknownActor': 'Reviewer no longer available', 'frameApproval.ownTitle': 'Approvals for my out-of-frame time entries', diff --git a/apps/web/src/app/navigation.spec.ts b/apps/web/src/app/navigation.spec.ts index e819bac..6d5b657 100644 --- a/apps/web/src/app/navigation.spec.ts +++ b/apps/web/src/app/navigation.spec.ts @@ -23,11 +23,16 @@ describe('role-aware navigation', () => { ), ).toBe(false); }); - it('always exposes working-time reports to HR admins only', () => { + it('exposes working-time reports to Managers and HR admins, not employees', () => { expect( visibleNavItems('Manager').some( (item) => item.to === '/admin/working-times', ), + ).toBe(true); + expect( + visibleNavItems('Employee').some( + (item) => item.to === '/admin/working-times', + ), ).toBe(false); expect( visibleNavItems('HRAdmin').some( diff --git a/apps/web/src/app/navigation.ts b/apps/web/src/app/navigation.ts index 5e1519a..d94ae6f 100644 --- a/apps/web/src/app/navigation.ts +++ b/apps/web/src/app/navigation.ts @@ -94,7 +94,7 @@ export const navItems: NavItem[] = [ to: '/admin/working-times', labelKey: 'nav.workingTimes', icon: FileBarChart, - roles: ['HRAdmin'], + roles: ['Manager', 'HRAdmin'], showInBottomNav: false, }, { diff --git a/apps/web/src/app/working-time-i18n.ts b/apps/web/src/app/working-time-i18n.ts new file mode 100644 index 0000000..8b09a6d --- /dev/null +++ b/apps/web/src/app/working-time-i18n.ts @@ -0,0 +1,171 @@ +export const workingTimeDe: Record = { + 'reports.accessDenied': + 'Diese Seite ist Führungskräften und HR-Admins vorbehalten.', + 'reports.scope.Manager': + 'Eigene Teammitglieder – der Server begrenzt die Auswahl und Auswertung auf dein zugewiesenes Team.', + 'reports.scope.HRAdmin': + 'Alle Mitarbeiter:innen – HR kann organisationsweit auswerten.', + 'reports.period': 'Zeitraum', + 'reports.week': 'Woche', + 'reports.month': 'Monat', + 'reports.custom': 'Benutzerdefiniert', + 'reports.employeesFailed': + 'Die Mitarbeiter-Auswahl konnte nicht geladen werden.', + 'comparison.title': 'Soll/Ist-Vergleich', + 'comparison.description': + 'Vertragliches Soll und erfasste Netto-Arbeitszeit für denselben gewählten Zeitraum – auch für Personen ohne Buchungen.', + 'comparison.target': 'Soll', + 'comparison.actual': 'Ist (vorläufig)', + 'comparison.difference': 'Differenz (Ist − Soll)', + 'comparison.indicator': 'Einordnung', + 'comparison.openEntries': 'Offene Buchungen (nicht enthalten)', + 'comparison.hours': '{hours} h', + 'comparison.period': + '{from} – {to} · Zeitzone: {timeZone} · Netto-Arbeitszeit', + 'comparison.provisional': + 'Vorläufiges Ist: Abgeschlossene Buchungen mit Status „Ausstehend“ oder „Genehmigt“ zählen nach Pausenabzug. Offene, abgelehnte und ungültige Buchungen zählen nicht.', + 'comparison.notAccount': + 'Die Differenz gilt nur für diesen Zeitraum. Sie ist kein fortgeschriebenes Überstundenkonto und enthält keinen Eröffnungssaldo.', + 'comparison.reviewHint': + 'Ab +{hours} h Differenz wird eine Prüfung des gewählten Zeitraums empfohlen. Dies ist weder eine gesetzliche Grenze noch eine rechtliche Bewertung.', + 'comparison.empty': + 'Keine Mitarbeiter:innen für die gewählte Auswahl vorhanden.', + 'comparison.historyGap': + 'Soll unbekannt: Bestätigte Vertragshistorie fehlt. HR muss die Vertragsdaten für diese Tage bestätigen.', + 'comparison.unknownDates': 'Tage ohne bestätigte Vertragsdaten: {dates}', + 'comparison.indicator.Unknown': 'Unbekannt', + 'comparison.indicator.Negative': 'Unter Soll', + 'comparison.indicator.Balanced': 'Ausgeglichen', + 'comparison.indicator.Positive': 'Über Soll', + 'comparison.indicator.Excess': 'Prüfung empfohlen (ab +10 h)', + 'contracts.title': 'Bestätigte Vertragszeiträume', + 'contracts.description': + 'Nur HR bestätigt historische Vertragsdaten. Ohne Bestätigung bleiben auch bekannte Altdaten für den Soll-Vergleich unbekannt.', + 'contracts.snapshotHint': + 'Jeder bestätigte Zeitraum speichert eigene Vertrags-, Arbeitstage- und Feiertagsdaten, keinen Live-Verweis auf einen Arbeitszeitplan. Spätere Profil- oder Planänderungen ändern die bestätigte Historie nicht. HR muss dafür einen neuen wirksamen Zeitraum hinzufügen.', + 'contracts.empty': + 'Noch keine bestätigten Vertragszeiträume. Das historische Soll ist unbekannt.', + 'contracts.loadFailed': 'Die Vertragshistorie konnte nicht geladen werden.', + 'contracts.refresh': 'Vertragshistorie aktualisieren', + 'contracts.history': 'Bestätigte Historie (nur lesbar)', + 'contracts.new': 'Neuen Vertragszeitraum bestätigen', + 'contracts.unconfirmed': + 'UNBESTÄTIGTER ENTWURF: Die vorausgefüllten aktuellen Stammdaten und Planwerte sind nur ein Vorschlag, kein Nachweis für historische Vertragsdaten. Bitte alle Angaben für den wirksamen Zeitraum prüfen.', + 'contracts.effectiveFrom': 'Vertrag gültig ab', + 'contracts.effectiveTo': 'Vertrag gültig bis (optional, einschließlich)', + 'contracts.employmentStart': 'Vertraglicher Beschäftigungsbeginn', + 'contracts.employmentEnd': 'Vertragliches Beschäftigungsende (optional)', + 'contracts.weeklyHours': 'Vertrags-Wochenstunden', + 'contracts.timeModel': 'Vertrags-Zeitmodell', + 'contracts.workingDays': 'Vertragliche Arbeitstage', + 'contracts.scheduleLabel': 'Planbezeichnung als Momentaufnahme (optional)', + 'contracts.holidayCalendar': 'Vertraglicher Feiertagskalender', + 'contracts.holidayDates': 'Vertragliche zusätzliche Feiertage', + 'contracts.confirmationNote': 'Bestätigungsnotiz (Pflicht)', + 'contracts.noteHint': + '5–1000 Zeichen: Quelle und Prüfung der Vertragsdaten dokumentieren.', + 'contracts.confirm': + 'Ich bestätige diese Vertragsdaten für den angegebenen Zeitraum ausdrücklich als HR.', + 'contracts.save': 'Vertragszeitraum bestätigen', + 'contracts.saved': + 'Vertragszeitraum bestätigt. Die Auswertung wird aktualisiert.', + 'contracts.saveFailed': 'Der Vertragszeitraum konnte nicht bestätigt werden.', + 'contracts.conflict': + 'Die Vertragshistorie wurde geändert oder der Zeitraum überschneidet sich. Bitte die Historie aktualisieren, den Zeitraum prüfen und gegebenenfalls die Ablösung erneut auswählen.', + 'contracts.supersedes': 'Bisherigen Zeitraum ablösen (optional)', + 'contracts.noSupersession': + 'Eigenständigen, nicht überlappenden Zeitraum hinzufügen', + 'contracts.supersessionHint': + 'Eine Ablösung beendet nur den ausgewählten bisherigen Zeitraum am Tag vor dem neuen Beginn (einschließlich). Der neue Beginn muss später als dessen Beginn und noch innerhalb des bisherigen Zeitraums liegen. Andere Überschneidungen sind nicht zulässig; bestätigte Fakten können nicht überschrieben oder gelöscht werden.', + 'contracts.invalid': + 'Bitte gültige Datumsgrenzen, Wochenstunden (0–168, höchstens 2 Nachkommastellen) und eine Bestätigungsnotiz angeben. Positive Wochenstunden benötigen Arbeitstage mit höchstens 24 Stunden je Tag. Bei einer Ablösung muss der neue Beginn nach dem bisherigen Beginn liegen.', + 'contracts.openEnded': 'unbefristet', + 'contracts.confirmedBy': 'Bestätigt von', + 'contracts.actorUnavailable': 'Person nicht mehr verfügbar', + 'contracts.revision': 'Revision', + 'contracts.createdAt': 'Erstellt am', +}; + +export const workingTimeEn: Record = { + 'reports.accessDenied': 'This page is reserved for managers and HR admins.', + 'reports.scope.Manager': + 'Your team members – the server restricts the selection and report to your assigned team.', + 'reports.scope.HRAdmin': + 'All employees – HR can report across the organisation.', + 'reports.period': 'Period', + 'reports.week': 'Week', + 'reports.month': 'Month', + 'reports.custom': 'Custom', + 'reports.employeesFailed': 'The employee selection could not be loaded.', + 'comparison.title': 'Target/actual comparison', + 'comparison.description': + 'Contractual target and recorded net working time for the same selected period, including employees without entries.', + 'comparison.target': 'Target', + 'comparison.actual': 'Actual (provisional)', + 'comparison.difference': 'Difference (actual − target)', + 'comparison.indicator': 'Indicator', + 'comparison.openEntries': 'Open entries (excluded)', + 'comparison.hours': '{hours} h', + 'comparison.period': + '{from} – {to} · Time zone: {timeZone} · Net working time', + 'comparison.provisional': + 'Provisional actual: Closed entries with status “Pending” or “Approved” count after break deduction. Open, rejected and voided entries are excluded.', + 'comparison.notAccount': + 'The difference applies only to this period. It is not a carried-forward overtime account and includes no opening balance.', + 'comparison.reviewHint': + 'A difference of +{hours} h or more flags the selected period for review. This is neither a statutory limit nor a legal assessment.', + 'comparison.empty': 'No employees for the selected filters.', + 'comparison.historyGap': + 'Target unknown: Confirmed contract history is missing. HR must confirm the contract facts for these dates.', + 'comparison.unknownDates': 'Dates without confirmed contract facts: {dates}', + 'comparison.indicator.Unknown': 'Unknown', + 'comparison.indicator.Negative': 'Below target', + 'comparison.indicator.Balanced': 'Balanced', + 'comparison.indicator.Positive': 'Above target', + 'comparison.indicator.Excess': 'Review recommended (from +10 h)', + 'contracts.title': 'Confirmed contract periods', + 'contracts.description': + 'Only HR confirms historical contract facts. Even known legacy data remains unknown for target comparisons until confirmed.', + 'contracts.snapshotHint': + 'Each confirmed period stores its own contract, working-day and holiday facts, not a live schedule reference. Later profile or schedule edits do not change confirmed history. HR must add a new effective period for changes.', + 'contracts.empty': + 'No confirmed contract periods yet. Historical targets are unknown.', + 'contracts.loadFailed': 'Contract history could not be loaded.', + 'contracts.refresh': 'Refresh contract history', + 'contracts.history': 'Confirmed history (read-only)', + 'contracts.new': 'Confirm a new contract period', + 'contracts.unconfirmed': + 'UNCONFIRMED DRAFT: Prefilled current profile and schedule values are suggestions only, not evidence of historical contract facts. Check every fact for the effective period.', + 'contracts.effectiveFrom': 'Contract effective from', + 'contracts.effectiveTo': 'Contract effective to (optional, inclusive)', + 'contracts.employmentStart': 'Contractual employment start', + 'contracts.employmentEnd': 'Contractual employment end (optional)', + 'contracts.weeklyHours': 'Contract weekly hours', + 'contracts.timeModel': 'Contract time model', + 'contracts.workingDays': 'Contract working days', + 'contracts.scheduleLabel': 'Schedule label snapshot (optional)', + 'contracts.holidayCalendar': 'Contract holiday calendar', + 'contracts.holidayDates': 'Contract additional holidays', + 'contracts.confirmationNote': 'Confirmation note (required)', + 'contracts.noteHint': + '5–1000 characters: Document the source and verification of the contract facts.', + 'contracts.confirm': + 'As HR, I explicitly confirm these contract facts for the stated period.', + 'contracts.save': 'Confirm contract period', + 'contracts.saved': + 'Contract period confirmed. The report is being refreshed.', + 'contracts.saveFailed': 'The contract period could not be confirmed.', + 'contracts.conflict': + 'Contract history changed or the period overlaps. Refresh history, check the dates and reselect the period to supersede if needed.', + 'contracts.supersedes': 'Supersede an existing period (optional)', + 'contracts.noSupersession': 'Add an independent, non-overlapping period', + 'contracts.supersessionHint': + 'Superseding only ends the selected old period on the day before the new start (inclusive). The new start must be later than its start and still within the existing period. Other overlaps are not allowed; confirmed facts cannot be rewritten or deleted.', + 'contracts.invalid': + 'Enter valid date boundaries, weekly hours (0–168, at most 2 decimal places) and a confirmation note. Positive weekly hours require working days with at most 24 hours per day. When superseding, the new start must be later than the old start.', + 'contracts.openEnded': 'open-ended', + 'contracts.confirmedBy': 'Confirmed by', + 'contracts.actorUnavailable': 'Person no longer available', + 'contracts.revision': 'Revision', + 'contracts.createdAt': 'Created at', +}; diff --git a/apps/web/src/app/working-time-routing.spec.tsx b/apps/web/src/app/working-time-routing.spec.tsx new file mode 100644 index 0000000..685e390 --- /dev/null +++ b/apps/web/src/app/working-time-routing.spec.tsx @@ -0,0 +1,46 @@ +import { screen } from '@testing-library/react'; +import { Outlet } from 'react-router-dom'; +import { beforeEach, expect, it, vi } from 'vitest'; +import { renderWithProviders } from '../test-utils'; +import App from './app'; + +const state = vi.hoisted(() => ({ role: 'Manager' })); +vi.mock('./auth', () => ({ + useAuth: () => ({ + user: { id: 'test-user', role: state.role }, + logout: vi.fn(), + }), +})); +vi.mock('./installation', () => ({ + useInstallation: () => ({ + data: { mode: 'Team', setupCompleted: true }, + isLoading: false, + refetch: vi.fn(), + }), +})); +vi.mock('./AppShell', () => ({ AppShell: () => })); +vi.mock('../routes/AdminWorkingTimesPage', () => ({ + AdminWorkingTimesPage: () =>

    Working-time comparison

    , +})); + +beforeEach(() => { + state.role = 'Manager'; +}); +it.each(['Manager', 'HRAdmin'])( + 'allows the direct working-time route for %s', + async (role) => { + state.role = role; + renderWithProviders(, { initialPath: '/admin/working-times' }); + expect( + await screen.findByRole('heading', { name: 'Working-time comparison' }), + ).toBeTruthy(); + }, +); +it('does not expose the route to ordinary employees', async () => { + state.role = 'Employee'; + renderWithProviders(, { initialPath: '/admin/working-times' }); + expect(await screen.findByText('Diese Seite existiert nicht.')).toBeTruthy(); + expect( + screen.queryByRole('heading', { name: 'Working-time comparison' }), + ).toBeNull(); +}); diff --git a/apps/web/src/routes/AdminEmployeesPage.spec.tsx b/apps/web/src/routes/AdminEmployeesPage.spec.tsx index 67e1dbe..30fdffb 100644 --- a/apps/web/src/routes/AdminEmployeesPage.spec.tsx +++ b/apps/web/src/routes/AdminEmployeesPage.spec.tsx @@ -6,11 +6,15 @@ import { AdminEmployeesPage } from './AdminEmployeesPage'; const createEmployee = vi.fn(); const updateEmployee = vi.fn(); const employees = vi.fn(); +const contractPeriods = vi.fn(); +const createContractPeriod = vi.fn(); vi.mock('../api/client', async (importOriginal) => ({ ...(await importOriginal()), api: { employees: (...args: unknown[]) => employees(...args), workSchedules: async () => [], + contractPeriods: (...args: unknown[]) => contractPeriods(...args), + createContractPeriod: (...args: unknown[]) => createContractPeriod(...args), createEmployee: (...args: unknown[]) => createEmployee(...args), updateEmployee: (...args: unknown[]) => updateEmployee(...args), }, @@ -38,7 +42,9 @@ const employee = { }; beforeEach(() => { - employees.mockResolvedValue([]); + employees.mockReset().mockResolvedValue([]); + contractPeriods.mockReset().mockResolvedValue([]); + createContractPeriod.mockReset(); createEmployee.mockReset().mockResolvedValue(employee); updateEmployee.mockReset().mockResolvedValue(employee); }); @@ -46,6 +52,10 @@ beforeEach(() => { it('creates an employee without a Germany-specific calendar and saves custom dates', async () => { renderWithProviders(); fireEvent.click(screen.getByRole('button', { name: 'Neu anlegen' })); + expect( + screen.queryByRole('region', { name: 'Bestätigte Vertragszeiträume' }), + ).toBeNull(); + expect(contractPeriods).not.toHaveBeenCalled(); expect( (screen.getByLabelText('Feiertagskalender') as HTMLSelectElement).value, ).toBe('NONE'); @@ -74,12 +84,19 @@ it('creates an employee without a Germany-specific calendar and saves custom dat }), ), ); + expect(createContractPeriod).not.toHaveBeenCalled(); }); it('updates both the preset calendar and custom dates instead of dropping calendar changes', async () => { employees.mockResolvedValue([employee]); renderWithProviders(); fireEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' })); + expect( + screen.getByRole('region', { name: 'Bestätigte Vertragszeiträume' }), + ).toBeTruthy(); + await waitFor(() => + expect(contractPeriods).toHaveBeenCalledWith(employee.id), + ); fireEvent.change(screen.getByLabelText('Feiertagskalender'), { target: { value: 'DE-BY' }, }); @@ -102,4 +119,5 @@ it('updates both the preset calendar and custom dates instead of dropping calend }), ), ); + expect(createContractPeriod).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/routes/AdminEmployeesPage.tsx b/apps/web/src/routes/AdminEmployeesPage.tsx index b5d29d2..7948f20 100644 --- a/apps/web/src/routes/AdminEmployeesPage.tsx +++ b/apps/web/src/routes/AdminEmployeesPage.tsx @@ -30,6 +30,7 @@ import { import { useCurrentUser } from '../app/auth'; import { useI18n } from '../app/i18n'; import { TerminalPinDialog } from './TerminalPinDialog'; +import { ContractPeriodsPanel } from './ContractPeriodsPanel'; const ROLES: EmployeeRole[] = ['Employee', 'Manager', 'HRAdmin']; const TIME_MODELS: TimeModel[] = [ @@ -685,6 +686,9 @@ function EmployeeEditor({ )} + {!isCreate && seed && ( + + )} {error && ( {error} diff --git a/apps/web/src/routes/AdminWorkingTimesPage.spec.tsx b/apps/web/src/routes/AdminWorkingTimesPage.spec.tsx index 1cb8ec7..bf275a7 100644 --- a/apps/web/src/routes/AdminWorkingTimesPage.spec.tsx +++ b/apps/web/src/routes/AdminWorkingTimesPage.spec.tsx @@ -1,8 +1,23 @@ -import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + screen, + waitFor, + within, +} from '@testing-library/react'; import { afterEach, beforeEach, expect, vi } from 'vitest'; import type { WorkingTimeReportDto } from '../api/client'; import { renderWithProviders } from '../test-utils'; +let selectedLocale = 'de'; +const currentUser = vi.hoisted(() => ({ + id: 'hr-1', + email: 'hr@test.local', + firstName: 'Hannah', + lastName: 'HR', + role: 'HRAdmin' as 'HRAdmin' | 'Manager' | 'Employee', + themePreference: 'System', +})); const employeesMock = vi.fn(); const workingTimeReportMock = vi.fn(); const downloadAuthenticatedMock = vi.fn(); @@ -16,20 +31,28 @@ vi.mock('../api/client', () => ({ }, })); -vi.mock('../app/auth', () => ({ - useCurrentUser: () => ({ - id: 'hr-1', - email: 'hr@test.local', - firstName: 'Hannah', - lastName: 'HR', - role: 'HRAdmin', - themePreference: 'System', - }), -})); +vi.mock('../app/auth', () => ({ useCurrentUser: () => currentUser })); const report: WorkingTimeReportDto = { from: '2026-08-01', to: '2026-08-31', + timeZone: 'Europe/Berlin', + timeDefinition: 'net_working_time', + excessThresholdMinutes: 600, + comparisons: [ + { + employeeId: 'employee-1', + employeeName: 'Dora Direct', + targetMinutes: 480, + actualMinutes: 450, + differenceMinutes: -30, + targetStatus: 'Known', + unknownDates: [], + reason: null, + indicator: 'Negative', + openEntryCount: 0, + }, + ], rows: [ { id: 'entry-1', @@ -61,6 +84,16 @@ const report: WorkingTimeReportDto = { describe('AdminWorkingTimesPage', () => { beforeEach(() => { + currentUser.role = 'HRAdmin'; + selectedLocale = 'de'; + vi.spyOn(window, 'localStorage', 'get').mockReturnValue({ + getItem: () => selectedLocale, + setItem: () => undefined, + clear: () => undefined, + removeItem: () => undefined, + key: () => null, + length: 0, + }); employeesMock.mockReset().mockResolvedValue([ { id: 'employee-1', @@ -401,6 +434,246 @@ describe('AdminWorkingTimesPage', () => { expect(url.searchParams.get('includeLocations')).toBe('true'); }); + it('shows all textual comparison states, locale hours, missing history and zero-actual employees', async () => { + const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage'); + workingTimeReportMock.mockResolvedValue({ + ...report, + comparisons: [ + report.comparisons[0], + { + ...report.comparisons[0], + employeeId: 'zero', + employeeName: 'Zero Actual', + actualMinutes: 0, + targetMinutes: 0, + differenceMinutes: 0, + indicator: 'Balanced', + }, + { + ...report.comparisons[0], + employeeId: 'positive', + employeeName: 'Positive Actual', + differenceMinutes: 30, + indicator: 'Positive', + }, + { + ...report.comparisons[0], + employeeId: 'excess', + employeeName: 'Excess Actual', + differenceMinutes: 600, + indicator: 'Excess', + }, + { + ...report.comparisons[0], + employeeId: 'gap', + employeeName: 'History Gap', + targetStatus: 'Unknown', + targetMinutes: null, + differenceMinutes: null, + actualMinutes: 0, + indicator: 'Unknown', + unknownDates: ['2026-08-12'], + reason: 'CONTRACT_HISTORY_GAP', + openEntryCount: 2, + }, + ], + }); + renderWithProviders(); + const table = within( + await screen.findByRole('table', { name: 'Soll/Ist-Vergleich' }), + ); + expect(table.getByText('-0,5 h')).toBeTruthy(); + expect(table.getByText('+0,5 h')).toBeTruthy(); + expect(table.getByText('+10 h')).toBeTruthy(); + for (const label of [ + 'Unter Soll', + 'Ausgeglichen', + 'Über Soll', + 'Prüfung empfohlen (ab +10 h)', + 'Unbekannt', + ]) { + expect(table.getByText(label)).toBeTruthy(); + } + const zero = within(table.getByRole('row', { name: /Zero Actual/ })); + expect(zero.getAllByText('0 h')).toHaveLength(2); + const unknown = within(table.getByRole('row', { name: /History Gap/ })); + expect(unknown.getAllByText('—')).toHaveLength(2); + expect(unknown.getByText('0 h')).toBeTruthy(); + expect(unknown.getByText(/Bestätigte Vertragshistorie fehlt/)).toBeTruthy(); + expect(unknown.getByText(/12.8.2026/)).toBeTruthy(); + expect(unknown.getByText('2')).toBeTruthy(); + expect(screen.getByText(/Zeitzone: Europe\/Berlin/)).toBeTruthy(); + expect( + screen.getByText(/Abgeschlossene Buchungen mit Status/), + ).toBeTruthy(); + expect( + screen.getByText(/kein fortgeschriebenes Überstundenkonto/), + ).toBeTruthy(); + expect(screen.getByText(/weder eine gesetzliche Grenze/)).toBeTruthy(); + }); + + it('uses server-scoped team endpoints for Managers and hides HR-only PDF controls', async () => { + const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage'); + currentUser.role = 'Manager'; + renderWithProviders(); + await screen.findByRole('table', { name: 'Soll/Ist-Vergleich' }); + expect(employeesMock).toHaveBeenCalledOnce(); + expect(workingTimeReportMock).toHaveBeenCalled(); + expect(screen.getByText(/Server begrenzt die Auswahl/)).toBeTruthy(); + expect( + screen.queryByRole('button', { name: 'PDF herunterladen' }), + ).toBeNull(); + expect( + screen.getByRole('button', { name: 'CSV herunterladen' }), + ).toBeTruthy(); + expect(downloadAuthenticatedMock).not.toHaveBeenCalled(); + }); + + it('denies ordinary employees before loading any report or employee choices', async () => { + const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage'); + currentUser.role = 'Employee'; + renderWithProviders(); + expect( + screen.getByText(/Führungskräften und HR-Admins vorbehalten/), + ).toBeTruthy(); + expect(employeesMock).not.toHaveBeenCalled(); + expect(workingTimeReportMock).not.toHaveBeenCalled(); + }); + + it('keeps week, month and custom periods consistent between comparison and detail queries', async () => { + const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage'); + workingTimeReportMock.mockImplementation((from, to) => + Promise.resolve({ ...report, from, to }), + ); + renderWithProviders(); + await screen.findByRole('table', { name: 'Soll/Ist-Vergleich' }); + fireEvent.change(screen.getByLabelText('Zeitraum'), { + target: { value: 'week' }, + }); + await waitFor(() => + expect( + (screen.getByLabelText('Zeitraum') as HTMLSelectElement).value, + ).toBe('week'), + ); + const from = (screen.getByLabelText('Von') as HTMLInputElement).value; + const to = (screen.getByLabelText('Bis') as HTMLInputElement).value; + expect(new Date(from + 'T00:00:00Z').getUTCDay()).toBe(1); + expect((Date.parse(to) - Date.parse(from)) / 86400000).toBe(6); + await waitFor(() => + expect(workingTimeReportMock.mock.calls.at(-1)?.slice(0, 2)).toEqual([ + from, + to, + ]), + ); + fireEvent.change(screen.getByLabelText('Zeitraum'), { + target: { value: 'month' }, + }); + expect((screen.getByLabelText('Von') as HTMLInputElement).value).toMatch( + /-01$/, + ); + fireEvent.change(screen.getByLabelText('Von'), { + target: { value: '2026-08-01' }, + }); + fireEvent.change(screen.getByLabelText('Bis'), { + target: { value: '2026-08-31' }, + }); + await waitFor(() => + expect(workingTimeReportMock.mock.calls.at(-1)?.slice(0, 2)).toEqual([ + '2026-08-01', + '2026-08-31', + ]), + ); + expect((screen.getByLabelText('Zeitraum') as HTMLSelectElement).value).toBe( + 'custom', + ); + expect(await screen.findByText(/1.8.2026 – 31.8.2026/)).toBeTruthy(); + }); + + it('shows empty comparisons and retains the existing empty-entry state', async () => { + const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage'); + workingTimeReportMock.mockResolvedValue({ + ...report, + rows: [], + comparisons: [], + }); + renderWithProviders(); + expect( + await screen.findByText( + 'Keine Mitarbeiter:innen für die gewählte Auswahl vorhanden.', + ), + ).toBeTruthy(); + expect( + screen.getByText('Keine Buchungen im gewählten Zeitraum.'), + ).toBeTruthy(); + }); + + it('renders the comparison and unknown explanation in English', async () => { + const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage'); + selectedLocale = 'en'; + workingTimeReportMock.mockResolvedValue({ + ...report, + comparisons: [ + { + ...report.comparisons[0], + indicator: 'Unknown', + targetStatus: 'Unknown', + targetMinutes: null, + differenceMinutes: null, + reason: 'CONTRACT_HISTORY_GAP', + unknownDates: ['2026-08-12'], + }, + ], + }); + renderWithProviders(); + const table = within( + await screen.findByRole('table', { name: 'Target/actual comparison' }), + ); + expect(table.getByText('7.5 h')).toBeTruthy(); + expect(table.getByText('Unknown')).toBeTruthy(); + expect( + table.getByText(/Confirmed contract history is missing/), + ).toBeTruthy(); + expect( + screen.getByText(/not a carried-forward overtime account/), + ).toBeTruthy(); + expect(screen.getByText(/neither a statutory limit/)).toBeTruthy(); + }); + + it('renders and exports clock times in the declared report timezone, independent of the viewer timezone', async () => { + const previousZone = process.env.TZ; + process.env.TZ = 'America/New_York'; + try { + const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage'); + renderWithProviders(); + expect(await screen.findByText('09:00')).toBeTruthy(); + expect(screen.getByText('17:00')).toBeTruthy(); + const { workingTimeReportToCsv } = + await import('./AdminWorkingTimesPage'); + const csv = workingTimeReportToCsv( + report, + { + date: 'Date', + employee: 'Employee', + start: 'Start', + clockInLocation: 'In location', + end: 'End', + clockOutLocation: 'Out location', + gross: 'Gross', + break: 'Break', + net: 'Net', + status: 'Status', + total: 'Total', + }, + 'de-DE', + (status) => status, + ); + expect(csv).toContain('2026-08-10;Dora Direct;09:00;17:00;'); + expect(screen.queryByText('03:00')).toBeNull(); + } finally { + if (previousZone === undefined) delete process.env.TZ; + else process.env.TZ = previousZone; + } + }); it('creates an Excel-friendly CSV containing the filtered rows and totals', async () => { const { workingTimeReportToCsv } = await import('./AdminWorkingTimesPage'); const csv = workingTimeReportToCsv( diff --git a/apps/web/src/routes/AdminWorkingTimesPage.tsx b/apps/web/src/routes/AdminWorkingTimesPage.tsx index 521a1c1..10806f5 100644 --- a/apps/web/src/routes/AdminWorkingTimesPage.tsx +++ b/apps/web/src/routes/AdminWorkingTimesPage.tsx @@ -17,6 +17,7 @@ import { downloadAuthenticated, type WorkingTimeReportDto, type WorkingTimeReportLocationDto, + type WorkingTimeComparisonDto, } from '../api/client'; import { useCurrentUser } from '../app/auth'; import { useI18n } from '../app/i18n'; @@ -53,10 +54,15 @@ function formatMinutes(minutes: number): string { return `${hours}:${minutePart}${secondPart ? `:${secondPart.toString().padStart(2, '0')}` : ''}`; } -function formatTime(value: string, languageTag: string): string { +function formatTime( + value: string, + languageTag: string, + timeZone?: string, +): string { return new Date(value).toLocaleTimeString(languageTag, { hour: '2-digit', minute: '2-digit', + timeZone, }); } @@ -104,11 +110,11 @@ export function workingTimeReportToCsv( [ row.date, row.employeeName, - formatTime(row.clockIn, languageTag), + formatTime(row.clockIn, languageTag, report.timeZone), ...(includeLocations ? [formatLocation(row.clockInLocation, languageTag)] : []), - formatTime(row.clockOut, languageTag), + formatTime(row.clockOut, languageTag, report.timeZone), ...(includeLocations ? [formatLocation(row.clockOutLocation, languageTag)] : []), @@ -144,12 +150,161 @@ function defaultRange(): { from: string; to: string } { }; } +type ReportPeriod = 'week' | 'month' | 'custom'; + +function presetRange(period: Exclude) { + if (period === 'month') return defaultRange(); + const today = new Date(); + const monday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + ); + monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7)); + const sunday = new Date(monday); + sunday.setDate(monday.getDate() + 6); + return { from: dateInputValue(monday), to: dateInputValue(sunday) }; +} + +function ComparisonTable({ report }: { report: WorkingTimeReportDto }) { + const { t, languageTag, formatDate } = useI18n(); + const hours = (minutes: number, signed = false) => + t('comparison.hours', { + hours: new Intl.NumberFormat(languageTag, { + maximumFractionDigits: 2, + ...(signed ? { signDisplay: 'always' as const } : {}), + }).format(minutes / 60), + }); + const colors: Record = { + Unknown: 'bg-muted text-muted-foreground', + Negative: 'bg-red-100 text-red-900 dark:bg-red-950 dark:text-red-200', + Balanced: + 'bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100', + Positive: + 'bg-green-100 text-green-900 dark:bg-green-950 dark:text-green-200', + Excess: 'bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-200', + }; + return ( + + + {t('comparison.title')} + {t('comparison.description')} + + +

    + {t('comparison.period', { + from: formatDate(report.from), + to: formatDate(report.to), + timeZone: report.timeZone, + })} +

    +

    + {t('comparison.provisional')} +

    + {report.comparisons.length ? ( +
    + + + + + + + + + + + + + {report.comparisons.map((comparison) => { + const unknown = comparison.targetStatus === 'Unknown'; + const indicator = unknown ? 'Unknown' : comparison.indicator; + return ( + + + + + + + + + ); + })} + +
    {t('common.employee')}{t('comparison.target')}{t('comparison.actual')} + {t('comparison.difference')} + {t('comparison.indicator')} + {t('comparison.openEntries')} +
    + {comparison.employeeName} + + {!unknown && comparison.targetMinutes !== null + ? hours(comparison.targetMinutes) + : '—'} + + {hours(comparison.actualMinutes)} + + {!unknown && comparison.differenceMinutes !== null + ? hours(comparison.differenceMinutes, true) + : '—'} + + + {t(`comparison.indicator.${indicator}`)} + + {unknown && ( +
    +

    {t('comparison.historyGap')}

    + {comparison.unknownDates.length > 0 && ( +

    + {t('comparison.unknownDates', { + dates: comparison.unknownDates + .map(formatDate) + .join(', '), + })} +

    + )} +
    + )} +
    + {new Intl.NumberFormat(languageTag).format( + comparison.openEntryCount, + )} +
    +
    + ) : ( +

    + {t('comparison.empty')} +

    + )} +

    + {t('comparison.reviewHint', { + hours: new Intl.NumberFormat(languageTag).format( + report.excessThresholdMinutes / 60, + ), + })} +

    +

    + {t('comparison.notAccount')} +

    +
    +
    + ); +} + const MULTIPLE_EMPLOYEES = '__multiple__'; export function AdminWorkingTimesPage() { const user = useCurrentUser(); const { t, enumLabel, languageTag, locale } = useI18n(); const initialRange = useMemo(defaultRange, []); + const [period, setPeriod] = useState('month'); const [from, setFrom] = useState(initialRange.from); const [to, setTo] = useState(initialRange.to); const [employeeId, setEmployeeId] = useState(''); @@ -157,7 +312,8 @@ export function AdminWorkingTimesPage() { const [includeLocations, setIncludeLocations] = useState(false); const [pdfPending, setPdfPending] = useState(false); const [pdfFailed, setPdfFailed] = useState(false); - const isAuthorized = user.role === 'HRAdmin'; + const isHr = user.role === 'HRAdmin'; + const isAuthorized = isHr || user.role === 'Manager'; const orderedRange = from !== '' && to !== '' && from <= to; const validRange = orderedRange && (Date.parse(to) - Date.parse(from)) / 86_400_000 < 366; @@ -169,7 +325,7 @@ export function AdminWorkingTimesPage() { const validSelection = !multipleEmployees || selectedEmployeeIds.length > 0; const employees = useQuery({ - queryKey: ['employees', 'working-time-report'], + queryKey: ['employees', 'working-time-report', user.id, user.role], queryFn: () => api.workingTimeReportEmployees(), enabled: isAuthorized, }); @@ -177,6 +333,8 @@ export function AdminWorkingTimesPage() { const report = useQuery({ queryKey: [ 'working-time-report', + user.id, + user.role, from, to, singleEmployeeId ?? null, @@ -203,7 +361,9 @@ export function AdminWorkingTimesPage() { if (!isAuthorized) { return ( -

    {t('reports.adminOnly')}

    +

    + {t('reports.accessDenied')} +

    ); } const downloadCsv = () => { @@ -236,7 +396,7 @@ export function AdminWorkingTimesPage() { URL.revokeObjectURL(url); }; const downloadPdf = async () => { - if (!canDownload) return; + if (!canDownload || !isHr) return; const params = new URLSearchParams({ from, to, locale }); if (singleEmployeeId) params.set('employeeId', singleEmployeeId); employeeIds?.forEach((id) => params.append('employeeIds', id)); @@ -263,6 +423,9 @@ export function AdminWorkingTimesPage() {

    {t('reports.description')}

    +

    + {t(`reports.scope.${user.role}`)} +

    @@ -272,6 +435,30 @@ export function AdminWorkingTimesPage() {
    +
    + + +
    setFrom(event.target.value)} + onChange={(event) => { + setPeriod('custom'); + setFrom(event.target.value); + }} />
    @@ -291,7 +481,10 @@ export function AdminWorkingTimesPage() { value={to} min={from || undefined} disabled={pdfPending} - onChange={(event) => setTo(event.target.value)} + onChange={(event) => { + setPeriod('custom'); + setTo(event.target.value); + }} />
    @@ -339,15 +532,17 @@ export function AdminWorkingTimesPage() {
    {multipleEmployees && (
    )} -

    - {t('reports.pdfHint')} -

    + {isHr && ( +

    + {t('reports.pdfHint')} +

    + )} + {employees.isError && ( +

    + {t('reports.employeesFailed')} +

    + )} {!validRange && (

    {t( @@ -414,17 +616,30 @@ export function AdminWorkingTimesPage() { )} + {validRange && + validSelection && + !report.isFetching && + !report.isError && + report.data && } + {t('reports.result')} {t('reports.projectIndependent')} - {report.isLoading ? ( -

    + {report.isFetching ? ( +

    {t('common.loading')}

    - ) : report.data && report.data.rows.length > 0 ? ( + ) : validRange && + validSelection && + !report.isError && + report.data && + report.data.rows.length > 0 ? (
    {report.data.rows.map((row) => ( - + {includeLocations && ( )} {includeLocations && (
    {row.date} {row.employeeName} - {formatTime(row.clockIn, languageTag)} + {formatTime( + row.clockIn, + languageTag, + report.data?.timeZone, + )} @@ -471,7 +693,11 @@ export function AdminWorkingTimesPage() { - {formatTime(row.clockOut, languageTag)} + {formatTime( + row.clockOut, + languageTag, + report.data?.timeZone, + )} diff --git a/apps/web/src/routes/ContractPeriodsPanel.spec.tsx b/apps/web/src/routes/ContractPeriodsPanel.spec.tsx new file mode 100644 index 0000000..99639a9 --- /dev/null +++ b/apps/web/src/routes/ContractPeriodsPanel.spec.tsx @@ -0,0 +1,469 @@ +import { + act, + fireEvent, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { + ApiError, + type ContractPeriodDto, + type EmployeeDto, + type WorkScheduleDto, +} from '../api/client'; +import { renderWithProviders } from '../test-utils'; +import { ContractPeriodsPanel } from './ContractPeriodsPanel'; + +let selectedLocale = 'de'; +afterEach(() => vi.restoreAllMocks()); + +const role = vi.hoisted(() => ({ value: 'HRAdmin' })); +const contractPeriods = vi.fn(); +const createContractPeriod = vi.fn(); +vi.mock('../api/client', async (importOriginal) => ({ + ...(await importOriginal()), + api: { + contractPeriods: (...args: unknown[]) => contractPeriods(...args), + createContractPeriod: (...args: unknown[]) => createContractPeriod(...args), + }, +})); +vi.mock('../app/auth', () => ({ + useCurrentUser: () => ({ role: role.value }), +})); + +const employee: EmployeeDto = { + id: 'employee-1', + personalNo: '1001', + firstName: 'Alex', + lastName: 'Example', + email: 'alex@example.test', + role: 'Employee', + timeModel: 'Teilzeit', + weeklyHours: 32, + annualLeaveDays: 20, + startDate: '2026-01-01', + overtimeOpeningBalanceMinutes: 0, + holidayCalendar: 'NONE', + holidayDates: ['2026-07-01'], + isActive: true, + managerId: null, + workScheduleId: 'schedule-1', + workScheduleName: 'Current plan', + allowDailyBlockBooking: false, +}; +const schedule: WorkScheduleDto = { + id: 'schedule-1', + name: 'Current plan', + description: null, + frameStart: '07:00', + frameEnd: '18:00', + isDefault: false, + workingDays: 15, + breakRules: [], + coreTimes: [], + employeeCount: 1, + updatedAt: '2026-01-01T00:00:00Z', +}; +const period: ContractPeriodDto = { + id: 'period-1', + employeeId: employee.id, + effectiveFrom: '2026-01-01', + effectiveTo: null, + employmentStart: '2026-01-01', + employmentEnd: null, + weeklyHours: 20, + timeModel: 'Teilzeit', + workingDays: 31, + holidayCalendar: 'DE-BY', + holidayDates: ['2026-08-08'], + scheduleLabel: 'Historical plan', + confirmationNote: 'Verified synthetic agreement', + confirmedById: 'hr-1', + revision: 3, + createdAt: '2026-02-01T12:00:00Z', +}; + +beforeEach(() => { + role.value = 'HRAdmin'; + selectedLocale = 'de'; + vi.spyOn(window, 'localStorage', 'get').mockReturnValue({ + getItem: () => selectedLocale, + setItem: () => undefined, + clear: () => undefined, + removeItem: () => undefined, + key: () => null, + length: 0, + }); + contractPeriods.mockReset().mockResolvedValue([period]); + createContractPeriod + .mockReset() + .mockResolvedValue({ ...period, id: 'period-2', revision: 1 }); +}); + +async function openDraft() { + await waitFor(() => + expect(contractPeriods).toHaveBeenCalledWith(employee.id), + ); + fireEvent.click( + screen.getByText('Neuen Vertragszeitraum bestätigen', { + selector: 'summary', + }), + ); + await screen.findByRole('button', { name: 'Vertragszeitraum bestätigen' }); +} + +function fillRequired(from = '2026-08-01') { + fireEvent.change(screen.getByLabelText('Vertrag gültig ab'), { + target: { value: from }, + }); + fireEvent.change(screen.getByLabelText('Bestätigungsnotiz (Pflicht)'), { + target: { value: ' Verified new synthetic agreement ' }, + }); +} +function confirm() { + fireEvent.click( + screen.getByRole('checkbox', { name: /Ich bestätige diese Vertragsdaten/ }), + ); +} + +it('shows immutable confirmed facts independently of current profile and schedule values', async () => { + const { rerender } = renderWithProviders( + , + ); + const summary = await screen.findByText( + /1.1.2026 – unbefristet · Revision 3/, + { selector: 'summary' }, + ); + fireEvent.click(summary); + const history = within(summary.closest('details') ?? summary); + expect(history.getByText('20')).toBeTruthy(); + expect(history.getByText('Historical plan')).toBeTruthy(); + expect(history.getByText('Verified synthetic agreement')).toBeTruthy(); + expect(history.getByText('hr-1')).toBeTruthy(); + expect(history.queryByRole('textbox')).toBeNull(); + expect(history.queryByRole('button')).toBeNull(); + rerender( + , + ); + expect(history.getByText('20')).toBeTruthy(); + expect(history.getByText('Historical plan')).toBeTruthy(); + expect( + screen.getByText( + /Spätere Profil- oder Planänderungen ändern die bestätigte Historie nicht/, + ), + ).toBeTruthy(); + expect( + screen.getByText(/Ohne Bestätigung bleiben auch bekannte Altdaten/), + ).toBeTruthy(); + expect(createContractPeriod).not.toHaveBeenCalled(); +}); + +it('requires a note and explicit HR confirmation and posts self-contained facts, not actor or live references', async () => { + renderWithProviders( + , + ); + await openDraft(); + const save = screen.getByRole('button', { + name: 'Vertragszeitraum bestätigen', + }); + expect(screen.getByText(/UNBESTÄTIGTER ENTWURF/)).toBeTruthy(); + expect( + (screen.getByLabelText('Vertrags-Wochenstunden') as HTMLInputElement).value, + ).toBe('32'); + expect( + (screen.getByRole('checkbox', { name: 'Freitag' }) as HTMLInputElement) + .checked, + ).toBe(false); + expect(save.hasAttribute('disabled')).toBe(true); + confirm(); + expect(save.hasAttribute('disabled')).toBe(true); + fillRequired(); + expect(save.hasAttribute('disabled')).toBe(true); + fireEvent.change(screen.getByLabelText('Bestätigungsnotiz (Pflicht)'), { + target: { value: 'x' }, + }); + confirm(); + expect(save.hasAttribute('disabled')).toBe(true); + fillRequired(); + fireEvent.change( + screen.getByLabelText('Vertrag gültig bis (optional, einschließlich)'), + { target: { value: '2026-12-31' } }, + ); + fireEvent.change( + screen.getByLabelText('Vertragliches Beschäftigungsende (optional)'), + { target: { value: '2027-01-31' } }, + ); + fireEvent.change(screen.getByLabelText('Vertraglicher Feiertagskalender'), { + target: { value: 'DE-BW' }, + }); + fireEvent.change( + screen.getByLabelText('Vertragliche zusätzliche Feiertage'), + { target: { value: '2026-12-25, 2026-08-08\n2026-08-08' } }, + ); + fireEvent.click(screen.getByRole('checkbox', { name: 'Dienstag' })); + fireEvent.click(screen.getByRole('checkbox', { name: 'Freitag' })); + confirm(); + fireEvent.click(save); + await waitFor(() => expect(createContractPeriod).toHaveBeenCalledOnce()); + expect(createContractPeriod).toHaveBeenCalledWith(employee.id, { + effectiveFrom: '2026-08-01', + effectiveTo: '2026-12-31', + employmentStart: '2026-01-01', + employmentEnd: '2027-01-31', + weeklyHours: 32, + timeModel: 'Teilzeit', + workingDays: 29, + holidayCalendar: 'DE-BW', + holidayDates: ['2026-08-08', '2026-12-25'], + scheduleLabel: 'Current plan', + confirmed: true, + confirmationNote: 'Verified new synthetic agreement', + }); + expect( + await screen.findByText( + 'Vertragszeitraum bestätigt. Die Auswertung wird aktualisiert.', + ), + ).toBeTruthy(); + expect( + ( + screen.getByRole('checkbox', { + name: /Ich bestätige diese Vertragsdaten/, + }) as HTMLInputElement + ).checked, + ).toBe(false); + expect(contractPeriods.mock.calls.length).toBeGreaterThan(1); +}); + +it('sends both supersession ID and selected revision, only for a later start', async () => { + const { queryClient } = renderWithProviders( + , + ); + await openDraft(); + fillRequired('2026-01-01'); + fireEvent.change( + screen.getByLabelText('Bisherigen Zeitraum ablösen (optional)'), + { target: { value: period.id } }, + ); + confirm(); + const save = screen.getByRole('button', { + name: 'Vertragszeitraum bestätigen', + }); + expect(save.hasAttribute('disabled')).toBe(true); + expect( + screen.getByText(/Eine Ablösung beendet nur den ausgewählten/), + ).toBeTruthy(); + fireEvent.change(screen.getByLabelText('Vertrag gültig ab'), { + target: { value: '2026-08-01' }, + }); + // A background refresh must not silently replace the explicitly selected CAS revision. + await act(async () => + queryClient.setQueryData( + ['contract-periods', employee.id], + [{ ...period, revision: 4 }], + ), + ); + confirm(); + fireEvent.click(save); + await waitFor(() => + expect(createContractPeriod).toHaveBeenCalledWith( + employee.id, + expect.objectContaining({ + supersedesPeriodId: period.id, + expectedRevision: 3, + effectiveFrom: '2026-08-01', + effectiveTo: null, + employmentEnd: null, + }), + ), + ); +}); + +it('handles concurrent/overlap conflicts without claiming the facts were saved', async () => { + createContractPeriod.mockRejectedValue( + new ApiError(409, 'CONTRACT_PERIOD_OVERLAP'), + ); + renderWithProviders( + , + ); + await openDraft(); + fillRequired(); + confirm(); + fireEvent.click( + screen.getByRole('button', { name: 'Vertragszeitraum bestätigen' }), + ); + expect( + await screen.findByText( + /Die Vertragshistorie wurde geändert oder der Zeitraum überschneidet sich/, + ), + ).toBeTruthy(); + expect( + screen.queryByText( + 'Vertragszeitraum bestätigt. Die Auswertung wird aktualisiert.', + ), + ).toBeNull(); + fireEvent.click( + screen.getByRole('button', { name: 'Vertragshistorie aktualisieren' }), + ); + await waitFor(() => expect(contractPeriods).toHaveBeenCalledTimes(2)); + expect( + ( + screen.getByRole('checkbox', { + name: /Ich bestätige diese Vertragsdaten/, + }) as HTMLInputElement + ).checked, + ).toBe(false); +}); + +it('validates holiday dates, interval ordering and the self-contained weekday selection', async () => { + renderWithProviders( + , + ); + await openDraft(); + fillRequired(); + fireEvent.change( + screen.getByLabelText('Vertragliche zusätzliche Feiertage'), + { target: { value: '2026-02-30' } }, + ); + confirm(); + const save = screen.getByRole('button', { + name: 'Vertragszeitraum bestätigen', + }); + expect(save.hasAttribute('disabled')).toBe(true); + fireEvent.change( + screen.getByLabelText('Vertragliche zusätzliche Feiertage'), + { target: { value: '' } }, + ); + fireEvent.change( + screen.getByLabelText('Vertrag gültig bis (optional, einschließlich)'), + { target: { value: '2026-07-31' } }, + ); + confirm(); + expect(save.hasAttribute('disabled')).toBe(true); + fireEvent.change( + screen.getByLabelText('Vertrag gültig bis (optional, einschließlich)'), + { target: { value: '' } }, + ); + for (const day of ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag']) + fireEvent.click(screen.getByRole('checkbox', { name: day })); + confirm(); + expect(save.hasAttribute('disabled')).toBe(true); + expect(createContractPeriod).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText('Vertrags-Wochenstunden'), { + target: { value: '0' }, + }); + confirm(); + expect(save.hasAttribute('disabled')).toBe(false); +}); + +it('shows loading, error and an empty historical target state', async () => { + let reject!: (error: Error) => void; + contractPeriods.mockReturnValueOnce( + new Promise((_resolve, rejectPromise) => { + reject = rejectPromise; + }), + ); + renderWithProviders( + , + ); + expect(screen.getByRole('status')).toBeTruthy(); + await act(async () => reject(new Error('Unavailable'))); + expect( + await screen.findByText( + 'Die Vertragshistorie konnte nicht geladen werden.', + ), + ).toBeTruthy(); + contractPeriods.mockResolvedValueOnce([]); + fireEvent.click( + screen.getByRole('button', { name: 'Vertragshistorie aktualisieren' }), + ); + expect( + await screen.findByText( + 'Noch keine bestätigten Vertragszeiträume. Das historische Soll ist unbekannt.', + ), + ).toBeTruthy(); +}); + +it.each(['Manager', 'Employee'])( + 'does not expose or fetch HR contract history for %s', + (value) => { + role.value = value; + const { container } = renderWithProviders( + , + ); + expect(container.textContent).toBe(''); + expect(contractPeriods).not.toHaveBeenCalled(); + }, +); + +it('explains unconfirmed legacy facts and immutable snapshots in English', async () => { + selectedLocale = 'en'; + contractPeriods.mockResolvedValue([]); + renderWithProviders( + , + ); + expect( + await screen.findByText( + 'No confirmed contract periods yet. Historical targets are unknown.', + ), + ).toBeTruthy(); + fireEvent.click( + screen.getByText('Confirm a new contract period', { selector: 'summary' }), + ); + expect(screen.getByText(/UNCONFIRMED DRAFT/)).toBeTruthy(); + expect( + screen.getByText(/Even known legacy data remains unknown/), + ).toBeTruthy(); + expect(screen.getByText(/not a live schedule reference/)).toBeTruthy(); + expect(screen.getByRole('checkbox', { name: 'Monday' })).toBeTruthy(); + expect( + screen + .getByRole('button', { name: 'Confirm contract period' }) + .hasAttribute('disabled'), + ).toBe(true); +}); + +it('serializes GET/POST through the employee-scoped contract API without modifying payload facts', async () => { + const { api } = + await vi.importActual('../api/client'); + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify(period), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + try { + await api.contractPeriods(employee.id); + const { + id: _id, + employeeId: _employeeId, + confirmedById: _actor, + revision: _revision, + createdAt: _createdAt, + ...facts + } = period; + const payload = { + ...facts, + confirmed: true as const, + supersedesPeriodId: period.id, + expectedRevision: period.revision, + }; + await api.createContractPeriod(employee.id, payload); + expect(fetchMock.mock.calls[0][0]).toBe( + `/api/employees/${employee.id}/contract-periods`, + ); + expect(fetchMock.mock.calls[1][0]).toBe( + `/api/employees/${employee.id}/contract-periods`, + ); + expect(fetchMock.mock.calls[1][1].method).toBe('POST'); + expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual(payload); + } finally { + vi.unstubAllGlobals(); + } +}); diff --git a/apps/web/src/routes/ContractPeriodsPanel.tsx b/apps/web/src/routes/ContractPeriodsPanel.tsx new file mode 100644 index 0000000..2af44cb --- /dev/null +++ b/apps/web/src/routes/ContractPeriodsPanel.tsx @@ -0,0 +1,555 @@ +import { useId, useState, type ReactNode } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + api, + ApiError, + BUNDESLAND_LABEL, + type Bundesland, + type ContractPeriodDto, + type CreateContractPeriodPayload, + type EmployeeDto, + type HolidayCalendar, + type TimeModel, + type WorkScheduleDto, +} from '../api/client'; +import { useCurrentUser } from '../app/auth'; +import { useI18n } from '../app/i18n'; + +const TIME_MODELS: TimeModel[] = [ + 'Vollzeit', + 'Teilzeit', + 'Gleitzeit', + 'Vertrauensarbeitszeit', +]; +const selectClass = + 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm'; + +function validDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || value < '0001-01-01') return false; + const date = new Date(`${value}T00:00:00Z`); + return ( + !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value + ); +} + +function localToday(): string { + const today = new Date(); + return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; +} + +function FactInput({ + label, + value, + onChange, + type = 'text', + maxLength, +}: { + label: string; + value: string; + onChange: (value: string) => void; + type?: string; + maxLength?: number; +}) { + const id = useId(); + return ( +
    + + onChange(event.target.value)} + /> +
    + ); +} + +/** HR-only, append/confirm workflow. No update/delete UI for confirmed facts. */ +export function ContractPeriodsPanel({ + employee, + schedules, +}: { + employee: EmployeeDto; + schedules: WorkScheduleDto[]; +}) { + const user = useCurrentUser(); + const { t, enumLabel, formatDate, formatDateTime, languageTag } = useI18n(); + const queryClient = useQueryClient(); + const panelId = useId(); + const isHr = user.role === 'HRAdmin'; + const [draft, setDraft] = useState(() => { + const schedule = employee.workScheduleId + ? schedules.find((item) => item.id === employee.workScheduleId) + : schedules.find((item) => item.isDefault); + return { + effectiveFrom: localToday(), + effectiveTo: '', + employmentStart: employee.startDate, + employmentEnd: '', + weeklyHours: String(employee.weeklyHours), + timeModel: employee.timeModel, + workingDays: schedule?.workingDays ?? 31, + scheduleLabel: schedule?.name ?? employee.workScheduleName ?? '', + holidayCalendar: employee.holidayCalendar, + holidayDatesText: employee.holidayDates.join(', '), + confirmationNote: '', + }; + }); + const [confirmed, setConfirmed] = useState(false); + const [saved, setSaved] = useState(false); + // Capture the revision on explicit selection, not from a subsequently refreshed query. + const [supersession, setSupersession] = useState<{ + id: string; + revision: number; + effectiveFrom: string; + effectiveTo: string | null; + } | null>(null); + const history = useQuery({ + queryKey: ['contract-periods', employee.id], + queryFn: () => api.contractPeriods(employee.id), + enabled: isHr, + }); + const update = (value: Partial) => { + setDraft((previous) => ({ ...previous, ...value })); + setConfirmed(false); + setSaved(false); + }; + const holidayDates = [ + ...new Set(draft.holidayDatesText.split(/[\s,;]+/).filter(Boolean)), + ].sort(); + const datesValid = + validDate(draft.effectiveFrom) && + validDate(draft.employmentStart) && + (!draft.effectiveTo || + (validDate(draft.effectiveTo) && + draft.effectiveTo >= draft.effectiveFrom)) && + (!draft.employmentEnd || + (validDate(draft.employmentEnd) && + draft.employmentEnd >= draft.employmentStart)) && + holidayDates.every(validDate); + const weeklyHours = Number(draft.weeklyHours); + const workingDayCount = Array.from({ length: 7 }, (_, index) => + Number(Boolean(draft.workingDays & (1 << index))), + ).reduce((sum, count) => sum + count, 0); + const noteValid = + draft.confirmationNote.trim().length >= 5 && + draft.confirmationNote.trim().length <= 1000; + const factsValid = + datesValid && + draft.weeklyHours.trim() !== '' && + Number.isFinite(weeklyHours) && + weeklyHours >= 0 && + weeklyHours <= 168 && + Math.abs(Math.round(weeklyHours * 100) / 100 - weeklyHours) < 1e-10 && + weeklyHours <= workingDayCount * 24 && + holidayDates.length <= 3660 && + draft.scheduleLabel.trim().length <= 120 && + (!supersession || + (draft.effectiveFrom > supersession.effectiveFrom && + (!supersession.effectiveTo || + draft.effectiveFrom <= supersession.effectiveTo))); + const save = useMutation({ + mutationFn: () => { + const facts = { + effectiveFrom: draft.effectiveFrom, + effectiveTo: draft.effectiveTo || null, + employmentStart: draft.employmentStart, + employmentEnd: draft.employmentEnd || null, + weeklyHours: Number(draft.weeklyHours), + timeModel: draft.timeModel, + workingDays: draft.workingDays, + holidayCalendar: draft.holidayCalendar, + holidayDates, + scheduleLabel: draft.scheduleLabel.trim() || null, + confirmationNote: draft.confirmationNote.trim(), + confirmed: true as const, + }; + const payload: CreateContractPeriodPayload = supersession + ? { + ...facts, + supersedesPeriodId: supersession.id, + expectedRevision: supersession.revision, + } + : facts; + return api.createContractPeriod(employee.id, payload); + }, + onSuccess: () => { + setSaved(true); + setConfirmed(false); + setSupersession(null); + setDraft((previous) => ({ ...previous, confirmationNote: '' })); + void queryClient.invalidateQueries({ + queryKey: ['contract-periods', employee.id], + }); + void queryClient.invalidateQueries({ queryKey: ['working-time-report'] }); + }, + }); + const weekdays = Array.from({ length: 7 }, (_, index) => + new Intl.DateTimeFormat(languageTag, { + weekday: 'long', + timeZone: 'UTC', + }).format(new Date(Date.UTC(2024, 0, 1 + index))), + ); + const periodRange = (period: ContractPeriodDto) => + `${formatDate(period.effectiveFrom)} – ${period.effectiveTo ? formatDate(period.effectiveTo) : t('contracts.openEnded')}`; + const fact = (label: string, value: ReactNode) => ( +
    +
    {label}
    +
    {value}
    +
    + ); + + if (!isHr) return null; + return ( +
    +

    + {t('contracts.title')} +

    +

    + {t('contracts.description')} +

    +

    + {t('contracts.snapshotHint')} +

    +
    +

    {t('contracts.history')}

    + +
    + {history.isPending && ( +

    + {t('common.loading')} +

    + )} + {history.isError && ( + + {t('contracts.loadFailed')} + + )} + {!history.isError && history.data?.length === 0 && ( +

    {t('contracts.empty')}

    + )} + {!history.isError && + history.data?.map((period) => ( +
    + + {periodRange(period)} · {t('contracts.revision')}{' '} + {period.revision} + +
    + {fact( + t('contracts.employmentStart'), + formatDate(period.employmentStart), + )} + {fact( + t('contracts.employmentEnd'), + period.employmentEnd + ? formatDate(period.employmentEnd) + : t('contracts.openEnded'), + )} + {fact( + t('contracts.weeklyHours'), + new Intl.NumberFormat(languageTag).format(period.weeklyHours), + )} + {fact(t('contracts.timeModel'), enumLabel(period.timeModel))} + {fact( + t('contracts.workingDays'), + weekdays + .filter((_, index) => period.workingDays & (1 << index)) + .join(', '), + )} + {fact(t('contracts.scheduleLabel'), period.scheduleLabel || '—')} + {fact( + t('contracts.holidayCalendar'), + period.holidayCalendar === 'NONE' + ? t('employees.holidayCalendarNone') + : period.holidayCalendar, + )} + {fact( + t('contracts.holidayDates'), + period.holidayDates.map(formatDate).join(', ') || '—', + )} + {fact(t('contracts.confirmationNote'), period.confirmationNote)} + {fact( + t('contracts.confirmedBy'), + period.confirmedById || t('contracts.actorUnavailable'), + )} + {fact(t('contracts.createdAt'), formatDateTime(period.createdAt))} +
    +
    + ))} +
    + + {t('contracts.new')} + +
    + + {t('contracts.unconfirmed')} + +
    + {t('contracts.new')} + update({ effectiveFrom })} + /> + update({ effectiveTo })} + /> + update({ employmentStart })} + /> + update({ employmentEnd })} + /> + update({ weeklyHours })} + /> +
    + + +
    +
    + + {t('contracts.workingDays')} + +
    + {weekdays.map((day, index) => ( + + ))} +
    +
    + update({ scheduleLabel })} + maxLength={120} + /> +
    + + +
    +
    + +