diff --git a/.env.prod.example b/.env.prod.example
index 6a6eeab..47ad747 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=2.1.0
+OPENCLOCKWORK_VERSION=2.2.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 d1d061e..4bd720d 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -1,12 +1,14 @@
# OpenClockwork Features
-OpenClockwork 2.1.0 provides **Solo** personal work tracking and **Team** time and
+OpenClockwork 2.2.0 provides **Solo** personal work tracking and **Team** time and
attendance in one responsive, self-hosted application. Both keep deployment,
data, and configuration under the operator's control, with German and English UI.
-Version 2.1.0 adds direct PIN identification and explicit time booking at Team
-tablets, HR PIN administration, and an authentication audit. It retains the
-personnel-data access restrictions introduced in 2.0.1. RFID is planned separately.
+Version 2.2.0 adds HR working-time PDF exports for one, selected, or all employees,
+including inactive employees with historical bookings. It retains the PIN tablet
+terminals and audit introduced in 2.1.0 and personnel-data access restrictions
+introduced in 2.0.1. RFID is planned separately. See the
+[German legal context and export limitations](docs/WORKING_TIME_EXPORTS.de.md).
This page describes implemented capabilities, not a guarantee that every rule,
device, or external browser handoff has been certified. See
@@ -87,7 +89,7 @@ contains effective work or time off, the accounting mode starts on the next
working-timezone calendar date. See [the mode guide](docs/OPERATING_MODES.md).
Solo customer management and personal billable reports are not Team screens in
-2.1.0. Preserving records across a switch does not make every mode-specific
+2.2.0. Preserving records across a switch does not make every mode-specific
workflow available in both modes.
## Mobile PWA
@@ -227,7 +229,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, with optional clocking locations |
+| Working-time reports | HR-only CSV/PDF reporting for one, selected, or all employees; optional locations in table/CSV only |
Production starts with an empty database. The interactive
`prisma/create-admin.ts` bootstrap offers Solo or Team and creates exactly one
@@ -252,9 +254,17 @@ 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
+- Direct PDF download for individual employees or a bulk selection, with separate
+ employee sections, personnel numbers, explicit timezone, full timestamps,
+ booking-day/period totals, approval state, and warnings for open timers
+- Historical inactive employees remain selectable for working-time exports
- 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
+PDFs exclude locations, project details, and private notes. Break deductions use
+the stored policy and do not establish when actual rest breaks were taken.
+See [German export requirements and limitations](docs/WORKING_TIME_EXPORTS.de.md).
+
Exports contain employee names and working-time data and must be handled as
personal data under the organisation's access and retention policies. Exact
clocking locations are excluded by default and require an explicit HR action;
diff --git a/NOTICE b/NOTICE
index 1082330..0c47539 100644
--- a/NOTICE
+++ b/NOTICE
@@ -46,3 +46,8 @@ superseded by this NOTICE. A consolidated list of runtime and build-time
dependencies is generated from the package manifests of each application
in the workspace; consult the lockfiles and the upstream projects for the
authoritative license terms.
+
+Working-time PDF reports embed Noto Sans Regular and Bold, distributed under
+the SIL Open Font License, Version 1.1. Copyright 2022 The Noto Project Authors
+(https://github.com/notofonts/latin-greek-cyrillic). The font files and full
+license are included in apps/api/src/assets/fonts/.
diff --git a/README.md b/README.md
index 1835e56..ae7d4d8 100644
--- a/README.md
+++ b/README.md
@@ -14,16 +14,18 @@
-OpenClockwork 2.1.0 provides two operating modes in one open-source application.
+OpenClockwork 2.2.0 provides two operating modes in one open-source application.
**Solo** helps an independent professional track personal work, organise customer
projects, and produce timesheets without inventing an HR department. **Team**
supports employee attendance, work schedules, leave, approvals, and tablet
terminals. Both use the same self-hosted PostgreSQL, API, and responsive PWA.
-**New in 2.1.0:** Team employees can identify themselves directly at a paired
-tablet with their personal number and a separate terminal PIN, then explicitly
-confirm clock-in or clock-out. HR manages PINs, terminal authentication methods,
-and the authentication audit. The access-control fixes from 2.0.1 are retained.
+**New in 2.2.0:** HR can download working-time PDFs alongside CSV for one,
+selected, or all employees, including inactive employees with historical
+bookings. Reports show full timestamps, break deductions, net work, status,
+and totals, with a separate section per person. Read the
+[German legal context and export limitations](docs/WORKING_TIME_EXPORTS.de.md).
+PIN tablet terminals from 2.1.0 and the access-control fixes from 2.0.1 are retained.
The interface is available in German and English on phones, tablets, and
desktops. Language is independent of working-time rules: targets, breaks,
@@ -33,7 +35,7 @@ country. No proprietary time-clock hardware or hosted subscription is required.
**Existing installations remain in Team mode after an upgrade.** A mode change
is a separate, authenticated administrator action with a preview and safety
checks. Invoice creation, monetary billing, and CAUR coding-agent usage import
-are not included in 2.1.0; see the [roadmap](ROADMAP.md).
+are not included in 2.2.0; see the [roadmap](ROADMAP.md).
## Choose your mode
@@ -89,7 +91,11 @@ Solo customer-management and billable-timesheet UI.
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 with optional clock-in/out locations.
+ export customer or working-time reports as CSV. HR can download working-time
+ PDFs for one, selected, or all employees, with a separate section per person.
+ Clock-in/out locations are optional in HR tables and CSV and excluded from PDFs.
+ See the [German working-time export guidance](docs/WORKING_TIME_EXPORTS.de.md)
+ for the legal context and the limits of automatic break deductions.
- **Self-hosted and API-first.** PostgreSQL, NestJS, React, OpenAPI, Socket.IO,
Docker, and an Azure reference deployment—without SaaS lock-in.
- **Localised interface.** German and English translations, browser-aware dates,
@@ -190,8 +196,8 @@ and read [UPGRADING.md](UPGRADING.md) before changing an existing installation.
The PWA is online-first. A running timer remains server state while a device is
offline; new writes require server confirmation and are not queued as offline
-bookings. CSV export starts a browser download, and print/PDF uses the browser's
-print dialog. Verify both handoffs in your chosen browser or installed PWA;
+bookings. CSV and HR working-time PDF exports start browser downloads; Solo
+print/PDF uses the browser's print dialog. Verify these handoffs in your chosen browser or installed PWA;
embedded browsers may handle downloads and printing differently.
## Tech stack
@@ -335,7 +341,7 @@ repository and enter its directory:
```bash
git clone https://github.com/patrickschiller/openclockwork.git
cd openclockwork
-git switch --detach v2.1.0
+git switch --detach v2.2.0
cp .env.prod.example .env.prod
```
@@ -366,7 +372,7 @@ openssl rand -hex 32
Open `.env.prod` in an editor and replace every `change-me` value:
-- Set `OPENCLOCKWORK_VERSION=2.1.0` for this release. Use the same explicit
+- Set `OPENCLOCKWORK_VERSION=2.2.0` for this release. Use the same explicit
version for the API and web images; never deploy `latest`.
- Put the first command's output into `POSTGRES_PASSWORD` and replace
`change-me-database-password` inside `DATABASE_URL` with that exact same
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index b6a5a75..bf153e2 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,3 +1,83 @@
+# OpenClockwork v2.2.0
+
+## Working-time PDFs for HR
+
+- HR can download a working-time PDF alongside the existing CSV export for one
+ employee, several selected employees, or all employees in the chosen period.
+ Historical records of inactive employees remain selectable. Each employee
+ starts on a new page in a combined document.
+- Reports include the employee's name and personnel number, reporting period,
+ export timestamp, installation timezone, full start/end dates and UTC offsets,
+ gross time, rule-based break deductions, net time, booking status and totals.
+ Elapsed time retains second precision in the report. Stored break-rule
+ snapshots determine deductions; changing a schedule does not rewrite history.
+- Pending completed bookings are labelled provisional. Open bookings are
+ flagged and excluded from totals; rejected and cancelled entries are omitted.
+ Overnight work belongs to its booking's start day, so daily subtotals are
+ booking-day totals rather than calendar-day compliance checks. Full dates and
+ UTC offsets make overnight work and daylight-saving transitions traceable.
+- PDF downloads enforce HR authorization and organization scope. Location data,
+ free-text notes and project details are excluded. Combined PDFs contain
+ personal data for several employees: restrict access and share them only with
+ the intended recipients.
+- Requested in [Discussion #37](https://github.com/patrickschiller/openclockwork/discussions/37).
+ A [two-page example PDF](https://github.com/user-attachments/files/33845433/openclockwork-arbeitszeitnachweis-beispiel.pdf)
+ uses synthetic data only. Read the
+ [German working-time export guide](https://github.com/patrickschiller/openclockwork/blob/v2.2.0/docs/WORKING_TIME_EXPORTS.de.md)
+ for selection, calculations and limitations.
+
+## Legal context in Germany
+
+- Employers must generally record the beginning, end and duration of daily
+ working time. German law does not prescribe a universal PDF layout or a
+ general PDF-export requirement for these records. See the
+ [BMAS working-time recording FAQ](https://www.bmas.de/DE/Arbeit/Arbeitsrecht/Arbeitnehmerrechte/Regelungen-zur-Arbeitszeit/Fragen-und-Antworten/faq-arbeitszeiterfassung.html)
+ and the [BAG decision of 13 September 2022, 1 ABR 22/21](https://www.bundesarbeitsgericht.de/entscheidung/1-abr-22-21/).
+- [Section 16(2) ArbZG](https://www.gesetze-im-internet.de/arbzg/__16.html)
+ requires the records specified there, including work beyond the working-day
+ limit under section 3 sentence 1, to be retained for at least two years.
+ [Section 17(1) MiLoG](https://www.gesetze-im-internet.de/milog/__17.html)
+ separately requires covered employers, notably for specified marginal
+ employment and sectors, to record beginning, end and duration by the end of
+ the seventh following calendar day and retain the records for at least two
+ years. Its scope and exemptions matter: this seven-day deadline is not a
+ general deadline for every employment relationship.
+- Automatic break deductions do not prove that breaks were actually taken.
+ The PDF states this limitation; it does not record actual break intervals.
+ Employers remain responsible for accurate, complete records, actual breaks,
+ lawful processing, access and retention. The export is a documentation aid,
+ not a certification of legal compliance or a complete payroll statement.
+ For minimum-wage documentation,
+ [BMAS confirms that signatures are not required](https://www.bmas.de/DE/Arbeit/Arbeitsrecht/Mindestlohn/Dokumentationspflicht/dokumentationspflicht.html).
+ The cited rules prescribe neither PDF/A nor export certification;
+ sector-specific rules and agreements may apply.
+ Legal sources were reviewed on 22 October 2026.
+
+## Upgrade and compatibility
+
+- Upgrading from 2.1.0 adds no database migrations and requires no seed, reset,
+ new secret or change to the working timezone. Preserve existing data,
+ attachment volumes, configuration and historical migrations. When skipping
+ older releases, follow their intervening requirements, including the 2.1.0
+ PIN migration. See the
+ [upgrade guide](https://github.com/patrickschiller/openclockwork/blob/v2.2.0/UPGRADING.md).
+- Deploy matching API and web images. If deployed separately, update the API
+ first, wait for `/api/health` to report `2.2.0`, then deploy the web image.
+ Reopen existing browser/PWA tabs to load the new export controls. The API
+ contract and generated web client include the PDF endpoint; existing CSV
+ exports, working-time accounts, approvals and break policies remain supported.
+- The PIN/QR functionality introduced in 2.1.0 and personnel-data access controls
+ introduced in 2.0.1 are retained. PIN deployment still requires its independent
+ secrets, restricted runtime role and operator-managed audit retention.
+
+## Docker images
+
+- `ghcr.io/patrickschiller/openclockwork-api:2.2.0`
+- `ghcr.io/patrickschiller/openclockwork-web:2.2.0`
+
+The release workflow also updates each image's `latest` tag. Pin `2.2.0` or a
+verified digest for production and use the same release for both images.
+
# OpenClockwork v2.1.0
## PIN tablet terminals
diff --git a/RELEASING.md b/RELEASING.md
index 755f353..68214ea 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -25,15 +25,18 @@ public-contract impact. Use matching metadata in five places:
5. Run `RELEASE_TAG=vMAJOR.MINOR.PATCH pnpm release:verify-tag` and the full CI
suite.
-For 2.1.0, include the additive PIN migration and matching QR/PIN API contract,
-upgrade instructions, role-setup tools and audit verification tools. Retain the
-2.0.1 personnel-data access fixes. Verify QR defaults and existing records across
-the upgrade, PIN and credential-access tests, concurrent booking/revocation,
-audit integrity and the separate runtime-role Docker path. Record the accepted
-local user check without claiming an unperformed target-device or external
-archive test. Production PIN activation still requires the operator's protected
-archive and retention configuration; publishing the source and images does not
-provision or certify that deployment.
+For 2.2.0, include the individual and bulk HR working-time PDF export, matching
+OpenAPI contract and client, legal context and upgrade instructions. Verify HR
+authorization and organization scope, selected/all employees including inactive
+employees with historical records, pending/open bookings, historical break-rule
+snapshots, second precision, overnight work and daylight-saving transitions.
+Render representative PDFs and inspect pagination, employee boundaries, totals
+and German/English text. Use synthetic data for public examples and attachments.
+There are no new migrations relative to 2.1.0; retain the PIN migration and all
+earlier security fixes. State the scoped German retention and recording rules
+without presenting the PDF as a legal certification. Production PIN activation
+still requires the operator's protected archive and retention configuration;
+publishing the source and images does not provision or certify that deployment.
## Publish
@@ -43,8 +46,8 @@ annotated tag on the exact merge commit and push it:
```bash
git switch main
git pull --ff-only
-git tag -a v2.1.0 -m "OpenClockwork v2.1.0"
-git push origin v2.1.0
+git tag -a v2.2.0 -m "OpenClockwork v2.2.0"
+git push origin v2.2.0
```
The release workflow verifies the tag and notes, runs Nx and API end-to-end
@@ -60,8 +63,8 @@ pulled anonymously by self-hosted installations.
## Verify the published release
```bash
-docker pull ghcr.io/patrickschiller/openclockwork-api:2.1.0
-docker pull ghcr.io/patrickschiller/openclockwork-web:2.1.0
+docker pull ghcr.io/patrickschiller/openclockwork-api:2.2.0
+docker pull ghcr.io/patrickschiller/openclockwork-web:2.2.0
```
Confirm that the GitHub Release is marked latest, contains the curated upgrade
diff --git a/ROADMAP.md b/ROADMAP.md
index 19c02ff..e32cec4 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,9 +1,10 @@
# OpenClockwork Roadmap
OpenClockwork is a public, Apache-2.0 self-hosted application for personal work
-and Team time and attendance. Version **2.1.0** adds PIN tablet terminals to
-Team, building on the Solo/Team workflows from 2.0 and the access-control fixes
-from 2.0.1. Customer invoicing and coding-agent usage accounting remain
+and Team time and attendance. Version **2.2.0** adds HR working-time PDF exports
+for one or several employees, building on PIN tablet terminals from 2.1.0,
+Solo/Team workflows from 2.0 and the access-control fixes from 2.0.1.
+Customer invoicing and coding-agent usage accounting remain
future work; a billable time flag does not mean those features already exist.
This roadmap expresses priorities and intended scope, not release dates or
@@ -15,7 +16,7 @@ boundaries, and [README.md](README.md) for release installation.
| Stage | Scope | Status / dependency |
| ----- | --------------------------------------------------- | --------------------------------------- |
-| 1 | Country-neutral configuration and defaults | Included in 1.4.0; retained in 2.1.0 |
+| 1 | Country-neutral configuration and defaults | Included in 1.4.0; retained in 2.2.0 |
| 2 | Complete personal Solo workflow alongside Team | Included in 2.0.0 |
| 3 | QR/PIN tablet terminals and HR authentication audit | Included in 2.1.0; Team only |
| 4 | Optional RFID terminal extension | Planned separately after PIN acceptance |
@@ -178,7 +179,7 @@ customer invoice from tracked work and follow it through payment or correction.
[CAUR — Coding Agent Usage Record](https://github.com/patrickschiller/caur)
defines a vendor-neutral usage record for a completed or interrupted coding-agent
run. It is a separate specification project, not an invoice format or pricing
-catalogue, and is not imported by OpenClockwork 2.1.0. A future integration should
+catalogue, and is not imported by OpenClockwork 2.2.0. A future integration should
consume explicitly supported schema versions and provide attribution, review,
and billing around them. Its design should follow the
[CAUR specification](https://github.com/patrickschiller/caur/blob/main/SPEC.md)
diff --git a/UPGRADING.md b/UPGRADING.md
index 99fc404..073779d 100644
--- a/UPGRADING.md
+++ b/UPGRADING.md
@@ -25,6 +25,32 @@ Compose from managing their deletion, but this does not protect against manual
volume removal, pruning unused volumes, Docker data loss or host failure. Do
not replace a populated installation's volume with a new name during an upgrade.
+## Required for 2.2.0
+
+Version 2.2.0 adds HR working-time PDF exports for one, selected, or all
+employees alongside CSV. Upgrading from 2.1.0 adds **no database migrations** and
+requires no seed, reset, new secret or timezone change. Preserve the existing
+database, attachment volumes, configuration and all applied migration files.
+Take a consistent backup and deploy matching 2.2.0 API/web images. If upgrading
+from an older version, also follow every intervening requirement below,
+including the 2.1.0 PIN migration where not yet applied.
+
+Deploy the API first when components are updated separately; wait for
+`/api/health` to report `2.2.0`, then deploy the web image. Reopen browser/PWA
+tabs to load the new PDF controls. Custom clients can use the updated
+[OpenAPI contract](apps/api/openapi.json); the PDF endpoint requires HR access.
+Verify exports for an individual and a selection of employees, employee page
+boundaries, totals and the configured working timezone before sharing reports.
+
+Review the [German export guide](docs/WORKING_TIME_EXPORTS.de.md) before using
+the PDF as a working-time record. Automatic break deductions do not prove
+actual breaks, pending bookings remain provisional, and overnight bookings are
+assigned to their start day. A downloaded PDF alone does not establish legal
+compliance or fulfill an installation's retention obligations. Combined PDFs
+contain personal data from several employees and need appropriate access and
+sharing controls. Existing CSV exports, working-time policies, approvals,
+accounts, QR/PIN support and personnel-data access restrictions are retained.
+
## Required for 2.1.0
Version 2.1.0 adds personal-number/PIN identification to paired Team terminals.
@@ -83,7 +109,7 @@ existing installations is recommended. From 2.0.0,
this update adds no database migrations and requires no seed or reset.
Keep the existing data, volumes, configuration and working timezone, take a
consistent backup, and deploy matching API/web versions. These access changes
-also apply when upgrading directly to 2.1.0; follow its additional requirements
+also apply when upgrading directly to 2.2.0; follow the additional requirements
above and the deployment procedure below.
Custom API clients must handle `401` and `403` responses and the scoped
@@ -387,7 +413,7 @@ runs in a non-production runtime; the API retains its production runtime.
## 2. Select and pull the release
Set `OPENCLOCKWORK_VERSION` in `.env.prod` to the exact version from the GitHub
-Release, **`2.1.0` for this upgrade**, only after preserving the old configuration
+Release, **`2.2.0` for this upgrade**, only after preserving the old configuration
in the backup set. Pin matching API/web versions or verified digests. Do not use
`latest`, a mutable `:local` image, or a source-build override for an unattended
production upgrade. Keep the same PostgreSQL major version; a PostgreSQL major
@@ -414,7 +440,7 @@ migrations and runtime grants; the API waits for its successful completion.
Prisma records applied migrations in `_prisma_migrations` and skips them on
subsequent starts. Production startup never seeds or resets the database.
If API/web are deployed independently, deploy the API first, wait for its
-health endpoint to report `2.1.0`, then deploy the matching web image. The web
+health endpoint to report `2.2.0`, then deploy the matching web image. The web
startup version check is not a promise of zero-downtime mixed-version operation;
keep additional writers paused until verification completes.
@@ -451,7 +477,7 @@ oc run --rm --no-deps -T migrate ./node_modules/.bin/prisma migrate status
The PIN runtime role deliberately has no access to `_prisma_migrations`; do not
broaden its permissions to run this maintenance check from the API container.
-Confirm `2.1.0` through the public web/proxy health route as well as the
+Confirm `2.2.0` through the public web/proxy health route as well as the
container check. Sign in and verify the actual deployed mode, a known employee,
an existing time entry, historic break totals, leave balances and configured
attachment storage. A 1.4.0 upgrade must still be Team unless an administrator
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 078ad3c..b8aa6e1 100644
--- a/apps/api-e2e/src/api/international-policies.e2e.spec.ts
+++ b/apps/api-e2e/src/api/international-policies.e2e.spec.ts
@@ -221,7 +221,15 @@ describe('International calendars and configurable break policies', () => {
)
.set('Authorization', `Bearer ${hrToken}`)
.expect(200);
- expect(report.body.totals).toEqual(closed.body.summary);
+ // The report preserves timestamp precision; account/ERP summaries keep
+ // 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,
+ });
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/working-time-reports.e2e.spec.ts b/apps/api-e2e/src/api/working-time-reports.e2e.spec.ts
index 107943e..27efd7f 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
@@ -1,3 +1,4 @@
+import { randomUUID } from 'node:crypto';
import {
createTestApp,
login,
@@ -193,4 +194,250 @@ describe('Project-independent working-time reports', () => {
.set('Authorization', `Bearer ${employeeToken}`)
.expect(403);
});
+
+ it('filters the JSON report to an explicit subset of employees', async () => {
+ const { hr, manager, report, foreign } = await fixture();
+ const token = await login(ctx.http, hr.email);
+ await ctx.prisma.timeEntry.createMany({
+ data: [manager, report, foreign].map((employee) => ({
+ employeeId: employee.id,
+ clockIn: new Date('2026-08-10T07:00:00.000Z'),
+ clockOut: new Date('2026-08-10T08:00:00.000Z'),
+ status: 'Approved',
+ })),
+ });
+
+ const selected = await ctx.http
+ .get(
+ `/api/reports/working-times?from=2026-08-01&to=2026-08-31&employeeIds=${report.id}&employeeIds=${foreign.id}`,
+ )
+ .set('Authorization', `Bearer ${token}`)
+ .expect(200);
+ expect(selected.body.rows).toHaveLength(2);
+ expect(
+ new Set(
+ selected.body.rows.map((row: { employeeId: string }) => row.employeeId),
+ ),
+ ).toEqual(new Set([report.id, foreign.id]));
+ expect(selected.body.totals).toEqual({
+ grossMinutes: 120,
+ breakMinutes: 0,
+ netMinutes: 120,
+ });
+
+ const one = await ctx.http
+ .get(
+ `/api/reports/working-times?from=2026-08-01&to=2026-08-31&employeeIds=${report.id}`,
+ )
+ .set('Authorization', `Bearer ${token}`)
+ .expect(200);
+ expect(one.body.rows).toHaveLength(1);
+ expect(one.body.rows[0].employeeId).toBe(report.id);
+ });
+
+ it('retains seconds in ungrouped report durations and preserves captured breaks', async () => {
+ const { hr, report } = await fixture();
+ const token = await login(ctx.http, hr.email);
+ await ctx.prisma.timeEntry.create({
+ data: {
+ employeeId: report.id,
+ clockIn: new Date('2026-08-10T07:00:01.000Z'),
+ clockOut: new Date('2026-08-10T15:00:59.000Z'),
+ breakRules: [{ afterMinutes: 360, breakMinutes: 30 }],
+ status: 'Approved',
+ },
+ });
+
+ const result = await ctx.http
+ .get(
+ `/api/reports/working-times?from=2026-08-10&to=2026-08-10&employeeId=${report.id}`,
+ )
+ .set('Authorization', `Bearer ${token}`)
+ .expect(200);
+ expect(result.body.rows).toHaveLength(1);
+ expect(result.body.rows[0]).toMatchObject({
+ clockIn: '2026-08-10T07:00:01.000Z',
+ clockOut: '2026-08-10T15:00:59.000Z',
+ breakMinutes: 30,
+ });
+ expect(result.body.rows[0].grossMinutes).toBeCloseTo(480 + 58 / 60, 8);
+ expect(result.body.rows[0].netMinutes).toBeCloseTo(450 + 58 / 60, 8);
+ expect(result.body.totals.grossMinutes).toBeCloseTo(480 + 58 / 60, 8);
+ expect(result.body.totals.breakMinutes).toBe(30);
+ expect(result.body.totals.netMinutes).toBeCloseTo(450 + 58 / 60, 8);
+ });
+
+ it('preserves capture-group break allocation when a sibling falls outside the selected dates', async () => {
+ const { hr, report } = await fixture();
+ const token = await login(ctx.http, hr.email);
+ const common = {
+ employeeId: report.id,
+ captureGroupId: randomUUID(),
+ breakRules: [{ afterMinutes: 360, breakMinutes: 30 }],
+ status: 'Approved' as const,
+ };
+ await ctx.prisma.timeEntry.createMany({
+ data: [
+ {
+ ...common,
+ clockIn: new Date('2026-08-10T18:00:00.000Z'),
+ clockOut: new Date('2026-08-10T22:00:00.000Z'),
+ },
+ {
+ ...common,
+ clockIn: new Date('2026-08-11T06:00:00.000Z'),
+ clockOut: new Date('2026-08-11T10:00:00.000Z'),
+ },
+ {
+ ...common,
+ clockIn: new Date('2026-08-12T06:00:00.000Z'),
+ clockOut: new Date('2026-08-12T10:00:00.000Z'),
+ status: 'Rejected',
+ },
+ {
+ ...common,
+ clockIn: new Date('2026-08-13T06:00:00.000Z'),
+ clockOut: new Date('2026-08-13T10:00:00.000Z'),
+ voidedAt: new Date(),
+ },
+ ],
+ });
+
+ for (const to of ['2026-08-10', '2026-08-11']) {
+ const result = await ctx.http
+ .get(
+ `/api/reports/working-times?from=2026-08-10&to=${to}&employeeId=${report.id}`,
+ )
+ .set('Authorization', `Bearer ${token}`)
+ .expect(200);
+ const count = to === '2026-08-10' ? 1 : 2;
+ expect(result.body.rows).toHaveLength(count);
+ for (const row of result.body.rows) {
+ expect(row).toMatchObject({
+ grossMinutes: 240,
+ breakMinutes: 15,
+ netMinutes: 225,
+ });
+ }
+ expect(result.body.totals).toEqual({
+ grossMinutes: 240 * count,
+ breakMinutes: 15 * count,
+ netMinutes: 225 * count,
+ });
+ }
+ });
+
+ it('downloads PDF attachments for single, selected and all employees in both languages', async () => {
+ const { hr, manager, report, foreign } = await fixture();
+ const token = await login(ctx.http, hr.email);
+ await ctx.prisma.employee.update({
+ where: { id: foreign.id },
+ data: { isActive: false },
+ });
+ await ctx.prisma.timeEntry.createMany({
+ data: [
+ {
+ employeeId: report.id,
+ clockIn: new Date('2026-08-10T07:00:00.000Z'),
+ clockOut: new Date('2026-08-10T15:00:00.000Z'),
+ breakRules: [{ afterMinutes: 360, breakMinutes: 30 }],
+ status: 'Approved',
+ note: 'Private note not intended for a working-time PDF',
+ terminalLocationLabel: 'Private location',
+ },
+ {
+ employeeId: foreign.id,
+ clockIn: new Date('2026-08-10T07:00:00.000Z'),
+ clockOut: new Date('2026-08-10T08:00:00.000Z'),
+ status: 'Pending',
+ },
+ {
+ employeeId: report.id,
+ clockIn: new Date('2026-08-11T07:00:00.000Z'),
+ clockOut: null,
+ status: 'Open',
+ },
+ ],
+ });
+
+ for (const filter of [
+ `&employeeId=${report.id}`,
+ `&employeeIds=${report.id}&employeeIds=${foreign.id}&locale=en`,
+ `&employeeIds=${foreign.id}&locale=de`,
+ `&employeeId=${manager.id}`,
+ '',
+ ]) {
+ const result = await ctx.http
+ .get(
+ `/api/reports/working-times.pdf?from=2026-08-01&to=2026-08-31${filter}`,
+ )
+ .set('Authorization', `Bearer ${token}`)
+ .buffer(true)
+ .parse((res, callback) => {
+ const chunks: Buffer[] = [];
+ res.on('data', (chunk: Buffer) => chunks.push(chunk));
+ res.on('end', () => callback(null, Buffer.concat(chunks)));
+ res.on('error', callback);
+ })
+ .expect(200);
+ expect(result.headers['content-type']).toContain('application/pdf');
+ expect(result.headers['content-disposition']).toBe(
+ 'attachment; filename="openclockwork-working-times-2026-08-01-2026-08-31.pdf"',
+ );
+ expect(result.headers['cache-control']).toContain('no-store');
+ expect(result.headers['x-content-type-options']).toBe('nosniff');
+ expect(Buffer.isBuffer(result.body)).toBe(true);
+ expect((result.body as Buffer).subarray(0, 5).toString()).toBe('%PDF-');
+ expect((result.body as Buffer).subarray(-20).toString()).toContain(
+ '%%EOF',
+ );
+ }
+ });
+
+ it('restricts PDF downloads to authenticated HR administrators', async () => {
+ const { manager, report } = await fixture();
+ const url = '/api/reports/working-times.pdf?from=2026-08-01&to=2026-08-31';
+ await ctx.http.get(url).expect(401);
+ for (const employee of [manager, report]) {
+ const token = await login(ctx.http, employee.email);
+ await ctx.http
+ .get(url)
+ .set('Authorization', `Bearer ${token}`)
+ .expect(403);
+ }
+ });
+
+ it('validates PDF date ranges, employee selections and locale', async () => {
+ const { hr, report, foreign } = await fixture();
+ const token = await login(ctx.http, hr.email);
+ const validDates = 'from=2026-08-01&to=2026-08-31';
+ const invalidQueries = [
+ 'from=2026-02-31&to=2026-03-01',
+ 'from=2026-08-31&to=2026-08-01',
+ 'from=2025-01-01&to=2026-12-31',
+ 'to=2026-08-31',
+ `${validDates}&employeeId=invalid`,
+ `${validDates}&employeeIds=${report.id}&employeeIds=invalid`,
+ `${validDates}&employeeId=${report.id}&employeeIds=${foreign.id}`,
+ `${validDates}&employeeIds=${report.id}&employeeIds=${report.id}`,
+ `${validDates}&employeeIds=${randomUUID()}`,
+ `${validDates}&locale=fr`,
+ ];
+ for (const query of invalidQueries) {
+ await ctx.http
+ .get(`/api/reports/working-times.pdf?${query}`)
+ .set('Authorization', `Bearer ${token}`)
+ .expect(400);
+ }
+
+ for (const filter of [
+ `employeeId=${report.id}&employeeIds=${foreign.id}`,
+ `employeeIds=${report.id}&employeeIds=invalid`,
+ ]) {
+ await ctx.http
+ .get(`/api/reports/working-times?${validDates}&${filter}`)
+ .set('Authorization', `Bearer ${token}`)
+ .expect(400);
+ }
+ });
});
diff --git a/apps/api/openapi.json b/apps/api/openapi.json
index d4e214e..1a60945 100644
--- a/apps/api/openapi.json
+++ b/apps/api/openapi.json
@@ -1856,6 +1856,18 @@
"type": "string"
}
},
+ {
+ "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.",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
{
"name": "includeLocations",
"required": false,
@@ -1886,6 +1898,83 @@
"tags": ["reports"]
}
},
+ "/api/reports/working-times.pdf": {
+ "get": {
+ "operationId": "ReportsController_workingTimesPdf",
+ "parameters": [
+ {
+ "name": "from",
+ "required": true,
+ "in": "query",
+ "schema": {
+ "format": "date",
+ "example": "2026-08-01",
+ "type": "string"
+ }
+ },
+ {
+ "name": "to",
+ "required": true,
+ "in": "query",
+ "schema": {
+ "format": "date",
+ "example": "2026-08-31",
+ "type": "string"
+ }
+ },
+ {
+ "name": "employeeId",
+ "required": false,
+ "in": "query",
+ "schema": {
+ "format": "uuid",
+ "type": "string"
+ }
+ },
+ {
+ "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.",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ {
+ "name": "locale",
+ "required": false,
+ "in": "query",
+ "schema": {
+ "default": "de",
+ "type": "string",
+ "enum": ["de", "en"]
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Working-time PDF for one, selected or all employees, with separate employee sections, booking-day totals, timezone and policy-based break deductions. Excludes locations and private notes.",
+ "content": {
+ "application/pdf": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": ["reports"]
+ }
+ },
"/api/timeentries": {
"get": {
"operationId": "TimeEntriesController_list",
@@ -4169,7 +4258,7 @@
"info": {
"title": "OpenClockwork API",
"description": "Self-hostable working-time tracker — REST + WebSocket surface.",
- "version": "2.1.0",
+ "version": "2.2.0",
"contact": {}
},
"tags": [],
@@ -4990,7 +5079,7 @@
},
"version": {
"type": "string",
- "example": "2.1.0"
+ "example": "2.2.0"
},
"utcTimestamp": {
"type": "string",
diff --git a/apps/api/package.json b/apps/api/package.json
index 5e7bd18..a39d5b1 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -96,6 +96,7 @@
"class-validator": "*",
"passport": "*",
"passport-jwt": "*",
+ "pdfkit": "^0.17.2",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0",
"shared": "workspace:*",
diff --git a/apps/api/src/app/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts
index d0da296..4cc5607 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: '2.1.0' })
+ @ApiProperty({ example: '2.2.0' })
version!: string;
@ApiProperty({ format: 'date-time' })
diff --git a/apps/api/src/app/reports/reports.controller.ts b/apps/api/src/app/reports/reports.controller.ts
index 7677f37..4cf0e10 100644
--- a/apps/api/src/app/reports/reports.controller.ts
+++ b/apps/api/src/app/reports/reports.controller.ts
@@ -8,6 +8,7 @@ import {
WorkingTimeReportDto,
WorkingTimeReportEmployeeDto,
WorkingTimeReportQueryDto,
+ WorkingTimePdfQueryDto,
} from './reports.dto';
import { ReportsService } from './reports.service';
import { CurrentUser } from '../auth/current-user.decorator';
@@ -65,4 +66,27 @@ export class ReportsController {
): Promise {
return this.reports.workingTimes(query);
}
+
+ @Get('working-times.pdf')
+ @ApiOkResponse({
+ description:
+ 'Working-time PDF for one, selected or all employees, with separate employee sections, booking-day totals, timezone and policy-based break deductions. Excludes locations and private notes.',
+ content: {
+ 'application/pdf': { schema: { type: 'string', format: 'binary' } },
+ },
+ })
+ async workingTimesPdf(
+ @Query() query: WorkingTimePdfQueryDto,
+ @Res() response: Response,
+ ): Promise {
+ const pdf = await this.reports.workingTimesPdf(query);
+ response.setHeader('Content-Type', 'application/pdf');
+ response.setHeader(
+ 'Content-Disposition',
+ `attachment; filename="openclockwork-working-times-${query.from}-${query.to}.pdf"`,
+ );
+ response.setHeader('Cache-Control', 'private, no-store');
+ response.setHeader('X-Content-Type-Options', 'nosniff');
+ response.send(pdf);
+ }
}
diff --git a/apps/api/src/app/reports/reports.dto.ts b/apps/api/src/app/reports/reports.dto.ts
index 2b3f712..d953fcf 100644
--- a/apps/api/src/app/reports/reports.dto.ts
+++ b/apps/api/src/app/reports/reports.dto.ts
@@ -1,5 +1,15 @@
-import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
-import { IsIn, IsOptional, IsUUID, Matches } from 'class-validator';
+import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger';
+import { Transform } from 'class-transformer';
+import {
+ ArrayMaxSize,
+ ArrayMinSize,
+ ArrayUnique,
+ IsArray,
+ IsIn,
+ IsOptional,
+ IsUUID,
+ Matches,
+} from 'class-validator';
const DATE_ONLY_PATTERN = '^\\d{4}-\\d{2}-\\d{2}$';
@@ -17,12 +27,36 @@ export class WorkingTimeReportQueryDto {
@IsUUID()
employeeId?: string;
+ @ApiPropertyOptional({
+ type: [String],
+ description:
+ 'Repeat employeeIds for a subset (maximum 200). Omit both employee filters for all employees; cannot be combined with employeeId.',
+ })
+ @IsOptional()
+ @Transform(({ value }) => (Array.isArray(value) ? value : [value]))
+ @IsArray()
+ @ArrayMinSize(1)
+ @ArrayMaxSize(200)
+ @ArrayUnique()
+ @IsUUID('all', { each: true })
+ employeeIds?: string[];
+
@ApiPropertyOptional({ type: Boolean, default: false })
@IsOptional()
@IsIn(['true', 'false'])
includeLocations?: string;
}
+export class WorkingTimePdfQueryDto extends OmitType(
+ WorkingTimeReportQueryDto,
+ ['includeLocations'] as const,
+) {
+ @ApiPropertyOptional({ enum: ['de', 'en'], default: 'de' })
+ @IsOptional()
+ @IsIn(['de', 'en'])
+ locale?: 'de' | 'en';
+}
+
export class WorkingTimeReportEmployeeDto {
@ApiProperty({ format: 'uuid' })
id!: string;
diff --git a/apps/api/src/app/reports/reports.service.ts b/apps/api/src/app/reports/reports.service.ts
index 0ab9656..f60d2d8 100644
--- a/apps/api/src/app/reports/reports.service.ts
+++ b/apps/api/src/app/reports/reports.service.ts
@@ -1,5 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
-import { summarize, parseBreakRules } from 'shared';
import { PrismaService } from '../prisma/prisma.service';
import { Prisma } from '@prisma/client';
import { InstallationService } from '../installation/installation.service';
@@ -8,10 +7,12 @@ import {
type WorkingTimeReportEmployeeDto,
type WorkingTimeReportDto,
type WorkingTimeReportQueryDto,
+ type WorkingTimePdfQueryDto,
type SoloReportQueryDto,
type SoloReportDto,
type SoloReportRowDto,
} from './reports.dto';
+import { renderWorkingTimePdf } from './working-time-pdf';
const DAY_MS = 86_400_000;
const MAX_REPORT_DAYS = 366;
@@ -272,9 +273,6 @@ export class ReportsService {
async workingTimeEmployees(): Promise {
return this.prisma.employee.findMany({
- where: {
- isActive: true,
- },
select: { id: true, firstName: true, lastName: true },
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
});
@@ -283,6 +281,78 @@ export class ReportsService {
async workingTimes(
query: WorkingTimeReportQueryDto,
): Promise {
+ return this.prisma.$transaction((tx) => this.buildWorkingTimes(query, tx), {
+ isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,
+ });
+ }
+
+ async workingTimesPdf(query: WorkingTimePdfQueryDto): Promise {
+ const input = await this.prisma.$transaction(
+ async (tx) => {
+ const report = await this.buildWorkingTimes(
+ { ...query, includeLocations: 'false' },
+ tx,
+ );
+ const from = parseLocalDay(query.from).date;
+ const to = parseLocalDay(query.to).date;
+ to.setDate(to.getDate() + 1);
+ const selectedIds = query.employeeId
+ ? [query.employeeId]
+ : query.employeeIds;
+ const periodEntries: Prisma.TimeEntryWhereInput = {
+ clockIn: { gte: from, lt: to },
+ voidedAt: null,
+ status: { not: 'Rejected' },
+ };
+ const employees = await tx.employee.findMany({
+ where: selectedIds
+ ? { id: { in: selectedIds } }
+ : { timeEntries: { some: periodEntries } },
+ select: {
+ id: true,
+ personalNo: true,
+ firstName: true,
+ lastName: true,
+ _count: {
+ select: {
+ timeEntries: {
+ where: { ...periodEntries, clockOut: null },
+ },
+ },
+ },
+ },
+ orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }, { id: 'asc' }],
+ });
+ if (selectedIds && employees.length !== selectedIds.length) {
+ throw new BadRequestException('One or more employees do not exist');
+ }
+ return {
+ from: report.from,
+ to: report.to,
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ generatedAt: new Date(),
+ locale: query.locale ?? 'de',
+ employees: employees.map(({ _count, ...employee }) => ({
+ ...employee,
+ openEntryCount: _count.timeEntries,
+ rows: report.rows.filter((row) => row.employeeId === employee.id),
+ })),
+ };
+ },
+ { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead },
+ );
+ return renderWorkingTimePdf(input);
+ }
+
+ private async buildWorkingTimes(
+ query: WorkingTimeReportQueryDto,
+ tx: Prisma.TransactionClient,
+ ): Promise {
+ if (query.employeeId && query.employeeIds) {
+ throw new BadRequestException(
+ 'employeeId and employeeIds cannot be combined',
+ );
+ }
const includeLocations = query.includeLocations === 'true';
const from = parseLocalDay(query.from);
const to = parseLocalDay(query.to);
@@ -303,9 +373,11 @@ export class ReportsService {
to.date.getDate() + 1,
);
- const entries = await this.prisma.timeEntry.findMany({
+ const entries = await tx.timeEntry.findMany({
where: {
- employeeId: query.employeeId,
+ employeeId:
+ query.employeeId ??
+ (query.employeeIds ? { in: query.employeeIds } : undefined),
clockIn: { gte: from.date, lt: toExclusive },
clockOut: { not: null },
status: { not: 'Rejected' },
@@ -315,38 +387,68 @@ export class ReportsService {
include: { employee: { select: { firstName: true, lastName: true } } },
});
- const rows = entries.map((entry) => {
- const summary = summarize(
- entry.clockIn,
- entry.clockOut as Date,
- parseBreakRules(entry.breakRules),
- );
- return {
- 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(),
- 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,
- };
+ const groupIds = [
+ ...new Set(
+ entries.flatMap((entry) =>
+ entry.captureGroupId ? [entry.captureGroupId] : [],
+ ),
+ ),
+ ];
+ const siblings = groupIds.length
+ ? await tx.timeEntry.findMany({
+ where: {
+ captureGroupId: { in: groupIds },
+ employeeId: {
+ in: [...new Set(entries.map((entry) => entry.employeeId))],
+ },
+ voidedAt: null,
+ status: { not: 'Rejected' },
+ clockOut: { not: null },
+ },
+ })
+ : [];
+ const summaries = calculateCaptureSummaries([
+ ...new Map(
+ [...entries, ...siblings].map((entry) => [entry.id, entry]),
+ ).values(),
+ ]);
+ const rows = entries.flatMap((entry) => {
+ 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 [
+ {
+ 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(),
+ 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,
+ grossMinutes,
+ netMinutes: grossMinutes - summary.breakMinutes,
+ },
+ ];
});
return {
diff --git a/apps/api/src/app/reports/working-time-pdf.ts b/apps/api/src/app/reports/working-time-pdf.ts
new file mode 100644
index 0000000..9319041
--- /dev/null
+++ b/apps/api/src/app/reports/working-time-pdf.ts
@@ -0,0 +1,464 @@
+import { existsSync } from 'node:fs';
+import { join } from 'node:path';
+import PDFDocument from 'pdfkit';
+import type { WorkingTimeReportRowDto } from './reports.dto';
+
+export interface WorkingTimePdfEmployee {
+ id: string;
+ personalNo: string;
+ firstName: string;
+ lastName: string;
+ openEntryCount: number;
+ rows: WorkingTimeReportRowDto[];
+}
+
+export interface WorkingTimePdfInput {
+ from: string;
+ to: string;
+ timeZone: string;
+ generatedAt: Date;
+ locale: 'de' | 'en';
+ employees: WorkingTimePdfEmployee[];
+}
+
+const labels = {
+ de: {
+ title: 'Arbeitszeitnachweis',
+ personalNo: 'Personalnummer',
+ employeeId: 'Mitarbeiter-ID',
+ period: 'Zeitraum',
+ timeZone: 'Zeitzone',
+ generated: 'Erstellt',
+ bookingDay: 'Buchungstag\nnach Beginn',
+ start: 'Beginn\nDatum, Uhrzeit, UTC-Offset',
+ end: 'Ende\nDatum, Uhrzeit, UTC-Offset',
+ gross: 'Brutto\nh:mm:ss',
+ break: 'Pausenabzug\nh:mm:ss',
+ net: 'Netto\nh:mm:ss',
+ status: 'Status',
+ dayTotal: 'Tagessumme',
+ periodTotal: 'Zeitraumsumme',
+ noEntries: 'Keine abgeschlossenen Buchungen im gewählten Zeitraum.',
+ noEmployees: 'Keine Mitarbeiter:innen für diesen Export vorhanden.',
+ openWarning:
+ 'Nicht enthalten: {count} laufende Buchung(en) ohne Ende. Sie fehlen auch in den Summen.',
+ provisionalWarning:
+ 'Vorläufig: {count} abgeschlossene Buchung(en) sind noch nicht genehmigt und in den Summen enthalten.',
+ intervalNote:
+ 'Zuordnung nach dem lokalen Tag des Beginns. Intervalle über Mitternacht bleiben vollständig erhalten; sie werden nicht an den Zeitraumgrenzen gekürzt. Abgelehnte und stornierte Buchungen sind ausgeschlossen.',
+ breakNote:
+ 'Pausenabzüge werden automatisch nach den konfigurierten Regeln berechnet; sie belegen keine tatsächlichen Pausenzeiten. Dauerangaben in h:mm:ss, auf Sekunden gerundet. Pausenabzug = angezeigte Brutto- minus Nettozeit; Summen aus den gerundeten Werten.',
+ pending: 'Ausstehend (vorläufig)',
+ open: 'Offen (vorläufig)',
+ approved: 'Genehmigt',
+ page: 'Seite',
+ of: 'von',
+ },
+ en: {
+ title: 'Working time record',
+ personalNo: 'Personnel number',
+ employeeId: 'Employee ID',
+ period: 'Period',
+ timeZone: 'Time zone',
+ generated: 'Generated',
+ bookingDay: 'Booking day\nby start',
+ start: 'Start\nDate, time, UTC offset',
+ end: 'End\nDate, time, UTC offset',
+ gross: 'Gross\nh:mm:ss',
+ break: 'Break deduction\nh:mm:ss',
+ net: 'Net\nh:mm:ss',
+ status: 'Status',
+ dayTotal: 'Day total',
+ periodTotal: 'Period total',
+ noEntries: 'No closed entries in the selected period.',
+ noEmployees: 'No employees available for this export.',
+ openWarning:
+ 'Excluded: {count} running entry/entries without an end. They are also excluded from totals.',
+ provisionalWarning:
+ 'Provisional: {count} closed entry/entries are not yet approved and are included in totals.',
+ intervalNote:
+ 'Assigned by the local start day. Overnight intervals remain complete and are not clipped at the period boundaries. Rejected and voided entries are excluded.',
+ breakNote:
+ 'Break deductions are calculated automatically from the configured rules; they do not document actual break times. Durations use h:mm:ss, rounded to seconds. Break deduction = displayed gross minus net; totals sum these rounded values.',
+ pending: 'Pending (provisional)',
+ open: 'Open (provisional)',
+ approved: 'Approved',
+ page: 'Page',
+ of: 'of',
+ },
+} as const;
+
+const MARGIN = 32;
+const CELL_PADDING = 6;
+const ROW_HEIGHT = 32;
+const TOTAL_HEIGHT = 23;
+const TEXT_COLOR = '#172033';
+const MUTED_COLOR = '#526076';
+const BORDER_COLOR = '#d5dce5';
+
+function fontPath(fileName: string): string | undefined {
+ return [
+ join(__dirname, 'assets/fonts', fileName),
+ join(__dirname, '../../assets/fonts', fileName),
+ ].find((candidate) => existsSync(candidate));
+}
+
+function seconds(minutes: number): number {
+ return Math.max(0, Math.round(minutes * 60));
+}
+
+function rowAmounts(row: WorkingTimeReportRowDto): [number, number, number] {
+ const gross = seconds(row.grossMinutes);
+ const net = seconds(row.netMinutes);
+ return [gross, gross - net, net];
+}
+
+function duration(totalSeconds: number): string {
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${(totalSeconds % 60)
+ .toString()
+ .padStart(2, '0')}`;
+}
+
+function calendarDate(
+ value: string,
+ locale: WorkingTimePdfInput['locale'],
+): string {
+ const [year, month, day] = value.split('-');
+ return locale === 'de' ? `${day}.${month}.${year}` : value;
+}
+
+function identityText(value: string): string {
+ return value.replace(/\s+/gu, ' ').trim();
+}
+
+function localTimestamp(value: Date, timeZone: string): [string, string] {
+ const parts = new Intl.DateTimeFormat('en-GB', {
+ timeZone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hourCycle: 'h23',
+ timeZoneName: 'longOffset',
+ }).formatToParts(value);
+ const part = (type: Intl.DateTimeFormatPartTypes) =>
+ parts.find((item) => item.type === type)?.value ?? '';
+ return [
+ `${part('year')}-${part('month')}-${part('day')}`,
+ `${part('hour')}:${part('minute')}:${part('second')} ${part('timeZoneName')}`,
+ ];
+}
+
+/** A snapshot of recorded intervals, without location or project data. */
+export async function renderWorkingTimePdf(
+ input: WorkingTimePdfInput,
+): Promise {
+ const regularFont = fontPath('NotoSans-Regular.ttf');
+ if (!regularFont)
+ throw new Error('The bundled working-time PDF font is missing');
+ const boldFont = fontPath('NotoSans-Bold.ttf') ?? regularFont;
+ const text = labels[input.locale];
+ const doc = new PDFDocument({
+ size: 'A4',
+ layout: 'landscape',
+ margin: MARGIN,
+ autoFirstPage: false,
+ bufferPages: true,
+ font: regularFont,
+ info: {
+ Title: text.title,
+ Author: 'OpenClockwork',
+ Subject: `${input.from} - ${input.to} (${input.timeZone})`,
+ CreationDate: input.generatedAt,
+ ModDate: input.generatedAt,
+ },
+ });
+ const result = new Promise((resolve, reject) => {
+ const chunks: Buffer[] = [];
+ doc.on('data', (chunk: Buffer) => chunks.push(chunk));
+ doc.on('end', () => resolve(Buffer.concat(chunks)));
+ doc.on('error', reject);
+ });
+ doc.registerFont('Regular', regularFont);
+ doc.registerFont('Bold', boldFont);
+
+ const pageWidth = 841.89;
+ const contentWidth = pageWidth - MARGIN * 2;
+ const columns = [
+ { label: text.bookingDay, width: 80, align: 'left' },
+ { label: text.start, width: 157, align: 'left' },
+ { label: text.end, width: 157, align: 'left' },
+ { label: text.gross, width: 73, align: 'right' },
+ { label: text.break, width: 87, align: 'right' },
+ { label: text.net, width: 73, align: 'right' },
+ { label: text.status, width: contentWidth - 627, align: 'left' },
+ ] as const;
+ const period = `${calendarDate(input.from, input.locale)} - ${calendarDate(input.to, input.locale)}`;
+ const generated = localTimestamp(input.generatedAt, input.timeZone);
+ const generatedLabel = `${calendarDate(generated[0], input.locale)} ${generated[1]}`;
+ let y = MARGIN;
+
+ const paragraph = (
+ value: string,
+ options: {
+ size?: number;
+ bold?: boolean;
+ color?: string;
+ gap?: number;
+ } = {},
+ ) => {
+ doc
+ .font(options.bold ? 'Bold' : 'Regular')
+ .fontSize(options.size ?? 8)
+ .fillColor(options.color ?? TEXT_COLOR);
+ const height = doc.heightOfString(value, {
+ width: contentWidth,
+ lineGap: 1,
+ });
+ doc.text(value, MARGIN, y, { width: contentWidth, lineGap: 1 });
+ y += height + (options.gap ?? 4);
+ };
+
+ const drawColumns = () => {
+ const headerHeight = 34;
+ doc.rect(MARGIN, y, contentWidth, headerHeight).fill('#e9eef5');
+ let x = MARGIN;
+ for (const column of columns) {
+ doc
+ .font('Bold')
+ .fontSize(7.5)
+ .fillColor(TEXT_COLOR)
+ .text(column.label, x + CELL_PADDING, y + 5, {
+ width: column.width - CELL_PADDING * 2,
+ align: column.align,
+ lineGap: 1,
+ });
+ x += column.width;
+ }
+ y += headerHeight;
+ };
+
+ const startPage = (employee: WorkingTimePdfEmployee, firstPage: boolean) => {
+ doc.addPage();
+ y = MARGIN;
+ paragraph(`OpenClockwork | ${text.title}`, {
+ size: 16,
+ bold: true,
+ gap: 7,
+ });
+ paragraph(identityText(`${employee.firstName} ${employee.lastName}`), {
+ size: 12,
+ bold: true,
+ });
+ paragraph(
+ `${text.personalNo}: ${identityText(employee.personalNo)} | ${text.employeeId}: ${identityText(employee.id)}`,
+ { size: 8 },
+ );
+ paragraph(
+ `${text.period}: ${period} | ${text.timeZone}: ${input.timeZone}`,
+ { size: 8 },
+ );
+ paragraph(`${text.generated}: ${generatedLabel}`, {
+ size: 7.5,
+ color: MUTED_COLOR,
+ gap: 7,
+ });
+ if (employee.openEntryCount > 0) {
+ paragraph(
+ text.openWarning.replace('{count}', String(employee.openEntryCount)),
+ {
+ bold: true,
+ size: 8,
+ color: '#9a3412',
+ gap: 5,
+ },
+ );
+ }
+ const provisional = employee.rows.filter(
+ (row) => row.status !== 'Approved',
+ ).length;
+ if (provisional > 0) {
+ paragraph(
+ text.provisionalWarning.replace('{count}', String(provisional)),
+ {
+ bold: true,
+ size: 8,
+ color: '#854d0e',
+ gap: 5,
+ },
+ );
+ }
+ if (firstPage) {
+ paragraph(text.intervalNote, { size: 7.5, color: MUTED_COLOR });
+ paragraph(text.breakNote, { size: 7.5, color: MUTED_COLOR, gap: 9 });
+ }
+ drawColumns();
+ };
+
+ const ensureSpace = (height: number, employee: WorkingTimePdfEmployee) => {
+ if (y + height > doc.page.height - MARGIN - 26) startPage(employee, false);
+ };
+
+ const totals = (
+ value: string,
+ rows: WorkingTimeReportRowDto[],
+ periodTotal: boolean,
+ ) => {
+ const amounts = rows.reduce(
+ (sum, row) => {
+ const values = rowAmounts(row);
+ return sum.map((amount, index) => amount + values[index]);
+ },
+ [0, 0, 0],
+ );
+ doc
+ .rect(MARGIN, y, contentWidth, TOTAL_HEIGHT)
+ .fill(periodTotal ? '#dbe6f3' : '#f2f5f9');
+ doc
+ .font('Bold')
+ .fontSize(8)
+ .fillColor(TEXT_COLOR)
+ .text(value, MARGIN + CELL_PADDING, y + 6, {
+ width: 394 - CELL_PADDING * 2,
+ });
+ let x = MARGIN + 394;
+ for (let index = 0; index < amounts.length; index += 1) {
+ const width = columns[index + 3].width;
+ doc.text(duration(amounts[index]), x + CELL_PADDING, y + 6, {
+ width: width - CELL_PADDING * 2,
+ align: 'right',
+ });
+ x += width;
+ }
+ y += TOTAL_HEIGHT;
+ };
+
+ for (const employee of input.employees) {
+ startPage(employee, true);
+ const rows = [...employee.rows].sort(
+ (left, right) =>
+ left.date.localeCompare(right.date) ||
+ left.clockIn.localeCompare(right.clockIn) ||
+ left.id.localeCompare(right.id),
+ );
+ if (rows.length === 0) {
+ y += 10;
+ paragraph(text.noEntries, { size: 9, gap: 10 });
+ }
+ let dayRows: WorkingTimeReportRowDto[] = [];
+ rows.forEach((row, index) => {
+ const lastOfDay = rows[index + 1]?.date !== row.date;
+ const finalTotalsHeight =
+ index === rows.length - 1 ? TOTAL_HEIGHT + 6 : 0;
+ ensureSpace(
+ ROW_HEIGHT + (lastOfDay ? TOTAL_HEIGHT : 0) + finalTotalsHeight,
+ employee,
+ );
+ const start = localTimestamp(new Date(row.clockIn), input.timeZone);
+ const end = localTimestamp(new Date(row.clockOut), input.timeZone);
+ const status =
+ row.status === 'Approved'
+ ? text.approved
+ : row.status === 'Pending'
+ ? text.pending
+ : text.open;
+ const values = [
+ calendarDate(row.date, input.locale),
+ `${calendarDate(start[0], input.locale)}\n${start[1]}`,
+ `${calendarDate(end[0], input.locale)}\n${end[1]}`,
+ ...rowAmounts(row).map(duration),
+ status,
+ ];
+ let x = MARGIN;
+ values.forEach((value, columnIndex) => {
+ const column = columns[columnIndex];
+ doc
+ .font('Regular')
+ .fontSize(8)
+ .fillColor(TEXT_COLOR)
+ .text(value, x + CELL_PADDING, y + 5, {
+ width: column.width - CELL_PADDING * 2,
+ align: column.align,
+ lineGap: 1,
+ });
+ x += column.width;
+ });
+ y += ROW_HEIGHT;
+ doc
+ .moveTo(MARGIN, y)
+ .lineTo(MARGIN + contentWidth, y)
+ .strokeColor(BORDER_COLOR)
+ .lineWidth(0.4)
+ .stroke();
+ dayRows.push(row);
+ if (lastOfDay) {
+ totals(
+ `${text.dayTotal} ${calendarDate(row.date, input.locale)}`,
+ dayRows,
+ false,
+ );
+ dayRows = [];
+ }
+ });
+ ensureSpace(TOTAL_HEIGHT + 6, employee);
+ y += 6;
+ totals(text.periodTotal, rows, true);
+ }
+
+ if (input.employees.length === 0) {
+ doc.addPage();
+ y = MARGIN;
+ paragraph(`OpenClockwork | ${text.title}`, {
+ size: 16,
+ bold: true,
+ gap: 10,
+ });
+ paragraph(
+ `${text.period}: ${period} | ${text.timeZone}: ${input.timeZone}`,
+ );
+ paragraph(`${text.generated}: ${generatedLabel}`);
+ paragraph(text.noEmployees, { size: 10 });
+ }
+
+ const pageRange = doc.bufferedPageRange();
+ for (
+ let index = pageRange.start;
+ index < pageRange.start + pageRange.count;
+ index += 1
+ ) {
+ doc.switchToPage(index);
+ // The reserved footer lies outside the body's bottom margin.
+ doc.page.margins.bottom = 0;
+ const footerY = doc.page.height - MARGIN + 7;
+ doc
+ .moveTo(MARGIN, footerY - 7)
+ .lineTo(MARGIN + contentWidth, footerY - 7)
+ .strokeColor(BORDER_COLOR)
+ .lineWidth(0.5)
+ .stroke();
+ doc
+ .font('Regular')
+ .fontSize(7)
+ .fillColor(MUTED_COLOR)
+ .text('OpenClockwork', MARGIN, footerY, {
+ width: contentWidth / 2,
+ lineBreak: false,
+ });
+ doc.text(
+ `${text.page} ${index + 1} ${text.of} ${pageRange.count}`,
+ MARGIN + contentWidth / 2,
+ footerY,
+ {
+ width: contentWidth / 2,
+ align: 'right',
+ lineBreak: false,
+ },
+ );
+ }
+ doc.end();
+ return result;
+}
diff --git a/apps/api/src/assets/fonts/NotoSans-Bold.ttf b/apps/api/src/assets/fonts/NotoSans-Bold.ttf
new file mode 100644
index 0000000..713a768
Binary files /dev/null and b/apps/api/src/assets/fonts/NotoSans-Bold.ttf differ
diff --git a/apps/api/src/assets/fonts/NotoSans-Regular.ttf b/apps/api/src/assets/fonts/NotoSans-Regular.ttf
new file mode 100644
index 0000000..5de3fa7
Binary files /dev/null and b/apps/api/src/assets/fonts/NotoSans-Regular.ttf differ
diff --git a/apps/api/src/assets/fonts/OFL.txt b/apps/api/src/assets/fonts/OFL.txt
new file mode 100644
index 0000000..7d7fbd8
--- /dev/null
+++ b/apps/api/src/assets/fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/apps/api/src/generate-openapi.ts b/apps/api/src/generate-openapi.ts
index c8091e1..5117ff6 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('2.1.0')
+ .setVersion('2.2.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts
index 1a576d3..de6c389 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('2.1.0')
+ .setVersion('2.2.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 d75410f..037c1a3 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -959,9 +959,13 @@ export const api = {
to: string,
employeeId?: string,
includeLocations = false,
+ employeeIds?: string[],
) => {
const params = new URLSearchParams({ from, to });
if (employeeId) params.set('employeeId', employeeId);
+ if (!employeeId) {
+ employeeIds?.forEach((id) => params.append('employeeIds', id));
+ }
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 ff54feb..4f7538e 100644
--- a/apps/web/src/api/generated.ts
+++ b/apps/web/src/api/generated.ts
@@ -644,6 +644,22 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/api/reports/working-times.pdf': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations['ReportsController_workingTimesPdf'];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
'/api/timeentries': {
parameters: {
query?: never;
@@ -1838,7 +1854,7 @@ export interface components {
status: string;
/** @example openclockwork-api */
service: string;
- /** @example 2.1.0 */
+ /** @example 2.2.0 */
version: string;
/** Format: date-time */
utcTimestamp: string;
@@ -4104,6 +4120,8 @@ 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. */
+ employeeIds?: string[];
includeLocations?: boolean;
};
header?: never;
@@ -4122,6 +4140,33 @@ export interface operations {
};
};
};
+ ReportsController_workingTimesPdf: {
+ parameters: {
+ query: {
+ 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. */
+ employeeIds?: string[];
+ locale?: 'de' | 'en';
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Working-time PDF for one, selected or all employees, with separate employee sections, booking-day totals, timezone and policy-based break deductions. Excludes locations and private notes. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'application/pdf': string;
+ };
+ };
+ };
+ };
TimeEntriesController_list: {
parameters: {
query: {
diff --git a/apps/web/src/app/i18n.tsx b/apps/web/src/app/i18n.tsx
index 1637c0e..bb83a16 100644
--- a/apps/web/src/app/i18n.tsx
+++ b/apps/web/src/app/i18n.tsx
@@ -444,6 +444,16 @@ const de: Catalog = {
'reports.projectIndependent':
'Abgeschlossene, nicht abgelehnte Buchungen – unabhängig von Projekten.',
'reports.downloadCsv': 'CSV herunterladen',
+ 'reports.downloadPdf': 'PDF herunterladen',
+ 'reports.preparingPdf': 'PDF wird erstellt …',
+ 'reports.selectMultiple': 'Mehrere Mitarbeiter:innen auswählen',
+ 'reports.selectEmployees': 'Mitarbeiter:innen für die Auswertung auswählen',
+ 'reports.selectionRequired': 'Wähle mindestens eine Person aus.',
+ 'reports.pdfHint':
+ 'Ein gemeinsames PDF, in dem jede Person auf einer neuen Seite beginnt. Stempelorte werden im PDF nicht ausgegeben.',
+ 'reports.pdfFailed': 'PDF konnte nicht heruntergeladen werden',
+ 'reports.pdfRetry': 'Bitte versuche den Download erneut.',
+ 'reports.rangeTooLong': 'Der Zeitraum darf höchstens 366 Tage umfassen.',
'reports.empty': 'Keine Buchungen im gewählten Zeitraum.',
'reports.invalidRange': 'Das Von-Datum muss vor oder am Bis-Datum liegen.',
'reports.loadFailed': 'Arbeitszeiten konnten nicht geladen werden',
@@ -1104,6 +1114,16 @@ const en: Catalog = {
'reports.projectIndependent':
'Closed, non-rejected entries, independent of projects.',
'reports.downloadCsv': 'Download CSV',
+ 'reports.downloadPdf': 'Download PDF',
+ 'reports.preparingPdf': 'Preparing PDF …',
+ 'reports.selectMultiple': 'Select multiple employees',
+ 'reports.selectEmployees': 'Select employees for the report',
+ 'reports.selectionRequired': 'Select at least one person.',
+ 'reports.pdfHint':
+ 'One combined PDF with each person starting on a new page. Clocking locations are excluded from the PDF.',
+ 'reports.pdfFailed': 'PDF could not be downloaded',
+ 'reports.pdfRetry': 'Please try the download again.',
+ 'reports.rangeTooLong': 'The date range may contain at most 366 days.',
'reports.empty': 'No entries in the selected date range.',
'reports.invalidRange':
'The from date must be before or equal to the to date.',
diff --git a/apps/web/src/routes/AdminWorkingTimesPage.spec.tsx b/apps/web/src/routes/AdminWorkingTimesPage.spec.tsx
index 51b1d37..1cb8ec7 100644
--- a/apps/web/src/routes/AdminWorkingTimesPage.spec.tsx
+++ b/apps/web/src/routes/AdminWorkingTimesPage.spec.tsx
@@ -1,12 +1,15 @@
-import { fireEvent, screen, waitFor } from '@testing-library/react';
-import { beforeEach, expect, vi } from 'vitest';
+import { act, fireEvent, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, expect, vi } from 'vitest';
import type { WorkingTimeReportDto } from '../api/client';
import { renderWithProviders } from '../test-utils';
const employeesMock = vi.fn();
const workingTimeReportMock = vi.fn();
+const downloadAuthenticatedMock = vi.fn();
vi.mock('../api/client', () => ({
+ downloadAuthenticated: (...args: unknown[]) =>
+ downloadAuthenticatedMock(...args),
api: {
workingTimeReportEmployees: () => employeesMock(),
workingTimeReport: (...args: unknown[]) => workingTimeReportMock(...args),
@@ -64,8 +67,19 @@ describe('AdminWorkingTimesPage', () => {
firstName: 'Dora',
lastName: 'Direct',
},
+ {
+ id: 'employee-2',
+ firstName: 'Erich',
+ lastName: 'Example',
+ },
]);
workingTimeReportMock.mockReset().mockResolvedValue(report);
+ downloadAuthenticatedMock.mockReset().mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
});
it('renders start, end, break and duration without project data', async () => {
@@ -105,6 +119,288 @@ describe('AdminWorkingTimesPage', () => {
expect(screen.getByText('49.800000, 9.940000 · ±18 m')).toBeTruthy();
});
+ it('downloads all employees as an authenticated PDF without location data', async () => {
+ const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage');
+ renderWithProviders();
+ const pdfButton = await screen.findByRole('button', {
+ name: 'PDF herunterladen',
+ });
+ await waitFor(() =>
+ expect((pdfButton as HTMLButtonElement).disabled).toBe(false),
+ );
+ fireEvent.click(screen.getByLabelText('Stempelorte einbeziehen'));
+ await waitFor(() =>
+ expect((pdfButton as HTMLButtonElement).disabled).toBe(false),
+ );
+ fireEvent.click(pdfButton);
+
+ await waitFor(() =>
+ expect(downloadAuthenticatedMock).toHaveBeenCalledOnce(),
+ );
+ const [path, filename] = downloadAuthenticatedMock.mock.calls[0];
+ const from = (screen.getByLabelText('Von') as HTMLInputElement).value;
+ const to = (screen.getByLabelText('Bis') as HTMLInputElement).value;
+ const url = new URL(path, 'https://openclockwork.test');
+ expect(url.pathname).toBe('/api/reports/working-times.pdf');
+ expect(Object.fromEntries(url.searchParams)).toEqual({
+ from,
+ to,
+ locale: 'de',
+ });
+ expect(filename).toBe(`arbeitszeiten-${from}-${to}.pdf`);
+ });
+
+ it('keeps the single employee filter for report and PDF downloads', async () => {
+ const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage');
+ renderWithProviders();
+ await screen.findAllByText('Dora Direct');
+ fireEvent.change(screen.getByLabelText('Mitarbeiter:in'), {
+ target: { value: 'employee-1' },
+ });
+ const pdfButton = screen.getByRole('button', { name: 'PDF herunterladen' });
+ await waitFor(() =>
+ expect((pdfButton as HTMLButtonElement).disabled).toBe(false),
+ );
+ expect(workingTimeReportMock.mock.calls.at(-1)?.slice(2)).toEqual([
+ 'employee-1',
+ false,
+ undefined,
+ ]);
+ fireEvent.click(pdfButton);
+ await waitFor(() =>
+ expect(downloadAuthenticatedMock).toHaveBeenCalledOnce(),
+ );
+ const url = new URL(
+ downloadAuthenticatedMock.mock.calls[0][0],
+ 'https://openclockwork.test',
+ );
+ expect(url.searchParams.get('employeeId')).toBe('employee-1');
+ expect(url.searchParams.has('employeeIds')).toBe(false);
+ });
+
+ it('uses a selected employee subset for PDF and CSV and can return to all employees', async () => {
+ const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage');
+ const selectedReport = {
+ ...report,
+ rows: [
+ ...report.rows,
+ {
+ ...report.rows[0],
+ id: 'entry-2',
+ employeeId: 'employee-2',
+ employeeName: 'Erich Example',
+ },
+ ],
+ };
+ workingTimeReportMock.mockImplementation(
+ (from, to, _employeeId, _includeLocations, employeeIds) =>
+ Promise.resolve({
+ ...(employeeIds?.length === 2 ? selectedReport : report),
+ from,
+ to,
+ }),
+ );
+ const createObjectURL = vi.fn<(blob: Blob) => string>(
+ () => 'blob:working-time-csv',
+ );
+ const revokeObjectURL = vi.fn();
+ vi.stubGlobal(
+ 'URL',
+ class extends URL {
+ static override createObjectURL = createObjectURL;
+ static override revokeObjectURL = revokeObjectURL;
+ },
+ );
+ const anchorClick = vi
+ .spyOn(HTMLAnchorElement.prototype, 'click')
+ .mockImplementation(() => undefined);
+ renderWithProviders();
+ await screen.findAllByText('Dora Direct');
+
+ fireEvent.change(screen.getByLabelText('Mitarbeiter:in'), {
+ target: { value: '__multiple__' },
+ });
+ expect(screen.getByText('Wähle mindestens eine Person aus.')).toBeTruthy();
+ expect(
+ (
+ screen.getByRole('button', {
+ name: 'CSV herunterladen',
+ }) as HTMLButtonElement
+ ).disabled,
+ ).toBe(true);
+ expect(
+ (
+ screen.getByRole('button', {
+ name: 'PDF herunterladen',
+ }) as HTMLButtonElement
+ ).disabled,
+ ).toBe(true);
+ fireEvent.click(screen.getByRole('checkbox', { name: 'Dora Direct' }));
+ fireEvent.click(screen.getByRole('checkbox', { name: 'Erich Example' }));
+ await waitFor(() =>
+ expect(
+ (
+ screen.getByRole('button', {
+ name: 'CSV herunterladen',
+ }) as HTMLButtonElement
+ ).disabled,
+ ).toBe(false),
+ );
+ expect(workingTimeReportMock.mock.calls.at(-1)?.slice(2)).toEqual([
+ undefined,
+ false,
+ ['employee-1', 'employee-2'],
+ ]);
+
+ fireEvent.click(screen.getByRole('button', { name: 'CSV herunterladen' }));
+ expect(anchorClick).toHaveBeenCalledOnce();
+ const blob = createObjectURL.mock.calls[0][0] as Blob;
+ const csv = await new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.readAsText(blob);
+ });
+ expect(csv).toContain('Dora Direct');
+ expect(csv).toContain('Erich Example');
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:working-time-csv');
+
+ fireEvent.click(screen.getByRole('button', { name: 'PDF herunterladen' }));
+ await waitFor(() =>
+ expect(downloadAuthenticatedMock).toHaveBeenCalledOnce(),
+ );
+ const url = new URL(
+ downloadAuthenticatedMock.mock.calls[0][0],
+ 'https://openclockwork.test',
+ );
+ expect(url.searchParams.getAll('employeeIds')).toEqual([
+ 'employee-1',
+ 'employee-2',
+ ]);
+ expect(url.searchParams.has('employeeId')).toBe(false);
+ await waitFor(() =>
+ expect(
+ (screen.getByLabelText('Mitarbeiter:in') as HTMLSelectElement).disabled,
+ ).toBe(false),
+ );
+ fireEvent.change(screen.getByLabelText('Mitarbeiter:in'), {
+ target: { value: '' },
+ });
+ await waitFor(() =>
+ expect(workingTimeReportMock.mock.calls.at(-1)?.slice(2)).toEqual([
+ undefined,
+ false,
+ undefined,
+ ]),
+ );
+ });
+
+ it('shows pending PDF creation and allows retry after download errors', async () => {
+ const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage');
+ let rejectDownload!: (error: Error) => void;
+ downloadAuthenticatedMock.mockReturnValueOnce(
+ new Promise((_resolve, reject) => {
+ rejectDownload = reject;
+ }),
+ );
+ renderWithProviders();
+ const pdfButton = screen.getByRole('button', { name: 'PDF herunterladen' });
+ await waitFor(() =>
+ expect((pdfButton as HTMLButtonElement).disabled).toBe(false),
+ );
+ fireEvent.click(pdfButton);
+ expect(
+ screen
+ .getByRole('button', { name: 'PDF wird erstellt …' })
+ .getAttribute('aria-busy'),
+ ).toBe('true');
+ expect((screen.getByLabelText('Von') as HTMLInputElement).disabled).toBe(
+ true,
+ );
+ expect(
+ (
+ screen.getByRole('button', {
+ name: 'CSV herunterladen',
+ }) as HTMLButtonElement
+ ).disabled,
+ ).toBe(true);
+
+ await act(async () => rejectDownload(new Error('Download failed (500)')));
+ expect(await screen.findByRole('alert')).toBeTruthy();
+ expect(
+ screen.getByText('PDF konnte nicht heruntergeladen werden'),
+ ).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: 'PDF herunterladen' }));
+ await waitFor(() =>
+ expect(downloadAuthenticatedMock).toHaveBeenCalledTimes(2),
+ );
+ expect(
+ screen.queryByText('PDF konnte nicht heruntergeladen werden'),
+ ).toBeNull();
+ });
+
+ it('blocks exports while updated filters load or fail, including an invalid date range', async () => {
+ const { AdminWorkingTimesPage } = await import('./AdminWorkingTimesPage');
+ renderWithProviders();
+ const csvButton = screen.getByRole('button', { name: 'CSV herunterladen' });
+ const pdfButton = screen.getByRole('button', { name: 'PDF herunterladen' });
+ await waitFor(() =>
+ expect((csvButton as HTMLButtonElement).disabled).toBe(false),
+ );
+ let rejectReport!: (error: Error) => void;
+ workingTimeReportMock.mockReturnValueOnce(
+ new Promise((_resolve, reject) => {
+ rejectReport = reject;
+ }),
+ );
+ fireEvent.change(screen.getByLabelText('Mitarbeiter:in'), {
+ target: { value: 'employee-2' },
+ });
+ expect((csvButton as HTMLButtonElement).disabled).toBe(true);
+ expect((pdfButton as HTMLButtonElement).disabled).toBe(true);
+ await act(async () => rejectReport(new Error('Report unavailable')));
+ await screen.findByText('Arbeitszeiten konnten nicht geladen werden');
+ expect((csvButton as HTMLButtonElement).disabled).toBe(true);
+ expect((pdfButton as HTMLButtonElement).disabled).toBe(true);
+ fireEvent.change(screen.getByLabelText('Von'), {
+ target: { value: '2099-01-01' },
+ });
+ expect(
+ screen.getByText('Das Von-Datum muss vor oder am Bis-Datum liegen.'),
+ ).toBeTruthy();
+ expect((csvButton as HTMLButtonElement).disabled).toBe(true);
+ expect((pdfButton as HTMLButtonElement).disabled).toBe(true);
+ expect(downloadAuthenticatedMock).not.toHaveBeenCalled();
+ });
+
+ it('serializes selected employee IDs as repeated JSON report query parameters', async () => {
+ const { api } =
+ await vi.importActual('../api/client');
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(report), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ await api.workingTimeReport('2026-08-01', '2026-08-31', undefined, true, [
+ 'employee-1',
+ 'employee-2',
+ ]);
+
+ const url = new URL(
+ fetchMock.mock.calls[0][0],
+ 'https://openclockwork.test',
+ );
+ expect(url.pathname).toBe('/api/reports/working-times');
+ expect(url.searchParams.getAll('employeeIds')).toEqual([
+ 'employee-1',
+ 'employee-2',
+ ]);
+ expect(url.searchParams.has('employeeId')).toBe(false);
+ expect(url.searchParams.get('includeLocations')).toBe('true');
+ });
+
it('creates an Excel-friendly CSV containing the filtered rows and totals', async () => {
const { workingTimeReportToCsv } = await import('./AdminWorkingTimesPage');
const csv = workingTimeReportToCsv(
@@ -163,4 +459,46 @@ describe('AdminWorkingTimesPage', () => {
expect(csv).toContain('Büro Würzburg · 49.791304, 9.953355 · ±12 m');
expect(csv).toContain('Gesamt;;;;;;8:00;0:30;7:30;');
});
+
+ it('preserves duration seconds and neutralizes employee names interpreted as spreadsheet formulas', async () => {
+ const { workingTimeReportToCsv } = await import('./AdminWorkingTimesPage');
+ const csv = workingTimeReportToCsv(
+ {
+ ...report,
+ rows: ['=1+1', ' +1', '\t-1', ' @SUM(A1)'].map(
+ (employeeName, index) => ({
+ ...report.rows[0],
+ id: `entry-${index}`,
+ employeeName,
+ grossMinutes: 240.5,
+ breakMinutes: 15,
+ netMinutes: 225.5,
+ }),
+ ),
+ totals: { grossMinutes: 962, breakMinutes: 60, netMinutes: 902 },
+ },
+ {
+ 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,
+ );
+
+ expect(csv).toContain(";'=1+1;");
+ expect(csv).toContain(";' +1;");
+ expect(csv).toContain(";'\t-1;");
+ expect(csv).toContain(";' @SUM(A1);");
+ expect(csv).toContain(';4:00:30;0:15;3:45:30;Approved');
+ expect(csv).toContain('Gesamt;;;;16:02;1:00;15:02;');
+ });
});
diff --git a/apps/web/src/routes/AdminWorkingTimesPage.tsx b/apps/web/src/routes/AdminWorkingTimesPage.tsx
index c080e5f..521a1c1 100644
--- a/apps/web/src/routes/AdminWorkingTimesPage.tsx
+++ b/apps/web/src/routes/AdminWorkingTimesPage.tsx
@@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
api,
+ downloadAuthenticated,
type WorkingTimeReportDto,
type WorkingTimeReportLocationDto,
} from '../api/client';
@@ -35,11 +36,21 @@ interface CsvLabels {
}
function csvEscape(value: string): string {
- return /[";\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
+ // Spreadsheet applications may evaluate a formula after leading whitespace.
+ const safeValue = /^\s*[=+\-@]/u.test(value) ? `'${value}` : value;
+ return /[";\n\r]/.test(safeValue)
+ ? `"${safeValue.replace(/"/g, '""')}"`
+ : safeValue;
}
function formatMinutes(minutes: number): string {
- return `${Math.floor(minutes / 60)}:${(minutes % 60).toString().padStart(2, '0')}`;
+ const totalSeconds = Math.round(minutes * 60);
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutePart = Math.floor((totalSeconds % 3600) / 60)
+ .toString()
+ .padStart(2, '0');
+ const secondPart = totalSeconds % 60;
+ return `${hours}:${minutePart}${secondPart ? `:${secondPart.toString().padStart(2, '0')}` : ''}`;
}
function formatTime(value: string, languageTag: string): string {
@@ -133,16 +144,29 @@ function defaultRange(): { from: string; to: string } {
};
}
+const MULTIPLE_EMPLOYEES = '__multiple__';
+
export function AdminWorkingTimesPage() {
const user = useCurrentUser();
- const { t, enumLabel, languageTag } = useI18n();
+ const { t, enumLabel, languageTag, locale } = useI18n();
const initialRange = useMemo(defaultRange, []);
const [from, setFrom] = useState(initialRange.from);
const [to, setTo] = useState(initialRange.to);
const [employeeId, setEmployeeId] = useState('');
+ const [selectedEmployeeIds, setSelectedEmployeeIds] = useState([]);
const [includeLocations, setIncludeLocations] = useState(false);
+ const [pdfPending, setPdfPending] = useState(false);
+ const [pdfFailed, setPdfFailed] = useState(false);
const isAuthorized = user.role === 'HRAdmin';
- const validRange = from !== '' && to !== '' && from <= to;
+ const orderedRange = from !== '' && to !== '' && from <= to;
+ const validRange =
+ orderedRange && (Date.parse(to) - Date.parse(from)) / 86_400_000 < 366;
+ const multipleEmployees = employeeId === MULTIPLE_EMPLOYEES;
+ const employeeIds = multipleEmployees ? selectedEmployeeIds : undefined;
+ const singleEmployeeId = multipleEmployees
+ ? undefined
+ : employeeId || undefined;
+ const validSelection = !multipleEmployees || selectedEmployeeIds.length > 0;
const employees = useQuery({
queryKey: ['employees', 'working-time-report'],
@@ -155,18 +179,27 @@ export function AdminWorkingTimesPage() {
'working-time-report',
from,
to,
- employeeId || null,
+ singleEmployeeId ?? null,
+ employeeIds ?? null,
includeLocations,
],
queryFn: () =>
api.workingTimeReport(
from,
to,
- employeeId || undefined,
+ singleEmployeeId,
includeLocations,
+ employeeIds,
),
- enabled: isAuthorized && validRange,
+ enabled: isAuthorized && validRange && validSelection,
});
+ const canDownload =
+ validRange &&
+ validSelection &&
+ !report.isFetching &&
+ !report.isError &&
+ !pdfPending &&
+ Boolean(report.data?.rows.length);
if (!isAuthorized) {
return (
@@ -174,7 +207,7 @@ export function AdminWorkingTimesPage() {
);
}
const downloadCsv = () => {
- if (!report.data) return;
+ if (!canDownload || !report.data) return;
const csv = workingTimeReportToCsv(
report.data,
{
@@ -202,6 +235,24 @@ export function AdminWorkingTimesPage() {
link.click();
URL.revokeObjectURL(url);
};
+ const downloadPdf = async () => {
+ if (!canDownload) return;
+ const params = new URLSearchParams({ from, to, locale });
+ if (singleEmployeeId) params.set('employeeId', singleEmployeeId);
+ employeeIds?.forEach((id) => params.append('employeeIds', id));
+ setPdfFailed(false);
+ setPdfPending(true);
+ try {
+ await downloadAuthenticated(
+ `/api/reports/working-times.pdf?${params.toString()}`,
+ `arbeitszeiten-${from}-${to}.pdf`,
+ );
+ } catch {
+ setPdfFailed(true);
+ } finally {
+ setPdfPending(false);
+ }
+ };
return (