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/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..0b2fcdb 100644
--- a/apps/api/openapi.json
+++ b/apps/api/openapi.json
@@ -2027,6 +2027,122 @@
"tags": ["time-entries"]
}
},
+ "/api/timeentries/approvals": {
+ "get": {
+ "operationId": "TimeEntriesController_approvalList",
+ "parameters": [
+ {
+ "name": "scope",
+ "required": false,
+ "in": "query",
+ "schema": {
+ "enum": ["own", "review"],
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/TimeEntryApprovalDto"
+ }
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["time-entries"]
+ }
+ },
+ "/api/timeentries/{id}/approval-history": {
+ "get": {
+ "operationId": "TimeEntriesController_approvalHistory",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/TimeEntryApprovalEventDto"
+ }
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["time-entries"]
+ }
+ },
+ "/api/timeentries/{id}/approval": {
+ "post": {
+ "operationId": "TimeEntriesController_decideApproval",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DecideTimeEntryApprovalDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TimeEntryApprovalDto"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["time-entries"]
+ }
+ },
"/api/timeentries/clock-in": {
"post": {
"operationId": "TimeEntriesController_clockIn",
@@ -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": [
@@ -3445,7 +3578,55 @@
],
"responses": {
"200": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["requests"]
+ },
+ "patch": {
+ "operationId": "RequestsController_updateDraft",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateDraftRequestDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3498,7 +3679,57 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["requests"]
+ }
+ },
+ "/api/requests/{id}/resubmit": {
+ "post": {
+ "operationId": "RequestsController_resubmit",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateDraftRequestDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3534,7 +3765,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3570,7 +3808,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3606,7 +3851,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3642,7 +3894,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3678,7 +3937,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3714,7 +3980,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3750,7 +4023,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3786,7 +4066,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3822,7 +4109,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3858,7 +4152,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -6338,6 +6639,112 @@
"summary"
]
},
+ "TimeEntryApprovalDto": {
+ "type": "object",
+ "properties": {
+ "entry": {
+ "$ref": "#/components/schemas/TimeEntryDto"
+ },
+ "revision": {
+ "type": "number",
+ "minimum": 0
+ },
+ "employeeName": {
+ "type": "string"
+ },
+ "managerId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "managerName": {
+ "type": "string",
+ "nullable": true
+ },
+ "state": {
+ "type": "string",
+ "enum": [
+ "Open",
+ "BlockedManager",
+ "PendingManager",
+ "PendingHr",
+ "Approved",
+ "Rejected",
+ "Voided"
+ ]
+ },
+ "canDecide": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "entry",
+ "revision",
+ "employeeName",
+ "managerId",
+ "managerName",
+ "state",
+ "canDecide"
+ ]
+ },
+ "TimeEntryApprovalEventDto": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "actorId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "actorName": {
+ "type": "string",
+ "nullable": true
+ },
+ "action": {
+ "type": "string"
+ },
+ "reason": {
+ "type": "string",
+ "nullable": true
+ },
+ "occurredAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ "required": [
+ "id",
+ "actorId",
+ "actorName",
+ "action",
+ "reason",
+ "occurredAt"
+ ]
+ },
+ "DecideTimeEntryApprovalDto": {
+ "type": "object",
+ "properties": {
+ "revision": {
+ "type": "number",
+ "minimum": 0
+ },
+ "stage": {
+ "type": "string",
+ "enum": ["Manager", "HR"]
+ },
+ "outcome": {
+ "type": "string",
+ "enum": ["Approve", "Reject"]
+ },
+ "note": {
+ "type": "string",
+ "maxLength": 2000
+ }
+ },
+ "required": ["revision", "stage", "outcome"]
+ },
"ClockInDto": {
"type": "object",
"properties": {
@@ -7656,6 +8063,128 @@
},
"required": ["baseDays", "carryOverDays", "adjustmentDays"]
},
+ "RequestDto": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "employeeId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "employeeName": {
+ "type": "string"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["Vacation", "HomeOffice", "SpecialLeave", "TimeAdjustment"]
+ },
+ "status": {
+ "type": "string"
+ },
+ "workflowState": {
+ "type": "string"
+ },
+ "from": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "to": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "reason": {
+ "type": "string",
+ "nullable": true
+ },
+ "requiresApproval": {
+ "type": "boolean"
+ },
+ "approverId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "currentApproverId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "substituteId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "substituteAcceptedAt": {
+ "type": "string",
+ "nullable": true,
+ "format": "date-time"
+ },
+ "hrConfirmedAt": {
+ "type": "string",
+ "nullable": true,
+ "format": "date-time"
+ },
+ "cancelledAt": {
+ "type": "string",
+ "nullable": true,
+ "format": "date-time"
+ },
+ "calculatedDays": {
+ "type": "number"
+ },
+ "halfDayStart": {
+ "type": "boolean"
+ },
+ "halfDayEnd": {
+ "type": "boolean"
+ },
+ "decidedAt": {
+ "type": "string",
+ "nullable": true,
+ "format": "date-time"
+ },
+ "decisionNote": {
+ "type": "string",
+ "nullable": true
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ "required": [
+ "id",
+ "employeeId",
+ "employeeName",
+ "updatedAt",
+ "type",
+ "status",
+ "workflowState",
+ "from",
+ "to",
+ "reason",
+ "requiresApproval",
+ "approverId",
+ "currentApproverId",
+ "substituteId",
+ "substituteAcceptedAt",
+ "hrConfirmedAt",
+ "cancelledAt",
+ "calculatedDays",
+ "halfDayStart",
+ "halfDayEnd",
+ "decidedAt",
+ "decisionNote",
+ "createdAt"
+ ]
+ },
"CreateRequestDto": {
"type": "object",
"properties": {
@@ -7678,7 +8207,7 @@
"example": "2026-08-07T00:00:00.000Z"
},
"reason": {
- "type": "object",
+ "type": "string",
"nullable": true,
"maxLength": 2000
}
@@ -7701,12 +8230,12 @@
"format": "date-time"
},
"substituteId": {
- "type": "object",
+ "type": "string",
"nullable": true,
"format": "uuid"
},
"reason": {
- "type": "object",
+ "type": "string",
"nullable": true,
"maxLength": 2000
},
@@ -7723,6 +8252,45 @@
},
"required": ["employeeId", "from", "to"]
},
+ "UpdateDraftRequestDto": {
+ "type": "object",
+ "properties": {
+ "from": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "to": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "substituteId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "reason": {
+ "type": "string",
+ "nullable": true,
+ "maxLength": 2000
+ },
+ "halfDayStart": {
+ "type": "boolean",
+ "default": false,
+ "description": "Take the first day as a half-day"
+ },
+ "halfDayEnd": {
+ "type": "boolean",
+ "default": false,
+ "description": "Take the last day as a half-day"
+ },
+ "expectedUpdatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "updatedAt from the version being edited; stale writes return 409"
+ }
+ },
+ "required": ["from", "to", "expectedUpdatedAt"]
+ },
"TransitionDto": {
"type": "object",
"properties": {
@@ -7731,7 +8299,7 @@
"format": "uuid"
},
"note": {
- "type": "object",
+ "type": "string",
"nullable": true,
"maxLength": 2000
}
@@ -7746,7 +8314,7 @@
"format": "uuid"
},
"note": {
- "type": "object",
+ "type": "string",
"nullable": true,
"maxLength": 2000
},
@@ -7786,7 +8354,7 @@
}
},
"note": {
- "type": "object",
+ "type": "string",
"nullable": true,
"maxLength": 2000
},
diff --git a/apps/api/src/app/accounts/vacation-balance.service.ts b/apps/api/src/app/accounts/vacation-balance.service.ts
index 61bc2b7..d50b4fb 100644
--- a/apps/api/src/app/accounts/vacation-balance.service.ts
+++ b/apps/api/src/app/accounts/vacation-balance.service.ts
@@ -1,3 +1,4 @@
+import type { Prisma } from '@prisma/client';
import { Injectable } from '@nestjs/common';
import { calculateWorkingDays } from 'shared';
import { LeaveAllowancesService } from '../leave-allowances/leave-allowances.service';
@@ -20,16 +21,22 @@ export class VacationBalanceService {
private readonly schedules: WorkSchedulesService,
) {}
- async compute(employeeId: string, year: number): Promise {
- const allowance = await this.allowances.getOrDefault(employeeId, year);
+ async compute(
+ employeeId: string,
+ year: number,
+ excludeRequestId?: string,
+ db: Prisma.TransactionClient = this.prisma,
+ ): Promise {
+ const allowance = await this.allowances.getOrDefault(employeeId, year, db);
const schedule = await this.schedules.resolveForEmployee(employeeId);
const yearStart = new Date(Date.UTC(year, 0, 1));
const yearEnd = new Date(Date.UTC(year, 11, 31, 23, 59, 59));
- const requests = await this.prisma.request.findMany({
+ const requests = await db.request.findMany({
where: {
employeeId,
type: 'Vacation',
+ ...(excludeRequestId ? { id: { not: excludeRequestId } } : {}),
from: { lte: yearEnd },
to: { gte: yearStart },
},
diff --git a/apps/api/src/app/leave-allowances/leave-allowances.service.ts b/apps/api/src/app/leave-allowances/leave-allowances.service.ts
index 76c1f78..4c4046c 100644
--- a/apps/api/src/app/leave-allowances/leave-allowances.service.ts
+++ b/apps/api/src/app/leave-allowances/leave-allowances.service.ts
@@ -1,5 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
-import type { EmployeeLeaveAllowance } from '@prisma/client';
+import type { EmployeeLeaveAllowance, Prisma } from '@prisma/client';
import type { JwtUser } from '../auth/jwt.strategy';
import { EmployeesService } from '../employees/employees.service';
import { PrismaService } from '../prisma/prisma.service';
@@ -29,13 +29,14 @@ export class LeaveAllowancesService {
async getOrDefault(
employeeId: string,
year: number,
+ db: Prisma.TransactionClient = this.prisma,
): Promise {
- const existing = await this.prisma.employeeLeaveAllowance.findUnique({
+ const existing = await db.employeeLeaveAllowance.findUnique({
where: { employeeId_year: { employeeId, year } },
});
if (existing) return existing;
- const employee = await this.assertEmployee(employeeId);
- return this.prisma.employeeLeaveAllowance.create({
+ const employee = await this.assertEmployee(employeeId, db);
+ return db.employeeLeaveAllowance.create({
data: {
employeeId,
year,
@@ -113,8 +114,11 @@ export class LeaveAllowancesService {
return { scanned: candidates.length, expired };
}
- private async assertEmployee(employeeId: string) {
- const employee = await this.prisma.employee.findUnique({
+ private async assertEmployee(
+ employeeId: string,
+ db: Prisma.TransactionClient = this.prisma,
+ ) {
+ const employee = await db.employee.findUnique({
where: { id: employeeId },
});
if (!employee)
diff --git a/apps/api/src/app/requests/requests.controller.ts b/apps/api/src/app/requests/requests.controller.ts
index ab41a63..40c6910 100644
--- a/apps/api/src/app/requests/requests.controller.ts
+++ b/apps/api/src/app/requests/requests.controller.ts
@@ -3,12 +3,18 @@ import {
Controller,
Get,
Param,
+ Patch,
ParseUUIDPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
-import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+import {
+ ApiBearerAuth,
+ ApiCreatedResponse,
+ ApiOkResponse,
+ ApiTags,
+} from '@nestjs/swagger';
import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { JwtUser } from '../auth/jwt.strategy';
@@ -20,9 +26,10 @@ import {
CreateVacationDto,
ManagerApproveDto,
TransitionDto,
+ UpdateDraftRequestDto,
TransitionWithRequiredNoteDto,
type BulkResult,
- type RequestDto,
+ RequestDto,
type RequestEventDto,
} from './requests.dto';
@@ -34,6 +41,7 @@ export class RequestsController {
constructor(private readonly service: RequestsService) {}
@Get()
+ @ApiOkResponse({ type: RequestDto, isArray: true })
list(
@CurrentUser() user: JwtUser,
@Query('employeeId') employeeId?: string,
@@ -57,6 +65,7 @@ export class RequestsController {
}
@Get(':id')
+ @ApiOkResponse({ type: RequestDto })
get(
@Param('id', new ParseUUIDPipe()) id: string,
@CurrentUser() user: JwtUser,
@@ -73,6 +82,7 @@ export class RequestsController {
}
@Post()
+ @ApiCreatedResponse({ type: RequestDto })
create(
@Body() dto: CreateRequestDto,
@CurrentUser() user: JwtUser,
@@ -81,6 +91,7 @@ export class RequestsController {
}
@Post('vacation')
+ @ApiCreatedResponse({ type: RequestDto })
createVacation(
@Body() dto: CreateVacationDto,
@CurrentUser() user: JwtUser,
@@ -88,9 +99,30 @@ export class RequestsController {
return this.service.createVacation({ ...dto, employeeId: user.id });
}
+ @Patch(':id')
+ @ApiOkResponse({ type: RequestDto })
+ updateDraft(
+ @Param('id', new ParseUUIDPipe()) id: string,
+ @Body() dto: UpdateDraftRequestDto,
+ @CurrentUser() user: JwtUser,
+ ): Promise {
+ return this.service.updateDraft(id, user.id, dto, false);
+ }
+
+ @Post(':id/resubmit')
+ @ApiCreatedResponse({ type: RequestDto })
+ resubmit(
+ @Param('id', new ParseUUIDPipe()) id: string,
+ @Body() dto: UpdateDraftRequestDto,
+ @CurrentUser() user: JwtUser,
+ ): Promise {
+ return this.service.updateDraft(id, user.id, dto, true);
+ }
+
// ----- Generic approve / reject (legacy convenience) -----
@Post(':id/approve')
+ @ApiCreatedResponse({ type: RequestDto })
approve(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionDto,
@@ -100,6 +132,7 @@ export class RequestsController {
}
@Post(':id/reject')
+ @ApiCreatedResponse({ type: RequestDto })
reject(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionDto,
@@ -111,6 +144,7 @@ export class RequestsController {
// ----- Vacation workflow -----
@Post(':id/manager-approve')
+ @ApiCreatedResponse({ type: RequestDto })
managerApprove(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: ManagerApproveDto,
@@ -125,6 +159,7 @@ export class RequestsController {
}
@Post(':id/manager-reject')
+ @ApiCreatedResponse({ type: RequestDto })
managerReject(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionDto,
@@ -134,6 +169,7 @@ export class RequestsController {
}
@Post(':id/hr-confirm')
+ @ApiCreatedResponse({ type: RequestDto })
hrConfirm(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionDto,
@@ -143,6 +179,7 @@ export class RequestsController {
}
@Post(':id/hr-reject')
+ @ApiCreatedResponse({ type: RequestDto })
hrReject(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionWithRequiredNoteDto,
@@ -152,6 +189,7 @@ export class RequestsController {
}
@Post(':id/substitute/accept')
+ @ApiCreatedResponse({ type: RequestDto })
substituteAccept(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionDto,
@@ -161,6 +199,7 @@ export class RequestsController {
}
@Post(':id/substitute/decline')
+ @ApiCreatedResponse({ type: RequestDto })
substituteDecline(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionWithRequiredNoteDto,
@@ -170,6 +209,7 @@ export class RequestsController {
}
@Post(':id/return')
+ @ApiCreatedResponse({ type: RequestDto })
returnForRevision(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionWithRequiredNoteDto,
@@ -179,6 +219,7 @@ export class RequestsController {
}
@Post(':id/cancel')
+ @ApiCreatedResponse({ type: RequestDto })
cancel(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionDto,
diff --git a/apps/api/src/app/requests/requests.dto.ts b/apps/api/src/app/requests/requests.dto.ts
index 40e78f5..546ff70 100644
--- a/apps/api/src/app/requests/requests.dto.ts
+++ b/apps/api/src/app/requests/requests.dto.ts
@@ -1,4 +1,4 @@
-import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger';
import {
ArrayNotEmpty,
IsArray,
@@ -12,7 +12,12 @@ import {
} from 'class-validator';
import type { Request } from '@prisma/client';
-const REQUEST_TYPES = ['Vacation', 'HomeOffice', 'SpecialLeave', 'TimeAdjustment'] as const;
+const REQUEST_TYPES = [
+ 'Vacation',
+ 'HomeOffice',
+ 'SpecialLeave',
+ 'TimeAdjustment',
+] as const;
export class CreateRequestDto {
@ApiProperty({ format: 'uuid' })
@@ -31,7 +36,7 @@ export class CreateRequestDto {
@IsISO8601()
to!: string;
- @ApiPropertyOptional({ nullable: true, maxLength: 2000 })
+ @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 })
@IsOptional()
@IsString()
@MaxLength(2000)
@@ -51,34 +56,53 @@ export class CreateVacationDto {
@IsISO8601()
to!: string;
- @ApiPropertyOptional({ nullable: true, format: 'uuid' })
+ @ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' })
@IsOptional()
@IsUUID()
substituteId?: string | null;
- @ApiPropertyOptional({ nullable: true, maxLength: 2000 })
+ @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 })
@IsOptional()
@IsString()
@MaxLength(2000)
reason?: string | null;
- @ApiPropertyOptional({ default: false, description: 'Take the first day as a half-day' })
+ @ApiPropertyOptional({
+ default: false,
+ description: 'Take the first day as a half-day',
+ })
@IsOptional()
@IsBoolean()
halfDayStart?: boolean;
- @ApiPropertyOptional({ default: false, description: 'Take the last day as a half-day' })
+ @ApiPropertyOptional({
+ default: false,
+ description: 'Take the last day as a half-day',
+ })
@IsOptional()
@IsBoolean()
halfDayEnd?: boolean;
}
+/** Complete editable values. Request type and owner are immutable. */
+export class UpdateDraftRequestDto extends OmitType(CreateVacationDto, [
+ 'employeeId',
+] as const) {
+ @ApiProperty({
+ format: 'date-time',
+ description:
+ 'updatedAt from the version being edited; stale writes return 409',
+ })
+ @IsISO8601()
+ expectedUpdatedAt!: string;
+}
+
export class TransitionDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
actorId!: string;
- @ApiPropertyOptional({ nullable: true, maxLength: 2000 })
+ @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 })
@IsOptional()
@IsString()
@MaxLength(2000)
@@ -114,7 +138,7 @@ export class BulkApproveDto {
@IsUUID('all', { each: true })
ids!: string[];
- @ApiPropertyOptional({ nullable: true, maxLength: 2000 })
+ @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 })
@IsOptional()
@IsString()
@MaxLength(2000)
@@ -151,34 +175,63 @@ export interface BulkResult {
error?: string;
}
-export interface RequestDto {
- id: string;
- employeeId: string;
- type: string;
- status: string;
- workflowState: string;
- from: string;
- to: string;
- reason: string | null;
- requiresApproval: boolean;
- approverId: string | null;
- currentApproverId: string | null;
- substituteId: string | null;
- substituteAcceptedAt: string | null;
- hrConfirmedAt: string | null;
- cancelledAt: string | null;
- calculatedDays: number;
- halfDayStart: boolean;
- halfDayEnd: boolean;
- decidedAt: string | null;
- decisionNote: string | null;
- createdAt: string;
+export class RequestDto {
+ @ApiProperty({ format: 'uuid' })
+ id!: string;
+ @ApiProperty({ format: 'uuid' })
+ employeeId!: string;
+ @ApiProperty()
+ employeeName!: string;
+ @ApiProperty({ format: 'date-time' })
+ updatedAt!: string;
+ @ApiProperty({ enum: REQUEST_TYPES })
+ type!: string;
+ @ApiProperty()
+ status!: string;
+ @ApiProperty()
+ workflowState!: string;
+ @ApiProperty({ format: 'date-time' })
+ from!: string;
+ @ApiProperty({ format: 'date-time' })
+ to!: string;
+ @ApiProperty({ type: String, nullable: true })
+ reason!: string | null;
+ @ApiProperty()
+ requiresApproval!: boolean;
+ @ApiProperty({ type: String, nullable: true, format: 'uuid' })
+ approverId!: string | null;
+ @ApiProperty({ type: String, nullable: true, format: 'uuid' })
+ currentApproverId!: string | null;
+ @ApiProperty({ type: String, nullable: true, format: 'uuid' })
+ substituteId!: string | null;
+ @ApiProperty({ type: String, nullable: true, format: 'date-time' })
+ substituteAcceptedAt!: string | null;
+ @ApiProperty({ type: String, nullable: true, format: 'date-time' })
+ hrConfirmedAt!: string | null;
+ @ApiProperty({ type: String, nullable: true, format: 'date-time' })
+ cancelledAt!: string | null;
+ @ApiProperty()
+ calculatedDays!: number;
+ @ApiProperty()
+ halfDayStart!: boolean;
+ @ApiProperty()
+ halfDayEnd!: boolean;
+ @ApiProperty({ type: String, nullable: true, format: 'date-time' })
+ decidedAt!: string | null;
+ @ApiProperty({ type: String, nullable: true })
+ decisionNote!: string | null;
+ @ApiProperty({ format: 'date-time' })
+ createdAt!: string;
}
-export function toRequestDto(r: Request): RequestDto {
+export function toRequestDto(
+ r: Request & { employee: { firstName: string; lastName: string } },
+): RequestDto {
return {
id: r.id,
employeeId: r.employeeId,
+ employeeName: `${r.employee.firstName} ${r.employee.lastName}`,
+ updatedAt: r.updatedAt.toISOString(),
type: r.type,
status: r.status,
workflowState: r.workflowState,
@@ -189,7 +242,9 @@ export function toRequestDto(r: Request): RequestDto {
approverId: r.approverId,
currentApproverId: r.currentApproverId,
substituteId: r.substituteId,
- substituteAcceptedAt: r.substituteAcceptedAt ? r.substituteAcceptedAt.toISOString() : null,
+ substituteAcceptedAt: r.substituteAcceptedAt
+ ? r.substituteAcceptedAt.toISOString()
+ : null,
hrConfirmedAt: r.hrConfirmedAt ? r.hrConfirmedAt.toISOString() : null,
cancelledAt: r.cancelledAt ? r.cancelledAt.toISOString() : null,
calculatedDays: Number(r.calculatedDays),
diff --git a/apps/api/src/app/requests/requests.service.ts b/apps/api/src/app/requests/requests.service.ts
index 95e562c..2a19d43 100644
--- a/apps/api/src/app/requests/requests.service.ts
+++ b/apps/api/src/app/requests/requests.service.ts
@@ -10,7 +10,6 @@ import type {
Request,
RequestEventKind,
RequestType,
- WorkflowState,
} from '@prisma/client';
import {
calculateVacationDays,
@@ -32,6 +31,7 @@ import {
type CreateVacationDto,
type RequestDto,
type RequestEventDto,
+ type UpdateDraftRequestDto,
} from './requests.dto';
export interface ListRequestsFilter {
@@ -43,6 +43,13 @@ export interface ListRequestsFilter {
substituteId?: string;
}
+const employeeInclude = {
+ employee: { select: { firstName: true, lastName: true } },
+} as const;
+type RequestWithEmployee = Prisma.RequestGetPayload<{
+ include: typeof employeeInclude;
+}>;
+
@Injectable()
export class RequestsService {
constructor(
@@ -72,6 +79,7 @@ export class RequestsService {
where: { AND: [where, await this.visibilityWhere(actorId)] },
orderBy: { createdAt: 'desc' },
take: 200,
+ include: employeeInclude,
});
return rows.map(toRequestDto);
}
@@ -99,143 +107,234 @@ export class RequestsService {
}));
}
- // ----- Generic create (HomeOffice, SpecialLeave, TimeAdjustment, Vacation-as-fallback) -----
-
+ // Creation and revision deliberately share validation. Serializing per employee
+ // keeps simultaneous resubmissions/creations from spending the same balance or
+ // passing the same overlap check. No request is deleted/recreated on revision.
async create(dto: CreateRequestDto): Promise {
- if (dto.type === 'Vacation') {
- // Vacation has its own validated path with leave balance check.
- return this.createVacation({
- employeeId: dto.employeeId,
- from: dto.from,
- to: dto.to,
- substituteId: null,
- reason: dto.reason ?? null,
- });
- }
- const employee = await this.employees.getById(dto.employeeId);
- const from = new Date(dto.from);
- const to = new Date(dto.to);
- if (to.getTime() < from.getTime()) {
- throw new BadRequestException('"to" must be on/after "from"');
- }
- await this.assertNoActiveOverlap(employee.id, from, to);
- const schedule = await this.schedules.resolveForEmployee(employee.id);
- let requires = false;
- if (dto.type === 'TimeAdjustment') {
- if (to.getTime() <= from.getTime()) {
- throw new BadRequestException(
- 'TimeAdjustment "to" must be after "from"',
- );
- }
- await this.assertNoTimeEntryOverlap(employee.id, from, to);
- requires = requiresSpecialApproval(from, to, schedule.frame);
- }
- const calculatedDays = calculateWorkingDays(from, to, {
- workingDays: schedule.workingDays,
- holidayProvider: schedule.holidayProvider,
- });
+ return this.createValidated(dto.type, dto);
+ }
+
+ async createVacation(dto: CreateVacationDto): Promise {
+ return this.createValidated('Vacation', dto);
+ }
+
+ private async lockEmployee(
+ tx: Prisma.TransactionClient,
+ employeeId: string,
+ ): Promise {
+ await tx.$queryRaw`SELECT "id" FROM "Employee" WHERE "id" = ${employeeId}::uuid FOR NO KEY UPDATE`;
+ }
+
+ private async createValidated(
+ type: RequestType,
+ dto: CreateVacationDto,
+ ): Promise {
const created = await this.prisma.$transaction(async (tx) => {
+ await this.lockEmployee(tx, dto.employeeId);
+ const values = await this.validateValues(type, dto, tx);
const request = await tx.request.create({
data: {
- employeeId: employee.id,
- type: dto.type as RequestType,
+ ...values,
+ employeeId: dto.employeeId,
+ type,
status: 'Submitted',
- workflowState: 'PendingManager',
- from,
- to,
- reason: dto.reason ?? null,
- requiresApproval: requires,
- calculatedDays,
- currentApproverId: employee.managerId,
+ workflowState: values.substituteId
+ ? 'PendingSubstitute'
+ : 'PendingManager',
},
+ include: employeeInclude,
});
await tx.requestEvent.create({
data: {
requestId: request.id,
kind: 'Submitted',
- actorId: employee.id,
+ actorId: dto.employeeId,
},
});
return request;
});
- this.notifications.notifyTransitioned(created, 'Submitted', employee.id);
+ this.notifications.notifyTransitioned(created, 'Submitted', dto.employeeId);
return toRequestDto(created);
}
- // ----- Vacation creation with balance check -----
+ async updateDraft(
+ id: string,
+ actorId: string,
+ dto: UpdateDraftRequestDto,
+ resubmit: boolean,
+ ): Promise {
+ const updated = await this.prisma.$transaction(async (tx) => {
+ await this.lockEmployee(tx, actorId);
+ const request = await tx.request.findUnique({ where: { id } });
+ if (!request) throw new NotFoundException(`Request ${id} not found`);
+ if (request.employeeId !== actorId) {
+ throw new ForbiddenException('Only the requester may edit or resubmit');
+ }
+ if (
+ request.workflowState !== 'Draft' ||
+ request.updatedAt.toISOString() !==
+ new Date(dto.expectedUpdatedAt).toISOString()
+ ) {
+ throw new ConflictException(
+ 'Request changed or is no longer a draft; reload before editing',
+ );
+ }
+ const values = await this.validateValues(
+ request.type,
+ { ...dto, employeeId: actorId },
+ tx,
+ id,
+ );
+ const data: Prisma.RequestUncheckedUpdateInput = {
+ ...values,
+ // A saved draft is not assigned until it is actually submitted.
+ currentApproverId: resubmit ? values.currentApproverId : null,
+ workflowState: resubmit
+ ? nextState('Draft', 'submit', {
+ hasSubstitute: !!values.substituteId,
+ })
+ : 'Draft',
+ status: 'Submitted', // legacy display status; workflowState gates writes
+ approverId: null,
+ substituteAcceptedAt: null,
+ hrConfirmedAt: null,
+ cancelledAt: null,
+ decidedAt: null,
+ // Keep the revision reason visible until submission; history always keeps it.
+ decisionNote: resubmit ? null : request.decisionNote,
+ };
+ const saved = await this.updateVersion(tx, request, data);
+ if (resubmit) {
+ await tx.requestEvent.create({
+ data: { requestId: id, kind: 'Resubmitted', actorId },
+ });
+ }
+ return saved;
+ });
+ if (resubmit)
+ this.notifications.notifyTransitioned(updated, 'Resubmitted', actorId);
+ return toRequestDto(updated);
+ }
- async createVacation(dto: CreateVacationDto): Promise {
+ private async validateValues(
+ type: RequestType,
+ dto: CreateVacationDto,
+ tx: Prisma.TransactionClient,
+ excludeRequestId?: string,
+ ) {
const employee = await this.employees.getById(dto.employeeId);
const from = new Date(dto.from);
const to = new Date(dto.to);
- if (to.getTime() < from.getTime()) {
- throw new BadRequestException('"to" must be on/after "from"');
+ if (
+ !Number.isFinite(from.getTime()) ||
+ !Number.isFinite(to.getTime()) ||
+ to < from
+ ) {
+ throw new BadRequestException('"to" must be on/after a valid "from"');
}
- if (from.getUTCFullYear() !== to.getUTCFullYear()) {
+ const vacation = type === 'Vacation';
+ if (vacation && from.getUTCFullYear() !== to.getUTCFullYear()) {
throw new BadRequestException(
'Vacation must lie within a single calendar year — split into two requests',
);
}
- if (dto.substituteId && dto.substituteId === employee.id) {
- throw new BadRequestException('Substitute must be a different employee');
+ if (!vacation && (dto.substituteId || dto.halfDayStart || dto.halfDayEnd)) {
+ throw new BadRequestException(
+ 'Substitute and half-days are only supported for Vacation',
+ );
+ }
+ const substituteId = vacation ? (dto.substituteId ?? null) : null;
+ if (substituteId) {
+ const substitute = await tx.employee.findUnique({
+ where: { id: substituteId },
+ });
+ if (substituteId === employee.id || !substitute?.isActive) {
+ throw new BadRequestException(
+ 'Substitute must be a different active employee',
+ );
+ }
}
const schedule = await this.schedules.resolveForEmployee(employee.id);
- const halfDayStart = !!dto.halfDayStart;
- const halfDayEnd = !!dto.halfDayEnd;
- const calculatedDays = calculateVacationDays(from, to, {
+ const halfDayStart = vacation && !!dto.halfDayStart;
+ const halfDayEnd = vacation && !!dto.halfDayEnd;
+ const options = {
workingDays: schedule.workingDays,
holidayProvider: schedule.holidayProvider,
halfDayStart,
halfDayEnd,
- });
- if (calculatedDays <= 0) {
+ };
+ const calculatedDays = vacation
+ ? calculateVacationDays(from, to, options)
+ : calculateWorkingDays(from, to, options);
+ if (vacation && calculatedDays <= 0)
throw new BadRequestException('Vacation range covers no working days');
- }
- await this.assertNoActiveOverlap(employee.id, from, to);
-
- const balance = await this.vacationBalance.compute(
+ await this.assertNoActiveOverlap(
employee.id,
- from.getUTCFullYear(),
+ from,
+ to,
+ tx,
+ excludeRequestId,
);
- if (balance.remainingDays < calculatedDays) {
- throw new ConflictException(
- `Not enough vacation days remaining (${balance.remainingDays} < ${calculatedDays})`,
+ let requiresApproval = false;
+ if (type === 'TimeAdjustment') {
+ if (to <= from)
+ throw new BadRequestException(
+ 'TimeAdjustment "to" must be after "from"',
+ );
+ await this.assertNoTimeEntryOverlap(employee.id, from, to, tx);
+ requiresApproval = requiresSpecialApproval(from, to, schedule.frame);
+ }
+ if (vacation) {
+ const balance = await this.vacationBalance.compute(
+ employee.id,
+ from.getUTCFullYear(),
+ excludeRequestId,
+ tx,
);
+ if (balance.remainingDays < calculatedDays) {
+ throw new ConflictException(
+ `Not enough vacation days remaining (${balance.remainingDays} < ${calculatedDays})`,
+ );
+ }
}
+ return {
+ from,
+ to,
+ reason: dto.reason ?? null,
+ calculatedDays,
+ halfDayStart,
+ halfDayEnd,
+ substituteId,
+ requiresApproval,
+ currentApproverId: substituteId ? null : employee.managerId,
+ };
+ }
- const hasSubstitute = !!dto.substituteId;
- const initialState: WorkflowState = hasSubstitute
- ? 'PendingSubstitute'
- : 'PendingManager';
-
- const created = await this.prisma.$transaction(async (tx) => {
- const request = await tx.request.create({
- data: {
- employeeId: employee.id,
- type: 'Vacation',
- status: 'Submitted',
- workflowState: initialState,
- from,
- to,
- reason: dto.reason ?? null,
- calculatedDays,
- halfDayStart,
- halfDayEnd,
- substituteId: dto.substituteId ?? null,
- currentApproverId: hasSubstitute ? null : employee.managerId,
- },
- });
- await tx.requestEvent.create({
- data: {
- requestId: request.id,
- kind: 'Submitted',
- actorId: employee.id,
- },
- });
- return request;
+ /** Compare-and-swap protects transitions too, including Draft → pending → Draft ABA races. */
+ private async updateVersion(
+ tx: Prisma.TransactionClient,
+ request: Request,
+ data: Prisma.RequestUncheckedUpdateInput,
+ ): Promise {
+ const changed = await tx.request.updateMany({
+ where: {
+ id: request.id,
+ workflowState: request.workflowState,
+ updatedAt: request.updatedAt,
+ },
+ data: {
+ ...data,
+ updatedAt: new Date(
+ Math.max(Date.now(), request.updatedAt.getTime() + 1),
+ ),
+ },
+ });
+ if (changed.count !== 1)
+ throw new ConflictException('Request changed; reload before continuing');
+ return tx.request.findUniqueOrThrow({
+ where: { id: request.id },
+ include: employeeInclude,
});
- this.notifications.notifyTransitioned(created, 'Submitted', employee.id);
- return toRequestDto(created);
}
// ----- Generic approve / reject (legacy path for non-Vacation) -----
@@ -263,23 +362,21 @@ export class RequestsService {
);
}
await this.assertApprover(request, actorId);
- const alreadyApproved = request.workflowState === 'Approved';
+ if (request.workflowState !== 'PendingManager')
+ throw new ConflictException('Request is not pending manager approval');
const updated = await this.prisma.$transaction(async (tx) => {
- const updated = await tx.request.update({
- where: { id: request.id },
- data: {
- status: 'Approved',
- workflowState: 'Approved',
- approverId: actorId,
- currentApproverId: null,
- decidedAt: new Date(),
- decisionNote: note,
- },
+ const updated = await this.updateVersion(tx, request, {
+ status: 'Approved',
+ workflowState: 'Approved',
+ approverId: actorId,
+ currentApproverId: null,
+ decidedAt: new Date(),
+ decisionNote: note,
});
await tx.requestEvent.create({
data: { requestId: id, kind: 'ManagerApproved', actorId, note },
});
- if (request.type === 'TimeAdjustment' && !alreadyApproved) {
+ if (request.type === 'TimeAdjustment') {
await this.materializeTimeAdjustment(tx, request);
}
return updated;
@@ -299,17 +396,16 @@ export class RequestsService {
return this.transitionVacation(request, 'manager_reject', actorId, note);
}
await this.assertApprover(request, actorId);
+ if (request.workflowState !== 'PendingManager')
+ throw new ConflictException('Request is not pending manager approval');
const updated = await this.prisma.$transaction(async (tx) => {
- const updated = await tx.request.update({
- where: { id: request.id },
- data: {
- status: 'Rejected',
- workflowState: 'Rejected',
- approverId: actorId,
- currentApproverId: null,
- decidedAt: new Date(),
- decisionNote: note,
- },
+ const updated = await this.updateVersion(tx, request, {
+ status: 'Rejected',
+ workflowState: 'Rejected',
+ approverId: actorId,
+ currentApproverId: null,
+ decidedAt: new Date(),
+ decisionNote: note,
});
await tx.requestEvent.create({
data: { requestId: id, kind: 'ManagerRejected', actorId, note },
@@ -578,7 +674,7 @@ export class RequestsService {
});
const status = deriveStatus(target);
- const data: Prisma.RequestUpdateInput = {
+ const data: Prisma.RequestUncheckedUpdateInput = {
workflowState: target,
status,
};
@@ -588,48 +684,47 @@ export class RequestsService {
// Side effects per transition.
if (event === 'submit') {
- // No-op here — submit is done at create time.
+ // Creation and resubmission have their own validated paths.
}
if (event === 'substitute_accept') {
data.substituteAcceptedAt = new Date();
// Move approver to manager.
const employee = await this.employees.getById(employeeId);
- data.currentApprover = employee.managerId
- ? { connect: { id: employee.managerId } }
- : { disconnect: true };
+ data.currentApproverId = employee.managerId;
}
if (event === 'substitute_decline') {
- data.currentApprover = { disconnect: true };
+ data.decisionNote = note;
+ data.currentApproverId = null;
}
if (event === 'manager_approve' || event === 'manager_approve_with_hr') {
- data.approver = { connect: { id: actorId } };
+ data.approverId = actorId;
data.decidedAt = event === 'manager_approve' ? new Date() : null;
data.decisionNote = event === 'manager_approve' ? note : null;
if (event === 'manager_approve_with_hr') {
- data.currentApprover = { disconnect: true };
+ data.currentApproverId = null;
} else {
- data.currentApprover = { disconnect: true };
+ data.currentApproverId = null;
}
}
if (event === 'manager_reject' || event === 'manager_return') {
- data.approver = { connect: { id: actorId } };
+ data.approverId = actorId;
data.decidedAt = event === 'manager_reject' ? new Date() : null;
data.decisionNote = note;
- data.currentApprover = { disconnect: true };
+ data.currentApproverId = null;
}
if (event === 'hr_confirm' || event === 'hr_reject') {
data.hrConfirmedAt = event === 'hr_confirm' ? new Date() : null;
data.decidedAt = new Date();
data.decisionNote = note;
- data.currentApprover = { disconnect: true };
+ data.currentApproverId = null;
}
if (event === 'cancel') {
data.cancelledAt = new Date();
- data.currentApprover = { disconnect: true };
+ data.currentApproverId = null;
}
const updated = await this.prisma.$transaction(async (tx) => {
- const u = await tx.request.update({ where: { id: request.id }, data });
+ const u = await this.updateVersion(tx, request, data);
await tx.requestEvent.create({
data: { requestId: request.id, kind: eventKind, actorId, note },
});
@@ -672,8 +767,11 @@ export class RequestsService {
});
}
- private async assertRequest(id: string): Promise {
- const request = await this.prisma.request.findUnique({ where: { id } });
+ private async assertRequest(id: string): Promise {
+ const request = await this.prisma.request.findUnique({
+ where: { id },
+ include: employeeInclude,
+ });
if (!request) throw new NotFoundException(`Request ${id} not found`);
return request;
}
@@ -737,11 +835,14 @@ export class RequestsService {
employeeId: string,
from: Date,
to: Date,
+ db: Prisma.TransactionClient = this.prisma,
+ excludeRequestId?: string,
): Promise {
- const overlap = await this.prisma.request.findFirst({
+ const overlap = await db.request.findFirst({
where: {
employeeId,
status: { notIn: ['Rejected', 'Cancelled'] },
+ ...(excludeRequestId ? { id: { not: excludeRequestId } } : {}),
from: { lte: to },
to: { gte: from },
},
@@ -762,8 +863,9 @@ export class RequestsService {
employeeId: string,
from: Date,
to: Date,
+ db: Prisma.TransactionClient = this.prisma,
): Promise {
- const overlap = await this.prisma.timeEntry.findFirst({
+ const overlap = await db.timeEntry.findFirst({
where: {
employeeId,
status: { not: 'Rejected' },
diff --git a/apps/api/src/app/terminals/terminal-booking.service.ts b/apps/api/src/app/terminals/terminal-booking.service.ts
index f91e7b7..a67f7b7 100644
--- a/apps/api/src/app/terminals/terminal-booking.service.ts
+++ b/apps/api/src/app/terminals/terminal-booking.service.ts
@@ -105,6 +105,8 @@ export async function bookTerminalTime(
revision: { increment: 1 },
status: requires ? 'Pending' : 'Approved',
requiresApproval: requires,
+ frameApproverId: null,
+ frameApprovedRevision: null,
clockOutLatitude: position?.latitude ?? null,
clockOutLongitude: position?.longitude ?? null,
clockOutAccuracyMeters: position?.accuracyMeters ?? null,
diff --git a/apps/api/src/app/time-entries/time-entries.controller.ts b/apps/api/src/app/time-entries/time-entries.controller.ts
index 3965574..11cb3bb 100644
--- a/apps/api/src/app/time-entries/time-entries.controller.ts
+++ b/apps/api/src/app/time-entries/time-entries.controller.ts
@@ -14,10 +14,17 @@ import {
ApiOkResponse,
ApiCreatedResponse,
ApiTags,
+ ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { JwtUser } from '../auth/jwt.strategy';
+import { TimeEntryApprovalsService } from './time-entry-approvals.service';
+import {
+ DecideTimeEntryApprovalDto,
+ TimeEntryApprovalDto,
+ TimeEntryApprovalEventDto,
+} from './time-entry-approvals.dto';
import { TimeEntriesService } from './time-entries.service';
import {
BookProjectRangeDto,
@@ -40,7 +47,10 @@ import {
@ApiTags('time-entries')
@Controller('timeentries')
export class TimeEntriesController {
- constructor(private readonly entries: TimeEntriesService) {}
+ constructor(
+ private readonly entries: TimeEntriesService,
+ private readonly approvals: TimeEntryApprovalsService,
+ ) {}
@Get()
@ApiOkResponse({ type: TimeEntryDto, isArray: true })
@@ -60,6 +70,41 @@ export class TimeEntriesController {
);
}
+ @Get('approvals')
+ @ApiQuery({ name: 'scope', required: false, enum: ['own', 'review'] })
+ @ApiOkResponse({ type: TimeEntryApprovalDto, isArray: true })
+ @ApiBearerAuth()
+ @UseGuards(JwtAuthGuard)
+ approvalList(
+ @CurrentUser() user: JwtUser,
+ @Query('scope') scope?: string,
+ ): Promise {
+ return this.approvals.list(user.id, scope);
+ }
+
+ @Get(':id/approval-history')
+ @ApiOkResponse({ type: TimeEntryApprovalEventDto, isArray: true })
+ @ApiBearerAuth()
+ @UseGuards(JwtAuthGuard)
+ approvalHistory(
+ @Param('id', new ParseUUIDPipe()) id: string,
+ @CurrentUser() user: JwtUser,
+ ): Promise {
+ return this.approvals.history(id, user);
+ }
+
+ @Post(':id/approval')
+ @ApiCreatedResponse({ type: TimeEntryApprovalDto })
+ @ApiBearerAuth()
+ @UseGuards(JwtAuthGuard)
+ decideApproval(
+ @Param('id', new ParseUUIDPipe()) id: string,
+ @Body() dto: DecideTimeEntryApprovalDto,
+ @CurrentUser() user: JwtUser,
+ ): Promise {
+ return this.approvals.decide(id, dto, user);
+ }
+
@Post('clock-in')
@ApiCreatedResponse({ type: TimeEntryDto })
@ApiBearerAuth()
diff --git a/apps/api/src/app/time-entries/time-entries.module.ts b/apps/api/src/app/time-entries/time-entries.module.ts
index 903233e..18f4328 100644
--- a/apps/api/src/app/time-entries/time-entries.module.ts
+++ b/apps/api/src/app/time-entries/time-entries.module.ts
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { TimeEntriesController } from './time-entries.controller';
+import { TimeEntryApprovalsService } from './time-entry-approvals.service';
import { TimeEntriesService } from './time-entries.service';
@Module({
imports: [AuthModule],
controllers: [TimeEntriesController],
- providers: [TimeEntriesService],
+ providers: [TimeEntriesService, TimeEntryApprovalsService],
exports: [TimeEntriesService],
})
export class TimeEntriesModule {}
diff --git a/apps/api/src/app/time-entries/time-entries.service.ts b/apps/api/src/app/time-entries/time-entries.service.ts
index 27a201f..5e5cbf2 100644
--- a/apps/api/src/app/time-entries/time-entries.service.ts
+++ b/apps/api/src/app/time-entries/time-entries.service.ts
@@ -681,6 +681,8 @@ export class TimeEntriesService {
clockOut: bookingNow,
status: requires ? 'Pending' : 'Approved',
requiresApproval: requires,
+ frameApproverId: null,
+ frameApprovedRevision: null,
revision: { increment: 1 },
clockOutLatitude:
policy.isSolo && !policy.gpsEnabled
@@ -808,9 +810,32 @@ export class TimeEntriesService {
const before = await this.auditSnapshot(tx, current);
const row = await tx.timeEntry.update({
where: { id },
- data: { ...data, revision: { increment: 1 } },
+ data: {
+ ...data,
+ revision: { increment: 1 },
+ ...(current.approvalMode !== 'Solo' && current.requiresApproval
+ ? {
+ frameApproverId: null,
+ frameApprovedRevision: null,
+ ...(!current.voidedAt &&
+ current.clockOut &&
+ current.status !== 'Rejected'
+ ? { status: 'Pending' as const }
+ : {}),
+ }
+ : {}),
+ },
include: ENTRY_INCLUDE,
});
+ if (current.approvalMode !== 'Solo' && current.requiresApproval)
+ await this.writeAudit(
+ tx,
+ row,
+ user.id,
+ 'FrameApprovalInvalidated',
+ before,
+ null,
+ );
if (isSolo || current.approvalMode === 'Solo')
await this.writeAudit(tx, row, user.id, 'BookingUpdated', before, null);
return row;
@@ -911,6 +936,8 @@ export class TimeEntriesService {
captureGroupId,
revision: { increment: 1 },
requiresApproval: firstRequires,
+ frameApproverId: null,
+ frameApprovedRevision: null,
status: statusFor(firstRequires),
clockOutLatitude: null,
clockOutLongitude: null,
@@ -956,6 +983,24 @@ export class TimeEntriesService {
where: { timeEntryId: entry.id, action: 'clock-out' },
data: { timeEntryId: secondSegment.id },
});
+ if (entry.approvalMode !== 'Solo' && entry.requiresApproval) {
+ await this.writeAudit(
+ tx,
+ firstSegment,
+ user.id,
+ 'FrameApprovalInvalidated',
+ before,
+ null,
+ );
+ await this.writeAudit(
+ tx,
+ secondSegment,
+ user.id,
+ 'FrameSegmentCreated',
+ null,
+ null,
+ );
+ }
if (isSolo || entry.approvalMode === 'Solo') {
await this.writeAudit(
tx,
@@ -1097,6 +1142,8 @@ export class TimeEntriesService {
clockOut,
captureGroupId,
requiresApproval,
+ frameApproverId: null,
+ frameApprovedRevision: null,
status: requiresApproval
? ('Pending' as const)
: ('Approved' as const),
@@ -1132,6 +1179,19 @@ export class TimeEntriesService {
where: { timeEntryId: entry.id, action: 'clock-out' },
data: { timeEntryId: last.id },
});
+ if (entry.approvalMode !== 'Solo' && entry.requiresApproval) {
+ for (const segment of segments)
+ await this.writeAudit(
+ tx,
+ segment,
+ user.id,
+ segment.id === entry.id
+ ? 'FrameApprovalInvalidated'
+ : 'FrameSegmentCreated',
+ segment.id === entry.id ? before : null,
+ null,
+ );
+ }
if (isSolo || entry.approvalMode === 'Solo') {
for (const segment of segments)
await this.writeAudit(
@@ -1725,6 +1785,8 @@ export class TimeEntriesService {
status: entry.status,
requiresApproval: entry.requiresApproval,
approvalMode: entry.approvalMode,
+ frameApproverId: entry.frameApproverId,
+ frameApprovedRevision: entry.frameApprovedRevision,
captureGroupId: entry.captureGroupId,
breakRules: parseBreakRules(entry.breakRules),
voidedAt: entry.voidedAt?.toISOString() ?? null,
diff --git a/apps/api/src/app/time-entries/time-entry-approvals.dto.ts b/apps/api/src/app/time-entries/time-entry-approvals.dto.ts
new file mode 100644
index 0000000..2decd4c
--- /dev/null
+++ b/apps/api/src/app/time-entries/time-entry-approvals.dto.ts
@@ -0,0 +1,71 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import {
+ IsIn,
+ IsInt,
+ IsOptional,
+ IsString,
+ MaxLength,
+ Min,
+} from 'class-validator';
+import { TimeEntryDto } from './time-entries.dto';
+
+export const ENTRY_APPROVAL_STATES = [
+ 'Open',
+ 'BlockedManager',
+ 'PendingManager',
+ 'PendingHr',
+ 'Approved',
+ 'Rejected',
+ 'Voided',
+] as const;
+export type EntryApprovalState = (typeof ENTRY_APPROVAL_STATES)[number];
+
+export class TimeEntryApprovalDto {
+ @ApiProperty({ type: TimeEntryDto })
+ entry!: TimeEntryDto;
+ @ApiProperty({ minimum: 0 })
+ revision!: number;
+ @ApiProperty()
+ employeeName!: string;
+ @ApiProperty({ type: String, nullable: true, format: 'uuid' })
+ managerId!: string | null;
+ @ApiProperty({ type: String, nullable: true })
+ managerName!: string | null;
+ @ApiProperty({ enum: ENTRY_APPROVAL_STATES })
+ state!: EntryApprovalState;
+ @ApiProperty()
+ canDecide!: boolean;
+}
+
+export class DecideTimeEntryApprovalDto {
+ @ApiProperty({ minimum: 0 })
+ @IsInt()
+ @Min(0)
+ revision!: number;
+ @ApiProperty({ enum: ['Manager', 'HR'] })
+ @IsIn(['Manager', 'HR'])
+ stage!: 'Manager' | 'HR';
+ @ApiProperty({ enum: ['Approve', 'Reject'] })
+ @IsIn(['Approve', 'Reject'])
+ outcome!: 'Approve' | 'Reject';
+ @ApiPropertyOptional({ maxLength: 2000 })
+ @IsOptional()
+ @IsString()
+ @MaxLength(2000)
+ note?: string;
+}
+
+export class TimeEntryApprovalEventDto {
+ @ApiProperty()
+ id!: string;
+ @ApiProperty({ type: String, nullable: true, format: 'uuid' })
+ actorId!: string | null;
+ @ApiProperty({ type: String, nullable: true })
+ actorName!: string | null;
+ @ApiProperty()
+ action!: string;
+ @ApiProperty({ type: String, nullable: true })
+ reason!: string | null;
+ @ApiProperty({ format: 'date-time' })
+ occurredAt!: string;
+}
diff --git a/apps/api/src/app/time-entries/time-entry-approvals.service.ts b/apps/api/src/app/time-entries/time-entry-approvals.service.ts
new file mode 100644
index 0000000..a0ab33c
--- /dev/null
+++ b/apps/api/src/app/time-entries/time-entry-approvals.service.ts
@@ -0,0 +1,347 @@
+import {
+ BadRequestException,
+ ConflictException,
+ ForbiddenException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { Prisma, type Employee } from '@prisma/client';
+import type { JwtUser } from '../auth/jwt.strategy';
+import { PrismaService } from '../prisma/prisma.service';
+import { EventsGateway } from '../events/events.gateway';
+import { toTimeEntryDto } from './time-entries.dto';
+import {
+ type DecideTimeEntryApprovalDto,
+ type EntryApprovalState,
+ type TimeEntryApprovalDto,
+ type TimeEntryApprovalEventDto,
+} from './time-entry-approvals.dto';
+
+const APPROVAL_INCLUDE = {
+ project: { select: { code: true, name: true } },
+ serviceOrder: { select: { orderNo: true, title: true } },
+ employee: {
+ select: {
+ id: true,
+ firstName: true,
+ lastName: true,
+ isActive: true,
+ managerId: true,
+ manager: {
+ select: {
+ id: true,
+ firstName: true,
+ lastName: true,
+ role: true,
+ isActive: true,
+ },
+ },
+ },
+ },
+} as const;
+type ReviewEntry = Prisma.TimeEntryGetPayload<{
+ include: typeof APPROVAL_INCLUDE;
+}>;
+
+function validManager(row: ReviewEntry): boolean {
+ const manager = row.employee.manager;
+ return (
+ !!manager?.isActive &&
+ manager.role === 'Manager' &&
+ manager.id !== row.employeeId
+ );
+}
+
+export function frameApprovalState(row: ReviewEntry): EntryApprovalState {
+ if (row.voidedAt) return 'Voided';
+ if (!row.clockOut || row.status === 'Open') return 'Open';
+ if (row.status === 'Approved') return 'Approved';
+ if (row.status === 'Rejected') return 'Rejected';
+ if (!validManager(row)) return 'BlockedManager';
+ return row.frameApprovedRevision === row.revision &&
+ row.frameApproverId === row.employee.managerId
+ ? 'PendingHr'
+ : 'PendingManager';
+}
+
+@Injectable()
+export class TimeEntryApprovalsService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly events: EventsGateway,
+ ) {}
+
+ private async assertTeam(tx: Prisma.TransactionClient): Promise {
+ const installation = await tx.installationSettings.findUnique({
+ where: { id: 1 },
+ });
+ if (installation?.mode === 'Solo')
+ throw new ForbiddenException(
+ 'Team time-entry approvals are disabled in Solo mode',
+ );
+ }
+
+ private async actor(
+ tx: Prisma.TransactionClient,
+ id: string,
+ ): Promise {
+ const actor = await tx.employee.findUnique({ where: { id } });
+ if (!actor?.isActive)
+ throw new ForbiddenException('Active employee required');
+ return actor;
+ }
+
+ private assertReviewAccess(
+ row: ReviewEntry,
+ actor: Employee,
+ allowOwner: boolean,
+ ): void {
+ if (allowOwner && actor.id === row.employeeId) return;
+ if (actor.id === row.employeeId)
+ throw new ForbiddenException('You cannot review your own time entry');
+ if (actor.role === 'HRAdmin') return;
+ if (actor.role === 'Manager' && row.employee.managerId === actor.id) return;
+ throw new ForbiddenException(
+ 'Only the assigned manager or HR may review this time entry',
+ );
+ }
+
+ private assertApplicable(row: ReviewEntry): void {
+ if (row.approvalMode === 'Solo' || !row.requiresApproval)
+ throw new ConflictException(
+ 'This time entry does not require Team frame approval',
+ );
+ }
+
+ private dto(row: ReviewEntry, actor: Employee): TimeEntryApprovalDto {
+ const state = frameApprovalState(row);
+ return {
+ entry: toTimeEntryDto(row),
+ revision: row.revision,
+ employeeName: `${row.employee.firstName} ${row.employee.lastName}`,
+ managerId: row.employee.managerId,
+ managerName: row.employee.manager
+ ? `${row.employee.manager.firstName} ${row.employee.manager.lastName}`
+ : null,
+ state,
+ canDecide:
+ row.employee.isActive &&
+ actor.id !== row.employeeId &&
+ ((state === 'PendingManager' &&
+ actor.role === 'Manager' &&
+ row.employee.managerId === actor.id) ||
+ (state === 'PendingHr' &&
+ actor.role === 'HRAdmin' &&
+ row.frameApproverId !== actor.id)),
+ };
+ }
+
+ async list(
+ actorId: string,
+ scope = 'review',
+ ): Promise {
+ if (scope !== 'review' && scope !== 'own')
+ throw new BadRequestException('Invalid approval scope');
+ return this.prisma.$transaction(async (tx) => {
+ await this.assertTeam(tx);
+ const actor = await this.actor(tx, actorId);
+ if (
+ scope === 'review' &&
+ actor.role !== 'Manager' &&
+ actor.role !== 'HRAdmin'
+ )
+ throw new ForbiddenException('Manager or HR access required');
+ const rows = await tx.timeEntry.findMany({
+ where: {
+ AND: [
+ { OR: [{ approvalMode: null }, { approvalMode: { not: 'Solo' } }] },
+ {
+ OR: [
+ { requiresApproval: true },
+ { auditEvents: { some: { action: { startsWith: 'Frame' } } } },
+ ],
+ },
+ ],
+ ...(scope === 'own'
+ ? { employeeId: actor.id }
+ : {
+ employeeId: { not: actor.id },
+ employee: {
+ isActive: true,
+ ...(actor.role === 'Manager' ? { managerId: actor.id } : {}),
+ },
+ }),
+ },
+ include: APPROVAL_INCLUDE,
+ orderBy: [{ clockIn: 'desc' }, { id: 'asc' }],
+ });
+ return rows.map((row) => this.dto(row, actor));
+ });
+ }
+
+ async history(
+ id: string,
+ user: JwtUser,
+ ): Promise {
+ return this.prisma.$transaction(async (tx) => {
+ await this.assertTeam(tx);
+ const actor = await this.actor(tx, user.id);
+ const row = await tx.timeEntry.findUnique({
+ where: { id },
+ include: APPROVAL_INCLUDE,
+ });
+ if (!row) throw new NotFoundException('Time entry not found');
+ this.assertReviewAccess(row, actor, true);
+ // A split may move the original ID wholly inside the frame. Its old
+ // decisions and invalidation remain reviewable, even though it no
+ // longer needs approval. Solo archives must remain inaccessible.
+ if (row.approvalMode === 'Solo')
+ throw new ForbiddenException(
+ 'Solo entries have no Team approval history',
+ );
+ const events = await tx.timeEntryAudit.findMany({
+ where: { timeEntryId: id, action: { startsWith: 'Frame' } },
+ include: { actor: { select: { firstName: true, lastName: true } } },
+ orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }],
+ });
+ return events.map((event) => ({
+ id: event.id,
+ actorId: event.actorId,
+ action: event.action,
+ reason: event.reason,
+ actorName: event.actor
+ ? `${event.actor.firstName} ${event.actor.lastName}`
+ : null,
+ occurredAt: event.occurredAt.toISOString(),
+ }));
+ });
+ }
+
+ async decide(
+ id: string,
+ dto: DecideTimeEntryApprovalDto,
+ user: JwtUser,
+ ): Promise {
+ const result = await this.prisma.$transaction(async (tx) => {
+ // Same serialization order as every booking/correction and installation
+ // mode change. Row locks also serialize concurrent role/manager changes.
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(7261500)`;
+ await this.assertTeam(tx);
+ const initial = await tx.timeEntry.findUnique({ where: { id } });
+ if (!initial) throw new NotFoundException('Time entry not found');
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${initial.employeeId}, 0))`;
+ await tx.$queryRaw`SELECT "id" FROM "Employee" WHERE "id" = ${initial.employeeId}::uuid FOR SHARE`;
+ const employee = await tx.employee.findUniqueOrThrow({
+ where: { id: initial.employeeId },
+ });
+ const ids = [
+ ...new Set(
+ [user.id, employee.id, employee.managerId].filter(
+ (value): value is string => !!value,
+ ),
+ ),
+ ].sort();
+ await tx.$queryRaw(
+ Prisma.sql`SELECT "id" FROM "Employee" WHERE "id" IN (${Prisma.join(ids.map((value) => Prisma.sql`${value}::uuid`))}) ORDER BY "id" FOR SHARE`,
+ );
+ const actor = await this.actor(tx, user.id);
+ const row = await tx.timeEntry.findUniqueOrThrow({
+ where: { id },
+ include: APPROVAL_INCLUDE,
+ });
+ this.assertReviewAccess(row, actor, false);
+ this.assertApplicable(row);
+ if (!row.employee.isActive)
+ throw new ForbiddenException('Employee account is inactive');
+ if (row.revision !== dto.revision)
+ throw new ConflictException(
+ 'Time entry changed; reload before deciding',
+ );
+ const view = this.dto(row, actor);
+ if (
+ (dto.stage === 'Manager' && actor.role !== 'Manager') ||
+ (dto.stage === 'HR' && actor.role !== 'HRAdmin')
+ )
+ throw new ForbiddenException('Wrong approval role');
+ if (
+ !view.canDecide ||
+ view.state !==
+ (dto.stage === 'Manager' ? 'PendingManager' : 'PendingHr')
+ )
+ throw new ConflictException(
+ 'Time entry is not awaiting this approval stage',
+ );
+ const note = dto.note?.trim() || null;
+ if (dto.outcome === 'Reject' && !note)
+ throw new BadRequestException('A rejection reason is required');
+ const approveManager =
+ dto.stage === 'Manager' && dto.outcome === 'Approve';
+ const data: Prisma.TimeEntryUncheckedUpdateInput = {
+ revision: { increment: 1 },
+ status:
+ dto.outcome === 'Reject'
+ ? 'Rejected'
+ : dto.stage === 'HR'
+ ? 'Approved'
+ : 'Pending',
+ ...(approveManager
+ ? {
+ frameApproverId: actor.id,
+ frameApprovedRevision: row.revision + 1,
+ }
+ : {}),
+ };
+ const changed = await tx.timeEntry.updateMany({
+ where: {
+ id,
+ revision: dto.revision,
+ status: 'Pending',
+ clockOut: { not: null },
+ voidedAt: null,
+ },
+ data,
+ });
+ if (changed.count !== 1)
+ throw new ConflictException(
+ 'Time entry changed; reload before deciding',
+ );
+ const updated = await tx.timeEntry.findUniqueOrThrow({
+ where: { id },
+ include: APPROVAL_INCLUDE,
+ });
+ const action =
+ dto.stage === 'Manager'
+ ? dto.outcome === 'Approve'
+ ? 'FrameManagerApproved'
+ : 'FrameManagerRejected'
+ : dto.outcome === 'Approve'
+ ? 'FrameHrConfirmed'
+ : 'FrameHrRejected';
+ await tx.timeEntryAudit.create({
+ data: {
+ timeEntryId: id,
+ actorId: actor.id,
+ action,
+ reason: note,
+ before: {
+ revision: row.revision,
+ state: view.state,
+ status: row.status,
+ },
+ after: {
+ revision: updated.revision,
+ state: frameApprovalState(updated),
+ status: updated.status,
+ },
+ },
+ });
+ return this.dto(updated, actor);
+ });
+ this.events.broadcast('time-entry:updated', {
+ id,
+ employeeId: result.entry.employeeId,
+ clockOut: result.entry.clockOut,
+ });
+ return result;
+ }
+}
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 037c1a3..9607e03 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -191,6 +191,41 @@ export interface TimeEntryDto {
summary: TimeSummaryDto | null;
}
+export type TimeEntryApprovalState =
+ | 'Open'
+ | 'BlockedManager'
+ | 'PendingManager'
+ | 'PendingHr'
+ | 'Approved'
+ | 'Rejected'
+ | 'Voided';
+
+export interface TimeEntryApprovalDto {
+ entry: TimeEntryDto;
+ revision: number;
+ employeeName: string;
+ managerId: string | null;
+ managerName: string | null;
+ state: TimeEntryApprovalState;
+ canDecide: boolean;
+}
+
+export interface TimeEntryApprovalDecision {
+ revision: number;
+ stage: 'Manager' | 'HR';
+ outcome: 'Approve' | 'Reject';
+ note?: string;
+}
+
+export interface TimeEntryApprovalEventDto {
+ id: string;
+ actorId: string | null;
+ actorName: string | null;
+ action: string;
+ reason: string | null;
+ occurredAt: string;
+}
+
export interface SplitTimeEntryResult {
first: TimeEntryDto;
second: TimeEntryDto;
@@ -367,6 +402,8 @@ export interface AccountDto {
export interface RequestDto {
id: string;
employeeId: string;
+ employeeName: string;
+ updatedAt: string;
type: RequestType;
status: RequestStatus;
workflowState: WorkflowState;
@@ -546,6 +583,13 @@ export interface CreateVacationPayload {
halfDayEnd?: boolean;
}
+export interface UpdateDraftRequestPayload extends Omit<
+ CreateVacationPayload,
+ 'employeeId'
+> {
+ expectedUpdatedAt: string;
+}
+
export interface UpsertLeaveAllowancePayload {
baseDays: number;
carryOverDays: number;
@@ -913,6 +957,19 @@ export const api = {
if (to) params.set('to', to);
return request(`/api/timeentries?${params.toString()}`);
},
+ timeEntryApprovals: (scope: 'own' | 'review' = 'review') =>
+ request(
+ `/api/timeentries/approvals?scope=${scope}`,
+ ),
+ decideTimeEntryApproval: (id: string, payload: TimeEntryApprovalDecision) =>
+ request(`/api/timeentries/${id}/approval`, {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ }),
+ timeEntryApprovalHistory: (id: string) =>
+ request(
+ `/api/timeentries/${id}/approval-history`,
+ ),
clockIn: (payload: ClockInPayload) =>
request('/api/timeentries/clock-in', {
method: 'POST',
@@ -1055,6 +1112,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..ae14830 100644
--- a/apps/web/src/api/generated.ts
+++ b/apps/web/src/api/generated.ts
@@ -676,6 +676,54 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/api/timeentries/approvals': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations['TimeEntriesController_approvalList'];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/timeentries/{id}/approval-history': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations['TimeEntriesController_approvalHistory'];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/timeentries/{id}/approval': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: operations['TimeEntriesController_decideApproval'];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
'/api/timeentries/clock-in': {
parameters: {
query?: never;
@@ -1281,7 +1329,7 @@ export interface paths {
delete?: never;
options?: never;
head?: never;
- patch?: never;
+ patch: operations['RequestsController_updateDraft'];
trace?: never;
};
'/api/requests/{id}/events': {
@@ -1316,6 +1364,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 +2396,42 @@ export interface components {
activity: string | null;
summary: components['schemas']['TimeSummaryDto'] | null;
};
+ TimeEntryApprovalDto: {
+ entry: components['schemas']['TimeEntryDto'];
+ revision: number;
+ employeeName: string;
+ /** Format: uuid */
+ managerId: string | null;
+ managerName: string | null;
+ /** @enum {string} */
+ state:
+ | 'Open'
+ | 'BlockedManager'
+ | 'PendingManager'
+ | 'PendingHr'
+ | 'Approved'
+ | 'Rejected'
+ | 'Voided';
+ canDecide: boolean;
+ };
+ TimeEntryApprovalEventDto: {
+ id: string;
+ /** Format: uuid */
+ actorId: string | null;
+ actorName: string | null;
+ action: string;
+ reason: string | null;
+ /** Format: date-time */
+ occurredAt: string;
+ };
+ DecideTimeEntryApprovalDto: {
+ revision: number;
+ /** @enum {string} */
+ stage: 'Manager' | 'HR';
+ /** @enum {string} */
+ outcome: 'Approve' | 'Reject';
+ note?: string;
+ };
ClockInDto: {
note?: string | null;
billable?: boolean;
@@ -2813,6 +2913,45 @@ export interface components {
adjustmentDays: number;
adjustmentReason?: Record | null;
};
+ RequestDto: {
+ /** Format: uuid */
+ id: string;
+ /** Format: uuid */
+ employeeId: string;
+ employeeName: string;
+ /** Format: date-time */
+ updatedAt: string;
+ /** @enum {string} */
+ type: 'Vacation' | 'HomeOffice' | 'SpecialLeave' | 'TimeAdjustment';
+ status: string;
+ workflowState: string;
+ /** Format: date-time */
+ from: string;
+ /** Format: date-time */
+ to: string;
+ reason: string | null;
+ requiresApproval: boolean;
+ /** Format: uuid */
+ approverId: string | null;
+ /** Format: uuid */
+ currentApproverId: string | null;
+ /** Format: uuid */
+ substituteId: string | null;
+ /** Format: date-time */
+ substituteAcceptedAt: string | null;
+ /** Format: date-time */
+ hrConfirmedAt: string | null;
+ /** Format: date-time */
+ cancelledAt: string | null;
+ calculatedDays: number;
+ halfDayStart: boolean;
+ halfDayEnd: boolean;
+ /** Format: date-time */
+ decidedAt: string | null;
+ decisionNote: string | null;
+ /** Format: date-time */
+ createdAt: string;
+ };
CreateRequestDto: {
/** Format: uuid */
employeeId: string;
@@ -2828,7 +2967,7 @@ export interface components {
* @example 2026-08-07T00:00:00.000Z
*/
to: string;
- reason?: Record | null;
+ reason?: string | null;
};
CreateVacationDto: {
/** Format: uuid */
@@ -2838,8 +2977,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 +2990,39 @@ export interface components {
*/
halfDayEnd: boolean;
};
+ UpdateDraftRequestDto: {
+ /** Format: date-time */
+ from: string;
+ /** Format: date-time */
+ to: string;
+ /** Format: uuid */
+ substituteId?: string | null;
+ reason?: string | null;
+ /**
+ * @description Take the first day as a half-day
+ * @default false
+ */
+ halfDayStart: boolean;
+ /**
+ * @description Take the last day as a half-day
+ * @default false
+ */
+ halfDayEnd: boolean;
+ /**
+ * Format: date-time
+ * @description updatedAt from the version being edited; stale writes return 409
+ */
+ expectedUpdatedAt: string;
+ };
TransitionDto: {
/** Format: uuid */
actorId: string;
- note?: Record | null;
+ note?: string | null;
};
ManagerApproveDto: {
/** Format: uuid */
actorId: string;
- note?: Record | null;
+ note?: string | null;
/** @default false */
requiresHrConfirmation: boolean;
};
@@ -2872,7 +3035,7 @@ export interface components {
/** Format: uuid */
actorId: string;
ids: string[];
- note?: Record | null;
+ note?: string | null;
/** @default false */
requiresHrConfirmation: boolean;
};
@@ -4190,6 +4353,73 @@ export interface operations {
};
};
};
+ TimeEntriesController_approvalList: {
+ parameters: {
+ query?: {
+ scope?: 'own' | 'review';
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['TimeEntryApprovalDto'][];
+ };
+ };
+ };
+ };
+ TimeEntriesController_approvalHistory: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['TimeEntryApprovalEventDto'][];
+ };
+ };
+ };
+ };
+ TimeEntriesController_decideApproval: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['DecideTimeEntryApprovalDto'];
+ };
+ };
+ responses: {
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['TimeEntryApprovalDto'];
+ };
+ };
+ };
+ };
TimeEntriesController_clockIn: {
parameters: {
query?: never;
@@ -5059,7 +5289,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'][];
+ };
};
};
};
@@ -5080,7 +5312,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5099,7 +5333,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 +5400,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 +5450,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5185,7 +5475,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5208,7 +5500,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5231,7 +5525,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5254,7 +5550,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5277,7 +5575,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5300,7 +5600,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5323,7 +5625,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5346,7 +5650,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5369,7 +5675,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..76a2b8a 100644
--- a/apps/web/src/app/i18n.tsx
+++ b/apps/web/src/app/i18n.tsx
@@ -16,6 +16,69 @@ type Catalog = Record;
const STORAGE_KEY = 'openclockwork.locale';
const de: Catalog = {
+ 'frameApproval.ownTitle': 'Rahmenzeit-Genehmigungen für meine Zeitbuchungen',
+ 'frameApproval.title': 'Zeitbuchungen',
+ 'frameApproval.chain':
+ 'Zeitbuchungen außerhalb der Rahmenzeit benötigen zuerst die Genehmigung der zugewiesenen Führungskraft und danach die Bestätigung durch HR. Beide bearbeiten sie unter Genehmigungen → Zeitbuchungen.',
+ 'frameApproval.managerChanged':
+ 'Solange HR noch nicht bestätigt hat, muss nach einer Änderung der zugewiesenen Führungskraft die neue Führungskraft erneut genehmigen.',
+ 'frameApproval.refresh': 'Genehmigungen aktualisieren',
+ 'frameApproval.empty':
+ 'Keine genehmigungspflichtigen Zeitbuchungen vorhanden.',
+ 'frameApproval.loadFailed':
+ 'Zeitbuchungs-Genehmigungen konnten nicht geladen werden.',
+ 'frameApproval.historyFailed':
+ 'Der Genehmigungsverlauf konnte nicht geladen werden.',
+ 'frameApproval.decisionFailed': 'Die Entscheidung wurde nicht gespeichert.',
+ 'frameApproval.refreshHint':
+ 'Bitte aktualisieren und den aktuellen Stand prüfen, bevor du erneut entscheidest.',
+ 'frameApproval.notAllowed': 'Diese Entscheidung ist nicht zulässig.',
+ 'frameApproval.note': 'Notiz (bei Ablehnung erforderlich)',
+ 'frameApproval.reasonRequired':
+ 'Für eine Ablehnung ist eine Begründung erforderlich.',
+ 'frameApproval.assignedManager': 'zugewiesene Führungskraft',
+ 'frameApproval.history': 'Genehmigungsverlauf',
+ 'frameApproval.noHistory':
+ 'Noch keine Entscheidungen oder Änderungen vorhanden.',
+ 'frameApproval.system': 'System',
+ 'frameApproval.managerResult':
+ 'Führungskraft-Genehmigung gespeichert. Als Nächstes bestätigt HR unter Genehmigungen → Zeitbuchungen.',
+ 'frameApproval.hrResult':
+ 'HR-Bestätigung gespeichert. Die Zeitbuchung ist genehmigt.',
+ 'frameApproval.rejectedResult': 'Ablehnung mit Begründung gespeichert.',
+ 'frameApproval.approve.Manager': 'Als Führungskraft genehmigen',
+ 'frameApproval.approve.HR': 'Als HR bestätigen',
+ 'frameApproval.reject.Manager': 'Als Führungskraft ablehnen',
+ 'frameApproval.reject.HR': 'Als HR ablehnen',
+ 'frameApproval.state.Open': 'Offen – zuerst ausstempeln',
+ 'frameApproval.state.BlockedManager': 'Blockiert – Führungskraft fehlt',
+ 'frameApproval.state.PendingManager': 'Wartet auf Führungskraft',
+ 'frameApproval.state.PendingHr': 'Wartet auf HR',
+ 'frameApproval.state.Approved': 'Genehmigt',
+ 'frameApproval.state.Rejected': 'Abgelehnt',
+ 'frameApproval.state.Voided': 'Ungültig',
+ 'frameApproval.help.Open':
+ 'Bitte zuerst ausstempeln. Erst die abgeschlossene Zeitbuchung kann von der zugewiesenen Führungskraft und danach von HR unter Genehmigungen → Zeitbuchungen geprüft werden.',
+ 'frameApproval.help.BlockedManager':
+ 'Bitte HR bitten, eine aktive Führungskraft zuzuweisen. Danach genehmigt diese Führungskraft zuerst, anschließend bestätigt HR unter Genehmigungen → Zeitbuchungen. Es gibt keine direkte HR-Freigabe ohne Führungskraft-Genehmigung.',
+ 'frameApproval.help.PendingManager':
+ 'Nächster Schritt: {manager} prüft als zugewiesene Führungskraft unter Genehmigungen → Zeitbuchungen. Danach bestätigt HR am selben Ort.',
+ 'frameApproval.help.PendingHr':
+ 'Die Führungskraft hat genehmigt. Nächster Schritt: HR bestätigt unter Genehmigungen → Zeitbuchungen.',
+ 'frameApproval.help.Approved':
+ 'Die Zeitbuchung ist genehmigt. Dokumentierte Entscheidungen stehen mit Person und Zeitpunkt im Genehmigungsverlauf.',
+ 'frameApproval.help.Rejected':
+ 'Die Zeitbuchung wurde abgelehnt. Begründung und entscheidende Person stehen im Genehmigungsverlauf.',
+ 'frameApproval.help.Voided':
+ 'Diese Zeitbuchung ist nicht mehr gültig und kann nicht genehmigt werden. Der Verlauf bleibt einsehbar.',
+ 'frameApproval.event.FrameSegmentCreated':
+ 'Buchungssegment nach Änderung neu angelegt',
+ 'frameApproval.event.FrameManagerApproved': 'Von Führungskraft genehmigt',
+ 'frameApproval.event.FrameManagerRejected': 'Von Führungskraft abgelehnt',
+ 'frameApproval.event.FrameHrConfirmed': 'Von HR bestätigt',
+ 'frameApproval.event.FrameHrRejected': 'Von HR abgelehnt',
+ 'frameApproval.event.FrameApprovalInvalidated':
+ 'Bisherige Genehmigung durch Änderung aufgehoben',
...soloDe,
...terminalPinDe,
'projects.deleteFailed': 'Projekt konnte nicht gelöscht werden',
@@ -347,6 +410,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 +769,64 @@ const de: Catalog = {
};
const en: Catalog = {
+ 'frameApproval.ownTitle': 'Approvals for my out-of-frame time entries',
+ 'frameApproval.title': 'Time entries',
+ 'frameApproval.chain':
+ 'Out-of-frame time entries first need approval by the assigned manager, then confirmation by HR. Both review them under Approvals → Time entries.',
+ 'frameApproval.managerChanged':
+ 'Before HR has confirmed, changing the assigned manager requires the new manager to approve again.',
+ 'frameApproval.refresh': 'Refresh approvals',
+ 'frameApproval.empty': 'No time entries requiring approval.',
+ 'frameApproval.loadFailed': 'Could not load time entry approvals.',
+ 'frameApproval.historyFailed': 'Could not load approval history.',
+ 'frameApproval.decisionFailed': 'The decision was not saved.',
+ 'frameApproval.refreshHint':
+ 'Refresh and check the current status before deciding again.',
+ 'frameApproval.notAllowed': 'This decision is not permitted.',
+ 'frameApproval.note': 'Note (required for rejection)',
+ 'frameApproval.reasonRequired': 'A reason is required to reject an entry.',
+ 'frameApproval.assignedManager': 'assigned manager',
+ 'frameApproval.history': 'Approval history',
+ 'frameApproval.noHistory': 'No decisions or changes yet.',
+ 'frameApproval.system': 'System',
+ 'frameApproval.managerResult':
+ 'Manager approval saved. HR confirms next under Approvals → Time entries.',
+ 'frameApproval.hrResult':
+ 'HR confirmation saved. The time entry is approved.',
+ 'frameApproval.rejectedResult': 'Rejection and reason saved.',
+ 'frameApproval.approve.Manager': 'Approve as manager',
+ 'frameApproval.approve.HR': 'Confirm as HR',
+ 'frameApproval.reject.Manager': 'Reject as manager',
+ 'frameApproval.reject.HR': 'Reject as HR',
+ 'frameApproval.state.Open': 'Open – clock out first',
+ 'frameApproval.state.BlockedManager': 'Blocked – no active manager',
+ 'frameApproval.state.PendingManager': 'Awaiting manager approval',
+ 'frameApproval.state.PendingHr': 'Awaiting HR confirmation',
+ 'frameApproval.state.Approved': 'Approved',
+ 'frameApproval.state.Rejected': 'Rejected',
+ 'frameApproval.state.Voided': 'Voided',
+ 'frameApproval.help.Open':
+ 'Clock out first. Only a closed time entry can be reviewed by the assigned manager and then HR under Approvals → Time entries.',
+ 'frameApproval.help.BlockedManager':
+ 'Ask HR to assign an active manager. That manager must approve first, followed by HR confirmation under Approvals → Time entries. HR cannot bypass manager approval.',
+ 'frameApproval.help.PendingManager':
+ 'Next: {manager}, the assigned manager, reviews under Approvals → Time entries. HR then confirms in the same place.',
+ 'frameApproval.help.PendingHr':
+ 'The manager has approved. Next: HR confirms under Approvals → Time entries.',
+ 'frameApproval.help.Approved':
+ 'This time entry is approved. Recorded decisions, reviewers and timestamps are available in approval history.',
+ 'frameApproval.help.Rejected':
+ 'The time entry was rejected. See approval history for the reason and reviewer.',
+ 'frameApproval.help.Voided':
+ 'This time entry is no longer valid and cannot be approved. Its history remains available.',
+ 'frameApproval.event.FrameSegmentCreated':
+ 'Entry segment created after a change',
+ 'frameApproval.event.FrameManagerApproved': 'Approved by manager',
+ 'frameApproval.event.FrameManagerRejected': 'Rejected by manager',
+ 'frameApproval.event.FrameHrConfirmed': 'Confirmed by HR',
+ 'frameApproval.event.FrameHrRejected': 'Rejected by HR',
+ 'frameApproval.event.FrameApprovalInvalidated':
+ 'Previous approval invalidated by a change',
...soloEn,
...terminalPinEn,
'projects.deleteFailed': 'Could not delete project',
@@ -1018,6 +1155,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..ad4971d 100644
--- a/apps/web/src/app/realtime.ts
+++ b/apps/web/src/app/realtime.ts
@@ -73,6 +73,8 @@ export function useRealtimeInvalidation() {
qc.invalidateQueries({ queryKey: ['solo-summary'] });
qc.invalidateQueries({ queryKey: ['solo-hints'] });
qc.invalidateQueries({ queryKey: ['time-entries'] });
+ qc.invalidateQueries({ queryKey: ['time-entry-approvals'] });
+ qc.invalidateQueries({ queryKey: ['time-entry-approval-history'] });
qc.invalidateQueries({ queryKey: ['account'] });
qc.invalidateQueries({ queryKey: ['violations'] });
};
diff --git a/apps/web/src/components/timeentries/TimeEntryApprovals.spec.tsx b/apps/web/src/components/timeentries/TimeEntryApprovals.spec.tsx
new file mode 100644
index 0000000..5487f0e
--- /dev/null
+++ b/apps/web/src/components/timeentries/TimeEntryApprovals.spec.tsx
@@ -0,0 +1,588 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+import { renderWithProviders } from '../../test-utils';
+import type { TimeEntryApprovalDto, TimeEntryDto } from '../../api/client';
+import { TimeEntryApprovals } from './TimeEntryApprovals';
+
+const mocks = vi.hoisted(() => ({
+ user: { id: 'manager-1', role: 'Manager' },
+ list: vi.fn(),
+ decide: vi.fn(),
+ history: vi.fn(),
+}));
+vi.mock('../../api/client', () => ({
+ api: {
+ timeEntryApprovals: mocks.list,
+ decideTimeEntryApproval: mocks.decide,
+ timeEntryApprovalHistory: mocks.history,
+ },
+}));
+vi.mock('../../app/auth', () => ({ useCurrentUser: () => mocks.user }));
+
+const entry: TimeEntryDto = {
+ id: 'entry-1',
+ employeeId: 'employee-1',
+ clockIn: '2026-05-04T05:00:00Z',
+ clockOut: '2026-05-04T17:00:00Z',
+ source: 'web',
+ status: 'Pending',
+ requiresApproval: true,
+ latitude: null,
+ longitude: null,
+ accuracyMeters: null,
+ terminalDistanceMeters: null,
+ terminalRadiusMeters: null,
+ terminalMaxAccuracyMeters: null,
+ positionTimestamp: null,
+ clockOutLatitude: null,
+ clockOutLongitude: null,
+ clockOutAccuracyMeters: null,
+ clockOutTerminalDistanceMeters: null,
+ clockOutTerminalRadiusMeters: null,
+ clockOutTerminalMaxAccuracyMeters: null,
+ clockOutPositionTimestamp: null,
+ terminalId: null,
+ clockOutTerminalId: null,
+ clockInChallengeId: null,
+ clockOutChallengeId: null,
+ projectId: null,
+ projectCode: null,
+ projectName: null,
+ serviceOrderId: null,
+ serviceOrderNo: null,
+ serviceOrderTitle: null,
+ activity: null,
+ summary: { grossMinutes: 720, breakMinutes: 45, netMinutes: 675 },
+};
+function approval(
+ overrides: Partial = {},
+): TimeEntryApprovalDto {
+ return {
+ entry,
+ revision: 7,
+ employeeName: 'Anna Example',
+ managerId: 'manager-1',
+ managerName: 'Marc Manager',
+ state: 'PendingManager',
+ canDecide: true,
+ ...overrides,
+ };
+}
+function renderPanel(scope: 'own' | 'review' = 'review') {
+ return renderWithProviders();
+}
+
+let storedLocale: string | null;
+beforeEach(() => {
+ storedLocale = 'de';
+ vi.spyOn(window, 'localStorage', 'get').mockReturnValue({
+ length: 1,
+ clear: () => {
+ storedLocale = null;
+ },
+ key: () => 'openclockwork.locale',
+ removeItem: () => {
+ storedLocale = null;
+ },
+ getItem: () => storedLocale,
+ setItem: (_key: string, value: string) => {
+ storedLocale = value;
+ },
+ });
+ mocks.user.id = 'manager-1';
+ mocks.user.role = 'Manager';
+ mocks.list.mockReset().mockResolvedValue([approval()]);
+ mocks.decide
+ .mockReset()
+ .mockResolvedValue(approval({ state: 'PendingHr', canDecide: false }));
+ mocks.history.mockReset().mockResolvedValue([]);
+});
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('out-of-frame time entry approvals', () => {
+ it('shows the manager the correct stage, sends the revision and refetches lists, history and totals', async () => {
+ const { queryClient } = renderPanel();
+ const invalidate = vi.spyOn(queryClient, 'invalidateQueries');
+ expect(await screen.findByText('Wartet auf Führungskraft')).toBeDefined();
+ expect(
+ screen.getByText(/Nächster Schritt: Marc Manager/).textContent,
+ ).toContain('Genehmigungen → Zeitbuchungen');
+ expect(mocks.list).toHaveBeenCalledWith('review');
+ expect(
+ screen.queryByRole('button', { name: 'Als HR bestätigen' }),
+ ).toBeNull();
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Genehmigungsverlauf' }),
+ );
+ await screen.findByText(
+ 'Noch keine Entscheidungen oder Änderungen vorhanden.',
+ );
+ mocks.list.mockResolvedValue([
+ approval({ state: 'PendingHr', canDecide: false }),
+ ]);
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Als Führungskraft genehmigen' }),
+ );
+ await waitFor(() =>
+ expect(mocks.decide).toHaveBeenCalledWith('entry-1', {
+ revision: 7,
+ stage: 'Manager',
+ outcome: 'Approve',
+ }),
+ );
+ expect(await screen.findByText('Wartet auf HR')).toBeDefined();
+ expect(screen.getByRole('status').textContent).toContain(
+ 'Als Nächstes bestätigt HR',
+ );
+ expect(
+ screen.queryByRole('button', { name: 'Als Führungskraft genehmigen' }),
+ ).toBeNull();
+ expect(mocks.list.mock.calls.length).toBeGreaterThan(1);
+ expect(mocks.history.mock.calls.length).toBeGreaterThan(1);
+ for (const key of [
+ 'time-entry-approvals',
+ 'time-entry-approval-history',
+ 'time-entries',
+ 'account',
+ 'violations',
+ 'working-time-report',
+ 'project-report',
+ ]) {
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: [key] });
+ }
+ });
+
+ it.each([
+ {
+ role: 'Manager',
+ id: 'manager-1',
+ state: 'PendingManager' as const,
+ stage: 'Manager',
+ label: 'Als Führungskraft ablehnen',
+ },
+ {
+ role: 'HRAdmin',
+ id: 'hr-1',
+ state: 'PendingHr' as const,
+ stage: 'HR',
+ label: 'Als HR ablehnen',
+ },
+ ])(
+ 'requires a nonblank reason when $role rejects',
+ async ({ role, id, state, stage, label }) => {
+ mocks.user.role = role;
+ mocks.user.id = id;
+ mocks.list.mockResolvedValue([approval({ state })]);
+ renderPanel();
+ const reject = await screen.findByRole('button', { name: label });
+ expect(reject.hasAttribute('disabled')).toBe(true);
+ const note = screen.getByLabelText('Notiz (bei Ablehnung erforderlich)');
+ fireEvent.change(note, { target: { value: ' ' } });
+ expect(reject.hasAttribute('disabled')).toBe(true);
+ fireEvent.click(reject);
+ expect(mocks.decide).not.toHaveBeenCalled();
+ fireEvent.change(note, {
+ target: { value: ' Bitte Zeitraum prüfen ' },
+ });
+ mocks.list.mockResolvedValue([
+ approval({ state: 'Rejected', canDecide: false }),
+ ]);
+ fireEvent.click(reject);
+ await waitFor(() =>
+ expect(mocks.decide).toHaveBeenCalledWith('entry-1', {
+ revision: 7,
+ stage,
+ outcome: 'Reject',
+ note: 'Bitte Zeitraum prüfen',
+ }),
+ );
+ expect(
+ await screen.findByText('Ablehnung mit Begründung gespeichert.'),
+ ).toBeDefined();
+ },
+ );
+
+ it('lets HR confirm only the HR stage with the current revision', async () => {
+ mocks.user.role = 'HRAdmin';
+ mocks.user.id = 'hr-1';
+ mocks.list.mockResolvedValue([
+ approval({ state: 'PendingHr', revision: 12 }),
+ ]);
+ renderPanel();
+ const confirm = await screen.findByRole('button', {
+ name: 'Als HR bestätigen',
+ });
+ expect(
+ screen.queryByRole('button', { name: 'Als Führungskraft genehmigen' }),
+ ).toBeNull();
+ fireEvent.change(
+ screen.getByLabelText('Notiz (bei Ablehnung erforderlich)'),
+ { target: { value: 'Geprüft' } },
+ );
+ mocks.list.mockResolvedValue([
+ approval({ state: 'Approved', canDecide: false }),
+ ]);
+ fireEvent.click(confirm);
+ await waitFor(() =>
+ expect(mocks.decide).toHaveBeenCalledWith('entry-1', {
+ revision: 12,
+ stage: 'HR',
+ outcome: 'Approve',
+ note: 'Geprüft',
+ }),
+ );
+ expect(await screen.findByText('Genehmigt')).toBeDefined();
+ });
+
+ it('does not let HR bypass the assigned manager', async () => {
+ mocks.user.role = 'HRAdmin';
+ mocks.user.id = 'hr-1';
+ renderPanel();
+ await screen.findByText('Wartet auf Führungskraft');
+ expect(
+ screen.queryByLabelText('Notiz (bei Ablehnung erforderlich)'),
+ ).toBeNull();
+ expect(
+ screen.queryByRole('button', { name: 'Als HR bestätigen' }),
+ ).toBeNull();
+ });
+
+ it.each([
+ 'Open',
+ 'BlockedManager',
+ 'Approved',
+ 'Rejected',
+ 'Voided',
+ 'PendingHr',
+ ] as const)(
+ 'shows %s without manager actions even with an inconsistent canDecide flag',
+ async (state) => {
+ mocks.list.mockResolvedValue([approval({ state })]);
+ renderPanel();
+ await screen.findByText(/Anna Example/);
+ expect(
+ screen.queryByLabelText('Notiz (bei Ablehnung erforderlich)'),
+ ).toBeNull();
+ expect(
+ screen.getByRole('button', { name: 'Genehmigungsverlauf' }),
+ ).toBeDefined();
+ },
+ );
+
+ it('respects server permissions and manager assignment', async () => {
+ mocks.list.mockResolvedValue([
+ approval({ canDecide: false }),
+ approval({
+ entry: { ...entry, id: 'entry-2' },
+ managerId: 'other-manager',
+ }),
+ ]);
+ renderPanel();
+ await screen.findAllByText('Wartet auf Führungskraft');
+ expect(
+ screen.queryByLabelText('Notiz (bei Ablehnung erforderlich)'),
+ ).toBeNull();
+ });
+
+ it.each(['Employee', 'Manager', 'HRAdmin'])(
+ 'shows own status/history but no decisions for an owner with role %s',
+ async (role) => {
+ mocks.user.role = role;
+ mocks.user.id = entry.employeeId;
+ mocks.list.mockResolvedValue([approval({ managerId: entry.employeeId })]);
+ renderPanel('own');
+ await screen.findByText('Wartet auf Führungskraft');
+ expect(
+ screen.getByText('Rahmenzeit-Genehmigungen für meine Zeitbuchungen'),
+ ).toBeDefined();
+ expect(mocks.list).toHaveBeenCalledWith('own');
+ expect(
+ screen.queryByLabelText('Notiz (bei Ablehnung erforderlich)'),
+ ).toBeNull();
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Genehmigungsverlauf' }),
+ );
+ expect(
+ await screen.findByText(
+ 'Noch keine Entscheidungen oder Änderungen vorhanden.',
+ ),
+ ).toBeDefined();
+ },
+ );
+
+ it('never offers manager-stage actions to HR even with an inconsistent server flag and assignment', async () => {
+ mocks.user.role = 'HRAdmin';
+ mocks.list.mockResolvedValue([
+ approval({ managerId: mocks.user.id, canDecide: true }),
+ ]);
+ renderPanel();
+ await screen.findByText('Wartet auf Führungskraft');
+ expect(
+ screen.queryByLabelText('Notiz (bei Ablehnung erforderlich)'),
+ ).toBeNull();
+ });
+
+ it('prevents self-approval even in a broad reviewer role', async () => {
+ mocks.user.role = 'HRAdmin';
+ mocks.user.id = entry.employeeId;
+ mocks.list.mockResolvedValue([approval({ state: 'PendingHr' })]);
+ renderPanel();
+ await screen.findByText('Wartet auf HR');
+ expect(
+ screen.queryByLabelText('Notiz (bei Ablehnung erforderlich)'),
+ ).toBeNull();
+ });
+
+ it('does not query the reviewer list for an employee', async () => {
+ mocks.user.role = 'Employee';
+ renderPanel();
+ expect(mocks.list).not.toHaveBeenCalled();
+ expect(screen.queryByText('Zeitbuchungen')).toBeNull();
+ });
+
+ it('keeps stale errors and the entered reason visible, then uses the refreshed revision', async () => {
+ mocks.decide.mockRejectedValueOnce(
+ new Error('Stale revision: entry changed'),
+ );
+ renderPanel();
+ const reject = await screen.findByRole('button', {
+ name: 'Als Führungskraft ablehnen',
+ });
+ const note = screen.getByLabelText(
+ 'Notiz (bei Ablehnung erforderlich)',
+ ) as HTMLInputElement;
+ fireEvent.change(note, { target: { value: 'Zeitraum falsch' } });
+ fireEvent.click(reject);
+ expect(await screen.findByRole('alert')).toBeDefined();
+ expect(screen.getByRole('alert').textContent).toContain(
+ 'Stale revision: entry changed',
+ );
+ expect(note.value).toBe('Zeitraum falsch');
+ mocks.list.mockResolvedValue([approval({ revision: 9 })]);
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Genehmigungen aktualisieren' }),
+ );
+ await waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(2));
+ await waitFor(() =>
+ expect(
+ screen
+ .getByRole('button', { name: 'Genehmigungen aktualisieren' })
+ .hasAttribute('disabled'),
+ ).toBe(false),
+ );
+ expect(screen.getByRole('alert').textContent).toContain(
+ 'Stale revision: entry changed',
+ );
+ mocks.decide.mockResolvedValueOnce(
+ approval({ state: 'Rejected', canDecide: false }),
+ );
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Als Führungskraft ablehnen' }),
+ );
+ await waitFor(() =>
+ expect(mocks.decide).toHaveBeenLastCalledWith('entry-1', {
+ revision: 9,
+ stage: 'Manager',
+ outcome: 'Reject',
+ note: 'Zeitraum falsch',
+ }),
+ );
+ });
+
+ it('renders every decision and invalidation with actor, timestamp and reason', async () => {
+ mocks.history.mockResolvedValue([
+ {
+ id: '1',
+ actorId: 'manager-1',
+ actorName: 'Marc Manager',
+ action: 'FrameManagerApproved',
+ reason: 'Kundentermin',
+ occurredAt: '2026-05-04T17:30:00Z',
+ },
+ {
+ id: '2',
+ actorId: 'hr-1',
+ actorName: 'Hannah HR',
+ action: 'FrameHrConfirmed',
+ reason: null,
+ occurredAt: '2026-05-04T18:00:00Z',
+ },
+ {
+ id: '3',
+ actorId: null,
+ actorName: null,
+ action: 'FrameApprovalInvalidated',
+ reason: 'Führungskraft geändert',
+ occurredAt: '2026-05-05T08:00:00Z',
+ },
+ {
+ id: '4',
+ actorId: 'manager-2',
+ actorName: 'New Manager',
+ action: 'FrameManagerRejected',
+ reason: 'Unklarer Zeitraum',
+ occurredAt: '2026-05-05T09:00:00Z',
+ },
+ {
+ id: '5',
+ actorId: 'hr-1',
+ actorName: 'Hannah HR',
+ action: 'FrameHrRejected',
+ reason: 'Fehlender Nachweis',
+ occurredAt: '2026-05-05T10:00:00Z',
+ },
+ {
+ id: '6',
+ actorId: 'owner-1',
+ actorName: 'Anna Example',
+ action: 'FrameSegmentCreated',
+ reason: null,
+ occurredAt: '2026-05-05T11:00:00Z',
+ },
+ ]);
+ const { container } = renderPanel('own');
+ fireEvent.click(
+ await screen.findByRole('button', { name: 'Genehmigungsverlauf' }),
+ );
+ expect(await screen.findByText('Kundentermin')).toBeDefined();
+ for (const label of [
+ 'Von Führungskraft genehmigt',
+ 'Von HR bestätigt',
+ 'Bisherige Genehmigung durch Änderung aufgehoben',
+ 'Von Führungskraft abgelehnt',
+ 'Von HR abgelehnt',
+ 'Buchungssegment nach Änderung neu angelegt',
+ 'Führungskraft geändert',
+ 'Unklarer Zeitraum',
+ 'Fehlender Nachweis',
+ ]) {
+ expect(screen.getByText(label)).toBeDefined();
+ }
+ expect(screen.getByText(/Marc Manager ·/)).toBeDefined();
+ expect(screen.getByText(/System ·/)).toBeDefined();
+ expect(container.querySelector('time')?.getAttribute('datetime')).toBe(
+ '2026-05-04T17:30:00Z',
+ );
+ });
+
+ it('shows loading, query errors and recoverable empty states', async () => {
+ let rejectList!: (reason: Error) => void;
+ mocks.list.mockImplementationOnce(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectList = reject;
+ }),
+ );
+ renderPanel();
+ expect(screen.getByRole('status').textContent).toBe('Lädt …');
+ rejectList(new Error('Service unavailable'));
+ expect((await screen.findByRole('alert')).textContent).toContain(
+ 'Service unavailable',
+ );
+ mocks.list.mockResolvedValue([]);
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Genehmigungen aktualisieren' }),
+ );
+ expect(
+ await screen.findByText(
+ 'Keine genehmigungspflichtigen Zeitbuchungen vorhanden.',
+ ),
+ ).toBeDefined();
+ });
+
+ it('surfaces history errors and allows retry', async () => {
+ mocks.history.mockRejectedValueOnce(new Error('History unavailable'));
+ renderPanel('own');
+ fireEvent.click(
+ await screen.findByRole('button', { name: 'Genehmigungsverlauf' }),
+ );
+ expect((await screen.findByRole('alert')).textContent).toContain(
+ 'History unavailable',
+ );
+ mocks.history.mockResolvedValue([]);
+ fireEvent.click(
+ screen.getAllByRole('button', { name: 'Genehmigungen aktualisieren' })[1],
+ );
+ expect(
+ await screen.findByText(
+ 'Noch keine Entscheidungen oder Änderungen vorhanden.',
+ ),
+ ).toBeDefined();
+ });
+
+ it.each([
+ {
+ locale: 'de',
+ title: 'Zeitbuchungen',
+ state: 'Blockiert – Führungskraft fehlt',
+ manager: 'aktive Führungskraft zuzuweisen',
+ where: 'Genehmigungen → Zeitbuchungen',
+ },
+ {
+ locale: 'en',
+ title: 'Time entries',
+ state: 'Blocked – no active manager',
+ manager: 'assign an active manager',
+ where: 'Approvals → Time entries',
+ },
+ ])(
+ 'explains the chain and missing-manager recovery in $locale',
+ async ({ locale, title, state, manager, where }) => {
+ storedLocale = locale;
+ mocks.list.mockResolvedValue([
+ approval({
+ state: 'BlockedManager',
+ managerId: null,
+ managerName: null,
+ canDecide: false,
+ }),
+ ]);
+ renderPanel();
+ expect(await screen.findByText(state)).toBeDefined();
+ expect(screen.getByText(title)).toBeDefined();
+ const help = screen.getByText(new RegExp(manager));
+ expect(help.textContent).toContain(where);
+ expect(screen.queryByLabelText(/Note|Notiz/)).toBeNull();
+ },
+ );
+
+ it('provides English actions, error guidance and history labels', async () => {
+ storedLocale = 'en';
+ mocks.user.role = 'HRAdmin';
+ mocks.user.id = 'hr-1';
+ mocks.list.mockResolvedValue([approval({ state: 'PendingHr' })]);
+ mocks.decide.mockRejectedValueOnce(new Error('Revision conflict'));
+ mocks.history.mockResolvedValue([
+ {
+ id: '1',
+ actorId: 'manager-1',
+ actorName: 'Marc Manager',
+ action: 'FrameManagerApproved',
+ reason: 'Client visit',
+ occurredAt: '2026-05-04T17:30:00Z',
+ },
+ ]);
+ renderPanel();
+ fireEvent.click(
+ await screen.findByRole('button', { name: 'Confirm as HR' }),
+ );
+ expect((await screen.findByRole('alert')).textContent).toContain(
+ 'The decision was not saved.',
+ );
+ expect(screen.getByRole('alert').textContent).toContain(
+ 'Refresh and check the current status',
+ );
+ expect(
+ screen.getByLabelText('Note (required for rejection)'),
+ ).toBeDefined();
+ expect(
+ screen
+ .getByRole('button', { name: 'Reject as HR' })
+ .hasAttribute('disabled'),
+ ).toBe(true);
+ fireEvent.click(screen.getByRole('button', { name: 'Approval history' }));
+ expect(await screen.findByText('Approved by manager')).toBeDefined();
+ expect(screen.getByText('Client visit')).toBeDefined();
+ });
+});
diff --git a/apps/web/src/components/timeentries/TimeEntryApprovals.tsx b/apps/web/src/components/timeentries/TimeEntryApprovals.tsx
new file mode 100644
index 0000000..9b20dcf
--- /dev/null
+++ b/apps/web/src/components/timeentries/TimeEntryApprovals.tsx
@@ -0,0 +1,319 @@
+import { useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ api,
+ type TimeEntryApprovalDecision,
+ type TimeEntryApprovalDto,
+} from '../../api/client';
+import { useCurrentUser } from '../../app/auth';
+import { useI18n } from '../../app/i18n';
+
+/** All lists and downstream totals must reflect decisions and edited entries. */
+export async function refreshTimeEntryApprovals(
+ qc: ReturnType,
+) {
+ await Promise.all(
+ [
+ 'time-entry-approvals',
+ 'time-entry-approval-history',
+ 'time-entries',
+ 'account',
+ 'violations',
+ 'working-time-report',
+ 'project-report',
+ ].map((key) => qc.invalidateQueries({ queryKey: [key] })),
+ );
+}
+
+export function TimeEntryApprovals({ scope }: { scope: 'own' | 'review' }) {
+ const user = useCurrentUser();
+ const { t } = useI18n();
+ const allowed =
+ scope === 'own' || user.role === 'Manager' || user.role === 'HRAdmin';
+ const query = useQuery({
+ queryKey: ['time-entry-approvals', scope, user.id, user.role],
+ queryFn: () => api.timeEntryApprovals(scope),
+ enabled: allowed,
+ });
+ if (!allowed) return null;
+
+ return (
+
+
+
+ {t(
+ scope === 'own' ? 'frameApproval.ownTitle' : 'frameApproval.title',
+ )}
+
+
+ {t('frameApproval.chain')}
+
+
+ {t('frameApproval.managerChanged')}
+
+
+
+ {query.isPending && {t('common.loading')}
}
+ {query.isError && (
+
+
+ {t('frameApproval.loadFailed')}{' '}
+ {query.error instanceof Error ? query.error.message : ''}
+
+
+ )}
+
+ {query.isSuccess && query.data.length === 0 && (
+
+ {t('frameApproval.empty')}
+
+ )}
+ {query.data && query.data.length > 0 && (
+
+ {query.data.map((approval) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+function TimeEntryApprovalRow({
+ approval,
+ scope,
+}: {
+ approval: TimeEntryApprovalDto;
+ scope: 'own' | 'review';
+}) {
+ const user = useCurrentUser();
+ const { t, formatDateTime } = useI18n();
+ const qc = useQueryClient();
+ const [note, setNote] = useState('');
+ const [historyOpen, setHistoryOpen] = useState(false);
+ const [result, setResult] = useState(null);
+ const { entry, state } = approval;
+ const stage =
+ state === 'PendingManager'
+ ? 'Manager'
+ : state === 'PendingHr'
+ ? 'HR'
+ : null;
+ // canDecide is server-authoritative; also guard role, stage and self-approval.
+ const canDecide =
+ scope === 'review' &&
+ approval.canDecide &&
+ entry.employeeId !== user.id &&
+ !!entry.clockOut &&
+ ((stage === 'Manager' &&
+ user.role === 'Manager' &&
+ approval.managerId === user.id) ||
+ (stage === 'HR' && user.role === 'HRAdmin'));
+ const decision = useMutation({
+ mutationFn: (outcome: TimeEntryApprovalDecision['outcome']) => {
+ // Do not allow a stale UI event to bypass the currently displayed stage.
+ if (!canDecide || !stage) throw new Error(t('frameApproval.notAllowed'));
+ if (outcome === 'Reject' && !note.trim()) {
+ throw new Error(t('frameApproval.reasonRequired'));
+ }
+ return api.decideTimeEntryApproval(entry.id, {
+ revision: approval.revision,
+ stage,
+ outcome,
+ ...(note.trim() ? { note: note.trim() } : {}),
+ });
+ },
+ onSuccess: async (_updated, outcome) => {
+ setResult(
+ t(
+ outcome === 'Reject'
+ ? 'frameApproval.rejectedResult'
+ : stage === 'Manager'
+ ? 'frameApproval.managerResult'
+ : 'frameApproval.hrResult',
+ ),
+ );
+ setNote('');
+ await refreshTimeEntryApprovals(qc);
+ },
+ });
+ const labelId = `frame-approval-${entry.id}`;
+
+ return (
+
+
+
+
+ {approval.employeeName} · {formatDateTime(entry.clockIn)} –{' '}
+ {entry.clockOut
+ ? formatDateTime(entry.clockOut)
+ : t('booking.open')}
+
+ {entry.summary && (
+
+ {t('booking.summary', {
+ gross: `${entry.summary.grossMinutes}min`,
+ break: entry.summary.breakMinutes,
+ net: `${entry.summary.netMinutes}min`,
+ })}
+
+ )}
+ {entry.projectCode && (
+
+ {entry.projectCode}
+ {entry.serviceOrderNo ? ` · ${entry.serviceOrderNo}` : ''}
+
+ )}
+ {entry.activity &&
{entry.activity}
}
+
+
+ {t(`frameApproval.state.${state}`)}
+
+
+
+ {t(`frameApproval.help.${state}`, {
+ manager: approval.managerName ?? t('frameApproval.assignedManager'),
+ })}
+
+ {result && (
+
+ {result}
+
+ )}
+ {decision.isError && (
+
+
+ {t('frameApproval.decisionFailed')}{' '}
+ {decision.error instanceof Error ? decision.error.message : ''}{' '}
+ {t('frameApproval.refreshHint')}
+
+
+ )}
+ {canDecide && (
+
+
+
setNote(event.target.value)}
+ />
+
+ {t('frameApproval.reasonRequired')}
+
+
+
+
+
+
+ )}
+
+ {historyOpen && }
+
+ );
+}
+
+function ApprovalHistory({ entryId }: { entryId: string }) {
+ const user = useCurrentUser();
+ const { t, formatDateTime } = useI18n();
+ const history = useQuery({
+ queryKey: ['time-entry-approval-history', entryId, user.id],
+ queryFn: () => api.timeEntryApprovalHistory(entryId),
+ });
+ return (
+
+ {history.isPending &&
{t('common.loading')}
}
+ {history.isError && (
+
+
+ {t('frameApproval.historyFailed')}{' '}
+ {history.error instanceof Error ? history.error.message : ''}
+
+
+ )}
+ {history.isSuccess && history.data.length === 0 && (
+
{t('frameApproval.noHistory')}
+ )}
+ {history.data && (
+
+ {history.data.map((event) => (
+ -
+
+ {t(`frameApproval.event.${event.action}`)}
+
+
+ {event.actorName ?? event.actorId ?? t('frameApproval.system')}{' '}
+ ·{' '}
+
+
+ {event.reason && (
+ {event.reason}
+ )}
+
+ ))}
+
+ )}
+ {history.isError && (
+
+ )}
+
+ );
+}
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..ba0ae87
--- /dev/null
+++ b/apps/web/src/routes/AdminRequestsPage.spec.tsx
@@ -0,0 +1,56 @@
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, screen } from '@testing-library/react';
+import { renderWithProviders } from '../test-utils';
+import { AdminRequestsPage } from './AdminRequestsPage';
+import { api } from '../api/client';
+
+const request = {
+ id: 'request-42',
+ 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] : [],
+ ),
+ getRequest: vi.fn().mockImplementation(async () => request),
+ getRequestEvents: vi
+ .fn()
+ .mockResolvedValue([
+ { id: 'event', kind: 'Resubmitted', at: '2026-05-01T12:00:00.000Z' },
+ ]),
+ },
+}));
+vi.mock('../app/auth', () => ({
+ useCurrentUser: () => ({
+ id: 'manager',
+ firstName: 'Marc',
+ lastName: 'Example',
+ role: 'Manager',
+ }),
+}));
+
+describe('request identity in approval context (#42)', () => {
+ it('shows the employee name in the inbox and retained audit trail', async () => {
+ renderWithProviders();
+ expect(await screen.findByText(/Anna Example · Urlaub/)).toBeDefined();
+ 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();
+ });
+});
diff --git a/apps/web/src/routes/AdminRequestsPage.tsx b/apps/web/src/routes/AdminRequestsPage.tsx
index d8fc2df..819cc25 100644
--- a/apps/web/src/routes/AdminRequestsPage.tsx
+++ b/apps/web/src/routes/AdminRequestsPage.tsx
@@ -25,6 +25,7 @@ import { api, type BulkResult, type RequestDto } from '../api/client';
import { useCurrentUser } from '../app/auth';
import { cn } from '@/lib/utils';
import { useI18n } from '../app/i18n';
+import { TimeEntryApprovals } from '../components/timeentries/TimeEntryApprovals';
type BulkDialog =
| { mode: 'approve'; ids: string[] }
@@ -107,6 +108,8 @@ export function AdminRequestsPage() {
+
+
{bulkOutcome && (
- {enumLabel(request.type)} ·{' '}
+ {request.employeeName} · {enumLabel(request.type)} ·{' '}
{isTimeAdjustment
? `${formatDateTime(request.from)} – ${formatDateTime(request.to)}`
: `${formatDate(request.from.slice(0, 10))} – ${formatDate(request.to.slice(0, 10))}`}
@@ -624,7 +627,7 @@ function AuditTrail({ requestId }: { requestId: string }) {
{t('requests.auditHistory')}
{requestQuery.data
- ? `${enumLabel(requestQuery.data.type)} · ${formatDate(requestQuery.data.type === 'TimeAdjustment' ? requestQuery.data.from : requestQuery.data.from.slice(0, 10))} – ${formatDate(requestQuery.data.type === 'TimeAdjustment' ? requestQuery.data.to : requestQuery.data.to.slice(0, 10))}`
+ ? `${requestQuery.data.employeeName} · ${enumLabel(requestQuery.data.type)} · ${formatDate(requestQuery.data.type === 'TimeAdjustment' ? requestQuery.data.from : requestQuery.data.from.slice(0, 10))} – ${formatDate(requestQuery.data.type === 'TimeAdjustment' ? requestQuery.data.to : requestQuery.data.to.slice(0, 10))}`
: '…'}
diff --git a/apps/web/src/routes/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..57004ea 100644
--- a/apps/web/src/routes/RequestsPage.spec.tsx
+++ b/apps/web/src/routes/RequestsPage.spec.tsx
@@ -1,15 +1,33 @@
-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();
vi.mock('../api/client', () => ({
api: {
- listRequests: vi.fn().mockResolvedValue([]),
+ listRequests: (...args: unknown[]) => listRequestsMock(...args),
+ getRequest: (...args: unknown[]) => getRequestMock(...args),
+ getRequestEvents: (...args: unknown[]) => getRequestEventsMock(...args),
+ updateDraftRequest: (...args: unknown[]) => updateDraftRequestMock(...args),
+ resubmitRequest: (...args: unknown[]) => resubmitRequestMock(...args),
employeeDirectory: (...args: unknown[]) => employeeDirectoryMock(...args),
employees: (...args: unknown[]) => employeesMock(...args),
vacationBalance: vi.fn().mockResolvedValue({
@@ -32,8 +50,32 @@ 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([]);
+ getRequestMock.mockReset();
+ getRequestEventsMock.mockReset().mockResolvedValue([]);
+ updateDraftRequestMock.mockReset().mockResolvedValue({});
+ resubmitRequestMock.mockReset().mockResolvedValue({});
employeesMock.mockClear();
employeeDirectoryMock.mockClear().mockResolvedValue([
{ id: 'requester', firstName: 'Anna', lastName: 'Example' },
@@ -72,4 +114,375 @@ 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();
+ }
+ });
});
diff --git a/apps/web/src/routes/RequestsPage.tsx b/apps/web/src/routes/RequestsPage.tsx
index ce0534b..2a26a39 100644
--- a/apps/web/src/routes/RequestsPage.tsx
+++ b/apps/web/src/routes/RequestsPage.tsx
@@ -15,9 +15,14 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
-import { api, type RequestType } from '../api/client';
+import { api, type RequestDto, type RequestType } from '../api/client';
import { useCurrentUser } from '../app/auth';
import { useI18n } from '../app/i18n';
+import {
+ formatLocalDateTime,
+ localDateTimeCandidates,
+ utcOffsetLabel,
+} from '../lib/local-date-time';
const TYPES: RequestType[] = [
'Vacation',
@@ -55,6 +60,7 @@ export function RequestsPage() {
const employeeId = user.id;
const qc = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
+ const [editingId, setEditingId] = useState(null);
const requests = useQuery({
queryKey: ['requests', { employeeId }],
@@ -76,17 +82,30 @@ export function RequestsPage() {
-
+
{
setDialogOpen(false);
qc.invalidateQueries({ queryKey: ['requests'] });
+ qc.invalidateQueries({ queryKey: ['vacation-balance'] });
}}
/>
+ {editingId && (
+ {
+ setEditingId(null);
+ qc.invalidateQueries({ queryKey: ['requests'] });
+ qc.invalidateQueries({ queryKey: ['request'] });
+ qc.invalidateQueries({ queryKey: ['request-events'] });
+ qc.invalidateQueries({ queryKey: ['vacation-balance'] });
+ }}
+ />
+ )}
{t('requests.own')}
@@ -101,6 +120,7 @@ export function RequestsPage() {
>
+ {r.employeeName || `${user.firstName} ${user.lastName}`} ·{' '}
{enumLabel(r.type)} ·{' '}
{formatRange(
r.type as RequestType,
@@ -122,9 +142,19 @@ export function RequestsPage() {
{t('requests.specialApproval')}
)}
-
- {enumLabel(r.workflowState)}
-
+ {r.workflowState === 'Draft' ? (
+
+ ) : (
+
+ {enumLabel(r.workflowState)}
+
+ )}
{r.type === 'SpecialLeave' && (
@@ -143,31 +173,160 @@ 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 (
+
+ );
+}
+
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,
+ change: (next: string) => {
+ setValue(next);
+ setChosenIso('');
+ },
+ };
+}
+
+function LocalDateTimeField({
+ id,
+ label,
+ time,
+}: {
+ id: string;
+ label: string;
+ time: ReturnType;
+}) {
+ const { t } = useI18n();
+ const ambiguous = time.candidates.length > 1;
+ const invalid = !time.iso;
+ return (
+ <>
+ time.change(e.target.value)}
+ aria-invalid={invalid}
+ aria-describedby={ambiguous || invalid ? `${id}-time-help` : undefined}
+ />
+ {ambiguous ? (
+
+
+ {t('requests.ambiguousTime', {
+ zone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ })}
+
+
+
+
+ ) : invalid ? (
+
+ {t('requests.nonexistentTime')}
+
+ ) : null}
+ >
+ );
}
-function NewRequestForm({ onClose }: NewRequestFormProps) {
+function NewRequestForm({ onClose, request }: NewRequestFormProps) {
const user = useCurrentUser();
const { t, enumLabel } = useI18n();
const employeeId = user.id;
const today = isoDateOnly(new Date());
- const [type, setType] = useState('Vacation');
- const [from, setFrom] = useState(today);
- const [to, setTo] = useState(today);
- const [fromDt, setFromDt] = useState(isoDateTimeLocalNow);
- const [toDt, setToDt] = useState(isoDateTimeLocalNow);
- const [reason, setReason] = useState('');
- const [substituteId, setSubstituteId] = useState('');
- const [halfDayStart, setHalfDayStart] = useState(false);
- const [halfDayEnd, setHalfDayEnd] = useState(false);
+ const [type, setType] = useState(request?.type ?? 'Vacation');
+ const [from, setFrom] = useState(request?.from.slice(0, 10) ?? today);
+ const [to, setTo] = useState(request?.to.slice(0, 10) ?? today);
+ const fromTime = useLocalDateTime(
+ request?.type === 'TimeAdjustment' ? request.from : undefined,
+ );
+ const toTime = useLocalDateTime(
+ request?.type === 'TimeAdjustment' ? request.to : undefined,
+ );
+ 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 +335,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,31 +360,55 @@ 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 (!fromIso || !toIso || invalidRange) {
+ throw new Error(
+ t(
+ isTimeAdjustment
+ ? 'requests.invalidTimeRange'
+ : 'requests.invalidDateRange',
+ ),
+ );
+ }
+ if (request) {
+ const payload = {
+ expectedUpdatedAt: request.updatedAt,
+ 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({
employeeId,
type,
@@ -233,19 +431,41 @@ function NewRequestForm({ onClose }: NewRequestFormProps) {
const insufficientVacation =
type === 'Vacation' &&
balance.data !== undefined &&
- balance.data.remainingDays < 1;
+ balance.data.remainingDays < 0.5;
return (
<>
- {t('requests.new')}
+
+ {t(request ? 'requests.edit' : 'requests.new')}
+
{t('requests.chooseTypeRange')}
+
+ {t('requests.employee')}:{' '}
+ {request?.employeeName || `${user.firstName} ${user.lastName}`}
+
+ {request && revisionNote && (
+
+
+ {t('requests.revisionReason')}: {revisionNote}
+
+
+ )}
+ {request && request.workflowState !== 'Draft' && (
+
+ {t('requests.notDraft')}
+
+ )}
+ {request?.type === 'SpecialLeave' && (
+
+ )}