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-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-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 1a60945..59a9d53 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",
@@ -3394,7 +3510,17 @@
],
"responses": {
"200": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
+ }
}
},
"security": [
@@ -3419,7 +3545,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3430,14 +3563,22 @@
"tags": ["requests"]
}
},
- "/api/requests/{id}": {
+ "/api/requests/time-adjustment-targets": {
"get": {
- "operationId": "RequestsController_get",
+ "operationId": "RequestsController_timeAdjustmentTargets",
"parameters": [
{
- "name": "id",
+ "name": "from",
"required": true,
- "in": "path",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "to",
+ "required": true,
+ "in": "query",
"schema": {
"type": "string"
}
@@ -3445,7 +3586,17 @@
],
"responses": {
"200": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/TimeEntryDto"
+ }
+ }
+ }
+ }
}
},
"security": [
@@ -3456,9 +3607,9 @@
"tags": ["requests"]
}
},
- "/api/requests/{id}/events": {
+ "/api/requests/{id}/time-entry-audit": {
"get": {
- "operationId": "RequestsController_events",
+ "operationId": "RequestsController_timeEntryAudit",
"parameters": [
{
"name": "id",
@@ -3471,7 +3622,17 @@
],
"responses": {
"200": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/TimeEntryAuditDto"
+ }
+ }
+ }
+ }
}
},
"security": [
@@ -3482,23 +3643,29 @@
"tags": ["requests"]
}
},
- "/api/requests/vacation": {
- "post": {
- "operationId": "RequestsController_createVacation",
- "parameters": [],
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/CreateVacationDto"
- }
+ "/api/requests/{id}": {
+ "get": {
+ "operationId": "RequestsController_get",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
}
}
- },
+ ],
"responses": {
- "201": {
- "description": ""
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3507,11 +3674,9 @@
}
],
"tags": ["requests"]
- }
- },
- "/api/requests/{id}/approve": {
- "post": {
- "operationId": "RequestsController_approve",
+ },
+ "patch": {
+ "operationId": "RequestsController_updateDraft",
"parameters": [
{
"name": "id",
@@ -3527,14 +3692,21 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/TransitionDto"
+ "$ref": "#/components/schemas/UpdateDraftRequestDto"
}
}
}
},
"responses": {
- "201": {
- "description": ""
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3545,9 +3717,9 @@
"tags": ["requests"]
}
},
- "/api/requests/{id}/reject": {
- "post": {
- "operationId": "RequestsController_reject",
+ "/api/requests/{id}/events": {
+ "get": {
+ "operationId": "RequestsController_events",
"parameters": [
{
"name": "id",
@@ -3558,19 +3730,43 @@
}
}
],
+ "responses": {
+ "200": {
+ "description": ""
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["requests"]
+ }
+ },
+ "/api/requests/vacation": {
+ "post": {
+ "operationId": "RequestsController_createVacation",
+ "parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/TransitionDto"
+ "$ref": "#/components/schemas/CreateVacationDto"
}
}
}
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3581,9 +3777,9 @@
"tags": ["requests"]
}
},
- "/api/requests/{id}/manager-approve": {
+ "/api/requests/{id}/resubmit": {
"post": {
- "operationId": "RequestsController_managerApprove",
+ "operationId": "RequestsController_resubmit",
"parameters": [
{
"name": "id",
@@ -3599,19 +3795,155 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ManagerApproveDto"
+ "$ref": "#/components/schemas/UpdateDraftRequestDto"
}
}
}
},
"responses": {
"201": {
- "description": ""
- }
- },
- "security": [
- {
- "bearer": []
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["requests"]
+ }
+ },
+ "/api/requests/{id}/approve": {
+ "post": {
+ "operationId": "RequestsController_approve",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TransitionDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["requests"]
+ }
+ },
+ "/api/requests/{id}/reject": {
+ "post": {
+ "operationId": "RequestsController_reject",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TransitionDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["requests"]
+ }
+ },
+ "/api/requests/{id}/manager-approve": {
+ "post": {
+ "operationId": "RequestsController_managerApprove",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ManagerApproveDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
}
],
"tags": ["requests"]
@@ -3642,7 +3974,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3678,7 +4017,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3714,7 +4060,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3750,7 +4103,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3786,7 +4146,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3822,7 +4189,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3858,7 +4232,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -6338,6 +6719,118 @@
"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": {
+ "requestId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "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": [
+ "requestId",
+ "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": {
@@ -6780,6 +7273,15 @@
"TimeEntryAuditDto": {
"type": "object",
"properties": {
+ "actorName": {
+ "type": "string",
+ "nullable": true
+ },
+ "requestId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
"id": {
"type": "string",
"format": "uuid"
@@ -6814,6 +7316,7 @@
}
},
"required": [
+ "requestId",
"id",
"timeEntryId",
"actorId",
@@ -7656,9 +8159,160 @@
},
"required": ["baseDays", "carryOverDays", "adjustmentDays"]
},
+ "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"
+ },
+ "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": [
+ "targetTimeEntryId",
+ "expectedTargetRevision",
+ "targetTimeEntryBefore",
+ "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": {
+ "targetTimeEntryId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "expectedTargetRevision": {
+ "type": "number",
+ "nullable": true,
+ "minimum": 0
+ },
"employeeId": {
"type": "string",
"format": "uuid"
@@ -7678,7 +8332,7 @@
"example": "2026-08-07T00:00:00.000Z"
},
"reason": {
- "type": "object",
+ "type": "string",
"nullable": true,
"maxLength": 2000
}
@@ -7688,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"
@@ -7701,12 +8365,12 @@
"format": "date-time"
},
"substituteId": {
- "type": "object",
+ "type": "string",
"nullable": true,
"format": "uuid"
},
"reason": {
- "type": "object",
+ "type": "string",
"nullable": true,
"maxLength": 2000
},
@@ -7723,6 +8387,55 @@
},
"required": ["employeeId", "from", "to"]
},
+ "UpdateDraftRequestDto": {
+ "type": "object",
+ "properties": {
+ "targetTimeEntryId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "expectedTargetRevision": {
+ "type": "number",
+ "nullable": true,
+ "minimum": 0
+ },
+ "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 +8444,7 @@
"format": "uuid"
},
"note": {
- "type": "object",
+ "type": "string",
"nullable": true,
"maxLength": 2000
}
@@ -7746,7 +8459,7 @@
"format": "uuid"
},
"note": {
- "type": "object",
+ "type": "string",
"nullable": true,
"maxLength": 2000
},
@@ -7786,7 +8499,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..7b44913 100644
--- a/apps/api/src/app/requests/requests.controller.ts
+++ b/apps/api/src/app/requests/requests.controller.ts
@@ -3,15 +3,25 @@ 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';
+import {
+ TimeEntryDto,
+ TimeEntryAuditDto,
+} from '../time-entries/time-entries.dto';
import { RequestsService } from './requests.service';
import {
BulkApproveDto,
@@ -20,9 +30,10 @@ import {
CreateVacationDto,
ManagerApproveDto,
TransitionDto,
+ UpdateDraftRequestDto,
TransitionWithRequiredNoteDto,
type BulkResult,
- type RequestDto,
+ RequestDto,
type RequestEventDto,
} from './requests.dto';
@@ -34,6 +45,7 @@ export class RequestsController {
constructor(private readonly service: RequestsService) {}
@Get()
+ @ApiOkResponse({ type: RequestDto, isArray: true })
list(
@CurrentUser() user: JwtUser,
@Query('employeeId') employeeId?: string,
@@ -56,7 +68,31 @@ 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(
@Param('id', new ParseUUIDPipe()) id: string,
@CurrentUser() user: JwtUser,
@@ -73,6 +109,7 @@ export class RequestsController {
}
@Post()
+ @ApiCreatedResponse({ type: RequestDto })
create(
@Body() dto: CreateRequestDto,
@CurrentUser() user: JwtUser,
@@ -81,6 +118,7 @@ export class RequestsController {
}
@Post('vacation')
+ @ApiCreatedResponse({ type: RequestDto })
createVacation(
@Body() dto: CreateVacationDto,
@CurrentUser() user: JwtUser,
@@ -88,9 +126,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 +159,7 @@ export class RequestsController {
}
@Post(':id/reject')
+ @ApiCreatedResponse({ type: RequestDto })
reject(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionDto,
@@ -111,6 +171,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 +186,7 @@ export class RequestsController {
}
@Post(':id/manager-reject')
+ @ApiCreatedResponse({ type: RequestDto })
managerReject(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionDto,
@@ -134,6 +196,7 @@ export class RequestsController {
}
@Post(':id/hr-confirm')
+ @ApiCreatedResponse({ type: RequestDto })
hrConfirm(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionDto,
@@ -143,6 +206,7 @@ export class RequestsController {
}
@Post(':id/hr-reject')
+ @ApiCreatedResponse({ type: RequestDto })
hrReject(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionWithRequiredNoteDto,
@@ -152,6 +216,7 @@ export class RequestsController {
}
@Post(':id/substitute/accept')
+ @ApiCreatedResponse({ type: RequestDto })
substituteAccept(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionDto,
@@ -161,6 +226,7 @@ export class RequestsController {
}
@Post(':id/substitute/decline')
+ @ApiCreatedResponse({ type: RequestDto })
substituteDecline(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionWithRequiredNoteDto,
@@ -170,6 +236,7 @@ export class RequestsController {
}
@Post(':id/return')
+ @ApiCreatedResponse({ type: RequestDto })
returnForRevision(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionWithRequiredNoteDto,
@@ -179,6 +246,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..c556192 100644
--- a/apps/api/src/app/requests/requests.dto.ts
+++ b/apps/api/src/app/requests/requests.dto.ts
@@ -1,8 +1,10 @@
-import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger';
import {
ArrayNotEmpty,
IsArray,
IsBoolean,
+ IsInt,
+ Min,
IsEnum,
IsISO8601,
IsOptional,
@@ -10,11 +12,27 @@ import {
IsUUID,
MaxLength,
} from 'class-validator';
-import type { Request } from '@prisma/client';
+import type { Request, Prisma } from '@prisma/client';
-const REQUEST_TYPES = ['Vacation', 'HomeOffice', 'SpecialLeave', 'TimeAdjustment'] as const;
+const REQUEST_TYPES = [
+ 'Vacation',
+ 'HomeOffice',
+ 'SpecialLeave',
+ 'TimeAdjustment',
+] 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;
@@ -31,7 +49,7 @@ export class CreateRequestDto {
@IsISO8601()
to!: string;
- @ApiPropertyOptional({ nullable: true, maxLength: 2000 })
+ @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 })
@IsOptional()
@IsString()
@MaxLength(2000)
@@ -39,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;
@@ -51,34 +80,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 +162,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 +199,79 @@ 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({ 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' })
+ 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,
+ targetTimeEntryId: r.targetTimeEntryId,
+ expectedTargetRevision: r.expectedTargetRevision,
+ targetTimeEntryBefore: r.targetTimeEntryBefore,
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 +282,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.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 95e562c..e3fedaa 100644
--- a/apps/api/src/app/requests/requests.service.ts
+++ b/apps/api/src/app/requests/requests.service.ts
@@ -5,25 +5,29 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
-import type {
+import {
Prisma,
- Request,
- RequestEventKind,
- RequestType,
- WorkflowState,
+ 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,
@@ -32,6 +36,7 @@ import {
type CreateVacationDto,
type RequestDto,
type RequestEventDto,
+ type UpdateDraftRequestDto,
} from './requests.dto';
export interface ListRequestsFilter {
@@ -43,6 +48,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(
@@ -51,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(
@@ -72,6 +86,7 @@ export class RequestsService {
where: { AND: [where, await this.visibilityWhere(actorId)] },
orderBy: { createdAt: 'desc' },
take: 200,
+ include: employeeInclude,
});
return rows.map(toRequestDto);
}
@@ -99,143 +114,274 @@ 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 {
+ // 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`;
+ }
+
+ 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,
+ targetTimeEntryId:
+ dto.targetTimeEntryId === undefined
+ ? request.targetTimeEntryId
+ : dto.targetTimeEntryId,
+ expectedTargetRevision:
+ dto.expectedTargetRevision === undefined
+ ? request.expectedTargetRevision
+ : dto.expectedTargetRevision,
+ },
+ tx,
+ id,
+ request,
+ );
+ 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,
+ original?: Request,
+ ) {
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 (
+ 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',
);
}
- 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,
+ type === 'TimeAdjustment',
);
- if (balance.remainingDays < calculatedDays) {
- throw new ConflictException(
- `Not enough vacation days remaining (${balance.remainingDays} < ${calculatedDays})`,
+ let requiresApproval = false;
+ let targetValues:
+ | Awaited>
+ | undefined;
+ if (type === 'TimeAdjustment') {
+ if (to <= from)
+ throw new BadRequestException(
+ 'TimeAdjustment "to" must be after "from"',
+ );
+ targetValues = await this.adjustments.validate(
+ tx,
+ employee.id,
+ from,
+ to,
+ dto,
+ original,
);
+ requiresApproval = targetValues.requiresApproval;
}
+ 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 {
+ ...(targetValues ?? {
+ targetTimeEntryId: null,
+ expectedTargetRevision: null,
+ targetTimeEntryBefore: Prisma.DbNull,
+ }),
+ 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) -----
@@ -246,42 +392,37 @@ export class RequestsService {
note: string | null,
): Promise {
const request = await this.assertRequest(id);
- if (request.type === 'Vacation') {
- // Vacation must use the multi-stage workflow.
- 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.
+ if (request.type === 'TimeAdjustment') {
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);
- 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) {
- await this.materializeTimeAdjustment(tx, request);
- }
return updated;
});
this.notifications.notifyTransitioned(updated, 'ManagerApproved', actorId);
@@ -299,17 +440,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 },
@@ -562,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);
}
@@ -573,12 +717,19 @@ 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,
});
const status = deriveStatus(target);
- const data: Prisma.RequestUpdateInput = {
+ const data: Prisma.RequestUncheckedUpdateInput = {
workflowState: target,
status,
};
@@ -588,92 +739,222 @@ 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 },
});
- // 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 {
- 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 +1018,18 @@ export class RequestsService {
employeeId: string,
from: Date,
to: Date,
+ db: Prisma.TransactionClient = this.prisma,
+ excludeRequestId?: string,
+ timeAdjustment = false,
): Promise {
- const overlap = await this.prisma.request.findFirst({
+ 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 },
},
@@ -753,31 +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,
- ): Promise {
- const overlap = await this.prisma.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/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.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-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..13f6aac
--- /dev/null
+++ b/apps/api/src/app/time-entries/time-entry-approvals.dto.ts
@@ -0,0 +1,73 @@
+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({ type: String, nullable: true, format: 'uuid' })
+ requestId!: string | null;
+ @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..530f1bb
--- /dev/null
+++ b/apps/api/src/app/time-entries/time-entry-approvals.service.ts
@@ -0,0 +1,348 @@
+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,
+ requestId: event.requestId,
+ 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 037c1a3..9d6aaad 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -191,6 +191,74 @@ 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'
+ | '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 {
+ requestId?: string | null;
+ id: string;
+ actorId: string | null;
+ actorName: string | null;
+ action: string;
+ reason: string | null;
+ occurredAt: string;
+}
+
export interface SplitTimeEntryResult {
first: TimeEntryDto;
second: TimeEntryDto;
@@ -365,8 +433,13 @@ export interface AccountDto {
}
export interface RequestDto {
+ targetTimeEntryId?: string | null;
+ expectedTargetRevision?: number | null;
+ targetTimeEntryBefore?: TimeEntryBeforeSnapshot | null;
id: string;
employeeId: string;
+ employeeName: string;
+ updatedAt: string;
type: RequestType;
status: RequestStatus;
workflowState: WorkflowState;
@@ -529,6 +602,8 @@ export interface UpsertWorkSchedulePayload {
}
export interface CreateRequestPayload {
+ targetTimeEntryId?: string | null;
+ expectedTargetRevision?: number | null;
employeeId: string;
type: RequestType;
from: string;
@@ -546,6 +621,15 @@ export interface CreateVacationPayload {
halfDayEnd?: boolean;
}
+export interface UpdateDraftRequestPayload extends Omit<
+ CreateVacationPayload,
+ 'employeeId'
+> {
+ expectedUpdatedAt: string;
+ targetTimeEntryId?: string | null;
+ expectedTargetRevision?: number | null;
+}
+
export interface UpsertLeaveAllowancePayload {
baseDays: number;
carryOverDays: number;
@@ -913,6 +997,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',
@@ -1042,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`),
@@ -1055,6 +1156,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..1972404 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;
@@ -1268,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;
@@ -1281,7 +1361,7 @@ export interface paths {
delete?: never;
options?: never;
head?: never;
- patch?: never;
+ patch: operations['RequestsController_updateDraft'];
trace?: never;
};
'/api/requests/{id}/events': {
@@ -1316,6 +1396,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;
@@ -2332,6 +2428,44 @@ 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: {
+ /** Format: uuid */
+ requestId: string | null;
+ 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;
@@ -2500,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 */
@@ -2813,7 +2950,56 @@ export interface components {
adjustmentDays: number;
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 */
+ 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 */
+ targetTimeEntryId?: string | null;
+ expectedTargetRevision?: number | null;
/** Format: uuid */
employeeId: string;
/** @enum {string} */
@@ -2828,9 +3014,12 @@ export interface components {
* @example 2026-08-07T00:00:00.000Z
*/
to: string;
- reason?: Record | null;
+ reason?: string | null;
};
CreateVacationDto: {
+ /** Format: uuid */
+ targetTimeEntryId?: string | null;
+ expectedTargetRevision?: number | null;
/** Format: uuid */
employeeId: string;
/** Format: date-time */
@@ -2838,8 +3027,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 +3040,42 @@ export interface components {
*/
halfDayEnd: boolean;
};
+ UpdateDraftRequestDto: {
+ /** Format: uuid */
+ targetTimeEntryId?: string | null;
+ expectedTargetRevision?: number | null;
+ /** 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 +3088,7 @@ export interface components {
/** Format: uuid */
actorId: string;
ids: string[];
- note?: Record | null;
+ note?: string | null;
/** @default false */
requiresHrConfirmation: boolean;
};
@@ -4190,6 +4406,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;
@@ -5059,7 +5342,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'][];
+ };
};
};
};
@@ -5080,7 +5365,52 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
+ };
+ };
+ };
+ 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'][];
+ };
};
};
};
@@ -5099,7 +5429,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 +5496,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 +5546,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5185,7 +5571,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5208,7 +5596,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5231,7 +5621,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5254,7 +5646,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5277,7 +5671,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5300,7 +5696,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5323,7 +5721,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5346,7 +5746,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5369,7 +5771,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..3724bec 100644
--- a/apps/web/src/app/i18n.tsx
+++ b/apps/web/src/app/i18n.tsx
@@ -16,6 +16,72 @@ 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':
+ '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.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',
+ 'frameApproval.event.FrameHrRejected': 'Von HR abgelehnt',
+ 'frameApproval.event.FrameApprovalInvalidated':
+ 'Bisherige Genehmigung durch Änderung aufgehoben',
...soloDe,
...terminalPinDe,
'projects.deleteFailed': 'Projekt konnte nicht gelöscht werden',
@@ -70,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.',
@@ -347,6 +440,22 @@ 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.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.',
@@ -690,6 +799,67 @@ 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':
+ '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.FrameRequestCorrected': 'Changed by correction request',
+ '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',
@@ -744,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".',
@@ -1018,6 +1216,22 @@ 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.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/app/realtime.ts b/apps/web/src/app/realtime.ts
index 0ff985d..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,9 @@ 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'] });
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..0f2c271
--- /dev/null
+++ b/apps/web/src/components/timeentries/TimeEntryApprovals.spec.tsx
@@ -0,0 +1,624 @@
+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();
+ });
+ 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
new file mode 100644
index 0000000..229c1a5
--- /dev/null
+++ b/apps/web/src/components/timeentries/TimeEntryApprovals.tsx
@@ -0,0 +1,322 @@
+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 : ''}
+
+
+ )}
+ void query.refetch()}
+ >
+ {t('frameApproval.refresh')}
+
+ {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 && (
+
+
+ {t('frameApproval.note')}
+
+
setNote(event.target.value)}
+ />
+
+ {t('frameApproval.reasonRequired')}
+
+
+ {
+ setResult(null);
+ decision.mutate('Approve');
+ }}
+ >
+ {t(`frameApproval.approve.${stage}`)}
+
+ {
+ setResult(null);
+ decision.mutate('Reject');
+ }}
+ >
+ {t(`frameApproval.reject.${stage}`)}
+
+
+
+ )}
+ setHistoryOpen((open) => !open)}
+ >
+ {t('frameApproval.history')}
+
+ {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) => (
+
+
+ {t(`frameApproval.event.${event.action}`)}
+
+
+ {event.actorName ?? event.actorId ?? t('frameApproval.system')}{' '}
+ ·{' '}
+
+ {formatDateTime(event.occurredAt)}
+
+
+ {event.requestId && (
+ {t('requests.relatedRequest', { id: event.requestId })}
+ )}
+ {event.reason && (
+ {event.reason}
+ )}
+
+ ))}
+
+ )}
+ {history.isError && (
+
void history.refetch()}
+ >
+ {t('frameApproval.refresh')}
+
+ )}
+
+ );
+}
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/AdminRequestsPage.spec.tsx b/apps/web/src/routes/AdminRequestsPage.spec.tsx
new file mode 100644
index 0000000..10fe097
--- /dev/null
+++ b/apps/web/src/routes/AdminRequestsPage.spec.tsx
@@ -0,0 +1,120 @@
+import { afterEach, 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',
+ 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: {
+ timeEntryApprovals: vi.fn().mockResolvedValue([]),
+ listRequests: vi
+ .fn()
+ .mockImplementation(async (filter) =>
+ filter.currentApproverId ? [request] : [],
+ ),
+ requestTimeEntryAudit: vi.fn().mockResolvedValue([]),
+ 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',
+ }),
+}));
+
+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( );
+ 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/),
+ ).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 d8fc2df..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';
@@ -25,6 +26,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 +109,8 @@ export function AdminRequestsPage() {
+
+
{bulkOutcome && (
- {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))}`}
@@ -526,6 +530,8 @@ function RequestRow({
+ {isTimeAdjustment && }
+
{((role === 'Manager' && inManagerInbox) ||
(role === 'HRAdmin' && (inHrInbox || inManagerInbox))) && (
@@ -624,10 +630,16 @@ 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))}`
: '…'}
+ {requestQuery.data?.type === 'TimeAdjustment' && (
+
+
+
+
+ )}
{eventsQuery.data && eventsQuery.data.length > 0 ? (
diff --git a/apps/web/src/routes/BookingPage.spec.tsx b/apps/web/src/routes/BookingPage.spec.tsx
index d749853..f445ffc 100644
--- a/apps/web/src/routes/BookingPage.spec.tsx
+++ b/apps/web/src/routes/BookingPage.spec.tsx
@@ -3,6 +3,8 @@ import { fireEvent, screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test-utils';
const timeEntriesMock = vi.fn().mockResolvedValue([]);
+const timeEntryApprovalsMock = vi.fn().mockResolvedValue([]);
+const approvalHistoryMock = vi.fn().mockResolvedValue([]);
const clockInMock = vi.fn();
const clockOutMock = vi.fn();
const dailyBlockOptionMock = vi.fn();
@@ -16,6 +18,9 @@ const violationsMock = vi.fn().mockResolvedValue([]);
vi.mock('../api/client', () => ({
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/apps/web/src/routes/RequestsPage.spec.tsx b/apps/web/src/routes/RequestsPage.spec.tsx
index 933cab4..d107e82 100644
--- a/apps/web/src/routes/RequestsPage.spec.tsx
+++ b/apps/web/src/routes/RequestsPage.spec.tsx
@@ -1,15 +1,41 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ afterAll,
+ afterEach,
+ 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';
+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();
+const createRequestMock = vi.fn();
+const targetsMock = vi.fn();
+const requestAuditMock = vi.fn();
+const cancelRequestMock = vi.fn();
vi.mock('../api/client', () => ({
api: {
- listRequests: vi.fn().mockResolvedValue([]),
+ 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),
+ updateDraftRequest: (...args: unknown[]) => updateDraftRequestMock(...args),
+ resubmitRequest: (...args: unknown[]) => resubmitRequestMock(...args),
employeeDirectory: (...args: unknown[]) => employeeDirectoryMock(...args),
employees: (...args: unknown[]) => employeesMock(...args),
vacationBalance: vi.fn().mockResolvedValue({
@@ -32,8 +58,36 @@ 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;
+ });
+ 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([]);
+ createRequestMock.mockReset().mockResolvedValue({});
+ targetsMock.mockReset().mockResolvedValue([]);
+ requestAuditMock.mockReset().mockResolvedValue([]);
+ cancelRequestMock.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 +126,712 @@ 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();
+ });
+ 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();
+ }
+ });
+ 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 ce0534b..1c69d63 100644
--- a/apps/web/src/routes/RequestsPage.tsx
+++ b/apps/web/src/routes/RequestsPage.tsx
@@ -15,9 +15,19 @@ 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,
+ type TimeAdjustmentTargetDto,
+} 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',
@@ -44,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));
@@ -55,6 +81,8 @@ export function RequestsPage() {
const employeeId = user.id;
const qc = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [historyId, setHistoryId] = useState(null);
const requests = useQuery({
queryKey: ['requests', { employeeId }],
@@ -76,17 +104,30 @@ export function RequestsPage() {
{t('requests.new')}
-
+
{
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 +142,7 @@ export function RequestsPage() {
>
+ {r.employeeName || `${user.firstName} ${user.lastName}`} ·{' '}
{enumLabel(r.type)} ·{' '}
{formatRange(
r.type as RequestType,
@@ -122,10 +164,38 @@ export function RequestsPage() {
{t('requests.specialApproval')}
)}
-
- {enumLabel(r.workflowState)}
-
+ {r.workflowState === 'Draft' ? (
+ setEditingId(r.id)}
+ >
+ {enumLabel(r.workflowState)} · {t('requests.edit')}
+
+ ) : (
+
+ {enumLabel(r.workflowState)}
+
+ )}
+ {r.type === 'TimeAdjustment' && (
+
+
+
+ setHistoryId(historyId === r.id ? null : r.id)
+ }
+ >
+ {t('requests.bookingHistory')}
+
+ {historyId === r.id && (
+
+ )}
+
+ )}
{r.type === 'SpecialLeave' && (
)}
@@ -143,31 +213,186 @@ 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();
- // 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,
+ reset: (iso: string) => {
+ setValue(formatLocalDateTime(iso));
+ setChosenIso(iso);
+ },
+ change: (next: string) => {
+ setValue(next);
+ setChosenIso('');
+ },
+ };
}
-function NewRequestForm({ onClose }: NewRequestFormProps) {
+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,
+ })}
+
+
+ {t('requests.timeOccurrence', { field: label })}
+
+
time.setChosenIso(e.target.value)}
+ aria-invalid={invalid}
+ aria-describedby={`${id}-time-help`}
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
+ >
+ {t('requests.chooseOccurrence')}
+ {time.candidates.map((iso, index) => (
+
+ {t('requests.timeOccurrenceOption', {
+ number: index + 1,
+ offset: utcOffsetLabel(iso),
+ })}
+
+ ))}
+
+
+ ) : invalid ? (
+
+ {t('requests.nonexistentTime')}
+
+ ) : null}
+ >
+ );
+}
+
+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('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 fromTime = useLocalDateTime(
+ request?.type === 'TimeAdjustment' ? request.from : undefined,
+ );
+ 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 ?? '',
+ );
+ 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 +401,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({
@@ -186,32 +426,71 @@ function NewRequestForm({ onClose }: 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 () => {
+ 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 (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(
+ isTimeAdjustment
+ ? 'requests.invalidTimeRange'
+ : 'requests.invalidDateRange',
+ ),
+ );
+ }
+ if (request) {
+ const payload = {
+ expectedUpdatedAt: request.updatedAt,
+ ...(isTimeAdjustment
+ ? {
+ targetTimeEntryId: request.targetTimeEntryId ?? null,
+ expectedTargetRevision: request.expectedTargetRevision ?? null,
+ }
+ : {}),
+ from: fromIso,
+ to: toIso,
+ 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,
- 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({
+ ...targetFields,
employeeId,
type,
from: fromIso,
@@ -230,22 +509,54 @@ function NewRequestForm({ onClose }: 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 &&
- 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.type')}
setType(e.target.value as RequestType)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
@@ -257,15 +568,103 @@ function NewRequestForm({ onClose }: NewRequestFormProps) {
))}
+ {isTimeAdjustment && (
+
+
+ {t('requests.adjustmentMode')}
+
+
{
+ setAdjustmentMode(event.target.value);
+ setSelectedTarget(null);
+ }}
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
+ >
+ {t('requests.missingBooking')}
+ {t('requests.correctBooking')}
+
+ {isCorrection && (
+ <>
+
+ {t('requests.targetBooking')}
+
+
{
+ const target =
+ targets.data?.find(
+ (entry) => entry.id === event.target.value,
+ ) ?? null;
+ setSelectedTarget(target);
+ if (target?.clockOut) {
+ fromTime.reset(target.clockIn);
+ toTime.reset(target.clockOut);
+ }
+ }}
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
+ >
+ {t('requests.chooseTarget')}
+ {request && targetId ? (
+
+ {t('requests.targetReference', {
+ id: targetId,
+ revision: targetRevision ?? '—',
+ })}
+
+ ) : (
+ targets.data?.map((entry) => (
+
+ {entry.id} ·{' '}
+ {correctionInstant(entry.clockIn, languageTag)} –{' '}
+ {entry.clockOut
+ ? correctionInstant(entry.clockOut, languageTag)
+ : ''}
+
+ ))
+ )}
+
+ {!request && targets.isPending && (
+
{t('common.loading')}
+ )}
+ {!request && targets.isError && (
+
+
+ {t('requests.targetsFailed')}
+
+ void targets.refetch()}
+ >
+ {t('requests.retry')}
+
+
+ )}
+ {!request && targets.isSuccess && targets.data.length === 0 && (
+
{t('requests.targetsEmpty')}
+ )}
+ {missingTarget && (
+
+ {t('requests.targetRequired')}
+
+ )}
+ >
+ )}
+
+ )}
{t('common.from')}
{isTimeAdjustment ? (
- setFromDt(e.target.value)}
+ label={t('common.from')}
+ time={fromTime}
/>
) : (
{t('common.to')}
{isTimeAdjustment ? (
- setToDt(e.target.value)}
+ label={t('common.to')}
+ time={toTime}
/>
) : (
+ {isTimeAdjustment && (!isCorrection || targetId) && (
+
+ )}
{t('requests.reasonOptional')}
)}
- {type === 'SpecialLeave' && (
+ {type === 'SpecialLeave' && !request && (
{t('requests.attachmentLabel')}
)}
- {invalidRange && (
+ {invalidRange && !unresolvedTime && (
{isTimeAdjustment
@@ -399,7 +814,22 @@ function NewRequestForm({ onClose }: NewRequestFormProps) {
{error && (
- {error}
+
+ {error}
+ {isCorrection && request && (
+
+
{t('requests.staleTargetHint')}
+
cancelStaleRequest.mutate()}
+ >
+ {t('requests.cancelStale')}
+
+
+ )}
+
)}
@@ -407,14 +837,39 @@ function NewRequestForm({ onClose }: NewRequestFormProps) {
{t('common.cancel')}
+ {request && (
+
{
+ setError(null);
+ create.mutate(true);
+ }}
+ >
+ {t('requests.saveDraft')}
+
+ )}
{
setError(null);
- create.mutate();
+ create.mutate(false);
}}
>
- {create.isPending ? t('common.saving') : t('requests.submit')}
+ {create.isPending
+ ? t('common.saving')
+ : t(request ? 'requests.resubmit' : 'requests.submit')}
>
@@ -459,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) => (
+ <>
+
+ {correctionInstant(from, languageTag)}
+ {' '}
+ →{' '}
+
+ {correctionInstant(to, languageTag)}
+
+ >
+ );
+ 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')}
+ void audit.refetch()}
+ >
+ {t('requests.retry')}
+
+
+ )}
+ {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 (
+
+
+ {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'),
+ })}
+
+
+ {formatDateTime(event.occurredAt)}
+
+ {after && (
+
+ )}
+ {event.reason && {event.reason}
}
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+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
new file mode 100644
index 0000000..b68b328
--- /dev/null
+++ b/docs/ISSUE_FIXES_MANUAL_TEST.de.md
@@ -0,0 +1,801 @@
+# 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 |
+
+### 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
+(`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** |
+
+#### 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.
+
+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.
+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
+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. 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.
+
+## 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 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-
+ 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 | 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 |
+| 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 87abafb..d002cc3 100644
--- a/ops/upgrade-smoke.mjs
+++ b/ops/upgrade-smoke.mjs
@@ -14,6 +14,9 @@ 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';
const fallbackEmployeeId = '00000000-0000-4000-8000-000000000414';
@@ -109,12 +112,100 @@ 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],
+ );
+ // 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",
+ "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/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 dbf4114..02e1667 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?
@@ -212,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)
@@ -376,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)
@@ -581,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)
@@ -593,6 +606,7 @@ model TimeEntryAudit {
occurredAt DateTime @default(now())
@@index([timeEntryId, occurredAt])
+ @@index([requestId])
}
model PersonalDay {