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)} + /> + +