ModulesDaywork Sheets

Daywork Sheets

A Daywork Sheet is a client-facing Time & Materials (T&M) claim document. It bundles a set of confirmed day-works dockets and materials dockets under one submittable header, mints its own per-project display ID, and is delivered to a client as a generated PDF.

It rides on top of the existing docket → day-works / materials → pricing stack. The per-line truth still lives in the dockets themselves — a sheet only references them and snapshots each docket’s total at attach-time, so a raised sheet’s claim figure stays stable even if a docket is later re-priced.

A Daywork Sheet is the outward claim. The Day Works catalogue + per-docket day-works entry is the inward costing primitive a sheet aggregates. They are distinct features.

Where it lives

Open the Day Works workbench tab on /admin/dockets. Inside it there are two sub-tabs:

  • Dockets — the existing one-row-per-docket day-works view (the source rows you build a sheet from).
  • Sheets — the Daywork Sheets list for the project.

Daywork Sheets are project-scoped — each project keeps its own list and its own display-ID sequence.

Create flow

  1. On the Dockets sub-tab, tick the checkbox on one or more rows. Only confirmed dockets are selectable — a docket must be finalised before it can be claimed. The per-row and “select all” checkboxes enforce this, and so does the Create button.
  2. Click Create Daywork Sheet. It is enabled only when at least one eligible (confirmed) row is selected; otherwise it carries a tooltip telling you to select confirmed dockets first.
  3. A right-side panel opens, pre-populated with the selected dockets, where you fill the header fields:
    • Raised By — read-only. Snapshotted server-side from your verified session; it is never sent on the wire.
    • Submitted To (required) — the client / contact the claim is submitted to.
    • Daywork Sheet Date (required) — date picker.
    • Location (optional).
    • Summary of works completed (required) — this is what shows as the sheet’s Description in the list.
    • Weather notes (optional).
  4. The Attached items section lists the pre-selected day-works dockets (by docket number, with the person and date when your organisation shows them: Docket Numbers, on screen and in the PDF) plus any materials receipts; each is deselectable before save. A running Total Claim is shown — the day-works subtotal is summed best-effort from cached workbench rows (≈ $ while the materials subtotal “calculated on create” is still pending).
  5. Finish with one of:
    • Save Draft — creates the sheet in saved (draft) status.
    • Save & Raise — creates then immediately raises it. This gates behind a confirm dialog and validates the form first, so an invalid header surfaces field errors rather than opening the dialog.

On success the workbench jumps to the Sheets sub-tab so the freshly created sheet is visible.

Lifecycle: saved → raised

A sheet has exactly two statuses:

StatusMeaning
savedDraft. Header is editable, dockets can be attached/detached, can be deleted.
raisedSubmitted to the client. Immutable.
  • Raising is one-way and idempotent. The transition is guarded so a sheet only ever moves saved → raised; a double-submit hits zero rows. Raising stamps raised_at and writes a row to the status-change audit trail.
  • You cannot raise a sheet with zero attached dockets (the API returns 409 ErrNoDocketsAttached; the Raise button is disabled in that case).
  • A raised sheet is read-only — header, attached dockets, and totals are all frozen. You can still view it, view its audit trail, and download the PDF, but it is no longer a draft.
  • Only dockets from the sheet’s own project can be attached. Each attached docket’s figure is its own total at the moment it was attached.

Sheets list

The Sheets sub-tab is a paginated table (25 per page) with these columns:

ColumnSource
Datesheet_date (the claim’s daywork date).
Descriptionsummary (summary of works completed).
Statussaved (secondary badge) or raised (success badge).
Total Claimtotal_claim (see below).
Submitted Onraised_at — shown as — while the sheet is still a draft.

The list is real-time: a daywork_sheets INSERT / status-change / PDF-status change / soft-delete fires a Postgres NOTIFY → WebSocket → TanStack Query invalidation, so it refreshes without polling.

Tick sheets to change the date, Submitted to, location or weather notes on all of them at once, with Undo on the result. Raised sheets are left as they are — see Bulk Edit & Undo.

Detail panel

Clicking a row opens a right-side detail panel keyed by sheet id. It has two modes driven by the loaded sheet’s status:

Saved (draft):

  • An editable header form (submitted_to / sheet_date / location / summary / weather_notes) — Save persists header edits.
  • The attached day-works dockets and materials receipts, each with a Detach action.
  • Raise Sheet — validates the header, then confirms via a dialog. It optimistically flips the status to raised and locks the form, persisting any unsaved header edits first, and rolls the cache back if the server rejects it.
  • Delete (soft) behind a confirm dialog — releases the attached dockets and removes the sheet from the list. Soft-deleted, so an administrator can restore it.

Raised:

  • A read-only header.
  • A Total Claim breakdown: Day works + Materials = Total Claim.
  • The sheet’s PDF: Generate PDF, then Download PDF once it is made (see below).
  • A collapsible Audit Trail of every status change (from → to, who, when), newest first.

Total Claim

total_claim is a STORED generated column in Postgres:

total_claim NUMERIC(15,4)
  GENERATED ALWAYS AS (dayworks_total + materials_total) STORED

Callers only ever write the two component totals (dayworks_total, materials_total); the database derives total_claim as the single source of truth for the headline figure. The component totals themselves come from the snapshotted per-docket totals captured at attach-time:

  • Each attached day-works docket snapshots its dayworks_total into daywork_sheet_dockets.dayworks_total_snapshot.
  • Each attached materials docket snapshots its materials_total into daywork_sheet_materials.materials_total_snapshot.

After any attach/detach the API recomputes and writes the two component totals; the generated column updates automatically. Because the figures are snapshots, re-pricing a docket later does not change a sheet that already references it.

PDF: generate, retry, download

A raised sheet’s PDF is made when you ask for it. The button in the raised sheet’s footer shows where it is:

The sheet’s PDFThe button
Not made yetGenerate PDF
Being madeGenerating PDF… (with a spinner, disabled)
MadeDownload PDF
FailedPDF failed, why, and Retry
  1. Open a raised sheet and click Generate PDF. The button turns to Generating PDF… straight away.
  2. When the PDF is ready the button turns to Download PDF on its own — no refresh needed. Click it to save the file.
  3. If the PDF fails, the button says PDF failed, with the reason under it. Click Retry to make it again. If it could not even start, you also see “PDF couldn’t start — please retry”.
Reason shownWhat happened
PDF couldn’t start — please retryThe PDF could not be started.
PDF interrupted — please retryIt stopped before it finished.
PDF failed — please retrySomething went wrong while it was being made.

A failed PDF with no stored reason shows PDF failed alone.

A draft sheet has no PDF: raise it first. A second click while the PDF is being made does nothing — the same PDF is still on its way.

How it works

  • Generate / Retry is a write: POST /v1/orgs/{orgId}/projects/{projectId}/daywork-sheets/{sheetId}/pdf (generateDayworkSheetPdf), sent by the Server Action generateDayworkSheetPdfAction with an Idempotency-Key. It needs daywork.manage_sheets in the project — the same permission as every other sheet action.
  • It moves pdf_status from none or failed to pending in one guarded UPDATE (raised sheets only), so two clicks at once start one render. A PDF already pending or ready comes back unchanged; a draft answers 409.
  • The PDF is rendered by a Temporal workflow (DayworkSheetGeneratePdf): build the PDF and upload it to R2 in one step → mark the sheet ready (or failed), with Temporal handling retries. The sheet’s pdf_status walks none → pending → ready (or failed, and back to pending on Retry).
  • A render that cannot be started marks the PDF failed at once, and the request answers with the sheet failed, so the screen offers Retry instead of waiting.
  • A render never leaves the PDF on pending for long. Each render runs under the sheet’s own id for at most 30 minutes, and a new request replaces a render still hanging on. A sweep every 5 minutes asks Temporal about each pending PDF and marks it failed once its render is gone.
  • pdf_status changes fire the daywork_sheets NOTIFY, so an open sheet flips to Download PDF or Retry with no polling. A write that changes nothing sends nothing.
  • Why it failed is daywork_sheets.pdf_error (pdf_error on the DayworkSheet API shape), one of exportrun’s plain PDF messages: MsgPdfStartFailed from the generate endpoint, MsgPdfFailed from the render workflow’s failure mark, MsgPdfInterrupted from the 5-minute sweep. The cause’s own text stays in the logs. Claiming a render and a PDF landing both clear it. The failure mark only moves a pending PDF, so pdf_error never changes without pdf_status changing, and the NOTIFY above carries it.

Download is proxied through the Next.js BFF route /api/projects/{projectId}/daywork-sheets/{sheetId}/download:

  • Once ready, the proxy streams the application/pdf back as a file download.
  • Before that (none / pending / failed) it answers 202 with the sheet and starts nothing — a GET never writes.

The proxy exists because a plain <a download> can’t carry the Bearer token; it signs a short-lived internal JWT and takes org_id strictly from the verified session (never the URL), so the Go API’s tenant-isolation check stays IDOR-safe.

The mobile app offers the same: Generate PDF, Generating PDF…, Download PDF, and PDF failed with the reason and Retry, in the raised sheet’s bottom bar.

Per-project display ID (DWS-)

Each sheet mints a stable per-project display ID of the form {prefix}-DWS-{NNNN} (e.g. ACME-DWS-0001), where {prefix} is the project’s existing display_id_prefix — the same prefix dockets and materials mint from. The number comes from a per-project atomic counter (projects.daywork_sheet_ticker_count, mirroring the issue / plant tickers) bumped inside the create transaction.

The display ID is unique per project on the active (non-soft-deleted) set, so a soft-deleted sheet never blocks reuse of its value.

Tenancy & data model

  • Four tables added by migration 101_daywork_sheets.sql: daywork_sheets (the header), daywork_sheet_dockets + daywork_sheet_materials (the attachment/snapshot children, UNIQUE(sheet_id, docket_id)), and daywork_sheet_status_changes (the append-only audit trail).
  • org_id is first-class on the header for tenancy/IDOR even though project_id already implies it. Every query is scoped by org_id (+ project_id on project-scoped routes) and deleted_at IS NULL; child mutations are gated by a tenant-scoped EXISTS on the parent sheet so a forged sheet_id can’t reach another org’s rows.
  • The full sheet read resolves the header, attached dockets, attached materials, and status trail in a single round trip via jsonb_agg (no N+1).
  • sqlc types those three jsonb_agg columns as interface{}, and pgx v5 scans jsonb into interface{} already decoded ([]any, map[string]any), never as bytes. The detail, the raise check, the totals recompute and the PDF all read them through pkg/jsonbvalue, which accepts every shape. pkg/jsonbvalue/guard_test.go fails the build on any new []byte read of such a value.

Spec & plan

  • Spec: docs/superpowers/specs/2026-05-17-dayworks-design.md (§8 — Daywork Sheet)