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 @@ Latest GitHub release

-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 (
@@ -228,6 +279,7 @@ export function AdminWorkingTimesPage() { type="date" value={from} max={to || undefined} + disabled={pdfPending} onChange={(event) => setFrom(event.target.value)} />
@@ -238,6 +290,7 @@ export function AdminWorkingTimesPage() { type="date" value={to} min={from || undefined} + disabled={pdfPending} onChange={(event) => setTo(event.target.value)} /> @@ -248,10 +301,16 @@ export function AdminWorkingTimesPage() { { + const checked = event.target.checked; + setSelectedEmployeeIds((selected) => + checked + ? [...selected, employee.id].sort() + : selected.filter((id) => id !== employee.id), + ); + }} + /> + {employee.firstName} {employee.lastName} + + ))} + + {!validSelection && ( +

+ {t('reports.selectionRequired')} +

+ )} + + )} +

+ {t('reports.pdfHint')} +

{!validRange && (

- {t('reports.invalidRange')} + {t( + orderedRange ? 'reports.rangeTooLong' : 'reports.invalidRange', + )}

)} + {pdfFailed && ( + + {t('reports.pdfFailed')} + {t('reports.pdfRetry')} + + )} + {report.isError && ( {t('reports.loadFailed')} diff --git a/apps/web/src/routes/KioskPinPanel.spec.tsx b/apps/web/src/routes/KioskPinPanel.spec.tsx index 9bebb71..ae039c0 100644 --- a/apps/web/src/routes/KioskPinPanel.spec.tsx +++ b/apps/web/src/routes/KioskPinPanel.spec.tsx @@ -158,10 +158,11 @@ it('requires a new explicit confirmation if location becomes mandatory', async ( timestamp: Date.now(), } as GeolocationPosition), ); - vi.stubGlobal('navigator', { - ...navigator, - geolocation: { getCurrentPosition: position }, + const locationNavigator = Object.create(navigator); + Object.defineProperty(locationNavigator, 'geolocation', { + value: { getCurrentPosition: position }, }); + vi.stubGlobal('navigator', locationNavigator); book.mockRejectedValueOnce( new ApiError(400, 'location required', 'TERMINAL_GPS_REQUIRED'), ); diff --git a/docker-compose.pin-test.yml b/docker-compose.pin-test.yml index 195cbdd..3d3ed9e 100644 --- a/docker-compose.pin-test.yml +++ b/docker-compose.pin-test.yml @@ -15,7 +15,7 @@ services: timeout: 5s retries: 12 migrate: - image: openclockwork-api:2.1.0-pin-local + image: openclockwork-api:2.2.0-pin-local build: context: . dockerfile: Dockerfile.api @@ -32,7 +32,7 @@ services: './node_modules/.bin/prisma migrate deploy && node /app/ops/configure-terminal-runtime.mjs', ] api: - image: openclockwork-api:2.1.0-pin-local + image: openclockwork-api:2.2.0-pin-local pull_policy: never depends_on: migrate: @@ -57,7 +57,7 @@ services: - pin-attachments:/app/data/attachments - pin-audit-export:/app/data/audit web: - image: openclockwork-web:2.1.0-pin-local + image: openclockwork-web:2.2.0-pin-local build: context: . dockerfile: Dockerfile.web diff --git a/docker-compose.solo-test.yml b/docker-compose.solo-test.yml index 3d7a5b5..5db91b3 100644 --- a/docker-compose.solo-test.yml +++ b/docker-compose.solo-test.yml @@ -18,7 +18,7 @@ services: timeout: 5s retries: 12 api: - image: openclockwork-api:2.1.0-local + image: openclockwork-api:2.2.0-local build: context: . dockerfile: Dockerfile.api @@ -48,7 +48,7 @@ services: './node_modules/.bin/prisma migrate deploy && exec node dist/main.js', ] web: - image: openclockwork-web:2.1.0-local + image: openclockwork-web:2.2.0-local build: context: . dockerfile: Dockerfile.web diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 59d19ca..9bc1dea 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -18,7 +18,7 @@ services: retries: 12 api: - image: openclockwork-api:2.1.0-local + image: openclockwork-api:2.2.0-local build: context: . dockerfile: Dockerfile.api @@ -47,7 +47,7 @@ services: ] web: - image: openclockwork-web:2.1.0-local + image: openclockwork-web:2.2.0-local build: context: . dockerfile: Dockerfile.web diff --git a/docs/LOCAL_PIN_DOCKER_TEST.de.md b/docs/LOCAL_PIN_DOCKER_TEST.de.md index 5ba7b64..e94bbc1 100644 --- a/docs/LOCAL_PIN_DOCKER_TEST.de.md +++ b/docs/LOCAL_PIN_DOCKER_TEST.de.md @@ -1,12 +1,13 @@ -# OpenClockwork 2.1.0 mit PIN lokal im Docker testen +# OpenClockwork 2.2.0 mit PIN lokal im Docker testen -Version 2.1.0 enthält die QR/PIN-Implementierung von Inkrement A. API und Web -verwenden die zusammen gebauten Images `openclockwork-api:2.1.0-pin-local` und -`openclockwork-web:2.1.0-pin-local`. Nach dem Neubau muss die API unter -`/api/health` Version `2.1.0` melden. +Version 2.2.0 enthält weiterhin die in 2.1.0 eingeführte QR/PIN-Implementierung +von Inkrement A. API und Web +verwenden die zusammen gebauten Images `openclockwork-api:2.2.0-pin-local` und +`openclockwork-web:2.2.0-pin-local`. Nach dem Neubau muss die API unter +`/api/health` Version `2.2.0` melden. -Die lokale PIN-Abnahme und die Release-Freigabe wurden am 11.10.2026 vom Nutzer -bestätigt. Damit ist keine zusätzliche Zielgeräte-/OS- +Die lokale PIN-Abnahme und die Release-Freigabe für 2.1.0 wurden am 11.10.2026 +vom Nutzer bestätigt. Damit ist keine zusätzliche Zielgeräte-/OS- oder externe Archivabnahme behauptet; diese bleibt installationsbezogen. Die separate Testversion bindet auf `http://localhost:18082`. Projektname, diff --git a/docs/OPERATING_MODES.md b/docs/OPERATING_MODES.md index bfcd685..e156953 100644 --- a/docs/OPERATING_MODES.md +++ b/docs/OPERATING_MODES.md @@ -1,6 +1,6 @@ # Operating modes: Solo and Team -OpenClockwork 2.1.0 offers two workflows on the same self-hosted installation. +OpenClockwork 2.2.0 offers two workflows on the same self-hosted installation. The mode is installation-wide, not a per-browser preference, billing plan, or separate database. Pick the workflow that matches who uses the application and who needs to make decisions about the recorded work. diff --git a/docs/SOLO_MODE.md b/docs/SOLO_MODE.md index 5d3bbc8..b15077c 100644 --- a/docs/SOLO_MODE.md +++ b/docs/SOLO_MODE.md @@ -1,6 +1,6 @@ # Solo mode -Solo mode in OpenClockwork 2.1.0 is for one person managing their own work: +Solo mode in OpenClockwork 2.2.0 is for one person managing their own work: freelancers, independent consultants, and owner-operated businesses. It provides time capture, customers, projects, service orders, personal calendar entries, and timesheets without manager approvals or a fictional HR department. @@ -31,11 +31,11 @@ are **not implemented** in this release. See the [roadmap](../ROADMAP.md). Use the [production installation guide](../README.md#production-installation-step-by-step) for host requirements, independent secrets, TLS, persistent volumes, and backups. -For 2.1.0, check out the `v2.1.0` tag and set the following in your private +For 2.2.0, check out the `v2.2.0` tag and set the following in your private `.env.prod`: ```dotenv -OPENCLOCKWORK_VERSION=2.1.0 +OPENCLOCKWORK_VERSION=2.2.0 TZ=UTC ``` diff --git a/docs/TEAM_MODE.md b/docs/TEAM_MODE.md index 2203484..0f177a3 100644 --- a/docs/TEAM_MODE.md +++ b/docs/TEAM_MODE.md @@ -13,8 +13,8 @@ employee, manager, kiosk, or approval setup has to be replaced by Solo. Choose ## Install and bootstrap Follow the [production installation guide](../README.md#production-installation-step-by-step). -For this release, use the `v2.1.0` source tag and set -`OPENCLOCKWORK_VERSION=2.1.0` in the private `.env.prod` file. Configure all +For this release, use the `v2.2.0` source tag and set +`OPENCLOCKWORK_VERSION=2.2.0` in the private `.env.prod` file. Configure all required independent secrets, browser origins, the installation's IANA working timezone (`TZ`), stable volume names, and TLS before using real personal data. @@ -82,7 +82,7 @@ switch succeeds. 3. **Create employees and reporting relationships.** Enter start dates, weekly hours, roles, managers, holiday calendars, and actual leave entitlements. Employee creation is administrator-managed; email invitation/onboarding is - not part of 2.1.0. + not part of 2.2.0. 4. **Review accounts.** Configure opening time balances, annual leave, carry-over, expiry, and reasoned adjustments as applicable. Check calculations against representative contractual cases before operational use. @@ -184,10 +184,20 @@ planned versus actual hours. Referenced projects/orders must be retained or deactivated rather than deleted. Project activity reports and CSV exports can be filtered by the supported period, employee, project, and order fields. -HR working-time reports show start/end, gross duration, breaks, net work, and -approval information independently of project assignment. Exact clock-in/out -locations are excluded by default; HR must explicitly include them in the table -and CSV export. +HR working-time reports show start/end, gross duration, break deductions, net work, +and approval information independently of project assignment. Select all, one, +or several employees, including inactive historical employees, and download CSV +or PDF. The bulk PDF starts a separate section for each employee and includes +personnel numbers, the deployment timezone, full start/end timestamps, totals +by booking day and period, and approval state. Open timers are excluded from +totals and called out in the PDF. Bookings belong to their start date, including +overnight bookings; these totals are not split at calendar midnight. + +Exact clock-in/out locations are excluded by default; HR must explicitly include +them in the table and CSV export. PDF exports always omit locations and private +notes. Automatic policy-based deductions are not evidence of actual rest breaks. +Read the [German legal context and export limitations](WORKING_TIME_EXPORTS.de.md) +before using the report for payroll or statutory recordkeeping. Solo customer CRUD, personal billable-timesheet filters, and personal policy screens are not exposed as Team features in 2.0. Customer/project references and @@ -195,7 +205,7 @@ booking billability survive a mode switch, but a shared Team customer/billing workspace is future work. Neither mode generates invoices or imports coding-agent usage records yet. -CSV exports contain personal working-time data. Review content, recipients, and +CSV and PDF exports contain personal working-time data. Review content, recipients, and retention before sharing. Browser downloads and print/PDF handoffs must be verified in the actual desktop browser or installed PWA; an HTTP response alone does not prove a local file was saved. diff --git a/docs/WORKING_TIME_EXPORTS.de.md b/docs/WORKING_TIME_EXPORTS.de.md new file mode 100644 index 0000000..9919444 --- /dev/null +++ b/docs/WORKING_TIME_EXPORTS.de.md @@ -0,0 +1,32 @@ +# Arbeitszeitnachweise als PDF + +OpenClockwork bietet neben CSV einen PDF-Download für Arbeitszeitnachweise. HR kann einen Mitarbeiter, mehrere ausgewählte Mitarbeiter oder alle Mitarbeiter des gewählten Zeitraums gemeinsam exportieren. Jeder Mitarbeiter beginnt im Sammeldokument auf einer eigenen Seite; weitere Seiten bleiben diesem Mitarbeiter zugeordnet. Auch inaktive Mitarbeiter mit historischen Buchungen können ausgewählt werden. Anlass ist [Discussion #37](https://github.com/patrickschiller/openclockwork/discussions/37). + +## Rechtlicher Rahmen in Deutschland + +Nach dem [Beschluss des Bundesarbeitsgerichts vom 13. September 2022, 1 ABR 22/21](https://www.bundesarbeitsgericht.de/entscheidung/1-abr-22-21/) müssen Arbeitgeber ein System zur Erfassung der Arbeitszeit vorhalten. Das [BMAS erläutert](https://www.bmas.de/DE/Arbeit/Arbeitsrecht/Arbeitnehmerrechte/Regelungen-zur-Arbeitszeit/Fragen-und-Antworten/faq-arbeitszeiterfassung.html), dass grundsätzlich die gesamte tägliche Arbeitszeit mit Beginn, Ende und Dauer aufzuzeichnen ist und derzeit keine allgemeine Formvorschrift besteht. Für diese Nachweise gibt es somit kein allgemein vorgeschriebenes PDF-Layout und keine allgemeine Pflicht zum PDF-Export. + +Arbeitszeit umfasst nach [§ 2 Abs. 1 ArbZG](https://www.gesetze-im-internet.de/arbzg/__2.html) die Zeit vom Beginn bis zum Ende der Arbeit ohne Ruhepausen. Die [BMAS-Hinweise zur Mindestlohndokumentation](https://www.bmas.de/DE/Arbeit/Arbeitsrecht/Mindestlohn/Dokumentationspflicht/dokumentationspflicht.html) bestätigen, dass Unterschriften von Arbeitgeber oder Arbeitnehmer dafür nicht erforderlich sind und die konkrete Lage der Pausen nicht aufgezeichnet werden muss. Ein Unterschriftsfeld oder eine digitale Signatur ist daher keine Voraussetzung dieses Exports. + +[§ 16 Abs. 2 ArbZG](https://www.gesetze-im-internet.de/arbzg/__16.html) verlangt die Aufzeichnung der über die werktägliche Arbeitszeit nach § 3 Satz 1 hinausgehenden Arbeitszeit und mindestens zwei Jahre Aufbewahrung dieser Nachweise. Zusätzlich verpflichtet [§ 17 Abs. 1 MiLoG](https://www.gesetze-im-internet.de/milog/__17.html) Arbeitgeber im dort genannten Anwendungsbereich, insbesondere bei geringfügiger Beschäftigung und bestimmten Branchen, Beginn, Ende und Dauer spätestens bis zum Ablauf des siebten folgenden Kalendertages aufzuzeichnen und die Aufzeichnungen mindestens zwei Jahre aufzubewahren. Haushalts-Minijobs nach § 8a SGB IV sind dort ausgenommen; weitere gesetzliche oder verordnete Ausnahmen können gelten. Die Sieben-Tage-Frist ist keine allgemeine Frist für alle Arbeitsverhältnisse. § 17 Abs. 2 MiLoG enthält außerdem Anforderungen an die Bereithaltung in deutscher Sprache. + +Diese Quellen schreiben weder PDF/A noch eine Zertifizierung des Exports vor. Ein PDF allein erfüllt nicht sämtliche Pflichten des Arbeitgebers: Die zugrunde liegende Erfassung muss vollständig, richtig, zugänglich und ordnungsgemäß aufbewahrt sein. Branchenspezifische Regelungen und betriebliche Vereinbarungen sind gesondert zu berücksichtigen. Die Quellen wurden am 22. Oktober 2026 geprüft. + +## Inhalt und Auswahl des Exports + +Der Nachweis enthält: + +- Name und Personalnummer, gewählten Zeitraum, Exportzeitpunkt sowie die konfigurierte IANA-Zeitzone der Installation. +- Vollständige Start- und Enddaten mit Uhrzeiten und UTC-Offsets, damit auch Nachtarbeit und Zeitumstellungen nachvollziehbar bleiben. +- Bruttodauer, nach der konfigurierten Regel abgezogene Pausen und resultierende Nettoarbeitszeit je Buchung sowie den Buchungsstatus. +- Tages- und Zeitraumssummen. Die Zuordnung zum Buchungstag richtet sich nach dem Arbeitsbeginn: Eine Schicht über Mitternacht bleibt vollständig beim Starttag und wird nicht in Kalendertage aufgeteilt. Die Tageswerte sind daher Buchungstagssummen, keine auf Mitternacht getrennten gesetzlichen Tagesprüfungen. + +Nur abgeschlossene Buchungen gehen in die Summen ein. Abgelehnte und stornierte Buchungen werden ausgeschlossen. Noch nicht genehmigte, abgeschlossene Buchungen bleiben mit ihrem Status sichtbar; ihre Werte sind vorläufig. Offene Buchungen werden nicht hochgerechnet; der Nachweis weist auf ihre Anzahl hin. Ein leerer Zeitraum ersetzt keinen Nachweis, dass tatsächlich keine Arbeit geleistet wurde. + +Die Pausenwerte bilden regelbasierte Abzüge aus den gespeicherten Regel-Snapshots ab. Automatische Abzüge belegen nicht, dass Pausen tatsächlich genommen wurden; tatsächliche Pausenlagen werden nicht dokumentiert. Der Arbeitgeber muss die zutreffende Erfassung und die Einhaltung der Pausenregeln sicherstellen. Im PDF werden Brutto und Netto auf Sekunden gerundet und der angezeigte Pausenabzug als Differenz daraus gebildet. Tages- und Zeitraumssummen addieren diese angezeigten Werte. + +## Berechtigung und Grenzen + +Der Download ist auf HR-Berechtigte und die berechtigte Organisation beschränkt. Ein Sammel-PDF enthält personenbezogene Arbeitszeitdaten mehrerer Mitarbeiter und muss entsprechend geschützt und gezielt weitergegeben werden. Standorte, Freitextnotizen und Projekte werden nicht exportiert. + +Der PDF-Export unterstützt die Arbeitszeitdokumentation und ist keine Bescheinigung allgemeiner Rechtskonformität. Er ist insbesondere keine vollständige Lohnabrechnung: Abwesenheiten, Vergütung und eine vollständige Überstundenabrechnung sind nicht Bestandteil dieses Nachweises. Der Download ersetzt weder das Erfassungssystem noch eine Aufbewahrungsstrategie. Die Verantwortung für Richtigkeit, Vollständigkeit, zulässige Verarbeitung und erforderliche Aufbewahrung verbleibt beim Arbeitgeber. diff --git a/package.json b/package.json index a84e60c..c574dbf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclockwork", - "version": "2.1.0", + "version": "2.2.0", "license": "Apache-2.0", "private": true, "description": "OpenClockwork — self-hosted time and attendance with configurable work rules for organisations worldwide", @@ -64,6 +64,7 @@ "@types/multer": "^2.1.0", "@types/node": "20.19.9", "@types/passport-jwt": "^4.0.1", + "@types/pdfkit": "^0.17.3", "@types/pg": "^8.20.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -135,6 +136,7 @@ "lucide-react": "^1.14.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "pdfkit": "^0.17.2", "prisma": "^6.19.3", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24b2de9..5430a64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: passport-jwt: specifier: ^4.0.1 version: 4.0.1 + pdfkit: + specifier: ^0.17.2 + version: 0.17.2 prisma: specifier: ^6.19.3 version: 6.19.3(typescript@5.9.3) @@ -243,6 +246,9 @@ importers: '@types/passport-jwt': specifier: ^4.0.1 version: 4.0.1 + '@types/pdfkit': + specifier: ^0.17.3 + version: 0.17.7 '@types/pg': specifier: ^8.20.0 version: 8.20.0 @@ -411,6 +417,9 @@ importers: passport-jwt: specifier: '*' version: 4.0.1 + pdfkit: + specifier: ^0.17.2 + version: 0.17.2 reflect-metadata: specifier: ^0.1.13 version: 0.1.14 @@ -3807,6 +3816,9 @@ packages: '@types/passport@1.0.17': resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + '@types/pdfkit@0.17.7': + resolution: {integrity: sha512-VMOZDWw7HtNtLbjIuHIhVArm9PlV0gxpz32jAl1IuXWlIgbr1lKFoTsrtvEzY8a9BIpQJEE5DwNPKD50otTs6g==} + '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} @@ -4629,6 +4641,10 @@ packages: bare-abort-controller: optional: true + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -4707,6 +4723,12 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + + browserify-zlib@0.2.0: + resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -4885,6 +4907,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -5094,6 +5120,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. + crypto-random-string@2.0.0: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} engines: {node: '>=8'} @@ -5355,6 +5385,9 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -5941,6 +5974,9 @@ packages: debug: optional: true + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -6803,6 +6839,10 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jpeg-exif@1.1.4: + resolution: {integrity: sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + js-levenshtein@1.1.6: resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} engines: {node: '>=0.10.0'} @@ -7029,6 +7069,9 @@ packages: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -7645,6 +7688,12 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -7741,6 +7790,9 @@ packages: pause@0.0.1: resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + pdfkit@0.17.2: + resolution: {integrity: sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -7837,6 +7889,9 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + png-js@1.1.0: + resolution: {integrity: sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==} + pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -8421,6 +8476,9 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + restructure@3.0.2: + resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -9207,6 +9265,9 @@ packages: resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} engines: {node: '>=12'} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -9433,10 +9494,16 @@ packages: resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} engines: {node: '>=4'} + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + unicode-property-aliases-ecmascript@2.2.0: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -13968,6 +14035,10 @@ snapshots: dependencies: '@types/express': 5.0.6 + '@types/pdfkit@0.17.7': + dependencies: + '@types/node': 20.19.9 + '@types/pg@8.20.0': dependencies: '@types/node': 20.19.9 @@ -14948,6 +15019,8 @@ snapshots: bare-events@2.8.2: {} + base64-js@0.0.8: {} + base64-js@1.5.1: {} base64id@2.0.0: {} @@ -15047,6 +15120,14 @@ snapshots: dependencies: fill-range: 7.1.1 + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + + browserify-zlib@0.2.0: + dependencies: + pako: 1.0.11 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.29 @@ -15238,6 +15319,8 @@ snapshots: clone@1.0.4: {} + clone@2.1.2: {} + clsx@2.1.1: {} co@4.6.0: {} @@ -15420,6 +15503,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-js@4.2.0: {} + crypto-random-string@2.0.0: {} css-declaration-sorter@7.4.0(postcss@8.5.16): @@ -15655,6 +15740,8 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 + dfa@1.2.0: {} + didyoumean@1.2.2: {} diff@4.0.4: {} @@ -16476,6 +16563,18 @@ snapshots: optionalDependencies: debug: 4.4.3(supports-color@10.2.2) + fontkit@2.0.4: + dependencies: + '@swc/helpers': 0.5.18 + brotli: 1.3.3 + clone: 2.1.2 + dfa: 1.2.0 + fast-deep-equal: 3.1.3 + restructure: 3.0.2 + tiny-inflate: 1.0.3 + unicode-properties: 1.4.1 + unicode-trie: 2.0.0 + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -17542,6 +17641,8 @@ snapshots: jiti@2.7.0: {} + jpeg-exif@1.1.4: {} + js-levenshtein@1.1.6: {} js-tokens@10.0.0: {} @@ -17770,6 +17871,11 @@ snapshots: lilconfig@3.1.3: {} + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + lines-and-columns@1.2.4: {} lines-and-columns@2.0.3: {} @@ -18455,6 +18561,10 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@0.2.9: {} + + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -18533,6 +18643,14 @@ snapshots: pause@0.0.1: {} + pdfkit@0.17.2: + dependencies: + crypto-js: 4.2.0 + fontkit: 2.0.4 + jpeg-exif: 1.1.4 + linebreak: 1.1.0 + png-js: 1.1.0 + pend@1.2.0: {} perfect-debounce@1.0.0: {} @@ -18622,6 +18740,10 @@ snapshots: pluralize@8.0.0: {} + png-js@1.1.0: + dependencies: + browserify-zlib: 0.2.0 + pngjs@5.0.0: {} portfinder@1.0.38: @@ -19190,6 +19312,8 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + restructure@3.0.2: {} + retry@0.13.1: {} reusify@1.1.0: {} @@ -20125,6 +20249,8 @@ snapshots: dependencies: convert-hrtime: 5.0.0 + tiny-inflate@1.0.3: {} + tinybench@2.9.0: {} tinyexec@1.1.2: {} @@ -20359,8 +20485,18 @@ snapshots: unicode-match-property-value-ecmascript@2.2.1: {} + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + unicode-property-aliases-ecmascript@2.2.0: {} + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unicorn-magic@0.3.0: {} union@0.5.0: