Skip to content

05 — Permit Detail: the shared section contract

Status: normative. Both frontends implement the same sections, in the same order, with the same field grouping. Ruled 2026-08-22 (the prompt & decision log (PROMPT-LOG.md), session 2, items 1.1 / 1.2): the detail page must show everything the user entered, not a summary, and the two apps must not diverge.

This file is the contract that keeps them aligned. It lives only here (wayfinder 092) — the contractor and safety repos each carried their own copy at this same relative path for a while, both copies drifted (one stale on the pre-067 single-day work window, one on outdated section counts), and nothing checked them against this file or each other. Both repo copies are deleted; each repo's AGENTS.md points here instead (../smart-work-permit-docs/docs/main/dev-handoff/05-permit-detail-sections.md, siblings on disk).

Why this exists: PMT-010 shipped a correct but lite detail page — banner, info card, audit timeline, QR. Four of the payload's richest keys (workers, photos, jsaSteps, latestSafetyReading) were never rendered at all, so a foreman could not see the JSA or the worker roster they had just filled in. If each app now fixes that independently they will invent two different section layouts for one permit.


1. The payload is the spec

Every section below is backed by a real key on GET /permits/:iddata. Do not invent a section with no key behind it, and do not leave a key unrendered. The full key list, from smart-work-permit-api/docs/openapi.json:

id type title description location foreman
startDate endDate dailyStart dailyEnd scheduleNote outdoorWork
ppeDeclared ppeNote status
createdById createdBy createdAt updatedAt submittedAt
approvedById approvedBy approvedAt rejectedReason rejectedAt
closedById closedBy closedAt closureChecklist
closeRequestedAt closeRequestedById closeRequestedBy closeRequestedRole closeRequestReason
fireMonitorStartedAt qrIssuedAt pinId
entrantCount fireWatch gasReadingStatus
jsaSteps workers photos latestSafetyReading validationSummary overlappingPermits

latitude/longitude and planId/planX/planY/areaId are gone — wayfinder 104/105/106 deleted Area and the permit's geo coordinate, and replaced both with pinId, a single reference to a named Pin on a FacilityPlan (verified against smart-work-permit-api/src/modules/permit/lib/permit.model.ts's entity).

workDate, workTimeStart and workTimeEnd are gone — not renamed, removed. A permit's work window is startDate/endDate (a calendar range) plus dailyStart/dailyEnd (one daily window repeating across it) plus a free-text scheduleNote. dailyStart/dailyEnd come back off a Postgres TIME column anchored to 1970-01-01T00:00:00Z: convert to local time to display them, never render them as a UTC wall clock, or every migrated permit shifts by the deployment's offset with nothing failing.

Item shapes are already modelled in both apps (contractor: src/models/modules/permit/Permit.model.ts + src/models/response/permit/PermitRes.model.ts). Reuse those models — do not re-declare a parallel shape.


2. Sections, in this order

Both apps use the same seven sections and the same order. Presentation may differ (the Safety app is mobile-first, the Contractor app desktop-first); the grouping and the order may not.

#SectionKeys it owns
1Overviewtype, title, id, status, description, foreman, outdoorWork
2Where & whenpinId, location, startDate, endDate, dailyStart, dailyEnd, scheduleNote
3Safety readingslatestSafetyReading, validationSummary, outdoorWork (as the bypass explanation), gasReadingStatus
4Workers & PPEworkers[], photos[]
5JSAjsaSteps[]
6Closure & Fire WatchclosureChecklist, entrantCount, fireWatch, fireMonitorStartedAt, closedBy, closedAt
7Audit trailGET /permits/:id/audit

Where each app stands, as of 2026-09-10 — read this before treating §2 as shipped.

The Safety/Inspector app implements all seven, in this order (safety-officer/pages/review-detail/), with WhereWhenSection.vue as §2. The contractor app does not yet have a Where & when section: it renders the work window inside its Overview info card (PermitInfoCard.vue, verified 2026-09-12) and has no component for the pin at all — confirmed by grepping that repo's pages/permit/pages/detail/ for pin/Pin, which finds nothing. §2 is therefore the target for the contractor app, not a description of it — closing that gap is its own ticket, and this table is the contract it should close against.

Blocks that sit outside the seven numbered sections, verified against both apps' current detail pages (smart-work-permit-frontend/.../review-detail/pages/SafetyReviewDetailPage.vue, smart-work-permit-contractor-frontend/.../detail/pages/PermitDetailPage.vue, 2026-09-12):

BlockWhereWhy it is not one of the seven
Overlapping permitsSafety app only, its own tab after ClosureAdvisory, and it is the officer's decision surface. OverlappingPermitsSection.vue. Not shared: the contractor app has no equivalent tab
Inspector visit timelineSafety app only, its own tab after Overlapping permitsInspectorVisitTimelineSection.vue. Not shared as a standalone tab — the contractor app has none — but the underlying data is no longer safety/inspector-only: wayfinder 119 opened GET /permits/:id/inspector-visits to the contractor on their own permit, notes included (this resolves wayfinder 083, closed by the round-4 owner ruling). The contractor app surfaces it inside its Report tab instead (below), not as its own section
Permit reportBoth apps, their own last tab (wayfinder 112)PermitReportSection.vue in both repos — a visits view (reusing the now-open inspector-visits data above) plus a closure summary once CLOSED, printable via a print stylesheet. Shipped identically in both apps, so it is genuinely shared, but it postdates this contract's seven sections and is not itself one of them — no ticket has folded it into §2's table as an eighth numbered section

Lifecycle timestamps (createdBy/createdAt, submittedAt, approvedBy/approvedAt, rejectedReason/rejectedAt) belong to the status banner + Overview, not to a section of their own — they are the story of the status, and the audit trail already lists them as events.

QR (qrIssuedAt) is a side panel, not a numbered section: right rail on desktop, collapsed below the main column at narrow widths. Rendered only for ACTIVE / FIRE_MONITOR — requesting a QR for a DRAFT is a guaranteed 403.

Tabs vs. stacked

Tabs are a presentation choice, not part of this contract. Whichever you use, all seven sections must be reachable, in this order, with these names in both locales. A section whose data is empty renders an explicit empty state — it is never hidden, because "no JSA rows" and "JSA not loaded" must not look identical.


3. Section rules that are not obvious

§2 Where & when. The permit carries only pinId — a single reference to a Pin (named, positioned by safety) on a FacilityPlan. Resolve the pin and its plan by id and render them by name, never as raw numbers; the pin is drawn on the exact plan it was placed on. Flag it stale when either the pin or its plan has since been deactivated (read off their own active flags), not hidden — a stale pin is known-stale, never silently dropped. There is no coordinate any more: latitude/longitude and the "open in maps" link they backed were reversed and removed from the wire the day after they shipped (wayfinder 068, reversed by round 4) — do not build one. overlappingPermits — rendered by the safety app in its own block, not inside §2 — is { checked, permits[] } and is advisory only; checked: false (this permit has no pinId, so nothing was compared — wayfinder 105 re-keyed this from areaId) must not render as the same reassuring empty state as checked: true, permits: []. Verified against smart-work-permit-frontend/src/pages/safety-officer/pages/review-detail/components/WhereWhenSection.vue, the one component that currently implements this section.

§3 Safety readings — render the server's verdict, never recompute it. validationSummary is { scope: 'safety_readings', passed, failures: [{ field, errorCode, message }] }. Localize off errorCode; never render message. Its scope is readings only — certificate gating happens at submit and is not in it, so do not label it "all checks passed". so2 is collected by the wizard but is not on the wire (GAPS row K) — do not display a value the server never stored.

gasReadingStatus is the server's verdict on the same screen: { dueAt, overdue, graceEndsAt, escalated }, null unless the permit is Confined Space in ACTIVE or FIRE_MONITOR. Render it; never recompute the two-hour interval or its half-hour grace client-side.

§4 Workers & PPE. Each row carries workerId plus the worker's name and role echoed for display. Identity is the id — never send or match a name. For Confined Space, bloodPressure and alcoholReading carry the Thai ministerial-regulation health check and must be shown with their pass/fail badge. photos[] is keyed by slotKey; fileRef is a stored path, not a URL — resolve it the way the app already does elsewhere, and show a slot that was required but never filled as explicitly missing.

§5 JSA. Group by phase (Pre / Process / Post) and order by sortOrder within each phase. Show the per-phase row count.

§6 Closure & Fire Watch. The countdown derives from the server's fireWatch.remainingSeconds, never a client-start timestamp — a page reload must not reset it. entrantCount is the count only; entrant names are not readable here (GAPS row I). Closure is blocked while entrants are inside or the Fire Watch is running, and the client must attempt the call and render the server's verdict rather than pre-empting it.

§7 Audit trail. Append-only, hash-chained. No edit or delete affordance anywhere, in any app.


4. Where the two apps legitimately differ

Same data, same sections — different actions and different emphasis.

Contractor (owner)Safety Officer (reviewer)
ActionsEdit draft, Submit, Mark complete, Close their own permitApprove, Reject (with reason + e-signature), Close any permit as a backstop
§3 emphasisTheir own readings, with the failures they must fixFull review surface — the officer decides on this screen, so every reading and every failure is shown expanded by default, not behind a "show more"
§4 emphasisRoster they enteredCertificate validity per worker; a blocked worker is the reason to reject
Role gatingOwner-only; another contractor's permit is a 403Role-gated to safety_officer

The Safety Officer app must not recompute validation client-side on the review screen — it renders the backend's pass/fail summary. That is a standing rule, not a preference.


5. Definition of done for 1.1 / 1.2

  • Every key in §1 is rendered somewhere, or has a written reason in progress.md why it is not.
  • Section names exist in both locales in both apps; default UI locale is Thai.
  • Timestamps display Asia/Bangkok (stored UTC).
  • Empty sections show an explicit empty state.
  • ./init.sh green in the repo, and node ../scripts/check-contract-sync.mjs green.
  • A narrow-width pass (375–390px) — the Safety app is mobile-first and the officer reviews in the field.