From 8c87dc9d7c5887c0784bd94378e6538e216368a4 Mon Sep 17 00:00:00 2001
From: Patrick Schiller
Date: Tue, 1 Sep 2026 13:21:46 +0200
Subject: [PATCH 1/2] feat: add optional clocking locations to reports
Signed-off-by: Patrick Schiller
---
FEATURES.md | 9 +-
README.md | 12 +-
apps/api-e2e/src/api/terminals.e2e.spec.ts | 15 +++
.../src/api/working-time-reports.e2e.spec.ts | 38 +++++++
apps/api/openapi.json | 54 +++++++++
apps/api/src/app/reports/reports.dto.ts | 27 ++++-
apps/api/src/app/reports/reports.service.ts | 32 ++++++
.../src/app/terminals/terminals.service.ts | 2 +
.../app/time-entries/time-entries.service.ts | 4 +
apps/web/src/api/client.ts | 17 ++-
apps/web/src/api/generated.ts | 13 +++
apps/web/src/app/i18n.tsx | 6 +
.../src/routes/AdminWorkingTimesPage.spec.tsx | 70 +++++++++++-
apps/web/src/routes/AdminWorkingTimesPage.tsx | 103 ++++++++++++++++--
.../migration.sql | 16 +++
prisma/schema.prisma | 4 +
16 files changed, 406 insertions(+), 16 deletions(-)
create mode 100644 prisma/migrations/20260901120000_working_time_report_locations/migration.sql
diff --git a/FEATURES.md b/FEATURES.md
index a41e7dd..48baf06 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -142,7 +142,7 @@ Employee submits
| Absence administration | Record and review sickness, training, and flextime entries |
| Approval operations | Manager/HR inboxes, bulk actions, correction loops, and workflow history |
| Terminal administration | Configure, activate, pair, monitor, revoke, re-pair, deactivate, or permanently delete tablet kiosks |
-| Working-time reports | HR-only start, end, break, gross, net, approval, and CSV reporting independent of projects |
+| Working-time reports | HR-only start, end, break, gross, net, approval, and CSV reporting, with optional clocking locations |
Production starts with an empty database. The interactive
`prisma/create-admin.ts` bootstrap creates exactly one first HR administrator,
@@ -166,9 +166,14 @@ bookings, and customer-facing activity reports in one administrative workflow.
- Detailed evaluations filtered by period, employee, project, and order
- Customer-facing activity report and CSV export
- Project-independent HR working-time report for all closed, non-rejected entries
+- Opt-in clock-in and clock-out locations in the report table and CSV export,
+ combining durable terminal labels with available GPS coordinates and accuracy
Exports contain employee names and working-time data and must be handled as
-personal data under the organisation's access and retention policies.
+personal data under the organisation's access and retention policies. Exact
+clocking locations are excluded by default and require an explicit HR action;
+operators must document a lawful purpose and suitable retention period before
+using them.
## Compliance-oriented domain logic
diff --git a/README.md b/README.md
index f4723cb..f47f81f 100644
--- a/README.md
+++ b/README.md
@@ -58,7 +58,7 @@ installable PWA on phones, tablets, and desktops.
bulk actions, and workflow history.
- **Projects and reporting.** Assign employees, structure projects by service
order, compare PLAN and IST hours, edit booking targets, split entries, and
- export customer or working-time reports.
+ export customer or working-time reports with optional clock-in/out locations.
- **Self-hosted and API-first.** PostgreSQL, NestJS, React, OpenAPI, Socket.IO,
Docker, and an Azure reference deployment—without SaaS lock-in.
- **German and English.** Centralised translations, locale-aware dates, and a
@@ -85,8 +85,9 @@ The security model is deliberately separate from an employee session:
- daily signing material is derived from a dedicated `TERMINAL_QR_SECRET`, not
from `JWT_SECRET`;
- clock-in/out uses the authenticated employee identity from the bearer token;
-- geofence, position, accuracy, and radius snapshots remain in the historical
- booking audit record even if the terminal is later deleted permanently;
+- terminal location labels, geofence positions, accuracy, and radius snapshots
+ remain in the historical booking audit record even if the terminal is later
+ deleted permanently;
- pairing, kiosk, and mobile camera/location flows are documented for trusted
local HTTPS and managed iPad deployments.
@@ -106,6 +107,11 @@ collective agreements, payroll integrations, privacy requirements, backups,
monitoring, and incident procedures. OpenClockwork provides technical controls;
it is not legal advice.
+Exact clock-in and clock-out locations are excluded from HR working-time reports
+by default. HR administrators must explicitly include them in the on-screen
+report and CSV export; operators remain responsible for a lawful purpose,
+appropriate access, and retention periods for this personal data.
+
See the [latest release](https://github.com/patrickschiller/openclockwork/releases/latest)
and read [UPGRADING.md](UPGRADING.md) before changing an existing installation.
diff --git a/apps/api-e2e/src/api/terminals.e2e.spec.ts b/apps/api-e2e/src/api/terminals.e2e.spec.ts
index 7bdafd0..1dce618 100644
--- a/apps/api-e2e/src/api/terminals.e2e.spec.ts
+++ b/apps/api-e2e/src/api/terminals.e2e.spec.ts
@@ -411,6 +411,15 @@ describe('Tablet terminals', () => {
TERMINAL_INPUT.maxAccuracyMeters,
);
expect(firstOut.body.entry.clockOutPositionTimestamp).toBeTruthy();
+ const storedEntry = await ctx.prisma.timeEntry.findUniqueOrThrow({
+ where: { id: firstOut.body.entry.id },
+ });
+ expect(storedEntry.terminalLocationLabel).toBe(
+ TERMINAL_INPUT.locationLabel,
+ );
+ expect(storedEntry.clockOutTerminalLocationLabel).toBe(
+ TERMINAL_INPUT.locationLabel,
+ );
await ctx.http
.post('/api/terminals/scan')
@@ -547,6 +556,12 @@ describe('Tablet terminals', () => {
expect(historicalEntry.clockOutTerminalId).toBeNull();
expect(historicalEntry.clockInChallengeId).toBeNull();
expect(historicalEntry.clockOutChallengeId).toBeNull();
+ expect(historicalEntry.terminalLocationLabel).toBe(
+ TERMINAL_INPUT.locationLabel,
+ );
+ expect(historicalEntry.clockOutTerminalLocationLabel).toBe(
+ TERMINAL_INPUT.locationLabel,
+ );
expect(Number(historicalEntry.latitude)).toBeCloseTo(
TERMINAL_INPUT.latitude,
);
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 36d8604..62714d4 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
@@ -66,6 +66,13 @@ describe('Project-independent working-time reports', () => {
clockIn: new Date('2026-08-10T07:00:00.000Z'),
clockOut: new Date('2026-08-10T15:00:00.000Z'),
status: 'Approved',
+ terminalLocationLabel: 'Büro Würzburg',
+ latitude: 49.791304,
+ longitude: 9.953355,
+ accuracyMeters: 12,
+ clockOutLatitude: 49.8,
+ clockOutLongitude: 9.94,
+ clockOutAccuracyMeters: 18.4,
},
{
employeeId: manager.id,
@@ -105,12 +112,37 @@ describe('Project-independent working-time reports', () => {
.set('Authorization', `Bearer ${hrToken}`)
.expect(200);
expect(hrReport.body.rows).toHaveLength(3);
+ expect(hrReport.body.rows[0]).not.toHaveProperty('clockInLocation');
+ expect(hrReport.body.rows[0]).not.toHaveProperty('clockOutLocation');
expect(hrReport.body.totals).toEqual({
grossMinutes: 660,
breakMinutes: 30,
netMinutes: 630,
});
+ const reportWithLocations = await ctx.http
+ .get(
+ '/api/reports/working-times?from=2026-08-01&to=2026-08-31&includeLocations=true',
+ )
+ .set('Authorization', `Bearer ${hrToken}`)
+ .expect(200);
+ expect(reportWithLocations.body.rows[0]).toMatchObject({
+ clockInLocation: {
+ label: 'Büro Würzburg',
+ latitude: 49.791304,
+ longitude: 9.953355,
+ accuracyMeters: 12,
+ },
+ clockOutLocation: {
+ label: null,
+ latitude: 49.8,
+ longitude: 9.94,
+ accuracyMeters: 18.4,
+ },
+ });
+ expect(reportWithLocations.body.rows[1].clockInLocation).toBeNull();
+ expect(reportWithLocations.body.rows[1].clockOutLocation).toBeNull();
+
const onlyDirect = await ctx.http
.get(
`/api/reports/working-times?from=2026-08-01&to=2026-08-31&employeeId=${report.id}`,
@@ -146,6 +178,12 @@ describe('Project-independent working-time reports', () => {
.get('/api/reports/working-times?from=2025-01-01&to=2026-12-31')
.set('Authorization', `Bearer ${hrToken}`)
.expect(400);
+ await ctx.http
+ .get(
+ '/api/reports/working-times?from=2026-08-01&to=2026-08-31&includeLocations=yes',
+ )
+ .set('Authorization', `Bearer ${hrToken}`)
+ .expect(400);
await ctx.http
.get('/api/reports/working-times?from=2026-08-01&to=2026-08-31')
.set('Authorization', `Bearer ${employeeToken}`)
diff --git a/apps/api/openapi.json b/apps/api/openapi.json
index 700d7a9..432abcb 100644
--- a/apps/api/openapi.json
+++ b/apps/api/openapi.json
@@ -935,6 +935,15 @@
"format": "uuid",
"type": "string"
}
+ },
+ {
+ "name": "includeLocations",
+ "required": false,
+ "in": "query",
+ "schema": {
+ "default": false,
+ "type": "boolean"
+ }
}
],
"responses": {
@@ -3121,6 +3130,33 @@
},
"required": ["id", "firstName", "lastName"]
},
+ "WorkingTimeReportLocationDto": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "string",
+ "nullable": true
+ },
+ "latitude": {
+ "type": "number",
+ "nullable": true,
+ "minimum": -90,
+ "maximum": 90
+ },
+ "longitude": {
+ "type": "number",
+ "nullable": true,
+ "minimum": -180,
+ "maximum": 180
+ },
+ "accuracyMeters": {
+ "type": "number",
+ "nullable": true,
+ "minimum": 0
+ }
+ },
+ "required": ["label", "latitude", "longitude", "accuracyMeters"]
+ },
"WorkingTimeReportRowDto": {
"type": "object",
"properties": {
@@ -3162,6 +3198,24 @@
"netMinutes": {
"type": "number",
"minimum": 0
+ },
+ "clockInLocation": {
+ "nullable": true,
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/WorkingTimeReportLocationDto"
+ }
+ ]
+ },
+ "clockOutLocation": {
+ "nullable": true,
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/WorkingTimeReportLocationDto"
+ }
+ ]
}
},
"required": [
diff --git a/apps/api/src/app/reports/reports.dto.ts b/apps/api/src/app/reports/reports.dto.ts
index 6a2eca8..9534b4a 100644
--- a/apps/api/src/app/reports/reports.dto.ts
+++ b/apps/api/src/app/reports/reports.dto.ts
@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
-import { IsOptional, IsUUID, Matches } from 'class-validator';
+import { IsIn, IsOptional, IsUUID, Matches } from 'class-validator';
const DATE_ONLY_PATTERN = '^\\d{4}-\\d{2}-\\d{2}$';
@@ -16,6 +16,11 @@ export class WorkingTimeReportQueryDto {
@IsOptional()
@IsUUID()
employeeId?: string;
+
+ @ApiPropertyOptional({ type: Boolean, default: false })
+ @IsOptional()
+ @IsIn(['true', 'false'])
+ includeLocations?: string;
}
export class WorkingTimeReportEmployeeDto {
@@ -29,6 +34,20 @@ export class WorkingTimeReportEmployeeDto {
lastName!: string;
}
+export class WorkingTimeReportLocationDto {
+ @ApiProperty({ type: String, nullable: true })
+ label!: string | null;
+
+ @ApiProperty({ type: Number, nullable: true, minimum: -90, maximum: 90 })
+ latitude!: number | null;
+
+ @ApiProperty({ type: Number, nullable: true, minimum: -180, maximum: 180 })
+ longitude!: number | null;
+
+ @ApiProperty({ type: Number, nullable: true, minimum: 0 })
+ accuracyMeters!: number | null;
+}
+
export class WorkingTimeReportRowDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@@ -59,6 +78,12 @@ export class WorkingTimeReportRowDto {
@ApiProperty({ minimum: 0 })
netMinutes!: number;
+
+ @ApiPropertyOptional({ type: WorkingTimeReportLocationDto, nullable: true })
+ clockInLocation?: WorkingTimeReportLocationDto | null;
+
+ @ApiPropertyOptional({ type: WorkingTimeReportLocationDto, nullable: true })
+ clockOutLocation?: WorkingTimeReportLocationDto | null;
}
export class WorkingTimeReportTotalsDto {
diff --git a/apps/api/src/app/reports/reports.service.ts b/apps/api/src/app/reports/reports.service.ts
index 32d3e8c..6034497 100644
--- a/apps/api/src/app/reports/reports.service.ts
+++ b/apps/api/src/app/reports/reports.service.ts
@@ -15,6 +15,21 @@ interface LocalDay {
dayNumber: number;
}
+function reportLocation(
+ label: string | null,
+ latitude: { toString(): string } | null,
+ longitude: { toString(): string } | null,
+ accuracyMeters: { toString(): string } | null,
+) {
+ if (label === null && latitude === null && longitude === null) return null;
+ return {
+ label,
+ latitude: latitude === null ? null : Number(latitude),
+ longitude: longitude === null ? null : Number(longitude),
+ accuracyMeters: accuracyMeters === null ? null : Number(accuracyMeters),
+ };
+}
+
function parseLocalDay(value: string): LocalDay {
const [year, month, day] = value.split('-').map(Number);
const date = new Date(year, month - 1, day);
@@ -54,6 +69,7 @@ export class ReportsService {
async workingTimes(
query: WorkingTimeReportQueryDto,
): Promise {
+ const includeLocations = query.includeLocations === 'true';
const from = parseLocalDay(query.from);
const to = parseLocalDay(query.to);
const inclusiveDays = to.dayNumber - from.dayNumber + 1;
@@ -94,6 +110,22 @@ export class ReportsService {
clockIn: entry.clockIn.toISOString(),
clockOut: (entry.clockOut as Date).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,
+ ),
+ }
+ : {}),
...summary,
};
});
diff --git a/apps/api/src/app/terminals/terminals.service.ts b/apps/api/src/app/terminals/terminals.service.ts
index 56b6674..b568181 100644
--- a/apps/api/src/app/terminals/terminals.service.ts
+++ b/apps/api/src/app/terminals/terminals.service.ts
@@ -583,6 +583,7 @@ export class TerminalsService {
? fresh.terminal.maxAccuracyMeters
: null,
positionTimestamp: freshPosition?.positionTimestamp ?? null,
+ terminalLocationLabel: fresh.terminal.locationLabel,
terminalId: fresh.terminalId,
clockInChallengeId: fresh.id,
},
@@ -617,6 +618,7 @@ export class TerminalsService {
: null,
clockOutPositionTimestamp:
freshPosition?.positionTimestamp ?? null,
+ clockOutTerminalLocationLabel: fresh.terminal.locationLabel,
clockOutTerminalId: fresh.terminalId,
clockOutChallengeId: fresh.id,
},
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 47710de..3714605 100644
--- a/apps/api/src/app/time-entries/time-entries.service.ts
+++ b/apps/api/src/app/time-entries/time-entries.service.ts
@@ -672,6 +672,7 @@ export class TimeEntriesService {
clockOutTerminalRadiusMeters: null,
clockOutTerminalMaxAccuracyMeters: null,
clockOutPositionTimestamp: null,
+ clockOutTerminalLocationLabel: null,
clockOutTerminalId: null,
clockOutChallengeId: null,
},
@@ -694,6 +695,7 @@ export class TimeEntriesService {
clockOutTerminalMaxAccuracyMeters:
entry.clockOutTerminalMaxAccuracyMeters,
clockOutPositionTimestamp: entry.clockOutPositionTimestamp,
+ clockOutTerminalLocationLabel: entry.clockOutTerminalLocationLabel,
clockOutTerminalId: entry.clockOutTerminalId,
clockOutChallengeId: entry.clockOutChallengeId,
...second,
@@ -1067,6 +1069,7 @@ export class TimeEntriesService {
clockOutTerminalRadiusMeters: null,
clockOutTerminalMaxAccuracyMeters: null,
clockOutPositionTimestamp: null,
+ clockOutTerminalLocationLabel: null,
clockOutTerminalId: null,
clockOutChallengeId: null,
};
@@ -1082,6 +1085,7 @@ export class TimeEntriesService {
clockOutTerminalMaxAccuracyMeters:
entry.clockOutTerminalMaxAccuracyMeters,
clockOutPositionTimestamp: entry.clockOutPositionTimestamp,
+ clockOutTerminalLocationLabel: entry.clockOutTerminalLocationLabel,
clockOutTerminalId: entry.clockOutTerminalId,
clockOutChallengeId: entry.clockOutChallengeId,
};
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 8849cd9..db62f7b 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -296,6 +296,15 @@ export interface WorkingTimeReportRowDto {
grossMinutes: number;
breakMinutes: number;
netMinutes: number;
+ clockInLocation?: WorkingTimeReportLocationDto | null;
+ clockOutLocation?: WorkingTimeReportLocationDto | null;
+}
+
+export interface WorkingTimeReportLocationDto {
+ label: string | null;
+ latitude: number | null;
+ longitude: number | null;
+ accuracyMeters: number | null;
}
export interface WorkingTimeReportEmployeeDto {
@@ -863,9 +872,15 @@ export const api = {
`/api/projects/${id}/report${qs ? `?${qs}` : ''}`,
);
},
- workingTimeReport: (from: string, to: string, employeeId?: string) => {
+ workingTimeReport: (
+ from: string,
+ to: string,
+ employeeId?: string,
+ includeLocations = false,
+ ) => {
const params = new URLSearchParams({ from, to });
if (employeeId) params.set('employeeId', employeeId);
+ if (includeLocations) params.set('includeLocations', 'true');
return request(
`/api/reports/working-times?${params.toString()}`,
);
diff --git a/apps/web/src/api/generated.ts b/apps/web/src/api/generated.ts
index 16a02fa..856fff4 100644
--- a/apps/web/src/api/generated.ts
+++ b/apps/web/src/api/generated.ts
@@ -1322,6 +1322,12 @@ export interface components {
firstName: string;
lastName: string;
};
+ WorkingTimeReportLocationDto: {
+ label: string | null;
+ latitude: number | null;
+ longitude: number | null;
+ accuracyMeters: number | null;
+ };
WorkingTimeReportRowDto: {
/** Format: uuid */
id: string;
@@ -1339,6 +1345,12 @@ export interface components {
grossMinutes: number;
breakMinutes: number;
netMinutes: number;
+ clockInLocation?:
+ | components['schemas']['WorkingTimeReportLocationDto']
+ | null;
+ clockOutLocation?:
+ | components['schemas']['WorkingTimeReportLocationDto']
+ | null;
};
WorkingTimeReportTotalsDto: {
grossMinutes: number;
@@ -2452,6 +2464,7 @@ export interface operations {
from: string;
to: string;
employeeId?: string;
+ includeLocations?: boolean;
};
header?: never;
path?: never;
diff --git a/apps/web/src/app/i18n.tsx b/apps/web/src/app/i18n.tsx
index 73af132..6ee8279 100644
--- a/apps/web/src/app/i18n.tsx
+++ b/apps/web/src/app/i18n.tsx
@@ -321,7 +321,10 @@ const de: Catalog = {
'reports.rangeHint': 'Der Zeitraum darf höchstens 366 Tage umfassen.',
'reports.date': 'Datum',
'reports.start': 'Beginn',
+ 'reports.clockInLocation': 'Stempelort Beginn',
'reports.end': 'Ende',
+ 'reports.clockOutLocation': 'Stempelort Ende',
+ 'reports.includeLocations': 'Stempelorte einbeziehen',
'reports.gross': 'Brutto',
'reports.break': 'Pause',
'reports.net': 'Netto',
@@ -872,7 +875,10 @@ const en: Catalog = {
'reports.rangeHint': 'The date range may contain at most 366 days.',
'reports.date': 'Date',
'reports.start': 'Start',
+ 'reports.clockInLocation': 'Clock-in location',
'reports.end': 'End',
+ 'reports.clockOutLocation': 'Clock-out location',
+ 'reports.includeLocations': 'Include clocking locations',
'reports.gross': 'Gross',
'reports.break': 'Break',
'reports.net': 'Net',
diff --git a/apps/web/src/routes/AdminWorkingTimesPage.spec.tsx b/apps/web/src/routes/AdminWorkingTimesPage.spec.tsx
index 631e838..51b1d37 100644
--- a/apps/web/src/routes/AdminWorkingTimesPage.spec.tsx
+++ b/apps/web/src/routes/AdminWorkingTimesPage.spec.tsx
@@ -1,4 +1,4 @@
-import { screen } from '@testing-library/react';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, expect, vi } from 'vitest';
import type { WorkingTimeReportDto } from '../api/client';
import { renderWithProviders } from '../test-utils';
@@ -39,6 +39,18 @@ const report: WorkingTimeReportDto = {
grossMinutes: 480,
breakMinutes: 30,
netMinutes: 450,
+ clockInLocation: {
+ label: 'Büro Würzburg',
+ latitude: 49.791304,
+ longitude: 9.953355,
+ accuracyMeters: 12,
+ },
+ clockOutLocation: {
+ label: null,
+ latitude: 49.8,
+ longitude: 9.94,
+ accuracyMeters: 18.4,
+ },
},
],
totals: { grossMinutes: 480, breakMinutes: 30, netMinutes: 450 },
@@ -66,6 +78,31 @@ describe('AdminWorkingTimesPage', () => {
expect(screen.getAllByText('0:30').length).toBeGreaterThan(0);
expect(screen.getAllByText('7:30').length).toBeGreaterThan(0);
expect(screen.queryByRole('columnheader', { name: 'Projekt' })).toBeNull();
+ expect(
+ screen.queryByRole('columnheader', { name: 'Stempelort Beginn' }),
+ ).toBeNull();
+ });
+
+ it('loads and shows clock-in and clock-out locations on demand', async () => {
+ const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage');
+ renderWithProviders();
+ await screen.findAllByText('Dora Direct');
+
+ fireEvent.click(screen.getByLabelText('Stempelorte einbeziehen'));
+
+ await waitFor(() => {
+ expect(workingTimeReportMock.mock.calls.at(-1)?.[3]).toBe(true);
+ });
+ expect(
+ await screen.findByRole('columnheader', { name: 'Stempelort Beginn' }),
+ ).toBeTruthy();
+ expect(
+ screen.getByRole('columnheader', { name: 'Stempelort Ende' }),
+ ).toBeTruthy();
+ expect(
+ await screen.findByText('Büro Würzburg · 49.791304, 9.953355 · ±12 m'),
+ ).toBeTruthy();
+ expect(screen.getByText('49.800000, 9.940000 · ±18 m')).toBeTruthy();
});
it('creates an Excel-friendly CSV containing the filtered rows and totals', async () => {
@@ -76,7 +113,9 @@ describe('AdminWorkingTimesPage', () => {
date: 'Datum',
employee: 'Mitarbeiter:in',
start: 'Beginn',
+ clockInLocation: 'Stempelort Beginn',
end: 'Ende',
+ clockOutLocation: 'Stempelort Ende',
gross: 'Brutto',
break: 'Pause',
net: 'Netto',
@@ -95,4 +134,33 @@ describe('AdminWorkingTimesPage', () => {
expect(csv).toContain(';8:00;0:30;7:30;Approved');
expect(csv).toContain('Gesamt;;;;8:00;0:30;7:30;');
});
+
+ it('adds clocking locations to the CSV only when requested', async () => {
+ const { workingTimeReportToCsv } = await import('./AdminWorkingTimesPage');
+ const csv = workingTimeReportToCsv(
+ report,
+ {
+ date: 'Datum',
+ employee: 'Mitarbeiter:in',
+ start: 'Beginn',
+ clockInLocation: 'Stempelort Beginn',
+ end: 'Ende',
+ clockOutLocation: 'Stempelort Ende',
+ gross: 'Brutto',
+ break: 'Pause',
+ net: 'Netto',
+ status: 'Status',
+ total: 'Gesamt',
+ },
+ 'de-DE',
+ (status) => status,
+ true,
+ );
+
+ expect(csv).toContain(
+ 'Datum;Mitarbeiter:in;Beginn;Stempelort Beginn;Ende;Stempelort Ende;Brutto;Pause;Netto;Status',
+ );
+ expect(csv).toContain('Büro Würzburg · 49.791304, 9.953355 · ±12 m');
+ expect(csv).toContain('Gesamt;;;;;;8:00;0:30;7:30;');
+ });
});
diff --git a/apps/web/src/routes/AdminWorkingTimesPage.tsx b/apps/web/src/routes/AdminWorkingTimesPage.tsx
index 2283966..73bc052 100644
--- a/apps/web/src/routes/AdminWorkingTimesPage.tsx
+++ b/apps/web/src/routes/AdminWorkingTimesPage.tsx
@@ -12,7 +12,11 @@ import {
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
-import { api, type WorkingTimeReportDto } from '../api/client';
+import {
+ api,
+ type WorkingTimeReportDto,
+ type WorkingTimeReportLocationDto,
+} from '../api/client';
import { useCurrentUser } from '../app/auth';
import { useI18n } from '../app/i18n';
@@ -20,7 +24,9 @@ interface CsvLabels {
date: string;
employee: string;
start: string;
+ clockInLocation: string;
end: string;
+ clockOutLocation: string;
gross: string;
break: string;
net: string;
@@ -43,17 +49,41 @@ function formatTime(value: string, languageTag: string): string {
});
}
+function formatLocation(
+ location: WorkingTimeReportLocationDto | null | undefined,
+ languageTag: string,
+): string {
+ if (!location) return '–';
+ const coordinates =
+ location.latitude !== null && location.longitude !== null
+ ? `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`
+ : null;
+ const accuracy =
+ location.accuracyMeters === null
+ ? null
+ : `±${new Intl.NumberFormat(languageTag, {
+ maximumFractionDigits: 0,
+ }).format(location.accuracyMeters)} m`;
+ const parts = [location.label, coordinates, accuracy].filter(
+ (part): part is string => Boolean(part),
+ );
+ return parts.length > 0 ? parts.join(' · ') : '–';
+}
+
export function workingTimeReportToCsv(
report: WorkingTimeReportDto,
labels: CsvLabels,
languageTag: string,
statusLabel: (status: string) => string,
+ includeLocations = false,
): string {
const header = [
labels.date,
labels.employee,
labels.start,
+ ...(includeLocations ? [labels.clockInLocation] : []),
labels.end,
+ ...(includeLocations ? [labels.clockOutLocation] : []),
labels.gross,
labels.break,
labels.net,
@@ -64,7 +94,13 @@ export function workingTimeReportToCsv(
row.date,
row.employeeName,
formatTime(row.clockIn, languageTag),
+ ...(includeLocations
+ ? [formatLocation(row.clockInLocation, languageTag)]
+ : []),
formatTime(row.clockOut, languageTag),
+ ...(includeLocations
+ ? [formatLocation(row.clockOutLocation, languageTag)]
+ : []),
formatMinutes(row.grossMinutes),
formatMinutes(row.breakMinutes),
formatMinutes(row.netMinutes),
@@ -75,9 +111,7 @@ export function workingTimeReportToCsv(
);
const total = [
labels.total,
- '',
- '',
- '',
+ ...Array.from({ length: includeLocations ? 5 : 3 }, () => ''),
formatMinutes(report.totals.grossMinutes),
formatMinutes(report.totals.breakMinutes),
formatMinutes(report.totals.netMinutes),
@@ -106,6 +140,7 @@ export function AdminWorkingTimesPage() {
const [from, setFrom] = useState(initialRange.from);
const [to, setTo] = useState(initialRange.to);
const [employeeId, setEmployeeId] = useState('');
+ const [includeLocations, setIncludeLocations] = useState(false);
const isAuthorized = user.role === 'HRAdmin';
const validRange = from !== '' && to !== '' && from <= to;
@@ -116,8 +151,20 @@ export function AdminWorkingTimesPage() {
});
const availableEmployees = employees.data ?? [];
const report = useQuery({
- queryKey: ['working-time-report', from, to, employeeId || null],
- queryFn: () => api.workingTimeReport(from, to, employeeId || undefined),
+ queryKey: [
+ 'working-time-report',
+ from,
+ to,
+ employeeId || null,
+ includeLocations,
+ ],
+ queryFn: () =>
+ api.workingTimeReport(
+ from,
+ to,
+ employeeId || undefined,
+ includeLocations,
+ ),
enabled: isAuthorized && validRange,
});
@@ -134,7 +181,9 @@ export function AdminWorkingTimesPage() {
date: t('reports.date'),
employee: t('common.employee'),
start: t('reports.start'),
+ clockInLocation: t('reports.clockInLocation'),
end: t('reports.end'),
+ clockOutLocation: t('reports.clockOutLocation'),
gross: t('reports.gross'),
break: t('reports.break'),
net: t('reports.net'),
@@ -143,6 +192,7 @@ export function AdminWorkingTimesPage() {
},
locale === 'de' ? 'de-DE' : 'en-US',
enumLabel,
+ includeLocations,
);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
@@ -211,6 +261,18 @@ export function AdminWorkingTimesPage() {
))}
+
+ setIncludeLocations(event.target.checked)}
+ />
+
+