diff --git a/.env.prod.example b/.env.prod.example index 05445b3..96f8f3e 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -3,7 +3,7 @@ # Pin deployments to an immutable OpenClockwork release. Do not use `latest` # for production upgrades. -OPENCLOCKWORK_VERSION=1.2.2 +OPENCLOCKWORK_VERSION=1.3.0 # Keep these names unchanged across upgrades. OPENCLOCKWORK_DB_VOLUME=openclockwork-db-data-prod OPENCLOCKWORK_ATTACHMENTS_VOLUME=openclockwork-attachments-prod 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/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2efce5d..58b4ef4 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,45 +1,51 @@ -# OpenClockwork v1.2.2 +# OpenClockwork v1.3.0 ## Highlights -- Prevents mixed web/API rollouts from exposing terminal controls against an - older API. This fixes the production failure that surfaced as - `Cannot POST /api/terminals` even though the terminal UI was already visible. -- Reports the running API release version through `/api/health`. The web - container waits for the matching API version before nginx accepts browser - traffic, so an incomplete rollout fails safely instead of serving an - incompatible application. -- Makes production Docker Compose wait for API health before starting the web - service and documents the required API-before-web order for separate Azure - Container App revisions. -- Adds regression coverage for creating and activating the very first terminal - after production administrator bootstrap, with no seeded terminal or support - prompt state. +- Adds an HR opt-in for clock-in and clock-out locations in the + project-independent working-time report and its CSV export. Location data is + excluded from report responses by default. +- Combines available GPS coordinates and accuracy with durable terminal + location labels. Terminal labels are captured at booking time so historical + reports remain stable after a terminal is renamed or deleted. +- Secures employee-directory and request-workflow APIs with bearer + authentication. Request actors and employees are derived from the + authenticated session instead of client-supplied identity fields. +- Extends regression coverage for location reporting, terminal deletion, + authenticated workflow transitions, database upgrades, and generated API + contracts. ## Upgrade notes - Back up PostgreSQL and request attachments before upgrading. -- Set `OPENCLOCKWORK_VERSION=1.2.2` and follow the - [upgrade guide](https://github.com/patrickschiller/openclockwork/blob/v1.2.2/UPGRADING.md). +- Set `OPENCLOCKWORK_VERSION=1.3.0` and follow the + [upgrade guide](https://github.com/patrickschiller/openclockwork/blob/v1.3.0/UPGRADING.md). - Existing production volumes are retained; do not use `docker compose down -v`. -- This patch contains no database migration and does not add demo data to - production installations. -- When API and web are deployed as separate services, deploy the `1.2.2` API - first and wait for `/api/health` to report version `1.2.2`; then deploy the - web image. Docker Compose performs this ordering automatically. +- Update external integrations that call employee or request endpoints: they + must send a valid JWT bearer token and must not rely on request-body + `employeeId` or `actorId` values to select the acting user. +- When API and web are deployed separately, deploy the `1.3.0` API first and + wait for `/api/health` to report version `1.3.0`; then deploy the web image. + Docker Compose performs this ordering automatically. ## Database migrations -None. +- `20260901120000_working_time_report_locations` adds nullable terminal location + label snapshots for clock-in and clock-out and backfills existing + terminal-linked entries where possible. +- The migration is additive and contains no destructive schema change. ## Breaking changes -None. +- All `/api/employees` and `/api/requests` endpoints now require bearer + authentication. Workflow identity is taken from the authenticated token; + unauthenticated calls and attempts to select another actor through request + bodies are rejected or ignored as appropriate. ## Docker images -- `ghcr.io/patrickschiller/openclockwork-api:1.2.2` -- `ghcr.io/patrickschiller/openclockwork-web:1.2.2` +- `ghcr.io/patrickschiller/openclockwork-api:1.3.0` +- `ghcr.io/patrickschiller/openclockwork-web:1.3.0` ## Known issues diff --git a/RELEASING.md b/RELEASING.md index 16fb059..88d3641 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -4,8 +4,8 @@ Only publish a release from a green `main` commit. Stable releases follow semantic versioning and use matching metadata in five places: - `package.json`: `MAJOR.MINOR.PATCH` -- `apps/api/src/main.ts` and `apps/api/src/generate-openapi.ts`: - `MAJOR.MINOR.PATCH` +- `apps/api/src/main.ts`, `apps/api/src/generate-openapi.ts`, and the health DTO + example: `MAJOR.MINOR.PATCH` - `RELEASE_NOTES.md`: `# OpenClockwork vMAJOR.MINOR.PATCH` - `.env.prod.example`: `OPENCLOCKWORK_VERSION=MAJOR.MINOR.PATCH` - Git tag: `vMAJOR.MINOR.PATCH` @@ -30,8 +30,8 @@ annotated tag on the exact merge commit and push it: ```bash git switch main git pull --ff-only -git tag -a v1.2.2 -m "OpenClockwork v1.2.2" -git push origin v1.2.2 +git tag -a v1.3.0 -m "OpenClockwork v1.3.0" +git push origin v1.3.0 ``` The release workflow verifies the tag and notes, runs Nx and API end-to-end @@ -46,8 +46,8 @@ pulled anonymously by self-hosted installations. ## Verify the published release ```bash -docker pull ghcr.io/patrickschiller/openclockwork-api:1.2.2 -docker pull ghcr.io/patrickschiller/openclockwork-web:1.2.2 +docker pull ghcr.io/patrickschiller/openclockwork-api:1.3.0 +docker pull ghcr.io/patrickschiller/openclockwork-web:1.3.0 ``` Confirm that the GitHub Release is marked latest, contains the curated upgrade diff --git a/UPGRADING.md b/UPGRADING.md index 2b3e1a9..570ebb8 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -47,17 +47,31 @@ secrets. Azure deployments must likewise set the secure `terminalQrSecret` Bicep parameter (for example in an untracked private parameter file); the deployment stores it as a separate Key Vault secret. -This release also hardens the existing live-booking API. `GET /api/timeentries`, +Existing releases from 1.2.0 onward also harden the live-booking API. `GET /api/timeentries`, `POST /api/timeentries/clock-in`, and `POST /api/timeentries/clock-out` now require an employee bearer token. Clock-in/out always use the employee identity from that token; a legacy `employeeId` in the request body is ignored. Update external clients before rollout and regenerate them from `apps/api/openapi.json` where applicable. +## Required for 1.3.0 + +All employee-directory and request-workflow endpoints now require a JWT bearer +token. Request creation, approval, rejection, substitution, cancellation, and +bulk operations derive the acting employee from that token instead of trusting +`employeeId` or `actorId` in a request body. Update external clients to +authenticate these calls and use the regenerated OpenAPI contract before the +rollout. + +Release 1.3.0 adds nullable clock-in and clock-out terminal-location snapshots +to `TimeEntry`. The forward migration backfills labels for entries that still +reference a terminal. No manual SQL is required; normal API startup applies the +migration before serving traffic. + ## 2. Select and pull the release Set `OPENCLOCKWORK_VERSION` in `.env.prod` to the exact version from the GitHub -Release, for example `1.2.2`. Do not use `latest` for a controlled production +Release, for example `1.3.0`. Do not use `latest` for a controlled production upgrade. ```bash 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..25a6899 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": { @@ -2660,7 +2669,7 @@ "info": { "title": "OpenClockwork API", "description": "Self-hostable working-time tracker — REST + WebSocket surface.", - "version": "1.2.2", + "version": "1.3.0", "contact": {} }, "tags": [], @@ -2730,7 +2739,7 @@ }, "version": { "type": "string", - "example": "1.2.2" + "example": "1.3.0" }, "utcTimestamp": { "type": "string", @@ -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/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts index 137840f..f84bb76 100644 --- a/apps/api/src/app/health/health.controller.ts +++ b/apps/api/src/app/health/health.controller.ts @@ -9,7 +9,7 @@ export class HealthResponseDto { @ApiProperty({ example: 'openclockwork-api' }) service!: string; - @ApiProperty({ example: '1.2.2' }) + @ApiProperty({ example: '1.3.0' }) version!: string; @ApiProperty({ format: 'date-time' }) 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/api/src/generate-openapi.ts b/apps/api/src/generate-openapi.ts index c8cc252..6e86468 100644 --- a/apps/api/src/generate-openapi.ts +++ b/apps/api/src/generate-openapi.ts @@ -33,7 +33,7 @@ async function main() { .setDescription( 'Self-hostable working-time tracker — REST + WebSocket surface.', ) - .setVersion('1.2.2') + .setVersion('1.3.0') .addBearerAuth() .build(); const document = SwaggerModule.createDocument(app, config); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 77d2fd3..a591ac5 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -38,7 +38,7 @@ async function bootstrap() { .setDescription( 'Self-hostable working-time tracker — REST + WebSocket surface.', ) - .setVersion('1.2.2') + .setVersion('1.3.0') .addBearerAuth() .build(); const document = SwaggerModule.createDocument(app, swaggerConfig); 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..2d20b8a 100644 --- a/apps/web/src/api/generated.ts +++ b/apps/web/src/api/generated.ts @@ -1136,7 +1136,7 @@ export interface components { status: string; /** @example openclockwork-api */ service: string; - /** @example 1.2.2 */ + /** @example 1.3.0 */ version: string; /** Format: date-time */ utcTimestamp: string; @@ -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)} + /> + +