diff --git a/apps/api-e2e/src/api/contract-periods.e2e.spec.ts b/apps/api-e2e/src/api/contract-periods.e2e.spec.ts
new file mode 100644
index 0000000..ef6061b
--- /dev/null
+++ b/apps/api-e2e/src/api/contract-periods.e2e.spec.ts
@@ -0,0 +1,396 @@
+import { ContractPeriodsService } from '../../../api/src/app/employees/contract-periods.service';
+import type { ConfirmContractPeriodDto } from '../../../api/src/app/employees/contract-periods.dto';
+import { JwtService } from '@nestjs/jwt';
+import {
+ createTestApp,
+ login,
+ seedEmployee,
+ type TestContext,
+} from '../support/test-app';
+
+const facts = {
+ effectiveFrom: '2026-01-01',
+ effectiveTo: null,
+ employmentStart: '2020-01-01',
+ employmentEnd: null,
+ weeklyHours: 40,
+ timeModel: 'Vollzeit',
+ workingDays: 31,
+ holidayCalendar: 'NONE',
+ holidayDates: ['2026-05-04'],
+ scheduleLabel: 'Synthetic Mon–Fri',
+ confirmationNote: 'Synthetic known contract, confirmed by HR',
+ confirmed: true,
+};
+describe('HR-confirmed contract history (#45)', () => {
+ let ctx: TestContext;
+ let hr: Awaited>;
+ let employee: Awaited>;
+ let manager: Awaited>;
+ let hrToken: string;
+ const path = () => `/api/employees/${employee.id}/contract-periods`;
+ beforeAll(async () => {
+ ctx = await createTestApp();
+ });
+ afterAll(async () => {
+ await ctx.close();
+ });
+ beforeEach(async () => {
+ await ctx.reset();
+ hr = await seedEmployee(ctx.prisma, {
+ personalNo: 'CON-HR',
+ firstName: 'Hanna',
+ lastName: 'HR',
+ email: 'contract.hr@test.local',
+ role: 'HRAdmin',
+ });
+ manager = await seedEmployee(ctx.prisma, {
+ personalNo: 'CON-M',
+ firstName: 'Marc',
+ lastName: 'Manager',
+ email: 'contract.manager@test.local',
+ role: 'Manager',
+ });
+ employee = await seedEmployee(ctx.prisma, {
+ personalNo: 'CON-E',
+ firstName: 'Emil',
+ lastName: 'Employee',
+ email: 'contract.employee@test.local',
+ managerId: manager.id,
+ });
+ hrToken = await login(ctx.http, hr.email);
+ });
+ const post = (token: string, body: object) =>
+ ctx.http.post(path()).set('Authorization', `Bearer ${token}`).send(body);
+ it('does not invent history from legacy mutable profile values', async () => {
+ const response = await ctx.http
+ .get(path())
+ .set('Authorization', `Bearer ${hrToken}`)
+ .expect(200);
+ expect(response.body).toEqual([]);
+ expect(await ctx.prisma.employeeContractPeriodAudit.count()).toBe(0);
+ });
+ it('confirms self-contained dated facts and records authoritative HR actor', async () => {
+ const response = await post(hrToken, {
+ ...facts,
+ confirmedById: employee.id,
+ employeeId: manager.id,
+ }).expect(201);
+ const persistedFacts = Object.fromEntries(
+ Object.entries(facts).filter(([key]) => key !== 'confirmed'),
+ );
+ expect(response.body).toMatchObject({
+ ...persistedFacts,
+ employeeId: employee.id,
+ confirmedById: hr.id,
+ revision: 0,
+ weeklyHours: 40,
+ });
+ expect(response.body.confirmed).toBeUndefined();
+ const audit =
+ await ctx.prisma.employeeContractPeriodAudit.findFirstOrThrow();
+ expect(audit).toMatchObject({
+ actorId: hr.id,
+ action: 'Confirmed',
+ before: null,
+ after: { id: response.body.id, employeeId: employee.id, weeklyHours: 40 },
+ });
+ expect(audit.occurredAt).toBeInstanceOf(Date);
+ });
+ it.each(['manager', 'employee'] as const)(
+ 'denies %s reading or confirming contracts',
+ async (role) => {
+ const actor = role === 'manager' ? manager : employee;
+ const token = await login(ctx.http, actor.email);
+ await ctx.http
+ .get(path())
+ .set('Authorization', `Bearer ${token}`)
+ .expect(403);
+ await post(token, facts).expect(403);
+ expect(await ctx.prisma.employeeContractPeriod.count()).toBe(0);
+ },
+ );
+ it('denies a forged/stale HR claim after the actor role changed', async () => {
+ const forged = ctx.app.get(JwtService).sign({
+ sub: manager.id,
+ role: 'HRAdmin',
+ email: manager.email,
+ authVersion: manager.authVersion,
+ });
+ await post(forged, facts).expect(403);
+ await ctx.prisma.employee.update({
+ where: { id: hr.id },
+ data: { role: 'Employee' },
+ });
+ await post(hrToken, facts).expect(403);
+ });
+ it.each(['revoked', 'demoted', 'inactive', 'Solo'] as const)(
+ 'revalidates in-flight handler authority after %s state changed',
+ async (change) => {
+ const service = ctx.app.get(ContractPeriodsService);
+ const identity = {
+ id: hr.id,
+ email: hr.email,
+ role: 'HRAdmin' as const,
+ authVersion: 0,
+ };
+ if (change === 'Solo') {
+ await ctx.prisma.installationSettings.create({
+ data: {
+ id: 1,
+ mode: 'Solo',
+ ownerEmployeeId: hr.id,
+ setupCompleted: true,
+ },
+ });
+ } else {
+ await ctx.prisma.employee.update({
+ where: { id: hr.id },
+ data:
+ change === 'revoked'
+ ? { authVersion: 1 }
+ : change === 'demoted'
+ ? { role: 'Employee' }
+ : { isActive: false },
+ });
+ }
+ await expect(
+ service.confirm(
+ employee.id,
+ facts as ConfirmContractPeriodDto,
+ identity,
+ ),
+ ).rejects.toMatchObject({ status: 403 });
+ await expect(service.list(employee.id, identity)).rejects.toMatchObject({
+ status: 403,
+ });
+ expect(await ctx.prisma.employeeContractPeriod.count()).toBe(0);
+ expect(await ctx.prisma.employeeContractPeriodAudit.count()).toBe(0);
+ },
+ );
+ it.each([
+ { confirmed: false },
+ { confirmed: 'true' },
+ { confirmationNote: ' ' },
+ { effectiveFrom: '2026-02-30' },
+ { effectiveTo: '2025-12-31' },
+ { employmentEnd: '2019-12-31' },
+ { holidayDates: ['2026-02-30'] },
+ { workingDays: 0 },
+ { workingDays: 128 },
+ { weeklyHours: -1 },
+ { weeklyHours: 169 },
+ { weeklyHours: 40.001 },
+ { workingDays: 1, weeklyHours: 25 },
+ { holidayCalendar: 'FAKE' },
+ { expectedRevision: 0 },
+ { supersedesPeriodId: '00000000-0000-4000-8000-000000000999' },
+ ])('rejects invalid confirmation %j', async (patch) => {
+ await post(hrToken, { ...facts, ...patch }).expect(400);
+ expect(await ctx.prisma.employeeContractPeriod.count()).toBe(0);
+ });
+ it('supports zero-hour contracts and explicit employment boundaries without mutating the profile', async () => {
+ const response = await post(hrToken, {
+ ...facts,
+ weeklyHours: 0,
+ workingDays: 0,
+ effectiveTo: '2026-12-31',
+ employmentStart: '2026-03-01',
+ employmentEnd: '2026-10-31',
+ }).expect(201);
+ expect(response.body).toMatchObject({
+ weeklyHours: 0,
+ workingDays: 0,
+ employmentStart: '2026-03-01',
+ employmentEnd: '2026-10-31',
+ });
+ expect(
+ (
+ await ctx.prisma.employee.findUniqueOrThrow({
+ where: { id: employee.id },
+ })
+ ).weeklyHours.toNumber(),
+ ).toBe(40);
+ });
+ it('rejects overlap including inclusive boundaries, without changing either period or audit', async () => {
+ await post(hrToken, { ...facts, effectiveTo: '2026-06-30' }).expect(201);
+ await post(hrToken, { ...facts, effectiveFrom: '2026-06-30' }).expect(409);
+ await post(hrToken, facts).expect(409);
+ expect(await ctx.prisma.employeeContractPeriod.count()).toBe(1);
+ expect(await ctx.prisma.employeeContractPeriodAudit.count()).toBe(1);
+ await post(hrToken, { ...facts, effectiveFrom: '2026-07-01' }).expect(201);
+ });
+ it('explicitly supersedes only the covering period with revision and before/after audit', async () => {
+ const old = (await post(hrToken, facts).expect(201)).body;
+ const next = await post(hrToken, {
+ ...facts,
+ effectiveFrom: '2026-07-01',
+ weeklyHours: 20,
+ timeModel: 'Teilzeit',
+ supersedesPeriodId: old.id,
+ expectedRevision: 0,
+ }).expect(201);
+ const previous = await ctx.prisma.employeeContractPeriod.findUniqueOrThrow({
+ where: { id: old.id },
+ });
+ expect(previous.effectiveTo?.toISOString()).toBe(
+ '2026-06-30T00:00:00.000Z',
+ );
+ expect(previous.weeklyHours.toNumber()).toBe(40);
+ expect(previous.revision).toBe(1);
+ expect(next.body).toMatchObject({
+ weeklyHours: 20,
+ revision: 0,
+ confirmedById: hr.id,
+ });
+ const audits = await ctx.prisma.employeeContractPeriodAudit.findMany({
+ where: { periodId: old.id },
+ orderBy: { occurredAt: 'asc' },
+ });
+ expect(audits).toHaveLength(2);
+ expect(audits[1]).toMatchObject({
+ action: 'Superseded',
+ actorId: hr.id,
+ before: { effectiveTo: null, weeklyHours: 40, revision: 0 },
+ after: { effectiveTo: '2026-06-30', weeklyHours: 40, revision: 1 },
+ });
+ await post(hrToken, {
+ ...facts,
+ effectiveFrom: '2026-08-01',
+ supersedesPeriodId: old.id,
+ expectedRevision: 0,
+ }).expect(409);
+ expect(await ctx.prisma.employeeContractPeriod.count()).toBe(2);
+ });
+ it('does not use a foreign employee contract as a supersession token', async () => {
+ const foreign = (
+ await ctx.http
+ .post(`/api/employees/${manager.id}/contract-periods`)
+ .set('Authorization', `Bearer ${hrToken}`)
+ .send(facts)
+ .expect(201)
+ ).body;
+ await post(hrToken, {
+ ...facts,
+ effectiveFrom: '2026-07-01',
+ supersedesPeriodId: foreign.id,
+ expectedRevision: 0,
+ }).expect(409);
+ expect(
+ await ctx.prisma.employeeContractPeriod.count({
+ where: { employeeId: employee.id },
+ }),
+ ).toBe(0);
+ });
+ it('rolls back supersession when the proposed period overlaps another known period', async () => {
+ const old = (
+ await post(hrToken, { ...facts, effectiveTo: '2026-06-30' }).expect(201)
+ ).body;
+ await post(hrToken, { ...facts, effectiveFrom: '2026-09-01' }).expect(201);
+ await post(hrToken, {
+ ...facts,
+ effectiveFrom: '2026-05-01',
+ supersedesPeriodId: old.id,
+ expectedRevision: 0,
+ }).expect(409);
+ expect(
+ (
+ await ctx.prisma.employeeContractPeriod.findUniqueOrThrow({
+ where: { id: old.id },
+ })
+ ).revision,
+ ).toBe(0);
+ expect(await ctx.prisma.employeeContractPeriodAudit.count()).toBe(2);
+ });
+ it('serializes competing confirmations and stale supersessions across concurrent requests', async () => {
+ const competing = await Promise.all([
+ post(hrToken, facts),
+ post(hrToken, { ...facts, weeklyHours: 20 }),
+ ]);
+ expect(competing.map((r) => r.status).sort()).toEqual([201, 409]);
+ const old = competing.find((r) => r.status === 201)?.body;
+ const competingChanges = await Promise.all(
+ [20, 30].map((weeklyHours) =>
+ post(hrToken, {
+ ...facts,
+ effectiveFrom: '2026-07-01',
+ weeklyHours,
+ supersedesPeriodId: old.id,
+ expectedRevision: 0,
+ }),
+ ),
+ );
+ expect(competingChanges.map((r) => r.status).sort()).toEqual([201, 409]);
+ expect(await ctx.prisma.employeeContractPeriod.count()).toBe(2);
+ expect(await ctx.prisma.employeeContractPeriodAudit.count()).toBe(3);
+ });
+ it('rolls back both period boundaries and facts if audit persistence fails', async () => {
+ const old = (await post(hrToken, facts).expect(201)).body;
+ await ctx.prisma
+ .$executeRawUnsafe(`CREATE FUNCTION "test45FailContractAudit"() RETURNS trigger AS $$
+ BEGIN
+ IF NEW."action" = 'Confirmed' AND NEW."after"->>'weeklyHours' = '13' THEN
+ RAISE EXCEPTION 'Synthetic audit storage failure';
+ END IF;
+ RETURN NEW;
+ END; $$ LANGUAGE plpgsql`);
+ try {
+ await ctx.prisma.$executeRawUnsafe(
+ `CREATE TRIGGER "test45FailContractAudit" BEFORE INSERT ON "EmployeeContractPeriodAudit" FOR EACH ROW EXECUTE FUNCTION "test45FailContractAudit"()`,
+ );
+ await post(hrToken, {
+ ...facts,
+ effectiveFrom: '2026-07-01',
+ weeklyHours: 13,
+ supersedesPeriodId: old.id,
+ expectedRevision: 0,
+ }).expect(500);
+ const retained =
+ await ctx.prisma.employeeContractPeriod.findUniqueOrThrow({
+ where: { id: old.id },
+ });
+ expect(retained).toMatchObject({ effectiveTo: null, revision: 0 });
+ expect(retained.weeklyHours.toNumber()).toBe(40);
+ expect(await ctx.prisma.employeeContractPeriod.count()).toBe(1);
+ expect(await ctx.prisma.employeeContractPeriodAudit.count()).toBe(1);
+ } finally {
+ await ctx.prisma.$executeRawUnsafe(
+ `DROP TRIGGER IF EXISTS "test45FailContractAudit" ON "EmployeeContractPeriodAudit"`,
+ );
+ await ctx.prisma.$executeRawUnsafe(
+ `DROP FUNCTION "test45FailContractAudit"()`,
+ );
+ }
+ });
+ it('retains immutable schedule and contract facts after operational profile/schedule updates', async () => {
+ const schedule = await ctx.prisma.workSchedule.create({
+ data: {
+ name: 'Mutable schedule',
+ frameStart: '07:00',
+ frameEnd: '18:00',
+ workingDays: 31,
+ },
+ });
+ const saved = (await post(hrToken, facts).expect(201)).body;
+ await ctx.prisma.employee.update({
+ where: { id: employee.id },
+ data: {
+ weeklyHours: 12,
+ timeModel: 'Teilzeit',
+ startDate: new Date('2026-09-01'),
+ workScheduleId: schedule.id,
+ holidayCalendar: 'DE-BY',
+ holidayDates: ['2026-05-05'],
+ },
+ });
+ await ctx.prisma.workSchedule.update({
+ where: { id: schedule.id },
+ data: { workingDays: 15, name: 'Now a different schedule' },
+ });
+ const response = await ctx.http
+ .get(path())
+ .set('Authorization', `Bearer ${hrToken}`)
+ .expect(200);
+ expect(response.body).toEqual([saved]);
+ });
+});
diff --git a/apps/api-e2e/src/api/international-policies.e2e.spec.ts b/apps/api-e2e/src/api/international-policies.e2e.spec.ts
index b8aa6e1..a6b08d3 100644
--- a/apps/api-e2e/src/api/international-policies.e2e.spec.ts
+++ b/apps/api-e2e/src/api/international-policies.e2e.spec.ts
@@ -225,11 +225,15 @@ describe('International calendars and configurable break policies', () => {
// their historical whole-minute policy. Both use the captured deduction.
const reportGrossMinutes =
((persisted.clockOut as Date).getTime() - clockIn.getTime()) / 60_000;
- expect(report.body.totals).toEqual({
- grossMinutes: reportGrossMinutes,
- breakMinutes: 20,
- netMinutes: reportGrossMinutes - 20,
- });
+ // The period report now splits an overnight entry at local midnight.
+ // Summing proportional fragments can differ by one floating-point ULP;
+ // verify sub-microsecond precision, not an incidental addition order.
+ expect(report.body.totals.grossMinutes).toBeCloseTo(reportGrossMinutes, 8);
+ expect(report.body.totals.breakMinutes).toBeCloseTo(20, 8);
+ expect(report.body.totals.netMinutes).toBeCloseTo(
+ reportGrossMinutes - 20,
+ 8,
+ );
const exported = await ctx.http
.get('/api/erp/timeentries')
.set('X-API-Key', process.env.ERP_API_KEY ?? 'e2e-erp-key')
diff --git a/apps/api-e2e/src/api/request-revision.e2e.spec.ts b/apps/api-e2e/src/api/request-revision.e2e.spec.ts
new file mode 100644
index 0000000..177ea93
--- /dev/null
+++ b/apps/api-e2e/src/api/request-revision.e2e.spec.ts
@@ -0,0 +1,452 @@
+import { EmployeesService } from '../../../api/src/app/employees/employees.service';
+import {
+ createTestApp,
+ login,
+ seedEmployee,
+ seedLeaveAllowance,
+ type TestContext,
+} from '../support/test-app';
+
+const YEAR = 2026;
+const FROM = '2026-05-04T00:00:00.000Z';
+const TO = '2026-05-08T00:00:00.000Z';
+
+describe('Returned requests — owner revision and resubmission (#42)', () => {
+ let ctx: TestContext;
+ let owner: { id: string; token: string };
+ let manager: { id: string; token: string };
+ let other: { id: string; token: string };
+ let hr: { id: string; token: string };
+
+ beforeAll(async () => {
+ ctx = await createTestApp();
+ });
+ afterAll(async () => {
+ await ctx.close();
+ });
+ beforeEach(async () => {
+ await ctx.reset();
+ async function person(
+ personalNo: string,
+ role: 'Employee' | 'Manager' | 'HRAdmin',
+ managerId?: string,
+ ) {
+ const e = await seedEmployee(ctx.prisma, {
+ personalNo,
+ firstName: personalNo,
+ lastName: 'Example',
+ email: `${personalNo}@test.local`,
+ role,
+ managerId,
+ });
+ return { id: e.id, token: await login(ctx.http, e.email) };
+ }
+ manager = await person('manager', 'Manager');
+ owner = await person('owner', 'Employee', manager.id);
+ other = await person('other', 'Employee', manager.id);
+ hr = await person('hr', 'HRAdmin');
+ await seedLeaveAllowance(ctx.prisma, owner.id, YEAR, 5);
+ });
+
+ async function create(type = 'Vacation', substituteId?: string) {
+ const r = await ctx.http
+ .post(type === 'Vacation' ? '/api/requests/vacation' : '/api/requests')
+ .set('Authorization', `Bearer ${owner.token}`)
+ .send({
+ employeeId: owner.id,
+ type,
+ from: FROM,
+ to: TO,
+ substituteId,
+ reason: 'Original',
+ })
+ .expect(201);
+ return r.body;
+ }
+ async function returned(type = 'Vacation') {
+ const r = await create(type);
+ const response = await ctx.http
+ .post(`/api/requests/${r.id}/return`)
+ .set('Authorization', `Bearer ${manager.token}`)
+ .send({ actorId: manager.id, note: 'Bitte kürzen' })
+ .expect(201);
+ return response.body;
+ }
+ function values(r: { updatedAt: string }, extra = {}) {
+ return {
+ from: FROM,
+ to: TO,
+ reason: 'Korrigiert',
+ expectedUpdatedAt: r.updatedAt,
+ ...extra,
+ };
+ }
+ function patch(id: string, token = owner.token) {
+ return ctx.http
+ .patch(`/api/requests/${id}`)
+ .set('Authorization', `Bearer ${token}`);
+ }
+ function submit(id: string, token = owner.token) {
+ return ctx.http
+ .post(`/api/requests/${id}/resubmit`)
+ .set('Authorization', `Bearer ${token}`);
+ }
+
+ it('manager return → save → resubmit retains ID/history, excludes self, recalculates halves and resets assignments', async () => {
+ const r = await returned();
+ expect(r).toMatchObject({
+ workflowState: 'Draft',
+ status: 'Submitted',
+ employeeName: 'owner Example',
+ decisionNote: 'Bitte kürzen',
+ });
+ const saved = await patch(r.id)
+ .send(values(r, { halfDayStart: true }))
+ .expect(200);
+ expect(saved.body).toMatchObject({
+ id: r.id,
+ workflowState: 'Draft',
+ currentApproverId: null,
+ approverId: null,
+ calculatedDays: 4.5,
+ });
+ expect(saved.body.updatedAt).not.toBe(r.updatedAt);
+ // Old decision data cannot leak into the new approval cycle.
+ await ctx.prisma.request.update({
+ where: { id: r.id },
+ data: {
+ approverId: manager.id,
+ hrConfirmedAt: new Date(),
+ decidedAt: new Date(),
+ cancelledAt: new Date(),
+ substituteAcceptedAt: new Date(),
+ },
+ });
+ const fresh = await ctx.http
+ .get(`/api/requests/${r.id}`)
+ .set('Authorization', `Bearer ${owner.token}`)
+ .expect(200);
+ const result = await submit(r.id)
+ .send(values(fresh.body, { halfDayStart: true }))
+ .expect(201);
+ expect(result.body).toMatchObject({
+ id: r.id,
+ workflowState: 'PendingManager',
+ currentApproverId: manager.id,
+ calculatedDays: 4.5,
+ approverId: null,
+ substituteAcceptedAt: null,
+ hrConfirmedAt: null,
+ cancelledAt: null,
+ decidedAt: null,
+ decisionNote: null,
+ });
+ expect(await ctx.prisma.request.count()).toBe(1);
+ const events = await ctx.prisma.requestEvent.findMany({
+ where: { requestId: r.id },
+ orderBy: { occurredAt: 'asc' },
+ });
+ expect(events.map((e) => e.kind)).toEqual([
+ 'Submitted',
+ 'Returned',
+ 'Resubmitted',
+ ]);
+ expect(events[1].note).toBe('Bitte kürzen');
+ const listed = await ctx.http
+ .get(`/api/requests?currentApproverId=${manager.id}`)
+ .set('Authorization', `Bearer ${manager.token}`)
+ .expect(200);
+ expect(listed.body[0].employeeName).toBe('owner Example');
+ });
+
+ it('initializes a missing allowance inside the request transaction without blocking itself', async () => {
+ await ctx.prisma.employeeLeaveAllowance.deleteMany();
+ const r = await create();
+ expect(r.calculatedDays).toBe(5);
+ expect(await ctx.prisma.employeeLeaveAllowance.count()).toBe(1);
+ });
+
+ it('substitute decline → edit → resubmit restarts substitute approval and preserves the decline reason', async () => {
+ const r = await create('Vacation', other.id);
+ const declined = await ctx.http
+ .post(`/api/requests/${r.id}/substitute/decline`)
+ .set('Authorization', `Bearer ${other.token}`)
+ .send({ actorId: other.id, note: 'Bin selbst weg' })
+ .expect(201);
+ expect(declined.body).toMatchObject({
+ workflowState: 'Draft',
+ decisionNote: 'Bin selbst weg',
+ });
+ const result = await submit(r.id)
+ .send(values(declined.body, { substituteId: other.id, halfDayEnd: true }))
+ .expect(201);
+ expect(result.body).toMatchObject({
+ id: r.id,
+ workflowState: 'PendingSubstitute',
+ currentApproverId: null,
+ substituteId: other.id,
+ substituteAcceptedAt: null,
+ calculatedDays: 4.5,
+ });
+ await ctx.http
+ .post(`/api/requests/${r.id}/substitute/accept`)
+ .set('Authorization', `Bearer ${other.token}`)
+ .send({ actorId: other.id })
+ .expect(201);
+ const events = await ctx.prisma.requestEvent.findMany({
+ where: { requestId: r.id },
+ orderBy: { occurredAt: 'asc' },
+ });
+ expect(events.map((e) => e.kind)).toEqual([
+ 'Submitted',
+ 'SubstituteDeclined',
+ 'Resubmitted',
+ 'SubstituteAccepted',
+ ]);
+ });
+
+ it('forbids other employees, manager and HR; ignores spoofed owner identity', async () => {
+ const r = await returned();
+ for (const actor of [other, manager, hr]) {
+ await patch(r.id, actor.token)
+ .send(values(r, { employeeId: owner.id }))
+ .expect(403);
+ await submit(r.id, actor.token)
+ .send(values(r, { employeeId: owner.id }))
+ .expect(403);
+ }
+ await ctx.http.patch(`/api/requests/${r.id}`).send(values(r)).expect(401);
+ expect(await ctx.prisma.requestEvent.count()).toBe(2);
+ });
+
+ it.each([
+ 'Submitted',
+ 'PendingSubstitute',
+ 'PendingManager',
+ 'PendingHr',
+ 'Approved',
+ 'Rejected',
+ 'Cancelled',
+ ] as const)(
+ 'refuses editing/resubmitting %s even with legacy status Submitted',
+ async (state) => {
+ const r = await returned();
+ const current = await ctx.prisma.request.update({
+ where: { id: r.id },
+ data: { workflowState: state, status: 'Submitted' },
+ });
+ await patch(r.id)
+ .send(values({ updatedAt: current.updatedAt.toISOString() }))
+ .expect(409);
+ await submit(r.id)
+ .send(values({ updatedAt: current.updatedAt.toISOString() }))
+ .expect(409);
+ },
+ );
+
+ it('rechecks vacation balance on both save and resubmit; does not spend the allowance twice', async () => {
+ const r = await returned();
+ await seedLeaveAllowance(ctx.prisma, owner.id, YEAR, 0.5);
+ await patch(r.id).send(values(r)).expect(409);
+ await submit(r.id).send(values(r)).expect(409);
+ const result = await submit(r.id)
+ .send(values(r, { to: FROM, halfDayStart: true }))
+ .expect(201);
+ expect(result.body.calculatedDays).toBe(0.5);
+ });
+
+ it('excludes only its own overlap reservation, not another draft/active request', async () => {
+ const r = await returned();
+ await ctx.prisma.request.create({
+ data: {
+ employeeId: owner.id,
+ type: 'HomeOffice',
+ from: new Date(FROM),
+ to: new Date(FROM),
+ workflowState: 'Draft',
+ status: 'Submitted',
+ },
+ });
+ await patch(r.id).send(values(r)).expect(409);
+ await submit(r.id).send(values(r)).expect(409);
+ expect(
+ (await ctx.prisma.request.findUniqueOrThrow({ where: { id: r.id } }))
+ .workflowState,
+ ).toBe('Draft');
+ });
+
+ it('revalidates dates, zero workdays, substitute and required version', async () => {
+ const r = await returned();
+ for (const extra of [
+ { from: TO, to: FROM },
+ { to: '2027-01-01T00:00:00.000Z' },
+ { from: '2026-05-09T00:00:00.000Z', to: '2026-05-10T00:00:00.000Z' },
+ { substituteId: owner.id },
+ { substituteId: '00000000-0000-0000-0000-000000000001' },
+ { from: 'invalid' },
+ { expectedUpdatedAt: undefined },
+ ]) {
+ await submit(r.id).send(values(r, extra)).expect(400);
+ }
+ await ctx.prisma.employee.update({
+ where: { id: other.id },
+ data: { isActive: false },
+ });
+ await submit(r.id)
+ .send(values(r, { substituteId: other.id }))
+ .expect(400);
+ });
+
+ it('preserves SpecialLeave attachments and immutable type/owner', async () => {
+ const r = await returned('SpecialLeave');
+ const attachment = await ctx.prisma.requestAttachment.create({
+ data: {
+ requestId: r.id,
+ fileName: 'synthetic.pdf',
+ mimeType: 'application/pdf',
+ sizeBytes: 10,
+ storageKey: 'synthetic-only',
+ uploadedById: owner.id,
+ },
+ });
+ const result = await submit(r.id)
+ .send(values(r, { type: 'Vacation', employeeId: other.id }))
+ .expect(201);
+ expect(result.body).toMatchObject({
+ id: r.id,
+ type: 'SpecialLeave',
+ employeeId: owner.id,
+ });
+ expect(
+ await ctx.prisma.requestAttachment.findUnique({
+ where: { id: attachment.id },
+ }),
+ ).toMatchObject({ requestId: r.id, storageKey: 'synthetic-only' });
+ });
+
+ it('revalidates TimeAdjustment overlap and off-hours approval', async () => {
+ const r = await returned('TimeAdjustment');
+ const from = '2026-05-04T04:00:00.000Z'; // before the frame in UTC/Berlin
+ const to = '2026-05-04T08:00:00.000Z';
+ await ctx.prisma.timeEntry.create({
+ data: {
+ employeeId: owner.id,
+ clockIn: new Date(from),
+ clockOut: new Date(to),
+ source: 'Manual',
+ status: 'Approved',
+ },
+ });
+ await submit(r.id).send(values(r, { from, to })).expect(409);
+ await ctx.prisma.timeEntry.deleteMany();
+ await submit(r.id)
+ .send(values(r, { from, to: from }))
+ .expect(400);
+ const result = await submit(r.id).send(values(r, { from, to })).expect(201);
+ expect(result.body.requiresApproval).toBe(true);
+ });
+
+ it('rejects stale edits and simultaneous resubmissions with no duplicate event', async () => {
+ const r = await returned();
+ const changed = await patch(r.id)
+ .send(values(r, { reason: 'First tab' }))
+ .expect(200);
+ await patch(r.id)
+ .send(values(r, { reason: 'Stale tab' }))
+ .expect(409);
+ await submit(r.id).send(values(r)).expect(409);
+ const results = await Promise.all([
+ submit(r.id).send(values(changed.body)),
+ submit(r.id).send(values(changed.body)),
+ ]);
+ expect(results.map((res) => res.status).sort()).toEqual([201, 409]);
+ expect(
+ await ctx.prisma.requestEvent.count({
+ where: { requestId: r.id, kind: 'Resubmitted' },
+ }),
+ ).toBe(1);
+ });
+
+ it('does not let an in-flight decision overwrite a returned and resubmitted version (ABA)', async () => {
+ const r = await create();
+ const employees = ctx.app.get(EmployeesService);
+ const original = employees.getById.bind(employees);
+ let release!: () => void;
+ let arrived!: () => void;
+ const waiting = new Promise((resolve) => {
+ arrived = resolve;
+ });
+ const barrier = new Promise((resolve) => {
+ release = resolve;
+ });
+ let delayOnce = true;
+ const spy = jest
+ .spyOn(employees, 'getById')
+ .mockImplementation(async (id) => {
+ const employee = await original(id);
+ if (id === manager.id && delayOnce) {
+ delayOnce = false;
+ arrived();
+ await barrier;
+ }
+ return employee;
+ });
+ const oldDecision = ctx.http
+ .post(`/api/requests/${r.id}/manager-approve`)
+ .set('Authorization', `Bearer ${manager.token}`)
+ .send({ actorId: manager.id })
+ .then((res) => res);
+ try {
+ await waiting;
+ const revised = await ctx.http
+ .post(`/api/requests/${r.id}/return`)
+ .set('Authorization', `Bearer ${hr.token}`)
+ .send({ actorId: hr.id, note: 'Neue Angaben nötig' })
+ .expect(201);
+ await submit(r.id).send(values(revised.body)).expect(201);
+ release();
+ expect((await oldDecision).status).toBe(409);
+ expect(
+ (await ctx.prisma.request.findUniqueOrThrow({ where: { id: r.id } }))
+ .workflowState,
+ ).toBe('PendingManager');
+ expect(
+ await ctx.prisma.requestEvent.count({
+ where: { requestId: r.id, kind: 'ManagerApproved' },
+ }),
+ ).toBe(0);
+ } finally {
+ release();
+ spy.mockRestore();
+ }
+ });
+
+ it('serializes competing drafts so parallel submissions cannot overspend vacation', async () => {
+ const a = await returned();
+ const b = await ctx.prisma.request.create({
+ data: {
+ employeeId: owner.id,
+ type: 'Vacation',
+ from: new Date('2026-05-11'),
+ to: new Date('2026-05-15'),
+ workflowState: 'Draft',
+ calculatedDays: 5,
+ },
+ });
+ const results = await Promise.all([
+ submit(a.id).send(values(a)),
+ submit(b.id).send(
+ values(
+ { updatedAt: b.updatedAt.toISOString() },
+ { from: b.from.toISOString(), to: b.to.toISOString() },
+ ),
+ ),
+ ]);
+ expect(results.map((res) => res.status).sort()).toEqual([201, 409]);
+ expect(
+ await ctx.prisma.request.count({
+ where: { workflowState: 'PendingManager' },
+ }),
+ ).toBe(1);
+ });
+});
diff --git a/apps/api-e2e/src/api/targeted-time-corrections.e2e.spec.ts b/apps/api-e2e/src/api/targeted-time-corrections.e2e.spec.ts
new file mode 100644
index 0000000..321d15e
--- /dev/null
+++ b/apps/api-e2e/src/api/targeted-time-corrections.e2e.spec.ts
@@ -0,0 +1,1224 @@
+import { randomUUID } from 'node:crypto';
+import type { Prisma, TimeEntry } from '@prisma/client';
+import { EmployeesService } from '../../../api/src/app/employees/employees.service';
+import type { RequestDto } from '../../../api/src/app/requests/requests.dto';
+import { TimeAdjustmentsService } from '../../../api/src/app/requests/time-adjustments.service';
+import {
+ createTestApp,
+ login,
+ seedEmployee,
+ seedProject,
+ type TestContext,
+} from '../support/test-app';
+
+const IN = '2026-05-04T06:00:00.000Z'; // 08:00 Europe/Berlin
+const OUT = '2026-05-04T14:00:00.000Z'; // 16:00 Europe/Berlin
+const EXTENDED = '2026-05-04T15:00:00.000Z';
+const LATER = '2026-05-04T07:00:00.000Z';
+const EARLY = '2026-05-04T04:00:00.000Z'; // outside the explicit 07–18 frame
+const RULES = [
+ { afterMinutes: 360, breakMinutes: 30 },
+ { afterMinutes: 540, breakMinutes: 45 },
+];
+type Person = { id: string; token: string };
+type Body = Record;
+type EntryExtra = Partial;
+
+describe('Targeted TimeAdjustment corrections (#43)', () => {
+ let ctx: TestContext;
+ let owner: Person, manager: Person, other: Person, hr: Person;
+ let scheduleId: string;
+
+ beforeAll(async () => {
+ ctx = await createTestApp();
+ });
+ afterAll(async () => {
+ await ctx.close();
+ });
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+ beforeEach(async () => {
+ await ctx.reset();
+ const schedule = await ctx.prisma.workSchedule.create({
+ data: {
+ name: 'Correction frame',
+ frameStart: '07:00',
+ frameEnd: '18:00',
+ breakRules: RULES,
+ },
+ });
+ scheduleId = schedule.id;
+ async function person(
+ name: string,
+ role: 'Employee' | 'Manager' | 'HRAdmin',
+ managerId?: string,
+ ): Promise {
+ const row = await seedEmployee(ctx.prisma, {
+ personalNo: name,
+ firstName: name,
+ lastName: 'Example',
+ email: `${name}@test.local`,
+ role,
+ managerId,
+ workScheduleId: scheduleId,
+ });
+ return { id: row.id, token: await login(ctx.http, row.email) };
+ }
+ manager = await person('correction-manager', 'Manager');
+ owner = await person('correction-owner', 'Employee', manager.id);
+ other = await person('correction-other', 'Employee', manager.id);
+ hr = await person('correction-hr', 'HRAdmin');
+ await ctx.prisma.installationSettings.create({
+ data: { id: 1, mode: 'Team', setupCompleted: true },
+ });
+ });
+
+ function entry(extra: EntryExtra = {}): Promise {
+ return ctx.prisma.timeEntry.create({
+ data: {
+ employeeId: owner.id,
+ clockIn: new Date(IN),
+ clockOut: new Date(OUT),
+ source: 'Pwa',
+ status: 'Approved',
+ approvalMode: 'Team',
+ breakRules: RULES,
+ ...extra,
+ },
+ });
+ }
+ function postRequest(extra: Body = {}, who = owner) {
+ return ctx.http
+ .post('/api/requests')
+ .set('Authorization', `Bearer ${who.token}`)
+ .send({
+ employeeId: who.id,
+ type: 'TimeAdjustment',
+ from: IN,
+ to: EXTENDED,
+ reason: 'Correct recorded working time',
+ ...extra,
+ });
+ }
+ async function correction(row: TimeEntry, extra: Body = {}) {
+ const response = await postRequest({
+ targetTimeEntryId: row.id,
+ expectedTargetRevision: row.revision,
+ ...extra,
+ }).expect(201);
+ return response.body as RequestDto;
+ }
+ function decide(
+ id: string,
+ route = 'manager-approve',
+ who = manager,
+ extra: Body = {},
+ ) {
+ return ctx.http
+ .post(`/api/requests/${id}/${route}`)
+ .set('Authorization', `Bearer ${who.token}`)
+ .send({ actorId: who.id, note: 'Checked correction', ...extra });
+ }
+ function frameDecision(id: string, revision = 0, stage = 'Manager') {
+ const who = stage === 'HR' ? hr : manager;
+ return ctx.http
+ .post(`/api/timeentries/${id}/approval`)
+ .set('Authorization', `Bearer ${who.token}`)
+ .send({ revision, stage, outcome: 'Approve' });
+ }
+ function targets(who = owner) {
+ return ctx.http
+ .get('/api/requests/time-adjustment-targets')
+ .set('Authorization', `Bearer ${who.token}`);
+ }
+ function linkedAudit(id: string, who = owner) {
+ return ctx.http
+ .get(`/api/requests/${id}/time-entry-audit`)
+ .set('Authorization', `Bearer ${who.token}`);
+ }
+ function listEntries() {
+ return ctx.http
+ .get(`/api/timeentries?employeeId=${owner.id}`)
+ .set('Authorization', `Bearer ${owner.token}`);
+ }
+ function account() {
+ return ctx.http
+ .get(`/api/accounts/${owner.id}`)
+ .set('Authorization', `Bearer ${owner.token}`);
+ }
+ function currentEntry(id: string) {
+ return ctx.prisma.timeEntry.findUniqueOrThrow({ where: { id } });
+ }
+ function currentRequest(id: string) {
+ return ctx.prisma.request.findUniqueOrThrow({ where: { id } });
+ }
+ function events(id: string) {
+ return ctx.prisma.requestEvent.findMany({
+ where: { requestId: id },
+ orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }],
+ });
+ }
+ function audits(id: string) {
+ return ctx.prisma.timeEntryAudit.findMany({
+ where: { timeEntryId: id },
+ orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }],
+ });
+ }
+ async function returned(row: TimeEntry, extra: Body = {}) {
+ const request = await correction(row, extra);
+ const response = await decide(request.id, 'return', manager, {
+ note: 'Please explain the change',
+ }).expect(201);
+ expect(response.body.workflowState).toBe('Draft');
+ return response.body as RequestDto;
+ }
+ function draftValues(request: RequestDto, extra: Body = {}) {
+ return {
+ from: request.from,
+ to: request.to,
+ reason: 'Reason clarified without rebinding',
+ expectedUpdatedAt: request.updatedAt,
+ ...extra,
+ };
+ }
+ function save(request: RequestDto, extra: Body = {}) {
+ return ctx.http
+ .patch(`/api/requests/${request.id}`)
+ .set('Authorization', `Bearer ${owner.token}`)
+ .send(draftValues(request, extra));
+ }
+ function resubmit(request: RequestDto, extra: Body = {}) {
+ return ctx.http
+ .post(`/api/requests/${request.id}/resubmit`)
+ .set('Authorization', `Bearer ${owner.token}`)
+ .send(draftValues(request, extra));
+ }
+ async function unchangedDecisionState(id: string, timeEntryId: string) {
+ return {
+ request: await currentRequest(id),
+ entry: await currentEntry(timeEntryId),
+ events: await events(id),
+ audits: await audits(timeEntryId),
+ };
+ }
+
+ it.each([
+ {
+ label: '08–16 → 08–17',
+ from: IN,
+ to: EXTENDED,
+ gross: 540,
+ breaks: 45,
+ net: 495,
+ route: 'approve',
+ },
+ {
+ label: '08–16 → 09–16',
+ from: LATER,
+ to: OUT,
+ gross: 420,
+ breaks: 30,
+ net: 390,
+ route: 'manager-approve',
+ },
+ ])(
+ '$label updates the same ID once, preserves metadata/policy/history and recalculates views',
+ async ({ from, to, gross, breaks, net, route }) => {
+ const project = await seedProject(ctx.prisma, {
+ code: 'CORRECT',
+ assigneeIds: [owner.id],
+ serviceOrders: [{ orderNo: 'C-1', title: 'Existing order' }],
+ });
+ const group = randomUUID();
+ const row = await entry({
+ projectId: project.id,
+ serviceOrderId: project.serviceOrders[0].id,
+ status: route === 'manager-approve' ? 'Pending' : 'Approved',
+ approvalMode: route === 'manager-approve' ? null : 'Team',
+ activity: 'Original activity',
+ note: 'Original private note',
+ billable: true,
+ captureGroupId: group,
+ });
+ const originalAudit = await ctx.prisma.timeEntryAudit.create({
+ data: {
+ timeEntryId: row.id,
+ actorId: owner.id,
+ action: 'BookingCreated',
+ after: { clockIn: IN, clockOut: OUT },
+ },
+ });
+ const beforeAccount = (await account().expect(200)).body;
+ const request = await correction(row, { from, to });
+ expect(request).toMatchObject({
+ targetTimeEntryId: row.id,
+ expectedTargetRevision: 0,
+ requiresApproval: false,
+ workflowState: 'PendingManager',
+ targetTimeEntryBefore: {
+ id: row.id,
+ revision: 0,
+ clockIn: IN,
+ clockOut: OUT,
+ summary: { grossMinutes: 480, breakMinutes: 30, netMinutes: 450 },
+ },
+ });
+ expect(await currentEntry(row.id)).toEqual(row);
+ expect((await linkedAudit(request.id).expect(200)).body).toEqual([]);
+ // Current schedule changes must not replace the booked break-policy snapshot.
+ await ctx.prisma.workSchedule.update({
+ where: { id: scheduleId },
+ data: { breakRules: [] },
+ });
+ const started = Date.now();
+ const final = await decide(request.id, route).expect(201);
+ expect(final.body).toMatchObject({
+ id: request.id,
+ status: 'Approved',
+ workflowState: 'Approved',
+ hrConfirmedAt: null,
+ approverId: manager.id,
+ });
+ expect(final.body.targetTimeEntryBefore).toEqual(
+ request.targetTimeEntryBefore,
+ );
+ expect(await ctx.prisma.timeEntry.count()).toBe(1);
+ expect(await currentEntry(row.id)).toMatchObject({
+ id: row.id,
+ revision: 1,
+ clockIn: new Date(from),
+ clockOut: new Date(to),
+ projectId: row.projectId,
+ serviceOrderId: row.serviceOrderId,
+ activity: row.activity,
+ note: row.note,
+ billable: true,
+ captureGroupId: group,
+ breakRules: RULES,
+ source: 'Pwa',
+ approvalMode: row.approvalMode,
+ status: 'Approved',
+ });
+ const listed = (await listEntries().expect(200)).body;
+ expect(listed).toHaveLength(1);
+ expect(listed[0]).toMatchObject({
+ id: row.id,
+ summary: { grossMinutes: gross, breakMinutes: breaks, netMinutes: net },
+ });
+ const report = await ctx.http
+ .get(
+ `/api/reports/working-times?from=2026-05-04&to=2026-05-04&employeeId=${owner.id}`,
+ )
+ .set('Authorization', `Bearer ${hr.token}`)
+ .expect(200);
+ expect(report.body.rows).toHaveLength(1);
+ expect(report.body.rows[0].id).toBe(row.id);
+ expect(report.body.totals).toEqual({
+ grossMinutes: gross,
+ breakMinutes: breaks,
+ netMinutes: net,
+ });
+ const afterAccount = (await account().expect(200)).body;
+ expect(afterAccount.overtimeMinutes - beforeAccount.overtimeMinutes).toBe(
+ net - 450,
+ );
+ const history = await audits(row.id);
+ expect(history).toHaveLength(2);
+ expect(history.find((event) => event.id === originalAudit.id)).toEqual(
+ originalAudit,
+ );
+ const linked = (await linkedAudit(request.id).expect(200)).body;
+ expect(linked).toHaveLength(1);
+ expect(linked[0]).toMatchObject({
+ requestId: request.id,
+ timeEntryId: row.id,
+ actorId: manager.id,
+ action: 'FrameRequestCorrected',
+ reason: request.reason,
+ before: request.targetTimeEntryBefore,
+ after: {
+ id: row.id,
+ revision: 1,
+ clockIn: from,
+ clockOut: to,
+ summary: {
+ grossMinutes: gross,
+ breakMinutes: breaks,
+ netMinutes: net,
+ },
+ },
+ });
+ expect(Date.parse(linked[0].occurredAt)).toBeGreaterThanOrEqual(started);
+ expect(Date.parse(linked[0].occurredAt)).toBeLessThanOrEqual(Date.now());
+ expect((await events(request.id)).map((event) => event.kind)).toEqual([
+ 'Submitted',
+ 'ManagerApproved',
+ ]);
+ },
+ );
+
+ it('captures the server original, ignores forged before/owner, and authorizes linked audit by JWT', async () => {
+ const row = await entry();
+ const request = await correction(row, {
+ employeeId: other.id,
+ targetTimeEntryBefore: { id: other.id, revision: 999, clockIn: EARLY },
+ });
+ expect(request).toMatchObject({
+ employeeId: owner.id,
+ targetTimeEntryBefore: {
+ id: row.id,
+ revision: 0,
+ clockIn: IN,
+ clockOut: OUT,
+ },
+ });
+ expect((await currentRequest(request.id)).targetTimeEntryBefore).toEqual(
+ request.targetTimeEntryBefore,
+ );
+ await decide(request.id, 'manager-approve', manager, {
+ actorId: hr.id,
+ }).expect(201);
+ const linked = (await linkedAudit(request.id, manager).expect(200)).body;
+ expect(linked[0].actorId).toBe(manager.id);
+ expect((await linkedAudit(request.id, hr).expect(200)).body).toEqual(
+ linked,
+ );
+ await linkedAudit(request.id, other).expect(403);
+ await ctx.http
+ .get(`/api/requests/${request.id}/time-entry-audit`)
+ .expect(401);
+ });
+
+ it('retains the omitted-pair missing-booking flow and links its newly added entry audit', async () => {
+ const response = await postRequest({ to: OUT }).expect(201);
+ const request = response.body as RequestDto;
+ expect(request).toMatchObject({
+ targetTimeEntryId: null,
+ expectedTargetRevision: null,
+ targetTimeEntryBefore: null,
+ });
+ expect(await ctx.prisma.timeEntry.count()).toBe(0);
+ await decide(request.id, 'approve').expect(201);
+ expect(await ctx.prisma.timeEntry.count()).toBe(1);
+ const rows = await ctx.prisma.timeEntry.findMany();
+ expect(rows[0]).toMatchObject({
+ employeeId: owner.id,
+ clockIn: new Date(IN),
+ clockOut: new Date(OUT),
+ source: 'Manual',
+ status: 'Approved',
+ approvalMode: 'Team',
+ breakRules: RULES,
+ });
+ expect((await linkedAudit(request.id).expect(200)).body).toEqual([
+ expect.objectContaining({
+ requestId: request.id,
+ timeEntryId: rows[0].id,
+ actorId: manager.id,
+ action: 'RequestMissingBookingAdded',
+ before: null,
+ after: expect.objectContaining({ clockIn: IN, clockOut: OUT }),
+ }),
+ ]);
+ });
+
+ it('lists only own closed nonvoided Pending/Approved Team or legacy-null targets', async () => {
+ const eligible = [
+ await entry(),
+ await entry({
+ status: 'Pending',
+ requiresApproval: true,
+ clockIn: new Date('2026-05-05T06:00:00Z'),
+ clockOut: new Date('2026-05-05T14:00:00Z'),
+ }),
+ await entry({
+ approvalMode: null,
+ clockIn: new Date('2026-05-06T06:00:00Z'),
+ clockOut: new Date('2026-05-06T14:00:00Z'),
+ }),
+ ];
+ await entry({ employeeId: other.id });
+ await entry({ clockOut: null, status: 'Open' });
+ await entry({ voidedAt: new Date() });
+ await entry({ status: 'Rejected' });
+ await entry({
+ approvalMode: 'Solo',
+ clockIn: new Date('2026-05-07T06:00:00Z'),
+ clockOut: new Date('2026-05-07T14:00:00Z'),
+ });
+ const expected = eligible.map((row) => row.id).sort();
+ for (const legacyInstallation of [false, true]) {
+ if (legacyInstallation)
+ await ctx.prisma.installationSettings.deleteMany();
+ const response = await targets().expect(200);
+ expect(response.body.map((row: { id: string }) => row.id).sort()).toEqual(
+ expected,
+ );
+ expect(response.body[0].summary).toEqual({
+ grossMinutes: 480,
+ breakMinutes: 30,
+ netMinutes: 450,
+ });
+ }
+ });
+
+ it('uses a static GET, takes listing identity only from JWT, and validates the date filter', async () => {
+ const own = await entry();
+ await entry({ employeeId: other.id });
+ const next = await entry({
+ clockIn: new Date('2026-05-05T06:00:00Z'),
+ clockOut: new Date('2026-05-05T14:00:00Z'),
+ });
+ await ctx.http.get('/api/requests/time-adjustment-targets').expect(401);
+ const listed = await targets()
+ .query({ employeeId: other.id, from: IN, to: OUT })
+ .send({ employeeId: other.id })
+ .expect(200);
+ expect(listed.body.map((row: { id: string }) => row.id)).toEqual([own.id]);
+ expect(
+ (await targets().query({ from: OUT }).expect(200)).body.map(
+ (row: { id: string }) => row.id,
+ ),
+ ).toEqual([next.id]);
+ await targets().query({ from: 'not-a-date' }).expect(400);
+ await targets().query({ from: OUT, to: IN }).expect(400);
+ await ctx.http
+ .post('/api/requests/time-adjustment-targets')
+ .set('Authorization', `Bearer ${owner.token}`)
+ .send({})
+ .expect(404);
+ });
+
+ it('keeps Solo direct owner correction separate and never exposes Team requests/targets in Solo', async () => {
+ const row = await entry({ approvalMode: 'Solo' });
+ const archived = await entry({
+ clockIn: new Date('2026-05-05T06:00:00Z'),
+ clockOut: new Date('2026-05-05T14:00:00Z'),
+ });
+ const pending = await correction(archived, {
+ from: '2026-05-05T06:00:00.000Z',
+ to: '2026-05-05T15:00:00.000Z',
+ });
+ await ctx.prisma.employee.update({
+ where: { id: owner.id },
+ data: { role: 'HRAdmin' },
+ });
+ await ctx.prisma.installationSettings.update({
+ where: { id: 1 },
+ data: { mode: 'Solo', ownerEmployeeId: owner.id },
+ });
+ await targets().expect(403);
+ await targets(hr).expect(403);
+ await postRequest().expect(403);
+ await decide(pending.id).expect(403);
+ await ctx.http
+ .patch(`/api/timeentries/${row.id}/correct`)
+ .set('Authorization', `Bearer ${owner.token}`)
+ .send({
+ clockIn: IN,
+ clockOut: EXTENDED,
+ revision: 0,
+ reason: 'Solo owner fixes a forgotten hour',
+ })
+ .expect(200);
+ expect(await currentEntry(row.id)).toMatchObject({
+ id: row.id,
+ revision: 1,
+ clockOut: new Date(EXTENDED),
+ status: 'Approved',
+ approvalMode: 'Solo',
+ requiresApproval: false,
+ });
+ expect((await audits(row.id)).map((event) => event.action)).toEqual([
+ 'Corrected',
+ ]);
+ expect(await currentEntry(archived.id)).toEqual(archived);
+ expect((await currentRequest(pending.id)).workflowState).toBe(
+ 'PendingManager',
+ );
+ });
+
+ it('rejects foreign and nonexistent targets even if employeeId is forged', async () => {
+ const foreign = await entry({ employeeId: other.id });
+ for (const targetTimeEntryId of [foreign.id, randomUUID()]) {
+ await postRequest({
+ targetTimeEntryId,
+ expectedTargetRevision: 0,
+ employeeId: other.id,
+ }).expect(403);
+ }
+ expect(await currentEntry(foreign.id)).toEqual(foreign);
+ expect(await ctx.prisma.request.count()).toBe(0);
+ expect(await ctx.prisma.timeEntryAudit.count()).toBe(0);
+ });
+
+ it('rejects open, voided, rejected and Solo target bindings without a request/event/audit', async () => {
+ const rows = [
+ await entry({ status: 'Open', clockOut: null }),
+ await entry({ voidedAt: new Date() }),
+ await entry({ status: 'Rejected' }),
+ await entry({ approvalMode: 'Solo' }),
+ ];
+ for (const row of rows) {
+ await postRequest({
+ targetTimeEntryId: row.id,
+ expectedTargetRevision: 0,
+ }).expect(409);
+ expect(await currentEntry(row.id)).toEqual(row);
+ }
+ expect(await ctx.prisma.request.count()).toBe(0);
+ expect(await ctx.prisma.requestEvent.count()).toBe(0);
+ expect(await ctx.prisma.timeEntryAudit.count()).toBe(0);
+ });
+
+ it('requires a complete target/version pair, validates versions, and rejects stale originals', async () => {
+ const row = await entry();
+ for (const binding of [
+ { targetTimeEntryId: row.id },
+ { expectedTargetRevision: 0 },
+ { targetTimeEntryId: row.id, expectedTargetRevision: null },
+ { targetTimeEntryId: null, expectedTargetRevision: 0 },
+ { targetTimeEntryId: row.id, expectedTargetRevision: -1 },
+ { targetTimeEntryId: row.id, expectedTargetRevision: 0.5 },
+ { targetTimeEntryId: row.id, expectedTargetRevision: 'invalid' },
+ { targetTimeEntryId: 'not-a-uuid', expectedTargetRevision: 0 },
+ ])
+ await postRequest(binding).expect(400);
+ await postRequest({
+ targetTimeEntryId: row.id,
+ expectedTargetRevision: 1,
+ }).expect(409);
+ expect(await currentEntry(row.id)).toEqual(row);
+ expect(await ctx.prisma.request.count()).toBe(0);
+ });
+
+ it('forbids correction fields on non-TimeAdjustment requests, including the vacation endpoint', async () => {
+ const row = await entry();
+ for (const type of ['HomeOffice', 'SpecialLeave', 'Vacation']) {
+ await postRequest({
+ type,
+ targetTimeEntryId: row.id,
+ expectedTargetRevision: 0,
+ }).expect(400);
+ }
+ await ctx.http
+ .post('/api/requests/vacation')
+ .set('Authorization', `Bearer ${owner.token}`)
+ .send({
+ employeeId: owner.id,
+ from: IN,
+ to: OUT,
+ targetTimeEntryId: row.id,
+ expectedTargetRevision: 0,
+ })
+ .expect(400);
+ expect(await ctx.prisma.request.count()).toBe(0);
+ });
+
+ it('exempts precisely the unchanged target ID, never another same-day booking', async () => {
+ const row = await entry();
+ const adjacent = await entry({
+ clockIn: new Date(OUT),
+ clockOut: new Date(EXTENDED),
+ });
+ await postRequest({
+ targetTimeEntryId: row.id,
+ expectedTargetRevision: 0,
+ }).expect(409);
+ // Keeping the original interval is legal; the adjacent booking is not overlapping.
+ const request = await correction(row, { to: OUT });
+ await decide(request.id).expect(201);
+ expect(await currentEntry(adjacent.id)).toEqual(adjacent);
+ expect(await ctx.prisma.timeEntry.count()).toBe(2);
+ expect(await currentEntry(row.id)).toMatchObject({
+ revision: 1,
+ clockOut: new Date(OUT),
+ });
+ });
+
+ it('does not exempt overlaps for missing-booking requests with omitted or explicitly null binding', async () => {
+ const row = await entry();
+ for (const binding of [
+ {},
+ { targetTimeEntryId: null, expectedTargetRevision: null },
+ ]) {
+ await postRequest(binding).expect(409);
+ }
+ expect(await currentEntry(row.id)).toEqual(row);
+ expect(await ctx.prisma.request.count()).toBe(0);
+ });
+
+ it.each(['approve', 'manager-approve'])(
+ '%s forces outside-frame proposed values through manager → HR and materializes only at the final stage',
+ async (route) => {
+ const row = await entry();
+ const from = route === 'approve' ? IN : EARLY;
+ const request = await correction(row, { from });
+ if (route === 'approve') {
+ expect(request.requiresApproval).toBe(false);
+ // Re-evaluate proposed values against the current frame, not only the
+ // stored request flag or the target's old approval state.
+ await ctx.prisma.workSchedule.update({
+ where: { id: scheduleId },
+ data: { frameEnd: '16:00' },
+ });
+ } else expect(request.requiresApproval).toBe(true);
+ await decide(request.id, 'hr-confirm', hr).expect(409);
+ const managed = await decide(request.id, route, manager, {
+ requiresHrConfirmation: false,
+ }).expect(201);
+ expect(managed.body).toMatchObject({
+ status: 'Submitted',
+ workflowState: 'PendingHr',
+ requiresApproval: true,
+ approverId: manager.id,
+ hrConfirmedAt: null,
+ });
+ expect(await currentEntry(row.id)).toEqual(row);
+ expect(await audits(row.id)).toEqual([]);
+ await decide(request.id, 'hr-confirm', owner).expect(403);
+ await decide(request.id, 'hr-confirm', manager).expect(403);
+ const final = await decide(request.id, 'hr-confirm', hr).expect(201);
+ expect(final.body).toMatchObject({
+ status: 'Approved',
+ workflowState: 'Approved',
+ });
+ expect(final.body.hrConfirmedAt).not.toBeNull();
+ expect(await currentEntry(row.id)).toMatchObject({
+ id: row.id,
+ revision: 1,
+ clockIn: new Date(from),
+ clockOut: new Date(EXTENDED),
+ status: 'Approved',
+ requiresApproval: true,
+ frameApproverId: null,
+ frameApprovedRevision: null,
+ });
+ expect(await ctx.prisma.timeEntry.count()).toBe(1);
+ expect((await events(request.id)).map((event) => event.kind)).toEqual([
+ 'Submitted',
+ 'ManagerApproved',
+ 'HrConfirmed',
+ ]);
+ expect((await linkedAudit(request.id).expect(200)).body).toEqual([
+ expect.objectContaining({
+ requestId: request.id,
+ actorId: hr.id,
+ before: expect.objectContaining({ clockIn: IN, clockOut: OUT }),
+ after: expect.objectContaining({ clockIn: from, clockOut: EXTENDED }),
+ }),
+ ]);
+ },
+ );
+
+ it('never reuses #41 approval of old values to authorize a changed outside-frame interval', async () => {
+ const row = await entry({
+ clockIn: new Date(EARLY),
+ status: 'Pending',
+ requiresApproval: true,
+ });
+ await frameDecision(row.id).expect(201);
+ await frameDecision(row.id, 1, 'HR').expect(201);
+ const approved = await currentEntry(row.id);
+ const oldAudits = await audits(row.id);
+ expect(approved).toMatchObject({ revision: 2, status: 'Approved' });
+ const changedFrom = '2026-05-04T03:00:00.000Z';
+ const request = await correction(approved, { from: changedFrom });
+ expect(request.requiresApproval).toBe(true);
+ await decide(request.id, 'hr-confirm', hr).expect(409);
+ await decide(request.id).expect(201);
+ expect((await currentRequest(request.id)).workflowState).toBe('PendingHr');
+ expect(await currentEntry(row.id)).toEqual(approved);
+ expect(await audits(row.id)).toEqual(oldAudits);
+ await decide(request.id, 'hr-confirm', hr).expect(201);
+ expect(await currentEntry(row.id)).toMatchObject({
+ revision: 3,
+ clockIn: new Date(changedFrom),
+ frameApproverId: null,
+ frameApprovedRevision: null,
+ });
+ for (const audit of oldAudits) {
+ expect(
+ (await audits(row.id)).find((event) => event.id === audit.id),
+ ).toEqual(audit);
+ }
+ await frameDecision(row.id, 2, 'HR').expect(409);
+ const history = await ctx.http
+ .get(`/api/timeentries/${row.id}/approval-history`)
+ .set('Authorization', `Bearer ${owner.token}`)
+ .expect(200);
+ expect(history.body).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ requestId: request.id,
+ action: 'FrameRequestCorrected',
+ }),
+ ]),
+ );
+ });
+
+ it('return → reason-only save/resubmit retains binding, version, exact before snapshot and ID', async () => {
+ const row = await entry();
+ const request = await returned(row);
+ const saved = await save(request, {
+ targetTimeEntryBefore: { clockIn: EARLY, revision: 99 },
+ }).expect(200);
+ expect(saved.body).toMatchObject({
+ id: request.id,
+ workflowState: 'Draft',
+ targetTimeEntryId: row.id,
+ expectedTargetRevision: 0,
+ targetTimeEntryBefore: request.targetTimeEntryBefore,
+ });
+ const submitted = await resubmit(saved.body).expect(201);
+ expect(submitted.body).toMatchObject({
+ id: request.id,
+ workflowState: 'PendingManager',
+ targetTimeEntryId: row.id,
+ expectedTargetRevision: 0,
+ targetTimeEntryBefore: request.targetTimeEntryBefore,
+ });
+ expect(await currentEntry(row.id)).toEqual(row);
+ await decide(request.id).expect(201);
+ expect(await ctx.prisma.request.count()).toBe(1);
+ expect((await events(request.id)).map((event) => event.kind)).toEqual([
+ 'Submitted',
+ 'Returned',
+ 'Resubmitted',
+ 'ManagerApproved',
+ ]);
+ });
+
+ it('denies rebinding, removing the target, or changing the expected revision on both draft paths', async () => {
+ const row = await entry();
+ const alternative = await entry({
+ clockIn: new Date('2026-05-05T06:00:00Z'),
+ clockOut: new Date('2026-05-05T14:00:00Z'),
+ });
+ const request = await returned(row);
+ for (const extra of [
+ { targetTimeEntryId: alternative.id, expectedTargetRevision: 0 },
+ { targetTimeEntryId: null, expectedTargetRevision: null },
+ { expectedTargetRevision: 1 },
+ ]) {
+ await save(request, extra).expect(400);
+ await resubmit(request, extra).expect(400);
+ }
+ expect(await currentRequest(request.id)).toMatchObject({
+ workflowState: 'Draft',
+ targetTimeEntryId: row.id,
+ expectedTargetRevision: 0,
+ targetTimeEntryBefore: request.targetTimeEntryBefore,
+ });
+ expect((await events(request.id)).map((event) => event.kind)).toEqual([
+ 'Submitted',
+ 'Returned',
+ ]);
+ });
+
+ it('rejects a stale target after return with 409 instead of silently refreshing the original snapshot', async () => {
+ const row = await entry();
+ const request = await returned(row);
+ await ctx.http
+ .patch(`/api/timeentries/${row.id}`)
+ .set('Authorization', `Bearer ${owner.token}`)
+ .send({ revision: 0, note: 'Changed after return' })
+ .expect(200);
+ const before = await unchangedDecisionState(request.id, row.id);
+ await save(request).expect(409);
+ await resubmit(request).expect(409);
+ expect(await unchangedDecisionState(request.id, row.id)).toEqual(before);
+ expect(before.request.targetTimeEntryBefore).toEqual(
+ request.targetTimeEntryBefore,
+ );
+ });
+
+ it('round-trips original/proposed DST spring-gap and repeated-fold instants with seconds through #42 revision and final correction', async () => {
+ const cases = [
+ {
+ originalIn: '2026-03-29T01:59:17.123+01:00',
+ originalOut: '2026-03-29T03:00:01.456+02:00',
+ from: '2026-03-29T01:59:37.123+01:00',
+ to: '2026-03-29T03:00:41.456+02:00',
+ },
+ {
+ originalIn: '2026-10-25T02:15:17.123+02:00',
+ originalOut: '2026-10-25T02:45:41.456+01:00',
+ from: '2026-10-25T02:20:31.987+02:00',
+ to: '2026-10-25T02:55:51.654+01:00',
+ },
+ ];
+ for (const values of cases) {
+ const row = await entry({
+ clockIn: new Date(values.originalIn),
+ clockOut: new Date(values.originalOut),
+ });
+ const original = {
+ clockIn: new Date(values.originalIn).toISOString(),
+ clockOut: new Date(values.originalOut).toISOString(),
+ };
+ const proposed = {
+ from: new Date(values.from).toISOString(),
+ to: new Date(values.to).toISOString(),
+ };
+ const request = await returned(row, { from: values.from, to: values.to });
+ expect(request.targetTimeEntryBefore).toMatchObject(original);
+ const saved = await save(request).expect(200);
+ expect(saved.body).toMatchObject(proposed);
+ const submitted = await resubmit(saved.body).expect(201);
+ expect(submitted.body).toMatchObject({
+ ...proposed,
+ targetTimeEntryBefore: request.targetTimeEntryBefore,
+ });
+ await decide(request.id).expect(201);
+ expect(await currentEntry(row.id)).toEqual(row);
+ await decide(request.id, 'hr-confirm', hr).expect(201);
+ const persisted = await currentEntry(row.id);
+ expect(persisted.clockIn.toISOString()).toBe(proposed.from);
+ expect(persisted.clockOut?.toISOString()).toBe(proposed.to);
+ const linked = (await linkedAudit(request.id).expect(200)).body[0];
+ expect(linked.before).toMatchObject(original);
+ expect(linked.after).toMatchObject({
+ clockIn: proposed.from,
+ clockOut: proposed.to,
+ });
+ // Existing ungrouped Team summaries bill/report whole minutes.
+ // Persisted timestamps above must still retain every millisecond.
+ expect(linked.after.summary.grossMinutes).toBe(
+ Math.floor(
+ (Date.parse(proposed.to) - Date.parse(proposed.from)) / 60_000,
+ ),
+ );
+ }
+ });
+
+ it.each(['Manager', 'HR'])(
+ 'a target changed before final %s approval leaves pending request, entry, events and audits untouched',
+ async (stage) => {
+ const row = await entry();
+ const request = await correction(
+ row,
+ stage === 'HR' ? { from: EARLY } : {},
+ );
+ if (stage === 'HR') await decide(request.id).expect(201);
+ await ctx.http
+ .patch(`/api/timeentries/${row.id}`)
+ .set('Authorization', `Bearer ${owner.token}`)
+ .send({ revision: 0, activity: 'New target revision' })
+ .expect(200);
+ const before = await unchangedDecisionState(request.id, row.id);
+ await decide(
+ request.id,
+ stage === 'HR' ? 'hr-confirm' : 'manager-approve',
+ stage === 'HR' ? hr : manager,
+ ).expect(409);
+ expect(await unchangedDecisionState(request.id, row.id)).toEqual(before);
+ expect(before.request.workflowState).toBe(
+ stage === 'HR' ? 'PendingHr' : 'PendingManager',
+ );
+ },
+ );
+
+ it.each(['metadata', 'split', 'frame approval'])(
+ '%s increments the target revision and invalidates an outstanding correction',
+ async (operation) => {
+ const row = await entry({
+ clockIn: new Date(EARLY),
+ status: 'Pending',
+ requiresApproval: true,
+ });
+ const request = await correction(row, { from: EARLY });
+ if (operation === 'metadata') {
+ await ctx.http
+ .patch(`/api/timeentries/${row.id}`)
+ .set('Authorization', `Bearer ${owner.token}`)
+ .send({ revision: 0, activity: 'Revised metadata' })
+ .expect(200);
+ } else if (operation === 'split') {
+ await ctx.http
+ .post(`/api/timeentries/${row.id}/split`)
+ .set('Authorization', `Bearer ${owner.token}`)
+ .send({ revision: 0, at: '2026-05-04T10:00:00Z' })
+ .expect(201);
+ } else await frameDecision(row.id).expect(201);
+ expect((await currentEntry(row.id)).revision).toBe(1);
+ const before = await unchangedDecisionState(request.id, row.id);
+ await decide(request.id).expect(409);
+ expect(await unchangedDecisionState(request.id, row.id)).toEqual(before);
+ },
+ );
+
+ it('serializes two disjoint correction requests for revision 0: exactly one final approval wins', async () => {
+ const row = await entry();
+ // Disjoint proposals deliberately avoid the independent request-overlap guard.
+ const a = await correction(row, {
+ from: '2026-05-05T06:00:00.000Z',
+ to: '2026-05-05T14:00:00.000Z',
+ });
+ const b = await correction(row, {
+ from: '2026-05-06T06:00:00.000Z',
+ to: '2026-05-06T14:00:00.000Z',
+ });
+ expect(a.expectedTargetRevision).toBe(0);
+ expect(b.expectedTargetRevision).toBe(0);
+ const replies = await Promise.all([decide(a.id), decide(b.id)]);
+ expect(replies.map((reply) => reply.status).sort()).toEqual([201, 409]);
+ const winner = replies[0].status === 201 ? a : b;
+ const loser = winner.id === a.id ? b : a;
+ expect(await currentEntry(row.id)).toMatchObject({
+ id: row.id,
+ revision: 1,
+ clockIn: new Date(winner.from),
+ clockOut: new Date(winner.to),
+ });
+ expect((await currentRequest(winner.id)).workflowState).toBe('Approved');
+ expect((await currentRequest(loser.id)).workflowState).toBe(
+ 'PendingManager',
+ );
+ expect((await events(loser.id)).map((event) => event.kind)).toEqual([
+ 'Submitted',
+ ]);
+ expect(await ctx.prisma.timeEntry.count()).toBe(1);
+ expect(await audits(row.id)).toEqual([
+ expect.objectContaining({ requestId: winner.id }),
+ ]);
+ });
+
+ it('rejects repeated final approvals without duplicate entry changes, events or linked audits', async () => {
+ const row = await entry();
+ const request = await correction(row);
+ await decide(request.id, 'approve').expect(201);
+ const before = await unchangedDecisionState(request.id, row.id);
+ // Legacy assignment guards may return 403 before the state guard returns 409.
+ for (const route of ['approve', 'manager-approve']) {
+ const response = await decide(request.id, route);
+ expect([403, 409]).toContain(response.status);
+ }
+ await decide(request.id, 'cancel', owner).expect(409);
+ expect(await unchangedDecisionState(request.id, row.id)).toEqual(before);
+ expect(
+ before.events.filter((event) => event.kind === 'ManagerApproved'),
+ ).toHaveLength(1);
+ expect(before.audits).toHaveLength(1);
+ expect(await ctx.prisma.timeEntry.count()).toBe(1);
+ });
+
+ it.each(['metadata', 'split', 'frame approval'])(
+ 'an in-flight final correction cannot overwrite a concurrent %s mutation',
+ async (operation) => {
+ const row = await entry({
+ clockIn: new Date(EARLY),
+ status: 'Pending',
+ requiresApproval: true,
+ });
+ // Move proposed values into the frame so manager approval would otherwise be final.
+ const request = await correction(row);
+ expect(request.requiresApproval).toBe(false);
+ const employees = ctx.app.get(EmployeesService);
+ const getById = employees.getById.bind(employees);
+ let release!: () => void;
+ let arrived!: () => void;
+ const waiting = new Promise((resolve) => {
+ arrived = resolve;
+ });
+ const barrier = new Promise((resolve) => {
+ release = resolve;
+ });
+ let delayOnce = true;
+ const spy = jest
+ .spyOn(employees, 'getById')
+ .mockImplementation(async (id) => {
+ const result = await getById(id);
+ if (id === manager.id && delayOnce) {
+ delayOnce = false;
+ arrived();
+ await barrier;
+ }
+ return result;
+ });
+ // Start HTTP immediately; the barrier pauses after its original request was read.
+ const inflight = decide(request.id).then((response) => response);
+ try {
+ await waiting;
+ if (operation === 'metadata') {
+ await ctx.http
+ .patch(`/api/timeentries/${row.id}`)
+ .set('Authorization', `Bearer ${owner.token}`)
+ .send({ revision: 0, note: 'Concurrent owner edit' })
+ .expect(200);
+ } else if (operation === 'split') {
+ await ctx.http
+ .post(`/api/timeentries/${row.id}/split`)
+ .set('Authorization', `Bearer ${owner.token}`)
+ .send({ revision: 0, at: '2026-05-04T10:00:00Z' })
+ .expect(201);
+ } else await frameDecision(row.id).expect(201);
+ const before = await unchangedDecisionState(request.id, row.id);
+ const allEntries = await ctx.prisma.timeEntry.findMany({
+ orderBy: { id: 'asc' },
+ });
+ release();
+ expect((await inflight).status).toBe(409);
+ expect(await unchangedDecisionState(request.id, row.id)).toEqual(
+ before,
+ );
+ expect(
+ await ctx.prisma.timeEntry.findMany({ orderBy: { id: 'asc' } }),
+ ).toEqual(allEntries);
+ expect(before.request.workflowState).toBe('PendingManager');
+ expect(before.entry.revision).toBe(1);
+ } finally {
+ release();
+ await inflight;
+ spy.mockRestore();
+ }
+ },
+ );
+
+ it.each(['approve', 'manager-approve', 'hr-confirm'])(
+ '%s rolls back request state/event and entry/audit if application fails after the entry changed inside the transaction',
+ async (route) => {
+ const row = await entry();
+ const request = await correction(
+ row,
+ route === 'hr-confirm' ? { from: EARLY } : {},
+ );
+ if (route === 'hr-confirm') await decide(request.id).expect(201);
+ const before = await unchangedDecisionState(request.id, row.id);
+ const service = ctx.app.get(TimeAdjustmentsService);
+ const apply = service.apply.bind(service);
+ let changedInsideTransaction = false;
+ const spy = jest
+ .spyOn(service, 'apply')
+ .mockImplementation(async (tx, value, actorId, requiresApproval) => {
+ const changed = await apply(tx, value, actorId, requiresApproval);
+ expect(changed.revision).toBe(row.revision + 1);
+ expect(changed.clockOut?.toISOString()).toBe(EXTENDED);
+ expect(
+ await tx.timeEntryAudit.count({ where: { requestId: request.id } }),
+ ).toBe(1);
+ changedInsideTransaction = true;
+ throw new Error(
+ 'Deliberate regression-test failure after entry and audit writes',
+ );
+ });
+ try {
+ // createTestApp uses logger:false; this intentional error must reach HTTP as 500.
+ await decide(
+ request.id,
+ route,
+ route === 'hr-confirm' ? hr : manager,
+ ).expect(500);
+ expect(changedInsideTransaction).toBe(true);
+ } finally {
+ spy.mockRestore();
+ }
+ expect(await unchangedDecisionState(request.id, row.id)).toEqual(before);
+ expect(await ctx.prisma.timeEntry.count()).toBe(1);
+ expect((await linkedAudit(request.id).expect(200)).body).toEqual([]);
+ // The same request/version remains usable after the failed transaction.
+ await decide(
+ request.id,
+ route,
+ route === 'hr-confirm' ? hr : manager,
+ ).expect(201);
+ expect(await audits(row.id)).toHaveLength(1);
+ expect((await currentEntry(row.id)).revision).toBe(1);
+ },
+ );
+
+ it('preserves a grouped capture policy and snapshots the full capture summary, not just the target segment', async () => {
+ const captureGroupId = randomUUID();
+ const row = await entry({
+ clockOut: new Date('2026-05-04T10:00:00Z'),
+ captureGroupId,
+ });
+ const sibling = await entry({
+ clockIn: new Date('2026-05-04T10:00:00Z'),
+ captureGroupId,
+ });
+ const request = await correction(row, {
+ from: '2026-05-04T05:00:00.000Z',
+ to: '2026-05-04T10:00:00.000Z',
+ });
+ expect(request.targetTimeEntryBefore).toMatchObject({
+ captureGroupId,
+ breakRules: RULES,
+ summary: { grossMinutes: 240, breakMinutes: 15, netMinutes: 225 },
+ captureSummary: { grossMinutes: 480, breakMinutes: 30, netMinutes: 450 },
+ });
+ await decide(request.id).expect(201); // 07:00 local is exactly in-frame
+ expect(await currentEntry(sibling.id)).toEqual(sibling);
+ expect(await ctx.prisma.timeEntry.count()).toBe(2);
+ const linked = (await linkedAudit(request.id).expect(200)).body[0];
+ expect(linked.before).toEqual(request.targetTimeEntryBefore);
+ expect(linked.after).toMatchObject({
+ captureGroupId,
+ breakRules: RULES,
+ summary: { grossMinutes: 300, breakMinutes: 25, netMinutes: 275 },
+ captureSummary: { grossMinutes: 540, breakMinutes: 45, netMinutes: 495 },
+ });
+ const listed = (await listEntries().expect(200)).body as Array<{
+ summary: {
+ grossMinutes: number;
+ breakMinutes: number;
+ netMinutes: number;
+ };
+ }>;
+ const total = listed.reduce(
+ (sum, item) => ({
+ grossMinutes: sum.grossMinutes + item.summary.grossMinutes,
+ breakMinutes: sum.breakMinutes + item.summary.breakMinutes,
+ netMinutes: sum.netMinutes + item.summary.netMinutes,
+ }),
+ { grossMinutes: 0, breakMinutes: 0, netMinutes: 0 },
+ );
+ expect(total).toEqual({
+ grossMinutes: 540,
+ breakMinutes: 45,
+ netMinutes: 495,
+ });
+ });
+
+ it('requires an active real assigned Manager and revalidates their assignment/role at decision time', async () => {
+ const row = await entry();
+ for (const managerId of [null, owner.id, hr.id]) {
+ await ctx.prisma.employee.update({
+ where: { id: owner.id },
+ data: { managerId },
+ });
+ await postRequest({
+ targetTimeEntryId: row.id,
+ expectedTargetRevision: 0,
+ }).expect(409);
+ }
+ await ctx.prisma.employee.update({
+ where: { id: owner.id },
+ data: { managerId: manager.id },
+ });
+ await ctx.prisma.employee.update({
+ where: { id: manager.id },
+ data: { isActive: false },
+ });
+ await postRequest({
+ targetTimeEntryId: row.id,
+ expectedTargetRevision: 0,
+ }).expect(409);
+ await ctx.prisma.employee.update({
+ where: { id: manager.id },
+ data: { isActive: true },
+ });
+ const request = await correction(row);
+ const before = await unchangedDecisionState(request.id, row.id);
+ await decide(request.id, 'manager-approve', hr, {
+ actorId: manager.id,
+ }).expect(403);
+ await ctx.prisma.employee.update({
+ where: { id: manager.id },
+ data: { role: 'Employee' },
+ });
+ await decide(request.id).expect(403); // JWT's old role must not authorize the action.
+ expect(await unchangedDecisionState(request.id, row.id)).toEqual(before);
+ });
+});
diff --git a/apps/api-e2e/src/api/time-entry-approvals.e2e.spec.ts b/apps/api-e2e/src/api/time-entry-approvals.e2e.spec.ts
new file mode 100644
index 0000000..6b8e8a3
--- /dev/null
+++ b/apps/api-e2e/src/api/time-entry-approvals.e2e.spec.ts
@@ -0,0 +1,572 @@
+import { TimeEntriesService } from '../../../api/src/app/time-entries/time-entries.service';
+import {
+ createTestApp,
+ login,
+ seedEmployee,
+ type TestContext,
+} from '../support/test-app';
+
+describe('Outside-frame TimeEntry approvals (#41)', () => {
+ let ctx: TestContext;
+ type Person = { id: string; token: string };
+ let manager: Person,
+ otherManager: Person,
+ owner: Person,
+ other: Person,
+ hr: Person,
+ secondHr: Person;
+
+ beforeAll(async () => {
+ ctx = await createTestApp();
+ });
+ afterAll(async () => {
+ await ctx.close();
+ });
+ beforeEach(async () => {
+ await ctx.reset();
+ async function person(
+ name: string,
+ role: 'Employee' | 'Manager' | 'HRAdmin',
+ managerId?: string,
+ ) {
+ const e = await seedEmployee(ctx.prisma, {
+ personalNo: name,
+ firstName: name,
+ lastName: 'Example',
+ email: `${name}@test.local`,
+ role,
+ managerId,
+ });
+ return { id: e.id, token: await login(ctx.http, e.email) };
+ }
+ manager = await person('manager', 'Manager');
+ otherManager = await person('other-manager', 'Manager');
+ owner = await person('owner', 'Employee', manager.id);
+ other = await person('other', 'Employee', otherManager.id);
+ hr = await person('hr', 'HRAdmin', manager.id);
+ secondHr = await person('second-hr', 'HRAdmin');
+ });
+
+ async function entry(employeeId = owner.id, extra = {}) {
+ return ctx.prisma.timeEntry.create({
+ data: {
+ employeeId,
+ clockIn: new Date('2026-05-04T04:00:00Z'),
+ clockOut: new Date('2026-05-04T06:00:00Z'),
+ status: 'Pending',
+ requiresApproval: true,
+ source: 'Pwa',
+ ...extra,
+ },
+ });
+ }
+ function list(who: Person, scope = 'review') {
+ return ctx.http
+ .get(`/api/timeentries/approvals?scope=${scope}`)
+ .set('Authorization', `Bearer ${who.token}`);
+ }
+ function decide(
+ id: string,
+ who: Person,
+ revision = 0,
+ stage = 'Manager',
+ outcome = 'Approve',
+ extra = {},
+ ) {
+ return ctx.http
+ .post(`/api/timeentries/${id}/approval`)
+ .set('Authorization', `Bearer ${who.token}`)
+ .send({ revision, stage, outcome, ...extra });
+ }
+ function history(id: string, who: Person) {
+ return ctx.http
+ .get(`/api/timeentries/${id}/approval-history`)
+ .set('Authorization', `Bearer ${who.token}`);
+ }
+ async function managerApproved(id: string) {
+ return (await decide(id, manager).expect(201)).body;
+ }
+
+ it('reviews legacy Pending/null mode entries through manager then HR with versioned audit and same ID', async () => {
+ const row = await entry();
+ expect(row.approvalMode).toBeNull();
+ expect((await list(manager).expect(200)).body[0]).toMatchObject({
+ revision: 0,
+ state: 'PendingManager',
+ canDecide: true,
+ managerId: manager.id,
+ employeeName: 'owner Example',
+ });
+ const managed = await managerApproved(row.id);
+ expect(managed).toMatchObject({
+ revision: 1,
+ state: 'PendingHr',
+ canDecide: false,
+ entry: { id: row.id, status: 'Pending' },
+ });
+ expect((await list(hr).expect(200)).body[0]).toMatchObject({
+ state: 'PendingHr',
+ canDecide: true,
+ });
+ const final = await decide(row.id, hr, 1, 'HR', 'Approve', {
+ note: 'HR checked',
+ }).expect(201);
+ expect(final.body).toMatchObject({
+ revision: 2,
+ state: 'Approved',
+ entry: { id: row.id, status: 'Approved', requiresApproval: true },
+ });
+ expect(await ctx.prisma.timeEntry.count()).toBe(1);
+ const events = (await history(row.id, owner).expect(200)).body;
+ expect(events).toMatchObject([
+ {
+ actorId: manager.id,
+ actorName: 'manager Example',
+ action: 'FrameManagerApproved',
+ },
+ { actorId: hr.id, action: 'FrameHrConfirmed', reason: 'HR checked' },
+ ]);
+ expect(
+ events.every(
+ (e: { occurredAt: string }) => !Number.isNaN(Date.parse(e.occurredAt)),
+ ),
+ ).toBe(true);
+ });
+
+ it.each(['Manager', 'HR'])(
+ 'supports %s rejection with mandatory reason and no approval afterward',
+ async (stage) => {
+ const row = await entry();
+ const actor = stage === 'Manager' ? manager : hr;
+ const revision =
+ stage === 'Manager' ? 0 : (await managerApproved(row.id)).revision;
+ await decide(row.id, actor, revision, stage, 'Reject', {
+ note: ' ',
+ }).expect(400);
+ const rejected = await decide(row.id, actor, revision, stage, 'Reject', {
+ note: 'Not authorized work',
+ }).expect(201);
+ expect(rejected.body).toMatchObject({
+ state: 'Rejected',
+ canDecide: false,
+ entry: { status: 'Rejected' },
+ });
+ await decide(row.id, actor, revision + 1, stage).expect(409);
+ const events = (await history(row.id, owner).expect(200)).body;
+ expect(events.at(-1)).toMatchObject({
+ action:
+ stage === 'Manager' ? 'FrameManagerRejected' : 'FrameHrRejected',
+ reason: 'Not authorized work',
+ });
+ },
+ );
+
+ it('scopes review to direct team and own list to bearer identity; employeeId/actorId cannot forge ownership', async () => {
+ const own = await entry();
+ const foreign = await entry(other.id);
+ expect(
+ (await list(manager).expect(200)).body.map(
+ (r: { entry: { id: string } }) => r.entry.id,
+ ),
+ ).toEqual([own.id]);
+ const response = await ctx.http
+ .get(`/api/timeentries/approvals?scope=own&employeeId=${other.id}`)
+ .set('Authorization', `Bearer ${owner.token}`)
+ .expect(200);
+ expect(
+ response.body.map((r: { entry: { id: string } }) => r.entry.id),
+ ).toEqual([own.id]);
+ await list(owner).expect(403);
+ await decide(own.id, otherManager, 0, 'Manager', 'Approve', {
+ actorId: manager.id,
+ employeeId: owner.id,
+ }).expect(403);
+ await decide(foreign.id, manager).expect(403);
+ await history(foreign.id, manager).expect(403);
+ await history(own.id, other).expect(403);
+ await decide(own.id, owner).expect(403);
+ await list(owner, 'invalid').expect(400);
+ await ctx.http.get('/api/timeentries/approvals').expect(401);
+ await ctx.http
+ .post(`/api/timeentries/${own.id}/approval`)
+ .send({ revision: 0, stage: 'Manager', outcome: 'Approve' })
+ .expect(401);
+ });
+
+ it('does not let HR skip manager stage, managers act as HR, or owners self-authorize through a broad role', async () => {
+ const row = await entry();
+ await decide(row.id, hr, 0, 'HR').expect(409);
+ await decide(row.id, hr, 0, 'Manager').expect(403);
+ await decide(row.id, manager, 0, 'HR').expect(403);
+ await managerApproved(row.id);
+ await decide(row.id, manager, 1, 'HR').expect(403);
+ const ownManager = await entry(manager.id);
+ await ctx.prisma.employee.update({
+ where: { id: manager.id },
+ data: { managerId: manager.id },
+ });
+ await decide(ownManager.id, manager).expect(403);
+ const ownHr = await entry(hr.id);
+ await decide(ownHr.id, manager).expect(201);
+ await decide(ownHr.id, hr, 1, 'HR').expect(403);
+ await decide(ownHr.id, secondHr, 1, 'HR').expect(201);
+ expect(
+ (await list(hr).expect(200)).body.some(
+ (r: { entry: { id: string } }) => r.entry.id === ownHr.id,
+ ),
+ ).toBe(false);
+ });
+
+ it.each(['missing', 'inactive', 'wrong-role', 'self'])(
+ 'blocks %s manager configuration until HR assigns a valid manager',
+ async (kind) => {
+ const row = await entry();
+ if (kind === 'missing')
+ await ctx.prisma.employee.update({
+ where: { id: owner.id },
+ data: { managerId: null },
+ });
+ if (kind === 'inactive')
+ await ctx.prisma.employee.update({
+ where: { id: manager.id },
+ data: { isActive: false },
+ });
+ if (kind === 'wrong-role')
+ await ctx.prisma.employee.update({
+ where: { id: owner.id },
+ data: { managerId: hr.id },
+ });
+ if (kind === 'self')
+ await ctx.prisma.employee.update({
+ where: { id: owner.id },
+ data: { managerId: owner.id },
+ });
+ expect((await list(owner, 'own').expect(200)).body[0]).toMatchObject({
+ state: 'BlockedManager',
+ canDecide: false,
+ });
+ await decide(row.id, hr, 0, 'HR').expect(409);
+ await ctx.prisma.employee.update({
+ where: { id: owner.id },
+ data: { managerId: otherManager.id },
+ });
+ const response = await decide(row.id, otherManager).expect(201);
+ expect(response.body.state).toBe('PendingHr');
+ await decide(row.id, hr, 1, 'HR').expect(201);
+ },
+ );
+
+ it('manager reassignment invalidates the pending HR stage and denies the old manager and stale HR tab', async () => {
+ const row = await entry();
+ await managerApproved(row.id);
+ await ctx.prisma.employee.update({
+ where: { id: owner.id },
+ data: { managerId: otherManager.id },
+ });
+ expect((await list(hr).expect(200)).body[0]).toMatchObject({
+ state: 'PendingManager',
+ canDecide: false,
+ });
+ await decide(row.id, hr, 1, 'HR').expect(409);
+ await decide(row.id, manager, 1).expect(403);
+ await decide(row.id, otherManager, 1).expect(201);
+ await decide(row.id, hr, 2, 'HR').expect(201);
+ });
+
+ it('manager role change invalidates their sign-off and cannot promote the same person to HR confirmer', async () => {
+ const row = await entry();
+ await managerApproved(row.id);
+ await ctx.prisma.employee.update({
+ where: { id: manager.id },
+ data: { role: 'HRAdmin' },
+ });
+ await decide(row.id, manager, 1, 'HR').expect(409);
+ expect((await list(owner, 'own').expect(200)).body[0].state).toBe(
+ 'BlockedManager',
+ );
+ });
+
+ it.each(['Open', 'Approved', 'Rejected', 'Voided', 'InFrame', 'Solo'])(
+ 'cannot approve %s entries',
+ async (kind) => {
+ const row = await entry(owner.id, {
+ ...(kind === 'Open' ? { clockOut: null, status: 'Open' } : {}),
+ ...(kind === 'Approved' || kind === 'Rejected' ? { status: kind } : {}),
+ ...(kind === 'Voided' ? { voidedAt: new Date() } : {}),
+ ...(kind === 'InFrame'
+ ? { requiresApproval: false, status: 'Approved' }
+ : {}),
+ ...(kind === 'Solo' ? { approvalMode: 'Solo' } : {}),
+ });
+ await decide(row.id, manager).expect(409);
+ if (kind === 'InFrame' || kind === 'Solo')
+ expect((await list(manager).expect(200)).body).toEqual([]);
+ },
+ );
+
+ it('requires exact revision/stage and only one parallel manager decision succeeds', async () => {
+ const row = await entry();
+ const replies = await Promise.all([
+ decide(row.id, manager),
+ decide(row.id, manager),
+ ]);
+ expect(replies.map((r) => r.status).sort()).toEqual([201, 409]);
+ await decide(row.id, manager, 0).expect(409);
+ expect(
+ await ctx.prisma.timeEntryAudit.count({ where: { timeEntryId: row.id } }),
+ ).toBe(1);
+ const hrs = await Promise.all([
+ decide(row.id, hr, 1, 'HR'),
+ decide(row.id, secondHr, 1, 'HR'),
+ ]);
+ expect(hrs.map((r) => r.status).sort()).toEqual([201, 409]);
+ expect(
+ await ctx.prisma.timeEntryAudit.count({ where: { timeEntryId: row.id } }),
+ ).toBe(2);
+ });
+
+ it('validates missing/bad versions and enums without modifying the entry', async () => {
+ const row = await entry();
+ for (const body of [
+ { stage: 'Manager', outcome: 'Approve' },
+ { revision: -1, stage: 'Manager', outcome: 'Approve' },
+ { revision: 0, stage: 'Employee', outcome: 'Approve' },
+ ]) {
+ await ctx.http
+ .post(`/api/timeentries/${row.id}/approval`)
+ .set('Authorization', `Bearer ${manager.token}`)
+ .send(body)
+ .expect(400);
+ }
+ expect(
+ (await ctx.prisma.timeEntry.findUniqueOrThrow({ where: { id: row.id } }))
+ .revision,
+ ).toBe(0);
+ });
+
+ it('editing manager-approved or HR-confirmed booking restarts manager review and audits invalidation', async () => {
+ const row = await entry();
+ await managerApproved(row.id);
+ const edit = () =>
+ ctx.http
+ .patch(`/api/timeentries/${row.id}`)
+ .set('Authorization', `Bearer ${owner.token}`)
+ .send({ activity: 'Corrected description' })
+ .expect(200);
+ await edit();
+ await decide(row.id, hr, 1, 'HR').expect(409);
+ expect((await list(owner, 'own').expect(200)).body[0]).toMatchObject({
+ state: 'PendingManager',
+ revision: 2,
+ });
+ await decide(row.id, manager, 2).expect(201);
+ await decide(row.id, hr, 3, 'HR').expect(201);
+ await edit();
+ expect((await list(owner, 'own').expect(200)).body[0]).toMatchObject({
+ state: 'PendingManager',
+ revision: 5,
+ entry: { status: 'Pending' },
+ });
+ expect(
+ (await history(row.id, owner).expect(200)).body.filter(
+ (r: { action: string }) => r.action === 'FrameApprovalInvalidated',
+ ),
+ ).toHaveLength(2);
+ });
+
+ it('serializes an edit against approval without allowing stale decisions to corrupt the result', async () => {
+ const row = await entry();
+ const replies = await Promise.all([
+ decide(row.id, manager),
+ ctx.http
+ .patch(`/api/timeentries/${row.id}`)
+ .set('Authorization', `Bearer ${owner.token}`)
+ .send({ activity: 'New activity', revision: 0 }),
+ ]);
+ expect(replies.filter((r) => r.status === 409)).toHaveLength(1);
+ expect(
+ replies.filter((r) => r.status === 200 || r.status === 201),
+ ).toHaveLength(1);
+ const current = await ctx.prisma.timeEntry.findUniqueOrThrow({
+ where: { id: row.id },
+ });
+ expect(current.revision).toBe(1);
+ expect(await ctx.prisma.timeEntryAudit.count()).toBe(1);
+ });
+
+ it('split invalidates prior sign-off, recalculates frame per segment, and never launders a rejection', async () => {
+ const row = await entry();
+ await managerApproved(row.id);
+ const split = await ctx.http
+ .post(`/api/timeentries/${row.id}/split`)
+ .set('Authorization', `Bearer ${owner.token}`)
+ .send({ at: '2026-05-04T05:00:00Z' })
+ .expect(201);
+ expect(split.body.first).toMatchObject({
+ requiresApproval: true,
+ status: 'Pending',
+ });
+ expect(split.body.second).toMatchObject({
+ requiresApproval: false,
+ status: 'Approved',
+ });
+ expect(
+ (await list(manager).expect(200)).body.find(
+ (item: { entry: { id: string } }) => item.entry.id === row.id,
+ ).state,
+ ).toBe('PendingManager');
+ await decide(row.id, manager, 2, 'Manager', 'Reject', {
+ note: 'No',
+ }).expect(201);
+ const again = await ctx.http
+ .post(`/api/timeentries/${row.id}/split`)
+ .set('Authorization', `Bearer ${owner.token}`)
+ .send({ at: '2026-05-04T04:30:00Z' })
+ .expect(201);
+ expect(again.body.first.status).toBe('Rejected');
+ expect(again.body.second.status).toBe('Rejected');
+ });
+
+ it('closed PWA booking computes actual frame and enters manager review', async () => {
+ // Controlled clock-out via the service; production controller calls same method.
+ const service = ctx.app.get(TimeEntriesService);
+ const schedule = await ctx.prisma.workSchedule.create({
+ data: { name: 'Narrow', frameStart: '07:00', frameEnd: '08:00' },
+ });
+ await ctx.prisma.employee.update({
+ where: { id: owner.id },
+ data: { workScheduleId: schedule.id },
+ });
+ const start = new Date();
+ start.setDate(start.getDate() - 1);
+ start.setHours(6, 0, 0, 0);
+ const open = await entry(owner.id, {
+ clockIn: start,
+ clockOut: null,
+ status: 'Open',
+ requiresApproval: true,
+ });
+ expect((await list(manager).expect(200)).body[0]).toMatchObject({
+ state: 'Open',
+ canDecide: false,
+ });
+ const closed = await service.clockOut(owner.id);
+ expect(closed).toMatchObject({
+ id: open.id,
+ requiresApproval: true,
+ status: 'Pending',
+ });
+ expect((await list(manager).expect(200)).body[0].state).toBe(
+ 'PendingManager',
+ );
+ });
+
+ it('leaves ordinary in-frame and Solo creation unchanged and does not list them for review', async () => {
+ await entry(owner.id, {
+ clockIn: new Date('2026-05-04T06:00:00Z'),
+ clockOut: new Date('2026-05-04T14:00:00Z'),
+ status: 'Approved',
+ requiresApproval: false,
+ });
+ await entry(owner.id, {
+ approvalMode: 'Solo',
+ status: 'Approved',
+ requiresApproval: false,
+ });
+ expect((await list(manager).expect(200)).body).toEqual([]);
+ expect((await list(owner, 'own').expect(200)).body).toEqual([]);
+ });
+
+ it('project-range segmentation invalidates a prior manager sign-off for every outside-frame segment', async () => {
+ const row = await entry();
+ await managerApproved(row.id);
+ const project = await ctx.prisma.project.create({
+ data: { code: 'FRAME', name: 'Synthetic project' },
+ });
+ await ctx.prisma.projectAssignment.create({
+ data: { projectId: project.id, employeeId: owner.id },
+ });
+ await ctx.http
+ .post('/api/timeentries/book-project')
+ .set('Authorization', `Bearer ${owner.token}`)
+ .send({
+ employeeId: owner.id,
+ from: '2026-05-04T04:15:00Z',
+ to: '2026-05-04T05:30:00Z',
+ projectId: project.id,
+ })
+ .expect(201);
+ const rows = (await list(manager).expect(200)).body;
+ expect(rows).toHaveLength(3);
+ const pending = rows.filter(
+ (item: { entry: { requiresApproval: boolean } }) =>
+ item.entry.requiresApproval,
+ );
+ expect(pending).toHaveLength(2);
+ expect(
+ pending.every(
+ (item: { state: string }) => item.state === 'PendingManager',
+ ),
+ ).toBe(true);
+ expect(
+ rows.filter((item: { state: string }) => item.state === 'Approved'),
+ ).toHaveLength(1);
+ await decide(row.id, hr, 1, 'HR').expect(409);
+ const invalidations = await ctx.prisma.timeEntryAudit.findMany({
+ where: { action: { startsWith: 'Frame' } },
+ });
+ expect(
+ invalidations.some(
+ (event) => event.action === 'FrameApprovalInvalidated',
+ ),
+ ).toBe(true);
+ });
+
+ it('retains discoverable decision history when a split moves the original ID wholly inside the frame', async () => {
+ const row = await entry(owner.id, {
+ clockIn: new Date('2026-05-04T06:00:00Z'),
+ clockOut: new Date('2026-05-04T22:00:00Z'),
+ });
+ await managerApproved(row.id);
+ await ctx.http
+ .post(`/api/timeentries/${row.id}/split`)
+ .set('Authorization', `Bearer ${owner.token}`)
+ .send({ at: '2026-05-04T20:00:00Z' })
+ .expect(201);
+ const views = (await list(owner, 'own').expect(200)).body;
+ expect(views).toHaveLength(2);
+ expect(
+ views.find((item: { entry: { id: string } }) => item.entry.id === row.id),
+ ).toMatchObject({
+ state: 'Approved',
+ canDecide: false,
+ entry: { requiresApproval: false },
+ });
+ const events = (await history(row.id, owner).expect(200)).body;
+ expect(events.map((event: { action: string }) => event.action)).toEqual([
+ 'FrameManagerApproved',
+ 'FrameApprovalInvalidated',
+ ]);
+ await decide(row.id, manager, 2).expect(409);
+ });
+
+ it('Solo installation cannot expose or approve archived Team entries', async () => {
+ const row = await entry();
+ await ctx.prisma.installationSettings.create({
+ data: {
+ id: 1,
+ mode: 'Solo',
+ ownerEmployeeId: hr.id,
+ setupCompleted: true,
+ },
+ });
+ await list(hr).expect(403);
+ await list(hr, 'own').expect(403);
+ await decide(row.id, hr, 0, 'HR').expect(403);
+ await history(row.id, hr).expect(403);
+ expect(
+ (await ctx.prisma.timeEntry.findUniqueOrThrow({ where: { id: row.id } }))
+ .status,
+ ).toBe('Pending');
+ });
+});
diff --git a/apps/api-e2e/src/api/working-time-comparisons.e2e.spec.ts b/apps/api-e2e/src/api/working-time-comparisons.e2e.spec.ts
new file mode 100644
index 0000000..2f7e5dc
--- /dev/null
+++ b/apps/api-e2e/src/api/working-time-comparisons.e2e.spec.ts
@@ -0,0 +1,784 @@
+import { ReportsService } from '../../../api/src/app/reports/reports.service';
+import { randomUUID } from 'node:crypto';
+import type { Prisma } from '@prisma/client';
+import type { WorkingTimeReportDto } from '../../../api/src/app/reports/reports.dto';
+import {
+ createTestApp,
+ login,
+ seedEmployee,
+ type TestContext,
+} from '../support/test-app';
+
+/** These fixtures intentionally create confirmed snapshots, never legacy defaults. */
+describe('Scoped working-time period comparisons', () => {
+ let ctx: TestContext;
+ beforeAll(async () => {
+ ctx = await createTestApp();
+ });
+ afterAll(async () => {
+ await ctx.close();
+ });
+ beforeEach(async () => {
+ await ctx.reset();
+ });
+
+ async function fixture() {
+ const employee = (
+ personalNo: string,
+ role: 'HRAdmin' | 'Manager' | 'Employee' = 'Employee',
+ managerId?: string,
+ ) =>
+ seedEmployee(ctx.prisma, {
+ personalNo,
+ firstName: personalNo,
+ lastName: 'Comparison',
+ email: `${personalNo.toLowerCase()}@test.local`,
+ holidayCalendar: 'NONE',
+ role,
+ managerId,
+ });
+ const hr = await employee('HR', 'HRAdmin');
+ const manager = await employee('MANAGER', 'Manager');
+ const direct = await employee('DIRECT', 'Employee', manager.id);
+ const foreign = await employee('FOREIGN');
+ const authorization = `Bearer ${await login(ctx.http, hr.email)}`;
+ const snapshot = (
+ employeeId: string,
+ values: Partial = {},
+ ) =>
+ ctx.prisma.employeeContractPeriod.create({
+ data: {
+ employeeId,
+ effectiveFrom: new Date('2026-01-01'),
+ employmentStart: new Date('2020-01-01'),
+ weeklyHours: 40,
+ timeModel: 'Vollzeit',
+ workingDays: 31,
+ holidayCalendar: 'NONE',
+ holidayDates: [],
+ confirmationNote: 'Synthetic HR-confirmed contractual facts',
+ confirmedById: hr.id,
+ ...values,
+ },
+ });
+ const entry = (
+ employeeId: string,
+ from: string,
+ to: string | null,
+ values: Partial = {},
+ ) =>
+ ctx.prisma.timeEntry.create({
+ data: {
+ employeeId,
+ clockIn: new Date(from),
+ clockOut: to ? new Date(to) : null,
+ status: 'Approved',
+ breakRules: [],
+ ...values,
+ },
+ });
+ const report = async (from: string, to = from, employeeId?: string) =>
+ (
+ await ctx.http
+ .get(
+ `/api/reports/working-times?from=${from}&to=${to}${employeeId ? `&employeeId=${employeeId}` : ''}`,
+ )
+ .set('Authorization', authorization)
+ .expect(200)
+ ).body as WorkingTimeReportDto;
+ return {
+ hr,
+ manager,
+ direct,
+ foreign,
+ authorization,
+ snapshot,
+ entry,
+ report,
+ };
+ }
+
+ it('derives full-time, part-time, workmask and inclusive contract-change targets, including zero actuals', async () => {
+ const { hr, manager, direct, foreign, snapshot, report } = await fixture();
+ await snapshot(hr.id);
+ await snapshot(manager.id, { weeklyHours: 33, workingDays: 15 });
+ await snapshot(foreign.id, { weeklyHours: 20, timeModel: 'Teilzeit' });
+ await snapshot(direct.id, { effectiveTo: new Date('2026-08-11') });
+ await snapshot(direct.id, {
+ effectiveFrom: new Date('2026-08-12'),
+ weeklyHours: 20,
+ timeModel: 'Teilzeit',
+ });
+ const result = await report('2026-08-10', '2026-08-16');
+ expect(result).toMatchObject({
+ timeZone: 'Europe/Berlin',
+ timeDefinition: 'net_working_time',
+ excessThresholdMinutes: 600,
+ rows: [],
+ totals: { grossMinutes: 0, breakMinutes: 0, netMinutes: 0 },
+ });
+ expect(result.comparisons).toHaveLength(4);
+ for (const [employeeId, target] of [
+ [hr.id, 2400],
+ [manager.id, 1980],
+ [direct.id, 1680],
+ [foreign.id, 1200],
+ ] as const) {
+ expect(
+ result.comparisons.find((row) => row.employeeId === employeeId),
+ ).toMatchObject({
+ targetMinutes: target,
+ actualMinutes: 0,
+ differenceMinutes: -target,
+ targetStatus: 'Known',
+ unknownDates: [],
+ reason: null,
+ indicator: 'Negative',
+ openEntryCount: 0,
+ });
+ }
+ });
+
+ it('uses employment bounds only inside confirmed coverage; never invents a prehire/legacy/weekend baseline', async () => {
+ const { direct, foreign, snapshot, report } = await fixture();
+ await snapshot(direct.id, {
+ effectiveFrom: new Date('2026-08-01'),
+ effectiveTo: new Date('2026-08-31'),
+ employmentStart: new Date('2026-08-11'),
+ employmentEnd: new Date('2026-08-12'),
+ });
+ expect(
+ (await report('2026-08-10', '2026-08-14', direct.id)).comparisons[0],
+ ).toMatchObject({
+ targetMinutes: 960,
+ targetStatus: 'Known',
+ unknownDates: [],
+ });
+ expect(
+ (await report('2026-08-10', '2026-08-10', direct.id)).comparisons[0]
+ .targetMinutes,
+ ).toBe(0);
+ expect(
+ (await report('2026-08-13', '2026-08-14', direct.id)).comparisons[0]
+ .targetMinutes,
+ ).toBe(0);
+ // An old employee without history is unknown, even when the live start date
+ // or weekend would otherwise suggest that their target is zero.
+ await ctx.prisma.employee.update({
+ where: { id: foreign.id },
+ data: { startDate: new Date('2027-01-01') },
+ });
+ expect(
+ (await report('2026-08-15', '2026-08-16', foreign.id)).comparisons[0],
+ ).toMatchObject({
+ targetMinutes: null,
+ differenceMinutes: null,
+ targetStatus: 'Unknown',
+ unknownDates: ['2026-08-15', '2026-08-16'],
+ reason: 'CONTRACT_HISTORY_GAP',
+ indicator: 'Unknown',
+ });
+ await snapshot(foreign.id, { effectiveFrom: new Date('2026-08-18') });
+ expect(
+ (await report('2026-08-17', '2026-08-18', foreign.id)).comparisons[0],
+ ).toMatchObject({
+ targetMinutes: null,
+ differenceMinutes: null,
+ unknownDates: ['2026-08-17'],
+ });
+ });
+
+ it('nulls the whole target/difference on any daily gap but retains actual minutes and known date coverage', async () => {
+ const { direct, snapshot, entry, report } = await fixture();
+ await snapshot(direct.id, { effectiveTo: new Date('2026-08-11') });
+ await snapshot(direct.id, { effectiveFrom: new Date('2026-08-13') });
+ await entry(direct.id, '2026-08-12T06:00:00Z', '2026-08-12T08:00:00Z');
+ const result = await report('2026-08-10', '2026-08-14', direct.id);
+ expect(result.comparisons[0]).toMatchObject({
+ targetMinutes: null,
+ actualMinutes: 120,
+ differenceMinutes: null,
+ targetStatus: 'Unknown',
+ unknownDates: ['2026-08-12'],
+ reason: 'CONTRACT_HISTORY_GAP',
+ indicator: 'Unknown',
+ });
+ expect(result.totals.netMinutes).toBe(120);
+ expect(
+ (await report('2026-08-10', '2026-08-11', direct.id)).comparisons[0]
+ .targetMinutes,
+ ).toBe(960);
+ });
+
+ it('keeps historical schedule, hours, employment and holiday facts immutable when current employee defaults change', async () => {
+ const { direct, snapshot, report } = await fixture();
+ const confirmed = await snapshot(direct.id, {
+ weeklyHours: 33,
+ workingDays: 15,
+ holidayDates: ['2026-08-11'],
+ scheduleLabel: 'Historic four-day week',
+ });
+ const before = (await report('2026-08-10', '2026-08-14', direct.id))
+ .comparisons[0];
+ expect(before.targetMinutes).toBe(1485);
+ const schedule = await ctx.prisma.workSchedule.create({
+ data: {
+ name: 'Changed live schedule',
+ workingDays: 127,
+ frameStart: '00:00',
+ frameEnd: '23:59',
+ },
+ });
+ await ctx.prisma.employee.update({
+ where: { id: direct.id },
+ data: {
+ weeklyHours: 10,
+ timeModel: 'Teilzeit',
+ startDate: new Date('2027-01-01'),
+ workScheduleId: schedule.id,
+ holidayCalendar: 'DE-BY',
+ holidayDates: ['2026-08-10', '2026-08-12', '2026-08-13'],
+ },
+ });
+ expect(
+ (await report('2026-08-10', '2026-08-14', direct.id)).comparisons[0],
+ ).toEqual(before);
+ expect(
+ await ctx.prisma.employeeContractPeriod.findUnique({
+ where: { id: confirmed.id },
+ }),
+ ).toEqual(confirmed);
+ });
+
+ it('uses snapshot preset/custom holidays and only recorded sickness/training as non-request excusals', async () => {
+ const { direct, snapshot, report } = await fixture();
+ await snapshot(direct.id, {
+ holidayCalendar: 'DE-BY',
+ holidayDates: ['2026-01-07'],
+ });
+ await ctx.prisma.absence.createMany({
+ data: [
+ {
+ employeeId: direct.id,
+ kind: 'Training',
+ from: new Date('2026-01-08'),
+ to: new Date('2026-01-08'),
+ },
+ {
+ employeeId: direct.id,
+ kind: 'Sickness',
+ from: new Date('2026-01-09'),
+ to: new Date('2026-01-09'),
+ },
+ {
+ employeeId: direct.id,
+ kind: 'Flextime',
+ from: new Date('2026-01-05'),
+ to: new Date('2026-01-05'),
+ },
+ ],
+ });
+ // Jan 6 is a Bavarian preset holiday; Jan 7 is a custom snapshot holiday.
+ expect(
+ (await report('2026-01-05', '2026-01-09', direct.id)).comparisons[0],
+ ).toMatchObject({ targetMinutes: 480, actualMinutes: 0 });
+ });
+
+ it('reduces targets only for approved leave, unions duplicate half-days and never double-credits work', async () => {
+ const { direct, snapshot, entry, report } = await fixture();
+ await snapshot(direct.id);
+ const leave = (
+ from: string,
+ to = from,
+ values: Partial = {},
+ ) =>
+ ctx.prisma.request.create({
+ data: {
+ employeeId: direct.id,
+ type: 'Vacation',
+ status: 'Approved',
+ workflowState: 'Approved',
+ from: new Date(from),
+ to: new Date(to),
+ ...values,
+ },
+ });
+ await leave('2026-08-10', undefined, {
+ halfDayStart: true,
+ halfDayEnd: true,
+ });
+ await leave('2026-08-10', undefined, {
+ type: 'SpecialLeave',
+ halfDayStart: true,
+ });
+ await leave('2026-08-10', undefined, { type: 'HomeOffice' });
+ await leave('2026-08-11');
+ await leave('2026-08-12', undefined, { type: 'SpecialLeave' });
+ await leave('2026-08-13', undefined, {
+ status: 'Submitted',
+ workflowState: 'PendingManager',
+ });
+ await leave('2026-08-13', undefined, { cancelledAt: new Date() });
+ await leave('2026-08-14', undefined, {
+ status: 'Rejected',
+ workflowState: 'Rejected',
+ });
+ await leave('2026-08-14', undefined, { type: 'HomeOffice' });
+ await leave('2026-08-17', '2026-08-19', {
+ halfDayStart: true,
+ halfDayEnd: true,
+ });
+ await ctx.prisma.absence.createMany({
+ data: [
+ {
+ employeeId: direct.id,
+ kind: 'Flextime',
+ from: new Date('2026-08-13'),
+ to: new Date('2026-08-13'),
+ },
+ {
+ employeeId: direct.id,
+ kind: 'Training',
+ from: new Date('2026-08-11'),
+ to: new Date('2026-08-11'),
+ },
+ ],
+ });
+ await entry(direct.id, '2026-08-11T06:00:00Z', '2026-08-11T07:00:00Z');
+ const result = await report('2026-08-10', '2026-08-21', direct.id);
+ // 240 + 0 + 0 + 480 + 480 + 240 + 0 + 240 + 480 + 480.
+ expect(result.comparisons[0]).toMatchObject({
+ targetMinutes: 2640,
+ actualMinutes: 60,
+ differenceMinutes: -2580,
+ });
+ expect(result.totals.netMinutes).toBe(60);
+ expect(
+ (await report('2026-08-11', '2026-08-11', direct.id)).comparisons[0],
+ ).toMatchObject({
+ targetMinutes: 0,
+ actualMinutes: 60,
+ differenceMinutes: 60,
+ });
+ });
+
+ it('includes only closed Pending/Approved entries and counts open timers separately', async () => {
+ const { direct, snapshot, entry, report } = await fixture();
+ await snapshot(direct.id);
+ const accepted = await entry(
+ direct.id,
+ '2026-08-10T06:00:00Z',
+ '2026-08-10T14:00:00Z',
+ {
+ breakRules: [{ afterMinutes: 360, breakMinutes: 30 }],
+ },
+ );
+ const pending = await entry(
+ direct.id,
+ '2026-08-10T15:00:00Z',
+ '2026-08-10T16:00:00Z',
+ { status: 'Pending' },
+ );
+ for (const values of [
+ { status: 'Open' as const },
+ { status: 'Rejected' as const },
+ { voidedAt: new Date() },
+ ]) {
+ await entry(
+ direct.id,
+ '2026-08-10T17:00:00Z',
+ '2026-08-10T18:00:00Z',
+ values,
+ );
+ }
+ // The database intentionally permits only one non-voided running row per
+ // employee (including rejected legacy rows), so test exclusion separately.
+ const oldOpen = await entry(direct.id, '2026-08-09T12:00:00Z', null, {
+ status: 'Open',
+ });
+ expect(
+ (await report('2026-08-10', '2026-08-10', direct.id)).comparisons[0]
+ .openEntryCount,
+ ).toBe(1);
+ await ctx.prisma.timeEntry.update({
+ where: { id: oldOpen.id },
+ data: { voidedAt: new Date() },
+ });
+ const rejectedOpen = await entry(direct.id, '2026-08-10T12:00:00Z', null, {
+ status: 'Rejected',
+ });
+ expect(
+ (await report('2026-08-10', '2026-08-10', direct.id)).comparisons[0]
+ .openEntryCount,
+ ).toBe(0);
+ await ctx.prisma.timeEntry.update({
+ where: { id: rejectedOpen.id },
+ data: { voidedAt: new Date() },
+ });
+ const futureOpen = await entry(direct.id, '2026-08-11T12:00:00Z', null, {
+ status: 'Open',
+ });
+ expect(
+ (await report('2026-08-10', '2026-08-10', direct.id)).comparisons[0]
+ .openEntryCount,
+ ).toBe(0);
+ await ctx.prisma.timeEntry.update({
+ where: { id: futureOpen.id },
+ data: { voidedAt: new Date() },
+ });
+ await entry(direct.id, '2026-08-10T12:00:00Z', null, { status: 'Open' });
+ const result = await report('2026-08-10', '2026-08-10', direct.id);
+ expect(result.rows.map((row) => row.id)).toEqual([accepted.id, pending.id]);
+ expect(result.totals).toEqual({
+ grossMinutes: 540,
+ breakMinutes: 30,
+ netMinutes: 510,
+ });
+ expect(result.comparisons[0]).toMatchObject({
+ targetMinutes: 480,
+ actualMinutes: 510,
+ differenceMinutes: 30,
+ indicator: 'Positive',
+ openEntryCount: 1,
+ });
+ });
+
+ it('allocates complete capture-group breaks before local clipping and excludes invalid/foreign siblings', async () => {
+ const { direct, foreign, snapshot, entry, report } = await fixture();
+ await snapshot(direct.id);
+ const captureGroupId = randomUUID();
+ const group = {
+ captureGroupId,
+ breakRules: [{ afterMinutes: 360, breakMinutes: 30 }],
+ };
+ // First four-hour member is half inside the requested Berlin local day.
+ const first = await entry(
+ direct.id,
+ '2026-08-09T20:00:00Z',
+ '2026-08-10T00:00:00Z',
+ group,
+ );
+ await entry(
+ direct.id,
+ '2026-08-11T06:00:00Z',
+ '2026-08-11T10:00:00Z',
+ group,
+ );
+ await entry(direct.id, '2026-08-12T06:00:00Z', '2026-08-12T10:00:00Z', {
+ ...group,
+ status: 'Rejected',
+ });
+ await entry(direct.id, '2026-08-13T06:00:00Z', '2026-08-13T10:00:00Z', {
+ ...group,
+ voidedAt: new Date(),
+ });
+ await entry(direct.id, '2026-08-14T06:00:00Z', '2026-08-14T10:00:00Z', {
+ ...group,
+ status: 'Open',
+ });
+ await entry(
+ foreign.id,
+ '2026-08-10T06:00:00Z',
+ '2026-08-10T10:00:00Z',
+ group,
+ );
+ const clipped = await report('2026-08-10', '2026-08-10', direct.id);
+ expect(clipped.rows).toHaveLength(1);
+ expect(clipped.rows[0]).toMatchObject({
+ id: first.id,
+ date: '2026-08-10',
+ clockIn: '2026-08-09T22:00:00.000Z',
+ clockOut: '2026-08-10T00:00:00.000Z',
+ grossMinutes: 120,
+ breakMinutes: 7.5,
+ netMinutes: 112.5,
+ });
+ expect(clipped.totals).toEqual({
+ grossMinutes: 120,
+ breakMinutes: 7.5,
+ netMinutes: 112.5,
+ });
+ expect(clipped.comparisons[0].actualMinutes).toBe(112.5);
+ const full = await report('2026-08-09', '2026-08-11', direct.id);
+ expect(full.rows).toHaveLength(3);
+ expect(full.totals).toEqual({
+ grossMinutes: 480,
+ breakMinutes: 30,
+ netMinutes: 450,
+ });
+ expect(full.comparisons[0].actualMinutes).toBe(450);
+ });
+
+ it.each([
+ [
+ 'spring-forward',
+ '2026-03-28T22:30:00Z',
+ '2026-03-29T02:30:00Z',
+ '2026-03-29',
+ 210,
+ '2026-03-28T23:00:00.000Z',
+ ],
+ [
+ 'fall-back',
+ '2026-10-24T21:30:00Z',
+ '2026-10-25T03:30:00Z',
+ '2026-10-25',
+ 330,
+ '2026-10-24T22:00:00.000Z',
+ ],
+ ] as const)(
+ 'clips across local midnight and %s without pretending every local day is 24 hours',
+ async (_label, from, to, date, minutes, expectedStart) => {
+ const { direct, snapshot, entry, report } = await fixture();
+ await snapshot(direct.id, { workingDays: 127, weeklyHours: 7 });
+ await entry(direct.id, from, to, { captureGroupId: randomUUID() });
+ const result = await report(date, date, direct.id);
+ expect(result.rows).toHaveLength(1);
+ expect(result.rows[0]).toMatchObject({
+ date,
+ clockIn: expectedStart,
+ clockOut: new Date(to).toISOString(),
+ grossMinutes: minutes,
+ netMinutes: minutes,
+ });
+ expect(result.totals.netMinutes).toBe(minutes);
+ expect(result.comparisons[0]).toMatchObject({
+ targetMinutes: 60,
+ actualMinutes: minutes,
+ differenceMinutes: Number(minutes) - 60,
+ });
+ },
+ );
+
+ it('splits a whole local day through both DST transitions using elapsed minutes, with seconds retained', async () => {
+ const { direct, snapshot, entry, report } = await fixture();
+ await snapshot(direct.id, { weeklyHours: 0 });
+ await entry(direct.id, '2026-03-28T23:00:00Z', '2026-03-29T22:00:00Z');
+ await entry(direct.id, '2026-10-24T22:00:00Z', '2026-10-25T23:00:00Z');
+ expect(
+ (await report('2026-03-29', '2026-03-29', direct.id)).totals.netMinutes,
+ ).toBe(1380);
+ expect(
+ (await report('2026-10-25', '2026-10-25', direct.id)).totals.netMinutes,
+ ).toBe(1500);
+ await entry(direct.id, '2026-08-10T07:00:01Z', '2026-08-10T15:00:59Z', {
+ breakRules: [{ afterMinutes: 360, breakMinutes: 30 }],
+ });
+ const seconds = await report('2026-08-10', '2026-08-10', direct.id);
+ expect(seconds.rows[0].grossMinutes).toBeCloseTo(480 + 58 / 60, 8);
+ expect(seconds.comparisons[0].actualMinutes).toBeCloseTo(450 + 58 / 60, 8);
+ expect(seconds.comparisons[0].actualMinutes).toBe(
+ seconds.totals.netMinutes,
+ );
+ });
+
+ it('uses selected-period indicator boundaries, not opening or carried balances', async () => {
+ const { direct, snapshot, entry, report } = await fixture();
+ await snapshot(direct.id, { weeklyHours: 0 });
+ await ctx.prisma.employee.update({
+ where: { id: direct.id },
+ data: { overtimeOpeningBalanceMinutes: -9999 },
+ });
+ for (const [date, minutes, indicator] of [
+ ['2026-08-10', 0, 'Balanced'],
+ ['2026-08-11', 599, 'Positive'],
+ ['2026-08-12', 600, 'Excess'],
+ ['2026-08-13', 601, 'Excess'],
+ ] as const) {
+ if (minutes)
+ await entry(
+ direct.id,
+ `${date}T06:00:00Z`,
+ new Date(
+ new Date(`${date}T06:00:00Z`).getTime() + minutes * 60_000,
+ ).toISOString(),
+ );
+ expect(
+ (await report(date, date, direct.id)).comparisons[0],
+ ).toMatchObject({
+ targetMinutes: 0,
+ actualMinutes: minutes,
+ differenceMinutes: minutes,
+ indicator,
+ });
+ }
+ });
+
+ it('scopes manager lists/reports to self and direct reports and forbids foreign IDs even without entries', async () => {
+ const { hr, manager, direct, foreign, authorization } = await fixture();
+ const indirect = await seedEmployee(ctx.prisma, {
+ personalNo: 'INDIRECT',
+ firstName: 'Indirect',
+ lastName: 'Report',
+ email: 'indirect@test.local',
+ managerId: direct.id,
+ });
+ const managerAuth = `Bearer ${await login(ctx.http, manager.email)}`;
+ const employees = await ctx.http
+ .get('/api/reports/working-times/employees')
+ .set('Authorization', managerAuth)
+ .expect(200);
+ expect(
+ new Set(employees.body.map((employee: { id: string }) => employee.id)),
+ ).toEqual(new Set([manager.id, direct.id]));
+ const result = await ctx.http
+ .get('/api/reports/working-times?from=2026-08-10&to=2026-08-10')
+ .set('Authorization', managerAuth)
+ .expect(200);
+ expect(
+ new Set(
+ result.body.comparisons.map(
+ (row: { employeeId: string }) => row.employeeId,
+ ),
+ ),
+ ).toEqual(new Set([manager.id, direct.id]));
+ for (const forbidden of [hr.id, foreign.id, indirect.id, randomUUID()]) {
+ for (const filter of [
+ `employeeId=${forbidden}`,
+ `employeeIds=${direct.id}&employeeIds=${forbidden}`,
+ ]) {
+ await ctx.http
+ .get(
+ `/api/reports/working-times?from=2026-08-10&to=2026-08-10&${filter}`,
+ )
+ .set('Authorization', managerAuth)
+ .expect(403);
+ }
+ }
+ const hrEmployees = await ctx.http
+ .get('/api/reports/working-times/employees')
+ .set('Authorization', authorization)
+ .expect(200);
+ expect(hrEmployees.body).toHaveLength(5);
+ await ctx.http
+ .get(
+ `/api/reports/working-times?from=2026-08-10&to=2026-08-10&employeeId=${foreign.id}`,
+ )
+ .set('Authorization', authorization)
+ .expect(200);
+ // A manager's scope follows the current server relationship immediately.
+ await ctx.prisma.employee.update({
+ where: { id: direct.id },
+ data: { managerId: null },
+ });
+ await ctx.http
+ .get(
+ `/api/reports/working-times?from=2026-08-10&to=2026-08-10&employeeId=${direct.id}`,
+ )
+ .set('Authorization', managerAuth)
+ .expect(403);
+ });
+
+ it('uses fresh active actor roles for existing tokens; employees are denied and PDF remains HR-only', async () => {
+ const { hr, manager, direct, authorization } = await fixture();
+ const managerAuth = `Bearer ${await login(ctx.http, manager.email)}`;
+ const employeeAuth = `Bearer ${await login(ctx.http, direct.email)}`;
+ const reportUrl =
+ '/api/reports/working-times?from=2026-08-10&to=2026-08-10';
+ const employeesUrl = '/api/reports/working-times/employees';
+ const pdfUrl =
+ '/api/reports/working-times.pdf?from=2026-08-10&to=2026-08-10';
+ for (const url of [reportUrl, employeesUrl, pdfUrl]) {
+ await ctx.http.get(url).expect(401);
+ await ctx.http.get(url).set('Authorization', employeeAuth).expect(403);
+ }
+ await ctx.http.get(pdfUrl).set('Authorization', managerAuth).expect(403);
+ await ctx.http.get(pdfUrl).set('Authorization', authorization).expect(200);
+ await ctx.prisma.employee.update({
+ where: { id: hr.id },
+ data: { role: 'Employee' },
+ });
+ await ctx.prisma.employee.update({
+ where: { id: manager.id },
+ data: { role: 'Employee' },
+ });
+ for (const url of [reportUrl, employeesUrl, pdfUrl]) {
+ await ctx.http.get(url).set('Authorization', authorization).expect(403);
+ await ctx.http.get(url).set('Authorization', managerAuth).expect(403);
+ }
+ await ctx.prisma.employee.update({
+ where: { id: direct.id },
+ data: { role: 'Manager' },
+ });
+ await ctx.http
+ .get(reportUrl)
+ .set('Authorization', employeeAuth)
+ .expect(200);
+ await ctx.prisma.employee.update({
+ where: { id: direct.id },
+ data: { isActive: false },
+ });
+ for (const url of [reportUrl, employeesUrl, pdfUrl]) {
+ await ctx.http.get(url).set('Authorization', employeeAuth).expect(401);
+ }
+ });
+ it('does not expose archived Team report data after an in-flight switch to Solo', async () => {
+ const { hr, direct, snapshot, entry } = await fixture();
+ await snapshot(direct.id);
+ await entry(direct.id, '2026-08-10T06:00:00Z', '2026-08-10T14:00:00Z');
+ await ctx.prisma.installationSettings.create({
+ data: {
+ id: 1,
+ mode: 'Solo',
+ ownerEmployeeId: hr.id,
+ setupCompleted: true,
+ },
+ });
+ const service = ctx.app.get(ReportsService);
+ await expect(service.workingTimeEmployees(hr.id)).rejects.toMatchObject({
+ status: 403,
+ });
+ await expect(
+ service.workingTimes(hr.id, { from: '2026-08-10', to: '2026-08-10' }),
+ ).rejects.toMatchObject({ status: 403 });
+ });
+ it('keeps physical location snapshots only on original booking edges, not synthetic midnight clips', async () => {
+ const { direct, entry, authorization } = await fixture();
+ await entry(direct.id, '2026-08-09T21:00:00Z', '2026-08-10T00:00:00Z', {
+ terminalLocationLabel: 'Synthetic entrance',
+ latitude: 0,
+ longitude: 0,
+ accuracyMeters: 5,
+ clockOutTerminalLocationLabel: 'Synthetic exit',
+ clockOutLatitude: 1,
+ clockOutLongitude: 1,
+ clockOutAccuracyMeters: 6,
+ });
+ const response = await ctx.http
+ .get(
+ `/api/reports/working-times?from=2026-08-09&to=2026-08-10&employeeId=${direct.id}&includeLocations=true`,
+ )
+ .set('Authorization', authorization)
+ .expect(200);
+ expect(response.body.rows).toHaveLength(2);
+ expect(response.body.rows[0]).toMatchObject({
+ date: '2026-08-09',
+ clockInLocation: {
+ label: 'Synthetic entrance',
+ latitude: 0,
+ longitude: 0,
+ accuracyMeters: 5,
+ },
+ clockOutLocation: null,
+ });
+ expect(response.body.rows[1]).toMatchObject({
+ date: '2026-08-10',
+ clockInLocation: null,
+ clockOutLocation: {
+ label: 'Synthetic exit',
+ latitude: 1,
+ longitude: 1,
+ accuracyMeters: 6,
+ },
+ });
+ const clipped = await ctx.http
+ .get(
+ `/api/reports/working-times?from=2026-08-10&to=2026-08-10&employeeId=${direct.id}&includeLocations=true`,
+ )
+ .set('Authorization', authorization)
+ .expect(200);
+ expect(clipped.body.rows).toHaveLength(1);
+ expect(clipped.body.rows[0].clockInLocation).toBeNull();
+ expect(clipped.body.totals.netMinutes).toBe(120);
+ });
+});
diff --git a/apps/api-e2e/src/api/working-time-reports.e2e.spec.ts b/apps/api-e2e/src/api/working-time-reports.e2e.spec.ts
index 27efd7f..fc6e474 100644
--- a/apps/api-e2e/src/api/working-time-reports.e2e.spec.ts
+++ b/apps/api-e2e/src/api/working-time-reports.e2e.spec.ts
@@ -51,7 +51,7 @@ describe('Project-independent working-time reports', () => {
return { hr, manager, report, foreign };
}
- it('reports closed non-rejected entries regardless of project assignment', async () => {
+ it('reports closed Pending/Approved entries regardless of project assignment', async () => {
const { hr, manager, report, foreign } = await fixture();
const hrToken = await login(ctx.http, hr.email);
const managerToken = await login(ctx.http, manager.email);
@@ -157,14 +157,27 @@ describe('Project-independent working-time reports', () => {
expect(onlyDirect.body.rows).toHaveLength(1);
expect(onlyDirect.body.rows[0].employeeId).toBe(report.id);
- await ctx.http
+ const managerEmployees = await ctx.http
.get('/api/reports/working-times/employees')
.set('Authorization', `Bearer ${managerToken}`)
- .expect(403);
- await ctx.http
+ .expect(200);
+ expect(
+ new Set(
+ managerEmployees.body.map((employee: { id: string }) => employee.id),
+ ),
+ ).toEqual(new Set([manager.id, report.id]));
+ const managerReport = await ctx.http
.get('/api/reports/working-times?from=2026-08-01&to=2026-08-31')
.set('Authorization', `Bearer ${managerToken}`)
- .expect(403);
+ .expect(200);
+ expect(
+ new Set(
+ managerReport.body.rows.map(
+ (row: { employeeId: string }) => row.employeeId,
+ ),
+ ),
+ ).toEqual(new Set([manager.id, report.id]));
+ expect(managerReport.body.totals.netMinutes).toBe(510);
});
it('validates calendar dates, ordering, range length and roles', async () => {
diff --git a/apps/api-e2e/src/support/test-app.ts b/apps/api-e2e/src/support/test-app.ts
index f7a5bb0..c132338 100644
--- a/apps/api-e2e/src/support/test-app.ts
+++ b/apps/api-e2e/src/support/test-app.ts
@@ -23,6 +23,8 @@ const RESET_SQL = `
"SoloPolicy",
"PersonalDay",
"TimeEntryAudit",
+ "EmployeeContractPeriodAudit",
+ "EmployeeContractPeriod",
"Customer",
"TerminalAuditEvent",
"TerminalBookingGrant",
@@ -63,7 +65,10 @@ export async function createTestApp(): Promise {
transformOptions: { enableImplicitConversion: true },
}),
);
- await app.init();
+ // Keep one loopback listener for the suite instead of opening/closing an
+ // ephemeral server for every supertest call (including concurrent requests).
+ // app.close() below owns its lifetime; all HTTP assertions remain unchanged.
+ await app.listen(0, '127.0.0.1');
const prisma = app.get(PrismaService);
const http = request.agent(app.getHttpServer());
diff --git a/apps/api/openapi.json b/apps/api/openapi.json
index 1a60945..fa04afc 100644
--- a/apps/api/openapi.json
+++ b/apps/api/openapi.json
@@ -988,6 +988,83 @@
"tags": ["employees"]
}
},
+ "/api/employees/{id}/contract-periods": {
+ "get": {
+ "operationId": "ContractPeriodsController_list",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/EmployeeContractPeriodDto"
+ }
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["employees"]
+ },
+ "post": {
+ "operationId": "ContractPeriodsController_confirm",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ConfirmContractPeriodDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EmployeeContractPeriodDto"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["employees"]
+ }
+ },
"/api/work-schedules": {
"get": {
"operationId": "WorkSchedulesController_list",
@@ -1860,7 +1937,7 @@
"name": "employeeIds",
"required": false,
"in": "query",
- "description": "Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees; cannot be combined with employeeId.",
+ "description": "Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees in the actor’s scope (HR: all; Manager: self and direct reports); cannot be combined with employeeId.",
"schema": {
"type": "array",
"items": {
@@ -1935,7 +2012,7 @@
"name": "employeeIds",
"required": false,
"in": "query",
- "description": "Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees; cannot be combined with employeeId.",
+ "description": "Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees in the actor’s scope (HR: all; Manager: self and direct reports); cannot be combined with employeeId.",
"schema": {
"type": "array",
"items": {
@@ -2027,6 +2104,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 +3587,17 @@
],
"responses": {
"200": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
+ }
}
},
"security": [
@@ -3419,7 +3622,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3430,14 +3640,22 @@
"tags": ["requests"]
}
},
- "/api/requests/{id}": {
+ "/api/requests/time-adjustment-targets": {
"get": {
- "operationId": "RequestsController_get",
+ "operationId": "RequestsController_timeAdjustmentTargets",
"parameters": [
{
- "name": "id",
+ "name": "from",
"required": true,
- "in": "path",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "to",
+ "required": true,
+ "in": "query",
"schema": {
"type": "string"
}
@@ -3445,7 +3663,17 @@
],
"responses": {
"200": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/TimeEntryDto"
+ }
+ }
+ }
+ }
}
},
"security": [
@@ -3456,9 +3684,9 @@
"tags": ["requests"]
}
},
- "/api/requests/{id}/events": {
+ "/api/requests/{id}/time-entry-audit": {
"get": {
- "operationId": "RequestsController_events",
+ "operationId": "RequestsController_timeEntryAudit",
"parameters": [
{
"name": "id",
@@ -3471,7 +3699,17 @@
],
"responses": {
"200": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/TimeEntryAuditDto"
+ }
+ }
+ }
+ }
}
},
"security": [
@@ -3482,22 +3720,95 @@
"tags": ["requests"]
}
},
- "/api/requests/vacation": {
- "post": {
- "operationId": "RequestsController_createVacation",
- "parameters": [],
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/CreateVacationDto"
- }
+ "/api/requests/{id}": {
+ "get": {
+ "operationId": "RequestsController_get",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
}
}
- },
+ ],
"responses": {
- "201": {
+ "200": {
+ "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": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["requests"]
+ }
+ },
+ "/api/requests/{id}/events": {
+ "get": {
+ "operationId": "RequestsController_events",
+ "parameters": [
+ {
+ "name": "id",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
"description": ""
}
},
@@ -3509,6 +3820,83 @@
"tags": ["requests"]
}
},
+ "/api/requests/vacation": {
+ "post": {
+ "operationId": "RequestsController_createVacation",
+ "parameters": [],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateVacationDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "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": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["requests"]
+ }
+ },
"/api/requests/{id}/approve": {
"post": {
"operationId": "RequestsController_approve",
@@ -3534,7 +3922,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3570,7 +3965,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3606,7 +4008,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3642,7 +4051,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3678,7 +4094,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3714,7 +4137,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3750,7 +4180,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3786,7 +4223,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3822,7 +4266,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -3858,7 +4309,14 @@
},
"responses": {
"201": {
- "description": ""
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RequestDto"
+ }
+ }
+ }
}
},
"security": [
@@ -5383,34 +5841,234 @@
},
"required": ["password"]
},
- "BreakRuleDto": {
+ "EmployeeContractPeriodDto": {
"type": "object",
"properties": {
- "afterMinutes": {
- "type": "number",
- "minimum": 0,
- "maximum": 1440,
- "description": "Inclusive attendance threshold in minutes."
+ "id": {
+ "type": "string",
+ "format": "uuid"
},
- "breakMinutes": {
- "type": "number",
- "minimum": 0,
- "maximum": 1440,
- "description": "Total deduction in minutes; must not exceed afterMinutes."
- }
- },
- "required": ["afterMinutes", "breakMinutes"]
- },
- "CoreTimeWindowDto": {
- "type": "object",
- "properties": {
- "label": {
- "type": "object",
- "nullable": true,
- "maxLength": 120,
- "example": "Vormittag"
+ "employeeId": {
+ "type": "string",
+ "format": "uuid"
},
- "start": {
+ "effectiveFrom": {
+ "type": "string",
+ "format": "date"
+ },
+ "effectiveTo": {
+ "type": "string",
+ "format": "date",
+ "nullable": true
+ },
+ "employmentStart": {
+ "type": "string",
+ "format": "date"
+ },
+ "employmentEnd": {
+ "type": "string",
+ "format": "date",
+ "nullable": true
+ },
+ "weeklyHours": {
+ "type": "number"
+ },
+ "timeModel": {
+ "type": "string",
+ "enum": [
+ "Teilzeit",
+ "Vollzeit",
+ "Vertrauensarbeitszeit",
+ "Gleitzeit"
+ ]
+ },
+ "workingDays": {
+ "type": "number"
+ },
+ "holidayCalendar": {
+ "type": "string"
+ },
+ "holidayDates": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "scheduleLabel": {
+ "type": "string",
+ "nullable": true
+ },
+ "confirmationNote": {
+ "type": "string"
+ },
+ "confirmedById": {
+ "type": "string",
+ "format": "uuid",
+ "nullable": true
+ },
+ "revision": {
+ "type": "number"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ "required": [
+ "id",
+ "employeeId",
+ "effectiveFrom",
+ "effectiveTo",
+ "employmentStart",
+ "employmentEnd",
+ "weeklyHours",
+ "timeModel",
+ "workingDays",
+ "holidayCalendar",
+ "holidayDates",
+ "scheduleLabel",
+ "confirmationNote",
+ "confirmedById",
+ "revision",
+ "createdAt"
+ ]
+ },
+ "ConfirmContractPeriodDto": {
+ "type": "object",
+ "properties": {
+ "effectiveFrom": {
+ "type": "string",
+ "format": "date"
+ },
+ "effectiveTo": {
+ "type": "string",
+ "format": "date",
+ "nullable": true
+ },
+ "employmentStart": {
+ "type": "string",
+ "format": "date"
+ },
+ "employmentEnd": {
+ "type": "string",
+ "format": "date",
+ "nullable": true
+ },
+ "weeklyHours": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 168
+ },
+ "timeModel": {
+ "type": "string",
+ "enum": [
+ "Teilzeit",
+ "Vollzeit",
+ "Vertrauensarbeitszeit",
+ "Gleitzeit"
+ ]
+ },
+ "workingDays": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 127,
+ "description": "Immutable weekday mask: Mon=1, Tue=2, ..., Sun=64. Equal daily target, rounded to whole minutes."
+ },
+ "holidayCalendar": {
+ "type": "string",
+ "enum": [
+ "NONE",
+ "DE-BW",
+ "DE-BY",
+ "DE-BE",
+ "DE-BB",
+ "DE-HB",
+ "DE-HH",
+ "DE-HE",
+ "DE-MV",
+ "DE-NI",
+ "DE-NW",
+ "DE-RP",
+ "DE-SL",
+ "DE-SN",
+ "DE-ST",
+ "DE-SH",
+ "DE-TH"
+ ]
+ },
+ "holidayDates": {
+ "description": "Explicit non-working dates, supplementing the snapshot calendar.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "scheduleLabel": {
+ "type": "string",
+ "nullable": true,
+ "maxLength": 120
+ },
+ "confirmationNote": {
+ "type": "string",
+ "minLength": 5,
+ "maxLength": 1000,
+ "description": "HR source/confirmation explanation. No automatic historical inference."
+ },
+ "confirmed": {
+ "type": "boolean",
+ "enum": [true],
+ "description": "Explicit confirmation that these dated facts are known, not inferred from the current employee profile."
+ },
+ "supersedesPeriodId": {
+ "type": "string",
+ "format": "uuid",
+ "description": "Explicitly close this prior period on the day before effectiveFrom; no other facts are changed."
+ },
+ "expectedRevision": {
+ "type": "number",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "effectiveFrom",
+ "employmentStart",
+ "weeklyHours",
+ "timeModel",
+ "workingDays",
+ "holidayCalendar",
+ "holidayDates",
+ "confirmationNote",
+ "confirmed"
+ ]
+ },
+ "BreakRuleDto": {
+ "type": "object",
+ "properties": {
+ "afterMinutes": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1440,
+ "description": "Inclusive attendance threshold in minutes."
+ },
+ "breakMinutes": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1440,
+ "description": "Total deduction in minutes; must not exceed afterMinutes."
+ }
+ },
+ "required": ["afterMinutes", "breakMinutes"]
+ },
+ "CoreTimeWindowDto": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "object",
+ "nullable": true,
+ "maxLength": 120,
+ "example": "Vormittag"
+ },
+ "start": {
"type": "string",
"pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
"example": "10:00"
@@ -5940,6 +6598,69 @@
},
"required": ["id", "firstName", "lastName"]
},
+ "WorkingTimeComparisonDto": {
+ "type": "object",
+ "properties": {
+ "employeeId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "employeeName": {
+ "type": "string"
+ },
+ "targetMinutes": {
+ "type": "number",
+ "nullable": true,
+ "minimum": 0
+ },
+ "actualMinutes": {
+ "type": "number",
+ "minimum": 0
+ },
+ "differenceMinutes": {
+ "type": "number",
+ "nullable": true
+ },
+ "targetStatus": {
+ "type": "string",
+ "enum": ["Known", "Unknown"]
+ },
+ "unknownDates": {
+ "description": "Calendar dates lacking a confirmed contract snapshot, including non-working dates.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "reason": {
+ "type": "string",
+ "enum": ["CONTRACT_HISTORY_GAP"],
+ "nullable": true
+ },
+ "indicator": {
+ "type": "string",
+ "enum": ["Unknown", "Negative", "Balanced", "Positive", "Excess"],
+ "description": "Selected-period review indicator only; not a legal limit or a carried overtime balance. Excess means difference >= 600 minutes."
+ },
+ "openEntryCount": {
+ "type": "number",
+ "minimum": 0,
+ "description": "Non-voided, non-rejected open entries started before the period end; excluded from actual minutes."
+ }
+ },
+ "required": [
+ "employeeId",
+ "employeeName",
+ "targetMinutes",
+ "actualMinutes",
+ "differenceMinutes",
+ "targetStatus",
+ "unknownDates",
+ "reason",
+ "indicator",
+ "openEntryCount"
+ ]
+ },
"WorkingTimeReportLocationDto": {
"type": "object",
"properties": {
@@ -5995,7 +6716,7 @@
},
"status": {
"type": "string",
- "enum": ["Open", "Pending", "Approved"]
+ "enum": ["Pending", "Approved"]
},
"grossMinutes": {
"type": "number",
@@ -6070,6 +6791,24 @@
"type": "string",
"format": "date"
},
+ "timeZone": {
+ "type": "string"
+ },
+ "timeDefinition": {
+ "type": "string",
+ "enum": ["net_working_time"]
+ },
+ "excessThresholdMinutes": {
+ "type": "number",
+ "enum": [600],
+ "description": "Period-review threshold, not a statutory working-time limit."
+ },
+ "comparisons": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/WorkingTimeComparisonDto"
+ }
+ },
"rows": {
"type": "array",
"items": {
@@ -6080,7 +6819,16 @@
"$ref": "#/components/schemas/WorkingTimeReportTotalsDto"
}
},
- "required": ["from", "to", "rows", "totals"]
+ "required": [
+ "from",
+ "to",
+ "timeZone",
+ "timeDefinition",
+ "excessThresholdMinutes",
+ "comparisons",
+ "rows",
+ "totals"
+ ]
},
"TimeSummaryDto": {
"type": "object",
@@ -6274,69 +7022,181 @@
"format": "uuid",
"nullable": true
},
- "serviceOrderNo": {
+ "serviceOrderNo": {
+ "type": "string",
+ "nullable": true
+ },
+ "serviceOrderTitle": {
+ "type": "string",
+ "nullable": true
+ },
+ "activity": {
+ "type": "string",
+ "nullable": true
+ },
+ "summary": {
+ "nullable": true,
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/TimeSummaryDto"
+ }
+ ]
+ }
+ },
+ "required": [
+ "note",
+ "revision",
+ "billable",
+ "voidedAt",
+ "captureGroupId",
+ "approvalMode",
+ "id",
+ "employeeId",
+ "clockIn",
+ "clockOut",
+ "source",
+ "status",
+ "requiresApproval",
+ "latitude",
+ "longitude",
+ "accuracyMeters",
+ "terminalDistanceMeters",
+ "terminalRadiusMeters",
+ "terminalMaxAccuracyMeters",
+ "positionTimestamp",
+ "clockOutLatitude",
+ "clockOutLongitude",
+ "clockOutAccuracyMeters",
+ "clockOutTerminalDistanceMeters",
+ "clockOutTerminalRadiusMeters",
+ "clockOutTerminalMaxAccuracyMeters",
+ "clockOutPositionTimestamp",
+ "terminalId",
+ "clockOutTerminalId",
+ "clockInChallengeId",
+ "clockOutChallengeId",
+ "projectId",
+ "projectCode",
+ "projectName",
+ "serviceOrderId",
+ "serviceOrderNo",
+ "serviceOrderTitle",
+ "activity",
+ "summary"
+ ]
+ },
+ "TimeEntryApprovalDto": {
+ "type": "object",
+ "properties": {
+ "entry": {
+ "$ref": "#/components/schemas/TimeEntryDto"
+ },
+ "revision": {
+ "type": "number",
+ "minimum": 0
+ },
+ "employeeName": {
+ "type": "string"
+ },
+ "managerId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "managerName": {
+ "type": "string",
+ "nullable": true
+ },
+ "state": {
+ "type": "string",
+ "enum": [
+ "Open",
+ "BlockedManager",
+ "PendingManager",
+ "PendingHr",
+ "Approved",
+ "Rejected",
+ "Voided"
+ ]
+ },
+ "canDecide": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "entry",
+ "revision",
+ "employeeName",
+ "managerId",
+ "managerName",
+ "state",
+ "canDecide"
+ ]
+ },
+ "TimeEntryApprovalEventDto": {
+ "type": "object",
+ "properties": {
+ "requestId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "id": {
+ "type": "string"
+ },
+ "actorId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "actorName": {
+ "type": "string",
+ "nullable": true
+ },
+ "action": {
+ "type": "string"
+ },
+ "reason": {
+ "type": "string",
+ "nullable": true
+ },
+ "occurredAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ "required": [
+ "requestId",
+ "id",
+ "actorId",
+ "actorName",
+ "action",
+ "reason",
+ "occurredAt"
+ ]
+ },
+ "DecideTimeEntryApprovalDto": {
+ "type": "object",
+ "properties": {
+ "revision": {
+ "type": "number",
+ "minimum": 0
+ },
+ "stage": {
"type": "string",
- "nullable": true
+ "enum": ["Manager", "HR"]
},
- "serviceOrderTitle": {
+ "outcome": {
"type": "string",
- "nullable": true
+ "enum": ["Approve", "Reject"]
},
- "activity": {
+ "note": {
"type": "string",
- "nullable": true
- },
- "summary": {
- "nullable": true,
- "type": "object",
- "allOf": [
- {
- "$ref": "#/components/schemas/TimeSummaryDto"
- }
- ]
+ "maxLength": 2000
}
},
- "required": [
- "note",
- "revision",
- "billable",
- "voidedAt",
- "captureGroupId",
- "approvalMode",
- "id",
- "employeeId",
- "clockIn",
- "clockOut",
- "source",
- "status",
- "requiresApproval",
- "latitude",
- "longitude",
- "accuracyMeters",
- "terminalDistanceMeters",
- "terminalRadiusMeters",
- "terminalMaxAccuracyMeters",
- "positionTimestamp",
- "clockOutLatitude",
- "clockOutLongitude",
- "clockOutAccuracyMeters",
- "clockOutTerminalDistanceMeters",
- "clockOutTerminalRadiusMeters",
- "clockOutTerminalMaxAccuracyMeters",
- "clockOutPositionTimestamp",
- "terminalId",
- "clockOutTerminalId",
- "clockInChallengeId",
- "clockOutChallengeId",
- "projectId",
- "projectCode",
- "projectName",
- "serviceOrderId",
- "serviceOrderNo",
- "serviceOrderTitle",
- "activity",
- "summary"
- ]
+ "required": ["revision", "stage", "outcome"]
},
"ClockInDto": {
"type": "object",
@@ -6780,6 +7640,15 @@
"TimeEntryAuditDto": {
"type": "object",
"properties": {
+ "actorName": {
+ "type": "string",
+ "nullable": true
+ },
+ "requestId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
"id": {
"type": "string",
"format": "uuid"
@@ -6814,6 +7683,7 @@
}
},
"required": [
+ "requestId",
"id",
"timeEntryId",
"actorId",
@@ -7656,9 +8526,160 @@
},
"required": ["baseDays", "carryOverDays", "adjustmentDays"]
},
+ "RequestDto": {
+ "type": "object",
+ "properties": {
+ "targetTimeEntryId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "expectedTargetRevision": {
+ "type": "number",
+ "nullable": true,
+ "minimum": 0
+ },
+ "targetTimeEntryBefore": {
+ "type": "object",
+ "nullable": true,
+ "additionalProperties": true,
+ "description": "Immutable server-captured original time-entry values, including id, revision, clockIn, clockOut and break-policy snapshot."
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "employeeId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "employeeName": {
+ "type": "string"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["Vacation", "HomeOffice", "SpecialLeave", "TimeAdjustment"]
+ },
+ "status": {
+ "type": "string"
+ },
+ "workflowState": {
+ "type": "string"
+ },
+ "from": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "to": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "reason": {
+ "type": "string",
+ "nullable": true
+ },
+ "requiresApproval": {
+ "type": "boolean"
+ },
+ "approverId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "currentApproverId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "substituteId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "substituteAcceptedAt": {
+ "type": "string",
+ "nullable": true,
+ "format": "date-time"
+ },
+ "hrConfirmedAt": {
+ "type": "string",
+ "nullable": true,
+ "format": "date-time"
+ },
+ "cancelledAt": {
+ "type": "string",
+ "nullable": true,
+ "format": "date-time"
+ },
+ "calculatedDays": {
+ "type": "number"
+ },
+ "halfDayStart": {
+ "type": "boolean"
+ },
+ "halfDayEnd": {
+ "type": "boolean"
+ },
+ "decidedAt": {
+ "type": "string",
+ "nullable": true,
+ "format": "date-time"
+ },
+ "decisionNote": {
+ "type": "string",
+ "nullable": true
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ "required": [
+ "targetTimeEntryId",
+ "expectedTargetRevision",
+ "targetTimeEntryBefore",
+ "id",
+ "employeeId",
+ "employeeName",
+ "updatedAt",
+ "type",
+ "status",
+ "workflowState",
+ "from",
+ "to",
+ "reason",
+ "requiresApproval",
+ "approverId",
+ "currentApproverId",
+ "substituteId",
+ "substituteAcceptedAt",
+ "hrConfirmedAt",
+ "cancelledAt",
+ "calculatedDays",
+ "halfDayStart",
+ "halfDayEnd",
+ "decidedAt",
+ "decisionNote",
+ "createdAt"
+ ]
+ },
"CreateRequestDto": {
"type": "object",
"properties": {
+ "targetTimeEntryId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "expectedTargetRevision": {
+ "type": "number",
+ "nullable": true,
+ "minimum": 0
+ },
"employeeId": {
"type": "string",
"format": "uuid"
@@ -7678,7 +8699,7 @@
"example": "2026-08-07T00:00:00.000Z"
},
"reason": {
- "type": "object",
+ "type": "string",
"nullable": true,
"maxLength": 2000
}
@@ -7688,6 +8709,16 @@
"CreateVacationDto": {
"type": "object",
"properties": {
+ "targetTimeEntryId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "expectedTargetRevision": {
+ "type": "number",
+ "nullable": true,
+ "minimum": 0
+ },
"employeeId": {
"type": "string",
"format": "uuid"
@@ -7701,12 +8732,12 @@
"format": "date-time"
},
"substituteId": {
- "type": "object",
+ "type": "string",
"nullable": true,
"format": "uuid"
},
"reason": {
- "type": "object",
+ "type": "string",
"nullable": true,
"maxLength": 2000
},
@@ -7723,6 +8754,55 @@
},
"required": ["employeeId", "from", "to"]
},
+ "UpdateDraftRequestDto": {
+ "type": "object",
+ "properties": {
+ "targetTimeEntryId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "expectedTargetRevision": {
+ "type": "number",
+ "nullable": true,
+ "minimum": 0
+ },
+ "from": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "to": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "substituteId": {
+ "type": "string",
+ "nullable": true,
+ "format": "uuid"
+ },
+ "reason": {
+ "type": "string",
+ "nullable": true,
+ "maxLength": 2000
+ },
+ "halfDayStart": {
+ "type": "boolean",
+ "default": false,
+ "description": "Take the first day as a half-day"
+ },
+ "halfDayEnd": {
+ "type": "boolean",
+ "default": false,
+ "description": "Take the last day as a half-day"
+ },
+ "expectedUpdatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "updatedAt from the version being edited; stale writes return 409"
+ }
+ },
+ "required": ["from", "to", "expectedUpdatedAt"]
+ },
"TransitionDto": {
"type": "object",
"properties": {
@@ -7731,7 +8811,7 @@
"format": "uuid"
},
"note": {
- "type": "object",
+ "type": "string",
"nullable": true,
"maxLength": 2000
}
@@ -7746,7 +8826,7 @@
"format": "uuid"
},
"note": {
- "type": "object",
+ "type": "string",
"nullable": true,
"maxLength": 2000
},
@@ -7786,7 +8866,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/employees/contract-periods.controller.ts b/apps/api/src/app/employees/contract-periods.controller.ts
new file mode 100644
index 0000000..6d779f8
--- /dev/null
+++ b/apps/api/src/app/employees/contract-periods.controller.ts
@@ -0,0 +1,51 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Param,
+ ParseUUIDPipe,
+ Post,
+ UseGuards,
+} from '@nestjs/common';
+import {
+ ApiBearerAuth,
+ ApiCreatedResponse,
+ ApiOkResponse,
+ ApiTags,
+} from '@nestjs/swagger';
+import { CurrentUser } from '../auth/current-user.decorator';
+import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import type { JwtUser } from '../auth/jwt.strategy';
+import { Roles } from '../auth/roles.decorator';
+import { RolesGuard } from '../auth/roles.guard';
+import {
+ ConfirmContractPeriodDto,
+ EmployeeContractPeriodDto,
+} from './contract-periods.dto';
+import { ContractPeriodsService } from './contract-periods.service';
+
+@ApiTags('employees')
+@ApiBearerAuth()
+@UseGuards(JwtAuthGuard, RolesGuard)
+@Roles('HRAdmin')
+@Controller('employees/:id/contract-periods')
+export class ContractPeriodsController {
+ constructor(private readonly contracts: ContractPeriodsService) {}
+ @Get()
+ @ApiOkResponse({ type: [EmployeeContractPeriodDto] })
+ list(
+ @Param('id', new ParseUUIDPipe()) id: string,
+ @CurrentUser() actor: JwtUser,
+ ) {
+ return this.contracts.list(id, actor);
+ }
+ @Post()
+ @ApiCreatedResponse({ type: EmployeeContractPeriodDto })
+ confirm(
+ @Param('id', new ParseUUIDPipe()) id: string,
+ @Body() dto: ConfirmContractPeriodDto,
+ @CurrentUser() actor: JwtUser,
+ ) {
+ return this.contracts.confirm(id, dto, actor);
+ }
+}
diff --git a/apps/api/src/app/employees/contract-periods.dto.ts b/apps/api/src/app/employees/contract-periods.dto.ts
new file mode 100644
index 0000000..9707558
--- /dev/null
+++ b/apps/api/src/app/employees/contract-periods.dto.ts
@@ -0,0 +1,129 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Transform } from 'class-transformer';
+import {
+ ArrayMaxSize,
+ ArrayUnique,
+ Equals,
+ IsArray,
+ IsEnum,
+ IsInt,
+ IsNumber,
+ IsOptional,
+ ValidateIf,
+ IsString,
+ IsUUID,
+ Matches,
+ Max,
+ MaxLength,
+ Min,
+ MinLength,
+} from 'class-validator';
+import { TimeModel } from '@prisma/client';
+import { HOLIDAY_CALENDARS } from 'shared';
+
+export class ConfirmContractPeriodDto {
+ @ApiProperty({ format: 'date' })
+ @Matches(/^\d{4}-\d{2}-\d{2}$/)
+ effectiveFrom!: string;
+ @ApiPropertyOptional({ type: String, format: 'date', nullable: true })
+ @IsOptional()
+ @Matches(/^\d{4}-\d{2}-\d{2}$/)
+ effectiveTo?: string | null;
+ @ApiProperty({ format: 'date' })
+ @Matches(/^\d{4}-\d{2}-\d{2}$/)
+ employmentStart!: string;
+ @ApiPropertyOptional({ type: String, format: 'date', nullable: true })
+ @IsOptional()
+ @Matches(/^\d{4}-\d{2}-\d{2}$/)
+ employmentEnd?: string | null;
+ @ApiProperty({ minimum: 0, maximum: 168 })
+ @IsNumber({ maxDecimalPlaces: 2 })
+ @Min(0)
+ @Max(168)
+ weeklyHours!: number;
+ @ApiProperty({ enum: TimeModel })
+ @IsEnum(TimeModel)
+ timeModel!: TimeModel;
+ @ApiProperty({
+ minimum: 0,
+ maximum: 127,
+ description:
+ 'Immutable weekday mask: Mon=1, Tue=2, ..., Sun=64. Equal daily target, rounded to whole minutes.',
+ })
+ @IsInt()
+ @Min(0)
+ @Max(127)
+ workingDays!: number;
+ @ApiProperty({ enum: HOLIDAY_CALENDARS })
+ @IsEnum(HOLIDAY_CALENDARS)
+ holidayCalendar!: string;
+ @ApiProperty({
+ type: [String],
+ description:
+ 'Explicit non-working dates, supplementing the snapshot calendar.',
+ })
+ @IsArray()
+ @ArrayMaxSize(3660)
+ @ArrayUnique()
+ @Matches(/^\d{4}-\d{2}-\d{2}$/, { each: true })
+ holidayDates!: string[];
+ @ApiPropertyOptional({ type: String, nullable: true, maxLength: 120 })
+ @IsOptional()
+ @IsString()
+ @MaxLength(120)
+ scheduleLabel?: string | null;
+ @ApiProperty({
+ minLength: 5,
+ maxLength: 1000,
+ description:
+ 'HR source/confirmation explanation. No automatic historical inference.',
+ })
+ @IsString()
+ @MinLength(5)
+ @MaxLength(1000)
+ confirmationNote!: string;
+ @ApiProperty({
+ enum: [true],
+ description:
+ 'Explicit confirmation that these dated facts are known, not inferred from the current employee profile.',
+ })
+ @Transform(({ obj }) => obj.confirmed)
+ @Equals(true)
+ confirmed!: true;
+ @ApiPropertyOptional({
+ format: 'uuid',
+ description:
+ 'Explicitly close this prior period on the day before effectiveFrom; no other facts are changed.',
+ })
+ @ValidateIf((_object, value) => value !== undefined)
+ @IsUUID()
+ supersedesPeriodId?: string;
+ @ApiPropertyOptional({ minimum: 0 })
+ @ValidateIf((_object, value) => value !== undefined)
+ @IsInt()
+ @Min(0)
+ expectedRevision?: number;
+}
+
+export class EmployeeContractPeriodDto {
+ @ApiProperty({ format: 'uuid' }) id!: string;
+ @ApiProperty({ format: 'uuid' }) employeeId!: string;
+ @ApiProperty({ format: 'date' }) effectiveFrom!: string;
+ @ApiProperty({ type: String, format: 'date', nullable: true }) effectiveTo!:
+ | string
+ | null;
+ @ApiProperty({ format: 'date' }) employmentStart!: string;
+ @ApiProperty({ type: String, format: 'date', nullable: true })
+ employmentEnd!: string | null;
+ @ApiProperty() weeklyHours!: number;
+ @ApiProperty({ enum: TimeModel }) timeModel!: TimeModel;
+ @ApiProperty() workingDays!: number;
+ @ApiProperty() holidayCalendar!: string;
+ @ApiProperty({ type: [String] }) holidayDates!: string[];
+ @ApiProperty({ type: String, nullable: true }) scheduleLabel!: string | null;
+ @ApiProperty() confirmationNote!: string;
+ @ApiProperty({ type: String, format: 'uuid', nullable: true })
+ confirmedById!: string | null;
+ @ApiProperty() revision!: number;
+ @ApiProperty({ format: 'date-time' }) createdAt!: string;
+}
diff --git a/apps/api/src/app/employees/contract-periods.service.ts b/apps/api/src/app/employees/contract-periods.service.ts
new file mode 100644
index 0000000..5b7c8e3
--- /dev/null
+++ b/apps/api/src/app/employees/contract-periods.service.ts
@@ -0,0 +1,236 @@
+import {
+ BadRequestException,
+ ConflictException,
+ ForbiddenException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import type { EmployeeContractPeriod, Prisma } from '@prisma/client';
+import type { JwtUser } from '../auth/jwt.strategy';
+import { PrismaService } from '../prisma/prisma.service';
+import {
+ ConfirmContractPeriodDto,
+ EmployeeContractPeriodDto,
+} from './contract-periods.dto';
+
+const DAY_MS = 86_400_000;
+function dateOnly(value: string): Date {
+ const date = new Date(`${value}T00:00:00.000Z`);
+ if (
+ !/^\d{4}-\d{2}-\d{2}$/.test(value) ||
+ !Number.isFinite(date.getTime()) ||
+ date.toISOString().slice(0, 10) !== value ||
+ value < '0001-01-01'
+ )
+ throw new BadRequestException('Invalid contract calendar date');
+ return date;
+}
+export function toContractPeriodDto(
+ period: EmployeeContractPeriod,
+): EmployeeContractPeriodDto {
+ return {
+ id: period.id,
+ employeeId: period.employeeId,
+ effectiveFrom: period.effectiveFrom.toISOString().slice(0, 10),
+ effectiveTo: period.effectiveTo?.toISOString().slice(0, 10) ?? null,
+ employmentStart: period.employmentStart.toISOString().slice(0, 10),
+ employmentEnd: period.employmentEnd?.toISOString().slice(0, 10) ?? null,
+ weeklyHours: Number(period.weeklyHours),
+ timeModel: period.timeModel,
+ workingDays: period.workingDays,
+ holidayCalendar: period.holidayCalendar,
+ holidayDates: period.holidayDates,
+ scheduleLabel: period.scheduleLabel,
+ confirmationNote: period.confirmationNote,
+ confirmedById: period.confirmedById,
+ revision: period.revision,
+ createdAt: period.createdAt.toISOString(),
+ };
+}
+
+@Injectable()
+export class ContractPeriodsService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async list(
+ employeeId: string,
+ actor: JwtUser,
+ ): Promise {
+ return this.prisma.$transaction(
+ async (tx) => {
+ await this.assertHr(tx, actor);
+ if (
+ !(await tx.employee.findUnique({
+ where: { id: employeeId },
+ select: { id: true },
+ }))
+ )
+ throw new NotFoundException('Employee not found');
+ return (
+ await tx.employeeContractPeriod.findMany({
+ where: { employeeId },
+ orderBy: { effectiveFrom: 'asc' },
+ })
+ ).map(toContractPeriodDto);
+ },
+ { isolationLevel: 'RepeatableRead' },
+ );
+ }
+
+ async confirm(
+ employeeId: string,
+ dto: ConfirmContractPeriodDto,
+ actor: JwtUser,
+ ): Promise {
+ const effectiveFrom = dateOnly(dto.effectiveFrom);
+ const effectiveTo =
+ dto.effectiveTo == null ? null : dateOnly(dto.effectiveTo);
+ const employmentStart = dateOnly(dto.employmentStart);
+ const employmentEnd =
+ dto.employmentEnd == null ? null : dateOnly(dto.employmentEnd);
+ dto.holidayDates.forEach(dateOnly);
+ if (dto.confirmed !== true || dto.confirmationNote.trim().length < 5)
+ throw new BadRequestException(
+ 'Explicit HR confirmation and source note are required',
+ );
+ if (
+ (effectiveTo && effectiveTo < effectiveFrom) ||
+ (employmentEnd && employmentEnd < employmentStart)
+ )
+ throw new BadRequestException('Contract date bounds are reversed');
+ const workingDayCount = Array.from({ length: 7 }, (_, bit) =>
+ Number(Boolean(dto.workingDays & (1 << bit))),
+ ).reduce((a, b) => a + b, 0);
+ if (
+ dto.weeklyHours > 0 &&
+ (!workingDayCount || dto.weeklyHours / workingDayCount > 24)
+ )
+ throw new BadRequestException(
+ 'Weekly hours cannot exceed 24 hours per working day',
+ );
+ if (
+ (dto.supersedesPeriodId !== undefined) !==
+ (dto.expectedRevision !== undefined)
+ )
+ throw new BadRequestException(
+ 'Supersession requires both period and expected revision',
+ );
+ return this.prisma.$transaction(async (tx) => {
+ // Same cross-instance lock order as time capture and profile changes.
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(7261500)`;
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${employeeId}, 0))`;
+ await tx.$queryRaw`SELECT id FROM "Employee" WHERE id = ${employeeId}::uuid FOR UPDATE`;
+ await this.assertHr(tx, actor);
+ if (
+ !(await tx.employee.findUnique({
+ where: { id: employeeId },
+ select: { id: true },
+ }))
+ )
+ throw new NotFoundException('Employee not found');
+ const periods = await tx.employeeContractPeriod.findMany({
+ where: { employeeId },
+ });
+ const previous = dto.supersedesPeriodId
+ ? periods.find((period) => period.id === dto.supersedesPeriodId)
+ : undefined;
+ if (
+ dto.supersedesPeriodId &&
+ (!previous ||
+ previous.revision !== dto.expectedRevision ||
+ previous.effectiveFrom >= effectiveFrom ||
+ (previous.effectiveTo && previous.effectiveTo < effectiveFrom))
+ )
+ throw new ConflictException(
+ 'The period changed or cannot be superseded at this date; reload the history',
+ );
+ if (
+ periods.some(
+ (period) =>
+ period.id !== previous?.id &&
+ (!effectiveTo || period.effectiveFrom <= effectiveTo) &&
+ (!period.effectiveTo || period.effectiveTo >= effectiveFrom),
+ )
+ )
+ throw new ConflictException(
+ 'Contract periods must not overlap; explicitly supersede the covering period',
+ );
+ if (previous) {
+ const result = await tx.employeeContractPeriod.updateMany({
+ where: { id: previous.id, revision: dto.expectedRevision },
+ data: {
+ effectiveTo: new Date(effectiveFrom.getTime() - DAY_MS),
+ revision: { increment: 1 },
+ },
+ });
+ if (result.count !== 1)
+ throw new ConflictException('Contract history changed; reload');
+ const after = await tx.employeeContractPeriod.findUniqueOrThrow({
+ where: { id: previous.id },
+ });
+ await tx.employeeContractPeriodAudit.create({
+ data: {
+ periodId: previous.id,
+ actorId: actor.id,
+ action: 'Superseded',
+ before: toContractPeriodDto(
+ previous,
+ ) as unknown as Prisma.InputJsonObject,
+ after: toContractPeriodDto(
+ after,
+ ) as unknown as Prisma.InputJsonObject,
+ },
+ });
+ }
+ const period = await tx.employeeContractPeriod.create({
+ data: {
+ employeeId,
+ effectiveFrom,
+ effectiveTo,
+ employmentStart,
+ employmentEnd,
+ weeklyHours: dto.weeklyHours,
+ timeModel: dto.timeModel,
+ workingDays: dto.workingDays,
+ holidayCalendar: dto.holidayCalendar,
+ holidayDates: [...dto.holidayDates].sort(),
+ scheduleLabel: dto.scheduleLabel?.trim() || null,
+ confirmationNote: dto.confirmationNote.trim(),
+ confirmedById: actor.id,
+ },
+ });
+ const after = toContractPeriodDto(period);
+ await tx.employeeContractPeriodAudit.create({
+ data: {
+ periodId: period.id,
+ actorId: actor.id,
+ action: 'Confirmed',
+ after: after as unknown as Prisma.InputJsonObject,
+ },
+ });
+ return after;
+ });
+ }
+
+ private async assertHr(tx: Prisma.TransactionClient, identity: JwtUser) {
+ const settings = await tx.installationSettings.findUnique({
+ where: { id: 1 },
+ });
+ if (settings?.mode === 'Solo')
+ throw new ForbiddenException(
+ 'Contract history management is a Team capability',
+ );
+ const actor = await tx.employee.findUnique({
+ where: { id: identity.id },
+ select: { role: true, isActive: true, authVersion: true },
+ });
+ if (
+ !actor?.isActive ||
+ actor.role !== 'HRAdmin' ||
+ actor.authVersion !== (identity.authVersion ?? 0)
+ )
+ throw new ForbiddenException(
+ 'Only active HR may confirm contract history',
+ );
+ }
+}
diff --git a/apps/api/src/app/employees/employees.module.ts b/apps/api/src/app/employees/employees.module.ts
index b13164d..11b200c 100644
--- a/apps/api/src/app/employees/employees.module.ts
+++ b/apps/api/src/app/employees/employees.module.ts
@@ -1,3 +1,5 @@
+import { ContractPeriodsController } from './contract-periods.controller';
+import { ContractPeriodsService } from './contract-periods.service';
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { EmployeesController } from './employees.controller';
@@ -5,8 +7,8 @@ import { EmployeesService } from './employees.service';
@Module({
imports: [AuthModule],
- controllers: [EmployeesController],
- providers: [EmployeesService],
+ controllers: [EmployeesController, ContractPeriodsController],
+ providers: [EmployeesService, ContractPeriodsService],
exports: [EmployeesService],
})
export class EmployeesModule {}
diff --git a/apps/api/src/app/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/reports/reports.controller.ts b/apps/api/src/app/reports/reports.controller.ts
index 4cf0e10..7dba8a4 100644
--- a/apps/api/src/app/reports/reports.controller.ts
+++ b/apps/api/src/app/reports/reports.controller.ts
@@ -54,17 +54,22 @@ export class ReportsController {
}
@Get('working-times/employees')
+ @Roles('Manager', 'HRAdmin')
@ApiOkResponse({ type: [WorkingTimeReportEmployeeDto] })
- workingTimeEmployees(): Promise {
- return this.reports.workingTimeEmployees();
+ workingTimeEmployees(
+ @CurrentUser() user: JwtUser,
+ ): Promise {
+ return this.reports.workingTimeEmployees(user.id);
}
@Get('working-times')
+ @Roles('Manager', 'HRAdmin')
@ApiOkResponse({ type: WorkingTimeReportDto })
workingTimes(
+ @CurrentUser() user: JwtUser,
@Query() query: WorkingTimeReportQueryDto,
): Promise {
- return this.reports.workingTimes(query);
+ return this.reports.workingTimes(user.id, query);
}
@Get('working-times.pdf')
@@ -76,10 +81,11 @@ export class ReportsController {
},
})
async workingTimesPdf(
+ @CurrentUser() user: JwtUser,
@Query() query: WorkingTimePdfQueryDto,
@Res() response: Response,
): Promise {
- const pdf = await this.reports.workingTimesPdf(query);
+ const pdf = await this.reports.workingTimesPdf(user.id, query);
response.setHeader('Content-Type', 'application/pdf');
response.setHeader(
'Content-Disposition',
diff --git a/apps/api/src/app/reports/reports.dto.ts b/apps/api/src/app/reports/reports.dto.ts
index d953fcf..e191381 100644
--- a/apps/api/src/app/reports/reports.dto.ts
+++ b/apps/api/src/app/reports/reports.dto.ts
@@ -30,7 +30,7 @@ export class WorkingTimeReportQueryDto {
@ApiPropertyOptional({
type: [String],
description:
- 'Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees; cannot be combined with employeeId.',
+ 'Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees in the actor’s scope (HR: all; Manager: self and direct reports); cannot be combined with employeeId.',
})
@IsOptional()
@Transform(({ value }) => (Array.isArray(value) ? value : [value]))
@@ -101,7 +101,7 @@ export class WorkingTimeReportRowDto {
@ApiProperty({ format: 'date-time' })
clockOut!: string;
- @ApiProperty({ enum: ['Open', 'Pending', 'Approved'] })
+ @ApiProperty({ enum: ['Pending', 'Approved'] })
status!: string;
@ApiProperty({ minimum: 0 })
@@ -131,6 +131,50 @@ export class WorkingTimeReportTotalsDto {
netMinutes!: number;
}
+export class WorkingTimeComparisonDto {
+ @ApiProperty({ format: 'uuid' })
+ employeeId!: string;
+
+ @ApiProperty()
+ employeeName!: string;
+
+ @ApiProperty({ type: Number, nullable: true, minimum: 0 })
+ targetMinutes!: number | null;
+
+ @ApiProperty({ minimum: 0 })
+ actualMinutes!: number;
+
+ @ApiProperty({ type: Number, nullable: true })
+ differenceMinutes!: number | null;
+
+ @ApiProperty({ enum: ['Known', 'Unknown'] })
+ targetStatus!: 'Known' | 'Unknown';
+
+ @ApiProperty({
+ type: [String],
+ description:
+ 'Calendar dates lacking a confirmed contract snapshot, including non-working dates.',
+ })
+ unknownDates!: string[];
+
+ @ApiProperty({ type: String, enum: ['CONTRACT_HISTORY_GAP'], nullable: true })
+ reason!: 'CONTRACT_HISTORY_GAP' | null;
+
+ @ApiProperty({
+ enum: ['Unknown', 'Negative', 'Balanced', 'Positive', 'Excess'],
+ description:
+ 'Selected-period review indicator only; not a legal limit or a carried overtime balance. Excess means difference >= 600 minutes.',
+ })
+ indicator!: 'Unknown' | 'Negative' | 'Balanced' | 'Positive' | 'Excess';
+
+ @ApiProperty({
+ minimum: 0,
+ description:
+ 'Non-voided, non-rejected open entries started before the period end; excluded from actual minutes.',
+ })
+ openEntryCount!: number;
+}
+
export class WorkingTimeReportDto {
@ApiProperty({ format: 'date' })
from!: string;
@@ -138,6 +182,21 @@ export class WorkingTimeReportDto {
@ApiProperty({ format: 'date' })
to!: string;
+ @ApiProperty()
+ timeZone!: string;
+
+ @ApiProperty({ enum: ['net_working_time'] })
+ timeDefinition!: 'net_working_time';
+
+ @ApiProperty({
+ enum: [600],
+ description: 'Period-review threshold, not a statutory working-time limit.',
+ })
+ excessThresholdMinutes!: 600;
+
+ @ApiProperty({ type: [WorkingTimeComparisonDto] })
+ comparisons!: WorkingTimeComparisonDto[];
+
@ApiProperty({ type: [WorkingTimeReportRowDto] })
rows!: WorkingTimeReportRowDto[];
diff --git a/apps/api/src/app/reports/reports.service.ts b/apps/api/src/app/reports/reports.service.ts
index f60d2d8..dc1a5d9 100644
--- a/apps/api/src/app/reports/reports.service.ts
+++ b/apps/api/src/app/reports/reports.service.ts
@@ -1,10 +1,15 @@
-import { BadRequestException, Injectable } from '@nestjs/common';
+import {
+ BadRequestException,
+ ForbiddenException,
+ Injectable,
+} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Prisma } from '@prisma/client';
import { InstallationService } from '../installation/installation.service';
import { calculateCaptureSummaries } from '../time-entries/capture-summary';
import {
type WorkingTimeReportEmployeeDto,
+ type WorkingTimeReportRowDto,
type WorkingTimeReportDto,
type WorkingTimeReportQueryDto,
type WorkingTimePdfQueryDto,
@@ -13,6 +18,10 @@ import {
type SoloReportRowDto,
} from './reports.dto';
import { renderWorkingTimePdf } from './working-time-pdf';
+import {
+ compareWorkingTime,
+ EXCESS_THRESHOLD_MINUTES,
+} from './working-time-comparison';
const DAY_MS = 86_400_000;
const MAX_REPORT_DAYS = 366;
@@ -271,25 +280,82 @@ export class ReportsService {
);
}
- async workingTimeEmployees(): Promise {
- return this.prisma.employee.findMany({
- select: { id: true, firstName: true, lastName: true },
- orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
- });
+ async workingTimeEmployees(
+ actorId: string,
+ ): Promise {
+ return this.prisma.$transaction(
+ (tx) => this.scopedEmployees(actorId, undefined, tx),
+ { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead },
+ );
}
async workingTimes(
+ actorId: string,
query: WorkingTimeReportQueryDto,
): Promise {
- return this.prisma.$transaction((tx) => this.buildWorkingTimes(query, tx), {
- isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,
+ return this.prisma.$transaction(
+ (tx) => this.buildWorkingTimes(actorId, query, tx),
+ {
+ isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,
+ },
+ );
+ }
+
+ /** Re-read the active server-side actor inside the report snapshot. */
+ private async scopedEmployees(
+ actorId: string,
+ selectedIds: string[] | undefined,
+ tx: Prisma.TransactionClient,
+ hrOnly = false,
+ ): Promise {
+ const settings = await tx.installationSettings.findUnique({
+ where: { id: 1 },
+ });
+ if (settings?.mode === 'Solo')
+ throw new ForbiddenException('Team reports are disabled in Solo mode');
+ const actor = await tx.employee.findUnique({
+ where: { id: actorId },
+ select: { id: true, role: true, isActive: true },
});
+ if (
+ !actor?.isActive ||
+ !['HRAdmin', 'Manager'].includes(actor.role) ||
+ (hrOnly && actor.role !== 'HRAdmin')
+ ) {
+ throw new ForbiddenException(
+ 'Working-time reports require an active authorized actor',
+ );
+ }
+ const scope: Prisma.EmployeeWhereInput =
+ actor.role === 'HRAdmin'
+ ? {}
+ : { OR: [{ id: actor.id }, { managerId: actor.id }] };
+ const employees = await tx.employee.findMany({
+ where: { ...scope, ...(selectedIds ? { id: { in: selectedIds } } : {}) },
+ select: { id: true, firstName: true, lastName: true },
+ orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }, { id: 'asc' }],
+ });
+ // Validate IDs independently of time entries: even an empty foreign report
+ // is forbidden, and mixtures never silently drop inaccessible employees.
+ if (selectedIds && employees.length !== selectedIds.length) {
+ if (actor.role === 'Manager')
+ throw new ForbiddenException(
+ 'Employee selection is outside your report scope',
+ );
+ throw new BadRequestException('One or more employees do not exist');
+ }
+ return employees;
}
- async workingTimesPdf(query: WorkingTimePdfQueryDto): Promise {
+ async workingTimesPdf(
+ actorId: string,
+ query: WorkingTimePdfQueryDto,
+ ): Promise {
const input = await this.prisma.$transaction(
async (tx) => {
+ await this.scopedEmployees(actorId, undefined, tx, true);
const report = await this.buildWorkingTimes(
+ actorId,
{ ...query, includeLocations: 'false' },
tx,
);
@@ -300,7 +366,8 @@ export class ReportsService {
? [query.employeeId]
: query.employeeIds;
const periodEntries: Prisma.TimeEntryWhereInput = {
- clockIn: { gte: from, lt: to },
+ clockIn: { lt: to },
+ OR: [{ clockOut: { gt: from } }, { clockOut: null }],
voidedAt: null,
status: { not: 'Rejected' },
};
@@ -345,6 +412,7 @@ export class ReportsService {
}
private async buildWorkingTimes(
+ actorId: string,
query: WorkingTimeReportQueryDto,
tx: Prisma.TransactionClient,
): Promise {
@@ -373,19 +441,83 @@ export class ReportsService {
to.date.getDate() + 1,
);
- const entries = await tx.timeEntry.findMany({
- where: {
- employeeId:
- query.employeeId ??
- (query.employeeIds ? { in: query.employeeIds } : undefined),
- clockIn: { gte: from.date, lt: toExclusive },
- clockOut: { not: null },
- status: { not: 'Rejected' },
- voidedAt: null,
- },
- orderBy: [{ clockIn: 'asc' }, { employeeId: 'asc' }],
- include: { employee: { select: { firstName: true, lastName: true } } },
- });
+ const employees = await this.scopedEmployees(
+ actorId,
+ query.employeeId ? [query.employeeId] : query.employeeIds,
+ tx,
+ );
+ const employeeIds = employees.map((employee) => employee.id);
+ // Query by interval overlap, not booking start date. Open timers never
+ // contribute provisional minutes to this closed-entry period comparison.
+ const [entries, contracts, requests, absences, openCounts] =
+ await Promise.all([
+ tx.timeEntry.findMany({
+ where: {
+ employeeId: { in: employeeIds },
+ clockIn: { lt: toExclusive },
+ clockOut: { gt: from.date },
+ status: { in: ['Pending', 'Approved'] },
+ voidedAt: null,
+ },
+ orderBy: [{ clockIn: 'asc' }, { employeeId: 'asc' }, { id: 'asc' }],
+ include: {
+ employee: { select: { firstName: true, lastName: true } },
+ },
+ }),
+ tx.employeeContractPeriod.findMany({
+ where: {
+ employeeId: { in: employeeIds },
+ effectiveFrom: { lte: new Date(`${query.to}T00:00:00.000Z`) },
+ OR: [
+ { effectiveTo: null },
+ { effectiveTo: { gte: new Date(`${query.from}T00:00:00.000Z`) } },
+ ],
+ },
+ orderBy: [
+ { effectiveFrom: 'desc' },
+ { revision: 'desc' },
+ { createdAt: 'desc' },
+ { id: 'desc' },
+ ],
+ }),
+ tx.request.findMany({
+ where: {
+ employeeId: { in: employeeIds },
+ type: { in: ['Vacation', 'SpecialLeave'] },
+ workflowState: 'Approved',
+ cancelledAt: null,
+ from: {
+ lt: new Date(
+ Date.UTC(
+ to.date.getFullYear(),
+ to.date.getMonth(),
+ to.date.getDate() + 1,
+ ),
+ ),
+ },
+ to: { gte: new Date(`${query.from}T00:00:00.000Z`) },
+ },
+ }),
+ tx.absence.findMany({
+ where: {
+ employeeId: { in: employeeIds },
+ kind: { in: ['Sickness', 'Training'] },
+ from: { lte: new Date(`${query.to}T00:00:00.000Z`) },
+ to: { gte: new Date(`${query.from}T00:00:00.000Z`) },
+ },
+ }),
+ tx.timeEntry.groupBy({
+ by: ['employeeId'],
+ where: {
+ employeeId: { in: employeeIds },
+ clockIn: { lt: toExclusive },
+ clockOut: null,
+ status: { not: 'Rejected' },
+ voidedAt: null,
+ },
+ _count: { _all: true },
+ }),
+ ]);
const groupIds = [
...new Set(
@@ -402,7 +534,7 @@ export class ReportsService {
in: [...new Set(entries.map((entry) => entry.employeeId))],
},
voidedAt: null,
- status: { not: 'Rejected' },
+ status: { in: ['Pending', 'Approved'] },
clockOut: { not: null },
},
})
@@ -412,48 +544,112 @@ export class ReportsService {
[...entries, ...siblings].map((entry) => [entry.id, entry]),
).values(),
]);
- const rows = entries.flatMap((entry) => {
+ const rows: WorkingTimeReportRowDto[] = [];
+ for (const entry of entries) {
const summary = summaries.get(entry.id);
- if (!summary) return [];
- // Reports retain the precision of their timestamps while keeping the
- // captured break rules and capture-group allocation used by accounts.
- const grossMinutes =
- ((entry.clockOut as Date).getTime() - entry.clockIn.getTime()) / 60_000;
- return [
- {
+ if (!entry.clockOut || !summary) continue;
+ const duration = entry.clockOut.getTime() - entry.clockIn.getTime();
+ if (duration <= 0) continue;
+ const clippedEnd = Math.min(
+ entry.clockOut.getTime(),
+ toExclusive.getTime(),
+ );
+ let cursor = new Date(
+ Math.max(entry.clockIn.getTime(), from.date.getTime()),
+ );
+ while (cursor.getTime() < clippedEnd) {
+ const midnight = new Date(
+ cursor.getFullYear(),
+ cursor.getMonth(),
+ cursor.getDate() + 1,
+ );
+ const rowEnd = Math.min(clippedEnd, midnight.getTime());
+ const ratio = (rowEnd - cursor.getTime()) / duration;
+ // Keep exact timestamp minutes, including historical ungrouped entries,
+ // while taking captured break deductions from the complete group.
+ const grossMinutes = (rowEnd - cursor.getTime()) / 60_000;
+ const breakMinutes = summary.breakMinutes * ratio;
+ rows.push({
id: entry.id,
employeeId: entry.employeeId,
employeeName: `${entry.employee.firstName} ${entry.employee.lastName}`,
- date: localDate(entry.clockIn),
- clockIn: entry.clockIn.toISOString(),
- clockOut: (entry.clockOut as Date).toISOString(),
+ date: localDate(cursor),
+ clockIn: cursor.toISOString(),
+ clockOut: new Date(rowEnd).toISOString(),
status: entry.status,
...(includeLocations
? {
- clockInLocation: reportLocation(
- entry.terminalLocationLabel,
- entry.latitude,
- entry.longitude,
- entry.accuracyMeters,
- ),
- clockOutLocation: reportLocation(
- entry.clockOutTerminalLocationLabel,
- entry.clockOutLatitude,
- entry.clockOutLongitude,
- entry.clockOutAccuracyMeters,
- ),
+ // Synthetic midnight/period edges are not physical clock
+ // events and must not inherit an unrelated GPS observation.
+ clockInLocation:
+ cursor.getTime() === entry.clockIn.getTime()
+ ? reportLocation(
+ entry.terminalLocationLabel,
+ entry.latitude,
+ entry.longitude,
+ entry.accuracyMeters,
+ )
+ : null,
+ clockOutLocation:
+ rowEnd === entry.clockOut.getTime()
+ ? reportLocation(
+ entry.clockOutTerminalLocationLabel,
+ entry.clockOutLatitude,
+ entry.clockOutLongitude,
+ entry.clockOutAccuracyMeters,
+ )
+ : null,
}
: {}),
- ...summary,
grossMinutes,
- netMinutes: grossMinutes - summary.breakMinutes,
- },
- ];
- });
+ breakMinutes,
+ netMinutes: grossMinutes - breakMinutes,
+ });
+ cursor = new Date(rowEnd);
+ }
+ }
+ // Splitting a long entry must not move its later days ahead of another
+ // employee's earlier booking rows.
+ rows.sort(
+ (left, right) =>
+ left.clockIn.localeCompare(right.clockIn) ||
+ left.employeeId.localeCompare(right.employeeId) ||
+ left.id.localeCompare(right.id),
+ );
+ const actualByEmployee = new Map();
+ for (const row of rows) {
+ actualByEmployee.set(
+ row.employeeId,
+ (actualByEmployee.get(row.employeeId) ?? 0) + row.netMinutes,
+ );
+ }
+ const openByEmployee = new Map(
+ openCounts.map((row) => [row.employeeId, row._count._all]),
+ );
+ const comparisons = employees.map((employee) =>
+ compareWorkingTime({
+ employeeId: employee.id,
+ employeeName: `${employee.firstName} ${employee.lastName}`,
+ from: query.from,
+ to: query.to,
+ snapshots: contracts.filter(
+ (period) => period.employeeId === employee.id,
+ ),
+ excused: [...requests, ...absences].filter(
+ (range) => range.employeeId === employee.id,
+ ),
+ actualMinutes: actualByEmployee.get(employee.id) ?? 0,
+ openEntryCount: openByEmployee.get(employee.id) ?? 0,
+ }),
+ );
return {
from: query.from,
to: query.to,
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ timeDefinition: 'net_working_time',
+ excessThresholdMinutes: EXCESS_THRESHOLD_MINUTES,
+ comparisons,
rows,
totals: rows.reduce(
(totals, row) => ({
diff --git a/apps/api/src/app/reports/working-time-comparison.ts b/apps/api/src/app/reports/working-time-comparison.ts
new file mode 100644
index 0000000..832ce4d
--- /dev/null
+++ b/apps/api/src/app/reports/working-time-comparison.ts
@@ -0,0 +1,132 @@
+import {
+ calculateDailyTargetMinutes,
+ holidayProviderForCalendar,
+} from 'shared';
+import type { WorkingTimeComparisonDto } from './reports.dto';
+
+export const EXCESS_THRESHOLD_MINUTES = 600;
+const DAY_MS = 86_400_000;
+
+/** Only persisted, explicitly confirmed facts belong here, never live employee defaults. */
+interface ContractSnapshot {
+ effectiveFrom: Date;
+ effectiveTo: Date | null;
+ employmentStart: Date;
+ employmentEnd: Date | null;
+ weeklyHours: { toString(): string } | number;
+ workingDays: number;
+ holidayCalendar: string;
+ holidayDates: string[];
+}
+
+interface ExcusedRange {
+ from: Date;
+ to: Date;
+ halfDayStart?: boolean;
+ halfDayEnd?: boolean;
+}
+
+/** Date-valued requests and absences use UTC calendar keys, not local instants. */
+function dateKey(value: Date): string {
+ return value.toISOString().slice(0, 10);
+}
+
+function coverage(range: ExcusedRange, key: string): number {
+ const from = dateKey(range.from);
+ const to = dateKey(range.to);
+ if (key < from || key > to) return 0;
+ // A one-day request with both endpoint flags still excuses just half a day.
+ return (key === from && range.halfDayStart) ||
+ (key === to && range.halfDayEnd)
+ ? 0.5
+ : 1;
+}
+
+export function compareWorkingTime(input: {
+ employeeId: string;
+ employeeName: string;
+ from: string;
+ to: string;
+ /** Most recent effectiveFrom/revision first. Effective end dates are inclusive. */
+ snapshots: readonly ContractSnapshot[];
+ /** Approved Vacation/SpecialLeave and recorded Sickness/Training only. */
+ excused: readonly ExcusedRange[];
+ actualMinutes: number;
+ openEntryCount: number;
+}): WorkingTimeComparisonDto {
+ const unknownDates: string[] = [];
+ let knownTarget = 0;
+ const holidays = new Map<
+ ContractSnapshot,
+ ReturnType
+ >();
+ for (
+ let day = new Date(`${input.from}T00:00:00.000Z`);
+ dateKey(day) <= input.to;
+ day = new Date(day.getTime() + DAY_MS)
+ ) {
+ const key = dateKey(day);
+ const snapshot = input.snapshots.find(
+ (period) =>
+ dateKey(period.effectiveFrom) <= key &&
+ (!period.effectiveTo || dateKey(period.effectiveTo) >= key),
+ );
+ // No inference from the current employee, even for weekends or prehire dates.
+ if (!snapshot) {
+ unknownDates.push(key);
+ continue;
+ }
+ if (
+ key < dateKey(snapshot.employmentStart) ||
+ (snapshot.employmentEnd && key > dateKey(snapshot.employmentEnd))
+ )
+ continue;
+ if ((snapshot.workingDays & (1 << ((day.getUTCDay() + 6) % 7))) === 0)
+ continue;
+ let calendar = holidays.get(snapshot);
+ if (!calendar) {
+ calendar = holidayProviderForCalendar(
+ snapshot.holidayCalendar,
+ snapshot.holidayDates,
+ );
+ holidays.set(snapshot, calendar);
+ }
+ if (calendar.isHoliday(day)) continue;
+ // The same day cannot be excused twice. Overlapping half-day records use the
+ // PersonalSummary max/union rule, not additive synthetic half-day credits.
+ const excused = Math.min(
+ 1,
+ Math.max(0, ...input.excused.map((range) => coverage(range, key))),
+ );
+ knownTarget +=
+ calculateDailyTargetMinutes(
+ Number(snapshot.weeklyHours),
+ snapshot.workingDays,
+ ) *
+ (1 - excused);
+ }
+ const targetMinutes = unknownDates.length ? null : knownTarget;
+ const differenceMinutes =
+ targetMinutes === null ? null : input.actualMinutes - targetMinutes;
+ return {
+ employeeId: input.employeeId,
+ employeeName: input.employeeName,
+ targetMinutes,
+ actualMinutes: input.actualMinutes,
+ differenceMinutes,
+ targetStatus: targetMinutes === null ? 'Unknown' : 'Known',
+ unknownDates,
+ reason: targetMinutes === null ? 'CONTRACT_HISTORY_GAP' : null,
+ indicator:
+ differenceMinutes === null
+ ? 'Unknown'
+ : differenceMinutes < 0
+ ? 'Negative'
+ : differenceMinutes === 0
+ ? 'Balanced'
+ : differenceMinutes >= EXCESS_THRESHOLD_MINUTES
+ ? 'Excess'
+ : 'Positive',
+ openEntryCount: input.openEntryCount,
+ };
+}
diff --git a/apps/api/src/app/requests/requests.controller.ts b/apps/api/src/app/requests/requests.controller.ts
index ab41a63..7b44913 100644
--- a/apps/api/src/app/requests/requests.controller.ts
+++ b/apps/api/src/app/requests/requests.controller.ts
@@ -3,15 +3,25 @@ import {
Controller,
Get,
Param,
+ Patch,
ParseUUIDPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
-import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+import {
+ ApiBearerAuth,
+ ApiCreatedResponse,
+ ApiOkResponse,
+ ApiTags,
+} from '@nestjs/swagger';
import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { JwtUser } from '../auth/jwt.strategy';
+import {
+ TimeEntryDto,
+ TimeEntryAuditDto,
+} from '../time-entries/time-entries.dto';
import { RequestsService } from './requests.service';
import {
BulkApproveDto,
@@ -20,9 +30,10 @@ import {
CreateVacationDto,
ManagerApproveDto,
TransitionDto,
+ UpdateDraftRequestDto,
TransitionWithRequiredNoteDto,
type BulkResult,
- type RequestDto,
+ RequestDto,
type RequestEventDto,
} from './requests.dto';
@@ -34,6 +45,7 @@ export class RequestsController {
constructor(private readonly service: RequestsService) {}
@Get()
+ @ApiOkResponse({ type: RequestDto, isArray: true })
list(
@CurrentUser() user: JwtUser,
@Query('employeeId') employeeId?: string,
@@ -56,7 +68,31 @@ export class RequestsController {
);
}
+ @Get('time-adjustment-targets')
+ @ApiOkResponse({ type: TimeEntryDto, isArray: true })
+ timeAdjustmentTargets(
+ @CurrentUser() user: JwtUser,
+ @Query('from') from?: string,
+ @Query('to') to?: string,
+ ): Promise {
+ return this.service.timeAdjustmentTargets(
+ user.id,
+ from ? new Date(from) : undefined,
+ to ? new Date(to) : undefined,
+ );
+ }
+
+ @Get(':id/time-entry-audit')
+ @ApiOkResponse({ type: TimeEntryAuditDto, isArray: true })
+ timeEntryAudit(
+ @Param('id', new ParseUUIDPipe()) id: string,
+ @CurrentUser() user: JwtUser,
+ ): Promise {
+ return this.service.timeEntryAudit(id, user.id);
+ }
+
@Get(':id')
+ @ApiOkResponse({ type: RequestDto })
get(
@Param('id', new ParseUUIDPipe()) id: string,
@CurrentUser() user: JwtUser,
@@ -73,6 +109,7 @@ export class RequestsController {
}
@Post()
+ @ApiCreatedResponse({ type: RequestDto })
create(
@Body() dto: CreateRequestDto,
@CurrentUser() user: JwtUser,
@@ -81,6 +118,7 @@ export class RequestsController {
}
@Post('vacation')
+ @ApiCreatedResponse({ type: RequestDto })
createVacation(
@Body() dto: CreateVacationDto,
@CurrentUser() user: JwtUser,
@@ -88,9 +126,30 @@ export class RequestsController {
return this.service.createVacation({ ...dto, employeeId: user.id });
}
+ @Patch(':id')
+ @ApiOkResponse({ type: RequestDto })
+ updateDraft(
+ @Param('id', new ParseUUIDPipe()) id: string,
+ @Body() dto: UpdateDraftRequestDto,
+ @CurrentUser() user: JwtUser,
+ ): Promise {
+ return this.service.updateDraft(id, user.id, dto, false);
+ }
+
+ @Post(':id/resubmit')
+ @ApiCreatedResponse({ type: RequestDto })
+ resubmit(
+ @Param('id', new ParseUUIDPipe()) id: string,
+ @Body() dto: UpdateDraftRequestDto,
+ @CurrentUser() user: JwtUser,
+ ): Promise {
+ return this.service.updateDraft(id, user.id, dto, true);
+ }
+
// ----- Generic approve / reject (legacy convenience) -----
@Post(':id/approve')
+ @ApiCreatedResponse({ type: RequestDto })
approve(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionDto,
@@ -100,6 +159,7 @@ export class RequestsController {
}
@Post(':id/reject')
+ @ApiCreatedResponse({ type: RequestDto })
reject(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionDto,
@@ -111,6 +171,7 @@ export class RequestsController {
// ----- Vacation workflow -----
@Post(':id/manager-approve')
+ @ApiCreatedResponse({ type: RequestDto })
managerApprove(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: ManagerApproveDto,
@@ -125,6 +186,7 @@ export class RequestsController {
}
@Post(':id/manager-reject')
+ @ApiCreatedResponse({ type: RequestDto })
managerReject(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionDto,
@@ -134,6 +196,7 @@ export class RequestsController {
}
@Post(':id/hr-confirm')
+ @ApiCreatedResponse({ type: RequestDto })
hrConfirm(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionDto,
@@ -143,6 +206,7 @@ export class RequestsController {
}
@Post(':id/hr-reject')
+ @ApiCreatedResponse({ type: RequestDto })
hrReject(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionWithRequiredNoteDto,
@@ -152,6 +216,7 @@ export class RequestsController {
}
@Post(':id/substitute/accept')
+ @ApiCreatedResponse({ type: RequestDto })
substituteAccept(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionDto,
@@ -161,6 +226,7 @@ export class RequestsController {
}
@Post(':id/substitute/decline')
+ @ApiCreatedResponse({ type: RequestDto })
substituteDecline(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionWithRequiredNoteDto,
@@ -170,6 +236,7 @@ export class RequestsController {
}
@Post(':id/return')
+ @ApiCreatedResponse({ type: RequestDto })
returnForRevision(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionWithRequiredNoteDto,
@@ -179,6 +246,7 @@ export class RequestsController {
}
@Post(':id/cancel')
+ @ApiCreatedResponse({ type: RequestDto })
cancel(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() body: TransitionDto,
diff --git a/apps/api/src/app/requests/requests.dto.ts b/apps/api/src/app/requests/requests.dto.ts
index 40e78f5..c556192 100644
--- a/apps/api/src/app/requests/requests.dto.ts
+++ b/apps/api/src/app/requests/requests.dto.ts
@@ -1,8 +1,10 @@
-import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger';
import {
ArrayNotEmpty,
IsArray,
IsBoolean,
+ IsInt,
+ Min,
IsEnum,
IsISO8601,
IsOptional,
@@ -10,11 +12,27 @@ import {
IsUUID,
MaxLength,
} from 'class-validator';
-import type { Request } from '@prisma/client';
+import type { Request, Prisma } from '@prisma/client';
-const REQUEST_TYPES = ['Vacation', 'HomeOffice', 'SpecialLeave', 'TimeAdjustment'] as const;
+const REQUEST_TYPES = [
+ 'Vacation',
+ 'HomeOffice',
+ 'SpecialLeave',
+ 'TimeAdjustment',
+] as const;
export class CreateRequestDto {
+ @ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' })
+ @IsOptional()
+ @IsUUID()
+ targetTimeEntryId?: string | null;
+
+ @ApiPropertyOptional({ type: Number, nullable: true, minimum: 0 })
+ @IsOptional()
+ @IsInt()
+ @Min(0)
+ expectedTargetRevision?: number | null;
+
@ApiProperty({ format: 'uuid' })
@IsUUID()
employeeId!: string;
@@ -31,7 +49,7 @@ export class CreateRequestDto {
@IsISO8601()
to!: string;
- @ApiPropertyOptional({ nullable: true, maxLength: 2000 })
+ @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 })
@IsOptional()
@IsString()
@MaxLength(2000)
@@ -39,6 +57,17 @@ export class CreateRequestDto {
}
export class CreateVacationDto {
+ @ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' })
+ @IsOptional()
+ @IsUUID()
+ targetTimeEntryId?: string | null;
+
+ @ApiPropertyOptional({ type: Number, nullable: true, minimum: 0 })
+ @IsOptional()
+ @IsInt()
+ @Min(0)
+ expectedTargetRevision?: number | null;
+
@ApiProperty({ format: 'uuid' })
@IsUUID()
employeeId!: string;
@@ -51,34 +80,53 @@ export class CreateVacationDto {
@IsISO8601()
to!: string;
- @ApiPropertyOptional({ nullable: true, format: 'uuid' })
+ @ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' })
@IsOptional()
@IsUUID()
substituteId?: string | null;
- @ApiPropertyOptional({ nullable: true, maxLength: 2000 })
+ @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 })
@IsOptional()
@IsString()
@MaxLength(2000)
reason?: string | null;
- @ApiPropertyOptional({ default: false, description: 'Take the first day as a half-day' })
+ @ApiPropertyOptional({
+ default: false,
+ description: 'Take the first day as a half-day',
+ })
@IsOptional()
@IsBoolean()
halfDayStart?: boolean;
- @ApiPropertyOptional({ default: false, description: 'Take the last day as a half-day' })
+ @ApiPropertyOptional({
+ default: false,
+ description: 'Take the last day as a half-day',
+ })
@IsOptional()
@IsBoolean()
halfDayEnd?: boolean;
}
+/** Complete editable values. Request type and owner are immutable. */
+export class UpdateDraftRequestDto extends OmitType(CreateVacationDto, [
+ 'employeeId',
+] as const) {
+ @ApiProperty({
+ format: 'date-time',
+ description:
+ 'updatedAt from the version being edited; stale writes return 409',
+ })
+ @IsISO8601()
+ expectedUpdatedAt!: string;
+}
+
export class TransitionDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
actorId!: string;
- @ApiPropertyOptional({ nullable: true, maxLength: 2000 })
+ @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 })
@IsOptional()
@IsString()
@MaxLength(2000)
@@ -114,7 +162,7 @@ export class BulkApproveDto {
@IsUUID('all', { each: true })
ids!: string[];
- @ApiPropertyOptional({ nullable: true, maxLength: 2000 })
+ @ApiPropertyOptional({ type: String, nullable: true, maxLength: 2000 })
@IsOptional()
@IsString()
@MaxLength(2000)
@@ -151,34 +199,79 @@ export interface BulkResult {
error?: string;
}
-export interface RequestDto {
- id: string;
- employeeId: string;
- type: string;
- status: string;
- workflowState: string;
- from: string;
- to: string;
- reason: string | null;
- requiresApproval: boolean;
- approverId: string | null;
- currentApproverId: string | null;
- substituteId: string | null;
- substituteAcceptedAt: string | null;
- hrConfirmedAt: string | null;
- cancelledAt: string | null;
- calculatedDays: number;
- halfDayStart: boolean;
- halfDayEnd: boolean;
- decidedAt: string | null;
- decisionNote: string | null;
- createdAt: string;
+export class RequestDto {
+ @ApiProperty({ type: String, nullable: true, format: 'uuid' })
+ targetTimeEntryId!: string | null;
+ @ApiProperty({ type: Number, nullable: true, minimum: 0 })
+ expectedTargetRevision!: number | null;
+ @ApiProperty({
+ type: 'object',
+ nullable: true,
+ additionalProperties: true,
+ description:
+ 'Immutable server-captured original time-entry values, including id, revision, clockIn, clockOut and break-policy snapshot.',
+ })
+ targetTimeEntryBefore!: Prisma.JsonValue | null;
+
+ @ApiProperty({ format: 'uuid' })
+ id!: string;
+ @ApiProperty({ format: 'uuid' })
+ employeeId!: string;
+ @ApiProperty()
+ employeeName!: string;
+ @ApiProperty({ format: 'date-time' })
+ updatedAt!: string;
+ @ApiProperty({ enum: REQUEST_TYPES })
+ type!: string;
+ @ApiProperty()
+ status!: string;
+ @ApiProperty()
+ workflowState!: string;
+ @ApiProperty({ format: 'date-time' })
+ from!: string;
+ @ApiProperty({ format: 'date-time' })
+ to!: string;
+ @ApiProperty({ type: String, nullable: true })
+ reason!: string | null;
+ @ApiProperty()
+ requiresApproval!: boolean;
+ @ApiProperty({ type: String, nullable: true, format: 'uuid' })
+ approverId!: string | null;
+ @ApiProperty({ type: String, nullable: true, format: 'uuid' })
+ currentApproverId!: string | null;
+ @ApiProperty({ type: String, nullable: true, format: 'uuid' })
+ substituteId!: string | null;
+ @ApiProperty({ type: String, nullable: true, format: 'date-time' })
+ substituteAcceptedAt!: string | null;
+ @ApiProperty({ type: String, nullable: true, format: 'date-time' })
+ hrConfirmedAt!: string | null;
+ @ApiProperty({ type: String, nullable: true, format: 'date-time' })
+ cancelledAt!: string | null;
+ @ApiProperty()
+ calculatedDays!: number;
+ @ApiProperty()
+ halfDayStart!: boolean;
+ @ApiProperty()
+ halfDayEnd!: boolean;
+ @ApiProperty({ type: String, nullable: true, format: 'date-time' })
+ decidedAt!: string | null;
+ @ApiProperty({ type: String, nullable: true })
+ decisionNote!: string | null;
+ @ApiProperty({ format: 'date-time' })
+ createdAt!: string;
}
-export function toRequestDto(r: Request): RequestDto {
+export function toRequestDto(
+ r: Request & { employee: { firstName: string; lastName: string } },
+): RequestDto {
return {
id: r.id,
+ targetTimeEntryId: r.targetTimeEntryId,
+ expectedTargetRevision: r.expectedTargetRevision,
+ targetTimeEntryBefore: r.targetTimeEntryBefore,
employeeId: r.employeeId,
+ employeeName: `${r.employee.firstName} ${r.employee.lastName}`,
+ updatedAt: r.updatedAt.toISOString(),
type: r.type,
status: r.status,
workflowState: r.workflowState,
@@ -189,7 +282,9 @@ export function toRequestDto(r: Request): RequestDto {
approverId: r.approverId,
currentApproverId: r.currentApproverId,
substituteId: r.substituteId,
- substituteAcceptedAt: r.substituteAcceptedAt ? r.substituteAcceptedAt.toISOString() : null,
+ substituteAcceptedAt: r.substituteAcceptedAt
+ ? r.substituteAcceptedAt.toISOString()
+ : null,
hrConfirmedAt: r.hrConfirmedAt ? r.hrConfirmedAt.toISOString() : null,
cancelledAt: r.cancelledAt ? r.cancelledAt.toISOString() : null,
calculatedDays: Number(r.calculatedDays),
diff --git a/apps/api/src/app/requests/requests.module.ts b/apps/api/src/app/requests/requests.module.ts
index 53c7728..6c0feca 100644
--- a/apps/api/src/app/requests/requests.module.ts
+++ b/apps/api/src/app/requests/requests.module.ts
@@ -3,11 +3,12 @@ import { AccountsModule } from '../accounts/accounts.module';
import { EmployeesModule } from '../employees/employees.module';
import { LeaveAllowancesModule } from '../leave-allowances/leave-allowances.module';
import { RequestsController } from './requests.controller';
+import { TimeAdjustmentsService } from './time-adjustments.service';
import { RequestsService } from './requests.service';
@Module({
imports: [EmployeesModule, AccountsModule, LeaveAllowancesModule],
controllers: [RequestsController],
- providers: [RequestsService],
+ providers: [RequestsService, TimeAdjustmentsService],
})
export class RequestsModule {}
diff --git a/apps/api/src/app/requests/requests.service.ts b/apps/api/src/app/requests/requests.service.ts
index 95e562c..e3fedaa 100644
--- a/apps/api/src/app/requests/requests.service.ts
+++ b/apps/api/src/app/requests/requests.service.ts
@@ -5,25 +5,29 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
-import type {
+import {
Prisma,
- Request,
- RequestEventKind,
- RequestType,
- WorkflowState,
+ type Request,
+ type RequestEventKind,
+ type RequestType,
} from '@prisma/client';
import {
calculateVacationDays,
calculateWorkingDays,
deriveStatus,
nextState,
- requiresSpecialApproval,
type WorkflowEvent,
} from 'shared';
import { VacationBalanceService } from '../accounts/vacation-balance.service';
import { EmployeesService } from '../employees/employees.service';
import { PrismaService } from '../prisma/prisma.service';
import { RequestNotificationService } from '../notifications/request-notification.service';
+import { EventsGateway } from '../events/events.gateway';
+import { TimeAdjustmentsService } from './time-adjustments.service';
+import type {
+ TimeEntryAuditDto,
+ TimeEntryDto,
+} from '../time-entries/time-entries.dto';
import { WorkSchedulesService } from '../work-schedules/work-schedules.service';
import {
toRequestDto,
@@ -32,6 +36,7 @@ import {
type CreateVacationDto,
type RequestDto,
type RequestEventDto,
+ type UpdateDraftRequestDto,
} from './requests.dto';
export interface ListRequestsFilter {
@@ -43,6 +48,13 @@ export interface ListRequestsFilter {
substituteId?: string;
}
+const employeeInclude = {
+ employee: { select: { firstName: true, lastName: true } },
+} as const;
+type RequestWithEmployee = Prisma.RequestGetPayload<{
+ include: typeof employeeInclude;
+}>;
+
@Injectable()
export class RequestsService {
constructor(
@@ -51,6 +63,8 @@ export class RequestsService {
private readonly notifications: RequestNotificationService,
private readonly vacationBalance: VacationBalanceService,
private readonly schedules: WorkSchedulesService,
+ private readonly adjustments: TimeAdjustmentsService,
+ private readonly eventsGateway: EventsGateway,
) {}
async list(
@@ -72,6 +86,7 @@ export class RequestsService {
where: { AND: [where, await this.visibilityWhere(actorId)] },
orderBy: { createdAt: 'desc' },
take: 200,
+ include: employeeInclude,
});
return rows.map(toRequestDto);
}
@@ -99,143 +114,274 @@ export class RequestsService {
}));
}
- // ----- Generic create (HomeOffice, SpecialLeave, TimeAdjustment, Vacation-as-fallback) -----
-
+ // Creation and revision deliberately share validation. Serializing per employee
+ // keeps simultaneous resubmissions/creations from spending the same balance or
+ // passing the same overlap check. No request is deleted/recreated on revision.
async create(dto: CreateRequestDto): Promise {
- if (dto.type === 'Vacation') {
- // Vacation has its own validated path with leave balance check.
- return this.createVacation({
- employeeId: dto.employeeId,
- from: dto.from,
- to: dto.to,
- substituteId: null,
- reason: dto.reason ?? null,
- });
- }
- const employee = await this.employees.getById(dto.employeeId);
- const from = new Date(dto.from);
- const to = new Date(dto.to);
- if (to.getTime() < from.getTime()) {
- throw new BadRequestException('"to" must be on/after "from"');
- }
- await this.assertNoActiveOverlap(employee.id, from, to);
- const schedule = await this.schedules.resolveForEmployee(employee.id);
- let requires = false;
- if (dto.type === 'TimeAdjustment') {
- if (to.getTime() <= from.getTime()) {
- throw new BadRequestException(
- 'TimeAdjustment "to" must be after "from"',
- );
- }
- await this.assertNoTimeEntryOverlap(employee.id, from, to);
- requires = requiresSpecialApproval(from, to, schedule.frame);
- }
- const calculatedDays = calculateWorkingDays(from, to, {
- workingDays: schedule.workingDays,
- holidayProvider: schedule.holidayProvider,
- });
+ return this.createValidated(dto.type, dto);
+ }
+
+ async createVacation(dto: CreateVacationDto): Promise {
+ return this.createValidated('Vacation', dto);
+ }
+
+ private async lockEmployee(
+ tx: Prisma.TransactionClient,
+ employeeId: string,
+ ): Promise {
+ // Same order as TimeEntriesService, terminal booking, #41 decisions and
+ // installation mode changes: global → employee advisory → employee row.
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(7261500)`;
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${employeeId}, 0))`;
+ await tx.$queryRaw`SELECT "id" FROM "Employee" WHERE "id" = ${employeeId}::uuid FOR NO KEY UPDATE`;
+ }
+
+ private async createValidated(
+ type: RequestType,
+ dto: CreateVacationDto,
+ ): Promise {
const created = await this.prisma.$transaction(async (tx) => {
+ await this.lockEmployee(tx, dto.employeeId);
+ const values = await this.validateValues(type, dto, tx);
const request = await tx.request.create({
data: {
- employeeId: employee.id,
- type: dto.type as RequestType,
+ ...values,
+ employeeId: dto.employeeId,
+ type,
status: 'Submitted',
- workflowState: 'PendingManager',
- from,
- to,
- reason: dto.reason ?? null,
- requiresApproval: requires,
- calculatedDays,
- currentApproverId: employee.managerId,
+ workflowState: values.substituteId
+ ? 'PendingSubstitute'
+ : 'PendingManager',
},
+ include: employeeInclude,
});
await tx.requestEvent.create({
data: {
requestId: request.id,
kind: 'Submitted',
- actorId: employee.id,
+ actorId: dto.employeeId,
},
});
return request;
});
- this.notifications.notifyTransitioned(created, 'Submitted', employee.id);
+ this.notifications.notifyTransitioned(created, 'Submitted', dto.employeeId);
return toRequestDto(created);
}
- // ----- Vacation creation with balance check -----
+ async updateDraft(
+ id: string,
+ actorId: string,
+ dto: UpdateDraftRequestDto,
+ resubmit: boolean,
+ ): Promise {
+ const updated = await this.prisma.$transaction(async (tx) => {
+ await this.lockEmployee(tx, actorId);
+ const request = await tx.request.findUnique({ where: { id } });
+ if (!request) throw new NotFoundException(`Request ${id} not found`);
+ if (request.employeeId !== actorId) {
+ throw new ForbiddenException('Only the requester may edit or resubmit');
+ }
+ if (
+ request.workflowState !== 'Draft' ||
+ request.updatedAt.toISOString() !==
+ new Date(dto.expectedUpdatedAt).toISOString()
+ ) {
+ throw new ConflictException(
+ 'Request changed or is no longer a draft; reload before editing',
+ );
+ }
+ const values = await this.validateValues(
+ request.type,
+ {
+ ...dto,
+ employeeId: actorId,
+ targetTimeEntryId:
+ dto.targetTimeEntryId === undefined
+ ? request.targetTimeEntryId
+ : dto.targetTimeEntryId,
+ expectedTargetRevision:
+ dto.expectedTargetRevision === undefined
+ ? request.expectedTargetRevision
+ : dto.expectedTargetRevision,
+ },
+ tx,
+ id,
+ request,
+ );
+ const data: Prisma.RequestUncheckedUpdateInput = {
+ ...values,
+ // A saved draft is not assigned until it is actually submitted.
+ currentApproverId: resubmit ? values.currentApproverId : null,
+ workflowState: resubmit
+ ? nextState('Draft', 'submit', {
+ hasSubstitute: !!values.substituteId,
+ })
+ : 'Draft',
+ status: 'Submitted', // legacy display status; workflowState gates writes
+ approverId: null,
+ substituteAcceptedAt: null,
+ hrConfirmedAt: null,
+ cancelledAt: null,
+ decidedAt: null,
+ // Keep the revision reason visible until submission; history always keeps it.
+ decisionNote: resubmit ? null : request.decisionNote,
+ };
+ const saved = await this.updateVersion(tx, request, data);
+ if (resubmit) {
+ await tx.requestEvent.create({
+ data: { requestId: id, kind: 'Resubmitted', actorId },
+ });
+ }
+ return saved;
+ });
+ if (resubmit)
+ this.notifications.notifyTransitioned(updated, 'Resubmitted', actorId);
+ return toRequestDto(updated);
+ }
- async createVacation(dto: CreateVacationDto): Promise {
+ private async validateValues(
+ type: RequestType,
+ dto: CreateVacationDto,
+ tx: Prisma.TransactionClient,
+ excludeRequestId?: string,
+ original?: Request,
+ ) {
const employee = await this.employees.getById(dto.employeeId);
const from = new Date(dto.from);
const to = new Date(dto.to);
- if (to.getTime() < from.getTime()) {
- throw new BadRequestException('"to" must be on/after "from"');
+ if (
+ !Number.isFinite(from.getTime()) ||
+ !Number.isFinite(to.getTime()) ||
+ to < from
+ ) {
+ throw new BadRequestException('"to" must be on/after a valid "from"');
}
- if (from.getUTCFullYear() !== to.getUTCFullYear()) {
+ const vacation = type === 'Vacation';
+ if (
+ type !== 'TimeAdjustment' &&
+ (dto.targetTimeEntryId != null || dto.expectedTargetRevision != null)
+ )
+ throw new BadRequestException(
+ 'Only TimeAdjustment supports a correction target',
+ );
+ if (vacation && from.getUTCFullYear() !== to.getUTCFullYear()) {
throw new BadRequestException(
'Vacation must lie within a single calendar year — split into two requests',
);
}
- if (dto.substituteId && dto.substituteId === employee.id) {
- throw new BadRequestException('Substitute must be a different employee');
+ if (!vacation && (dto.substituteId || dto.halfDayStart || dto.halfDayEnd)) {
+ throw new BadRequestException(
+ 'Substitute and half-days are only supported for Vacation',
+ );
+ }
+ const substituteId = vacation ? (dto.substituteId ?? null) : null;
+ if (substituteId) {
+ const substitute = await tx.employee.findUnique({
+ where: { id: substituteId },
+ });
+ if (substituteId === employee.id || !substitute?.isActive) {
+ throw new BadRequestException(
+ 'Substitute must be a different active employee',
+ );
+ }
}
const schedule = await this.schedules.resolveForEmployee(employee.id);
- const halfDayStart = !!dto.halfDayStart;
- const halfDayEnd = !!dto.halfDayEnd;
- const calculatedDays = calculateVacationDays(from, to, {
+ const halfDayStart = vacation && !!dto.halfDayStart;
+ const halfDayEnd = vacation && !!dto.halfDayEnd;
+ const options = {
workingDays: schedule.workingDays,
holidayProvider: schedule.holidayProvider,
halfDayStart,
halfDayEnd,
- });
- if (calculatedDays <= 0) {
+ };
+ const calculatedDays = vacation
+ ? calculateVacationDays(from, to, options)
+ : calculateWorkingDays(from, to, options);
+ if (vacation && calculatedDays <= 0)
throw new BadRequestException('Vacation range covers no working days');
- }
- await this.assertNoActiveOverlap(employee.id, from, to);
-
- const balance = await this.vacationBalance.compute(
+ await this.assertNoActiveOverlap(
employee.id,
- from.getUTCFullYear(),
+ from,
+ to,
+ tx,
+ excludeRequestId,
+ type === 'TimeAdjustment',
);
- if (balance.remainingDays < calculatedDays) {
- throw new ConflictException(
- `Not enough vacation days remaining (${balance.remainingDays} < ${calculatedDays})`,
+ let requiresApproval = false;
+ let targetValues:
+ | Awaited>
+ | undefined;
+ if (type === 'TimeAdjustment') {
+ if (to <= from)
+ throw new BadRequestException(
+ 'TimeAdjustment "to" must be after "from"',
+ );
+ targetValues = await this.adjustments.validate(
+ tx,
+ employee.id,
+ from,
+ to,
+ dto,
+ original,
);
+ requiresApproval = targetValues.requiresApproval;
}
+ if (vacation) {
+ const balance = await this.vacationBalance.compute(
+ employee.id,
+ from.getUTCFullYear(),
+ excludeRequestId,
+ tx,
+ );
+ if (balance.remainingDays < calculatedDays) {
+ throw new ConflictException(
+ `Not enough vacation days remaining (${balance.remainingDays} < ${calculatedDays})`,
+ );
+ }
+ }
+ return {
+ ...(targetValues ?? {
+ targetTimeEntryId: null,
+ expectedTargetRevision: null,
+ targetTimeEntryBefore: Prisma.DbNull,
+ }),
+ from,
+ to,
+ reason: dto.reason ?? null,
+ calculatedDays,
+ halfDayStart,
+ halfDayEnd,
+ substituteId,
+ requiresApproval,
+ currentApproverId: substituteId ? null : employee.managerId,
+ };
+ }
- const hasSubstitute = !!dto.substituteId;
- const initialState: WorkflowState = hasSubstitute
- ? 'PendingSubstitute'
- : 'PendingManager';
-
- const created = await this.prisma.$transaction(async (tx) => {
- const request = await tx.request.create({
- data: {
- employeeId: employee.id,
- type: 'Vacation',
- status: 'Submitted',
- workflowState: initialState,
- from,
- to,
- reason: dto.reason ?? null,
- calculatedDays,
- halfDayStart,
- halfDayEnd,
- substituteId: dto.substituteId ?? null,
- currentApproverId: hasSubstitute ? null : employee.managerId,
- },
- });
- await tx.requestEvent.create({
- data: {
- requestId: request.id,
- kind: 'Submitted',
- actorId: employee.id,
- },
- });
- return request;
+ /** Compare-and-swap protects transitions too, including Draft → pending → Draft ABA races. */
+ private async updateVersion(
+ tx: Prisma.TransactionClient,
+ request: Request,
+ data: Prisma.RequestUncheckedUpdateInput,
+ ): Promise {
+ const changed = await tx.request.updateMany({
+ where: {
+ id: request.id,
+ workflowState: request.workflowState,
+ updatedAt: request.updatedAt,
+ },
+ data: {
+ ...data,
+ updatedAt: new Date(
+ Math.max(Date.now(), request.updatedAt.getTime() + 1),
+ ),
+ },
+ });
+ if (changed.count !== 1)
+ throw new ConflictException('Request changed; reload before continuing');
+ return tx.request.findUniqueOrThrow({
+ where: { id: request.id },
+ include: employeeInclude,
});
- this.notifications.notifyTransitioned(created, 'Submitted', employee.id);
- return toRequestDto(created);
}
// ----- Generic approve / reject (legacy path for non-Vacation) -----
@@ -246,42 +392,37 @@ export class RequestsService {
note: string | null,
): Promise {
const request = await this.assertRequest(id);
- if (request.type === 'Vacation') {
- // Vacation must use the multi-stage workflow.
- await this.assertApprover(request, actorId);
- return this.transitionVacation(request, 'manager_approve', actorId, note);
- }
- if (requiresTwoStageApproval(request)) {
- // Off-hours TimeAdjustment: manager approves the off-hours allowance,
- // then HR finalises the actual time correction.
+ if (request.type === 'TimeAdjustment') {
await this.assertApprover(request, actorId);
- return this.transitionVacation(
+ return this.approveTimeAdjustment(
request,
- 'manager_approve_with_hr',
+ requiresTwoStageApproval(request)
+ ? 'manager_approve_with_hr'
+ : 'manager_approve',
actorId,
note,
);
}
+ if (request.type === 'Vacation') {
+ // Vacation must use the multi-stage workflow.
+ await this.assertApprover(request, actorId);
+ return this.transitionVacation(request, 'manager_approve', actorId, note);
+ }
await this.assertApprover(request, actorId);
- const alreadyApproved = request.workflowState === 'Approved';
+ if (request.workflowState !== 'PendingManager')
+ throw new ConflictException('Request is not pending manager approval');
const updated = await this.prisma.$transaction(async (tx) => {
- const updated = await tx.request.update({
- where: { id: request.id },
- data: {
- status: 'Approved',
- workflowState: 'Approved',
- approverId: actorId,
- currentApproverId: null,
- decidedAt: new Date(),
- decisionNote: note,
- },
+ const updated = await this.updateVersion(tx, request, {
+ status: 'Approved',
+ workflowState: 'Approved',
+ approverId: actorId,
+ currentApproverId: null,
+ decidedAt: new Date(),
+ decisionNote: note,
});
await tx.requestEvent.create({
data: { requestId: id, kind: 'ManagerApproved', actorId, note },
});
- if (request.type === 'TimeAdjustment' && !alreadyApproved) {
- await this.materializeTimeAdjustment(tx, request);
- }
return updated;
});
this.notifications.notifyTransitioned(updated, 'ManagerApproved', actorId);
@@ -299,17 +440,16 @@ export class RequestsService {
return this.transitionVacation(request, 'manager_reject', actorId, note);
}
await this.assertApprover(request, actorId);
+ if (request.workflowState !== 'PendingManager')
+ throw new ConflictException('Request is not pending manager approval');
const updated = await this.prisma.$transaction(async (tx) => {
- const updated = await tx.request.update({
- where: { id: request.id },
- data: {
- status: 'Rejected',
- workflowState: 'Rejected',
- approverId: actorId,
- currentApproverId: null,
- decidedAt: new Date(),
- decisionNote: note,
- },
+ const updated = await this.updateVersion(tx, request, {
+ status: 'Rejected',
+ workflowState: 'Rejected',
+ approverId: actorId,
+ currentApproverId: null,
+ decidedAt: new Date(),
+ decisionNote: note,
});
await tx.requestEvent.create({
data: { requestId: id, kind: 'ManagerRejected', actorId, note },
@@ -562,6 +702,10 @@ export class RequestsService {
);
}
}
+ if (request.targetTimeEntryId && request.workflowState === 'Approved')
+ throw new ConflictException(
+ 'An applied correction cannot be undone by cancelling its request; submit a new correction',
+ );
return this.transitionVacation(request, 'cancel', actorId, note);
}
@@ -573,12 +717,19 @@ export class RequestsService {
actorId: string,
note: string | null,
): Promise {
+ if (
+ request.type === 'TimeAdjustment' &&
+ (event === 'manager_approve' ||
+ event === 'manager_approve_with_hr' ||
+ event === 'hr_confirm')
+ )
+ return this.approveTimeAdjustment(request, event, actorId, note);
const target = nextState(request.workflowState, event, {
hasSubstitute: !!request.substituteId,
});
const status = deriveStatus(target);
- const data: Prisma.RequestUpdateInput = {
+ const data: Prisma.RequestUncheckedUpdateInput = {
workflowState: target,
status,
};
@@ -588,92 +739,222 @@ export class RequestsService {
// Side effects per transition.
if (event === 'submit') {
- // No-op here — submit is done at create time.
+ // Creation and resubmission have their own validated paths.
}
if (event === 'substitute_accept') {
data.substituteAcceptedAt = new Date();
// Move approver to manager.
const employee = await this.employees.getById(employeeId);
- data.currentApprover = employee.managerId
- ? { connect: { id: employee.managerId } }
- : { disconnect: true };
+ data.currentApproverId = employee.managerId;
}
if (event === 'substitute_decline') {
- data.currentApprover = { disconnect: true };
+ data.decisionNote = note;
+ data.currentApproverId = null;
}
if (event === 'manager_approve' || event === 'manager_approve_with_hr') {
- data.approver = { connect: { id: actorId } };
+ data.approverId = actorId;
data.decidedAt = event === 'manager_approve' ? new Date() : null;
data.decisionNote = event === 'manager_approve' ? note : null;
if (event === 'manager_approve_with_hr') {
- data.currentApprover = { disconnect: true };
+ data.currentApproverId = null;
} else {
- data.currentApprover = { disconnect: true };
+ data.currentApproverId = null;
}
}
if (event === 'manager_reject' || event === 'manager_return') {
- data.approver = { connect: { id: actorId } };
+ data.approverId = actorId;
data.decidedAt = event === 'manager_reject' ? new Date() : null;
data.decisionNote = note;
- data.currentApprover = { disconnect: true };
+ data.currentApproverId = null;
}
if (event === 'hr_confirm' || event === 'hr_reject') {
data.hrConfirmedAt = event === 'hr_confirm' ? new Date() : null;
data.decidedAt = new Date();
data.decisionNote = note;
- data.currentApprover = { disconnect: true };
+ data.currentApproverId = null;
}
if (event === 'cancel') {
data.cancelledAt = new Date();
- data.currentApprover = { disconnect: true };
+ data.currentApproverId = null;
}
const updated = await this.prisma.$transaction(async (tx) => {
- const u = await tx.request.update({ where: { id: request.id }, data });
+ const u = await this.updateVersion(tx, request, data);
await tx.requestEvent.create({
data: { requestId: request.id, kind: eventKind, actorId, note },
});
- // Reaching Approved via the multi-stage path (off-hours TimeAdjustment:
- // manager → HR) must materialise the correction just like the direct
- // approve() path does.
- if (target === 'Approved' && request.type === 'TimeAdjustment') {
- await this.materializeTimeAdjustment(tx, request);
- }
return u;
});
this.notifications.notifyTransitioned(updated, eventKind, actorId);
return toRequestDto(updated);
}
- /**
- * Approving a TimeAdjustment is a correction of *booked time*, so it has
- * to land as a real TimeEntry — otherwise the day stays empty and the
- * overtime account shows a phantom deficit. For TimeAdjustment requests
- * `from` / `to` carry the corrected clock-in / clock-out timestamps
- * (full date-time, not just a date), set by the request form.
- */
- private async materializeTimeAdjustment(
- tx: Prisma.TransactionClient,
- request: Request,
- ): Promise {
- const schedule = await this.schedules.resolveForEmployee(
- request.employeeId,
- );
- await tx.timeEntry.create({
- data: {
- employeeId: request.employeeId,
- clockIn: request.from,
- clockOut: request.to,
- source: 'Manual',
- breakRules: schedule.breakRules,
- status: 'Approved',
- requiresApproval: false,
- },
+ async timeAdjustmentTargets(
+ actorId: string,
+ from?: Date,
+ to?: Date,
+ ): Promise {
+ return this.adjustments.targets(actorId, from, to);
+ }
+
+ async timeEntryAudit(
+ id: string,
+ actorId: string,
+ ): Promise {
+ const request = await this.assertRequest(id);
+ await this.assertCanView(request, actorId);
+ const rows = await this.prisma.timeEntryAudit.findMany({
+ where: { requestId: id },
+ include: { actor: { select: { firstName: true, lastName: true } } },
+ orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }],
+ });
+ return rows.map(({ actor, ...row }) => ({
+ ...row,
+ actorName: actor ? `${actor.firstName} ${actor.lastName}` : null,
+ occurredAt: row.occurredAt.toISOString(),
+ }));
+ }
+
+ private async approveTimeAdjustment(
+ expected: Request,
+ event: WorkflowEvent,
+ actorId: string,
+ note: string | null,
+ ): Promise {
+ const result = await this.prisma.$transaction(async (tx) => {
+ await this.lockEmployee(tx, expected.employeeId);
+ await this.adjustments.assertTeam(tx);
+ const request = await tx.request.findUniqueOrThrow({
+ where: { id: expected.id },
+ });
+ if (
+ request.updatedAt.getTime() !== expected.updatedAt.getTime() ||
+ request.workflowState !== expected.workflowState
+ )
+ throw new ConflictException(
+ 'Request changed; reload before continuing',
+ );
+ const employee = await tx.employee.findUniqueOrThrow({
+ where: { id: request.employeeId },
+ });
+ const ids = [
+ ...new Set(
+ [actorId, employee.managerId].filter((id): id is string => !!id),
+ ),
+ ].sort();
+ if (ids.length)
+ await tx.$queryRaw(
+ Prisma.sql`SELECT "id" FROM "Employee" WHERE "id" IN (${Prisma.join(ids.map((id) => Prisma.sql`${id}::uuid`))}) ORDER BY "id" FOR SHARE`,
+ );
+ const actor = await tx.employee.findUniqueOrThrow({
+ where: { id: actorId },
+ });
+ if (!actor.isActive || !employee.isActive)
+ throw new ForbiddenException('Active employee and reviewer required');
+ const hr = event === 'hr_confirm';
+ if (request.workflowState !== (hr ? 'PendingHr' : 'PendingManager'))
+ throw new ConflictException(
+ 'Request is not awaiting this approval stage',
+ );
+ if (hr) {
+ if (actor.role !== 'HRAdmin')
+ throw new ForbiddenException('HR confirmation required');
+ } else if (actor.role !== 'Manager' && actor.role !== 'HRAdmin') {
+ throw new ForbiddenException('Manager approval required');
+ }
+ if (request.targetTimeEntryId) {
+ if (actorId === request.employeeId)
+ throw new ForbiddenException(
+ 'Self approval of a correction is forbidden',
+ );
+ if (
+ !hr &&
+ (actor.role !== 'Manager' ||
+ actorId !== employee.managerId ||
+ request.currentApproverId !== employee.managerId)
+ )
+ throw new ForbiddenException(
+ 'Only the current assigned manager may approve this correction',
+ );
+ if (hr && request.approverId !== employee.managerId)
+ throw new ConflictException(
+ 'Assigned manager changed; cancel and create a new correction request',
+ );
+ } else if (
+ !hr &&
+ actor.role === 'Manager' &&
+ request.currentApproverId !== actorId
+ ) {
+ throw new ForbiddenException(
+ 'Managers may only approve assigned requests',
+ );
+ }
+ const values = await this.adjustments.validate(
+ tx,
+ request.employeeId,
+ request.from,
+ request.to,
+ request,
+ request,
+ );
+ await this.assertNoActiveOverlap(
+ request.employeeId,
+ request.from,
+ request.to,
+ tx,
+ request.id,
+ true,
+ );
+ const needsHr =
+ !hr &&
+ (values.requiresApproval ||
+ request.requiresApproval ||
+ event === 'manager_approve_with_hr');
+ const target = needsHr ? 'PendingHr' : 'Approved';
+ const u = await this.updateVersion(tx, request, {
+ status: needsHr ? 'Submitted' : 'Approved',
+ workflowState: target,
+ requiresApproval: values.requiresApproval || request.requiresApproval,
+ ...(hr ? { hrConfirmedAt: new Date() } : { approverId: actorId }),
+ decidedAt: needsHr ? null : new Date(),
+ decisionNote: needsHr ? null : note,
+ currentApproverId: null,
+ });
+ const kind = hr ? 'HrConfirmed' : 'ManagerApproved';
+ await tx.requestEvent.create({
+ data: { requestId: request.id, kind, actorId, note },
+ });
+ const entry =
+ target === 'Approved'
+ ? await this.adjustments.apply(
+ tx,
+ request,
+ actorId,
+ values.requiresApproval,
+ )
+ : null;
+ return { request: u, entry, kind };
});
+ this.notifications.notifyTransitioned(result.request, result.kind, actorId);
+ if (result.entry)
+ this.eventsGateway.broadcast(
+ expected.targetTimeEntryId
+ ? 'time-entry:updated'
+ : 'time-entry:created',
+ {
+ id: result.entry.id,
+ employeeId: result.entry.employeeId,
+ clockOut: result.entry.clockOut?.toISOString() ?? null,
+ },
+ );
+ return toRequestDto(result.request);
}
- private async assertRequest(id: string): Promise {
- const request = await this.prisma.request.findUnique({ where: { id } });
+ private async assertRequest(id: string): Promise {
+ const request = await this.prisma.request.findUnique({
+ where: { id },
+ include: employeeInclude,
+ });
if (!request) throw new NotFoundException(`Request ${id} not found`);
return request;
}
@@ -737,11 +1018,18 @@ export class RequestsService {
employeeId: string,
from: Date,
to: Date,
+ db: Prisma.TransactionClient = this.prisma,
+ excludeRequestId?: string,
+ timeAdjustment = false,
): Promise {
- const overlap = await this.prisma.request.findFirst({
+ const overlap = await db.request.findFirst({
where: {
employeeId,
status: { notIn: ['Rejected', 'Cancelled'] },
+ ...(timeAdjustment
+ ? { NOT: { type: 'TimeAdjustment', status: 'Approved' } }
+ : {}),
+ ...(excludeRequestId ? { id: { not: excludeRequestId } } : {}),
from: { lte: to },
to: { gte: from },
},
@@ -753,31 +1041,6 @@ export class RequestsService {
);
}
}
-
- /**
- * TimeAdjustment currently materialises a new entry on approval. Rejecting
- * overlaps prevents a correction (or daily block) from being counted twice.
- */
- private async assertNoTimeEntryOverlap(
- employeeId: string,
- from: Date,
- to: Date,
- ): Promise {
- const overlap = await this.prisma.timeEntry.findFirst({
- where: {
- employeeId,
- status: { not: 'Rejected' },
- clockIn: { lt: to },
- OR: [{ clockOut: null }, { clockOut: { gt: from } }],
- },
- select: { id: true },
- });
- if (overlap) {
- throw new ConflictException(
- 'Time adjustment overlaps an existing time entry for this employee',
- );
- }
- }
}
function requiresTwoStageApproval(request: Request): boolean {
diff --git a/apps/api/src/app/requests/time-adjustments.service.ts b/apps/api/src/app/requests/time-adjustments.service.ts
new file mode 100644
index 0000000..365d813
--- /dev/null
+++ b/apps/api/src/app/requests/time-adjustments.service.ts
@@ -0,0 +1,299 @@
+import {
+ BadRequestException,
+ ConflictException,
+ ForbiddenException,
+ Injectable,
+} from '@nestjs/common';
+import { Prisma, type Request, type TimeEntry } from '@prisma/client';
+import { parseBreakRules, requiresSpecialApproval } from 'shared';
+import { PrismaService } from '../prisma/prisma.service';
+import { WorkSchedulesService } from '../work-schedules/work-schedules.service';
+import { calculateCaptureSummaries } from '../time-entries/capture-summary';
+import {
+ toTimeEntryDto,
+ type TimeEntryDto,
+} from '../time-entries/time-entries.dto';
+
+interface TargetBinding {
+ targetTimeEntryId?: string | null;
+ expectedTargetRevision?: number | null;
+}
+
+@Injectable()
+export class TimeAdjustmentsService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly schedules: WorkSchedulesService,
+ ) {}
+
+ async assertTeam(tx: Prisma.TransactionClient): Promise {
+ const settings = await tx.installationSettings.findUnique({
+ where: { id: 1 },
+ });
+ if (settings?.mode === 'Solo')
+ throw new ForbiddenException(
+ 'Team correction requests are unavailable in Solo mode',
+ );
+ }
+
+ async targets(
+ employeeId: string,
+ from?: Date,
+ to?: Date,
+ ): Promise {
+ if (
+ (from && !Number.isFinite(+from)) ||
+ (to && !Number.isFinite(+to)) ||
+ (from && to && from > to)
+ )
+ throw new BadRequestException('Invalid target date range');
+ await this.assertTeam(this.prisma);
+ const rows = await this.prisma.timeEntry.findMany({
+ where: {
+ employeeId,
+ voidedAt: null,
+ clockOut: { not: null },
+ status: { in: ['Pending', 'Approved'] },
+ OR: [{ approvalMode: null }, { approvalMode: { not: 'Solo' } }],
+ },
+ include: {
+ project: { select: { code: true, name: true } },
+ serviceOrder: { select: { orderNo: true, title: true } },
+ },
+ orderBy: [{ clockIn: 'desc' }, { id: 'asc' }],
+ });
+ const summaries = calculateCaptureSummaries(rows);
+ return rows
+ .filter(
+ (row) =>
+ (!from || (row.clockOut && row.clockOut > from)) &&
+ (!to || row.clockIn < to),
+ )
+ .map((row) => ({
+ ...toTimeEntryDto(row),
+ summary: summaries.get(row.id) ?? null,
+ }));
+ }
+
+ async snapshot(
+ tx: Prisma.TransactionClient,
+ entry: TimeEntry,
+ ): Promise {
+ const group = entry.captureGroupId
+ ? await tx.timeEntry.findMany({
+ where: {
+ employeeId: entry.employeeId,
+ captureGroupId: entry.captureGroupId,
+ },
+ })
+ : [entry];
+ const summaries = calculateCaptureSummaries(group);
+ const summary = summaries.get(entry.id);
+ const captureSummary = [...summaries.values()].reduce(
+ (sum, item) => ({
+ grossMinutes: sum.grossMinutes + item.grossMinutes,
+ breakMinutes: sum.breakMinutes + item.breakMinutes,
+ netMinutes: sum.netMinutes + item.netMinutes,
+ }),
+ { grossMinutes: 0, breakMinutes: 0, netMinutes: 0 },
+ );
+ return {
+ id: entry.id,
+ revision: entry.revision,
+ clockIn: entry.clockIn.toISOString(),
+ clockOut: entry.clockOut?.toISOString() ?? null,
+ status: entry.status,
+ source: entry.source,
+ projectId: entry.projectId,
+ serviceOrderId: entry.serviceOrderId,
+ activity: entry.activity,
+ note: entry.note,
+ billable: entry.billable,
+ breakRules: parseBreakRules(entry.breakRules),
+ captureGroupId: entry.captureGroupId,
+ approvalMode: entry.approvalMode,
+ requiresApproval: entry.requiresApproval,
+ frameApproverId: entry.frameApproverId,
+ frameApprovedRevision: entry.frameApprovedRevision,
+ bookingDate: entry.bookingDate?.toISOString() ?? null,
+ summary: summary ? { ...summary } : null,
+ captureSummary: { ...captureSummary },
+ };
+ }
+
+ async validate(
+ tx: Prisma.TransactionClient,
+ employeeId: string,
+ from: Date,
+ to: Date,
+ binding: TargetBinding,
+ original?: Request,
+ ) {
+ await this.assertTeam(tx);
+ const targetId = binding.targetTimeEntryId ?? null;
+ const revision = binding.expectedTargetRevision ?? null;
+ if (
+ (targetId === null) !== (revision === null) ||
+ (revision !== null && (!Number.isInteger(revision) || revision < 0))
+ )
+ throw new BadRequestException(
+ 'Correction target and expected revision must be supplied together',
+ );
+ if (
+ original &&
+ (targetId !== original.targetTimeEntryId ||
+ revision !== original.expectedTargetRevision)
+ )
+ throw new BadRequestException(
+ 'The correction target and original revision are immutable; cancel and create a new request',
+ );
+ let target: TimeEntry | null = null;
+ let before: Prisma.InputJsonValue | typeof Prisma.DbNull = Prisma.DbNull;
+ if (targetId) {
+ target = await tx.timeEntry.findUnique({ where: { id: targetId } });
+ if (!target || target.employeeId !== employeeId)
+ throw new ForbiddenException(
+ 'The correction target must be your own time entry',
+ );
+ if (
+ target.voidedAt ||
+ !target.clockOut ||
+ target.status === 'Open' ||
+ target.status === 'Rejected' ||
+ target.approvalMode === 'Solo'
+ )
+ throw new ConflictException(
+ 'Target must be a closed, non-voided Team entry that is not rejected',
+ );
+ if (target.revision !== revision)
+ throw new ConflictException(
+ 'Correction target changed; cancel and create a new request from its current version',
+ );
+ if (to.getTime() > Date.now())
+ throw new BadRequestException(
+ 'Corrected working time cannot be in the future',
+ );
+ if (original) {
+ if (
+ !original.targetTimeEntryBefore ||
+ typeof original.targetTimeEntryBefore !== 'object' ||
+ Array.isArray(original.targetTimeEntryBefore)
+ )
+ throw new ConflictException(
+ 'Original correction snapshot is unavailable',
+ );
+ before = original.targetTimeEntryBefore as Prisma.InputJsonObject;
+ } else before = await this.snapshot(tx, target);
+ const employee = await tx.employee.findUniqueOrThrow({
+ where: { id: employeeId },
+ include: { manager: true },
+ });
+ if (
+ !employee.isActive ||
+ !employee.manager?.isActive ||
+ employee.manager.role !== 'Manager' ||
+ employee.managerId === employeeId
+ )
+ throw new ConflictException(
+ 'An active assigned manager is required; ask HR to correct the manager assignment',
+ );
+ }
+ if (!Number.isFinite(+from) || !Number.isFinite(+to) || to <= from)
+ throw new BadRequestException(
+ 'TimeAdjustment "to" must be after a valid "from"',
+ );
+ const overlap = await tx.timeEntry.findFirst({
+ where: {
+ employeeId,
+ voidedAt: null,
+ status: { not: 'Rejected' },
+ ...(targetId ? { id: { not: targetId } } : {}),
+ clockIn: { lt: to },
+ OR: [{ clockOut: null }, { clockOut: { gt: from } }],
+ },
+ select: { id: true },
+ });
+ if (overlap)
+ throw new ConflictException(
+ 'Time adjustment overlaps another time entry for this employee',
+ );
+ const schedule = await this.schedules.resolveForEmployee(employeeId);
+ return {
+ targetTimeEntryId: targetId,
+ expectedTargetRevision: revision,
+ targetTimeEntryBefore: before,
+ requiresApproval: requiresSpecialApproval(from, to, schedule.frame),
+ };
+ }
+
+ async apply(
+ tx: Prisma.TransactionClient,
+ request: Request,
+ actorId: string,
+ requiresApproval: boolean,
+ ): Promise {
+ let row: TimeEntry;
+ let before: Prisma.InputJsonObject | null = null;
+ if (request.targetTimeEntryId) {
+ const target = await tx.timeEntry.findUniqueOrThrow({
+ where: { id: request.targetTimeEntryId },
+ });
+ before = await this.snapshot(tx, target);
+ const changed = await tx.timeEntry.updateMany({
+ where: {
+ id: target.id,
+ employeeId: request.employeeId,
+ revision: request.expectedTargetRevision ?? -1,
+ voidedAt: null,
+ clockOut: { not: null },
+ status: { in: ['Pending', 'Approved'] },
+ },
+ data: {
+ clockIn: request.from,
+ clockOut: request.to,
+ revision: { increment: 1 },
+ status: 'Approved',
+ requiresApproval,
+ frameApproverId: null,
+ frameApprovedRevision: null,
+ ...(target.source === 'DailyBlock' ? { bookingDate: null } : {}),
+ },
+ });
+ if (changed.count !== 1)
+ throw new ConflictException(
+ 'Correction target changed before application',
+ );
+ row = await tx.timeEntry.findUniqueOrThrow({ where: { id: target.id } });
+ } else {
+ const schedule = await this.schedules.resolveForEmployee(
+ request.employeeId,
+ );
+ row = await tx.timeEntry.create({
+ data: {
+ employeeId: request.employeeId,
+ clockIn: request.from,
+ clockOut: request.to,
+ source: 'Manual',
+ approvalMode: 'Team',
+ breakRules: schedule.breakRules,
+ status: 'Approved',
+ requiresApproval: false,
+ },
+ });
+ }
+ await tx.timeEntryAudit.create({
+ data: {
+ timeEntryId: row.id,
+ requestId: request.id,
+ actorId,
+ action: request.targetTimeEntryId
+ ? 'FrameRequestCorrected'
+ : 'RequestMissingBookingAdded',
+ before: before ?? Prisma.DbNull,
+ after: await this.snapshot(tx, row),
+ reason: request.reason,
+ },
+ });
+ return row;
+ }
+}
diff --git a/apps/api/src/app/terminals/terminal-booking.service.ts b/apps/api/src/app/terminals/terminal-booking.service.ts
index f91e7b7..a67f7b7 100644
--- a/apps/api/src/app/terminals/terminal-booking.service.ts
+++ b/apps/api/src/app/terminals/terminal-booking.service.ts
@@ -105,6 +105,8 @@ export async function bookTerminalTime(
revision: { increment: 1 },
status: requires ? 'Pending' : 'Approved',
requiresApproval: requires,
+ frameApproverId: null,
+ frameApprovedRevision: null,
clockOutLatitude: position?.latitude ?? null,
clockOutLongitude: position?.longitude ?? null,
clockOutAccuracyMeters: position?.accuracyMeters ?? null,
diff --git a/apps/api/src/app/time-entries/time-entries.controller.ts b/apps/api/src/app/time-entries/time-entries.controller.ts
index 3965574..11cb3bb 100644
--- a/apps/api/src/app/time-entries/time-entries.controller.ts
+++ b/apps/api/src/app/time-entries/time-entries.controller.ts
@@ -14,10 +14,17 @@ import {
ApiOkResponse,
ApiCreatedResponse,
ApiTags,
+ ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { JwtUser } from '../auth/jwt.strategy';
+import { TimeEntryApprovalsService } from './time-entry-approvals.service';
+import {
+ DecideTimeEntryApprovalDto,
+ TimeEntryApprovalDto,
+ TimeEntryApprovalEventDto,
+} from './time-entry-approvals.dto';
import { TimeEntriesService } from './time-entries.service';
import {
BookProjectRangeDto,
@@ -40,7 +47,10 @@ import {
@ApiTags('time-entries')
@Controller('timeentries')
export class TimeEntriesController {
- constructor(private readonly entries: TimeEntriesService) {}
+ constructor(
+ private readonly entries: TimeEntriesService,
+ private readonly approvals: TimeEntryApprovalsService,
+ ) {}
@Get()
@ApiOkResponse({ type: TimeEntryDto, isArray: true })
@@ -60,6 +70,41 @@ export class TimeEntriesController {
);
}
+ @Get('approvals')
+ @ApiQuery({ name: 'scope', required: false, enum: ['own', 'review'] })
+ @ApiOkResponse({ type: TimeEntryApprovalDto, isArray: true })
+ @ApiBearerAuth()
+ @UseGuards(JwtAuthGuard)
+ approvalList(
+ @CurrentUser() user: JwtUser,
+ @Query('scope') scope?: string,
+ ): Promise {
+ return this.approvals.list(user.id, scope);
+ }
+
+ @Get(':id/approval-history')
+ @ApiOkResponse({ type: TimeEntryApprovalEventDto, isArray: true })
+ @ApiBearerAuth()
+ @UseGuards(JwtAuthGuard)
+ approvalHistory(
+ @Param('id', new ParseUUIDPipe()) id: string,
+ @CurrentUser() user: JwtUser,
+ ): Promise {
+ return this.approvals.history(id, user);
+ }
+
+ @Post(':id/approval')
+ @ApiCreatedResponse({ type: TimeEntryApprovalDto })
+ @ApiBearerAuth()
+ @UseGuards(JwtAuthGuard)
+ decideApproval(
+ @Param('id', new ParseUUIDPipe()) id: string,
+ @Body() dto: DecideTimeEntryApprovalDto,
+ @CurrentUser() user: JwtUser,
+ ): Promise {
+ return this.approvals.decide(id, dto, user);
+ }
+
@Post('clock-in')
@ApiCreatedResponse({ type: TimeEntryDto })
@ApiBearerAuth()
diff --git a/apps/api/src/app/time-entries/time-entries.dto.ts b/apps/api/src/app/time-entries/time-entries.dto.ts
index 60a1187..b3d60b7 100644
--- a/apps/api/src/app/time-entries/time-entries.dto.ts
+++ b/apps/api/src/app/time-entries/time-entries.dto.ts
@@ -649,6 +649,10 @@ export class SwitchProjectDto extends ClockInDto {
}
export class TimeEntryAuditDto {
+ @ApiPropertyOptional({ type: String, nullable: true })
+ actorName?: string | null;
+ @ApiProperty({ type: String, nullable: true, format: 'uuid' })
+ requestId!: string | null;
@ApiProperty({ format: 'uuid' })
id!: string;
diff --git a/apps/api/src/app/time-entries/time-entries.module.ts b/apps/api/src/app/time-entries/time-entries.module.ts
index 903233e..18f4328 100644
--- a/apps/api/src/app/time-entries/time-entries.module.ts
+++ b/apps/api/src/app/time-entries/time-entries.module.ts
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { TimeEntriesController } from './time-entries.controller';
+import { TimeEntryApprovalsService } from './time-entry-approvals.service';
import { TimeEntriesService } from './time-entries.service';
@Module({
imports: [AuthModule],
controllers: [TimeEntriesController],
- providers: [TimeEntriesService],
+ providers: [TimeEntriesService, TimeEntryApprovalsService],
exports: [TimeEntriesService],
})
export class TimeEntriesModule {}
diff --git a/apps/api/src/app/time-entries/time-entries.service.ts b/apps/api/src/app/time-entries/time-entries.service.ts
index 27a201f..5e5cbf2 100644
--- a/apps/api/src/app/time-entries/time-entries.service.ts
+++ b/apps/api/src/app/time-entries/time-entries.service.ts
@@ -681,6 +681,8 @@ export class TimeEntriesService {
clockOut: bookingNow,
status: requires ? 'Pending' : 'Approved',
requiresApproval: requires,
+ frameApproverId: null,
+ frameApprovedRevision: null,
revision: { increment: 1 },
clockOutLatitude:
policy.isSolo && !policy.gpsEnabled
@@ -808,9 +810,32 @@ export class TimeEntriesService {
const before = await this.auditSnapshot(tx, current);
const row = await tx.timeEntry.update({
where: { id },
- data: { ...data, revision: { increment: 1 } },
+ data: {
+ ...data,
+ revision: { increment: 1 },
+ ...(current.approvalMode !== 'Solo' && current.requiresApproval
+ ? {
+ frameApproverId: null,
+ frameApprovedRevision: null,
+ ...(!current.voidedAt &&
+ current.clockOut &&
+ current.status !== 'Rejected'
+ ? { status: 'Pending' as const }
+ : {}),
+ }
+ : {}),
+ },
include: ENTRY_INCLUDE,
});
+ if (current.approvalMode !== 'Solo' && current.requiresApproval)
+ await this.writeAudit(
+ tx,
+ row,
+ user.id,
+ 'FrameApprovalInvalidated',
+ before,
+ null,
+ );
if (isSolo || current.approvalMode === 'Solo')
await this.writeAudit(tx, row, user.id, 'BookingUpdated', before, null);
return row;
@@ -911,6 +936,8 @@ export class TimeEntriesService {
captureGroupId,
revision: { increment: 1 },
requiresApproval: firstRequires,
+ frameApproverId: null,
+ frameApprovedRevision: null,
status: statusFor(firstRequires),
clockOutLatitude: null,
clockOutLongitude: null,
@@ -956,6 +983,24 @@ export class TimeEntriesService {
where: { timeEntryId: entry.id, action: 'clock-out' },
data: { timeEntryId: secondSegment.id },
});
+ if (entry.approvalMode !== 'Solo' && entry.requiresApproval) {
+ await this.writeAudit(
+ tx,
+ firstSegment,
+ user.id,
+ 'FrameApprovalInvalidated',
+ before,
+ null,
+ );
+ await this.writeAudit(
+ tx,
+ secondSegment,
+ user.id,
+ 'FrameSegmentCreated',
+ null,
+ null,
+ );
+ }
if (isSolo || entry.approvalMode === 'Solo') {
await this.writeAudit(
tx,
@@ -1097,6 +1142,8 @@ export class TimeEntriesService {
clockOut,
captureGroupId,
requiresApproval,
+ frameApproverId: null,
+ frameApprovedRevision: null,
status: requiresApproval
? ('Pending' as const)
: ('Approved' as const),
@@ -1132,6 +1179,19 @@ export class TimeEntriesService {
where: { timeEntryId: entry.id, action: 'clock-out' },
data: { timeEntryId: last.id },
});
+ if (entry.approvalMode !== 'Solo' && entry.requiresApproval) {
+ for (const segment of segments)
+ await this.writeAudit(
+ tx,
+ segment,
+ user.id,
+ segment.id === entry.id
+ ? 'FrameApprovalInvalidated'
+ : 'FrameSegmentCreated',
+ segment.id === entry.id ? before : null,
+ null,
+ );
+ }
if (isSolo || entry.approvalMode === 'Solo') {
for (const segment of segments)
await this.writeAudit(
@@ -1725,6 +1785,8 @@ export class TimeEntriesService {
status: entry.status,
requiresApproval: entry.requiresApproval,
approvalMode: entry.approvalMode,
+ frameApproverId: entry.frameApproverId,
+ frameApprovedRevision: entry.frameApprovedRevision,
captureGroupId: entry.captureGroupId,
breakRules: parseBreakRules(entry.breakRules),
voidedAt: entry.voidedAt?.toISOString() ?? null,
diff --git a/apps/api/src/app/time-entries/time-entry-approvals.dto.ts b/apps/api/src/app/time-entries/time-entry-approvals.dto.ts
new file mode 100644
index 0000000..13f6aac
--- /dev/null
+++ b/apps/api/src/app/time-entries/time-entry-approvals.dto.ts
@@ -0,0 +1,73 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import {
+ IsIn,
+ IsInt,
+ IsOptional,
+ IsString,
+ MaxLength,
+ Min,
+} from 'class-validator';
+import { TimeEntryDto } from './time-entries.dto';
+
+export const ENTRY_APPROVAL_STATES = [
+ 'Open',
+ 'BlockedManager',
+ 'PendingManager',
+ 'PendingHr',
+ 'Approved',
+ 'Rejected',
+ 'Voided',
+] as const;
+export type EntryApprovalState = (typeof ENTRY_APPROVAL_STATES)[number];
+
+export class TimeEntryApprovalDto {
+ @ApiProperty({ type: TimeEntryDto })
+ entry!: TimeEntryDto;
+ @ApiProperty({ minimum: 0 })
+ revision!: number;
+ @ApiProperty()
+ employeeName!: string;
+ @ApiProperty({ type: String, nullable: true, format: 'uuid' })
+ managerId!: string | null;
+ @ApiProperty({ type: String, nullable: true })
+ managerName!: string | null;
+ @ApiProperty({ enum: ENTRY_APPROVAL_STATES })
+ state!: EntryApprovalState;
+ @ApiProperty()
+ canDecide!: boolean;
+}
+
+export class DecideTimeEntryApprovalDto {
+ @ApiProperty({ minimum: 0 })
+ @IsInt()
+ @Min(0)
+ revision!: number;
+ @ApiProperty({ enum: ['Manager', 'HR'] })
+ @IsIn(['Manager', 'HR'])
+ stage!: 'Manager' | 'HR';
+ @ApiProperty({ enum: ['Approve', 'Reject'] })
+ @IsIn(['Approve', 'Reject'])
+ outcome!: 'Approve' | 'Reject';
+ @ApiPropertyOptional({ maxLength: 2000 })
+ @IsOptional()
+ @IsString()
+ @MaxLength(2000)
+ note?: string;
+}
+
+export class TimeEntryApprovalEventDto {
+ @ApiProperty({ type: String, nullable: true, format: 'uuid' })
+ requestId!: string | null;
+ @ApiProperty()
+ id!: string;
+ @ApiProperty({ type: String, nullable: true, format: 'uuid' })
+ actorId!: string | null;
+ @ApiProperty({ type: String, nullable: true })
+ actorName!: string | null;
+ @ApiProperty()
+ action!: string;
+ @ApiProperty({ type: String, nullable: true })
+ reason!: string | null;
+ @ApiProperty({ format: 'date-time' })
+ occurredAt!: string;
+}
diff --git a/apps/api/src/app/time-entries/time-entry-approvals.service.ts b/apps/api/src/app/time-entries/time-entry-approvals.service.ts
new file mode 100644
index 0000000..530f1bb
--- /dev/null
+++ b/apps/api/src/app/time-entries/time-entry-approvals.service.ts
@@ -0,0 +1,348 @@
+import {
+ BadRequestException,
+ ConflictException,
+ ForbiddenException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { Prisma, type Employee } from '@prisma/client';
+import type { JwtUser } from '../auth/jwt.strategy';
+import { PrismaService } from '../prisma/prisma.service';
+import { EventsGateway } from '../events/events.gateway';
+import { toTimeEntryDto } from './time-entries.dto';
+import {
+ type DecideTimeEntryApprovalDto,
+ type EntryApprovalState,
+ type TimeEntryApprovalDto,
+ type TimeEntryApprovalEventDto,
+} from './time-entry-approvals.dto';
+
+const APPROVAL_INCLUDE = {
+ project: { select: { code: true, name: true } },
+ serviceOrder: { select: { orderNo: true, title: true } },
+ employee: {
+ select: {
+ id: true,
+ firstName: true,
+ lastName: true,
+ isActive: true,
+ managerId: true,
+ manager: {
+ select: {
+ id: true,
+ firstName: true,
+ lastName: true,
+ role: true,
+ isActive: true,
+ },
+ },
+ },
+ },
+} as const;
+type ReviewEntry = Prisma.TimeEntryGetPayload<{
+ include: typeof APPROVAL_INCLUDE;
+}>;
+
+function validManager(row: ReviewEntry): boolean {
+ const manager = row.employee.manager;
+ return (
+ !!manager?.isActive &&
+ manager.role === 'Manager' &&
+ manager.id !== row.employeeId
+ );
+}
+
+export function frameApprovalState(row: ReviewEntry): EntryApprovalState {
+ if (row.voidedAt) return 'Voided';
+ if (!row.clockOut || row.status === 'Open') return 'Open';
+ if (row.status === 'Approved') return 'Approved';
+ if (row.status === 'Rejected') return 'Rejected';
+ if (!validManager(row)) return 'BlockedManager';
+ return row.frameApprovedRevision === row.revision &&
+ row.frameApproverId === row.employee.managerId
+ ? 'PendingHr'
+ : 'PendingManager';
+}
+
+@Injectable()
+export class TimeEntryApprovalsService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly events: EventsGateway,
+ ) {}
+
+ private async assertTeam(tx: Prisma.TransactionClient): Promise {
+ const installation = await tx.installationSettings.findUnique({
+ where: { id: 1 },
+ });
+ if (installation?.mode === 'Solo')
+ throw new ForbiddenException(
+ 'Team time-entry approvals are disabled in Solo mode',
+ );
+ }
+
+ private async actor(
+ tx: Prisma.TransactionClient,
+ id: string,
+ ): Promise {
+ const actor = await tx.employee.findUnique({ where: { id } });
+ if (!actor?.isActive)
+ throw new ForbiddenException('Active employee required');
+ return actor;
+ }
+
+ private assertReviewAccess(
+ row: ReviewEntry,
+ actor: Employee,
+ allowOwner: boolean,
+ ): void {
+ if (allowOwner && actor.id === row.employeeId) return;
+ if (actor.id === row.employeeId)
+ throw new ForbiddenException('You cannot review your own time entry');
+ if (actor.role === 'HRAdmin') return;
+ if (actor.role === 'Manager' && row.employee.managerId === actor.id) return;
+ throw new ForbiddenException(
+ 'Only the assigned manager or HR may review this time entry',
+ );
+ }
+
+ private assertApplicable(row: ReviewEntry): void {
+ if (row.approvalMode === 'Solo' || !row.requiresApproval)
+ throw new ConflictException(
+ 'This time entry does not require Team frame approval',
+ );
+ }
+
+ private dto(row: ReviewEntry, actor: Employee): TimeEntryApprovalDto {
+ const state = frameApprovalState(row);
+ return {
+ entry: toTimeEntryDto(row),
+ revision: row.revision,
+ employeeName: `${row.employee.firstName} ${row.employee.lastName}`,
+ managerId: row.employee.managerId,
+ managerName: row.employee.manager
+ ? `${row.employee.manager.firstName} ${row.employee.manager.lastName}`
+ : null,
+ state,
+ canDecide:
+ row.employee.isActive &&
+ actor.id !== row.employeeId &&
+ ((state === 'PendingManager' &&
+ actor.role === 'Manager' &&
+ row.employee.managerId === actor.id) ||
+ (state === 'PendingHr' &&
+ actor.role === 'HRAdmin' &&
+ row.frameApproverId !== actor.id)),
+ };
+ }
+
+ async list(
+ actorId: string,
+ scope = 'review',
+ ): Promise {
+ if (scope !== 'review' && scope !== 'own')
+ throw new BadRequestException('Invalid approval scope');
+ return this.prisma.$transaction(async (tx) => {
+ await this.assertTeam(tx);
+ const actor = await this.actor(tx, actorId);
+ if (
+ scope === 'review' &&
+ actor.role !== 'Manager' &&
+ actor.role !== 'HRAdmin'
+ )
+ throw new ForbiddenException('Manager or HR access required');
+ const rows = await tx.timeEntry.findMany({
+ where: {
+ AND: [
+ { OR: [{ approvalMode: null }, { approvalMode: { not: 'Solo' } }] },
+ {
+ OR: [
+ { requiresApproval: true },
+ { auditEvents: { some: { action: { startsWith: 'Frame' } } } },
+ ],
+ },
+ ],
+ ...(scope === 'own'
+ ? { employeeId: actor.id }
+ : {
+ employeeId: { not: actor.id },
+ employee: {
+ isActive: true,
+ ...(actor.role === 'Manager' ? { managerId: actor.id } : {}),
+ },
+ }),
+ },
+ include: APPROVAL_INCLUDE,
+ orderBy: [{ clockIn: 'desc' }, { id: 'asc' }],
+ });
+ return rows.map((row) => this.dto(row, actor));
+ });
+ }
+
+ async history(
+ id: string,
+ user: JwtUser,
+ ): Promise {
+ return this.prisma.$transaction(async (tx) => {
+ await this.assertTeam(tx);
+ const actor = await this.actor(tx, user.id);
+ const row = await tx.timeEntry.findUnique({
+ where: { id },
+ include: APPROVAL_INCLUDE,
+ });
+ if (!row) throw new NotFoundException('Time entry not found');
+ this.assertReviewAccess(row, actor, true);
+ // A split may move the original ID wholly inside the frame. Its old
+ // decisions and invalidation remain reviewable, even though it no
+ // longer needs approval. Solo archives must remain inaccessible.
+ if (row.approvalMode === 'Solo')
+ throw new ForbiddenException(
+ 'Solo entries have no Team approval history',
+ );
+ const events = await tx.timeEntryAudit.findMany({
+ where: { timeEntryId: id, action: { startsWith: 'Frame' } },
+ include: { actor: { select: { firstName: true, lastName: true } } },
+ orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }],
+ });
+ return events.map((event) => ({
+ id: event.id,
+ requestId: event.requestId,
+ actorId: event.actorId,
+ action: event.action,
+ reason: event.reason,
+ actorName: event.actor
+ ? `${event.actor.firstName} ${event.actor.lastName}`
+ : null,
+ occurredAt: event.occurredAt.toISOString(),
+ }));
+ });
+ }
+
+ async decide(
+ id: string,
+ dto: DecideTimeEntryApprovalDto,
+ user: JwtUser,
+ ): Promise {
+ const result = await this.prisma.$transaction(async (tx) => {
+ // Same serialization order as every booking/correction and installation
+ // mode change. Row locks also serialize concurrent role/manager changes.
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(7261500)`;
+ await this.assertTeam(tx);
+ const initial = await tx.timeEntry.findUnique({ where: { id } });
+ if (!initial) throw new NotFoundException('Time entry not found');
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${initial.employeeId}, 0))`;
+ await tx.$queryRaw`SELECT "id" FROM "Employee" WHERE "id" = ${initial.employeeId}::uuid FOR SHARE`;
+ const employee = await tx.employee.findUniqueOrThrow({
+ where: { id: initial.employeeId },
+ });
+ const ids = [
+ ...new Set(
+ [user.id, employee.id, employee.managerId].filter(
+ (value): value is string => !!value,
+ ),
+ ),
+ ].sort();
+ await tx.$queryRaw(
+ Prisma.sql`SELECT "id" FROM "Employee" WHERE "id" IN (${Prisma.join(ids.map((value) => Prisma.sql`${value}::uuid`))}) ORDER BY "id" FOR SHARE`,
+ );
+ const actor = await this.actor(tx, user.id);
+ const row = await tx.timeEntry.findUniqueOrThrow({
+ where: { id },
+ include: APPROVAL_INCLUDE,
+ });
+ this.assertReviewAccess(row, actor, false);
+ this.assertApplicable(row);
+ if (!row.employee.isActive)
+ throw new ForbiddenException('Employee account is inactive');
+ if (row.revision !== dto.revision)
+ throw new ConflictException(
+ 'Time entry changed; reload before deciding',
+ );
+ const view = this.dto(row, actor);
+ if (
+ (dto.stage === 'Manager' && actor.role !== 'Manager') ||
+ (dto.stage === 'HR' && actor.role !== 'HRAdmin')
+ )
+ throw new ForbiddenException('Wrong approval role');
+ if (
+ !view.canDecide ||
+ view.state !==
+ (dto.stage === 'Manager' ? 'PendingManager' : 'PendingHr')
+ )
+ throw new ConflictException(
+ 'Time entry is not awaiting this approval stage',
+ );
+ const note = dto.note?.trim() || null;
+ if (dto.outcome === 'Reject' && !note)
+ throw new BadRequestException('A rejection reason is required');
+ const approveManager =
+ dto.stage === 'Manager' && dto.outcome === 'Approve';
+ const data: Prisma.TimeEntryUncheckedUpdateInput = {
+ revision: { increment: 1 },
+ status:
+ dto.outcome === 'Reject'
+ ? 'Rejected'
+ : dto.stage === 'HR'
+ ? 'Approved'
+ : 'Pending',
+ ...(approveManager
+ ? {
+ frameApproverId: actor.id,
+ frameApprovedRevision: row.revision + 1,
+ }
+ : {}),
+ };
+ const changed = await tx.timeEntry.updateMany({
+ where: {
+ id,
+ revision: dto.revision,
+ status: 'Pending',
+ clockOut: { not: null },
+ voidedAt: null,
+ },
+ data,
+ });
+ if (changed.count !== 1)
+ throw new ConflictException(
+ 'Time entry changed; reload before deciding',
+ );
+ const updated = await tx.timeEntry.findUniqueOrThrow({
+ where: { id },
+ include: APPROVAL_INCLUDE,
+ });
+ const action =
+ dto.stage === 'Manager'
+ ? dto.outcome === 'Approve'
+ ? 'FrameManagerApproved'
+ : 'FrameManagerRejected'
+ : dto.outcome === 'Approve'
+ ? 'FrameHrConfirmed'
+ : 'FrameHrRejected';
+ await tx.timeEntryAudit.create({
+ data: {
+ timeEntryId: id,
+ actorId: actor.id,
+ action,
+ reason: note,
+ before: {
+ revision: row.revision,
+ state: view.state,
+ status: row.status,
+ },
+ after: {
+ revision: updated.revision,
+ state: frameApprovalState(updated),
+ status: updated.status,
+ },
+ },
+ });
+ return this.dto(updated, actor);
+ });
+ this.events.broadcast('time-entry:updated', {
+ id,
+ employeeId: result.entry.employeeId,
+ clockOut: result.entry.clockOut,
+ });
+ return result;
+ }
+}
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 037c1a3..5fd9611 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -109,6 +109,45 @@ export interface EmployeeDto {
isActive: boolean;
}
+/** Confirmed, self-contained historical facts; never a live schedule reference. */
+export interface ContractPeriodDto {
+ id: string;
+ employeeId: string;
+ effectiveFrom: string;
+ effectiveTo: string | null;
+ employmentStart: string;
+ employmentEnd: string | null;
+ weeklyHours: number;
+ timeModel: TimeModel;
+ workingDays: number;
+ holidayCalendar: HolidayCalendar;
+ holidayDates: string[];
+ scheduleLabel: string | null;
+ confirmationNote: string;
+ confirmedById: string | null;
+ revision: number;
+ createdAt: string;
+}
+
+type ContractPeriodSupersession =
+ | { supersedesPeriodId: string; expectedRevision: number }
+ | { supersedesPeriodId?: never; expectedRevision?: never };
+
+export type CreateContractPeriodPayload = Pick<
+ ContractPeriodDto,
+ | 'effectiveFrom'
+ | 'effectiveTo'
+ | 'employmentStart'
+ | 'employmentEnd'
+ | 'weeklyHours'
+ | 'timeModel'
+ | 'workingDays'
+ | 'holidayCalendar'
+ | 'holidayDates'
+ | 'scheduleLabel'
+ | 'confirmationNote'
+> & { confirmed: true } & ContractPeriodSupersession;
+
export interface CreateEmployeePayload {
personalNo: string;
firstName: string;
@@ -191,6 +230,74 @@ export interface TimeEntryDto {
summary: TimeSummaryDto | null;
}
+/** Eligibility and revision are server-authoritative; this endpoint is own-only. */
+export interface TimeAdjustmentTargetDto extends TimeEntryDto {
+ revision: number;
+}
+
+export interface TimeEntryBeforeSnapshot {
+ id: string;
+ revision: number;
+ clockIn: string;
+ clockOut: string;
+ status: string;
+ projectId: string | null;
+ serviceOrderId: string | null;
+ activity: string | null;
+ breakRules: unknown;
+ captureGroupId: string | null;
+ approvalMode: string | null;
+}
+
+export interface TimeEntryAuditDto {
+ actorName?: string | null;
+ id: string;
+ timeEntryId: string;
+ actorId: string | null;
+ action: string;
+ before: unknown;
+ after: unknown;
+ reason: string | null;
+ occurredAt: string;
+ requestId: string | null;
+}
+
+export type TimeEntryApprovalState =
+ | 'Open'
+ | 'BlockedManager'
+ | 'PendingManager'
+ | 'PendingHr'
+ | 'Approved'
+ | 'Rejected'
+ | 'Voided';
+
+export interface TimeEntryApprovalDto {
+ entry: TimeEntryDto;
+ revision: number;
+ employeeName: string;
+ managerId: string | null;
+ managerName: string | null;
+ state: TimeEntryApprovalState;
+ canDecide: boolean;
+}
+
+export interface TimeEntryApprovalDecision {
+ revision: number;
+ stage: 'Manager' | 'HR';
+ outcome: 'Approve' | 'Reject';
+ note?: string;
+}
+
+export interface TimeEntryApprovalEventDto {
+ requestId?: string | null;
+ id: string;
+ actorId: string | null;
+ actorName: string | null;
+ action: string;
+ reason: string | null;
+ occurredAt: string;
+}
+
export interface SplitTimeEntryResult {
first: TimeEntryDto;
second: TimeEntryDto;
@@ -333,11 +440,28 @@ export interface WorkingTimeReportTotalsDto {
netMinutes: number;
}
+export interface WorkingTimeComparisonDto {
+ employeeId: string;
+ employeeName: string;
+ targetMinutes: number | null;
+ actualMinutes: number;
+ differenceMinutes: number | null;
+ targetStatus: 'Known' | 'Unknown';
+ unknownDates: string[];
+ reason: 'CONTRACT_HISTORY_GAP' | null;
+ indicator: 'Unknown' | 'Negative' | 'Balanced' | 'Positive' | 'Excess';
+ openEntryCount: number;
+}
+
export interface WorkingTimeReportDto {
from: string;
to: string;
rows: WorkingTimeReportRowDto[];
totals: WorkingTimeReportTotalsDto;
+ comparisons: WorkingTimeComparisonDto[];
+ timeZone: string;
+ timeDefinition: 'net_working_time';
+ excessThresholdMinutes: 600;
}
export interface UpsertProjectPayload {
@@ -365,8 +489,13 @@ export interface AccountDto {
}
export interface RequestDto {
+ targetTimeEntryId?: string | null;
+ expectedTargetRevision?: number | null;
+ targetTimeEntryBefore?: TimeEntryBeforeSnapshot | null;
id: string;
employeeId: string;
+ employeeName: string;
+ updatedAt: string;
type: RequestType;
status: RequestStatus;
workflowState: WorkflowState;
@@ -529,6 +658,8 @@ export interface UpsertWorkSchedulePayload {
}
export interface CreateRequestPayload {
+ targetTimeEntryId?: string | null;
+ expectedTargetRevision?: number | null;
employeeId: string;
type: RequestType;
from: string;
@@ -546,6 +677,15 @@ export interface CreateVacationPayload {
halfDayEnd?: boolean;
}
+export interface UpdateDraftRequestPayload extends Omit<
+ CreateVacationPayload,
+ 'employeeId'
+> {
+ expectedUpdatedAt: string;
+ targetTimeEntryId?: string | null;
+ expectedTargetRevision?: number | null;
+}
+
export interface UpsertLeaveAllowancePayload {
baseDays: number;
carryOverDays: number;
@@ -896,6 +1036,21 @@ export const api = {
method: 'PUT',
body: JSON.stringify(payload),
}),
+ contractPeriods: (employeeId: string) =>
+ request(
+ `/api/employees/${employeeId}/contract-periods`,
+ ),
+ createContractPeriod: (
+ employeeId: string,
+ payload: CreateContractPeriodPayload,
+ ) =>
+ request(
+ `/api/employees/${employeeId}/contract-periods`,
+ {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ },
+ ),
setEmployeePassword: (id: string, password: string) =>
request(`/api/employees/${id}/password`, {
method: 'POST',
@@ -913,6 +1068,19 @@ export const api = {
if (to) params.set('to', to);
return request(`/api/timeentries?${params.toString()}`);
},
+ timeEntryApprovals: (scope: 'own' | 'review' = 'review') =>
+ request(
+ `/api/timeentries/approvals?scope=${scope}`,
+ ),
+ decideTimeEntryApproval: (id: string, payload: TimeEntryApprovalDecision) =>
+ request(`/api/timeentries/${id}/approval`, {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ }),
+ timeEntryApprovalHistory: (id: string) =>
+ request(
+ `/api/timeentries/${id}/approval-history`,
+ ),
clockIn: (payload: ClockInPayload) =>
request('/api/timeentries/clock-in', {
method: 'POST',
@@ -1042,6 +1210,10 @@ export const api = {
const qs = params.toString();
return request(`/api/requests${qs ? `?${qs}` : ''}`);
},
+ timeAdjustmentTargets: () =>
+ request('/api/requests/time-adjustment-targets'),
+ requestTimeEntryAudit: (id: string) =>
+ request(`/api/requests/${id}/time-entry-audit`),
getRequest: (id: string) => request(`/api/requests/${id}`),
getRequestEvents: (id: string) =>
request(`/api/requests/${id}/events`),
@@ -1055,6 +1227,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..8a3fa11 100644
--- a/apps/web/src/api/generated.ts
+++ b/apps/web/src/api/generated.ts
@@ -388,6 +388,22 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/api/employees/{id}/contract-periods': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations['ContractPeriodsController_list'];
+ put?: never;
+ post: operations['ContractPeriodsController_confirm'];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
'/api/work-schedules': {
parameters: {
query?: never;
@@ -676,6 +692,54 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/api/timeentries/approvals': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations['TimeEntriesController_approvalList'];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/timeentries/{id}/approval-history': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations['TimeEntriesController_approvalHistory'];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/timeentries/{id}/approval': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: operations['TimeEntriesController_decideApproval'];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
'/api/timeentries/clock-in': {
parameters: {
query?: never;
@@ -1268,6 +1332,38 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/api/requests/time-adjustment-targets': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations['RequestsController_timeAdjustmentTargets'];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ '/api/requests/{id}/time-entry-audit': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations['RequestsController_timeEntryAudit'];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
'/api/requests/{id}': {
parameters: {
query?: never;
@@ -1281,7 +1377,7 @@ export interface paths {
delete?: never;
options?: never;
head?: never;
- patch?: never;
+ patch: operations['RequestsController_updateDraft'];
trace?: never;
};
'/api/requests/{id}/events': {
@@ -1316,6 +1412,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;
@@ -2046,6 +2158,91 @@ export interface components {
SetPasswordDto: {
password: string;
};
+ EmployeeContractPeriodDto: {
+ /** Format: uuid */
+ id: string;
+ /** Format: uuid */
+ employeeId: string;
+ /** Format: date */
+ effectiveFrom: string;
+ /** Format: date */
+ effectiveTo: string | null;
+ /** Format: date */
+ employmentStart: string;
+ /** Format: date */
+ employmentEnd: string | null;
+ weeklyHours: number;
+ /** @enum {string} */
+ timeModel:
+ | 'Teilzeit'
+ | 'Vollzeit'
+ | 'Vertrauensarbeitszeit'
+ | 'Gleitzeit';
+ workingDays: number;
+ holidayCalendar: string;
+ holidayDates: string[];
+ scheduleLabel: string | null;
+ confirmationNote: string;
+ /** Format: uuid */
+ confirmedById: string | null;
+ revision: number;
+ /** Format: date-time */
+ createdAt: string;
+ };
+ ConfirmContractPeriodDto: {
+ /** Format: date */
+ effectiveFrom: string;
+ /** Format: date */
+ effectiveTo?: string | null;
+ /** Format: date */
+ employmentStart: string;
+ /** Format: date */
+ employmentEnd?: string | null;
+ weeklyHours: number;
+ /** @enum {string} */
+ timeModel:
+ | 'Teilzeit'
+ | 'Vollzeit'
+ | 'Vertrauensarbeitszeit'
+ | 'Gleitzeit';
+ /** @description Immutable weekday mask: Mon=1, Tue=2, ..., Sun=64. Equal daily target, rounded to whole minutes. */
+ workingDays: number;
+ /** @enum {string} */
+ holidayCalendar:
+ | 'NONE'
+ | 'DE-BW'
+ | 'DE-BY'
+ | 'DE-BE'
+ | 'DE-BB'
+ | 'DE-HB'
+ | 'DE-HH'
+ | 'DE-HE'
+ | 'DE-MV'
+ | 'DE-NI'
+ | 'DE-NW'
+ | 'DE-RP'
+ | 'DE-SL'
+ | 'DE-SN'
+ | 'DE-ST'
+ | 'DE-SH'
+ | 'DE-TH';
+ /** @description Explicit non-working dates, supplementing the snapshot calendar. */
+ holidayDates: string[];
+ scheduleLabel?: string | null;
+ /** @description HR source/confirmation explanation. No automatic historical inference. */
+ confirmationNote: string;
+ /**
+ * @description Explicit confirmation that these dated facts are known, not inferred from the current employee profile.
+ * @enum {boolean}
+ */
+ confirmed: true;
+ /**
+ * Format: uuid
+ * @description Explicitly close this prior period on the day before effectiveFrom; no other facts are changed.
+ */
+ supersedesPeriodId?: string;
+ expectedRevision?: number;
+ };
BreakRuleDto: {
/** @description Inclusive attendance threshold in minutes. */
afterMinutes: number;
@@ -2222,6 +2419,27 @@ export interface components {
firstName: string;
lastName: string;
};
+ WorkingTimeComparisonDto: {
+ /** Format: uuid */
+ employeeId: string;
+ employeeName: string;
+ targetMinutes: number | null;
+ actualMinutes: number;
+ differenceMinutes: number | null;
+ /** @enum {string} */
+ targetStatus: 'Known' | 'Unknown';
+ /** @description Calendar dates lacking a confirmed contract snapshot, including non-working dates. */
+ unknownDates: string[];
+ /** @enum {string|null} */
+ reason: 'CONTRACT_HISTORY_GAP' | null;
+ /**
+ * @description Selected-period review indicator only; not a legal limit or a carried overtime balance. Excess means difference >= 600 minutes.
+ * @enum {string}
+ */
+ indicator: 'Unknown' | 'Negative' | 'Balanced' | 'Positive' | 'Excess';
+ /** @description Non-voided, non-rejected open entries started before the period end; excluded from actual minutes. */
+ openEntryCount: number;
+ };
WorkingTimeReportLocationDto: {
label: string | null;
latitude: number | null;
@@ -2241,7 +2459,7 @@ export interface components {
/** Format: date-time */
clockOut: string;
/** @enum {string} */
- status: 'Open' | 'Pending' | 'Approved';
+ status: 'Pending' | 'Approved';
grossMinutes: number;
breakMinutes: number;
netMinutes: number;
@@ -2262,6 +2480,15 @@ export interface components {
from: string;
/** Format: date */
to: string;
+ timeZone: string;
+ /** @enum {string} */
+ timeDefinition: 'net_working_time';
+ /**
+ * @description Period-review threshold, not a statutory working-time limit.
+ * @enum {number}
+ */
+ excessThresholdMinutes: 600;
+ comparisons: components['schemas']['WorkingTimeComparisonDto'][];
rows: components['schemas']['WorkingTimeReportRowDto'][];
totals: components['schemas']['WorkingTimeReportTotalsDto'];
};
@@ -2332,6 +2559,44 @@ export interface components {
activity: string | null;
summary: components['schemas']['TimeSummaryDto'] | null;
};
+ TimeEntryApprovalDto: {
+ entry: components['schemas']['TimeEntryDto'];
+ revision: number;
+ employeeName: string;
+ /** Format: uuid */
+ managerId: string | null;
+ managerName: string | null;
+ /** @enum {string} */
+ state:
+ | 'Open'
+ | 'BlockedManager'
+ | 'PendingManager'
+ | 'PendingHr'
+ | 'Approved'
+ | 'Rejected'
+ | 'Voided';
+ canDecide: boolean;
+ };
+ TimeEntryApprovalEventDto: {
+ /** Format: uuid */
+ requestId: string | null;
+ id: string;
+ /** Format: uuid */
+ actorId: string | null;
+ actorName: string | null;
+ action: string;
+ reason: string | null;
+ /** Format: date-time */
+ occurredAt: string;
+ };
+ DecideTimeEntryApprovalDto: {
+ revision: number;
+ /** @enum {string} */
+ stage: 'Manager' | 'HR';
+ /** @enum {string} */
+ outcome: 'Approve' | 'Reject';
+ note?: string;
+ };
ClockInDto: {
note?: string | null;
billable?: boolean;
@@ -2500,6 +2765,9 @@ export interface components {
second: components['schemas']['TimeEntryDto'];
};
TimeEntryAuditDto: {
+ actorName?: string | null;
+ /** Format: uuid */
+ requestId: string | null;
/** Format: uuid */
id: string;
/** Format: uuid */
@@ -2813,7 +3081,56 @@ export interface components {
adjustmentDays: number;
adjustmentReason?: Record | null;
};
+ RequestDto: {
+ /** Format: uuid */
+ targetTimeEntryId: string | null;
+ expectedTargetRevision: number | null;
+ /** @description Immutable server-captured original time-entry values, including id, revision, clockIn, clockOut and break-policy snapshot. */
+ targetTimeEntryBefore: {
+ [key: string]: unknown;
+ } | null;
+ /** Format: uuid */
+ id: string;
+ /** Format: uuid */
+ employeeId: string;
+ employeeName: string;
+ /** Format: date-time */
+ updatedAt: string;
+ /** @enum {string} */
+ type: 'Vacation' | 'HomeOffice' | 'SpecialLeave' | 'TimeAdjustment';
+ status: string;
+ workflowState: string;
+ /** Format: date-time */
+ from: string;
+ /** Format: date-time */
+ to: string;
+ reason: string | null;
+ requiresApproval: boolean;
+ /** Format: uuid */
+ approverId: string | null;
+ /** Format: uuid */
+ currentApproverId: string | null;
+ /** Format: uuid */
+ substituteId: string | null;
+ /** Format: date-time */
+ substituteAcceptedAt: string | null;
+ /** Format: date-time */
+ hrConfirmedAt: string | null;
+ /** Format: date-time */
+ cancelledAt: string | null;
+ calculatedDays: number;
+ halfDayStart: boolean;
+ halfDayEnd: boolean;
+ /** Format: date-time */
+ decidedAt: string | null;
+ decisionNote: string | null;
+ /** Format: date-time */
+ createdAt: string;
+ };
CreateRequestDto: {
+ /** Format: uuid */
+ targetTimeEntryId?: string | null;
+ expectedTargetRevision?: number | null;
/** Format: uuid */
employeeId: string;
/** @enum {string} */
@@ -2828,9 +3145,12 @@ export interface components {
* @example 2026-08-07T00:00:00.000Z
*/
to: string;
- reason?: Record | null;
+ reason?: string | null;
};
CreateVacationDto: {
+ /** Format: uuid */
+ targetTimeEntryId?: string | null;
+ expectedTargetRevision?: number | null;
/** Format: uuid */
employeeId: string;
/** Format: date-time */
@@ -2838,8 +3158,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 +3171,42 @@ export interface components {
*/
halfDayEnd: boolean;
};
+ UpdateDraftRequestDto: {
+ /** Format: uuid */
+ targetTimeEntryId?: string | null;
+ expectedTargetRevision?: number | null;
+ /** Format: date-time */
+ from: string;
+ /** Format: date-time */
+ to: string;
+ /** Format: uuid */
+ substituteId?: string | null;
+ reason?: string | null;
+ /**
+ * @description Take the first day as a half-day
+ * @default false
+ */
+ halfDayStart: boolean;
+ /**
+ * @description Take the last day as a half-day
+ * @default false
+ */
+ halfDayEnd: boolean;
+ /**
+ * Format: date-time
+ * @description updatedAt from the version being edited; stale writes return 409
+ */
+ expectedUpdatedAt: string;
+ };
TransitionDto: {
/** Format: uuid */
actorId: string;
- note?: Record | null;
+ note?: string | null;
};
ManagerApproveDto: {
/** Format: uuid */
actorId: string;
- note?: Record | null;
+ note?: string | null;
/** @default false */
requiresHrConfirmation: boolean;
};
@@ -2872,7 +3219,7 @@ export interface components {
/** Format: uuid */
actorId: string;
ids: string[];
- note?: Record | null;
+ note?: string | null;
/** @default false */
requiresHrConfirmation: boolean;
};
@@ -3613,6 +3960,52 @@ export interface operations {
};
};
};
+ ContractPeriodsController_list: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['EmployeeContractPeriodDto'][];
+ };
+ };
+ };
+ };
+ ContractPeriodsController_confirm: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['ConfirmContractPeriodDto'];
+ };
+ };
+ responses: {
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['EmployeeContractPeriodDto'];
+ };
+ };
+ };
+ };
WorkSchedulesController_list: {
parameters: {
query?: never;
@@ -4120,7 +4513,7 @@ export interface operations {
from: string;
to: string;
employeeId?: string;
- /** @description Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees; cannot be combined with employeeId. */
+ /** @description Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees in the actor’s scope (HR: all; Manager: self and direct reports); cannot be combined with employeeId. */
employeeIds?: string[];
includeLocations?: boolean;
};
@@ -4146,7 +4539,7 @@ export interface operations {
from: string;
to: string;
employeeId?: string;
- /** @description Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees; cannot be combined with employeeId. */
+ /** @description Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees in the actor’s scope (HR: all; Manager: self and direct reports); cannot be combined with employeeId. */
employeeIds?: string[];
locale?: 'de' | 'en';
};
@@ -4190,6 +4583,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 +5519,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'][];
+ };
};
};
};
@@ -5080,7 +5542,52 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
+ };
+ };
+ };
+ RequestsController_timeAdjustmentTargets: {
+ parameters: {
+ query: {
+ from: string;
+ to: string;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['TimeEntryDto'][];
+ };
+ };
+ };
+ };
+ RequestsController_timeEntryAudit: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/json': components['schemas']['TimeEntryAuditDto'][];
+ };
};
};
};
@@ -5099,7 +5606,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 +5673,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 +5723,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5185,7 +5748,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5208,7 +5773,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5231,7 +5798,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5254,7 +5823,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5277,7 +5848,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5300,7 +5873,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5323,7 +5898,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5346,7 +5923,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
@@ -5369,7 +5948,9 @@ export interface operations {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ 'application/json': components['schemas']['RequestDto'];
+ };
};
};
};
diff --git a/apps/web/src/app/app.tsx b/apps/web/src/app/app.tsx
index a15e58f..1485a46 100644
--- a/apps/web/src/app/app.tsx
+++ b/apps/web/src/app/app.tsx
@@ -195,10 +195,12 @@ export function App() {
} />
)}
} />
- }
- />
+ {(user.role === 'Manager' || user.role === 'HRAdmin') && (
+ }
+ />
+ )}
} />
} />
;
@@ -16,6 +17,73 @@ type Catalog = Record;
const STORAGE_KEY = 'openclockwork.locale';
const de: Catalog = {
+ ...workingTimeDe,
+ 'requests.appliedBy': 'Angewendet von {actor}',
+ 'requests.unknownActor': 'Nicht mehr verfügbare Person',
+ 'frameApproval.ownTitle': 'Rahmenzeit-Genehmigungen für meine Zeitbuchungen',
+ 'frameApproval.title': 'Zeitbuchungen',
+ 'frameApproval.chain':
+ 'Zeitbuchungen außerhalb der Rahmenzeit benötigen zuerst die Genehmigung der zugewiesenen Führungskraft und danach die Bestätigung durch HR. Beide bearbeiten sie unter Genehmigungen → Zeitbuchungen.',
+ 'frameApproval.managerChanged':
+ 'Solange HR noch nicht bestätigt hat, muss nach einer Änderung der zugewiesenen Führungskraft die neue Führungskraft erneut genehmigen.',
+ 'frameApproval.refresh': 'Genehmigungen aktualisieren',
+ 'frameApproval.empty':
+ 'Keine genehmigungspflichtigen Zeitbuchungen vorhanden.',
+ 'frameApproval.loadFailed':
+ 'Zeitbuchungs-Genehmigungen konnten nicht geladen werden.',
+ 'frameApproval.historyFailed':
+ 'Der Genehmigungsverlauf konnte nicht geladen werden.',
+ 'frameApproval.decisionFailed': 'Die Entscheidung wurde nicht gespeichert.',
+ 'frameApproval.refreshHint':
+ 'Bitte aktualisieren und den aktuellen Stand prüfen, bevor du erneut entscheidest.',
+ 'frameApproval.notAllowed': 'Diese Entscheidung ist nicht zulässig.',
+ 'frameApproval.note': 'Notiz (bei Ablehnung erforderlich)',
+ 'frameApproval.reasonRequired':
+ 'Für eine Ablehnung ist eine Begründung erforderlich.',
+ 'frameApproval.assignedManager': 'zugewiesene Führungskraft',
+ 'frameApproval.history': 'Genehmigungsverlauf',
+ 'frameApproval.noHistory':
+ 'Noch keine Entscheidungen oder Änderungen vorhanden.',
+ 'frameApproval.system': 'System',
+ 'frameApproval.managerResult':
+ 'Führungskraft-Genehmigung gespeichert. Als Nächstes bestätigt HR unter Genehmigungen → Zeitbuchungen.',
+ 'frameApproval.hrResult':
+ 'HR-Bestätigung gespeichert. Die Zeitbuchung ist genehmigt.',
+ 'frameApproval.rejectedResult': 'Ablehnung mit Begründung gespeichert.',
+ 'frameApproval.approve.Manager': 'Als Führungskraft genehmigen',
+ 'frameApproval.approve.HR': 'Als HR bestätigen',
+ 'frameApproval.reject.Manager': 'Als Führungskraft ablehnen',
+ 'frameApproval.reject.HR': 'Als HR ablehnen',
+ 'frameApproval.state.Open': 'Offen – zuerst ausstempeln',
+ 'frameApproval.state.BlockedManager': 'Blockiert – Führungskraft fehlt',
+ 'frameApproval.state.PendingManager': 'Wartet auf Führungskraft',
+ 'frameApproval.state.PendingHr': 'Wartet auf HR',
+ 'frameApproval.state.Approved': 'Genehmigt',
+ 'frameApproval.state.Rejected': 'Abgelehnt',
+ 'frameApproval.state.Voided': 'Ungültig',
+ 'frameApproval.help.Open':
+ 'Bitte zuerst ausstempeln. Erst die abgeschlossene Zeitbuchung kann von der zugewiesenen Führungskraft und danach von HR unter Genehmigungen → Zeitbuchungen geprüft werden.',
+ 'frameApproval.help.BlockedManager':
+ 'Bitte HR bitten, eine aktive Führungskraft zuzuweisen. Danach genehmigt diese Führungskraft zuerst, anschließend bestätigt HR unter Genehmigungen → Zeitbuchungen. Es gibt keine direkte HR-Freigabe ohne Führungskraft-Genehmigung.',
+ 'frameApproval.help.PendingManager':
+ 'Nächster Schritt: {manager} prüft als zugewiesene Führungskraft unter Genehmigungen → Zeitbuchungen. Danach bestätigt HR am selben Ort.',
+ 'frameApproval.help.PendingHr':
+ 'Die Führungskraft hat genehmigt. Nächster Schritt: HR bestätigt unter Genehmigungen → Zeitbuchungen.',
+ 'frameApproval.help.Approved':
+ 'Die Zeitbuchung ist genehmigt. Dokumentierte Entscheidungen stehen mit Person und Zeitpunkt im Genehmigungsverlauf.',
+ 'frameApproval.help.Rejected':
+ 'Die Zeitbuchung wurde abgelehnt. Begründung und entscheidende Person stehen im Genehmigungsverlauf.',
+ 'frameApproval.help.Voided':
+ 'Diese Zeitbuchung ist nicht mehr gültig und kann nicht genehmigt werden. Der Verlauf bleibt einsehbar.',
+ 'frameApproval.event.FrameSegmentCreated':
+ 'Buchungssegment nach Änderung neu angelegt',
+ 'frameApproval.event.FrameRequestCorrected': 'Durch Korrekturantrag geändert',
+ 'frameApproval.event.FrameManagerApproved': 'Von Führungskraft genehmigt',
+ 'frameApproval.event.FrameManagerRejected': 'Von Führungskraft abgelehnt',
+ 'frameApproval.event.FrameHrConfirmed': 'Von HR bestätigt',
+ 'frameApproval.event.FrameHrRejected': 'Von HR abgelehnt',
+ 'frameApproval.event.FrameApprovalInvalidated':
+ 'Bisherige Genehmigung durch Änderung aufgehoben',
...soloDe,
...terminalPinDe,
'projects.deleteFailed': 'Projekt konnte nicht gelöscht werden',
@@ -70,6 +138,33 @@ const de: Catalog = {
'requests.attachmentLabel': 'Beleg (optional, max 10 MB)',
'requests.balanceHint':
'{remaining} Tage verfügbar ({total} gesamt − {approved} genehmigt − {pending} eingereicht).',
+ 'requests.cancelStale': 'Diesen Antrag stornieren',
+ 'requests.adjustmentMode': 'Art der Zeitkorrektur',
+ 'requests.missingBooking': 'Fehlende Buchung nachtragen',
+ 'requests.correctBooking': 'Bestehende Buchung korrigieren',
+ 'requests.targetBooking': 'Zu korrigierende Buchung',
+ 'requests.chooseTarget': 'Buchung auswählen',
+ 'requests.targetsEmpty':
+ 'Keine abgeschlossenen Team-Buchungen für eine Korrektur verfügbar.',
+ 'requests.targetsFailed': 'Buchungen konnten nicht geladen werden.',
+ 'requests.targetRequired': 'Bitte eine zu korrigierende Buchung auswählen.',
+ 'requests.correctionHint':
+ 'Korrigiert nur diese Buchung mit derselben ID. Es entsteht keine doppelte Buchung; andere Buchungen des Tages bleiben unverändert.',
+ 'requests.targetReference': 'Buchung {id} · Revision {revision}',
+ 'requests.before': 'Vorher',
+ 'requests.proposed': 'Vorgeschlagen',
+ 'requests.grossDuration': 'Bruttodauer: {before} s → {after} s (Δ {delta} s)',
+ 'requests.grossProposed': 'Vorgeschlagene Bruttodauer: {duration} s',
+ 'requests.staleTargetHint':
+ 'Ist die Buchung inzwischen geändert, diesen Antrag abbrechen und einen neuen Korrekturantrag mit der aktuellen Buchung erstellen. Ziel und Revision dieses Entwurfs bleiben unverändert.',
+ 'requests.auditFailed': 'Buchungsverlauf konnte nicht geladen werden.',
+ 'requests.auditEmpty': 'Noch keine Buchungsänderung durch diesen Antrag.',
+ 'requests.auditApplied': 'Buchung {id} durch Antrag {requestId} korrigiert',
+ 'requests.auditMissingAdded':
+ 'Fehlende Buchung {id} durch Antrag {requestId} ergänzt',
+ 'requests.relatedRequest': 'Zugehöriger Antrag: {id}',
+ 'requests.retry': 'Erneut laden',
+ 'requests.bookingHistory': 'Buchungsänderung im Verlauf',
'requests.timeAdjustmentPolicy':
'Genehmigungen richten sich nach deinem Arbeitszeitplan. Zeiten außerhalb des konfigurierten Rahmens benötigen eine zusätzliche Genehmigung vor der HR-Bestätigung.',
'requests.invalidTimeRange': '"Bis" muss nach "Von" liegen.',
@@ -347,6 +442,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 +801,68 @@ const de: Catalog = {
};
const en: Catalog = {
+ ...workingTimeEn,
+ 'requests.appliedBy': 'Applied by {actor}',
+ 'requests.unknownActor': 'Reviewer no longer available',
+ 'frameApproval.ownTitle': 'Approvals for my out-of-frame time entries',
+ 'frameApproval.title': 'Time entries',
+ 'frameApproval.chain':
+ 'Out-of-frame time entries first need approval by the assigned manager, then confirmation by HR. Both review them under Approvals → Time entries.',
+ 'frameApproval.managerChanged':
+ 'Before HR has confirmed, changing the assigned manager requires the new manager to approve again.',
+ 'frameApproval.refresh': 'Refresh approvals',
+ 'frameApproval.empty': 'No time entries requiring approval.',
+ 'frameApproval.loadFailed': 'Could not load time entry approvals.',
+ 'frameApproval.historyFailed': 'Could not load approval history.',
+ 'frameApproval.decisionFailed': 'The decision was not saved.',
+ 'frameApproval.refreshHint':
+ 'Refresh and check the current status before deciding again.',
+ 'frameApproval.notAllowed': 'This decision is not permitted.',
+ 'frameApproval.note': 'Note (required for rejection)',
+ 'frameApproval.reasonRequired': 'A reason is required to reject an entry.',
+ 'frameApproval.assignedManager': 'assigned manager',
+ 'frameApproval.history': 'Approval history',
+ 'frameApproval.noHistory': 'No decisions or changes yet.',
+ 'frameApproval.system': 'System',
+ 'frameApproval.managerResult':
+ 'Manager approval saved. HR confirms next under Approvals → Time entries.',
+ 'frameApproval.hrResult':
+ 'HR confirmation saved. The time entry is approved.',
+ 'frameApproval.rejectedResult': 'Rejection and reason saved.',
+ 'frameApproval.approve.Manager': 'Approve as manager',
+ 'frameApproval.approve.HR': 'Confirm as HR',
+ 'frameApproval.reject.Manager': 'Reject as manager',
+ 'frameApproval.reject.HR': 'Reject as HR',
+ 'frameApproval.state.Open': 'Open – clock out first',
+ 'frameApproval.state.BlockedManager': 'Blocked – no active manager',
+ 'frameApproval.state.PendingManager': 'Awaiting manager approval',
+ 'frameApproval.state.PendingHr': 'Awaiting HR confirmation',
+ 'frameApproval.state.Approved': 'Approved',
+ 'frameApproval.state.Rejected': 'Rejected',
+ 'frameApproval.state.Voided': 'Voided',
+ 'frameApproval.help.Open':
+ 'Clock out first. Only a closed time entry can be reviewed by the assigned manager and then HR under Approvals → Time entries.',
+ 'frameApproval.help.BlockedManager':
+ 'Ask HR to assign an active manager. That manager must approve first, followed by HR confirmation under Approvals → Time entries. HR cannot bypass manager approval.',
+ 'frameApproval.help.PendingManager':
+ 'Next: {manager}, the assigned manager, reviews under Approvals → Time entries. HR then confirms in the same place.',
+ 'frameApproval.help.PendingHr':
+ 'The manager has approved. Next: HR confirms under Approvals → Time entries.',
+ 'frameApproval.help.Approved':
+ 'This time entry is approved. Recorded decisions, reviewers and timestamps are available in approval history.',
+ 'frameApproval.help.Rejected':
+ 'The time entry was rejected. See approval history for the reason and reviewer.',
+ 'frameApproval.help.Voided':
+ 'This time entry is no longer valid and cannot be approved. Its history remains available.',
+ 'frameApproval.event.FrameSegmentCreated':
+ 'Entry segment created after a change',
+ 'frameApproval.event.FrameRequestCorrected': 'Changed by correction request',
+ 'frameApproval.event.FrameManagerApproved': 'Approved by manager',
+ 'frameApproval.event.FrameManagerRejected': 'Rejected by manager',
+ 'frameApproval.event.FrameHrConfirmed': 'Confirmed by HR',
+ 'frameApproval.event.FrameHrRejected': 'Rejected by HR',
+ 'frameApproval.event.FrameApprovalInvalidated':
+ 'Previous approval invalidated by a change',
...soloEn,
...terminalPinEn,
'projects.deleteFailed': 'Could not delete project',
@@ -744,6 +917,34 @@ const en: Catalog = {
'requests.attachmentLabel': 'Attachment (optional, max 10 MB)',
'requests.balanceHint':
'{remaining} days available ({total} total − {approved} approved − {pending} submitted).',
+ 'requests.cancelStale': 'Cancel this request',
+ 'requests.adjustmentMode': 'Time adjustment mode',
+ 'requests.missingBooking': 'Add missing booking',
+ 'requests.correctBooking': 'Correct existing booking',
+ 'requests.targetBooking': 'Booking to correct',
+ 'requests.chooseTarget': 'Choose booking',
+ 'requests.targetsEmpty':
+ 'No closed Team bookings are available for correction.',
+ 'requests.targetsFailed': 'Bookings could not be loaded.',
+ 'requests.targetRequired': 'Choose a booking to correct.',
+ 'requests.correctionHint':
+ 'Corrects only this booking, retaining its ID. No duplicate is created; other bookings on that day remain unchanged.',
+ 'requests.targetReference': 'Booking {id} · revision {revision}',
+ 'requests.before': 'Before',
+ 'requests.proposed': 'Proposed',
+ 'requests.grossDuration':
+ 'Gross duration: {before} s → {after} s (Δ {delta} s)',
+ 'requests.grossProposed': 'Proposed gross duration: {duration} s',
+ 'requests.staleTargetHint':
+ 'If the booking has changed, cancel this request and create a new correction request using the current booking. This draft’s target and revision remain unchanged.',
+ 'requests.auditFailed': 'Booking history could not be loaded.',
+ 'requests.auditEmpty': 'No booking change from this request yet.',
+ 'requests.auditApplied': 'Booking {id} corrected by request {requestId}',
+ 'requests.auditMissingAdded':
+ 'Missing booking {id} added by request {requestId}',
+ 'requests.relatedRequest': 'Related request: {id}',
+ 'requests.retry': 'Retry',
+ 'requests.bookingHistory': 'Booking change history',
'requests.timeAdjustmentPolicy':
'Approvals follow your work schedule. Time outside its configured frame requires additional approval before HR confirmation.',
'requests.invalidTimeRange': '"To" must be after "From".',
@@ -1018,6 +1219,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/navigation.spec.ts b/apps/web/src/app/navigation.spec.ts
index e819bac..6d5b657 100644
--- a/apps/web/src/app/navigation.spec.ts
+++ b/apps/web/src/app/navigation.spec.ts
@@ -23,11 +23,16 @@ describe('role-aware navigation', () => {
),
).toBe(false);
});
- it('always exposes working-time reports to HR admins only', () => {
+ it('exposes working-time reports to Managers and HR admins, not employees', () => {
expect(
visibleNavItems('Manager').some(
(item) => item.to === '/admin/working-times',
),
+ ).toBe(true);
+ expect(
+ visibleNavItems('Employee').some(
+ (item) => item.to === '/admin/working-times',
+ ),
).toBe(false);
expect(
visibleNavItems('HRAdmin').some(
diff --git a/apps/web/src/app/navigation.ts b/apps/web/src/app/navigation.ts
index 5e1519a..d94ae6f 100644
--- a/apps/web/src/app/navigation.ts
+++ b/apps/web/src/app/navigation.ts
@@ -94,7 +94,7 @@ export const navItems: NavItem[] = [
to: '/admin/working-times',
labelKey: 'nav.workingTimes',
icon: FileBarChart,
- roles: ['HRAdmin'],
+ roles: ['Manager', 'HRAdmin'],
showInBottomNav: false,
},
{
diff --git a/apps/web/src/app/realtime.ts b/apps/web/src/app/realtime.ts
index 0ff985d..d477623 100644
--- a/apps/web/src/app/realtime.ts
+++ b/apps/web/src/app/realtime.ts
@@ -64,6 +64,7 @@ export function useRealtimeInvalidation() {
const onTransition = () => {
qc.invalidateQueries({ queryKey: ['requests'] });
+ qc.invalidateQueries({ queryKey: ['request-time-entry-audit'] });
qc.invalidateQueries({ queryKey: ['vacation-balance'] });
qc.invalidateQueries({ queryKey: ['account'] });
};
@@ -73,6 +74,9 @@ export function useRealtimeInvalidation() {
qc.invalidateQueries({ queryKey: ['solo-summary'] });
qc.invalidateQueries({ queryKey: ['solo-hints'] });
qc.invalidateQueries({ queryKey: ['time-entries'] });
+ qc.invalidateQueries({ queryKey: ['time-adjustment-targets'] });
+ qc.invalidateQueries({ queryKey: ['time-entry-approvals'] });
+ qc.invalidateQueries({ queryKey: ['time-entry-approval-history'] });
qc.invalidateQueries({ queryKey: ['account'] });
qc.invalidateQueries({ queryKey: ['violations'] });
};
diff --git a/apps/web/src/app/working-time-i18n.ts b/apps/web/src/app/working-time-i18n.ts
new file mode 100644
index 0000000..8b09a6d
--- /dev/null
+++ b/apps/web/src/app/working-time-i18n.ts
@@ -0,0 +1,171 @@
+export const workingTimeDe: Record = {
+ 'reports.accessDenied':
+ 'Diese Seite ist Führungskräften und HR-Admins vorbehalten.',
+ 'reports.scope.Manager':
+ 'Eigene Teammitglieder – der Server begrenzt die Auswahl und Auswertung auf dein zugewiesenes Team.',
+ 'reports.scope.HRAdmin':
+ 'Alle Mitarbeiter:innen – HR kann organisationsweit auswerten.',
+ 'reports.period': 'Zeitraum',
+ 'reports.week': 'Woche',
+ 'reports.month': 'Monat',
+ 'reports.custom': 'Benutzerdefiniert',
+ 'reports.employeesFailed':
+ 'Die Mitarbeiter-Auswahl konnte nicht geladen werden.',
+ 'comparison.title': 'Soll/Ist-Vergleich',
+ 'comparison.description':
+ 'Vertragliches Soll und erfasste Netto-Arbeitszeit für denselben gewählten Zeitraum – auch für Personen ohne Buchungen.',
+ 'comparison.target': 'Soll',
+ 'comparison.actual': 'Ist (vorläufig)',
+ 'comparison.difference': 'Differenz (Ist − Soll)',
+ 'comparison.indicator': 'Einordnung',
+ 'comparison.openEntries': 'Offene Buchungen (nicht enthalten)',
+ 'comparison.hours': '{hours} h',
+ 'comparison.period':
+ '{from} – {to} · Zeitzone: {timeZone} · Netto-Arbeitszeit',
+ 'comparison.provisional':
+ 'Vorläufiges Ist: Abgeschlossene Buchungen mit Status „Ausstehend“ oder „Genehmigt“ zählen nach Pausenabzug. Offene, abgelehnte und ungültige Buchungen zählen nicht.',
+ 'comparison.notAccount':
+ 'Die Differenz gilt nur für diesen Zeitraum. Sie ist kein fortgeschriebenes Überstundenkonto und enthält keinen Eröffnungssaldo.',
+ 'comparison.reviewHint':
+ 'Ab +{hours} h Differenz wird eine Prüfung des gewählten Zeitraums empfohlen. Dies ist weder eine gesetzliche Grenze noch eine rechtliche Bewertung.',
+ 'comparison.empty':
+ 'Keine Mitarbeiter:innen für die gewählte Auswahl vorhanden.',
+ 'comparison.historyGap':
+ 'Soll unbekannt: Bestätigte Vertragshistorie fehlt. HR muss die Vertragsdaten für diese Tage bestätigen.',
+ 'comparison.unknownDates': 'Tage ohne bestätigte Vertragsdaten: {dates}',
+ 'comparison.indicator.Unknown': 'Unbekannt',
+ 'comparison.indicator.Negative': 'Unter Soll',
+ 'comparison.indicator.Balanced': 'Ausgeglichen',
+ 'comparison.indicator.Positive': 'Über Soll',
+ 'comparison.indicator.Excess': 'Prüfung empfohlen (ab +10 h)',
+ 'contracts.title': 'Bestätigte Vertragszeiträume',
+ 'contracts.description':
+ 'Nur HR bestätigt historische Vertragsdaten. Ohne Bestätigung bleiben auch bekannte Altdaten für den Soll-Vergleich unbekannt.',
+ 'contracts.snapshotHint':
+ 'Jeder bestätigte Zeitraum speichert eigene Vertrags-, Arbeitstage- und Feiertagsdaten, keinen Live-Verweis auf einen Arbeitszeitplan. Spätere Profil- oder Planänderungen ändern die bestätigte Historie nicht. HR muss dafür einen neuen wirksamen Zeitraum hinzufügen.',
+ 'contracts.empty':
+ 'Noch keine bestätigten Vertragszeiträume. Das historische Soll ist unbekannt.',
+ 'contracts.loadFailed': 'Die Vertragshistorie konnte nicht geladen werden.',
+ 'contracts.refresh': 'Vertragshistorie aktualisieren',
+ 'contracts.history': 'Bestätigte Historie (nur lesbar)',
+ 'contracts.new': 'Neuen Vertragszeitraum bestätigen',
+ 'contracts.unconfirmed':
+ 'UNBESTÄTIGTER ENTWURF: Die vorausgefüllten aktuellen Stammdaten und Planwerte sind nur ein Vorschlag, kein Nachweis für historische Vertragsdaten. Bitte alle Angaben für den wirksamen Zeitraum prüfen.',
+ 'contracts.effectiveFrom': 'Vertrag gültig ab',
+ 'contracts.effectiveTo': 'Vertrag gültig bis (optional, einschließlich)',
+ 'contracts.employmentStart': 'Vertraglicher Beschäftigungsbeginn',
+ 'contracts.employmentEnd': 'Vertragliches Beschäftigungsende (optional)',
+ 'contracts.weeklyHours': 'Vertrags-Wochenstunden',
+ 'contracts.timeModel': 'Vertrags-Zeitmodell',
+ 'contracts.workingDays': 'Vertragliche Arbeitstage',
+ 'contracts.scheduleLabel': 'Planbezeichnung als Momentaufnahme (optional)',
+ 'contracts.holidayCalendar': 'Vertraglicher Feiertagskalender',
+ 'contracts.holidayDates': 'Vertragliche zusätzliche Feiertage',
+ 'contracts.confirmationNote': 'Bestätigungsnotiz (Pflicht)',
+ 'contracts.noteHint':
+ '5–1000 Zeichen: Quelle und Prüfung der Vertragsdaten dokumentieren.',
+ 'contracts.confirm':
+ 'Ich bestätige diese Vertragsdaten für den angegebenen Zeitraum ausdrücklich als HR.',
+ 'contracts.save': 'Vertragszeitraum bestätigen',
+ 'contracts.saved':
+ 'Vertragszeitraum bestätigt. Die Auswertung wird aktualisiert.',
+ 'contracts.saveFailed': 'Der Vertragszeitraum konnte nicht bestätigt werden.',
+ 'contracts.conflict':
+ 'Die Vertragshistorie wurde geändert oder der Zeitraum überschneidet sich. Bitte die Historie aktualisieren, den Zeitraum prüfen und gegebenenfalls die Ablösung erneut auswählen.',
+ 'contracts.supersedes': 'Bisherigen Zeitraum ablösen (optional)',
+ 'contracts.noSupersession':
+ 'Eigenständigen, nicht überlappenden Zeitraum hinzufügen',
+ 'contracts.supersessionHint':
+ 'Eine Ablösung beendet nur den ausgewählten bisherigen Zeitraum am Tag vor dem neuen Beginn (einschließlich). Der neue Beginn muss später als dessen Beginn und noch innerhalb des bisherigen Zeitraums liegen. Andere Überschneidungen sind nicht zulässig; bestätigte Fakten können nicht überschrieben oder gelöscht werden.',
+ 'contracts.invalid':
+ 'Bitte gültige Datumsgrenzen, Wochenstunden (0–168, höchstens 2 Nachkommastellen) und eine Bestätigungsnotiz angeben. Positive Wochenstunden benötigen Arbeitstage mit höchstens 24 Stunden je Tag. Bei einer Ablösung muss der neue Beginn nach dem bisherigen Beginn liegen.',
+ 'contracts.openEnded': 'unbefristet',
+ 'contracts.confirmedBy': 'Bestätigt von',
+ 'contracts.actorUnavailable': 'Person nicht mehr verfügbar',
+ 'contracts.revision': 'Revision',
+ 'contracts.createdAt': 'Erstellt am',
+};
+
+export const workingTimeEn: Record = {
+ 'reports.accessDenied': 'This page is reserved for managers and HR admins.',
+ 'reports.scope.Manager':
+ 'Your team members – the server restricts the selection and report to your assigned team.',
+ 'reports.scope.HRAdmin':
+ 'All employees – HR can report across the organisation.',
+ 'reports.period': 'Period',
+ 'reports.week': 'Week',
+ 'reports.month': 'Month',
+ 'reports.custom': 'Custom',
+ 'reports.employeesFailed': 'The employee selection could not be loaded.',
+ 'comparison.title': 'Target/actual comparison',
+ 'comparison.description':
+ 'Contractual target and recorded net working time for the same selected period, including employees without entries.',
+ 'comparison.target': 'Target',
+ 'comparison.actual': 'Actual (provisional)',
+ 'comparison.difference': 'Difference (actual − target)',
+ 'comparison.indicator': 'Indicator',
+ 'comparison.openEntries': 'Open entries (excluded)',
+ 'comparison.hours': '{hours} h',
+ 'comparison.period':
+ '{from} – {to} · Time zone: {timeZone} · Net working time',
+ 'comparison.provisional':
+ 'Provisional actual: Closed entries with status “Pending” or “Approved” count after break deduction. Open, rejected and voided entries are excluded.',
+ 'comparison.notAccount':
+ 'The difference applies only to this period. It is not a carried-forward overtime account and includes no opening balance.',
+ 'comparison.reviewHint':
+ 'A difference of +{hours} h or more flags the selected period for review. This is neither a statutory limit nor a legal assessment.',
+ 'comparison.empty': 'No employees for the selected filters.',
+ 'comparison.historyGap':
+ 'Target unknown: Confirmed contract history is missing. HR must confirm the contract facts for these dates.',
+ 'comparison.unknownDates': 'Dates without confirmed contract facts: {dates}',
+ 'comparison.indicator.Unknown': 'Unknown',
+ 'comparison.indicator.Negative': 'Below target',
+ 'comparison.indicator.Balanced': 'Balanced',
+ 'comparison.indicator.Positive': 'Above target',
+ 'comparison.indicator.Excess': 'Review recommended (from +10 h)',
+ 'contracts.title': 'Confirmed contract periods',
+ 'contracts.description':
+ 'Only HR confirms historical contract facts. Even known legacy data remains unknown for target comparisons until confirmed.',
+ 'contracts.snapshotHint':
+ 'Each confirmed period stores its own contract, working-day and holiday facts, not a live schedule reference. Later profile or schedule edits do not change confirmed history. HR must add a new effective period for changes.',
+ 'contracts.empty':
+ 'No confirmed contract periods yet. Historical targets are unknown.',
+ 'contracts.loadFailed': 'Contract history could not be loaded.',
+ 'contracts.refresh': 'Refresh contract history',
+ 'contracts.history': 'Confirmed history (read-only)',
+ 'contracts.new': 'Confirm a new contract period',
+ 'contracts.unconfirmed':
+ 'UNCONFIRMED DRAFT: Prefilled current profile and schedule values are suggestions only, not evidence of historical contract facts. Check every fact for the effective period.',
+ 'contracts.effectiveFrom': 'Contract effective from',
+ 'contracts.effectiveTo': 'Contract effective to (optional, inclusive)',
+ 'contracts.employmentStart': 'Contractual employment start',
+ 'contracts.employmentEnd': 'Contractual employment end (optional)',
+ 'contracts.weeklyHours': 'Contract weekly hours',
+ 'contracts.timeModel': 'Contract time model',
+ 'contracts.workingDays': 'Contract working days',
+ 'contracts.scheduleLabel': 'Schedule label snapshot (optional)',
+ 'contracts.holidayCalendar': 'Contract holiday calendar',
+ 'contracts.holidayDates': 'Contract additional holidays',
+ 'contracts.confirmationNote': 'Confirmation note (required)',
+ 'contracts.noteHint':
+ '5–1000 characters: Document the source and verification of the contract facts.',
+ 'contracts.confirm':
+ 'As HR, I explicitly confirm these contract facts for the stated period.',
+ 'contracts.save': 'Confirm contract period',
+ 'contracts.saved':
+ 'Contract period confirmed. The report is being refreshed.',
+ 'contracts.saveFailed': 'The contract period could not be confirmed.',
+ 'contracts.conflict':
+ 'Contract history changed or the period overlaps. Refresh history, check the dates and reselect the period to supersede if needed.',
+ 'contracts.supersedes': 'Supersede an existing period (optional)',
+ 'contracts.noSupersession': 'Add an independent, non-overlapping period',
+ 'contracts.supersessionHint':
+ 'Superseding only ends the selected old period on the day before the new start (inclusive). The new start must be later than its start and still within the existing period. Other overlaps are not allowed; confirmed facts cannot be rewritten or deleted.',
+ 'contracts.invalid':
+ 'Enter valid date boundaries, weekly hours (0–168, at most 2 decimal places) and a confirmation note. Positive weekly hours require working days with at most 24 hours per day. When superseding, the new start must be later than the old start.',
+ 'contracts.openEnded': 'open-ended',
+ 'contracts.confirmedBy': 'Confirmed by',
+ 'contracts.actorUnavailable': 'Person no longer available',
+ 'contracts.revision': 'Revision',
+ 'contracts.createdAt': 'Created at',
+};
diff --git a/apps/web/src/app/working-time-routing.spec.tsx b/apps/web/src/app/working-time-routing.spec.tsx
new file mode 100644
index 0000000..685e390
--- /dev/null
+++ b/apps/web/src/app/working-time-routing.spec.tsx
@@ -0,0 +1,46 @@
+import { screen } from '@testing-library/react';
+import { Outlet } from 'react-router-dom';
+import { beforeEach, expect, it, vi } from 'vitest';
+import { renderWithProviders } from '../test-utils';
+import App from './app';
+
+const state = vi.hoisted(() => ({ role: 'Manager' }));
+vi.mock('./auth', () => ({
+ useAuth: () => ({
+ user: { id: 'test-user', role: state.role },
+ logout: vi.fn(),
+ }),
+}));
+vi.mock('./installation', () => ({
+ useInstallation: () => ({
+ data: { mode: 'Team', setupCompleted: true },
+ isLoading: false,
+ refetch: vi.fn(),
+ }),
+}));
+vi.mock('./AppShell', () => ({ AppShell: () => }));
+vi.mock('../routes/AdminWorkingTimesPage', () => ({
+ AdminWorkingTimesPage: () => Working-time comparison
,
+}));
+
+beforeEach(() => {
+ state.role = 'Manager';
+});
+it.each(['Manager', 'HRAdmin'])(
+ 'allows the direct working-time route for %s',
+ async (role) => {
+ state.role = role;
+ renderWithProviders(, { initialPath: '/admin/working-times' });
+ expect(
+ await screen.findByRole('heading', { name: 'Working-time comparison' }),
+ ).toBeTruthy();
+ },
+);
+it('does not expose the route to ordinary employees', async () => {
+ state.role = 'Employee';
+ renderWithProviders(, { initialPath: '/admin/working-times' });
+ expect(await screen.findByText('Diese Seite existiert nicht.')).toBeTruthy();
+ expect(
+ screen.queryByRole('heading', { name: 'Working-time comparison' }),
+ ).toBeNull();
+});
diff --git a/apps/web/src/components/timeentries/TimeEntryApprovals.spec.tsx b/apps/web/src/components/timeentries/TimeEntryApprovals.spec.tsx
new file mode 100644
index 0000000..0f2c271
--- /dev/null
+++ b/apps/web/src/components/timeentries/TimeEntryApprovals.spec.tsx
@@ -0,0 +1,624 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+import { renderWithProviders } from '../../test-utils';
+import type { TimeEntryApprovalDto, TimeEntryDto } from '../../api/client';
+import { TimeEntryApprovals } from './TimeEntryApprovals';
+
+const mocks = vi.hoisted(() => ({
+ user: { id: 'manager-1', role: 'Manager' },
+ list: vi.fn(),
+ decide: vi.fn(),
+ history: vi.fn(),
+}));
+vi.mock('../../api/client', () => ({
+ api: {
+ timeEntryApprovals: mocks.list,
+ decideTimeEntryApproval: mocks.decide,
+ timeEntryApprovalHistory: mocks.history,
+ },
+}));
+vi.mock('../../app/auth', () => ({ useCurrentUser: () => mocks.user }));
+
+const entry: TimeEntryDto = {
+ id: 'entry-1',
+ employeeId: 'employee-1',
+ clockIn: '2026-05-04T05:00:00Z',
+ clockOut: '2026-05-04T17:00:00Z',
+ source: 'web',
+ status: 'Pending',
+ requiresApproval: true,
+ latitude: null,
+ longitude: null,
+ accuracyMeters: null,
+ terminalDistanceMeters: null,
+ terminalRadiusMeters: null,
+ terminalMaxAccuracyMeters: null,
+ positionTimestamp: null,
+ clockOutLatitude: null,
+ clockOutLongitude: null,
+ clockOutAccuracyMeters: null,
+ clockOutTerminalDistanceMeters: null,
+ clockOutTerminalRadiusMeters: null,
+ clockOutTerminalMaxAccuracyMeters: null,
+ clockOutPositionTimestamp: null,
+ terminalId: null,
+ clockOutTerminalId: null,
+ clockInChallengeId: null,
+ clockOutChallengeId: null,
+ projectId: null,
+ projectCode: null,
+ projectName: null,
+ serviceOrderId: null,
+ serviceOrderNo: null,
+ serviceOrderTitle: null,
+ activity: null,
+ summary: { grossMinutes: 720, breakMinutes: 45, netMinutes: 675 },
+};
+function approval(
+ overrides: Partial = {},
+): TimeEntryApprovalDto {
+ return {
+ entry,
+ revision: 7,
+ employeeName: 'Anna Example',
+ managerId: 'manager-1',
+ managerName: 'Marc Manager',
+ state: 'PendingManager',
+ canDecide: true,
+ ...overrides,
+ };
+}
+function renderPanel(scope: 'own' | 'review' = 'review') {
+ return renderWithProviders();
+}
+
+let storedLocale: string | null;
+beforeEach(() => {
+ storedLocale = 'de';
+ vi.spyOn(window, 'localStorage', 'get').mockReturnValue({
+ length: 1,
+ clear: () => {
+ storedLocale = null;
+ },
+ key: () => 'openclockwork.locale',
+ removeItem: () => {
+ storedLocale = null;
+ },
+ getItem: () => storedLocale,
+ setItem: (_key: string, value: string) => {
+ storedLocale = value;
+ },
+ });
+ mocks.user.id = 'manager-1';
+ mocks.user.role = 'Manager';
+ mocks.list.mockReset().mockResolvedValue([approval()]);
+ mocks.decide
+ .mockReset()
+ .mockResolvedValue(approval({ state: 'PendingHr', canDecide: false }));
+ mocks.history.mockReset().mockResolvedValue([]);
+});
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('out-of-frame time entry approvals', () => {
+ it('shows the manager the correct stage, sends the revision and refetches lists, history and totals', async () => {
+ const { queryClient } = renderPanel();
+ const invalidate = vi.spyOn(queryClient, 'invalidateQueries');
+ expect(await screen.findByText('Wartet auf Führungskraft')).toBeDefined();
+ expect(
+ screen.getByText(/Nächster Schritt: Marc Manager/).textContent,
+ ).toContain('Genehmigungen → Zeitbuchungen');
+ expect(mocks.list).toHaveBeenCalledWith('review');
+ expect(
+ screen.queryByRole('button', { name: 'Als HR bestätigen' }),
+ ).toBeNull();
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Genehmigungsverlauf' }),
+ );
+ await screen.findByText(
+ 'Noch keine Entscheidungen oder Änderungen vorhanden.',
+ );
+ mocks.list.mockResolvedValue([
+ approval({ state: 'PendingHr', canDecide: false }),
+ ]);
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Als Führungskraft genehmigen' }),
+ );
+ await waitFor(() =>
+ expect(mocks.decide).toHaveBeenCalledWith('entry-1', {
+ revision: 7,
+ stage: 'Manager',
+ outcome: 'Approve',
+ }),
+ );
+ expect(await screen.findByText('Wartet auf HR')).toBeDefined();
+ expect(screen.getByRole('status').textContent).toContain(
+ 'Als Nächstes bestätigt HR',
+ );
+ expect(
+ screen.queryByRole('button', { name: 'Als Führungskraft genehmigen' }),
+ ).toBeNull();
+ expect(mocks.list.mock.calls.length).toBeGreaterThan(1);
+ expect(mocks.history.mock.calls.length).toBeGreaterThan(1);
+ for (const key of [
+ 'time-entry-approvals',
+ 'time-entry-approval-history',
+ 'time-entries',
+ 'account',
+ 'violations',
+ 'working-time-report',
+ 'project-report',
+ ]) {
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: [key] });
+ }
+ });
+
+ it.each([
+ {
+ role: 'Manager',
+ id: 'manager-1',
+ state: 'PendingManager' as const,
+ stage: 'Manager',
+ label: 'Als Führungskraft ablehnen',
+ },
+ {
+ role: 'HRAdmin',
+ id: 'hr-1',
+ state: 'PendingHr' as const,
+ stage: 'HR',
+ label: 'Als HR ablehnen',
+ },
+ ])(
+ 'requires a nonblank reason when $role rejects',
+ async ({ role, id, state, stage, label }) => {
+ mocks.user.role = role;
+ mocks.user.id = id;
+ mocks.list.mockResolvedValue([approval({ state })]);
+ renderPanel();
+ const reject = await screen.findByRole('button', { name: label });
+ expect(reject.hasAttribute('disabled')).toBe(true);
+ const note = screen.getByLabelText('Notiz (bei Ablehnung erforderlich)');
+ fireEvent.change(note, { target: { value: ' ' } });
+ expect(reject.hasAttribute('disabled')).toBe(true);
+ fireEvent.click(reject);
+ expect(mocks.decide).not.toHaveBeenCalled();
+ fireEvent.change(note, {
+ target: { value: ' Bitte Zeitraum prüfen ' },
+ });
+ mocks.list.mockResolvedValue([
+ approval({ state: 'Rejected', canDecide: false }),
+ ]);
+ fireEvent.click(reject);
+ await waitFor(() =>
+ expect(mocks.decide).toHaveBeenCalledWith('entry-1', {
+ revision: 7,
+ stage,
+ outcome: 'Reject',
+ note: 'Bitte Zeitraum prüfen',
+ }),
+ );
+ expect(
+ await screen.findByText('Ablehnung mit Begründung gespeichert.'),
+ ).toBeDefined();
+ },
+ );
+
+ it('lets HR confirm only the HR stage with the current revision', async () => {
+ mocks.user.role = 'HRAdmin';
+ mocks.user.id = 'hr-1';
+ mocks.list.mockResolvedValue([
+ approval({ state: 'PendingHr', revision: 12 }),
+ ]);
+ renderPanel();
+ const confirm = await screen.findByRole('button', {
+ name: 'Als HR bestätigen',
+ });
+ expect(
+ screen.queryByRole('button', { name: 'Als Führungskraft genehmigen' }),
+ ).toBeNull();
+ fireEvent.change(
+ screen.getByLabelText('Notiz (bei Ablehnung erforderlich)'),
+ { target: { value: 'Geprüft' } },
+ );
+ mocks.list.mockResolvedValue([
+ approval({ state: 'Approved', canDecide: false }),
+ ]);
+ fireEvent.click(confirm);
+ await waitFor(() =>
+ expect(mocks.decide).toHaveBeenCalledWith('entry-1', {
+ revision: 12,
+ stage: 'HR',
+ outcome: 'Approve',
+ note: 'Geprüft',
+ }),
+ );
+ expect(await screen.findByText('Genehmigt')).toBeDefined();
+ });
+
+ it('does not let HR bypass the assigned manager', async () => {
+ mocks.user.role = 'HRAdmin';
+ mocks.user.id = 'hr-1';
+ renderPanel();
+ await screen.findByText('Wartet auf Führungskraft');
+ expect(
+ screen.queryByLabelText('Notiz (bei Ablehnung erforderlich)'),
+ ).toBeNull();
+ expect(
+ screen.queryByRole('button', { name: 'Als HR bestätigen' }),
+ ).toBeNull();
+ });
+
+ it.each([
+ 'Open',
+ 'BlockedManager',
+ 'Approved',
+ 'Rejected',
+ 'Voided',
+ 'PendingHr',
+ ] as const)(
+ 'shows %s without manager actions even with an inconsistent canDecide flag',
+ async (state) => {
+ mocks.list.mockResolvedValue([approval({ state })]);
+ renderPanel();
+ await screen.findByText(/Anna Example/);
+ expect(
+ screen.queryByLabelText('Notiz (bei Ablehnung erforderlich)'),
+ ).toBeNull();
+ expect(
+ screen.getByRole('button', { name: 'Genehmigungsverlauf' }),
+ ).toBeDefined();
+ },
+ );
+
+ it('respects server permissions and manager assignment', async () => {
+ mocks.list.mockResolvedValue([
+ approval({ canDecide: false }),
+ approval({
+ entry: { ...entry, id: 'entry-2' },
+ managerId: 'other-manager',
+ }),
+ ]);
+ renderPanel();
+ await screen.findAllByText('Wartet auf Führungskraft');
+ expect(
+ screen.queryByLabelText('Notiz (bei Ablehnung erforderlich)'),
+ ).toBeNull();
+ });
+
+ it.each(['Employee', 'Manager', 'HRAdmin'])(
+ 'shows own status/history but no decisions for an owner with role %s',
+ async (role) => {
+ mocks.user.role = role;
+ mocks.user.id = entry.employeeId;
+ mocks.list.mockResolvedValue([approval({ managerId: entry.employeeId })]);
+ renderPanel('own');
+ await screen.findByText('Wartet auf Führungskraft');
+ expect(
+ screen.getByText('Rahmenzeit-Genehmigungen für meine Zeitbuchungen'),
+ ).toBeDefined();
+ expect(mocks.list).toHaveBeenCalledWith('own');
+ expect(
+ screen.queryByLabelText('Notiz (bei Ablehnung erforderlich)'),
+ ).toBeNull();
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Genehmigungsverlauf' }),
+ );
+ expect(
+ await screen.findByText(
+ 'Noch keine Entscheidungen oder Änderungen vorhanden.',
+ ),
+ ).toBeDefined();
+ },
+ );
+
+ it('never offers manager-stage actions to HR even with an inconsistent server flag and assignment', async () => {
+ mocks.user.role = 'HRAdmin';
+ mocks.list.mockResolvedValue([
+ approval({ managerId: mocks.user.id, canDecide: true }),
+ ]);
+ renderPanel();
+ await screen.findByText('Wartet auf Führungskraft');
+ expect(
+ screen.queryByLabelText('Notiz (bei Ablehnung erforderlich)'),
+ ).toBeNull();
+ });
+
+ it('prevents self-approval even in a broad reviewer role', async () => {
+ mocks.user.role = 'HRAdmin';
+ mocks.user.id = entry.employeeId;
+ mocks.list.mockResolvedValue([approval({ state: 'PendingHr' })]);
+ renderPanel();
+ await screen.findByText('Wartet auf HR');
+ expect(
+ screen.queryByLabelText('Notiz (bei Ablehnung erforderlich)'),
+ ).toBeNull();
+ });
+
+ it('does not query the reviewer list for an employee', async () => {
+ mocks.user.role = 'Employee';
+ renderPanel();
+ expect(mocks.list).not.toHaveBeenCalled();
+ expect(screen.queryByText('Zeitbuchungen')).toBeNull();
+ });
+
+ it('keeps stale errors and the entered reason visible, then uses the refreshed revision', async () => {
+ mocks.decide.mockRejectedValueOnce(
+ new Error('Stale revision: entry changed'),
+ );
+ renderPanel();
+ const reject = await screen.findByRole('button', {
+ name: 'Als Führungskraft ablehnen',
+ });
+ const note = screen.getByLabelText(
+ 'Notiz (bei Ablehnung erforderlich)',
+ ) as HTMLInputElement;
+ fireEvent.change(note, { target: { value: 'Zeitraum falsch' } });
+ fireEvent.click(reject);
+ expect(await screen.findByRole('alert')).toBeDefined();
+ expect(screen.getByRole('alert').textContent).toContain(
+ 'Stale revision: entry changed',
+ );
+ expect(note.value).toBe('Zeitraum falsch');
+ mocks.list.mockResolvedValue([approval({ revision: 9 })]);
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Genehmigungen aktualisieren' }),
+ );
+ await waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(2));
+ await waitFor(() =>
+ expect(
+ screen
+ .getByRole('button', { name: 'Genehmigungen aktualisieren' })
+ .hasAttribute('disabled'),
+ ).toBe(false),
+ );
+ expect(screen.getByRole('alert').textContent).toContain(
+ 'Stale revision: entry changed',
+ );
+ mocks.decide.mockResolvedValueOnce(
+ approval({ state: 'Rejected', canDecide: false }),
+ );
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Als Führungskraft ablehnen' }),
+ );
+ await waitFor(() =>
+ expect(mocks.decide).toHaveBeenLastCalledWith('entry-1', {
+ revision: 9,
+ stage: 'Manager',
+ outcome: 'Reject',
+ note: 'Zeitraum falsch',
+ }),
+ );
+ });
+
+ it('renders every decision and invalidation with actor, timestamp and reason', async () => {
+ mocks.history.mockResolvedValue([
+ {
+ id: '1',
+ actorId: 'manager-1',
+ actorName: 'Marc Manager',
+ action: 'FrameManagerApproved',
+ reason: 'Kundentermin',
+ occurredAt: '2026-05-04T17:30:00Z',
+ },
+ {
+ id: '2',
+ actorId: 'hr-1',
+ actorName: 'Hannah HR',
+ action: 'FrameHrConfirmed',
+ reason: null,
+ occurredAt: '2026-05-04T18:00:00Z',
+ },
+ {
+ id: '3',
+ actorId: null,
+ actorName: null,
+ action: 'FrameApprovalInvalidated',
+ reason: 'Führungskraft geändert',
+ occurredAt: '2026-05-05T08:00:00Z',
+ },
+ {
+ id: '4',
+ actorId: 'manager-2',
+ actorName: 'New Manager',
+ action: 'FrameManagerRejected',
+ reason: 'Unklarer Zeitraum',
+ occurredAt: '2026-05-05T09:00:00Z',
+ },
+ {
+ id: '5',
+ actorId: 'hr-1',
+ actorName: 'Hannah HR',
+ action: 'FrameHrRejected',
+ reason: 'Fehlender Nachweis',
+ occurredAt: '2026-05-05T10:00:00Z',
+ },
+ {
+ id: '6',
+ actorId: 'owner-1',
+ actorName: 'Anna Example',
+ action: 'FrameSegmentCreated',
+ reason: null,
+ occurredAt: '2026-05-05T11:00:00Z',
+ },
+ ]);
+ const { container } = renderPanel('own');
+ fireEvent.click(
+ await screen.findByRole('button', { name: 'Genehmigungsverlauf' }),
+ );
+ expect(await screen.findByText('Kundentermin')).toBeDefined();
+ for (const label of [
+ 'Von Führungskraft genehmigt',
+ 'Von HR bestätigt',
+ 'Bisherige Genehmigung durch Änderung aufgehoben',
+ 'Von Führungskraft abgelehnt',
+ 'Von HR abgelehnt',
+ 'Buchungssegment nach Änderung neu angelegt',
+ 'Führungskraft geändert',
+ 'Unklarer Zeitraum',
+ 'Fehlender Nachweis',
+ ]) {
+ expect(screen.getByText(label)).toBeDefined();
+ }
+ expect(screen.getByText(/Marc Manager ·/)).toBeDefined();
+ expect(screen.getByText(/System ·/)).toBeDefined();
+ expect(container.querySelector('time')?.getAttribute('datetime')).toBe(
+ '2026-05-04T17:30:00Z',
+ );
+ });
+
+ it('shows loading, query errors and recoverable empty states', async () => {
+ let rejectList!: (reason: Error) => void;
+ mocks.list.mockImplementationOnce(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectList = reject;
+ }),
+ );
+ renderPanel();
+ expect(screen.getByRole('status').textContent).toBe('Lädt …');
+ rejectList(new Error('Service unavailable'));
+ expect((await screen.findByRole('alert')).textContent).toContain(
+ 'Service unavailable',
+ );
+ mocks.list.mockResolvedValue([]);
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Genehmigungen aktualisieren' }),
+ );
+ expect(
+ await screen.findByText(
+ 'Keine genehmigungspflichtigen Zeitbuchungen vorhanden.',
+ ),
+ ).toBeDefined();
+ });
+
+ it('surfaces history errors and allows retry', async () => {
+ mocks.history.mockRejectedValueOnce(new Error('History unavailable'));
+ renderPanel('own');
+ fireEvent.click(
+ await screen.findByRole('button', { name: 'Genehmigungsverlauf' }),
+ );
+ expect((await screen.findByRole('alert')).textContent).toContain(
+ 'History unavailable',
+ );
+ mocks.history.mockResolvedValue([]);
+ fireEvent.click(
+ screen.getAllByRole('button', { name: 'Genehmigungen aktualisieren' })[1],
+ );
+ expect(
+ await screen.findByText(
+ 'Noch keine Entscheidungen oder Änderungen vorhanden.',
+ ),
+ ).toBeDefined();
+ });
+
+ it.each([
+ {
+ locale: 'de',
+ title: 'Zeitbuchungen',
+ state: 'Blockiert – Führungskraft fehlt',
+ manager: 'aktive Führungskraft zuzuweisen',
+ where: 'Genehmigungen → Zeitbuchungen',
+ },
+ {
+ locale: 'en',
+ title: 'Time entries',
+ state: 'Blocked – no active manager',
+ manager: 'assign an active manager',
+ where: 'Approvals → Time entries',
+ },
+ ])(
+ 'explains the chain and missing-manager recovery in $locale',
+ async ({ locale, title, state, manager, where }) => {
+ storedLocale = locale;
+ mocks.list.mockResolvedValue([
+ approval({
+ state: 'BlockedManager',
+ managerId: null,
+ managerName: null,
+ canDecide: false,
+ }),
+ ]);
+ renderPanel();
+ expect(await screen.findByText(state)).toBeDefined();
+ expect(screen.getByText(title)).toBeDefined();
+ const help = screen.getByText(new RegExp(manager));
+ expect(help.textContent).toContain(where);
+ expect(screen.queryByLabelText(/Note|Notiz/)).toBeNull();
+ },
+ );
+
+ it('provides English actions, error guidance and history labels', async () => {
+ storedLocale = 'en';
+ mocks.user.role = 'HRAdmin';
+ mocks.user.id = 'hr-1';
+ mocks.list.mockResolvedValue([approval({ state: 'PendingHr' })]);
+ mocks.decide.mockRejectedValueOnce(new Error('Revision conflict'));
+ mocks.history.mockResolvedValue([
+ {
+ id: '1',
+ actorId: 'manager-1',
+ actorName: 'Marc Manager',
+ action: 'FrameManagerApproved',
+ reason: 'Client visit',
+ occurredAt: '2026-05-04T17:30:00Z',
+ },
+ ]);
+ renderPanel();
+ fireEvent.click(
+ await screen.findByRole('button', { name: 'Confirm as HR' }),
+ );
+ expect((await screen.findByRole('alert')).textContent).toContain(
+ 'The decision was not saved.',
+ );
+ expect(screen.getByRole('alert').textContent).toContain(
+ 'Refresh and check the current status',
+ );
+ expect(
+ screen.getByLabelText('Note (required for rejection)'),
+ ).toBeDefined();
+ expect(
+ screen
+ .getByRole('button', { name: 'Reject as HR' })
+ .hasAttribute('disabled'),
+ ).toBe(true);
+ fireEvent.click(screen.getByRole('button', { name: 'Approval history' }));
+ expect(await screen.findByText('Approved by manager')).toBeDefined();
+ expect(screen.getByText('Client visit')).toBeDefined();
+ });
+ it.each([
+ [
+ 'de',
+ 'Durch Korrekturantrag geändert',
+ 'Zugehöriger Antrag: correction-request',
+ ],
+ [
+ 'en',
+ 'Changed by correction request',
+ 'Related request: correction-request',
+ ],
+ ])(
+ 'labels linked request corrections without presenting them as an old approval (%s)',
+ async (locale, label, reference) => {
+ storedLocale = locale;
+ mocks.history.mockResolvedValue([
+ {
+ id: 'correction-event',
+ actorId: 'hr',
+ actorName: 'HR Example',
+ action: 'FrameRequestCorrected',
+ reason: 'Changed booking',
+ occurredAt: '2026-05-05T12:00:00Z',
+ requestId: 'correction-request',
+ },
+ ]);
+ renderPanel();
+ fireEvent.click(
+ await screen.findByRole('button', {
+ name: locale === 'de' ? 'Genehmigungsverlauf' : 'Approval history',
+ }),
+ );
+ expect(await screen.findByText(label)).toBeDefined();
+ expect(screen.getByText(reference)).toBeDefined();
+ },
+ );
+});
diff --git a/apps/web/src/components/timeentries/TimeEntryApprovals.tsx b/apps/web/src/components/timeentries/TimeEntryApprovals.tsx
new file mode 100644
index 0000000..229c1a5
--- /dev/null
+++ b/apps/web/src/components/timeentries/TimeEntryApprovals.tsx
@@ -0,0 +1,322 @@
+import { useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ api,
+ type TimeEntryApprovalDecision,
+ type TimeEntryApprovalDto,
+} from '../../api/client';
+import { useCurrentUser } from '../../app/auth';
+import { useI18n } from '../../app/i18n';
+
+/** All lists and downstream totals must reflect decisions and edited entries. */
+export async function refreshTimeEntryApprovals(
+ qc: ReturnType,
+) {
+ await Promise.all(
+ [
+ 'time-entry-approvals',
+ 'time-entry-approval-history',
+ 'time-entries',
+ 'account',
+ 'violations',
+ 'working-time-report',
+ 'project-report',
+ ].map((key) => qc.invalidateQueries({ queryKey: [key] })),
+ );
+}
+
+export function TimeEntryApprovals({ scope }: { scope: 'own' | 'review' }) {
+ const user = useCurrentUser();
+ const { t } = useI18n();
+ const allowed =
+ scope === 'own' || user.role === 'Manager' || user.role === 'HRAdmin';
+ const query = useQuery({
+ queryKey: ['time-entry-approvals', scope, user.id, user.role],
+ queryFn: () => api.timeEntryApprovals(scope),
+ enabled: allowed,
+ });
+ if (!allowed) return null;
+
+ return (
+
+
+
+ {t(
+ scope === 'own' ? 'frameApproval.ownTitle' : 'frameApproval.title',
+ )}
+
+
+ {t('frameApproval.chain')}
+
+
+ {t('frameApproval.managerChanged')}
+
+
+
+ {query.isPending && {t('common.loading')}
}
+ {query.isError && (
+
+
+ {t('frameApproval.loadFailed')}{' '}
+ {query.error instanceof Error ? query.error.message : ''}
+
+
+ )}
+
+ {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.requestId && (
+ {t('requests.relatedRequest', { id: event.requestId })}
+ )}
+ {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/AdminEmployeesPage.spec.tsx b/apps/web/src/routes/AdminEmployeesPage.spec.tsx
index 67e1dbe..30fdffb 100644
--- a/apps/web/src/routes/AdminEmployeesPage.spec.tsx
+++ b/apps/web/src/routes/AdminEmployeesPage.spec.tsx
@@ -6,11 +6,15 @@ import { AdminEmployeesPage } from './AdminEmployeesPage';
const createEmployee = vi.fn();
const updateEmployee = vi.fn();
const employees = vi.fn();
+const contractPeriods = vi.fn();
+const createContractPeriod = vi.fn();
vi.mock('../api/client', async (importOriginal) => ({
...(await importOriginal()),
api: {
employees: (...args: unknown[]) => employees(...args),
workSchedules: async () => [],
+ contractPeriods: (...args: unknown[]) => contractPeriods(...args),
+ createContractPeriod: (...args: unknown[]) => createContractPeriod(...args),
createEmployee: (...args: unknown[]) => createEmployee(...args),
updateEmployee: (...args: unknown[]) => updateEmployee(...args),
},
@@ -38,7 +42,9 @@ const employee = {
};
beforeEach(() => {
- employees.mockResolvedValue([]);
+ employees.mockReset().mockResolvedValue([]);
+ contractPeriods.mockReset().mockResolvedValue([]);
+ createContractPeriod.mockReset();
createEmployee.mockReset().mockResolvedValue(employee);
updateEmployee.mockReset().mockResolvedValue(employee);
});
@@ -46,6 +52,10 @@ beforeEach(() => {
it('creates an employee without a Germany-specific calendar and saves custom dates', async () => {
renderWithProviders();
fireEvent.click(screen.getByRole('button', { name: 'Neu anlegen' }));
+ expect(
+ screen.queryByRole('region', { name: 'Bestätigte Vertragszeiträume' }),
+ ).toBeNull();
+ expect(contractPeriods).not.toHaveBeenCalled();
expect(
(screen.getByLabelText('Feiertagskalender') as HTMLSelectElement).value,
).toBe('NONE');
@@ -74,12 +84,19 @@ it('creates an employee without a Germany-specific calendar and saves custom dat
}),
),
);
+ expect(createContractPeriod).not.toHaveBeenCalled();
});
it('updates both the preset calendar and custom dates instead of dropping calendar changes', async () => {
employees.mockResolvedValue([employee]);
renderWithProviders();
fireEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' }));
+ expect(
+ screen.getByRole('region', { name: 'Bestätigte Vertragszeiträume' }),
+ ).toBeTruthy();
+ await waitFor(() =>
+ expect(contractPeriods).toHaveBeenCalledWith(employee.id),
+ );
fireEvent.change(screen.getByLabelText('Feiertagskalender'), {
target: { value: 'DE-BY' },
});
@@ -102,4 +119,5 @@ it('updates both the preset calendar and custom dates instead of dropping calend
}),
),
);
+ expect(createContractPeriod).not.toHaveBeenCalled();
});
diff --git a/apps/web/src/routes/AdminEmployeesPage.tsx b/apps/web/src/routes/AdminEmployeesPage.tsx
index b5d29d2..7948f20 100644
--- a/apps/web/src/routes/AdminEmployeesPage.tsx
+++ b/apps/web/src/routes/AdminEmployeesPage.tsx
@@ -30,6 +30,7 @@ import {
import { useCurrentUser } from '../app/auth';
import { useI18n } from '../app/i18n';
import { TerminalPinDialog } from './TerminalPinDialog';
+import { ContractPeriodsPanel } from './ContractPeriodsPanel';
const ROLES: EmployeeRole[] = ['Employee', 'Manager', 'HRAdmin'];
const TIME_MODELS: TimeModel[] = [
@@ -685,6 +686,9 @@ function EmployeeEditor({
)}
+ {!isCreate && seed && (
+
+ )}
{error && (
{error}
diff --git a/apps/web/src/routes/AdminRequestsPage.spec.tsx b/apps/web/src/routes/AdminRequestsPage.spec.tsx
new file mode 100644
index 0000000..10fe097
--- /dev/null
+++ b/apps/web/src/routes/AdminRequestsPage.spec.tsx
@@ -0,0 +1,120 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, screen } from '@testing-library/react';
+import { renderWithProviders } from '../test-utils';
+import { AdminRequestsPage } from './AdminRequestsPage';
+import { api } from '../api/client';
+
+const request = {
+ id: 'request-42',
+ employeeId: 'owner',
+ employeeName: 'Anna Example',
+ type: 'Vacation',
+ workflowState: 'PendingManager',
+ status: 'Submitted',
+ currentApproverId: 'manager',
+ from: '2026-05-04T00:00:00.000Z',
+ to: '2026-05-08T00:00:00.000Z',
+ calculatedDays: 5,
+};
+vi.mock('../api/client', () => ({
+ api: {
+ timeEntryApprovals: vi.fn().mockResolvedValue([]),
+ listRequests: vi
+ .fn()
+ .mockImplementation(async (filter) =>
+ filter.currentApproverId ? [request] : [],
+ ),
+ requestTimeEntryAudit: vi.fn().mockResolvedValue([]),
+ getRequest: vi.fn().mockImplementation(async () => request),
+ getRequestEvents: vi
+ .fn()
+ .mockResolvedValue([
+ { id: 'event', kind: 'Resubmitted', at: '2026-05-01T12:00:00.000Z' },
+ ]),
+ },
+}));
+vi.mock('../app/auth', () => ({
+ useCurrentUser: () => ({
+ id: 'manager',
+ firstName: 'Marc',
+ lastName: 'Example',
+ role: 'Manager',
+ }),
+}));
+
+afterEach(() => {
+ vi.clearAllMocks();
+ vi.unstubAllEnvs();
+});
+
+describe('request identity in approval context (#42)', () => {
+ it('shows the employee name in the inbox and retained audit trail', async () => {
+ renderWithProviders();
+ expect(await screen.findByText(/Anna Example · Urlaub/)).toBeDefined();
+ expect(await screen.findByText('Zeitbuchungen')).toBeDefined();
+ expect(api.timeEntryApprovals).toHaveBeenCalledWith('review');
+ fireEvent.click(screen.getByRole('button', { name: 'Verlauf' }));
+ expect(
+ await screen.findByText(/Anna Example · Urlaub · 4.5.2026/),
+ ).toBeDefined();
+ expect(await screen.findByText('Erneut eingereicht')).toBeDefined();
+ });
+ it('shows the immutable correction before/proposed diff in both inbox and audit trail, with request-scoped final audit', async () => {
+ vi.stubEnv('TZ', 'Europe/Berlin');
+ const before = {
+ id: 'original-entry',
+ revision: 4,
+ clockIn: '2026-10-25T00:50:23.456Z',
+ clockOut: '2026-10-25T01:10:45.789Z',
+ };
+ const correction = {
+ ...request,
+ type: 'TimeAdjustment',
+ targetTimeEntryId: before.id,
+ expectedTargetRevision: 4,
+ targetTimeEntryBefore: before,
+ from: before.clockIn,
+ to: '2026-10-25T02:10:45.789Z',
+ };
+ vi.mocked(api.listRequests).mockImplementation(
+ async (filter) =>
+ (filter?.currentApproverId ? [correction] : []) as never,
+ );
+ vi.mocked(api.getRequest).mockResolvedValue(correction as never);
+ vi.mocked(api.requestTimeEntryAudit).mockResolvedValue([
+ {
+ id: 'audit',
+ timeEntryId: before.id,
+ requestId: request.id,
+ action: 'FrameRequestCorrected',
+ before,
+ after: { ...before, clockOut: correction.to },
+ reason: 'Correction',
+ occurredAt: '2026-10-26T12:00:00Z',
+ actorId: 'hr',
+ actorName: 'Hannah Reviewer',
+ },
+ ]);
+ renderWithProviders();
+ expect(
+ await screen.findByText('Buchung original-entry · Revision 4'),
+ ).toBeDefined();
+ expect(
+ screen.getByText('Bruttodauer: 1.222,333 s → 4.822,333 s (Δ 3.600 s)'),
+ ).toBeDefined();
+ expect(screen.getAllByTitle(before.clockIn)).toHaveLength(2);
+ expect(screen.getAllByText(/02:50:23[,.]456.*UTC\+02:00/)).toHaveLength(2);
+ expect(screen.getByTitle(before.clockOut)).toBeDefined();
+ fireEvent.click(screen.getByRole('button', { name: 'Verlauf' }));
+ expect(
+ await screen.findByText(
+ 'Buchung original-entry durch Antrag request-42 korrigiert',
+ ),
+ ).toBeDefined();
+ expect(api.requestTimeEntryAudit).toHaveBeenCalledWith('request-42');
+ expect(screen.getByText('Angewendet von Hannah Reviewer')).toBeDefined();
+ expect(
+ screen.getAllByText('Bruttodauer: 1.222,333 s → 4.822,333 s (Δ 3.600 s)'),
+ ).toHaveLength(3);
+ });
+});
diff --git a/apps/web/src/routes/AdminRequestsPage.tsx b/apps/web/src/routes/AdminRequestsPage.tsx
index d8fc2df..21ac833 100644
--- a/apps/web/src/routes/AdminRequestsPage.tsx
+++ b/apps/web/src/routes/AdminRequestsPage.tsx
@@ -1,3 +1,4 @@
+import { TimeAdjustmentDiff, RequestTimeEntryAudit } from './RequestsPage';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Alert, AlertDescription } from '@/components/ui/alert';
@@ -25,6 +26,7 @@ import { api, type BulkResult, type RequestDto } from '../api/client';
import { useCurrentUser } from '../app/auth';
import { cn } from '@/lib/utils';
import { useI18n } from '../app/i18n';
+import { TimeEntryApprovals } from '../components/timeentries/TimeEntryApprovals';
type BulkDialog =
| { mode: 'approve'; ids: string[] }
@@ -107,6 +109,8 @@ export function AdminRequestsPage() {
+
+
{bulkOutcome && (
- {enumLabel(request.type)} ·{' '}
+ {request.employeeName} · {enumLabel(request.type)} ·{' '}
{isTimeAdjustment
? `${formatDateTime(request.from)} – ${formatDateTime(request.to)}`
: `${formatDate(request.from.slice(0, 10))} – ${formatDate(request.to.slice(0, 10))}`}
@@ -526,6 +530,8 @@ function RequestRow({
+ {isTimeAdjustment && }
+
{((role === 'Manager' && inManagerInbox) ||
(role === 'HRAdmin' && (inHrInbox || inManagerInbox))) && (
@@ -624,10 +630,16 @@ function AuditTrail({ requestId }: { requestId: string }) {
{t('requests.auditHistory')}
{requestQuery.data
- ? `${enumLabel(requestQuery.data.type)} · ${formatDate(requestQuery.data.type === 'TimeAdjustment' ? requestQuery.data.from : requestQuery.data.from.slice(0, 10))} – ${formatDate(requestQuery.data.type === 'TimeAdjustment' ? requestQuery.data.to : requestQuery.data.to.slice(0, 10))}`
+ ? `${requestQuery.data.employeeName} · ${enumLabel(requestQuery.data.type)} · ${formatDate(requestQuery.data.type === 'TimeAdjustment' ? requestQuery.data.from : requestQuery.data.from.slice(0, 10))} – ${formatDate(requestQuery.data.type === 'TimeAdjustment' ? requestQuery.data.to : requestQuery.data.to.slice(0, 10))}`
: '…'}
+ {requestQuery.data?.type === 'TimeAdjustment' && (
+
+
+
+
+ )}
{eventsQuery.data && eventsQuery.data.length > 0 ? (
diff --git a/apps/web/src/routes/AdminWorkingTimesPage.spec.tsx b/apps/web/src/routes/AdminWorkingTimesPage.spec.tsx
index 1cb8ec7..bf275a7 100644
--- a/apps/web/src/routes/AdminWorkingTimesPage.spec.tsx
+++ b/apps/web/src/routes/AdminWorkingTimesPage.spec.tsx
@@ -1,8 +1,23 @@
-import { act, fireEvent, screen, waitFor } from '@testing-library/react';
+import {
+ act,
+ fireEvent,
+ screen,
+ waitFor,
+ within,
+} from '@testing-library/react';
import { afterEach, beforeEach, expect, vi } from 'vitest';
import type { WorkingTimeReportDto } from '../api/client';
import { renderWithProviders } from '../test-utils';
+let selectedLocale = 'de';
+const currentUser = vi.hoisted(() => ({
+ id: 'hr-1',
+ email: 'hr@test.local',
+ firstName: 'Hannah',
+ lastName: 'HR',
+ role: 'HRAdmin' as 'HRAdmin' | 'Manager' | 'Employee',
+ themePreference: 'System',
+}));
const employeesMock = vi.fn();
const workingTimeReportMock = vi.fn();
const downloadAuthenticatedMock = vi.fn();
@@ -16,20 +31,28 @@ vi.mock('../api/client', () => ({
},
}));
-vi.mock('../app/auth', () => ({
- useCurrentUser: () => ({
- id: 'hr-1',
- email: 'hr@test.local',
- firstName: 'Hannah',
- lastName: 'HR',
- role: 'HRAdmin',
- themePreference: 'System',
- }),
-}));
+vi.mock('../app/auth', () => ({ useCurrentUser: () => currentUser }));
const report: WorkingTimeReportDto = {
from: '2026-08-01',
to: '2026-08-31',
+ timeZone: 'Europe/Berlin',
+ timeDefinition: 'net_working_time',
+ excessThresholdMinutes: 600,
+ comparisons: [
+ {
+ employeeId: 'employee-1',
+ employeeName: 'Dora Direct',
+ targetMinutes: 480,
+ actualMinutes: 450,
+ differenceMinutes: -30,
+ targetStatus: 'Known',
+ unknownDates: [],
+ reason: null,
+ indicator: 'Negative',
+ openEntryCount: 0,
+ },
+ ],
rows: [
{
id: 'entry-1',
@@ -61,6 +84,16 @@ const report: WorkingTimeReportDto = {
describe('AdminWorkingTimesPage', () => {
beforeEach(() => {
+ currentUser.role = 'HRAdmin';
+ selectedLocale = 'de';
+ vi.spyOn(window, 'localStorage', 'get').mockReturnValue({
+ getItem: () => selectedLocale,
+ setItem: () => undefined,
+ clear: () => undefined,
+ removeItem: () => undefined,
+ key: () => null,
+ length: 0,
+ });
employeesMock.mockReset().mockResolvedValue([
{
id: 'employee-1',
@@ -401,6 +434,246 @@ describe('AdminWorkingTimesPage', () => {
expect(url.searchParams.get('includeLocations')).toBe('true');
});
+ it('shows all textual comparison states, locale hours, missing history and zero-actual employees', async () => {
+ const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage');
+ workingTimeReportMock.mockResolvedValue({
+ ...report,
+ comparisons: [
+ report.comparisons[0],
+ {
+ ...report.comparisons[0],
+ employeeId: 'zero',
+ employeeName: 'Zero Actual',
+ actualMinutes: 0,
+ targetMinutes: 0,
+ differenceMinutes: 0,
+ indicator: 'Balanced',
+ },
+ {
+ ...report.comparisons[0],
+ employeeId: 'positive',
+ employeeName: 'Positive Actual',
+ differenceMinutes: 30,
+ indicator: 'Positive',
+ },
+ {
+ ...report.comparisons[0],
+ employeeId: 'excess',
+ employeeName: 'Excess Actual',
+ differenceMinutes: 600,
+ indicator: 'Excess',
+ },
+ {
+ ...report.comparisons[0],
+ employeeId: 'gap',
+ employeeName: 'History Gap',
+ targetStatus: 'Unknown',
+ targetMinutes: null,
+ differenceMinutes: null,
+ actualMinutes: 0,
+ indicator: 'Unknown',
+ unknownDates: ['2026-08-12'],
+ reason: 'CONTRACT_HISTORY_GAP',
+ openEntryCount: 2,
+ },
+ ],
+ });
+ renderWithProviders();
+ const table = within(
+ await screen.findByRole('table', { name: 'Soll/Ist-Vergleich' }),
+ );
+ expect(table.getByText('-0,5 h')).toBeTruthy();
+ expect(table.getByText('+0,5 h')).toBeTruthy();
+ expect(table.getByText('+10 h')).toBeTruthy();
+ for (const label of [
+ 'Unter Soll',
+ 'Ausgeglichen',
+ 'Über Soll',
+ 'Prüfung empfohlen (ab +10 h)',
+ 'Unbekannt',
+ ]) {
+ expect(table.getByText(label)).toBeTruthy();
+ }
+ const zero = within(table.getByRole('row', { name: /Zero Actual/ }));
+ expect(zero.getAllByText('0 h')).toHaveLength(2);
+ const unknown = within(table.getByRole('row', { name: /History Gap/ }));
+ expect(unknown.getAllByText('—')).toHaveLength(2);
+ expect(unknown.getByText('0 h')).toBeTruthy();
+ expect(unknown.getByText(/Bestätigte Vertragshistorie fehlt/)).toBeTruthy();
+ expect(unknown.getByText(/12.8.2026/)).toBeTruthy();
+ expect(unknown.getByText('2')).toBeTruthy();
+ expect(screen.getByText(/Zeitzone: Europe\/Berlin/)).toBeTruthy();
+ expect(
+ screen.getByText(/Abgeschlossene Buchungen mit Status/),
+ ).toBeTruthy();
+ expect(
+ screen.getByText(/kein fortgeschriebenes Überstundenkonto/),
+ ).toBeTruthy();
+ expect(screen.getByText(/weder eine gesetzliche Grenze/)).toBeTruthy();
+ });
+
+ it('uses server-scoped team endpoints for Managers and hides HR-only PDF controls', async () => {
+ const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage');
+ currentUser.role = 'Manager';
+ renderWithProviders();
+ await screen.findByRole('table', { name: 'Soll/Ist-Vergleich' });
+ expect(employeesMock).toHaveBeenCalledOnce();
+ expect(workingTimeReportMock).toHaveBeenCalled();
+ expect(screen.getByText(/Server begrenzt die Auswahl/)).toBeTruthy();
+ expect(
+ screen.queryByRole('button', { name: 'PDF herunterladen' }),
+ ).toBeNull();
+ expect(
+ screen.getByRole('button', { name: 'CSV herunterladen' }),
+ ).toBeTruthy();
+ expect(downloadAuthenticatedMock).not.toHaveBeenCalled();
+ });
+
+ it('denies ordinary employees before loading any report or employee choices', async () => {
+ const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage');
+ currentUser.role = 'Employee';
+ renderWithProviders();
+ expect(
+ screen.getByText(/Führungskräften und HR-Admins vorbehalten/),
+ ).toBeTruthy();
+ expect(employeesMock).not.toHaveBeenCalled();
+ expect(workingTimeReportMock).not.toHaveBeenCalled();
+ });
+
+ it('keeps week, month and custom periods consistent between comparison and detail queries', async () => {
+ const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage');
+ workingTimeReportMock.mockImplementation((from, to) =>
+ Promise.resolve({ ...report, from, to }),
+ );
+ renderWithProviders();
+ await screen.findByRole('table', { name: 'Soll/Ist-Vergleich' });
+ fireEvent.change(screen.getByLabelText('Zeitraum'), {
+ target: { value: 'week' },
+ });
+ await waitFor(() =>
+ expect(
+ (screen.getByLabelText('Zeitraum') as HTMLSelectElement).value,
+ ).toBe('week'),
+ );
+ const from = (screen.getByLabelText('Von') as HTMLInputElement).value;
+ const to = (screen.getByLabelText('Bis') as HTMLInputElement).value;
+ expect(new Date(from + 'T00:00:00Z').getUTCDay()).toBe(1);
+ expect((Date.parse(to) - Date.parse(from)) / 86400000).toBe(6);
+ await waitFor(() =>
+ expect(workingTimeReportMock.mock.calls.at(-1)?.slice(0, 2)).toEqual([
+ from,
+ to,
+ ]),
+ );
+ fireEvent.change(screen.getByLabelText('Zeitraum'), {
+ target: { value: 'month' },
+ });
+ expect((screen.getByLabelText('Von') as HTMLInputElement).value).toMatch(
+ /-01$/,
+ );
+ fireEvent.change(screen.getByLabelText('Von'), {
+ target: { value: '2026-08-01' },
+ });
+ fireEvent.change(screen.getByLabelText('Bis'), {
+ target: { value: '2026-08-31' },
+ });
+ await waitFor(() =>
+ expect(workingTimeReportMock.mock.calls.at(-1)?.slice(0, 2)).toEqual([
+ '2026-08-01',
+ '2026-08-31',
+ ]),
+ );
+ expect((screen.getByLabelText('Zeitraum') as HTMLSelectElement).value).toBe(
+ 'custom',
+ );
+ expect(await screen.findByText(/1.8.2026 – 31.8.2026/)).toBeTruthy();
+ });
+
+ it('shows empty comparisons and retains the existing empty-entry state', async () => {
+ const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage');
+ workingTimeReportMock.mockResolvedValue({
+ ...report,
+ rows: [],
+ comparisons: [],
+ });
+ renderWithProviders();
+ expect(
+ await screen.findByText(
+ 'Keine Mitarbeiter:innen für die gewählte Auswahl vorhanden.',
+ ),
+ ).toBeTruthy();
+ expect(
+ screen.getByText('Keine Buchungen im gewählten Zeitraum.'),
+ ).toBeTruthy();
+ });
+
+ it('renders the comparison and unknown explanation in English', async () => {
+ const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage');
+ selectedLocale = 'en';
+ workingTimeReportMock.mockResolvedValue({
+ ...report,
+ comparisons: [
+ {
+ ...report.comparisons[0],
+ indicator: 'Unknown',
+ targetStatus: 'Unknown',
+ targetMinutes: null,
+ differenceMinutes: null,
+ reason: 'CONTRACT_HISTORY_GAP',
+ unknownDates: ['2026-08-12'],
+ },
+ ],
+ });
+ renderWithProviders();
+ const table = within(
+ await screen.findByRole('table', { name: 'Target/actual comparison' }),
+ );
+ expect(table.getByText('7.5 h')).toBeTruthy();
+ expect(table.getByText('Unknown')).toBeTruthy();
+ expect(
+ table.getByText(/Confirmed contract history is missing/),
+ ).toBeTruthy();
+ expect(
+ screen.getByText(/not a carried-forward overtime account/),
+ ).toBeTruthy();
+ expect(screen.getByText(/neither a statutory limit/)).toBeTruthy();
+ });
+
+ it('renders and exports clock times in the declared report timezone, independent of the viewer timezone', async () => {
+ const previousZone = process.env.TZ;
+ process.env.TZ = 'America/New_York';
+ try {
+ const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage');
+ renderWithProviders();
+ expect(await screen.findByText('09:00')).toBeTruthy();
+ expect(screen.getByText('17:00')).toBeTruthy();
+ const { workingTimeReportToCsv } =
+ await import('./AdminWorkingTimesPage');
+ const csv = workingTimeReportToCsv(
+ report,
+ {
+ date: 'Date',
+ employee: 'Employee',
+ start: 'Start',
+ clockInLocation: 'In location',
+ end: 'End',
+ clockOutLocation: 'Out location',
+ gross: 'Gross',
+ break: 'Break',
+ net: 'Net',
+ status: 'Status',
+ total: 'Total',
+ },
+ 'de-DE',
+ (status) => status,
+ );
+ expect(csv).toContain('2026-08-10;Dora Direct;09:00;17:00;');
+ expect(screen.queryByText('03:00')).toBeNull();
+ } finally {
+ if (previousZone === undefined) delete process.env.TZ;
+ else process.env.TZ = previousZone;
+ }
+ });
it('creates an Excel-friendly CSV containing the filtered rows and totals', async () => {
const { workingTimeReportToCsv } = await import('./AdminWorkingTimesPage');
const csv = workingTimeReportToCsv(
diff --git a/apps/web/src/routes/AdminWorkingTimesPage.tsx b/apps/web/src/routes/AdminWorkingTimesPage.tsx
index 521a1c1..10806f5 100644
--- a/apps/web/src/routes/AdminWorkingTimesPage.tsx
+++ b/apps/web/src/routes/AdminWorkingTimesPage.tsx
@@ -17,6 +17,7 @@ import {
downloadAuthenticated,
type WorkingTimeReportDto,
type WorkingTimeReportLocationDto,
+ type WorkingTimeComparisonDto,
} from '../api/client';
import { useCurrentUser } from '../app/auth';
import { useI18n } from '../app/i18n';
@@ -53,10 +54,15 @@ function formatMinutes(minutes: number): string {
return `${hours}:${minutePart}${secondPart ? `:${secondPart.toString().padStart(2, '0')}` : ''}`;
}
-function formatTime(value: string, languageTag: string): string {
+function formatTime(
+ value: string,
+ languageTag: string,
+ timeZone?: string,
+): string {
return new Date(value).toLocaleTimeString(languageTag, {
hour: '2-digit',
minute: '2-digit',
+ timeZone,
});
}
@@ -104,11 +110,11 @@ export function workingTimeReportToCsv(
[
row.date,
row.employeeName,
- formatTime(row.clockIn, languageTag),
+ formatTime(row.clockIn, languageTag, report.timeZone),
...(includeLocations
? [formatLocation(row.clockInLocation, languageTag)]
: []),
- formatTime(row.clockOut, languageTag),
+ formatTime(row.clockOut, languageTag, report.timeZone),
...(includeLocations
? [formatLocation(row.clockOutLocation, languageTag)]
: []),
@@ -144,12 +150,161 @@ function defaultRange(): { from: string; to: string } {
};
}
+type ReportPeriod = 'week' | 'month' | 'custom';
+
+function presetRange(period: Exclude) {
+ if (period === 'month') return defaultRange();
+ const today = new Date();
+ const monday = new Date(
+ today.getFullYear(),
+ today.getMonth(),
+ today.getDate(),
+ );
+ monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
+ const sunday = new Date(monday);
+ sunday.setDate(monday.getDate() + 6);
+ return { from: dateInputValue(monday), to: dateInputValue(sunday) };
+}
+
+function ComparisonTable({ report }: { report: WorkingTimeReportDto }) {
+ const { t, languageTag, formatDate } = useI18n();
+ const hours = (minutes: number, signed = false) =>
+ t('comparison.hours', {
+ hours: new Intl.NumberFormat(languageTag, {
+ maximumFractionDigits: 2,
+ ...(signed ? { signDisplay: 'always' as const } : {}),
+ }).format(minutes / 60),
+ });
+ const colors: Record = {
+ Unknown: 'bg-muted text-muted-foreground',
+ Negative: 'bg-red-100 text-red-900 dark:bg-red-950 dark:text-red-200',
+ Balanced:
+ 'bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100',
+ Positive:
+ 'bg-green-100 text-green-900 dark:bg-green-950 dark:text-green-200',
+ Excess: 'bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-200',
+ };
+ return (
+
+
+ {t('comparison.title')}
+ {t('comparison.description')}
+
+
+
+ {t('comparison.period', {
+ from: formatDate(report.from),
+ to: formatDate(report.to),
+ timeZone: report.timeZone,
+ })}
+
+
+ {t('comparison.provisional')}
+
+ {report.comparisons.length ? (
+
+
+
+
+ | {t('common.employee')} |
+ {t('comparison.target')} |
+ {t('comparison.actual')} |
+
+ {t('comparison.difference')}
+ |
+ {t('comparison.indicator')} |
+
+ {t('comparison.openEntries')}
+ |
+
+
+
+ {report.comparisons.map((comparison) => {
+ const unknown = comparison.targetStatus === 'Unknown';
+ const indicator = unknown ? 'Unknown' : comparison.indicator;
+ return (
+
+ |
+ {comparison.employeeName}
+ |
+
+ {!unknown && comparison.targetMinutes !== null
+ ? hours(comparison.targetMinutes)
+ : '—'}
+ |
+
+ {hours(comparison.actualMinutes)}
+ |
+
+ {!unknown && comparison.differenceMinutes !== null
+ ? hours(comparison.differenceMinutes, true)
+ : '—'}
+ |
+
+
+ {t(`comparison.indicator.${indicator}`)}
+
+ {unknown && (
+
+ {t('comparison.historyGap')}
+ {comparison.unknownDates.length > 0 && (
+
+ {t('comparison.unknownDates', {
+ dates: comparison.unknownDates
+ .map(formatDate)
+ .join(', '),
+ })}
+
+ )}
+
+ )}
+ |
+
+ {new Intl.NumberFormat(languageTag).format(
+ comparison.openEntryCount,
+ )}
+ |
+
+ );
+ })}
+
+
+
+ ) : (
+
+ {t('comparison.empty')}
+
+ )}
+
+ {t('comparison.reviewHint', {
+ hours: new Intl.NumberFormat(languageTag).format(
+ report.excessThresholdMinutes / 60,
+ ),
+ })}
+
+
+ {t('comparison.notAccount')}
+
+
+
+ );
+}
+
const MULTIPLE_EMPLOYEES = '__multiple__';
export function AdminWorkingTimesPage() {
const user = useCurrentUser();
const { t, enumLabel, languageTag, locale } = useI18n();
const initialRange = useMemo(defaultRange, []);
+ const [period, setPeriod] = useState('month');
const [from, setFrom] = useState(initialRange.from);
const [to, setTo] = useState(initialRange.to);
const [employeeId, setEmployeeId] = useState('');
@@ -157,7 +312,8 @@ export function AdminWorkingTimesPage() {
const [includeLocations, setIncludeLocations] = useState(false);
const [pdfPending, setPdfPending] = useState(false);
const [pdfFailed, setPdfFailed] = useState(false);
- const isAuthorized = user.role === 'HRAdmin';
+ const isHr = user.role === 'HRAdmin';
+ const isAuthorized = isHr || user.role === 'Manager';
const orderedRange = from !== '' && to !== '' && from <= to;
const validRange =
orderedRange && (Date.parse(to) - Date.parse(from)) / 86_400_000 < 366;
@@ -169,7 +325,7 @@ export function AdminWorkingTimesPage() {
const validSelection = !multipleEmployees || selectedEmployeeIds.length > 0;
const employees = useQuery({
- queryKey: ['employees', 'working-time-report'],
+ queryKey: ['employees', 'working-time-report', user.id, user.role],
queryFn: () => api.workingTimeReportEmployees(),
enabled: isAuthorized,
});
@@ -177,6 +333,8 @@ export function AdminWorkingTimesPage() {
const report = useQuery({
queryKey: [
'working-time-report',
+ user.id,
+ user.role,
from,
to,
singleEmployeeId ?? null,
@@ -203,7 +361,9 @@ export function AdminWorkingTimesPage() {
if (!isAuthorized) {
return (
- {t('reports.adminOnly')}
+
+ {t('reports.accessDenied')}
+
);
}
const downloadCsv = () => {
@@ -236,7 +396,7 @@ export function AdminWorkingTimesPage() {
URL.revokeObjectURL(url);
};
const downloadPdf = async () => {
- if (!canDownload) return;
+ if (!canDownload || !isHr) return;
const params = new URLSearchParams({ from, to, locale });
if (singleEmployeeId) params.set('employeeId', singleEmployeeId);
employeeIds?.forEach((id) => params.append('employeeIds', id));
@@ -263,6 +423,9 @@ export function AdminWorkingTimesPage() {
{t('reports.description')}
+
+ {t(`reports.scope.${user.role}`)}
+
@@ -272,6 +435,30 @@ export function AdminWorkingTimesPage() {
+
+
+
+
setFrom(event.target.value)}
+ onChange={(event) => {
+ setPeriod('custom');
+ setFrom(event.target.value);
+ }}
/>
@@ -291,7 +481,10 @@ export function AdminWorkingTimesPage() {
value={to}
min={from || undefined}
disabled={pdfPending}
- onChange={(event) => setTo(event.target.value)}
+ onChange={(event) => {
+ setPeriod('custom');
+ setTo(event.target.value);
+ }}
/>
@@ -339,15 +532,17 @@ export function AdminWorkingTimesPage() {
{t('reports.downloadCsv')}
-
+ {isHr && (
+
+ )}
{multipleEmployees && (