Plant Register
Plant Register is a project-scoped, first-class catalogue of physical assets (plant / equipment) with identity, defaults, supplier binding, and optional automation. It replaces the legacy “rebuild a plant docket from scratch every shift” workflow with a register row that knows its supplier, equipment rate, operator classification, default operator / supervisor / shift, on-hire state, and auto-generation config.
When auto-generation is enabled, a per-plant Temporal schedule fires daily to create a labour-plant docket in draft status — pre-populated, priced, and allocation-tagged — which supervisors review rather than hand-build. The feature also introduces two reusable platform primitives: a sticky Alerts Header (first consumer: Plant Health Alerts) and a Temporal scheduler wrapper (first consumer: the per-plant auto-docket schedules). Both are available to every future Lacroo feature.
Core concepts — the four-layer model
Plant data is deliberately decomposed into four layers so each can evolve independently:
| Layer | Table(s) | Purpose |
|---|---|---|
| Catalog | contract_rate_lines | The priced contract line (“20 t excavator, $185/hr wet”). Contract-scoped, billing authority. A plant points at exactly ONE rate line via plant_register.rate_line_id, and that line — not hire_type — decides whether the plant records an operator pay dimension. contract_equipment_rates / contract_classifications survive only as legacy pass-through refs. |
| Register | plant_register | Stable identity of an individual machine on a project. Survives across hire windows. |
| Hire | hire_periods | Time-bounded on-site window, bound to the register row. The window IS the on-site truth — on-site is derived from it, not stored (see below). |
| Usage | dockets, docket_equipment | Daily recorded usage (meter hours, overtime). Auto-generated as draft; human-reviewed before confirm. |
Hire types
- Wet hire — operator is supplied by the hire company. Requires a contract classification so the operator’s labour can be priced. The plant can be registered before the operator is named; auto-generation is what waits on them (see Scheduler → wet hire needs an operator to ARM).
- Dry hire — customer provides the operator. No classification required.
Drift fix (PR-7). This section used to say auto-generation was wet-hire-only and “hard-blocked by a check constraint” for dry hire. That has not been true since the rate-engine cutover: the eligibility fork is
rate_line_id, nothire_type. A rate-line plant (every plant created since the cutover, wet or dry) materialises through the unmanned path and needs only adefault_shift_type; a legacy manned plant (norate_line_id) still needs wet hire + classification + operator + supervisor + shift. The DB CHECK, the Go guard and the client guard all fork on that same key — see Scheduler → supervisor is OPTIONAL.
Asset numbers
A plant’s asset / plant number (plant_id) is its own label, such as EX-001. It is optional. Two live plants in the same project cannot share one (owner ruling 2026-09-26: “Plant: Yes to all 3.”). A create, or an edit that changes the number, is refused if another live plant in the project already uses it, and the message names that plant (“Excavator 20t (PR-0040) already uses asset number EX-001.”). Someone whose role sees only some plants gets “Another plant already uses asset number EX-001.” instead. The form shows it under the asset number field.
- Same rule as restore. Numbers match with spaces trimmed and case ignored, so
ex-001andEX-001clash. A number with no letters or digits, such as-, never clashes. Archived plants never block a number. - Blank is always allowed, and so is saving a plant without touching its number.
- Existing duplicates stay editable. Plants that already shared a number before the rule keep it, and can still be edited, as long as the edit leaves the number alone. Changing one of them to a number another live plant uses is refused.
- Create, edit and restore take the same per-project lock, so two people saving the same number at once cannot both win.
On-site lifecycle — on-site is DERIVED, never stored
There is no on-site switch. A plant is on site exactly when its current hire window covers today in the project’s local calendar:
hire_start_date <= today AND (hire_end_date IS NULL OR hire_end_date >= today)today is (now() AT TIME ZONE projects.timezone)::date, so a Brisbane site and a UTC server always agree about which day it is. hire_end_date is inclusive — a plant closed with today’s date still reads on site for the rest of today.
Because the answer is computed rather than remembered, it can never drift out of step with the hire history, and it can express states a boolean could not:
| State | Meaning | Shown as |
|---|---|---|
on_site | window is open and covers today | green On Site |
scheduled | window exists but starts in the future | amber Starts 3 Sep |
ended | window’s end date has passed | gray Ended 30 Aug |
off_site | no hire window at all | gray Off Site |
The list, the detail header and the detail Overview all render the same pill — there is one word for one state.
Changing it:
- Start hire —
POST /on-site/opencreates ahire_periodsrow and stamps it ascurrent_hire_period_id. That IS going on site. A window starting next Monday readsscheduleduntil then, which is the honest answer. - End hire —
POST /on-site/closestampshire_end_date, force-disables auto-generation and unregisters the Temporal schedule. The hire pointer is deliberately retained as the record of the window the plant last ran under; re-hiring opens a brand-new hire period (register identity is preserved either way). - Both actions take a real date on the list bulk bar and on the detail header, so a hire that actually started last Tuesday can be recorded as such and one starting next month can be scheduled ahead. Neither is pinned to “today” any more.
- A plant can also be registered already on hire: the Add Plant sheet’s Hire Period section sends the window with the create, and the plant row plus its opening
hire_periodsrow are written in one transaction.
plant_register.is_on_sitestill exists as a column but is written by nothing and read by nothing — it is a shadow scheduled for removal in a later contract-phase migration. Do not add readers.
Auto-generation
A per-plant Temporal schedule keyed plant-register-auto-docket:{plant_id} fires at the plant’s default_shift_type start time in the project timezone. Each fire launches the PlantRegisterAutoDocket workflow → CreateAutoDocket activity, which runs health checks, resolves shift timings, and inserts a draft labour-plant docket with one docket_workers row (operator + classification) and one docket_equipment row (equipment rate + prior meter_end as meter_start), fully priced via the Award Engine + Layer 2 allocation resolver.
Workflow IDs are deterministic — plant-register-auto-docket:{plant_id}:{YYYY-MM-DD} — so Temporal dedupes duplicate runs automatically.
Editing the schedule (incl. after a re-hire)
A plant that already has a recurrence schedule keeps its Edit schedule control in every state — including after its hire has ended. That matters after a re-hire: closing a hire never rewrites the schedule, so its dates can be stale, and the editor is how they get fixed.
- While the plant’s current hire is on site or scheduled, saving a schedule whose Start/End dates actually changed also moves the hire period’s billing window to match (scheduling and billing never diverge). A save that only changes frequency or cost code leaves the hire window alone, and a window that would overlap another hire period for the same plant is rejected.
- On an ended or off-site plant, saving updates the recurrence only — a closed hire’s billing window is never rewritten. The next hire period defines the new window, and the editor re-seeds its dates from it.
Default Dayworks (optional)
A plant can carry an optional default day-works item (plant_register.default_dayworks_item_id, a pointer into the project’s dayworks_items catalogue). When set, every docket the plant generates gets that item attached as a pending day-works line:
- The item only — never a quantity. The line is written with
quantity = NULL, soline_total = COALESCE(quantity, 0) × rate = 0and nothing is billed while it waits. The quantity is entered on the docket; guessing it at configuration time would flowrate × qtystraight into docket totals, exports and invoices. - Docket level, one per docket (
docket_dayworks). It is not attached per line: the per-line table (docket_line_dayworks) only accepts worker / worker-group / scheduled-item entities, and an auto-generated machine docket has none of those. - Project-scoped. The item must belong to the plant’s project — the API rejects anything else with a 400, and the carry-over re-checks project membership and liveness at fire time. An item soft-deleted after the default was set is silently skipped; the docket still generates.
- Set it in the Add Plant sheet’s Auto-Generate Dockets section, or via
default_dayworks_item_idon create/update. The plant’s Overview tab shows the current default.
Day-works totals are deliberately kept OUT of dockets.grand_total — the docket matrix stays the single source of truth for docket value.
Adding a plant’s docket on request
A dry-hire machine’s docket normally comes from the daily schedule. Two places let you add one by hand, for a day the schedule did not cover. Both make the same docket the schedule would: no workers, priced as a draft, put on the diary for that day.
- From a diary. On the diary’s Dockets tab, + Add Docket now opens a Field worker / Plant choice. Choose Plant, pick a machine, and its docket is made for that diary’s own date, shift and supervisor. It works on any diary, past or submitted. Machines that are not on hire that day, or already have a docket, are refused.
- From the plant. On a dry-hire plant’s Dockets tab, Create docket asks for a date (today or earlier, weekends included). The plant’s default supervisor and shift decide the diary, exactly as the schedule does: if that supervisor has a diary for the date and shift, the docket goes on it with no question. A plant with no default shift shows a Shift choice. If the plant has no default supervisor, or that supervisor has no diary that day, the form lists the project’s diaries for that date and shift (supervisor and status) with a last option Not on a diary. If there are no diaries that day, the docket is made on its own and joins the diary when one is created.
Only one docket is made per plant and day, even when two people (or the schedule and a backfill) ask at the same moment: the second is told the day already has a docket. Both need Create dockets permission. A day that already has a docket for the plant is refused, and the plant screen offers to open the existing one. Stood-down days and plant whose rate is not yet confirmed are refused with a plain message. These dockets do not change the plant’s last-run status or alerts, which describe the schedule.
A machine that is already billed by hand
The daily schedule, Create docket and the past-day backfill leave a day alone when a live manual docket on the project already bills that machine for it: a docket that names the plant, or one with no plant that bills the plant’s own rate line or equipment rate. A rate shared by another plant on the project never counts, so one roller’s docket cannot hide a second roller. The skip is recorded as “a manual docket already covers this machine today” and is not an error. Putting a docket on a machine that already has a docket that day is refused, whether you change the machine or only move the docket to another day: “This machine already has a docket for this day.”
Past start dates
Saving a repeat rule whose Start is in the past first asks how many dockets that would create: “This creates N dockets for past days.” Confirming saves the schedule and, in the background, makes the missed dockets from the start date up to yesterday, using the same day rule as the daily schedule. Days that already have a docket, days with no hire period, and days on confirmed diaries are skipped and listed in the confirm (a diary confirmed after you looked is skipped too). Saving a backfill needs Create dockets as well as plant edit. A run covers at most 366 days. The plant’s Audit tab gets one row with the counts.
Archiving a plant
Deleting a plant archives it, and archiving ends its hire that day (owner ruling 2026-09-26: “Archiving a plant ends its open hire.”): a hire still running ends on the archive day, and one that had not started yet is cancelled. The Audit tab reads “ended the hire” and “archived this plant”. Its dockets and costs stay: every cost tile, report and export keeps counting them. Its page still opens, labelled Archived, with its hire history, dockets and audit. It takes no new work: no new dockets, hire, automation or allocations, and its automatic dockets stop. Archiving also closes the plant’s open alerts, and the Audit tab reads “closed its open alerts”. Its hire stays ended: the hire’s end date cannot be cleared or moved past the archive day (in the project’s timezone), and no new hire can be added. Other hire edits, such as an earlier end date or notes, still save. Only people whose role has Archive or restore plant (plants.delete) see Archive, on web and mobile. See API reference.
Restoring a plant
An archived plant’s page, and each archived row in the register’s Archived: Show view, offers Restore plant (“Hire and automatic dockets stay off until you start them.”). Anyone who can archive a plant can restore it: engineers and every admin tier. The plant takes new work again, off hire and with automatic dockets off; nothing is back-filled for the archived days, the hire the archive ended stays ended, and no docket changes. The audit trail reads “restored this plant”. If a live plant now uses the same register ID or asset number, the restore is refused and names that plant. Month-end CSV exports always include archived plants. See API reference.
Allocation hard-gate
Auto-generation cannot be enabled unless both the plant’s equipment rate and its operator classification have supplier_payment_allocations rows with confirmed_at IS NOT NULL. This guarantees every priced line on an auto-docket books to a cost-allocation target. The gate is enforced at four points:
POST /auto-gen/enable— structured 409 with deep link to the allocation screen.POST /plant-registerwithauto_generate_dockets: true(the Add Plant sheet’s tick box) — the same gate, but the plant row has already committed by the time it runs, so a block is reported as 201 +auto_gen_blocked, never as a failed create. The plant exists; automation is simply off, and the sheet raises a persistent toast carrying the same deep link. See Scheduler → two registration call sites.CreateAutoDocketactivity runtime guard — skip + auto-pause if the gate was invalidated after enablement.- Allocation-change listener — if an allocation loses
confirmed_atafter enablement, the plant’s schedule is paused and anallocation_invalidatedalert is raised.
User workflows
HC admin onboarding a supplier’s fleet
- Open Plant Register in the project sidebar.
- Click + Add Plant. The Add Plant sheet opens.
- Fill in identity (hire type, name, plant ID, manufacturer / model).
- Select the supplier, the contract, the equipment schedule line, and — for wet hire — the operator classification. The allocation-status banner shows green (confirmed) / amber (unconfirmed) with a deep-link to fix.
- In Hire Period, set Hire Start (and optionally Off Hire) if the plant is on hire or about to be. Leave Hire Start blank to register a plant that isn’t hired yet. The pill beside the heading previews the state the plant will land in.
- Tick Auto Generate Dockets. The tick box is enabled once a Hire Start date exists (auto-generation with no hire window could only ever skip), and ticking it reveals everything the schedule needs: Supervisor, Shift Type, an optional Default Dayworks item, the plant-level cost code, and the recurrence builder. Leave the recurrence off to draft a docket every day the plant is on hire.
- The Supervisor list is the project’s supervisors: members whose role on the project is Supervisor or Senior Supervisor (Docket Supervisor). It is optional. With none picked, the generated dockets carry no supervisor: they will not appear in any supervisor’s diary, and you find them in the Docket Workbench and on the plant’s own Dockets tab. Pressing Create Plant in that state asks once for confirmation (the button relabels to Create without supervisor) — no modal, and any edit resets it. Assigning a supervisor later applies to future dockets only.
- Leaving the box unticked is exactly the pre-A2 behaviour: the plant is registered with no automation and no Temporal schedule, and you can enable it later from the plant’s Overview tab.
- Save. The row appears in the list with a green Auto-Gen badge and a Temporal schedule is registered — no second trip to the Overview tab. If the allocation gate (or Temporal) refuses, the plant is still created and a toast explains that auto-generate is off, with a deep link to fix it.
Supervisor confirming daily draft dockets
- Next morning, drafts are already in the docket workbench (linked to today’s diary if one exists; orphaned otherwise — the
attach_orphan_dockets_to_diarytrigger auto-binds them retroactively when the diary is created). - Supervisor opens each draft, fills in
meter_end, reviews pricing, submits. - No typing of operator, classification, equipment, start / end time — all pre-filled.
Changing many plants at once
- On the list, tick the plants to change (or tick the whole page, then Select all N matching to reach every plant the filters match, up to 500).
- Click Edit on the selection bar. Set any of Default supervisor, Default shift, Hire start and Hire end; a field left blank is kept as it is.
- Save. Each plant is changed under the same rules as editing it on its own page, and the hire dates move the plant’s current hire under the same rules as editing that hire. The result names the plants that were left alone and why (for example, a plant with no hire, a closed hire, a hire that would run into another, or a plant on auto-dockets whose shift would be cleared).
- Undo on the result puts every changed plant and hire back, unless something has changed them since.
Changing hire dates needs the Manage hire periods permission as well as Edit plants.
What undo can and can’t put back is in Bulk Edit & Undo.
Handling a plant breakdown mid-shift
- Supervisor opens the plant’s detail page.
- Clicks End hire. A small popover asks for the off-hire date, defaulting to today (the date is inclusive, so the plant reads on site for the rest of that day).
- On submit, the API closes the active
hire_period, force-disables auto-generation, and unregisters the Temporal schedule. The plant’s pill flips toEnded {date}. The other 14 machines on hire keep running as normal.
UI surfaces
Web (/admin/plant-register)
- List page — 5 KPI tiles (Plant On Site · Auto Dockets Enabled % · Plant & Equipment Cost MTD · Plant & Equipment Cost Last Month · On Site With No Supervisor), alert section, filterable TanStack Table with the derived Status pill, contextual bulk action bar (Edit — default supervisor, default shift and the current hire’s dates, with Undo on the result — Enable / Disable Auto-Gen, Start hire / End hire — each a dated popover — Archive). An Archived: Hide / Show filter lists archived plants, badged Archived, each with Restore plant; they cannot be bulk-selected or bulk-edited, and Select all matching is offered only while archived plants are hidden. Changing a filter clears the row selection, so a bulk action can never reach a plant that scrolled out of the filtered view. The cost tiles sum the
grand_totalof dockets attributed to a registered plant and carry a count-based coverage sub-label saying how many of the window’s plant dockets are attributed so far — see API reference. The “No Supervisor” tile is a link: it drills into the list filtered to exactly the plants it counted. - Add Plant sheet — 4 labelled sections (Identity, Supplier & Rate Source, Hire Period, Auto-Generate Dockets). The Hire Period section is two date pickers plus the derived pill — there is no on-site checkbox. The Auto-Generate Dockets section is one tick box: enabled once a Hire Start exists, and when ticked it reveals the whole schedule (supervisor, shift, default dayworks, cost code, recurrence) AND turns automation on as part of the create. Built with the Radix two-phase mount pattern to survive Next.js hot-reload cycles without unmounting while open.
- Detail page — RSC with
<Suspense>tabs: Overview · Hire History · Dockets · Audit. The header action is Archive plant (“Any hire ends today, or is cancelled if it hasn’t started. Its dockets and costs stay.”), shown to people who can archive plants. An open page stays current when someone else changes the plant: its tabs refetch, and it re-renders when the plant is archived or restored elsewhere. An archived plant’s page opens with an Archived badge and date, a read-only form, and one action, Restore plant. The register, the supplier portal and mobile show it no hire or automation status, and its audit trail reads “archived this plant”. - Sticky Alerts Header — mounted in
apps/web/src/app/admin/layout.tsx. Hidden when no open alerts; thin red / amber bar with severity-separated counts otherwise. - CSV export — deterministic, sorted by
display_id ASC, filter-aware. Always includes archived plants, marked in thestatuscolumn. Cost-controller uses this for monthly reconciliation.
Mobile (Expo)
- Office role: filterable list (with an Archived: Hide / Show filter) + detail page with recent dockets, plus the Start hire / End hire, auto-gen and Archive plant actions. An archived plant opens badged Archived; its one action is Restore plant. The mobile hire actions are one-tap “as of today” (a date picker is web-only) and they gate on the same derived state, so a plant whose hire is merely scheduled offers End hire rather than opening a second window. Field operator / write surfaces are deferred to Tier 2.
- Supplier portal: supplier-side read of their own plant per project + hire status.
Supplier Portal (Web)
/supplier-portal/plant-register— read-only list of the supplier’s own plant across every HC they work with. Scoped bysession.supplier_org_id.
Screenshots
Screenshots to be captured post-launch — will live in
apps/docs/public/screenshots/plant-register/.
overview-list.png— admin list page with the 5 KPI tiles and alert section.add-plant-sheet.png— Add Plant sheet, Auto-Generate section focused.detail-hire-history.png— detail page, Hire History tab.alerts-header.png— sticky alerts header in admin layout.mobile-list.png— Expo office list page.
Related docs
- API Reference — every endpoint, request / response, OpenAPI schema references.
- Scheduler Primitive — reusable Temporal Schedules wrapper; use this for any future cron-shaped workflow.
- Plant Health Alerts — the 6 MVP alert codes, the sticky header primitive, and how to extend
alert_kinds.
Spec & plan
- Spec:
docs/superpowers/specs/2026-04-20-plant-register-design.md - Plan:
docs/superpowers/plans/2026-04-20-plant-register.md