Merge remote-tracking branch 'origin/main' into feature/reporting-redesign

# Conflicts:
#	CONTEXT.md
#	src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx
#	src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts
#	src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-19 00:22:52 +01:00
commit 948a313c97
117 changed files with 106409 additions and 370 deletions

View file

@ -21,7 +21,9 @@
"Bash(npx drizzle-kit *)",
"Bash(pip install *)",
"Bash(terraform fmt *)",
"Bash(gh label *)"
"Bash(gh label *)",
"Bash(python3 -c \"import psycopg2\")",
"Bash(python3 -c \"import psycopg\")"
],
"deny": [
"Bash(npx drizzle-kit generate)",

6
.gitignore vendored
View file

@ -46,3 +46,9 @@ docs/wip/**
# Personal Claude Code settings (per-developer, not shared)
.claude/settings.local.json
# transient local scratch (dev session helpers)
.mint.cjs
.chip-preview.*
.shot.cjs
scratch-*.cjs

View file

@ -0,0 +1,78 @@
---
target: portfolio page (property table + toolbar + no-plans state)
total_score: 27
p0_count: 1
p1_count: 1
timestamp: 2026-07-13T21-29-24Z
slug: rc-app-portfolio-slug-components-propertytable-tsx
---
Method: dual-agent (A: ad10a436 design-review · B: a517f2c1 detector+evidence)
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| 1 | Visibility of System Status | 3 | Good results count + spinner overlay, but first-load is bare "Loading properties…" (no skeleton) and a no-plans portfolio gives zero signal that modelling is the missing step. |
| 2 | Match System / Real World | 3 | "Plan Cost / No cost", "Expected EPC" read well; but blank EPC bubbles + "—" Ref communicate nothing about *why* they're empty. |
| 3 | User Control and Freedom | 3 | Clear/Clear-all on filters + selection; no undo on optimistic tag assign, no cancel-export. |
| 4 | Consistency and Standards | 2 | Two colour systems: toolbar uses brandblue/primary; filter sidebar + Edit-Columns checkboxes use raw black/bg-black/accent-black. |
| 5 | Error Prevention | 3 | Delete has a preview modal, export gated >6000 — but the delete modal has Cancel and no wired Confirm (dead-end). |
| 6 | Recognition Rather Than Recall | 3 | Columns labelled, Current EPC has a "?" tooltip; but Current/Lodged/Expected are three identical blank bubbles with no legend when empty. |
| 7 | Flexibility and Efficiency | 3 | Column visibility, quick filters, filter builder, export — strong; undercut by a 7-row page size on a desk tool. |
| 8 | Aesthetic and Minimalist Design | 2 | The no-plans grid is a wall of "—", blank bubbles, italic "No cost"; 6 top-level toolbar actions + two dark CTAs + shouty micro-labels. |
| 9 | Error Recovery | 3 | load-more + export fail silently; tag-assign error only shows inline in the popover footer. |
| 10 | Help and Documentation | 2 | One tooltip (Current EPC); no inline guidance connecting an unmodelled portfolio to "Run modelling". |
| **Total** | | **27/40** | **Acceptable — solid plumbing, dragged down by the empty state + consistency** |
## Anti-Patterns Verdict
**LLM assessment:** Not egregious slop — the table is competently built and restrained — but three tells betray a generated-template lineage: tiny uppercase black micro-labels everywhere (`RESULTS:` font-black uppercase, `CURATE SELECTION` at 9px), `rounded-xl` + `shadow-sm` cards the brief names as an anti-reference, and em-dash-as-content filling 56 of 9 columns. Bones are institutional; finish is generic-admin.
**Deterministic scan:** detect.mjs exit 0 — **zero findings**. No gradient text, decorative gradients, side-stripe borders, glassmorphism, over-rounded radii (only rounded-lg/xl/full), heavy shadows (only shadow-sm), or arbitrary z-index. The mechanical slop tells are genuinely absent — the problems here are IA/hierarchy/contrast that a static scanner can't see, which is exactly where the design review earns its keep.
**Visual overlays:** none — browser automation unavailable (arm64 container can't run the bundled x86 Chromium under qemu). Fallback: static grep evidence corroborated the review (8 placeholder "—" cells in propertyTableColumns.tsx: 256/336/442/451/459/470/483/494; text-slate-300 at 384/442/451/494/524; 6 toolbar controls at 602/617/658/686/693/742; no dedicated no-plans empty-state component).
## Overall Impression
The interaction plumbing is genuinely good (optimistic tag writes, filter-scoped selection, provenance signalling). What lets it down is a single unaddressed truth: **the default state of every brand-new portfolio — properties added, nothing modelled — is the one state with zero design investment.** A first-time housing-association user lands on 185 rows where Ref is "—", three EPC columns are blank, Expiry is "—", and every Plan Cost is a faint italic "No cost". The read isn't "fresh start", it's "this tool is broken / my import failed" — the worst possible first impression for a product whose promise is *trustworthy numbers*. The single biggest opportunity is to design the unmodelled state as a first-class surface, not a fallthrough.
## What's Working
1. **The Current-EPC "?" tooltip** — exactly right for the institutional register: explains a load-bearing figure inline without cluttering the header. A pattern to extend, not remove.
2. **The Predicted pill + provenance model** (amber "Predicted", ADR-0002) — genuine trust-signalling that tells analysts which EPCs to trust less; precisely the "trustworthy numbers" value the brand claims.
3. **Optimistic tag writes across every cached page + filter-scoped selection surviving pagination** — Linear/Stripe-class plumbing, invisible when it works.
## Priority Issues
**[P0] The no-plans / unmodelled state has no design at all.** `EmptyPropertyState` only fires when there are literally zero rows; a portfolio with 185 unmodelled properties falls straight through to the full table with 56 empty columns. This is the *default* state of every new portfolio and the exact case flagged as "terrible". **Fix:** detect "has properties, zero plans/EPC" and render a purpose-built view — collapse/hide the three EPC + Expiry + Plan Cost columns, show Address/Ref/Tags at full density, and surface one reassuring banner ("185 properties added — run modelling to generate EPC ratings and retrofit costs →") wired to the existing Run-modelling route. *Command: `onboard`*
**[P1] Empty cells fail contrast and read as breakage.** `text-slate-300 italic` "No cost" and slate-300 em-dashes / "Unknown" are below WCAG AA on white and make populated data indistinguishable from missing data at a glance. Four different "empty" renderings coexist (blank `w-5 h-5` div, "—", italic "No cost", "Unknown"). **Fix:** raise empty text to slate-400/500 and pick ONE absence vocabulary. *Command: `harden`*
**[P2] Six top-level toolbar actions with two competing dark CTAs.** Filters, Edit Columns, Export, Tags▾, Add properties▾, Run modelling in one undifferentiated row; Add properties and Run modelling are both navy `bg-primary` — no primary focus, and the brief says navy belongs "in small doses". **Fix:** one dark CTA at a time (Add properties when empty → Run modelling once populated), fold Export/Edit Columns/Tags into a quieter secondary group. *Command: `distill`*
**[P3] Colour-system drift between toolbar and filter sidebar.** Toolbar uses brandblue/primary; `PropertyFilters.tsx` and the Edit-Columns checkboxes use raw black/bg-black/accent-black — two systems on one page. **Fix:** replace every black token with the brand navy/brandblue scale. *Command: `colorize`*
**[P3] No first-load skeleton; silent failures.** First load is a lone "Loading properties…" string; load-more and export swallow errors (`// silently ignore`). For a data-trust product a silent export failure is a credibility risk. **Fix:** skeleton rows + a toast/inline error on export and load-more failure. *Command: `polish`*
## Persona Red Flags
**Alex (power user):** `pageSize: 7` on a large desk screen means paging 27× through 185 homes; lazy load-more only triggers on reaching the last page — no jump-to-end or density control. Wants 50100 rows + persistent EPC/cost sort.
**Sam (accessibility):** slate-300 "No cost"/"Unknown" fail AA outright; the quick-filter clear control is a `<span role="button" tabIndex=0>` nested *inside* a `<button>` (invalid interactive nesting, unpredictable for SR/keyboard); the empty EpcLetterBubble is an unlabelled `<div className="w-5 h-5" />` with no "no data" text.
**Riley (edge/empty-state stress):** the 185-no-plans grid is the headline failure — renders as a wall of placeholders with no explanation. Compounded by the delete modal's Cancel-with-no-Confirm dead-end (and `deletePreview` never populated, so the impact list is always empty), and load-more failing silently leaving a partial list.
## Minor Observations
- Give the empty EpcLetterBubble an aria-hidden "—" so the column isn't three invisible gaps.
- `RESULTS:` in font-black uppercase is louder than the numbers it introduces — invert the weight.
- Export button sits in `bg-slate-100` while neighbours are `bg-white` — reads as disabled when it's active.
- The floating chat launcher (third-party) overlaps the right-edge Actions/⋯ column and can occlude the row menu.
- Delete modal `DialogDescription` promises "Review the impact below" but `deletePreview` is never fetched in this file — always empty (engineering bug).
- Heavy useEffect/useMemo usage contradicts the repo CLAUDE.md React guidance (for the engineering review, not design).
## Questions to Consider
1. If the default state of every new portfolio is "properties added, nothing modelled", why is that the state with zero design investment — shouldn't the unmodelled view be the primary design target?
2. Three EPC columns collapse to three identical blank bubbles with no data — should Lodged/Expected be progressive-disclosure behind the modelled state?
3. Two navy buttons say "both of these are the most important thing" — which ONE action is the product betting a first-time user should take, and does the layout make that bet visible?

View file

@ -12,7 +12,7 @@ import { Home, Zap, Leaf, LineChart, FileQuestionIcon } from "lucide-react";
import { formatNumber, sapToEpc } from "@/app/utils";
import type {
AverageMetrics,
EstimatedCounts,
EpcCoverageCounts,
TotalMetrics,
ScenarioOverlayMetrics,
MetricKey,
@ -66,18 +66,18 @@ export function DashboardSummaryCards({
total,
totals,
averages,
estimatedCounts,
epcCoverage,
scenarioOverlay,
loading = false,
}: {
total: number;
totals: TotalMetrics;
averages: AverageMetrics;
estimatedCounts: EstimatedCounts;
epcCoverage: EpcCoverageCounts;
scenarioOverlay?: ScenarioOverlayMetrics | null;
loading?: boolean;
}) {
const missingEpcCount = estimatedCounts.estimated;
const missingEpcCount = epcCoverage.withoutEpc;
const missingEpcPercent = total > 0 ? (missingEpcCount / total) * 100 : 0;
const averageCurrentEpc = sapToEpc(averages.avg_sap || 0);

View file

@ -9,21 +9,21 @@ import {
} from "@/app/shadcn_components/ui/card";
import { motion } from "framer-motion";
import { FileQuestion, AlertTriangle, TrendingDown } from "lucide-react";
import type { EstimatedCounts } from "./types";
import type { EpcCoverageCounts } from "./types";
export function EpcQualityCards({
estimatedCounts,
epcCoverage,
total,
expiredEpcs,
likelyDowngrades,
}: {
estimatedCounts: EstimatedCounts;
epcCoverage: EpcCoverageCounts;
total: number;
expiredEpcs: number;
likelyDowngrades: number;
}) {
// Missing EPCs (estimated = true)
const missing = estimatedCounts.estimated;
// Homes with no certificate at all — neither lodged nor historical.
const missing = epcCoverage.withoutEpc;
const pctMissing = total > 0 ? (missing / total) * 100 : 0;
// Expired EPCs
@ -39,7 +39,7 @@ export function EpcQualityCards({
icon: FileQuestion,
color: "text-red-600",
value: missing,
subtitle: `${pctMissing.toFixed(1)}% missing EPC records`,
subtitle: `${pctMissing.toFixed(1)}% have no EPC on record`,
barColor: "bg-red-500",
barWidth: pctMissing,
gradient: "bg-gradient-to-br from-white to-red-50/20",

View file

@ -101,15 +101,19 @@ A measure whose installation involves major internal works for residents (intern
_Avoid_: invasive (unused here), wet trades (a narrower, overlapping set: plaster-and-screed work)
**Measure exclusion**:
A Scenario constraint that takes a measure off the table for the optimiser. The *only* measure-constraint concept on a Scenario — there is no stored inclusion list; "only Solar PV + ASHP" is expressed by excluding everything else. An empty exclusion set is valid and means every measure is available.
A Scenario constraint that takes a measure off the table for the optimiser. The only measure-*exclusion* concept on a Scenario — there is no stored inclusion list; "only Solar PV + ASHP" is expressed by excluding everything else. An empty exclusion set is valid and means every measure is available. (Exclusions decide *which* measures are available; **Fabric first** decides the *order* they are considered — the two compose.)
_Avoid_: inclusions, allowed measures (as a stored concept — UI may present "allowed/excluded" toggles, but what's captured is the exclusion set)
**Fabric first**:
A Scenario constraint that forces the optimiser to apply every viable **fabric measure** (insulation, glazing, ventilation) before it will consider heating or renewables — the heating/renewables tier is reached only if the Scenario's upgrade target is still unmet once fabric is exhausted. Unlike a Measure exclusion (which removes a measure entirely), it constrains the *order* measures are considered, not which are on the table; the two compose (an excluded fabric measure simply isn't among those forced in). Off by default, set at creation and immutable like the rest of the config. The modelling backend owns the ordering logic and the fabric/heating split — the app only records the flag (trigger-payload field `enforce_fabric_first`).
_Avoid_: fabric only (the quick-start that *excludes* heating/renewables outright — fabric-first still allows them as a fallback), sequencing/phasing (reserve for PIBI installation order), enforce fabric first (a UI verb on the toggle, not the domain noun)
**Modelling run**:
One user-initiated act of triggering modelling: a set of Scenarios crossed with one filter-defined property selection within a Portfolio. A durable record of *what was asked* — the Run filters, the Scenarios, who triggered it, when, and the matched-property count shown at preview — so Plans can be traced back to the selection that produced them, alongside how the work is going. A Scenario is the *question*; a Modelling run *asks* it (possibly again, possibly for a different property selection). Re-running an already-modelled property appends a newer Plan; latest wins on read.
_Avoid_: job, batch, trigger request, modelling task (the pipeline's execution records)
**Run filter**:
The property-selecting constraints of a Modelling run: postcode, property type, and built form (each multi-select; tags later). An absent filter means *unconstrained* — "all postcodes" is the absence of a postcode filter, never an enumeration. Type and built form are the canonical enum vocabularies plus **Unknown**, which selects properties with no resolvable value at all. Resolution follows the Landlord-override rule: an override fact beats an EPC-derived value; the modelling backend is the single authority for resolving filters to properties (the app never re-implements the rule — matched counts come from the backend).
The property-selecting constraints of a Modelling run: postcode, property type, built form, and **Tags** (each multi-select). An absent filter means *unconstrained* — "all postcodes" is the absence of a postcode filter, never an enumeration. Type and built form are the canonical enum vocabularies plus **Unknown**, which selects properties with no resolvable value at all; Tags are the Portfolio's own Tags (multi-select is **any-of**). Resolution follows the Landlord-override rule for type/built-form (an override fact beats an EPC-derived value) and a **direct membership lookup** for Tags — but in all cases the modelling backend is the single authority for resolving filters to properties (the app never re-implements the rule — matched counts come from the backend, which reads the same Tag membership the app writes). See [ADR-0013](./docs/adr/0013-tags-are-app-owned-property-groupings.md).
_Avoid_: property query, segment, search (the postcode-search journey is unrelated)
### Reporting
@ -130,6 +134,26 @@ _Avoid_: PC cost, prelims
The reporting view of a Portfolio with no Scenario applied — every figure is Effective performance, aggregated. Also the "before" side of any scenario comparison ("Current → After scenario").
_Avoid_: baseline (reserved for property-level performance concepts), portfolio overview
### Live projects
**Project**:
A delivery programme within a Portfolio — a batch of Properties taken through retrofit together — identified by its HubSpot **project code** (`hubspot_deal_data.project_code`; a stable **project id** will replace the code once the HubSpot ops board is settled, so the code is the *current* identifier, not the essence). A Property whose deal carries no project code sits outside every Project (the "Unknown Project" grouping) and cannot be reached by selecting Projects.
_Avoid_: programme, scheme, deal group, batch (a **Batch** is a separate per-deal field)
**Bulk document download**:
An app-triggered, asynchronous assembly of a single ZIP of the documents held against a chosen set of Properties — picked as a row selection in the Documents tab, scoped by the tab's current Project/group — delivered as a time-limited link emailed to the requester (and, within the session, retrievable in-app). A selection that matches no documents produces no ZIP: it fails rather than emailing an empty archive. Properties are matched to their documents by **landlord property id**. See [ADR-0011](./docs/adr/0011-app-owned-task-marker-in-task-source.md).
_Avoid_: bulk export, document export, zip download
### Tags
**Tag**:
A user-created, coloured label owned by a Portfolio, used to form an arbitrary **group** of its Properties for bulk actions (e.g. running Scenarios over the group, or excluding it from a Reporting view). Flat (no hierarchy) and many-to-many: a Property carries zero or more Tags, and a Tag labels zero or more Properties. Distinct from a **Project** (a HubSpot-sourced grouping, at most one per Property) and from a **Scenario** (a modelling question): a Tag is an app-owned, hand-curated selection with no external source and no modelling semantics of its own.
_Avoid_: label, category, group (the group is the *effect* of a Tag, not a separate entity), segment, tag group
**Bulk tag assignment**:
Assigning one Tag to many Properties at once from a small uploaded file (CSV/Excel) of property identifiers — either **landlord property id** or **UPRN**, whichever column the file carries. Parsed and matched entirely in the app (no FastAPI pipeline); the target Tag is chosen in the upload dialog, not named in the file. An identifier that matches no Property is reported back (never silently dropped); an already-tagged match is a no-op. The parallel filter-driven path assigns a Tag to **every Property matching the current property-table filter**.
_Avoid_: tag import, bulk tagging (verb is fine; the noun is Bulk tag assignment)
## Lifecycle
A **BulkUpload** moves through these statuses:
@ -181,11 +205,29 @@ The act of substituting a corrected set of performance metrics in place of the L
_Avoid_: override, adjustment, correction
**EPC provenance** (`epc_property.source`):
Whether a property's EPC picture is a real certificate or a gap-fill. `lodged` = a real public/landlord EPC exists; `predicted` = no certificate, so the EPC was estimated from nearby properties (EPC Prediction gap-fill). Independent of Rebaseline: a `lodged` property may still be rebaselined, and a `predicted` property still carries Lodged-performance figures (mirrored estimates), so the presence of `lodged_*` columns does **not** imply a real certificate — only `source = lodged` does.
Whether a property's EPC picture is a real certificate or a gap-fill. Three values:
- `lodged` — a real, current public/landlord EPC exists.
- `predicted` — no certificate exists anywhere, so the EPC was gap-filled from nearby properties (EPC Prediction).
- `expired` — also gap-filled, but conditioned on **this dwelling's own expired certificate** from the historic register (Model ADR-0054). The gov EPC API only serves certificates registered since 2012, so a pre-2012 dwelling looks EPC-less to ingestion even though a real, stale certificate exists for it.
Independent of Rebaseline: a `lodged` property may still be rebaselined, and a gap-filled property still carries Lodged-performance figures (mirrored estimates), so the presence of `lodged_*` columns does **not** imply a real certificate — only `source = lodged` does.
_Avoid_: estimated EPC (reserve "estimated" for the UI signal), source EPC
**Predicted slot**:
`predicted` and `expired` are two flavours of one slot — a property holds at most one of them, and a re-ingestion may flip which. Every query must therefore address the **slot** (`source IN ('predicted','expired')`), never a single member: matching `source = 'predicted'` alone makes `expired` rows invisible, and since `epc_property.source` is plain `text` with no enum or CHECK constraint, nothing catches it. Mirrors `PREDICTED_SLOT_SOURCES` in the Model repo. See [ADR-0014](./docs/adr/0014-epc-cards-count-certificates-not-predictions.md).
_Avoid_: predicted source (it is two sources)
**EPC coverage** — *does any certificate exist for this dwelling?*
`lodged` **or** `expired` ⇒ yes, one does (current, or out of date). `predicted`, or no `epc_property` rows at all ⇒ no, none does.
This is a **different question** from provenance's "was the picture modelled?" — an `expired` property is gap-filled *and* has a certificate. Conflating the two is what made the reporting cards wrong: the "Homes Without an EPC" card counted every gap-fill, so homes whose only fault was an out-of-date certificate were reported as having no EPC at all. The cards partition on coverage, not on provenance.
_Avoid_: missing EPC (ambiguous — say "without an EPC" for coverage, "estimated" for provenance)
_Avoid_: estimated EPC (reserve "estimated" for the UI signal), source EPC
**Provenance signal** (UI):
What the user is told about an EPC's trustworthiness, derived from EPC provenance and Rebaseline together: **Estimated** (`source = predicted` — dominant; "estimated based on nearby homes") **Re-modelled** (`source = lodged AND rebaseline_reason != none` — effective diverged from the lodged certificate under SAP 10) **none** (`source = lodged AND rebaseline_reason = none` — effective equals lodged). Estimated always wins when both could apply.
What the user is told about an EPC's trustworthiness, derived from EPC provenance and Rebaseline together: **Estimated** (`source IN (predicted, expired)` — the whole predicted slot, dominant; "estimated based on nearby homes") **Re-modelled** (`source = lodged AND rebaseline_reason != none` — effective diverged from the lodged certificate under SAP 10) **none** (`source = lodged AND rebaseline_reason = none` — effective equals lodged). Estimated always wins when both could apply.
An `expired` property is **Estimated**: its picture was modelled, not read off a current certificate. That it *also* has an (out-of-date) certificate is a matter of EPC coverage, which the reporting cards use — not of this signal.
_Avoid_: estimation notification, banner (those are component names)
**Likely downgrade**:
@ -208,6 +250,7 @@ _Avoid_: quick win, free upgrade
## Flagged ambiguities
- The portfolio **Property table**'s **Property Type**, **Built Form**, **Construction Age**, **Main Fuel**, **Wall Type**, **Roof Type** and **Heating System** columns show the **landlord-override-resolved** value — the same precedence the modelling **Run filter** applies (override fact → EPC-derived → **Unknown**) — not the raw `epc_property` value or the legacy property-row column. See [ADR-0012](./docs/adr/0012-portfolio-list-resolves-descriptors-via-override-precedence.md). All seven are also **filters**, and the per-part ones (wall/roof) read the **main building part**. Wall/Roof/Heating filter by **coarse bucket** rather than the full vocabulary — their resolved values are free-text (override vocab / EPC `epc_energy_element.description` / legacy column) with hundreds of variants, so a fixed set of prefix/substring buckets (Wall → Cavity / Solid Brick / Timber Frame / Stone / …; Roof → Pitched / Flat / Room-in-Roof / …; Heating → Gas Boiler / Community / Electric Storage / Heat Pump / …) matches them, while the columns still **display the full description**. `classifyDescriptor` is the pure twin of the SQL bucketing. Two label subtleties: **Construction Age** is an RdSAP band (e.g. "19001929", "before 1900"), never a single year (it was formerly the "Year Built" column); and **Main Fuel** for new-approach properties reads the EPC's `epc_main_heating_detail.main_fuel_type` (an RdSAP `main_fuel` code or an EPR string, folded to a coarse label) beneath any landlord override — it only reads **Unknown** when neither is present.
- The UI surfaces labelled **"Current EPC"** and **"Current Efficiency State"** (property table, building-passport card) show **Effective performance**, not Lodged — despite the glossary advising against "current performance" for Effective. The label is a product choice ("current" = the property's true present-day modelled state); the underlying datum is still **Effective performance**. The **"Lodged EPC"** column/badge is the only surface showing **Lodged performance**, and only when a real certificate exists (`source = lodged`).
- "Upload" is used in the codebase to mean both the file-on-S3 and the BulkUpload row. We standardise on **BulkUpload** for the row; the file is just "the source file."
- "Onboarding" appears in some route paths (`bulk_onboarding_inputs/...`) but isn't part of this glossary — we use **BulkUpload** end-to-end.

View file

@ -35,9 +35,13 @@ modelling exists.
distinguishable.
- **Deterministic mapping** (fixed table, tested): RD02→House+Detached,
RD03→House+Semi-Detached, RD04→House (Mid/End unknowable — no built form),
RD06→Flat, RD07→House. Every other code maps nothing; `Unknown` is never
RD06→Flat, RD07→House, RH01/RH02/RH03→House (the dedicated HMO family, built
form unknowable). Every other code maps nothing; `Unknown` is never
written (absence, not noise) — preserving the finaliser's invariant. The
backend's predict-EPC service consumes these for archetype matching.
- **Addable codes**: the RD (dwelling) family except RD10 (institutions), plus
the RH (HMO) family. All other codes — commercial, institutional, land — are
not addable and render "Non-residential".
- **Read-through cache** on the existing `postcode_search` jsonb table:
serve when fresher than 30 days, else refresh from OS Places and upsert.

View file

@ -0,0 +1,46 @@
# 11. App-owned, backend-executed tasks carry their marker in `task_source`
Date: 2026-07-08
## Status
Accepted
## Context
Bulk document download follows the app-created-task convention (ADR-0008): Next.js
inserts a `tasks` row whose `inputs` column carries the selection config, then hands
the backend only `{ task_id }`. Unlike a Modelling run — where the distributor
receives the whole config in the dispatch payload and never looks at the task again —
the document worker gets *only* the id, re-reads the `tasks` row, and resolves the
selection from `inputs`. It therefore needs a durable, in-row way to recognise "this
is a bulk-document-download task I'm allowed to act on" before it assembles anything.
Modelling runs put that marker in `service` (`modelling_run`) and leave `task_source`
human-readable (`"Modelling run 2 scenarios"`). Reusing `service` here would work,
but it invites a future engineer to "unify" the two conventions and move the marker —
silently breaking the worker's guard.
## Decision
- **The marker is `task_source = "app:bulk_document_download"`** — a single, stable
string the worker guards on. The `app:` prefix denotes an app-created task (as
opposed to a backend-created one). It is engineer-facing only (Settings → Logs), so
a machine-shaped string is acceptable; no friendly-label mapping is added.
- **`service` is left null.** For a task whose marker already lives in `task_source`,
a second marker in `service` is redundant and can drift. In-app status polling
scopes by `id = task_id AND source_id = <portfolio> AND task_source =
"app:bulk_document_download"`.
- **Portfolio attachment is unchanged and uniform:** `source = "portfolio_id"`,
`source_id = <portfolio_id>`. This is what the Portfolio Logs page filters on, so
the run shows up there for free — the same as every other app-owned task.
## Consequences
- Do **not** move this marker into `service` to match Modelling runs. The asymmetry is
deliberate: the marker lives where the executor reads it — `service` for a
payload-fed distributor, `task_source` for a task-re-reading worker.
- A large selection never travels in an HTTP body: it lives in `inputs`, and the only
thing dispatched is `task_id` (ADR-0008 lineage).
- Future app-owned, backend-executed tasks should follow this shape:
`task_source = "app:<name>"`, config in `inputs`, `source`/`source_id` for scoping.

View file

@ -0,0 +1,89 @@
# 12. The portfolio property-list resolves descriptors through the landlord-override precedence
Date: 2026-07-10
## Status
Accepted
## Context
The portfolio property-list's **Property Type**, **Built Form**, **Year Built**
and **Main Fuel** columns and filters read raw `property` row columns (and, for
main fuel, the legacy `property_details_epc` table). Those columns are NULL for
**new-approach** properties (`property.updated_at >= 2026-06-01`, the EPC-graph
cutoff — see `epcSources.ts`), so for any new portfolio the columns render blank
and the filters match nothing. The already-migrated columns (current/lodged EPC,
floor area, CO₂, provenance, expiry) route through `epcSources` fragments and
work; these four were simply never migrated.
Separately, [ADR-0008](./0008-modelling-runs-filters-to-distributor.md)
established the resolution precedence for the modelling **Run filter**:
**landlord-override fact → EPC-derived value (new-approach graph, lodged over
predicted, RdSAP codes mapped; legacy property-row fallback) → Unknown**. It is
implemented in-app inline in `previewModellingRun` and mirrored by the
distributor; divergence between the two is defined as a bug.
## Decision
The portfolio list resolves Property Type, Built Form, Construction Age and Main
Fuel through the **same override precedence as the Run filter**, so the browse
surface agrees with what a modelling run would actually select. Concretely:
- **Shared fragments, one home for the rule.** `epcSources.ts` gains
`overrideJoins` (LEFT JOINs of `property_overrides` aliased per component at
`building_part = 0`) and `resolvedPropertyTypeSql` / `resolvedBuiltFormSql` /
`resolvedConstructionBandSql` / `resolvedMainFuelSql`. Both the list query and
`previewModellingRun` consume them; the inline COALESCE in
`previewModellingRun` is refactored onto the shared fragments.
- **Never-null.** Each fragment is `COALESCE(override, epcSql, 'Unknown')`. Every
property resolves to a concrete, filterable string; the **Unknown** filter chip
matches "no resolvable value at all" — character-for-character the semantics
CONTEXT.md already gives the Run filter's Unknown. The list columns render an
explicit **Unknown** pill instead of a blank `—`.
- **Construction Age is band-native, not a year.** Override and EPC both store
the RdSAP band **letter** (`A..M`, `Unknown`); the field resolves in letter
space and displays the band **label** ("before 1900", "19001929", … "2023
onwards"). A numeric year cannot represent an `Unknown` override and misreads a
band as a precise year. The column is **renamed "Year Built" → "Construction
Age"** and its filter options become the bands, not years; legacy raw years are
bucketed into bands.
- **Main Fuel is override-only for new-approach.** The EPC graph exposes no clean
main-fuel string (a documented `epcSources` gap → NULL), so for new-approach
properties the override is the *only* source; unoverridden properties read
**Unknown**. The override vocabulary (`"mains gas"`, `"LPG (bulk)"`, …) is
folded into the existing collapsed filter labels (Mains Gas, LPG, …).
- **`tenure` is removed from the list**, not fixed. It has no override component,
and its `epc_property.tenure` values don't cleanly reconcile with the filter's
`dbValues`. It is dropped from the list column and filter only;
`resolvePropertyMeta`/`resolvePropertyDescriptors` still resolve it for the
per-property building passport, and `property.tenure` is retained.
## Alternatives considered
- **EPC-derived only, no overrides.** Smaller, and consistent with how the other
already-migrated columns behave. Rejected: the list would then disagree with
the modelling Run filter — e.g. show "Flat" for a property a landlord override
makes (and modelling treats as) "Maisonette" — reintroducing exactly the
browse-vs-model mismatch this work set out to remove.
- **Construction Age as a numeric year.** Familiar and a smaller diff. Rejected:
cannot represent an `Unknown` override (collapses to `—`, breaking the
never-null contract) and misrepresents a band as a point year.
- **Keep the precedence rule inline in `previewModellingRun` and duplicate it in
the list.** Avoids touching a working feature. Rejected: it re-creates the
two-homes divergence ADR-0008 explicitly warns against — now within the app.
## Consequences
- The count query (`getPropertiesCount`) must join `property_overrides` when any
of these fields is filtered — `EPC_JOIN_FILTER_FIELDS` (or a sibling set) is
extended. Joins are perf-sensitive (the count path already brushes Vercel's
15s limit), so the joins stay conditional on an active filter.
- Every new-approach property without a main-fuel override shows **Unknown** — a
lot of Unknown pills, but an honest signal of the EPC gap rather than a blank
implying "not applicable".
- Filter option lists grow: Property Type gains **Park home** + **Unknown**;
Built Form gains **Unknown** (keeping **Not Recorded** for the explicit
override); Main Fuel folds the override vocab into existing labels.
- `previewModellingRun` and the portfolio list are now provably in sync — the
shared fragment is the single reference implementation ADR-0008 asked for.

View file

@ -0,0 +1,101 @@
# 13. Tags are app-owned Property groupings, filtered in-app and passed to modelling as a filter key
Date: 2026-07-13
## Status
Accepted
Numbering note: 0010 (reporting redesign) and 0012 (portfolio-list descriptor
resolution) are accepted on their own in-flight branches; 0013 avoids collision
with both.
## Context
Users need to form **arbitrary, hand-curated groups** of a Portfolio's
Properties to drive bulk actions — running Scenarios over a subset, and (later)
including/excluding a group from Reporting. Nothing existing covers this:
- **Projects** (ADR: HubSpot-sourced) are at most one per Property and owned by
an external system — not user-curated, not many-per-property.
- **Run filters** (ADR-0008) select by postcode / property type / built form —
data-derived facts, not a free-form selection.
ADR-0008 already anticipated this: it kept the distributor's filter payload a set
of tiny keys resolved against the shared Postgres, **explicitly rejected sending
resolved property-id lists** (~50k ids overflow the async entry point and move
resolution into the app), and reserved that "tags become one more optional
filter key."
## Decision
- **A Tag is a flat, app-owned, Portfolio-scoped label**, many-to-many with
Property (`portfolio_tag`, `property_tag`). No hierarchy, no external source,
no modelling semantics of its own — it exists only to name a group. See
CONTEXT.md (Tag).
- **Assignment has four paths, all app-owned:** a per-Property inline editor; a
filter-driven "tag everything matching the current property-table filter";
a row-multiselect (whose "select all matching" delegates to the filter path);
and a **Bulk tag assignment** file upload.
- **The bulk upload is parsed and matched entirely in the app — no FastAPI
pipeline.** The BulkUpload pipeline exists to match *arbitrary addresses* to
UPRNs and to *classify free text*; tag assignment matches an *existing
identifier* (landlord property id **or** UPRN, whichever column the file
carries) to *existing* Properties in one Portfolio. There is no address
matching and no classification, and the files are small — so the pipeline's
cost (S3 round-trip, async task, combiner) buys nothing. An unmatched
identifier is reported, never silently dropped; an ambiguous landlord id (they
are **not** unique per Portfolio) tags every match and says so.
- **Tags reach modelling as a `tag_ids` filter key (extends ADR-0008), never as
a property-id list.** `RunFilters` gains `tag_ids`; it is **AND-combined**
with the other filter dimensions and **OR-within** (any-of), matching the
property-table filter. The app computes the in-app preview count by joining
`property_tag` (the reference implementation ADR-0008 requires); the
distributor resolves the same key by the same join against the shared DB it
already reads. This needs one backend change — the distributor learning
`tag_ids` — so the app side ships first and scenarios-on-tags goes live when
the backend lands.
- **The property-table Tag filter is app-resolved** (unlike modelling, the app
owns the browse surface): a dynamic per-Portfolio option list, any-of, plus a
synthetic **Untagged** option (zero memberships).
- **Delete is hard + cascade.** Removing a Tag drops its memberships behind a
confirm that shows the count. A Tag id baked into a past Run's stored filter
becomes dangling and renders as "(deleted tag)" in Run history — no archive
state to query around.
- **Schema migrations ship in their own PR**, separate from the feature code
(project convention).
## Alternatives considered
- **Two-level Tag Group → Tag hierarchy.** More expressive (mutually-exclusive
groups), but every stated use case is served by flat Tags with far less schema
and UI; grouping can be added later without reshaping the join.
- **Reuse Projects as the grouping.** No new tables, but Projects are
HubSpot-owned, one-per-Property, and not user-editable — the opposite of what a
Tag is.
- **App resolves `tag_ids` → property-id list for the run.** No backend change,
but re-runs exactly what ADR-0008 rejected: an unbounded payload and the
resolution rule living in the app, where preview and run can drift.
- **Route the bulk upload through the FastAPI BulkUpload pipeline.** Reuses the
existing async machinery, but pays for address-matching and classification the
task doesn't need, and couples a simple in-Portfolio identifier join to a
multi-stage S3 pipeline.
- **Soft-delete / archive Tags.** Preserves history references exactly, but
burdens every Tag query with an `archived` predicate for a gain a
"(deleted tag)" label already covers.
## Consequences
- **One backend ask** must land for scenarios-on-tags to work end-to-end: the
distributor accepts and resolves a `tag_ids` filter key against `property_tag`,
mirroring the app's preview. Until then the run UI can show and preview a Tag
selection but the run won't scope by it.
- **Reporting-by-tag is deferred to the reporting redesign** (PRD #370 /
ADR-0010): the substrate (`property_tag`) ships now; "include/exclude a tagged
group" becomes a requirement of that redesign rather than a retrofit onto the
scenario-aggregate reporting it replaces.
- A deleted Tag leaves a dangling id in any Run-history record that referenced
it; history reads must tolerate it (render "(deleted tag)").
- `landlord_property_id` is partial (~43% of Properties) and non-unique per
Portfolio, so bulk uploads that use it cover less than half the Portfolio and
can tag duplicates — UPRN is offered as the reliable alternative key.

View file

@ -0,0 +1,105 @@
# The EPC reporting cards count certificates, not predictions
## Status
accepted
## Context
The portfolio reporting page shows two EPC-quality cards: **Homes Without an EPC**
and **Expired EPCs**. Both were derived from `estimatedSql`, which asked *"is this
property's EPC picture a gap-fill?"*:
```sql
-- "Homes Without an EPC"
SUM(CASE WHEN estimatedSql = true THEN 1 ELSE 0 END)
-- "Expired EPCs"
SUM(CASE WHEN isExpiredSql = true AND estimatedSql = false THEN 1 ELSE 0 END)
```
That conflates two genuinely different questions:
- **Provenance** — was this picture *modelled*, or read off a real certificate?
- **Coverage** — does a certificate *exist* for this dwelling at all?
They used to give the same answer, because `epc_property.source` had two values and
a gap-fill implied no certificate. That stopped being true.
The gov EPC API only serves certificates registered since **1 January 2012**. A
dwelling whose only certificate predates that looks EPC-less to ingestion, so its
picture gets gap-filled — even though a real, if stale, certificate exists for it in
the historic register. Model ADR-0054 introduced a third `source` value, `expired`,
for exactly this case: a prediction *conditioned on the dwelling's own expired
certificate*. `predicted` and `expired` share one slot (`PREDICTED_SLOT_SOURCES`).
Measured on portfolio 824 (5,276 homes): **403** homes were gap-filled, and **375**
of them have a real pre-2012 certificate in the historic backup. So the card claimed
403 homes had no EPC when 375 of them did — they just had an out-of-date one, which
is a different and far more actionable problem. It is also the *wrong* problem to
solve: you do not commission a new EPC survey for a home you believe has never been
certified in the same way you chase a lapsed one.
Two further defects fell out of the same conflation:
- `epc_property.source` is plain `text` with **no enum and no CHECK constraint**, and
every query matched `source = 'predicted'` literally. An `expired` row matches
neither the lodged nor the predicted alias, so the home would have read as though it
held a *valid current certificate* — silently absent from **both** cards, with no
Estimated badge. Nothing in the database would have caught this.
- `isExpiredSql` read `epl.registration_date` alone, but 714 of 824's 4,873 lodged
certificates carry only an `inspection_date`. All 714 were treated as current; 170
of them are in fact over ten years old. (`lodgementDateSql` already coalesced the
two — the expiry predicate simply didn't.)
## Decision
**The two cards partition on coverage, not provenance.** A prediction is not a
certificate; an expired certificate is still a certificate.
- `withoutEpcSql`*no lodgement record and no historical record*:
`epl.id IS NULL AND epx.id IS NULL`. A blind `predicted` row is not evidence of an
EPC, so it is not consulted. A property with no `epc_property` rows at all also
lands here, correctly.
- `isExpiredSql`*we found a certificate, of either kind, and it is out of date*:
an `expired` row (>10 years old by construction, since its conditioning certificate
predates 2012), **or** a lodged certificate older than ten years, dating it by
`COALESCE(registration_date, inspection_date)`.
- `estimatedSql` keeps its own, separate meaning — *was this modelled?* — and now
spans the whole predicted slot, so an `expired` home still carries the **Estimated**
provenance badge. It is estimated **and** has an EPC. Both are true.
The `AND estimated = false` guard is removed from the Expired card and must stay
removed: an `expired` home *is* estimated, so the guard would exclude precisely the
homes the card exists to count. The rule it was really enforcing — *a gap-fill has no
certificate to expire* — now lives inside `isExpiredSql`, where a caller cannot forget
it.
**Every query addresses the predicted slot (`epp` OR `epx`), never one member.**
## Consequences
Legacy properties (`updated_at` before the 2026-06-01 cutoff) are **unaffected**
they have no `epc_property` rows and keep reading the flat `property_details_epc`
flags. Portfolio 632 reports 8 / 1,662 before and after.
For new-approach properties the numbers move, in two steps. The `inspection_date`
fallback lands immediately and is a straight bug fix: 824's Expired count goes
**913 → 1,083**. Then, once Model starts writing `expired` rows, the cards settle at
**403 → 25** without an EPC and **1,083 → 1,461** expired. The portfolio has not
changed; we were miscounting it.
This repo is safe to deploy **before** Model starts writing `expired` rows — the
predicates simply find none. The reverse is not true: had Model shipped first, every
`expired` home would have gone quietly missing from both cards.
Two things we accept:
- **The three-way reality is still squashed into booleans per card.** We do not
surface "has an expired certificate" and "has nothing at all" as distinct states
anywhere except by the two cards being disjoint. If a third state ever needs its own
treatment, this is where to add it.
- **Nothing enforces the slot at the database level.** `source` remains `text`. A
future query that matches `source = 'predicted'` alone would reintroduce the bug in
silence. `src/lib/services/epcSlot.test.ts` pins the invariant structurally; a CHECK
constraint or a Postgres enum would be stronger, and is worth doing when the schema
is next touched.

View file

@ -0,0 +1,73 @@
# Backend ask — resolve a `tag_ids` Run-filter key
**For:** the distributor (`POST /v1/modelling/trigger-run`)
**From:** the portfolio tagging system (app), ADR-0013 (extends ADR-0008)
**Status:** app side built; backend change outstanding
## What's needed
The modelling **Run filter** gains one optional key, `tag_ids` — a list of
portfolio Tag ids. The distributor already receives
`{ task_id, portfolio_id, scenario_ids, filters }` and resolves `filters`
against the shared Postgres. It must now also resolve `tag_ids`.
```jsonc
// dispatch payload (unchanged except the new optional filters key)
{
"portfolio_id": 814,
"scenario_ids": [12, 34],
"filters": {
"postcodes": ["B93 8SU"], // existing
"property_types": ["House"], // existing
"built_forms": ["Detached"], // existing
"tag_ids": [7, 3] // NEW — optional
}
}
```
## Resolution rule
Tags are a **direct membership** lookup, not the override→EPC precedence used for
type/built-form. A property matches `tag_ids` when it has a row in
`property_tag` for any of the ids (**any-of**). Combine with the other filter
dimensions the same way they combine today: **AND across dimensions, OR within**.
```sql
-- add to the property-matching WHERE, only when tag_ids is present:
p.id IN (
SELECT pt.property_id FROM property_tag pt
WHERE pt.tag_id IN (:tag_ids)
)
```
Tables (already migrated — see PR #387 / migration `0271`):
- `portfolio_tag(id, portfolio_id, name, colour, …)`
- `property_tag(property_id, tag_id)` — PK `(property_id, tag_id)`, index
`ix_property_tag_tag` on `tag_id` for exactly this reverse lookup.
Ids that don't belong to the portfolio simply match nothing (no validation
needed). An absent/empty `tag_ids` means unconstrained, like the other keys.
## Reference implementation (must stay in lock-step)
The app's in-app **preview count** is the reference implementation ADR-0008
requires — the distributor's resolution must match it. See
`previewModellingRun` in `src/lib/modellingRuns/server.ts`: the tag clause is
```ts
conditions.push(
sql`resolved.id IN (SELECT pt.property_id FROM property_tag pt
WHERE pt.tag_id IN (${inList(args.filters.tagIds)}))`,
);
```
Divergence between the app preview and the distributor is defined as a bug
(ADR-0008).
## Until this lands
The run UI can show and preview a Tag selection (the app resolves the preview),
but a triggered run won't scope by tags until the distributor resolves
`tag_ids`. No contract-shape change is needed on either side beyond reading the
new key.

View file

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getBulkDownloadStatus } from "@/lib/bulkDocumentDownload/server";
// GET — status of a triggered bulk download, for in-app polling. Reads the
// worker's sub_task.outputs (presigned URL, included count, skipped list).
export async function GET(
_request: NextRequest,
props: { params: Promise<{ portfolioId: string; taskId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId, taskId } = await props.params;
if (!/^\d+$/.test(portfolioId)) {
return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 });
}
try {
const status = await getBulkDownloadStatus(portfolioId, taskId);
if (!status) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(status);
} catch (err) {
console.error("GET /bulk-document-download/[taskId] error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { z } from "zod";
import { triggerBulkDocumentDownload } from "@/lib/bulkDocumentDownload/server";
// The document service resolves the selection and creates its sub_task
// synchronously before returning 202, which can outrun the default function
// budget on large projects. Give the round-trip headroom so the browser gets
// our JSON (202 / mapped error) rather than a platform 504.
export const maxDuration = 60;
const scopeSchema = z.union([
z.object({ kind: z.literal("project"), projectCode: z.string().min(1) }),
z.object({
kind: z.literal("all-projects"),
projectCodes: z.array(z.string().min(1)),
}),
]);
const bodySchema = z.object({
scope: scopeSchema,
includeAll: z.boolean(),
landlordPropertyIds: z.array(z.string().min(1)).default([]),
});
function sessionToken(request: NextRequest): string | undefined {
return (
request.cookies.get("__Secure-next-auth.session-token")?.value ??
request.cookies.get("next-auth.session-token")?.value
);
}
// POST — record + dispatch a bulk document download (ADR-0008 convention)
export async function POST(
request: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
if (!/^\d+$/.test(portfolioId)) {
return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 });
}
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await request.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
try {
const outcome = await triggerBulkDocumentDownload({
selection: {
scope: body.scope,
includeAll: body.includeAll,
landlordPropertyIds: body.landlordPropertyIds,
portfolioId: Number(portfolioId),
},
sessionToken: sessionToken(request),
});
switch (outcome.kind) {
case "ok":
return NextResponse.json({ taskId: outcome.taskId }, { status: 202 });
case "invalid_selection":
return NextResponse.json({ error: outcome.message }, { status: 400 });
case "backend_error":
// Pass the backend's status (400 over-cap / 409 already-started / 5xx)
// and its detail message straight through.
return NextResponse.json(
{ error: outcome.message },
{ status: outcome.status },
);
}
} catch (err) {
console.error("POST /bulk-document-download error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { z } from "zod";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { upsertUprnCorrection } from "@/lib/bulkUpload/addressMatches";
// Confirm one flagged combiner row (ADR-0057): either assign a UPRN chosen from
// OS Places, or assert the address has none. Keyed by `source_row_id` (there is
// no property.id yet — the finaliser creates identities). Upsert semantics, so
// re-confirming overwrites. The confirmation is overlaid onto the combiner CSV
// at dispatchFinaliser.
const AssignSchema = z.object({
sourceRowId: z.string().min(1),
uprn: z.string().regex(/^\d+$/, "UPRN must be numeric"),
address: z.string().min(1),
postcode: z.string().min(1),
});
const NoUprnSchema = z.object({
sourceRowId: z.string().min(1),
markedNoUprn: z.literal(true),
});
const BodySchema = z.union([NoUprnSchema, AssignSchema]);
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ portfolioId: string; uploadId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { uploadId } = await params;
const parsed = BodySchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
{ status: 400 },
);
}
await upsertUprnCorrection(uploadId, parsed.data, session.user?.email ?? undefined);
return NextResponse.json({ ok: true }, { status: 200 });
}

View file

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getFlaggedAddresses } from "@/lib/bulkUpload/addressMatches";
// Read-only: the combiner rows address2uprn could not confidently match
// (unmatched / ambiguous_duplicate / invalid_postcode), joined with any saved
// correction. Drives the pre-finalise "Addresses" review tab (ADR-0057); the
// Finalise gate blocks until every flagged row is resolved.
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ portfolioId: string; uploadId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { uploadId } = await params;
const flagged = await getFlaggedAddresses(uploadId);
const unresolved = flagged.filter((f) => !f.resolved).length;
return NextResponse.json({ flagged, unresolved }, { status: 200 });
}

View file

@ -35,6 +35,14 @@ export async function POST(
return NextResponse.json({ error: "Combiner not finished" }, { status: 409 });
case "missing_task":
return NextResponse.json({ error: "Upload has no task to finalise" }, { status: 409 });
case "unresolved_addresses":
return NextResponse.json(
{
error: `${result.count} address${result.count === 1 ? "" : "es"} still need a UPRN. Resolve them on the Addresses tab before finalising.`,
unresolvedAddresses: result.count,
},
{ status: 409 },
);
case "wrong_state":
return NextResponse.json(
{ error: `Upload not ready to finalize (state: ${result.current})` },

View file

@ -12,6 +12,7 @@ const bodySchema = z.object({
postcodes: z.array(z.string()).optional(),
propertyTypes: z.array(z.string()).optional(),
builtForms: z.array(z.string()).optional(),
tagIds: z.array(z.union([z.string(), z.number()])).optional(),
})
.default({}),
});

View file

@ -12,6 +12,7 @@ const filtersSchema = z.object({
postcodes: z.array(z.string()).optional(),
propertyTypes: z.array(z.string()).optional(),
builtForms: z.array(z.string()).optional(),
tagIds: z.array(z.union([z.string(), z.number()])).optional(),
});
const bodySchema = z.object({
@ -51,6 +52,21 @@ export async function POST(
if (!normalised.ok) {
return NextResponse.json({ error: normalised.error }, { status: 400 });
}
// Defence-in-depth: the distributor can't resolve `tag_ids` yet (backend ask
// in docs/backend-asks/tag-ids-run-filter.md), so a tagged run would model the
// unscoped superset while preview counted the tag subset — the exact
// preview≠run divergence ADR-0008 forbids. Reject here so the block isn't
// UI-only (the RunModellingClient guard is just the friendly first line).
// Remove once the distributor lands `tag_ids`.
if (normalised.filters.tagIds?.length) {
return NextResponse.json(
{
error:
"Running scenarios over tags isn't available yet — the modelling engine can't scope by tag. Remove the tag filter to run.",
},
{ status: 400 },
);
}
try {
const outcome = await triggerModellingRun({

View file

@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { z } from "zod";
import { db } from "@/app/db/db";
import { property } from "@/app/db/schema/property";
import { and, eq } from "drizzle-orm";
// Assign a chosen Ordnance Survey address (from the postcode search) to an
// existing property: writes the canonical uprn/address/postcode and keeps what
// the user typed as the user-inputted fallback. Keyed by (property, portfolio)
// so a caller can only touch a property in a portfolio they addressed. The
// candidate is guaranteed residential + UPRN-bearing by the picker UI.
const patchSchema = z.object({
uprn: z.string().regex(/^\d+$/, "UPRN must be numeric"),
address: z.string().trim().min(1, "Address is required"),
postcode: z.string().trim().min(1, "Postcode is required"),
// What the user typed in the edit fields — kept as the raw fallback, mirroring
// how the bulk finaliser stores user_inputted_* alongside the matched values.
userInputtedAddress: z.string().trim().optional(),
userInputtedPostcode: z.string().trim().optional(),
});
function isUniqueViolation(err: unknown): boolean {
const code = (err as { code?: string })?.code;
const message = (err as { message?: string })?.message ?? "";
return code === "23505" || message.includes("uq_property_portfolio_uprn");
}
export async function PATCH(
request: NextRequest,
props: { params: Promise<{ portfolioId: string; propertyId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId, propertyId } = await props.params;
if (!/^\d+$/.test(portfolioId) || !/^\d+$/.test(propertyId)) {
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
}
const parsed = patchSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
{ status: 400 },
);
}
const { uprn, address, postcode, userInputtedAddress, userInputtedPostcode } =
parsed.data;
try {
const updated = await db
.update(property)
.set({
uprn: BigInt(uprn),
address,
postcode,
...(userInputtedAddress !== undefined ? { userInputtedAddress } : {}),
...(userInputtedPostcode !== undefined ? { userInputtedPostcode } : {}),
updatedAt: new Date(),
})
.where(
and(
eq(property.id, BigInt(propertyId)),
eq(property.portfolioId, BigInt(portfolioId)),
),
)
.returning({ id: property.id });
if (updated.length === 0) {
return NextResponse.json({ error: "Property not found" }, { status: 404 });
}
return NextResponse.json({ ok: true });
} catch (err) {
if (isUniqueViolation(err)) {
return NextResponse.json(
{
error:
"That UPRN is already assigned to another property in this portfolio.",
},
{ status: 409 },
);
}
console.error("PATCH /properties/[propertyId] error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -29,6 +29,7 @@ export async function POST(
exclusions: Array.isArray(b.exclusions)
? b.exclusions.filter((k): k is string => typeof k === "string")
: [],
fabricFirst: b.fabricFirst === true,
});
if (!result.ok) {

View file

@ -0,0 +1,181 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { portfolioTag, propertyTag } from "@/app/db/schema/tags";
import { property } from "@/app/db/schema/property";
import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { canWriteTags, getPortfolioRole } from "@/lib/tags/server";
import { MAX_BULK_TAG_ROWS } from "@/lib/tags/bulkAssign";
import { getFilteredPropertyIds } from "@/app/portfolio/[slug]/utils";
import type { FilterGroups } from "@/app/utils/propertyFilters";
type Params = { params: Promise<{ portfolioId: string; tagId: string }> };
/** Batch size for membership writes — keeps a whole-portfolio op under the bind-param cap. */
const CHUNK = 5000;
const bodySchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("properties"),
action: z.enum(["add", "remove"]),
propertyIds: z.array(z.string()).min(1),
}),
z.object({
mode: z.literal("filter"),
action: z.enum(["add", "remove"]),
// The property-table filter groups; resolved to ids server-side so the whole
// matching set is tagged, not just the visible page (ADR-0013).
filterGroups: z.array(z.unknown()),
}),
z.object({
mode: z.literal("identifiers"),
key: z.enum(["landlord_property_id", "uprn"]),
identifiers: z.array(z.string()).min(1).max(MAX_BULK_TAG_ROWS),
}),
]);
/**
* POST add/remove Property memberships for one Tag (ADR-0013). `properties`
* mode takes explicit ids (the inline popover + row multi-select); `identifiers`
* mode matches a Bulk tag assignment upload's landlord ids / UPRNs to Properties
* and returns a match summary. All writes are idempotent.
*/
export async function POST(req: NextRequest, props: Params) {
const session = await getServerSession(AuthOptions);
const { portfolioId, tagId } = await props.params;
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
if (!/^\d+$/.test(portfolioId) || !/^\d+$/.test(tagId)) {
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
}
const pId = BigInt(portfolioId);
const tId = BigInt(tagId);
const role = await getPortfolioRole(pId, session.user.email);
if (!canWriteTags(role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// The tag must belong to this portfolio (prevents cross-portfolio writes).
const tag = await db
.select({ id: portfolioTag.id })
.from(portfolioTag)
.where(and(eq(portfolioTag.id, tId), eq(portfolioTag.portfolioId, pId)))
.limit(1);
if (tag.length === 0) {
return NextResponse.json({ error: "Tag not found" }, { status: 404 });
}
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
if (body.mode === "properties" || body.mode === "filter") {
let ids: bigint[];
if (body.mode === "properties") {
// Only ids that actually belong to this portfolio (guards spoofed ids).
const owned = await db
.select({ id: property.id })
.from(property)
.where(
and(
eq(property.portfolioId, pId),
inArray(
property.id,
body.propertyIds.map((id) => BigInt(id)),
),
),
);
ids = owned.map((o) => o.id);
} else {
// Resolve the current property-table filter to its whole matching set.
ids = await getFilteredPropertyIds(portfolioId, body.filterGroups as FilterGroups);
}
if (ids.length === 0) return NextResponse.json({ changed: 0 });
// Chunk so a whole-portfolio selection stays under Postgres' bind-param cap.
let changed = 0;
for (let i = 0; i < ids.length; i += CHUNK) {
const batch = ids.slice(i, i + CHUNK);
if (body.action === "add") {
const inserted = await db
.insert(propertyTag)
.values(batch.map((propertyId) => ({ propertyId, tagId: tId })))
.onConflictDoNothing()
.returning({ propertyId: propertyTag.propertyId });
changed += inserted.length;
} else {
const removed = await db
.delete(propertyTag)
.where(
and(eq(propertyTag.tagId, tId), inArray(propertyTag.propertyId, batch)),
)
.returning({ propertyId: propertyTag.propertyId });
changed += removed.length;
}
}
return NextResponse.json({ changed });
}
// identifiers mode — Bulk tag assignment (add only) with a match summary.
if (body.mode !== "identifiers") {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
// Capture the narrowed discriminant in a const — TS widens `let body` back to
// the full union inside the closures below (e.g. matches.map).
const key = body.key;
const wanted = [...new Set(body.identifiers.map((s) => s.trim()).filter(Boolean))];
const matches = await db
.select({
id: property.id,
landlordPropertyId: property.landlordPropertyId,
uprn: property.uprn,
})
.from(property)
.where(
and(
eq(property.portfolioId, pId),
key === "uprn"
? inArray(
property.uprn,
wanted
.filter((v) => /^\d+$/.test(v))
.map((v) => BigInt(v)),
)
: inArray(property.landlordPropertyId, wanted),
),
);
const matchedValues = new Set(
matches.map((m) =>
key === "uprn" ? String(m.uprn) : String(m.landlordPropertyId),
),
);
const unmatched = wanted.filter((v) => !matchedValues.has(v));
const matchedPropertyIds = matches.map((m) => m.id);
let taggedCount = 0;
for (let i = 0; i < matchedPropertyIds.length; i += CHUNK) {
const batch = matchedPropertyIds.slice(i, i + CHUNK);
const inserted = await db
.insert(propertyTag)
.values(batch.map((propertyId) => ({ propertyId, tagId: tId })))
.onConflictDoNothing()
.returning({ propertyId: propertyTag.propertyId });
taggedCount += inserted.length;
}
return NextResponse.json({
matchedPropertyCount: matchedPropertyIds.length,
taggedCount,
alreadyTaggedCount: matchedPropertyIds.length - taggedCount,
unmatchedCount: unmatched.length,
unmatchedIdentifiers: unmatched.slice(0, 100),
});
}

View file

@ -0,0 +1,113 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { portfolioTag } from "@/app/db/schema/tags";
import { and, eq, ne } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { validateTagDraft } from "@/lib/tags/model";
import {
canWriteTags,
getPortfolioRole,
isUniqueViolation,
} from "@/lib/tags/server";
const editSchema = z.object({ name: z.string(), colour: z.string() });
type Params = { params: Promise<{ portfolioId: string; tagId: string }> };
async function authWrite(portfolioId: string, tagId: string, email: string | undefined) {
if (!email) return { error: "Unauthorised", status: 401 as const };
if (!/^\d+$/.test(portfolioId) || !/^\d+$/.test(tagId)) {
return { error: "Invalid id", status: 400 as const };
}
const role = await getPortfolioRole(BigInt(portfolioId), email);
if (!canWriteTags(role)) return { error: "Forbidden", status: 403 as const };
return { ok: true as const };
}
/** PATCH — rename / recolour a tag (write-gated). */
export async function PATCH(req: NextRequest, props: Params) {
const session = await getServerSession(AuthOptions);
const { portfolioId, tagId } = await props.params;
const auth = await authWrite(portfolioId, tagId, session?.user?.email);
if ("error" in auth) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const pId = BigInt(portfolioId);
const tId = BigInt(tagId);
let body: z.infer<typeof editSchema>;
try {
body = editSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
// Uniqueness is checked against the OTHER tags, so a tag may keep/recase its
// own name (ADR-0013).
const others = await db
.select({ name: portfolioTag.name })
.from(portfolioTag)
.where(and(eq(portfolioTag.portfolioId, pId), ne(portfolioTag.id, tId)));
const result = validateTagDraft(body, others.map((o) => o.name));
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: 400 });
}
// The `others` pre-check is racy — two concurrent renames to the same name
// both pass it — so the case-insensitive unique index is the real guard.
// Mirror POST: a unique violation is a clean 409, not an uncaught 500.
let updated;
try {
updated = await db
.update(portfolioTag)
.set({ name: result.tag.name, colour: result.tag.colour })
.where(and(eq(portfolioTag.id, tId), eq(portfolioTag.portfolioId, pId)))
.returning({ id: portfolioTag.id });
} catch (err) {
if (isUniqueViolation(err)) {
return NextResponse.json(
{ error: "A tag with this name already exists" },
{ status: 409 },
);
}
console.error("PATCH /tags update error:", err);
return NextResponse.json({ error: "Couldn't save the tag" }, { status: 500 });
}
if (updated.length === 0) {
return NextResponse.json({ error: "Tag not found" }, { status: 404 });
}
return NextResponse.json({
id: tId.toString(),
name: result.tag.name,
colour: result.tag.colour,
});
}
/** DELETE — remove a tag; memberships cascade (ADR-0013). */
export async function DELETE(_req: NextRequest, props: Params) {
const session = await getServerSession(AuthOptions);
const { portfolioId, tagId } = await props.params;
const auth = await authWrite(portfolioId, tagId, session?.user?.email);
if ("error" in auth) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const deleted = await db
.delete(portfolioTag)
.where(
and(
eq(portfolioTag.id, BigInt(tagId)),
eq(portfolioTag.portfolioId, BigInt(portfolioId)),
),
)
.returning({ id: portfolioTag.id });
if (deleted.length === 0) {
return NextResponse.json({ error: "Tag not found" }, { status: 404 });
}
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,135 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { portfolioTag, propertyTag } from "@/app/db/schema/tags";
import { eq, sql } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { validateTagDraft } from "@/lib/tags/model";
import {
canReadTags,
canWriteTags,
getPortfolioRole,
isUniqueViolation,
} from "@/lib/tags/server";
const createSchema = z.object({
name: z.string(),
colour: z.string(),
});
/** GET — the portfolio's tags with their property counts (ADR-0013). */
export async function GET(
_req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
if (!/^\d+$/.test(portfolioId)) {
return NextResponse.json({ error: "Invalid portfolio" }, { status: 400 });
}
const pId = BigInt(portfolioId);
const role = await getPortfolioRole(pId, session.user.email);
if (!canReadTags(role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const rows = await db
.select({
id: portfolioTag.id,
name: portfolioTag.name,
colour: portfolioTag.colour,
propertyCount: sql<number>`count(${propertyTag.propertyId})::int`,
})
.from(portfolioTag)
.leftJoin(propertyTag, eq(propertyTag.tagId, portfolioTag.id))
.where(eq(portfolioTag.portfolioId, pId))
.groupBy(portfolioTag.id)
.orderBy(portfolioTag.name);
return NextResponse.json(
rows.map((r) => ({
id: r.id.toString(),
name: r.name,
colour: r.colour,
propertyCount: r.propertyCount,
})),
);
}
/** POST — create a tag (write-gated). */
export async function POST(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
if (!/^\d+$/.test(portfolioId)) {
return NextResponse.json({ error: "Invalid portfolio" }, { status: 400 });
}
const pId = BigInt(portfolioId);
const role = await getPortfolioRole(pId, session.user.email);
if (!canWriteTags(role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
let body: z.infer<typeof createSchema>;
try {
body = createSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const existing = await db
.select({ name: portfolioTag.name })
.from(portfolioTag)
.where(eq(portfolioTag.portfolioId, pId));
const result = validateTagDraft(body, existing.map((e) => e.name));
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: 400 });
}
try {
const [inserted] = await db
.insert(portfolioTag)
.values({
portfolioId: pId,
name: result.tag.name,
colour: result.tag.colour,
})
.returning({ id: portfolioTag.id });
return NextResponse.json(
{
id: inserted.id.toString(),
name: result.tag.name,
colour: result.tag.colour,
propertyCount: 0,
},
{ status: 201 },
);
} catch (err) {
// Only the case-insensitive unique index racing a concurrent create is a
// 409; anything else (connection drop, other constraint) is a real failure
// that must not be masked as a duplicate-name.
if (isUniqueViolation(err)) {
return NextResponse.json(
{ error: "A tag with this name already exists" },
{ status: 409 },
);
}
console.error("POST /tags insert error:", err);
return NextResponse.json(
{ error: "Couldn't create the tag" },
{ status: 500 },
);
}
}

View file

@ -2,7 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
import { getProperties, getPropertiesCount } from "@/app/portfolio/[slug]/utils";
import { FilterGroups } from "@/app/utils/propertyFilters";
const DEFAULT_LIMIT = 1000;
// The property list paginates at 7 rows/page and isn't scrolled through in bulk,
// so a small initial page keeps the (join-heavy) query fast. Export fetches its
// own larger batch on demand (see EXPORT_LIMIT in PropertyTable).
const DEFAULT_LIMIT = 250;
export async function POST(req: NextRequest) {
const body = await req.json();

View file

@ -16,6 +16,7 @@ import * as CrmSchema from "@/app/db/schema/crm/hubspot_deal_table";
import * as UploadedFilesSchema from "@/app/db/schema/uploaded_files";
import * as PortfolioOrgSchema from "@/app/db/schema/portfolio_organisation";
import * as BulkAddressUploadsSchema from "@/app/db/schema/bulk_address_uploads";
import * as TagsSchema from "@/app/db/schema/tags";
export const pool = new Pool({
host: process.env.DB_HOST,
@ -43,6 +44,7 @@ const schema = {
...UploadedFilesSchema,
...PortfolioOrgSchema,
...BulkAddressUploadsSchema,
...TagsSchema,
};
export const db = drizzle(pool, {

View file

@ -0,0 +1,15 @@
CREATE TABLE "bulk_upload_uprn_corrections" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"upload_id" uuid NOT NULL,
"source_row_id" text NOT NULL,
"uprn" text,
"address" text,
"postcode" text,
"marked_no_uprn" boolean DEFAULT false NOT NULL,
"user_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "bulk_upload_uprn_corrections_upload_row_unique" UNIQUE("upload_id","source_row_id")
);
--> statement-breakpoint
ALTER TABLE "bulk_upload_uprn_corrections" ADD CONSTRAINT "bulk_upload_uprn_corrections_upload_id_bulk_address_uploads_id_fk" FOREIGN KEY ("upload_id") REFERENCES "public"."bulk_address_uploads"("id") ON DELETE cascade ON UPDATE no action;

View file

@ -0,0 +1,21 @@
-- Covering partial index for the reporting "where the money goes" aggregate.
-- The query joins the latest plan per property to its recommendations ON
-- plan_id, filters to default = true AND already_installed = false, and sums
-- estimated_cost / counts property_id per measure_type. The plain
-- idx_recommendation_plan_id only locates the rows; the aggregate then
-- heap-fetches every recommendation to apply the filter and read these
-- columns, which times out on large portfolios (e.g. 434). Carrying
-- measure_type, property_id and estimated_cost as key columns makes the whole
-- aggregate index-only.
--
-- OPS: `recommendation` is large, so build this CONCURRENTLY out-of-band on
-- prod BEFORE this migration runs, so the migration no-ops there (matches the
-- 0258 idx_plan_recommendations_recommendation_id pattern). A plain
-- CREATE INDEX here would lock writes on the table. Fresh environments build
-- it inline via IF NOT EXISTS.
--
-- CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_recommendation_plan_default"
-- ON "recommendation" USING btree ("plan_id","measure_type","property_id","estimated_cost")
-- WHERE "default" = true AND "already_installed" = false;
CREATE INDEX IF NOT EXISTS "idx_recommendation_plan_default" ON "recommendation" USING btree ("plan_id","measure_type","property_id","estimated_cost") WHERE "recommendation"."default" = true AND "recommendation"."already_installed" = false;

View file

@ -0,0 +1,8 @@
ALTER TABLE "hubspot_deal_data" ADD COLUMN "design_constraints" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "planning_comments" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "planning_status" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "planning_suggested_approach" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "planning_authority" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "designated_area" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "article_4_pd_rights" text;--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "listed_building" text;

View file

@ -0,0 +1 @@
ALTER TABLE "scenario" ADD COLUMN "fabric_first" boolean DEFAULT false NOT NULL;

View file

@ -0,0 +1 @@
CREATE INDEX "idx_plan_property_default_latest" ON "plan" USING btree ("property_id","portfolio_id","created_at" DESC NULLS LAST) WHERE is_default = true;

View file

@ -0,0 +1 @@
CREATE INDEX "ix_epc_main_heating_detail_epc_property" ON "epc_main_heating_detail" USING btree ("epc_property_id");

View file

@ -0,0 +1,21 @@
CREATE TABLE "portfolio_tag" (
"id" bigserial PRIMARY KEY NOT NULL,
"portfolio_id" bigint NOT NULL,
"name" text NOT NULL,
"colour" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "property_tag" (
"property_id" bigint NOT NULL,
"tag_id" bigint NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "property_tag_property_id_tag_id_pk" PRIMARY KEY("property_id","tag_id")
);
--> statement-breakpoint
ALTER TABLE "portfolio_tag" ADD CONSTRAINT "portfolio_tag_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "property_tag" ADD CONSTRAINT "property_tag_property_id_property_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."property"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "property_tag" ADD CONSTRAINT "property_tag_tag_id_portfolio_tag_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."portfolio_tag"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "uq_portfolio_tag_portfolio_lower_name" ON "portfolio_tag" USING btree ("portfolio_id",lower("name"));--> statement-breakpoint
CREATE INDEX "ix_property_tag_tag" ON "property_tag" USING btree ("tag_id");

View file

@ -0,0 +1 @@
ALTER TABLE "hubspot_deal_data" ADD COLUMN "third_party_surveyor_identifier" text;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1849,6 +1849,62 @@
"when": 1783430551377,
"tag": "0264_yummy_master_chief",
"breakpoints": true
},
{
"idx": 265,
"version": "7",
"when": 1783446767021,
"tag": "0265_uprn_corrections",
"breakpoints": true
},
{
"idx": 266,
"version": "7",
"when": 1783511384352,
"tag": "0266_hesitant_bromley",
"breakpoints": true
},
{
"idx": 267,
"version": "7",
"when": 1783521828294,
"tag": "0267_add_hubspot_deal_planning_columns",
"breakpoints": true
},
{
"idx": 268,
"version": "7",
"when": 1783596612599,
"tag": "0268_free_magneto",
"breakpoints": true
},
{
"idx": 269,
"version": "7",
"when": 1783933385119,
"tag": "0269_lyrical_sway",
"breakpoints": true
},
{
"idx": 270,
"version": "7",
"when": 1783953369297,
"tag": "0270_epc_main_heating_detail_epc_property_index",
"breakpoints": true
},
{
"idx": 271,
"version": "7",
"when": 1783957414552,
"tag": "0271_portfolio_tag_and_property_tag",
"breakpoints": true
},
{
"idx": 272,
"version": "7",
"when": 1784292106010,
"tag": "0272_flashy_brood",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,46 @@
import { pgTable, uuid, text, boolean, timestamp, unique } from "drizzle-orm/pg-core";
import { bulkAddressUploads } from "./bulk_address_uploads";
// User confirmations of a combiner row's UPRN, captured on the pre-finalise
// "Addresses" review tab (ADR-0057). address2uprn withholds ambiguous / no-UPRN
// matches; the user resolves each flagged row via OS Places here, keyed by the
// combiner CSV's `source_row_id` (there is no `property.id` yet — the finaliser
// creates identities). At dispatchFinaliser these corrections are overlaid onto
// the combiner CSV before the finaliser reads it, so the confirmed UPRN drives
// both the identity insert and property_overrides in one pass — no post-finalise
// backfill (which is why the portfolio "Unmatched" tab is retired).
//
// A row is resolved when it has a numeric `uprn` OR `markedNoUprn = true` (the
// user asserts no UPRN exists — an accepted terminal state; the finaliser
// leaves it as a no-UPRN property).
export const bulkUploadUprnCorrections = pgTable(
"bulk_upload_uprn_corrections",
{
id: uuid("id").defaultRandom().primaryKey(),
uploadId: uuid("upload_id")
.notNull()
.references(() => bulkAddressUploads.id, { onDelete: "cascade" }),
// The combiner CSV join key (synthetic UUID minted at start-address-matching).
sourceRowId: text("source_row_id").notNull(),
// The confirmed UPRN; null when markedNoUprn.
uprn: text("uprn"),
address: text("address"),
postcode: text("postcode"),
// The user asserts this address has no UPRN — resolved without one.
markedNoUprn: boolean("marked_no_uprn").notNull().default(false),
userId: text("user_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => ({
// One correction per combiner row — the confirm action upserts on this.
uploadRowUnique: unique("bulk_upload_uprn_corrections_upload_row_unique").on(
t.uploadId,
t.sourceRowId,
),
}),
);

View file

@ -82,6 +82,16 @@ export const hubspotDealData = pgTable("hubspot_deal_data", {
numberOfAttempts: text("number_of_attempts"),
clientBookingReference: text("client_booking_reference"),
thirdPartySurveyorIdentifier: text("third_party_surveyor_identifier"),
designConstraints: text("design_constraints"),
planningComments: text("planning_comments"),
planningStatus: text("planning_status"),
planningSuggestedApproach: text("planning_suggested_approach"),
planningAuthority: text("planning_authority"),
designatedArea: text("designated_area"),
article4PdRights: text("article_4_pd_rights"),
listedBuilding: text("listed_building"),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()

View file

@ -410,12 +410,23 @@ export interface PropertyWithRelations extends Record<string, unknown> {
// Optional columns (hidden by default)
propertyType: string | null;
builtForm: string | null;
tenure: string | null;
yearBuilt: string | null;
totalFloorArea: number | null;
co2Emissions: number | null;
mainfuel: string | null;
wallType: string | null;
roofType: string | null;
heatingSystem: string | null;
lexiscore: number | null;
// Portfolio Tags on this property (ADR-0013), name-ordered; [] when untagged.
tags: PropertyTag[];
}
/** A Tag as it appears on a property row (id serialised to a string). */
export interface PropertyTag {
id: string;
name: string;
colour: string;
}
export type NonIntrusiveSurveyNotes = InferModel<
@ -719,6 +730,12 @@ export const epcMainHeatingDetail = pgTable(
communityHeatingBoilerFuelType: integer("community_heating_boiler_fuel_type"), // SAP fuel code, nullable
communityHeatingChpFraction: real("community_heating_chp_fraction"), // 0..1 fraction, nullable
},
(table) => [
// The portfolio list resolves Main Fuel per row via a correlated subquery on
// epc_property_id (mainfuelSql, ADR-0012); without this index that seq-scans
// the table for every property in the count/export path.
index("ix_epc_main_heating_detail_epc_property").on(table.epcPropertyId),
],
);
// ─── epc_building_part ────────────────────────────────────────────────────────

View file

@ -118,6 +118,26 @@ export const recommendation = pgTable(
sql`${table.default} = true AND ${table.alreadyInstalled} = false`,
),
// Covering partial index for the reporting "where the money goes"
// aggregate, which joins the latest plan per property to its
// recommendations ON plan_id, filters to the default active set, and sums
// estimated_cost / counts property_id per measure_type. The plain
// idx_recommendation_plan_id only locates the rows — the aggregate then
// heap-fetches every recommendation to apply the filter and read these
// columns, which times out on large portfolios. Carrying measure_type,
// property_id and estimated_cost as key columns (drizzle-orm has no
// INCLUDE) makes the whole aggregate index-only.
index("idx_recommendation_plan_default")
.on(
table.planId,
table.measureType,
table.propertyId,
table.estimatedCost,
)
.where(
sql`${table.default} = true AND ${table.alreadyInstalled} = false`,
),
index("idx_recommendation_material_id").on(table.materialId),
],
);
@ -241,6 +261,15 @@ export const plan = pgTable(
table.propertyId,
table.createdAt.desc(),
),
// Serves the default-plan LATERAL in the property-list query (getProperties):
// "latest default plan for this property". The existing indexes lead with
// (portfolio_id, scenario_id, …), so with scenario_id unconstrained the LATERAL
// scans the whole portfolio's plans per property (~11.5ms × N rows). Leading
// with property_id turns it into a direct seek. Partial on is_default keeps it
// lean. See PR investigation of portfolio/796 (~12s → ~sub-second).
index("idx_plan_property_default_latest")
.on(table.propertyId, table.portfolioId, table.createdAt.desc())
.where(sql`is_default = true`),
],
);
@ -298,6 +327,7 @@ export const scenario = pgTable("scenario", {
exclusions: text("exclusions"),
multiPlan: boolean("multi_plan"),
isDefault: boolean("is_default").notNull(),
fabricFirst: boolean("fabric_first").notNull().default(false),
// Aggregations that were previously being stored against the portfolio, that are now being stored against the scenario
cost: real("cost"),
contingency: real("contingency"),

87
src/app/db/schema/tags.ts Normal file
View file

@ -0,0 +1,87 @@
import {
bigint,
bigserial,
index,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { relations, sql } from "drizzle-orm";
import { portfolio } from "./portfolio";
import { property } from "./property";
// A Tag is a user-created, coloured label owned by a Portfolio, used to form an
// arbitrary group of its Properties for bulk actions (CONTEXT.md, ADR-0013).
// Flat (no hierarchy) and many-to-many with Property via `property_tag`.
export const portfolioTag = pgTable(
"portfolio_tag",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id, { onDelete: "cascade" }),
name: text("name").notNull(),
// Freeform hex string (e.g. "#7C3AED"); format is validated in the app.
colour: text("colour").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => [
// Names are unique within a Portfolio, case-insensitively ("Void" vs "void").
uniqueIndex("uq_portfolio_tag_portfolio_lower_name").on(
table.portfolioId,
sql`lower(${table.name})`,
),
],
);
// The many-to-many membership: one row per (Property, Tag). Both FKs cascade —
// deleting a Tag drops its memberships (ADR-0013, hard delete), and deleting a
// Property drops its tags.
export const propertyTag = pgTable(
"property_tag",
{
propertyId: bigint("property_id", { mode: "bigint" })
.notNull()
.references(() => property.id, { onDelete: "cascade" }),
tagId: bigint("tag_id", { mode: "bigint" })
.notNull()
.references(() => portfolioTag.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
// A Property can't hold the same Tag twice.
primaryKey({ columns: [table.propertyId, table.tagId] }),
// The composite PK leads with property_id; reverse lookups ("all Properties
// for this Tag" — chips, filter, run resolution) need a tag_id-leading index.
index("ix_property_tag_tag").on(table.tagId),
],
);
export const portfolioTagRelations = relations(portfolioTag, ({ one, many }) => ({
portfolio: one(portfolio, {
fields: [portfolioTag.portfolioId],
references: [portfolio.id],
}),
memberships: many(propertyTag),
}));
export const propertyTagRelations = relations(propertyTag, ({ one }) => ({
tag: one(portfolioTag, {
fields: [propertyTag.tagId],
references: [portfolioTag.id],
}),
property: one(property, {
fields: [propertyTag.propertyId],
references: [property.id],
}),
}));

View file

@ -0,0 +1,322 @@
"use client";
import { useState } from "react";
import { MagnifyingGlassIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import { describeCandidate } from "@/lib/postcodeSearch/model";
import { searchAddresses, type SearchCandidate } from "@/lib/properties/client";
import { useSaveUprnCorrection, type FlaggedAddress } from "@/lib/bulkUpload/client";
// The pre-finalise "Addresses" review tab (ADR-0057). Lists the combiner rows
// address2uprn could not confidently match; the user resolves each via Ordnance
// Survey (residential candidates only → a guaranteed valid UPRN) or marks it as
// having no UPRN. Confirmations are saved as corrections (keyed by
// source_row_id) and overlaid onto the combiner CSV at finalise.
export default function AddressesPanel({
flagged,
portfolioId,
uploadId,
}: {
flagged: FlaggedAddress[];
portfolioId: string;
uploadId: string;
}) {
return (
<div className="space-y-3 rounded-xl border border-amber-200 bg-amber-50/40 p-4">
<div>
<h3 className="text-sm font-semibold text-gray-800">
Addresses that need a UPRN
</h3>
<p className="mt-0.5 text-xs text-gray-500">
We couldn&apos;t confidently match these to Ordnance Survey. Pick the
right address or mark it as having no UPRN so onboarding creates the
correct property.
</p>
</div>
{flagged.length === 0 ? (
<p className="text-xs text-gray-400">Nothing to resolve.</p>
) : (
<ul className="divide-y divide-amber-100 rounded-lg border border-amber-100 bg-white">
{flagged.map((row) => (
<AddressRow
key={row.sourceRowId}
row={row}
portfolioId={portfolioId}
uploadId={uploadId}
/>
))}
</ul>
)}
</div>
);
}
function statusLabel(status: string): string {
switch (status) {
case "ambiguous_duplicate":
return "Ambiguous match";
case "low_score":
return "Low-confidence match";
case "unmatched":
return "No match";
case "invalid_postcode":
return "Invalid postcode";
case "error":
return "Match error";
default:
return status;
}
}
function AddressRow({
row,
portfolioId,
uploadId,
}: {
row: FlaggedAddress;
portfolioId: string;
uploadId: string;
}) {
const save = useSaveUprnCorrection(portfolioId, uploadId);
const [open, setOpen] = useState(false);
const [address, setAddress] = useState(row.address ?? "");
const [postcode, setPostcode] = useState(row.postcode ?? "");
// null = not searched yet; [] = searched, no residential matches.
const [residential, setResidential] = useState<SearchCandidate[] | null>(null);
const [selected, setSelected] = useState<SearchCandidate | null>(null);
const [source, setSource] = useState<"cache" | "live" | null>(null);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
async function runSearch() {
setSearching(true);
setSearchError(null);
setResidential(null);
setSelected(null);
try {
const result = await searchAddresses(portfolioId, postcode);
// Residential only — we never assign a non-residential UPRN.
setResidential(result.candidates.filter((c) => c.selectable));
setSource(result.source);
} catch (e) {
setSearchError(e instanceof Error ? e.message : "Search failed.");
} finally {
setSearching(false);
}
}
function onAssign() {
if (!selected) return;
save.mutate(
{
sourceRowId: row.sourceRowId,
uprn: selected.uprn,
address: selected.address,
postcode: selected.postcode,
},
{ onSuccess: () => setOpen(false) },
);
}
function onNoUprn() {
save.mutate({ sourceRowId: row.sourceRowId, markedNoUprn: true });
}
return (
<li className="flex items-start justify-between gap-3 px-3 py-2.5">
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-gray-800" title={row.address}>
{row.address || "(no address)"}
</p>
<p className="text-[11px] text-gray-500">
{row.postcode || "no postcode"}
{row.lexiscore != null ? ` · score ${row.lexiscore.toFixed(2)}` : ""}
{" · "}
<span className="font-medium text-amber-700">{statusLabel(row.status)}</span>
</p>
{row.resolved && (
<p className="mt-1 inline-flex items-center gap-1 text-[11px] font-medium text-green-700">
<CheckCircleIcon className="h-3.5 w-3.5" />
{row.markedNoUprn
? "Marked: no UPRN"
: `UPRN ${row.resolvedUprn}${row.resolvedAddress ? ` · ${row.resolvedAddress}` : ""}`}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{row.resolved ? (
<button
type="button"
onClick={() => setOpen(true)}
className="text-xs text-gray-500 underline underline-offset-2 hover:text-gray-800"
>
Change
</button>
) : (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-xs font-semibold text-amber-800 transition-colors hover:bg-amber-100"
>
<MagnifyingGlassIcon className="h-3.5 w-3.5" />
Find UPRN
</button>
<button
type="button"
onClick={onNoUprn}
disabled={save.isPending}
className="text-xs text-gray-500 underline underline-offset-2 hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
>
No UPRN
</button>
</>
)}
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Match to a UPRN</DialogTitle>
<DialogDescription>
Confirm the postcode and pick the matching address. Only residential
addresses from Ordnance Survey are shown, so the UPRN is always valid.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-semibold text-gray-600">
Address
</label>
<input
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="e.g. 20 Brenchley Road"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
</div>
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="mb-1 block text-xs font-semibold text-gray-600">
Postcode
</label>
<input
value={postcode}
onChange={(e) => setPostcode(e.target.value)}
placeholder="e.g. BR5 2TD"
onKeyDown={(e) => {
if (e.key === "Enter" && postcode.trim() && !searching)
runSearch();
}}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
</div>
<button
type="button"
onClick={runSearch}
disabled={!postcode.trim() || searching}
className="inline-flex items-center gap-1.5 rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:bg-amber-300"
>
<MagnifyingGlassIcon className="h-4 w-4" />
{searching ? "Searching…" : "Search"}
</button>
</div>
{searchError && <p className="text-xs text-red-600">{searchError}</p>}
{residential && (
<div>
<div className="mb-1 flex items-center justify-between">
<p className="text-xs font-semibold text-gray-600">
Residential addresses
</p>
{source === "cache" && (
<span className="text-[10px] font-medium uppercase tracking-wide text-gray-400">
from cache
</span>
)}
</div>
<div className="max-h-64 overflow-y-auto rounded-lg border border-gray-100">
{residential.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-gray-400">
No residential addresses found for that postcode.
</p>
) : (
<ul className="divide-y divide-gray-50">
{residential.map((c) => {
const { label } = describeCandidate(c);
const isSelected = selected?.uprn === c.uprn;
return (
<li key={c.uprn}>
<button
type="button"
onClick={() => setSelected(c)}
className={`flex w-full items-start gap-2 px-3 py-2 text-left text-sm transition-colors ${
isSelected ? "bg-amber-50" : "hover:bg-gray-50"
}`}
>
<CheckCircleIcon
className={`mt-0.5 h-4 w-4 shrink-0 ${
isSelected ? "text-amber-600" : "text-gray-200"
}`}
/>
<span className="min-w-0 flex-1">
<span
className="block truncate text-gray-800"
title={c.address}
>
{c.address}
</span>
<span className="block truncate text-[11px] text-gray-500">
UPRN {c.uprn} · {label}
{c.alreadyInPortfolio ? " · already in portfolio" : ""}
</span>
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
)}
</div>
<DialogFooter>
{save.error && (
<p className="mr-auto self-center text-xs text-red-600">
{save.error.message}
</p>
)}
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg px-4 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-100"
>
Cancel
</button>
<button
type="button"
onClick={onAssign}
disabled={!selected || save.isPending}
className="rounded-lg bg-amber-600 px-4 py-2 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:bg-amber-300"
>
{save.isPending ? "Assigning…" : "Assign UPRN"}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</li>
);
}

View file

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowRightIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import {
useAddressMatches,
useBulkUploadProgress,
useConfirmMultiEntryOrdering,
useConfirmVerification,
@ -14,6 +15,7 @@ import {
useSampleClassifications,
type SampleClassifications,
} from "@/lib/bulkUpload/client";
import AddressesPanel from "./AddressesPanel";
import {
partLabel,
isPermutation,
@ -69,6 +71,9 @@ export default function OnboardingProgress({
});
const combine = useRequestCombine(portfolioId, uploadId);
const finalize = useFinalize(portfolioId, uploadId);
const [reviewTab, setReviewTab] = useState<"classification" | "addresses">(
"classification",
);
// Read-only classifications for the multi-entry sample (issue #298). Fetched
// only once a sample exists at awaiting_review. Hook stays above the early
@ -77,6 +82,13 @@ export default function OnboardingProgress({
progress.data?.upload.status === "awaiting_review" &&
!!progress.data.upload.multiEntrySummary?.sample;
const classifications = useSampleClassifications(portfolioId, uploadId, sampleReady);
// Combiner rows address2uprn could not confidently match (ADR-0057). Fetched
// once at awaiting_review; drives the Addresses tab + the Finalise gate.
const addressMatches = useAddressMatches(
portfolioId,
uploadId,
progress.data?.upload.status === "awaiting_review",
);
if (progress.isError) return null;
if (!progress.data) {
@ -139,11 +151,17 @@ export default function OnboardingProgress({
(n, descriptions) => n + descriptions.length,
0,
);
// Flagged addresses (no confident UPRN) must all be resolved before finalise,
// else a withheld row lands as an unintended no-UPRN property (ADR-0057).
const flaggedCount = addressMatches.data?.flagged.length ?? 0;
const unresolvedAddresses = addressMatches.data?.unresolved ?? 0;
const showAddressTab = isAwaitingReview && flaggedCount > 0;
const canFinalize =
isAwaitingReview &&
(!needsVerify || verifyAck) &&
(!needsOrdering || orderingConfirmed) &&
unknownTotal === 0;
unknownTotal === 0 &&
unresolvedAddresses === 0;
return (
<div className="mt-6 space-y-3">
@ -211,47 +229,94 @@ export default function OnboardingProgress({
)}
</div>
{needsVerify && sample && (
<VerifyClassificationPanel
sample={sample}
classifications={classifications.data?.classifications ?? {}}
verified={verifyAck}
stepLabel={showStepNumbers ? "Step 1" : undefined}
portfolioId={portfolioId}
uploadId={uploadId}
/>
{/* Two-tab review at awaiting_review: classification checks | address/UPRN
confirmation (ADR-0057). The Addresses tab only appears when there are
flagged rows; otherwise the classification panels render as before. */}
{showAddressTab && (
<div className="flex gap-4 border-b border-gray-200 text-sm">
<button
type="button"
onClick={() => setReviewTab("classification")}
className={`-mb-px border-b-2 px-1 pb-2 transition-colors ${
reviewTab === "classification"
? "border-[#14163d] font-medium text-[#14163d]"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
Classification
</button>
<button
type="button"
onClick={() => setReviewTab("addresses")}
className={`-mb-px flex items-center gap-1.5 border-b-2 px-1 pb-2 transition-colors ${
reviewTab === "addresses"
? "border-[#14163d] font-medium text-[#14163d]"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
Addresses
{unresolvedAddresses > 0 && (
<span className="rounded-full bg-amber-500 px-1.5 text-[10px] font-semibold text-white">
{unresolvedAddresses}
</span>
)}
</button>
</div>
)}
{isAwaitingReview && unknownTotal > 0 && (
<UnresolvedClassificationsPanel
unknown={unknownByField}
portfolioId={portfolioId}
uploadId={uploadId}
/>
)}
{needsOrdering && orderingSamples.length > 0 && (
<div className="space-y-3">
{orderingSamples.map(([count, orderSample], i) => (
<MultiEntryOrderingPanel
key={count}
sample={orderSample}
ordering={upload.multiEntryOrdering ?? null}
{(!showAddressTab || reviewTab === "classification") && (
<>
{needsVerify && sample && (
<VerifyClassificationPanel
sample={sample}
classifications={classifications.data?.classifications ?? {}}
// Number the panels only when there's also a verify step or more
// than one count, so a lone ordering panel stays unnumbered.
stepLabel={
showStepNumbers
? `Step ${i + 2}`
: orderingSamples.length > 1
? `Part group ${i + 1}`
: undefined
}
verified={verifyAck}
stepLabel={showStepNumbers ? "Step 1" : undefined}
portfolioId={portfolioId}
uploadId={uploadId}
/>
))}
</div>
)}
{isAwaitingReview && unknownTotal > 0 && (
<UnresolvedClassificationsPanel
unknown={unknownByField}
portfolioId={portfolioId}
uploadId={uploadId}
/>
)}
{needsOrdering && orderingSamples.length > 0 && (
<div className="space-y-3">
{orderingSamples.map(([count, orderSample], i) => (
<MultiEntryOrderingPanel
key={count}
sample={orderSample}
ordering={upload.multiEntryOrdering ?? null}
classifications={classifications.data?.classifications ?? {}}
// Number the panels only when there's also a verify step or more
// than one count, so a lone ordering panel stays unnumbered.
stepLabel={
showStepNumbers
? `Step ${i + 2}`
: orderingSamples.length > 1
? `Part group ${i + 1}`
: undefined
}
portfolioId={portfolioId}
uploadId={uploadId}
/>
))}
</div>
)}
</>
)}
{showAddressTab && reviewTab === "addresses" && (
<AddressesPanel
flagged={addressMatches.data?.flagged ?? []}
portfolioId={portfolioId}
uploadId={uploadId}
/>
)}
{(canRunCombiner || isAwaitingReview) && (
@ -271,11 +336,13 @@ export default function OnboardingProgress({
isPending={finalize.isPending}
disabled={!canFinalize}
disabledReason={
unknownTotal > 0
? `Resolve ${unknownTotal} unclassified description${unknownTotal === 1 ? "" : "s"} first`
: needsVerify && !verifyAck
? "Verify the classification first"
: "Confirm the building-part order first"
unresolvedAddresses > 0
? `Resolve ${unresolvedAddresses} address${unresolvedAddresses === 1 ? "" : "es"} on the Addresses tab first`
: unknownTotal > 0
? `Resolve ${unknownTotal} unclassified description${unknownTotal === 1 ? "" : "s"} first`
: needsVerify && !verifyAck
? "Verify the classification first"
: "Confirm the building-part order first"
}
onClick={() =>
finalize.mutate(undefined, { onSuccess: () => router.refresh() })

View file

@ -11,9 +11,10 @@ export default async function Page(props: {
const params = await props.params;
const portfolioId = params.slug;
return (
<>
<PropertyTable portfolioId={portfolioId} />
</>
);
// UPRN matching is now confirmed during bulk-upload onboarding (ADR-0057), so
// properties arrive already matched — the portfolio-level "Unmatched" tab is
// retired. The tab components (PortfolioTabs / UnmatchedProperties /
// getUnmatchedProperties) are left in place for now, pending a follow-up that
// reconciles legacy no-UPRN properties onboarded before this feature.
return <PropertyTable portfolioId={portfolioId} />;
}

View file

@ -7,6 +7,7 @@ import { getScenarioWithPlansCount } from "@/lib/scenarios/queries";
import {
BandChip,
EPC_SOFT,
FabricFirstChip,
GOAL_SHORT_LABELS,
GoalIcon,
StatusPill,
@ -114,7 +115,7 @@ export default async function ScenarioDetailPage(props: {
)}
</div>
</div>
<div className="relative grid grid-cols-[200px_1fr] items-start gap-4 px-6 py-4">
<div className="relative grid grid-cols-[200px_1fr] items-start gap-4 border-b border-gray-100 px-6 py-4">
<div className={rowLabel}>Exclusions</div>
<div className="text-sm">
{summary.kind === "all" ? (
@ -151,6 +152,21 @@ export default async function ScenarioDetailPage(props: {
)}
</div>
</div>
<div className="relative grid grid-cols-[200px_1fr] items-start gap-4 px-6 py-4">
<div className={rowLabel}>Fabric first</div>
<div className="text-sm text-brandblue">
{s.fabricFirst ? (
<span className="flex flex-wrap items-center gap-2">
<FabricFirstChip />
<span className="text-gray-600">
insulation, glazing &amp; ventilation before heating &amp; renewables
</span>
</span>
) : (
<span className="text-gray-400">Off measures considered together</span>
)}
</div>
</div>
</div>
</DetailActions>
</div>

View file

@ -24,6 +24,7 @@ interface ExistingScenario {
goalValue: string | null;
budget: number | null;
exclusions: string[];
fabricFirst: boolean;
modelled: boolean;
}
@ -73,6 +74,7 @@ export function NewScenarioJourney({
const [band, setBand] = useState<string | null>(template?.goalValue ?? null);
const [budget, setBudget] = useState(template?.budget ? String(template.budget) : "");
const [excluded, setExcluded] = useState<Set<string>>(new Set(template?.exclusions ?? []));
const [fabricFirst, setFabricFirst] = useState(template?.fabricFirst ?? false);
const [errors, setErrors] = useState<{ name?: string; band?: string; excl?: string }>({});
const isEpc = goal === PORTFOLIO_GOALS.EPC;
@ -90,6 +92,7 @@ export function NewScenarioJourney({
goalValue: isEpc ? band : null,
budgetPerProperty: budgetNumber,
exclusions: [...excluded],
fabricFirst,
}),
});
if (!res.ok) throw new Error((await res.json()).error ?? "Save failed");
@ -126,6 +129,7 @@ export function NewScenarioJourney({
goalValue: isEpc ? band : null,
budgetPerProperty: budgetNumber,
exclusions: [...excluded],
fabricFirst,
},
existing.map((s) => ({
id: BigInt(s.id),
@ -135,6 +139,7 @@ export function NewScenarioJourney({
goalValue: s.goalValue,
budget: s.budget,
exclusions: s.exclusions.length ? `{${s.exclusions.join(",")}}` : null,
fabricFirst: s.fabricFirst,
})),
)
: null;
@ -455,6 +460,51 @@ export function NewScenarioJourney({
</div>
</div>
))}
<label
className={`mt-2 flex cursor-pointer items-start gap-3.5 rounded-xl border p-4 transition ${
fabricFirst
? "border-emerald-300 bg-emerald-50/70 shadow-[0_0_0_3px_rgba(16,185,129,.12)]"
: "border-gray-200 bg-white hover:border-emerald-200 hover:bg-emerald-50/30"
}`}
>
<span
aria-hidden
className={`mt-0.5 inline-flex h-9 w-9 flex-none items-center justify-center rounded-lg transition ${
fabricFirst ? "bg-emerald-500 text-white" : "bg-emerald-50 text-emerald-500"
}`}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 2 2 7l10 5 10-5-10-5Z" />
<path d="m2 17 10 5 10-5" />
<path d="m2 12 10 5 10-5" />
</svg>
</span>
<span className="flex min-w-0 flex-col">
<span className="font-manrope text-[10px] font-extrabold uppercase tracking-[.12em] text-emerald-600">
Optimisation order
</span>
<span className="text-sm font-semibold text-brandblue">Fabric first</span>
<span className="mt-0.5 text-[13px] text-gray-500">
Insulation, glazing and ventilation are applied first; heating and
renewables are only considered if the target isn&apos;t met.
</span>
</span>
<input
type="checkbox"
checked={fabricFirst}
onChange={(e) => setFabricFirst(e.target.checked)}
className="mt-1 h-4 w-4 flex-none accent-emerald-600"
/>
</label>
{errors.excl && <div className="text-[13px] text-red-800">{errors.excl}</div>}
</div>
)}
@ -542,6 +592,14 @@ export function NewScenarioJourney({
</span>
),
],
[
"Fabric first",
fabricFirst ? (
"On — fabric before heating & renewables"
) : (
<span key="ff" className="text-gray-400">Off measures considered together</span>
),
],
].map(([label, value]) => (
<div
key={label as string}

View file

@ -22,6 +22,7 @@ export default async function NewScenarioPage(props: {
goalValue: s.goalValue,
budget: s.budget,
exclusions: s.exclusions,
fabricFirst: s.fabricFirst,
modelled: s.status === "modelled",
}));
const template = from ? existing.find((s) => s.id === from) ?? null : null;

View file

@ -4,6 +4,7 @@ import { listScenariosWithStatus } from "@/lib/scenarios/queries";
import {
BandChip,
EPC_SOFT,
FabricFirstChip,
GOAL_SHORT_LABELS,
GoalIcon,
StatusPill,
@ -139,6 +140,12 @@ export default async function ScenariosPage(props: {
/property cap
</>
) : null}
{s.fabricFirst ? (
<>
{" "}
· <FabricFirstChip className="align-middle" />
</>
) : null}
</div>
<div className="relative flex items-center justify-between text-xs tabular-nums text-gray-400">
<StatusPill status={s.status} />

View file

@ -131,6 +131,19 @@ export function constraintLine(exclusions: string[]): React.ReactNode {
);
}
/** Small badge marking a fabric-first Scenario. Render only when the flag is set. */
export function FabricFirstChip({ className = "" }: { className?: string }) {
return (
<span
className={`inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-semibold text-emerald-800 ${className}`}
title="Insulation, glazing and ventilation are applied before heating and renewables."
>
<span aria-hidden className="h-1.5 w-1.5 flex-none rounded-full bg-emerald-500" />
Fabric first
</span>
);
}
export const formatDate = (d: Date | string) =>
new Date(d).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });

View file

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { SettingsSidebarLink } from "./SettingsSidebarLink";
import { Settings2, Users, Building2, ScrollText } from "lucide-react";
import { Settings2, Users, Building2, ScrollText, Tag } from "lucide-react";
export default async function SettingsLayout({
children,
@ -33,6 +33,12 @@ export default async function SettingsLayout({
>
User Access
</SettingsSidebarLink>
<SettingsSidebarLink
href={`/portfolio/${slug}/settings/tags`}
icon={<Tag size={16} />}
>
Tags
</SettingsSidebarLink>
{isDomnaUser && (
<SettingsSidebarLink
href={`/portfolio/${slug}/settings/connected-organisation`}

View file

@ -0,0 +1,237 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
import { Pencil, Trash2, Check, X, Plus } from "lucide-react";
import { MAX_TAG_NAME_LENGTH } from "@/lib/tags/model";
import { TagChip } from "@/app/portfolio/[slug]/components/TagChip";
import {
Tag,
usePortfolioTags,
portfolioTagsKey,
} from "@/app/portfolio/[slug]/components/useTags";
const DEFAULT_COLOUR = "#6366f1";
export default function TagsCard({ portfolioId }: { portfolioId: string }) {
const queryClient = useQueryClient();
const { data: tags = [], isLoading } = usePortfolioTags(portfolioId);
const [name, setName] = useState("");
const [colour, setColour] = useState(DEFAULT_COLOUR);
const [formError, setFormError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: portfolioTagsKey(portfolioId) });
const createTag = useMutation({
mutationFn: async (input: { name: string; colour: string }) => {
const res = await fetch(`/api/portfolio/${portfolioId}/tags`, {
method: "POST",
body: JSON.stringify(input),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Failed to create tag");
return body;
},
onSuccess: () => {
setName("");
setColour(DEFAULT_COLOUR);
setFormError(null);
invalidate();
},
onError: (e: Error) => setFormError(e.message),
});
return (
<div className="max-w-2xl">
<h2 className="text-lg font-semibold mb-1">Tags</h2>
<p className="text-sm text-gray-500 mb-5">
Group properties to run scenarios on a subset or filter your reporting.
Assign tags from the property table.
</p>
{/* Create */}
<form
className="flex items-end gap-2 mb-6"
onSubmit={(e) => {
e.preventDefault();
createTag.mutate({ name, colour });
}}
>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-gray-500">Colour</span>
<input
type="color"
value={colour}
onChange={(e) => setColour(e.target.value)}
className="h-9 w-10 rounded-md border border-gray-300 bg-white p-0.5 cursor-pointer"
aria-label="Tag colour"
/>
</label>
<label className="flex flex-col gap-1 flex-1">
<span className="text-xs font-medium text-gray-500">Name</span>
<Input
value={name}
maxLength={MAX_TAG_NAME_LENGTH}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Void, Phase 1, Exclude from report"
/>
</label>
<Button type="submit" disabled={!name.trim() || createTag.isLoading}>
<Plus className="h-4 w-4 mr-1" />
Add tag
</Button>
</form>
{formError && <p className="text-sm text-red-600 -mt-4 mb-4">{formError}</p>}
{/* List */}
{isLoading ? (
<p className="text-sm text-gray-400">Loading</p>
) : tags.length === 0 ? (
<p className="text-sm text-gray-400">No tags yet.</p>
) : (
<ul className="divide-y divide-gray-100 border border-gray-200 rounded-lg">
{tags.map((tag) =>
editingId === tag.id ? (
<EditRow
key={tag.id}
portfolioId={portfolioId}
tag={tag}
onDone={() => {
setEditingId(null);
invalidate();
}}
onCancel={() => setEditingId(null)}
/>
) : (
<TagRow
key={tag.id}
portfolioId={portfolioId}
tag={tag}
onEdit={() => setEditingId(tag.id)}
onDeleted={invalidate}
/>
),
)}
</ul>
)}
</div>
);
}
function TagRow({
portfolioId,
tag,
onEdit,
onDeleted,
}: {
portfolioId: string;
tag: Tag;
onEdit: () => void;
onDeleted: () => void;
}) {
const del = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/portfolio/${portfolioId}/tags/${tag.id}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to delete tag");
},
onSuccess: onDeleted,
});
return (
<li className="flex items-center gap-3 px-3 py-2.5">
<TagChip colour={tag.colour} name={tag.name} />
<span className="text-xs text-gray-400">
{tag.propertyCount} propert{tag.propertyCount === 1 ? "y" : "ies"}
</span>
<div className="ml-auto flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={onEdit} title="Edit">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
disabled={del.isLoading}
title="Delete"
onClick={() => {
if (
confirm(
`Delete "${tag.name}"? This removes it from ${tag.propertyCount} propert${tag.propertyCount === 1 ? "y" : "ies"}.`,
)
) {
del.mutate();
}
}}
>
<Trash2 className="h-3.5 w-3.5 text-red-500" />
</Button>
</div>
</li>
);
}
function EditRow({
portfolioId,
tag,
onDone,
onCancel,
}: {
portfolioId: string;
tag: Tag;
onDone: () => void;
onCancel: () => void;
}) {
const [name, setName] = useState(tag.name);
const [colour, setColour] = useState(tag.colour);
const [error, setError] = useState<string | null>(null);
const save = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/portfolio/${portfolioId}/tags/${tag.id}`, {
method: "PATCH",
body: JSON.stringify({ name, colour }),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Failed to save");
return body;
},
onSuccess: onDone,
onError: (e: Error) => setError(e.message),
});
return (
<li className="flex items-center gap-2 px-3 py-2.5 bg-gray-50">
<input
type="color"
value={colour}
onChange={(e) => setColour(e.target.value)}
className="h-8 w-9 rounded-md border border-gray-300 bg-white p-0.5 cursor-pointer"
aria-label="Tag colour"
/>
<Input
value={name}
maxLength={MAX_TAG_NAME_LENGTH}
onChange={(e) => setName(e.target.value)}
className="flex-1"
/>
{error && <span className="text-xs text-red-600">{error}</span>}
<Button
size="sm"
disabled={!name.trim() || save.isLoading}
onClick={() => save.mutate()}
title="Save"
>
<Check className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" onClick={onCancel} title="Cancel">
<X className="h-3.5 w-3.5" />
</Button>
</li>
);
}

View file

@ -0,0 +1,12 @@
import TagsCard from "./TagsCard";
export default async function TagsSettingsPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
return (
<div className="text-brandblue">
<TagsCard portfolioId={slug} />
</div>
);
}

View file

@ -0,0 +1,360 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
PackageCheck,
AlertTriangle,
X,
Download,
Loader2,
CheckCircle2,
} from "lucide-react";
import DocumentTable, { type DocumentSelection } from "./DocumentTable";
import type {
ClassifiedDeal,
DocStatusMap,
PortfolioCapabilityType,
ApprovalsByDeal,
} from "./types";
import {
MAX_BULK_DOWNLOAD_PROPERTIES,
buildSelectionConfig,
bulkDownloadRefetchInterval,
type BulkDownloadStatus,
} from "@/lib/bulkDocumentDownload/model";
interface Props {
data: ClassifiedDeal[];
onOpenDrawer: (
dealId: string,
uprn: string | null,
landlordPropertyId: string | null,
dealname: string | null,
batch: string | null,
batchDescription: string | null,
) => void;
docStatusMap: DocStatusMap;
portfolioId: string;
userCapability: PortfolioCapabilityType;
approvalsByDeal?: ApprovalsByDeal;
/** Current Documents-tab scope — a real project code or the "__ALL__" sentinel. */
currentProjectCode: string;
}
/** JSON body or null — never throws, so a 504's HTML page can't crash the UI. */
async function parseBody(res: Response): Promise<{ error?: string; taskId?: string } | null> {
const text = await res.text().catch(() => "");
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return null;
}
}
/**
* Bulk document download on the Documents tab: a "Bulk download" button flips the
* table into row-selection mode; the picked landlord property ids become a task
* whose ZIP is emailed (and shown here while the page stays open). Scoped by the
* tab's existing project/group selection.
*/
export default function DocumentBulkDownload({
data,
onOpenDrawer,
docStatusMap,
portfolioId,
userCapability,
approvalsByDeal,
currentProjectCode,
}: Props) {
const [selecting, setSelecting] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [done, setDone] = useState<{ taskId: string } | null>(null);
const landlordPropertyIds = [...selectedIds];
const built = buildSelectionConfig({
scope: { kind: "project", projectCode: currentProjectCode },
includeAll: false,
landlordPropertyIds,
portfolioId: Number(portfolioId),
});
const trigger = useMutation<{ taskId: string }, Error, void>({
mutationFn: async () => {
const res = await fetch(`/api/portfolio/${portfolioId}/bulk-document-download`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope: { kind: "project", projectCode: currentProjectCode },
includeAll: false,
landlordPropertyIds,
}),
});
const body = await parseBody(res);
if (!res.ok) {
throw new Error(
body?.error ??
(res.status >= 500
? "The server didnt respond in time. Your download may still be preparing — check your email shortly."
: `Couldnt start the download (${res.status}).`),
);
}
if (!body?.taskId) throw new Error("Unexpected response from the server.");
return { taskId: body.taskId };
},
onSuccess: ({ taskId }) => {
setDone({ taskId });
setSelecting(false);
setSelectedIds(new Set());
},
});
const status = useQuery<BulkDownloadStatus, Error>({
queryKey: ["bulk-download-status", portfolioId, done?.taskId],
enabled: !!done,
refetchInterval: (d) => bulkDownloadRefetchInterval(d),
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/bulk-document-download/${done!.taskId}`,
);
const body = await parseBody(res);
if (!res.ok || !body) {
throw new Error("Couldnt check the download status.");
}
return body as unknown as BulkDownloadStatus;
},
});
const selection: DocumentSelection | undefined = selecting
? {
selectedIds,
onToggle: (id) =>
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
}),
onSetMany: (ids, on) =>
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of ids) {
if (on) next.add(id);
else next.delete(id);
}
return next;
}),
}
: undefined;
const overCap = selectedIds.size > MAX_BULK_DOWNLOAD_PROPERTIES;
return (
<div className="space-y-3">
{/* Delivery / status card */}
{done && (
<DeliveryCard
status={status.data}
isError={status.isError}
onDismiss={() => setDone(null)}
/>
)}
{/* Bulk-download control bar */}
{!selecting ? (
<div className="flex items-center justify-between gap-3">
<button
onClick={() => setSelecting(true)}
className="inline-flex items-center gap-2 h-9 px-3.5 rounded-lg border border-brandblue/20 bg-white text-sm font-medium text-brandblue hover:border-brandblue/40 hover:bg-brandlightblue/10 transition-colors"
>
<PackageCheck className="h-4 w-4" />
Bulk download
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-brandblue/20 bg-brandlightblue/10 px-4 py-2.5">
<span className="text-sm font-semibold text-brandblue">
{selectedIds.size} selected
</span>
<span className="text-xs text-gray-500">
Tick properties, or use the header box to select the page (then select
all across pages).
</span>
{overCap && (
<span className="text-xs font-medium text-red-600">
Over the {MAX_BULK_DOWNLOAD_PROPERTIES} limit narrow your selection.
</span>
)}
{trigger.isError && (
<span className="text-xs font-medium text-red-600">{trigger.error.message}</span>
)}
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => {
setSelecting(false);
setSelectedIds(new Set());
trigger.reset();
}}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-600 hover:bg-gray-50 transition-colors"
>
<X className="h-3.5 w-3.5" />
Cancel
</button>
<button
onClick={() => trigger.mutate()}
disabled={!built.ok || trigger.isLoading}
className="inline-flex items-center gap-2 h-9 px-3.5 rounded-lg bg-brandblue text-sm font-semibold text-white hover:bg-brandblue/90 disabled:opacity-40 transition-colors"
>
{trigger.isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{trigger.isLoading ? "Starting…" : "Download selected"}
</button>
</div>
</div>
)}
<DocumentTable
data={data}
onOpenDrawer={onOpenDrawer}
docStatusMap={docStatusMap}
portfolioId={portfolioId}
userCapability={userCapability}
approvalsByDeal={approvalsByDeal}
selection={selection}
/>
</div>
);
}
function DeliveryCard({
status,
isError,
onDismiss,
}: {
status: BulkDownloadStatus | undefined;
isError: boolean;
onDismiss: () => void;
}) {
const [showSkipped, setShowSkipped] = useState(false);
const state = status?.state ?? "preparing";
// Completed, but the link couldn't be surfaced in-app (unparseable outputs).
// The worker still emails it — send the user there rather than hang.
if (state === "ready" && !status?.delivery) {
return (
<div
role="status"
className="flex flex-wrap items-center gap-3 rounded-xl border border-emerald-200 bg-emerald-50/70 px-4 py-3"
>
<CheckCircle2 className="h-5 w-5 flex-none text-emerald-600" aria-hidden />
<div className="min-w-0">
<p className="text-sm font-semibold text-brandblue">Your download is ready</p>
<p className="text-[13px] text-gray-600">
Weve emailed you the link its valid for about 60 minutes.
</p>
</div>
<button
onClick={onDismiss}
className="ml-auto inline-flex items-center gap-1.5 h-9 px-3 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-600 hover:bg-gray-50 transition-colors"
>
Dismiss
</button>
</div>
);
}
if (state === "ready" && status?.delivery) {
const { presignedUrl, included, skipped } = status.delivery;
return (
<div
role="status"
className="rounded-xl border border-emerald-200 bg-emerald-50/70 px-4 py-3"
>
<div className="flex flex-wrap items-center gap-3">
<CheckCircle2 className="h-5 w-5 flex-none text-emerald-600" aria-hidden />
<div className="min-w-0">
<p className="text-sm font-semibold text-brandblue">Your download is ready</p>
<p className="text-[13px] text-gray-600">
{included} {included === 1 ? "document" : "documents"} included
{skipped.length > 0 && ` · ${skipped.length} skipped`}. Weve also emailed
the link its valid for about 60 minutes.
</p>
</div>
<a
href={presignedUrl}
className="ml-auto inline-flex items-center gap-2 h-9 px-3.5 rounded-lg bg-brandblue text-sm font-semibold text-white hover:bg-brandblue/90 transition-colors"
>
<Download className="h-4 w-4" />
Download ZIP
</a>
</div>
{skipped.length > 0 && (
<div className="mt-2">
<button
onClick={() => setShowSkipped((s) => !s)}
className="text-[12.5px] font-semibold text-brandmidblue hover:underline"
>
{showSkipped ? "Hide" : "Show"} {skipped.length} skipped
</button>
{showSkipped && (
<ul className="mt-1.5 max-h-40 overflow-y-auto rounded-lg border border-gray-100 bg-white px-3 py-2 text-[12.5px] text-gray-600">
{skipped.map((s, i) => (
<li key={`${s.landlord_property_id}-${i}`}>
<b className="font-semibold text-gray-700">{s.landlord_property_id}</b> {s.reason}
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
if (state === "failed" || isError) {
return (
<div
role="status"
className="flex flex-wrap items-center gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3"
>
<AlertTriangle className="h-5 w-5 flex-none text-amber-500" aria-hidden />
<div className="min-w-0">
<p className="text-sm font-semibold text-brandblue">
We couldnt build this download
</p>
<p className="text-[13px] text-gray-600">
The selection may contain no documents. Try a different selection.
</p>
</div>
<button
onClick={onDismiss}
className="ml-auto inline-flex items-center gap-1.5 h-9 px-3 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-600 hover:bg-gray-50 transition-colors"
>
Dismiss
</button>
</div>
);
}
return (
<div
role="status"
className="flex items-center gap-3 rounded-xl border border-brandblue/20 bg-brandlightblue/10 px-4 py-3"
>
<Loader2 className="h-5 w-5 flex-none animate-spin text-brandmidblue" aria-hidden />
<div>
<p className="text-sm font-semibold text-brandblue">
Your download is being prepared well email you a link
</p>
<p className="text-[13px] text-gray-500">
You can leave this page; the link will be in your inbox, and itll appear here
if you stay.
</p>
</div>
</div>
);
}

View file

@ -10,6 +10,7 @@ import {
flexRender,
type SortingState,
type PaginationState,
type ColumnDef,
} from "@tanstack/react-table";
import {
Table,
@ -34,6 +35,18 @@ import type { ClassifiedDeal, DocStatusMap, PortfolioCapabilityType, ApprovalsBy
type RetroAssessmentFilter = "all" | "none" | "partial" | "complete";
type InstallStatusFilter = "all" | "none" | "hasDocs" | "partial" | "complete";
/**
* Row-selection wiring for bulk document download. When present, the table
* shows a tick column; select-all operates over every filtered row (all pages),
* not just the visible page. Rows without a landlord property id are un-tickable
* documents are matched by landlord property id, so there is nothing to zip.
*/
export interface DocumentSelection {
selectedIds: Set<string>;
onToggle: (landlordPropertyId: string) => void;
onSetMany: (landlordPropertyIds: string[], selected: boolean) => void;
}
interface DocumentTableProps {
data: ClassifiedDeal[];
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null, batch: string | null, batchDescription: string | null) => void;
@ -41,6 +54,7 @@ interface DocumentTableProps {
portfolioId: string;
userCapability: PortfolioCapabilityType;
approvalsByDeal?: ApprovalsByDeal;
selection?: DocumentSelection;
}
function escapeCell(value: unknown): string {
@ -54,7 +68,7 @@ function escapeCell(value: unknown): string {
: str;
}
export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability, approvalsByDeal }: DocumentTableProps) {
export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability, approvalsByDeal, selection }: DocumentTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [retroAssessmentFilter, setRetroAssessmentFilter] = useState<RetroAssessmentFilter>("all");
const [installStatusFilter, setInstallStatusFilter] = useState<InstallStatusFilter>("all");
@ -87,7 +101,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
});
}, [data, retroAssessmentFilter, installStatusFilter, docStatusMap]);
const columns = useMemo(
const baseColumns = useMemo(
() => createDocumentTableColumns(
onOpenDrawer,
docStatusMap,
@ -96,6 +110,60 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
[onOpenDrawer, docStatusMap, userCapability],
);
// Prepend a tick column when in selection mode. Recomputed when `selection`
// changes (a fresh Set per toggle) so checkbox state stays live.
const columns: ColumnDef<ClassifiedDeal>[] = useMemo(() => {
if (!selection) return baseColumns;
const selectColumn: ColumnDef<ClassifiedDeal> = {
id: "select",
enableSorting: false,
enableGlobalFilter: false,
header: ({ table }) => {
// Header ticks the current page; the banner below offers all pages.
const pageIds = table
.getRowModel()
.rows.map((r) => r.original.landlordPropertyId)
.filter((id): id is string => !!id);
const allSelected =
pageIds.length > 0 && pageIds.every((id) => selection.selectedIds.has(id));
const someSelected = pageIds.some((id) => selection.selectedIds.has(id));
return (
<input
type="checkbox"
aria-label="Select all properties on this page"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected && !allSelected;
}}
onChange={() => selection.onSetMany(pageIds, !allSelected)}
className="h-4 w-4 accent-brandblue align-middle"
/>
);
},
cell: ({ row }) => {
const id = row.original.landlordPropertyId;
if (!id) {
return (
<span
title="No landlord property id — no documents to include"
className="inline-block h-4 w-4 rounded border border-gray-200 bg-gray-100 align-middle"
/>
);
}
return (
<input
type="checkbox"
aria-label={`Select ${id}`}
checked={selection.selectedIds.has(id)}
onChange={() => selection.onToggle(id)}
className="h-4 w-4 accent-brandblue align-middle"
/>
);
},
};
return [selectColumn, ...baseColumns];
}, [baseColumns, selection]);
const table = useReactTable({
data: filteredData,
columns,
@ -151,6 +219,31 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
const currentPage = table.getState().pagination.pageIndex + 1;
const totalFiltered = table.getFilteredRowModel().rows.length;
// "Select all across pages" banner: shown when the whole current page is
// ticked and there are more filtered rows than fit on it (Gmail-style).
const pageSelectableIds = selection
? table
.getRowModel()
.rows.map((r) => r.original.landlordPropertyId)
.filter((id): id is string => !!id)
: [];
const filteredSelectableIds = selection
? table
.getFilteredRowModel()
.rows.map((r) => r.original.landlordPropertyId)
.filter((id): id is string => !!id)
: [];
const pageFullySelected =
pageSelectableIds.length > 0 &&
pageSelectableIds.every((id) => selection!.selectedIds.has(id));
const allFilteredSelected =
filteredSelectableIds.length > 0 &&
filteredSelectableIds.every((id) => selection!.selectedIds.has(id));
const showSelectAllBanner =
!!selection &&
pageFullySelected &&
filteredSelectableIds.length > pageSelectableIds.length;
const retroAssessmentLabel: Record<RetroAssessmentFilter, string> = {
all: "All retrofit statuses",
none: "No Retrofit Docs",
@ -248,6 +341,40 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
propert{totalFiltered === 1 ? "y" : "ies"}
</p>
{/* Select-all-across-pages banner */}
{showSelectAllBanner && (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-brandblue/20 bg-brandlightblue/20 px-3.5 py-2 text-[13px] text-brandblue">
{allFilteredSelected ? (
<>
<span>
All <span className="font-semibold">{filteredSelectableIds.length}</span>{" "}
matching propert{filteredSelectableIds.length === 1 ? "y is" : "ies are"}{" "}
selected.
</span>
<button
onClick={() => selection!.onSetMany(filteredSelectableIds, false)}
className="font-semibold underline hover:text-brandblue/80"
>
Clear selection
</button>
</>
) : (
<>
<span>
All <span className="font-semibold">{pageSelectableIds.length}</span> on this
page selected.
</span>
<button
onClick={() => selection!.onSetMany(filteredSelectableIds, true)}
className="font-semibold underline hover:text-brandblue/80"
>
Select all {filteredSelectableIds.length} across pages
</button>
</>
)}
</div>
)}
{/* Table */}
<div className="rounded-xl border border-gray-200 overflow-hidden shadow-sm">
<div className="overflow-x-auto">

View file

@ -67,7 +67,15 @@ const COLUMN_LABELS: Record<string, string> = {
coordinationComments: "Coordination Comments",
dampAndMouldGrowth: "Damp and Mould Growth",
dampMouldAndRepairComments: "Damp, Mould and Repair Comments",
domnaSurveyRequested: "Domna Survey Requested"
domnaSurveyRequested: "Domna Survey Requested",
planningAuthority: "Planning Authority",
designatedArea: "Designated Area",
article4PdRights: "Article 4 PD Rights",
listedBuilding: "Listed Building",
designConstraints: "Design Constraints",
planningComments: "Planning Comments",
planningStatus: "Planning Status",
planningSuggestedApproach: "Planning Suggested Approach",
};
type DocFilter = "all" | "has_docs" | "incomplete" | "none";
@ -114,6 +122,14 @@ export default function PropertyTable({ data, onOpenDrawer, portfolioId = "", sh
dampAndMouldGrowth: false,
dampMouldAndRepairComments: false,
domnaSurveyRequested: false,
planningAuthority: false,
designatedArea: false,
article4PdRights: false,
listedBuilding: false,
designConstraints: false,
planningComments: false,
planningStatus: false,
planningSuggestedApproach: false,
});
// Pre-filter by stage, doc status, and removal status before TanStack gets it

View file

@ -439,6 +439,102 @@ export function createPropertyTableColumns(
},
},
// ── Planning authority ───────────────────────────────────────────────
{
accessorKey: "planningAuthority",
id: "planningAuthority",
header: ({ column }) => <SortableHeader label="Planning Authority" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs text-gray-600 max-w-[180px] line-clamp-2 leading-snug">
{row.original.planningAuthority ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Designated area ──────────────────────────────────────────────────
{
accessorKey: "designatedArea",
id: "designatedArea",
header: ({ column }) => <SortableHeader label="Designated Area" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs text-gray-600 max-w-[180px] line-clamp-2 leading-snug">
{row.original.designatedArea ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Article 4 PD rights ──────────────────────────────────────────────
{
accessorKey: "article4PdRights",
id: "article4PdRights",
header: ({ column }) => <SortableHeader label="Article 4 PD Rights" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs text-gray-600 max-w-[180px] line-clamp-2 leading-snug">
{row.original.article4PdRights ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Listed building ──────────────────────────────────────────────────
{
accessorKey: "listedBuilding",
id: "listedBuilding",
header: ({ column }) => <SortableHeader label="Listed Building" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs text-gray-600 max-w-[180px] line-clamp-2 leading-snug">
{row.original.listedBuilding ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Design constraints ───────────────────────────────────────────────
{
accessorKey: "designConstraints",
id: "designConstraints",
header: ({ column }) => <SortableHeader label="Design Constraints" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs text-gray-600 max-w-[220px] line-clamp-2 leading-snug">
{row.original.designConstraints ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Planning comments ────────────────────────────────────────────────
{
accessorKey: "planningComments",
id: "planningComments",
header: ({ column }) => <SortableHeader label="Planning Comments" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs text-gray-600 max-w-[220px] line-clamp-2 leading-snug">
{row.original.planningComments ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Planning status ──────────────────────────────────────────────────
{
accessorKey: "planningStatus",
id: "planningStatus",
header: ({ column }) => <SortableHeader label="Planning Status" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs text-gray-600 max-w-[180px] line-clamp-2 leading-snug">
{row.original.planningStatus ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Planning suggested approach ──────────────────────────────────────
{
accessorKey: "planningSuggestedApproach",
id: "planningSuggestedApproach",
header: ({ column }) => <SortableHeader label="Planning Suggested Approach" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs text-gray-600 max-w-[220px] line-clamp-2 leading-snug">
{row.original.planningSuggestedApproach ?? <span className="text-gray-300"></span>}
</span>
),
},
];
if (showDocuments) {

View file

@ -73,6 +73,14 @@ export function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal {
domnaSurveyDate: d.domnaSurveyDate,
batch: d.batch,
batchDescription: d.batchDescription,
planningAuthority: d.planningAuthority,
designatedArea: d.designatedArea,
article4PdRights: d.article4PdRights,
listedBuilding: d.listedBuilding,
designConstraints: d.designConstraints,
planningComments: d.planningComments,
planningStatus: d.planningStatus,
planningSuggestedApproach: d.planningSuggestedApproach,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
};

View file

@ -60,6 +60,14 @@ function makeDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
domnaSurveyDate: null,
batch: null,
batchDescription: null,
planningAuthority: null,
designatedArea: null,
article4PdRights: null,
listedBuilding: null,
designConstraints: null,
planningComments: null,
planningStatus: null,
planningSuggestedApproach: null,
coordinationComments: null,
domnasurveyRequired: null,
createdAt: new Date(),

View file

@ -59,6 +59,14 @@ function makeDeal(overrides: Partial<ClassifiedDeal> = {}): ClassifiedDeal {
domnaSurveyDate: null,
batch: null,
batchDescription: null,
planningAuthority: null,
designatedArea: null,
article4PdRights: null,
listedBuilding: null,
designConstraints: null,
planningComments: null,
planningStatus: null,
planningSuggestedApproach: null,
coordinationComments: null,
domnasurveyRequired: null,
createdAt: new Date(),

View file

@ -59,6 +59,14 @@ function makeDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
domnaSurveyDate: null,
batch: null,
batchDescription: null,
planningAuthority: null,
designatedArea: null,
article4PdRights: null,
listedBuilding: null,
designConstraints: null,
planningComments: null,
planningStatus: null,
planningSuggestedApproach: null,
coordinationComments: null,
domnasurveyRequired: null,
createdAt: new Date(),

View file

@ -30,6 +30,14 @@ export const PROPERTY_CSV_FIELDS: PropertyCsvField[] = [
{ key: "dampMouldFlag", label: "Damp and Mould Growth" },
{ key: "dampMouldAndRepairComments", label: "Damp Mould and Repair Comments" },
{ key: "domnasurveyRequired", label: "Domna Survey Required" },
{ key: "planningAuthority", label: "Planning Authority" },
{ key: "designatedArea", label: "Designated Area" },
{ key: "article4PdRights", label: "Article 4 PD Rights" },
{ key: "listedBuilding", label: "Listed Building" },
{ key: "designConstraints", label: "Design Constraints" },
{ key: "planningComments", label: "Planning Comments" },
{ key: "planningStatus", label: "Planning Status" },
{ key: "planningSuggestedApproach", label: "Planning Suggested Approach" },
];
export function escapeCsvCell(value: unknown): string {

View file

@ -66,6 +66,14 @@ function makeDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
domnaSurveyDate: null,
batch: null,
batchDescription: null,
planningAuthority: null,
designatedArea: null,
article4PdRights: null,
listedBuilding: null,
designConstraints: null,
planningComments: null,
planningStatus: null,
planningSuggestedApproach: null,
coordinationComments: null,
domnasurveyRequired: null,
createdAt: new Date(),

View file

@ -71,6 +71,16 @@ export type HubspotDeal = {
batch: string | null;
batchDescription: string | null;
// ── Planning fields (issue: extra PM columns) ─────────────────────────
planningAuthority: string | null;
designatedArea: string | null;
article4PdRights: string | null;
listedBuilding: string | null;
designConstraints: string | null;
planningComments: string | null;
planningStatus: string | null;
planningSuggestedApproach: string | null;
createdAt: Date;
updatedAt: Date;
};

View file

@ -6,8 +6,10 @@ import {
SparklesIcon,
BuildingOfficeIcon,
ShieldCheckIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import {
getPropertyMeta,
getConditionReport,
@ -67,6 +69,43 @@ export default async function BuildingPassportHome(props: {
}) {
const params = await props.params;
const propertyMeta = await getPropertyMeta(params.propertyId);
// A property with no UPRN isn't matched to an Ordnance Survey address yet, so
// the whole UPRN-keyed passport (spatial data, installed measures, EPC) has
// nothing to show — and getSpatialData(BigInt(null)) would 500. Surface a
// clear "not matched" state and link back to the portfolio instead.
if (!propertyMeta.uprn) {
return (
<div className="max-w-3xl mx-auto px-6 py-16">
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-8 text-center shadow-sm">
<ExclamationTriangleIcon className="mx-auto h-10 w-10 text-amber-500" />
<h1 className="mt-4 text-lg font-bold text-amber-900">
Not matched to a UPRN yet
</h1>
<p className="mx-auto mt-1 max-w-md text-sm text-amber-700">
{propertyMeta.address ? (
<>
<span className="font-medium">{propertyMeta.address}</span>{" "}
hasn&apos;t{" "}
</>
) : (
"This property hasn't "
)}
been matched to an Ordnance Survey address, so its building passport
isn&apos;t available yet. Match it to a UPRN from the portfolio to
unlock the passport.
</p>
<Link
href={`/portfolio/${params.slug}`}
className="mt-6 inline-flex items-center gap-1.5 rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
Back to portfolio
</Link>
</div>
</div>
);
}
const conditionReport = await getConditionReport(params.propertyId);
const spatial = await getSpatialData(propertyMeta.uprn);
const installedMeasures = await getInstalledMeasuresByUprn(Number(propertyMeta.uprn));

View file

@ -0,0 +1,324 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import * as XLSX from "xlsx";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import { Button } from "@/app/shadcn_components/ui/button";
import { parseTagIdentifierRows, IdentifierKey } from "@/lib/tags/bulkAssign";
import { Tag, useInvalidateTagSurfaces, usePortfolioTags } from "./useTags";
import { TagChip } from "./TagChip";
interface Parsed {
key: IdentifierKey;
identifiers: string[];
}
interface AssignSummary {
matchedPropertyCount: number;
taggedCount: number;
alreadyTaggedCount: number;
unmatchedCount: number;
unmatchedIdentifiers: string[];
}
const KEY_LABEL: Record<IdentifierKey, string> = {
landlord_property_id: "Property Ref (landlord id)",
uprn: "UPRN",
};
/** Download a one-column CSV template the user can fill in and re-upload. */
function downloadTemplate(key: IdentifierKey) {
const example =
key === "uprn"
? ["100012345678", "100012345679"]
: ["PROP-0001", "PROP-0002"];
const csv = [key, ...example].join("\n") + "\n";
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8;" }));
const a = document.createElement("a");
a.href = url;
a.download = `tag-assignment-template-${key}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Bulk tag assignment upload (ADR-0013): reads a small CSV/Excel of identifiers
* client-side, parses it with the shared pure parser, lets the user pick a
* target Tag, then POSTs {mode:"identifiers"} and renders the match summary.
* No FastAPI pipeline the file matches existing identifiers to Properties.
*/
export function BulkTagUploadModal({
portfolioId,
open,
onOpenChange,
}: {
portfolioId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { data: tags = [] } = usePortfolioTags(portfolioId);
const invalidate = useInvalidateTagSurfaces(portfolioId);
const [fileName, setFileName] = useState<string | null>(null);
const [parsed, setParsed] = useState<Parsed | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
const [targetTag, setTargetTag] = useState<Tag | null>(null);
const [summary, setSummary] = useState<AssignSummary | null>(null);
function reset() {
setFileName(null);
setParsed(null);
setParseError(null);
setTargetTag(null);
setSummary(null);
assign.reset();
}
async function onFile(file: File) {
setParseError(null);
setParsed(null);
setSummary(null);
setFileName(file.name);
try {
const buf = await file.arrayBuffer();
const wb = XLSX.read(buf, { type: "array" });
const sheet = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }) as (
| string
| number
| null
)[][];
const result = parseTagIdentifierRows(rows);
if (!result.ok) {
setParseError(result.error);
return;
}
setParsed({ key: result.key, identifiers: result.identifiers });
} catch {
setParseError("Couldn't read that file — is it a valid CSV or Excel?");
}
}
const assign = useMutation<AssignSummary, Error, void>({
mutationFn: async () => {
if (!parsed || !targetTag) throw new Error("Pick a file and a tag first");
const res = await fetch(
`/api/portfolio/${portfolioId}/tags/${targetTag.id}/assignments`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "identifiers",
key: parsed.key,
identifiers: parsed.identifiers,
}),
},
);
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Couldn't assign the tag");
return body;
},
onSuccess: (data) => {
setSummary(data);
invalidate();
},
});
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) reset();
onOpenChange(o);
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Assign a tag from a file</DialogTitle>
<DialogDescription>
Upload a CSV or Excel file with a{" "}
<span className="font-semibold">landlord_property_id</span> or{" "}
<span className="font-semibold">uprn</span> column. We&apos;ll match
those to properties in this portfolio and apply the tag you pick.
</DialogDescription>
</DialogHeader>
{summary ? (
<div className="space-y-3">
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
<p className="font-semibold">
Tagged {summary.taggedCount.toLocaleString()}{" "}
propert{summary.taggedCount === 1 ? "y" : "ies"}
{targetTag ? (
<>
{" "}
with{" "}
<TagChip name={targetTag.name} colour={targetTag.colour} size="xs" />
</>
) : null}
.
</p>
<ul className="mt-1.5 space-y-0.5 text-[13px] text-emerald-800">
<li>{summary.matchedPropertyCount.toLocaleString()} matched the file</li>
{summary.alreadyTaggedCount > 0 && (
<li>
{summary.alreadyTaggedCount.toLocaleString()} already had the tag
</li>
)}
{summary.unmatchedCount > 0 && (
<li className="text-amber-700">
{summary.unmatchedCount.toLocaleString()} identifier
{summary.unmatchedCount === 1 ? "" : "s"} matched no property
</li>
)}
</ul>
</div>
{summary.unmatchedIdentifiers.length > 0 && (
<details className="text-xs text-slate-500">
<summary className="cursor-pointer font-semibold">
Show unmatched identifiers
</summary>
<p className="mt-1 max-h-32 overflow-y-auto break-words rounded bg-slate-50 p-2 font-mono text-[11px]">
{summary.unmatchedIdentifiers.join(", ")}
{summary.unmatchedCount > summary.unmatchedIdentifiers.length &&
" …"}
</p>
</details>
)}
</div>
) : (
<div className="space-y-4">
<label className="flex cursor-pointer flex-col items-center gap-1 rounded-xl border-2 border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-center transition hover:border-slate-300">
<span className="text-sm font-semibold text-slate-600">
{fileName ?? "Choose a CSV or Excel file"}
</span>
<span className="text-xs text-slate-400">
First row must be a header
</span>
<input
type="file"
accept=".csv,.xlsx,.xls"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onFile(f);
}}
/>
</label>
<p className="text-xs text-slate-400">
Need a starting point? Download a template for{" "}
<button
type="button"
onClick={() => downloadTemplate("landlord_property_id")}
className="font-semibold text-brandmidblue hover:underline"
>
Property Ref
</button>{" "}
or{" "}
<button
type="button"
onClick={() => downloadTemplate("uprn")}
className="font-semibold text-brandmidblue hover:underline"
>
UPRN
</button>
.
</p>
{parseError && (
<p className="text-sm text-red-600">{parseError}</p>
)}
{parsed && (
<div className="space-y-3">
<p className="text-sm text-slate-600">
Found{" "}
<span className="font-semibold text-slate-800">
{parsed.identifiers.length.toLocaleString()}
</span>{" "}
identifiers, matching on{" "}
<span className="font-semibold text-slate-800">
{KEY_LABEL[parsed.key]}
</span>
.
</p>
<div>
<p className="mb-1.5 text-xs font-semibold text-slate-500">
Apply which tag?
</p>
{tags.length === 0 ? (
<p className="text-xs text-slate-400">
No tags yet {" "}
<a
href={`/portfolio/${portfolioId}/settings/tags`}
className="font-semibold text-brandmidblue hover:underline"
>
create one first
</a>
.
</p>
) : (
<div className="flex flex-wrap gap-1.5">
{tags.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setTargetTag(t)}
className={`rounded-full transition ${
targetTag?.id === t.id
? "ring-2 ring-brandmidblue ring-offset-1"
: "opacity-70 hover:opacity-100"
}`}
>
<TagChip name={t.name} colour={t.colour} />
</button>
))}
</div>
)}
</div>
</div>
)}
{assign.isError && (
<p className="text-sm text-red-600">{assign.error.message}</p>
)}
</div>
)}
<DialogFooter>
{summary ? (
<>
<Button variant="ghost" onClick={reset}>
Assign another
</Button>
<Button onClick={() => onOpenChange(false)}>Done</Button>
</>
) : (
<>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
disabled={!parsed || !targetTag || assign.isLoading}
onClick={() => assign.mutate()}
>
{assign.isLoading ? "Assigning…" : "Assign tag"}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,242 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
MagnifyingGlassIcon,
CheckCircleIcon,
} from "@heroicons/react/24/outline";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import { describeCandidate } from "@/lib/postcodeSearch/model";
import {
searchAddresses,
useAssignUprn,
type SearchCandidate,
} from "@/lib/properties/client";
import type { UnmatchedProperty } from "@/lib/properties/unmatched";
// Row action for an unmatched property: edit the address, confirm the postcode,
// search Ordnance Survey, and pick from the RESIDENTIAL addresses in that
// postcode. Picking assigns that candidate's UPRN — every candidate is a real
// OS address, so this guarantees a valid, residential UPRN. On success the
// property leaves the "Needs attention" tab (the page re-fetches).
export default function MatchAddress({
property,
portfolioId,
}: {
property: UnmatchedProperty;
portfolioId: string;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [address, setAddress] = useState(property.address ?? "");
const [postcode, setPostcode] = useState(property.postcode ?? "");
// null = not searched yet; [] = searched, no residential matches.
const [residential, setResidential] = useState<SearchCandidate[] | null>(null);
const [selected, setSelected] = useState<SearchCandidate | null>(null);
const [source, setSource] = useState<"cache" | "live" | null>(null);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const assign = useAssignUprn(portfolioId, property.id);
async function runSearch() {
setSearching(true);
setSearchError(null);
setResidential(null);
setSelected(null);
try {
const result = await searchAddresses(portfolioId, postcode);
// Residential only — we never assign a non-residential UPRN.
setResidential(result.candidates.filter((c) => c.selectable));
setSource(result.source);
} catch (e) {
setSearchError(e instanceof Error ? e.message : "Search failed.");
} finally {
setSearching(false);
}
}
function onAssign() {
if (!selected) return;
assign.mutate(
{
uprn: selected.uprn,
address: selected.address,
postcode: selected.postcode,
userInputtedAddress: address.trim() || undefined,
userInputtedPostcode: postcode.trim() || undefined,
},
{
onSuccess: () => {
setOpen(false);
router.refresh();
},
},
);
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-xs font-semibold text-amber-800 transition-colors hover:bg-amber-100"
>
<MagnifyingGlassIcon className="h-3.5 w-3.5" />
Find UPRN
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Match to a UPRN</DialogTitle>
<DialogDescription>
Confirm the postcode and pick the matching address. Only
residential addresses from Ordnance Survey are shown, so the UPRN
is always valid.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-semibold text-gray-600">
Address
</label>
<input
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="e.g. 20 Brenchley Road"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
</div>
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="mb-1 block text-xs font-semibold text-gray-600">
Postcode
</label>
<input
value={postcode}
onChange={(e) => setPostcode(e.target.value)}
placeholder="e.g. BR5 2TD"
onKeyDown={(e) => {
if (e.key === "Enter" && postcode.trim() && !searching)
runSearch();
}}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
</div>
<button
type="button"
onClick={runSearch}
disabled={!postcode.trim() || searching}
className="inline-flex items-center gap-1.5 rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:bg-amber-300"
>
<MagnifyingGlassIcon className="h-4 w-4" />
{searching ? "Searching…" : "Search"}
</button>
</div>
{searchError && <p className="text-xs text-red-600">{searchError}</p>}
{residential && (
<div>
<div className="mb-1 flex items-center justify-between">
<p className="text-xs font-semibold text-gray-600">
Residential addresses
</p>
{source === "cache" && (
<span className="text-[10px] font-medium uppercase tracking-wide text-gray-400">
from cache
</span>
)}
</div>
<div className="max-h-64 overflow-y-auto rounded-lg border border-gray-100">
{residential.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-gray-400">
No residential addresses found for that postcode.
</p>
) : (
<ul className="divide-y divide-gray-50">
{residential.map((c) => {
const { label } = describeCandidate(c);
const disabled = c.alreadyInPortfolio;
const isSelected = selected?.uprn === c.uprn;
return (
<li key={c.uprn}>
<button
type="button"
disabled={disabled}
onClick={() => setSelected(c)}
className={`flex w-full items-start gap-2 px-3 py-2 text-left text-sm transition-colors ${
disabled
? "cursor-not-allowed text-gray-400"
: isSelected
? "bg-amber-50"
: "hover:bg-gray-50"
}`}
>
<CheckCircleIcon
className={`mt-0.5 h-4 w-4 shrink-0 ${
isSelected ? "text-amber-600" : "text-gray-200"
}`}
/>
<span className="min-w-0 flex-1">
<span
className="block truncate text-gray-800"
title={c.address}
>
{c.address}
</span>
<span className="block truncate text-[11px] text-gray-500">
UPRN {c.uprn} · {label}
{c.alreadyInPortfolio
? " · already in portfolio"
: ""}
</span>
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
)}
</div>
<DialogFooter>
{assign.error && (
<p className="mr-auto self-center text-xs text-red-600">
{assign.error.message}
</p>
)}
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg px-4 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-100"
>
Cancel
</button>
<button
type="button"
onClick={onAssign}
disabled={!selected || assign.isPending}
className="rounded-lg bg-amber-600 px-4 py-2 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:bg-amber-300"
>
{assign.isPending ? "Assigning…" : "Assign UPRN"}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// Two-tab shell for the portfolio landing view: the main properties table, and
// a "Needs attention" tab for properties that aren't matched to a UPRN yet.
// Both panels stay mounted (hidden with CSS) so the table keeps its filter /
// pagination state when the user flicks between tabs.
export default function PortfolioTabs({
needsAttentionCount,
properties,
needsAttention,
}: {
needsAttentionCount: number;
properties: React.ReactNode;
needsAttention: React.ReactNode;
}) {
const [tab, setTab] = useState<"properties" | "needs-attention">("properties");
// Nothing unmatched → no tab strip at all, just the properties table.
if (needsAttentionCount === 0) return <>{properties}</>;
return (
<div>
<div className="mb-4 flex items-center gap-1 border-b border-gray-200">
<TabButton
active={tab === "properties"}
onClick={() => setTab("properties")}
>
Properties
</TabButton>
<TabButton
active={tab === "needs-attention"}
onClick={() => setTab("needs-attention")}
>
<ExclamationTriangleIcon className="h-4 w-4" />
Needs attention
{needsAttentionCount > 0 && (
<span className="inline-flex min-w-[1.25rem] items-center justify-center rounded-full bg-amber-100 px-1.5 text-[11px] font-bold text-amber-800">
{needsAttentionCount}
</span>
)}
</TabButton>
</div>
<div className={tab === "properties" ? "" : "hidden"}>{properties}</div>
<div className={tab === "needs-attention" ? "" : "hidden"}>
{needsAttention}
</div>
</div>
);
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`-mb-px flex items-center gap-1.5 border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors ${
active
? "border-primary text-primary"
: "border-transparent text-gray-500 hover:text-gray-800"
}`}
>
{children}
</button>
);
}

View file

@ -3,6 +3,8 @@
import React, { useState, useRef, useEffect } from "react";
import { X, Plus, ChevronDown, Check } from "lucide-react";
import { getEpcColorClass } from "@/app/utils";
import { tagDotColour } from "@/lib/tags/colour";
import { Tag, usePortfolioTags } from "./useTags";
import {
FilterGroups,
FilterGroup,
@ -10,13 +12,14 @@ import {
FilterField,
FilterOperator,
DatePreset,
EnumOption,
PROPERTY_TYPE_OPTIONS,
BUILT_FORM_OPTIONS,
TENURE_OPTIONS,
YEAR_BUILT_OPTIONS,
MAINFUEL_OPTIONS,
PROVENANCE_OPTIONS,
WALL_TYPE_OPTIONS,
ROOF_TYPE_OPTIONS,
HEATING_SYSTEM_OPTIONS,
} from "@/app/utils/propertyFilters";
/* -----------------------------------------------------------------------
@ -32,14 +35,20 @@ const FIELD_OPTIONS: { value: FilterField; label: string }[] = [
{ value: "epcExpiryDate", label: "EPC Expiry Date" },
{ value: "propertyType", label: "Property Type" },
{ value: "builtForm", label: "Built Form" },
{ value: "tenure", label: "Tenure" },
{ value: "yearBuilt", label: "Year Built" },
{ value: "yearBuilt", label: "Construction Age" },
{ value: "provenance", label: "Provenance" },
{ value: "floorArea", label: "Floor Area (m²)" },
{ value: "co2Emissions", label: "CO₂ Emissions (kg/m²/yr)" },
{ value: "mainfuel", label: "Main Fuel" },
{ value: "wallType", label: "Wall Type" },
{ value: "roofType", label: "Roof Type" },
{ value: "heatingSystem", label: "Heating System" },
{ value: "tags", label: "Tags" },
];
/** The synthetic "no tags" bucket the server SQL recognises (ADR-0013). */
const UNTAGGED_VALUE = "__untagged__";
const EPC_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [
{ value: "epc_less_than", label: "is worse than" },
{ value: "equals", label: "equals" },
@ -71,13 +80,18 @@ const NUM_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [
{ value: "num_equals", label: "= (equals)" },
];
const ENUM_FIELD_OPTIONS: Record<string, EnumOption[]> = {
propertyType: PROPERTY_TYPE_OPTIONS,
builtForm: BUILT_FORM_OPTIONS,
tenure: TENURE_OPTIONS,
yearBuilt: YEAR_BUILT_OPTIONS,
provenance: PROVENANCE_OPTIONS,
mainfuel: MAINFUEL_OPTIONS,
// Options need only a `label` for the dropdown: the exact-enum fields carry
// EnumOption[] (dbValues used server-side), the free-text descriptor fields carry
// DescriptorBucket[] (needles used server-side). Both satisfy `{ label }`.
const ENUM_FIELD_OPTIONS: Record<string, { label: string }[]> = {
propertyType: PROPERTY_TYPE_OPTIONS,
builtForm: BUILT_FORM_OPTIONS,
yearBuilt: YEAR_BUILT_OPTIONS,
provenance: PROVENANCE_OPTIONS,
mainfuel: MAINFUEL_OPTIONS,
wallType: WALL_TYPE_OPTIONS,
roofType: ROOF_TYPE_OPTIONS,
heatingSystem: HEATING_SYSTEM_OPTIONS,
};
/* -----------------------------------------------------------------------
@ -95,10 +109,14 @@ function isNumericField(field: FilterField) {
return field === "floorArea" || field === "co2Emissions";
}
function isTagField(field: FilterField) {
return field === "tags";
}
function operatorsForField(field: FilterField): { value: FilterOperator; label: string }[] {
if (isEpcField(field)) return EPC_OPERATOR_OPTIONS;
if (field === "epcExpiryDate") return DATE_OPERATOR_OPTIONS;
if (isEnumField(field)) return ENUM_OPERATOR_OPTIONS;
if (isEnumField(field) || isTagField(field)) return ENUM_OPERATOR_OPTIONS;
if (isNumericField(field)) return NUM_OPERATOR_OPTIONS;
return [];
}
@ -107,9 +125,22 @@ function defaultOperatorForField(field: FilterField): FilterOperator {
return operatorsForField(field)[0]?.value ?? "equals";
}
function conditionLabel(condition: PropertyFilter): string {
function conditionLabel(condition: PropertyFilter, tags: Tag[] = []): string {
const fieldLabel = FIELD_OPTIONS.find((f) => f.value === condition.field)?.label ?? condition.field;
if (isTagField(condition.field)) {
try {
const values: string[] = JSON.parse(condition.value);
const byId = new Map(tags.map((t) => [t.id, t.name]));
const names = values.map((v) =>
v === UNTAGGED_VALUE ? "Untagged" : (byId.get(v) ?? "(deleted tag)"),
);
return `${fieldLabel} is one of: ${names.join(", ")}`;
} catch {
return `${fieldLabel} is one of: ${condition.value}`;
}
}
if (isEpcField(condition.field)) {
const opLabel = EPC_OPERATOR_OPTIONS.find((o) => o.value === condition.operator)?.label ?? condition.operator;
const value =
@ -217,7 +248,7 @@ function EpcDropdown({
ref={buttonRef}
type="button"
onClick={openDropdown}
className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-black/10 transition"
className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-brandblue/20 transition"
>
<span className="flex items-center gap-1 flex-wrap min-h-[1.25rem]">
{selected.length === 0 ? (
@ -256,7 +287,7 @@ function EpcDropdown({
{multi ? (
<span
className={`ml-auto w-4 h-4 rounded border flex items-center justify-center shrink-0 transition ${
isSelected ? "bg-black border-black" : "border-gray-300"
isSelected ? "bg-brandmidblue border-brandmidblue" : "border-gray-300"
}`}
>
{isSelected && <Check className="h-2.5 w-2.5 text-white" />}
@ -281,7 +312,7 @@ function EnumMultiDropdown({
selectedLabels,
onChange,
}: {
options: EnumOption[];
options: { label: string }[];
selectedLabels: string[];
onChange: (labels: string[]) => void;
}) {
@ -341,7 +372,7 @@ function EnumMultiDropdown({
ref={buttonRef}
type="button"
onClick={openDropdown}
className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-black/10 transition"
className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-brandblue/20 transition"
>
<span className="flex items-center gap-1 flex-wrap min-h-[1.25rem] text-left">
{selectedLabels.length === 0 ? (
@ -367,7 +398,134 @@ function EnumMultiDropdown({
<span className="flex-1 text-gray-700 leading-tight">{opt.label}</span>
<span
className={`ml-auto w-4 h-4 rounded border flex items-center justify-center shrink-0 transition ${
isSelected ? "bg-black border-black" : "border-gray-300"
isSelected ? "bg-brandmidblue border-brandmidblue" : "border-gray-300"
}`}
>
{isSelected && <Check className="h-2.5 w-2.5 text-white" />}
</span>
</button>
);
})}
</div>
)}
</div>
);
}
/* -----------------------------------------------------------------------
Tag Multi-Select Dropdown (dynamic per-portfolio, id-valued + Untagged)
------------------------------------------------------------------------ */
function TagMultiDropdown({
tags,
selected,
onChange,
}: {
tags: Tag[];
selected: string[]; // tag ids and/or UNTAGGED_VALUE
onChange: (values: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
function handleScroll(e: Event) {
if (dropdownRef.current && dropdownRef.current.contains(e.target as Node)) return;
setOpen(false);
}
if (open) {
document.addEventListener("mousedown", handleOutside);
window.addEventListener("scroll", handleScroll, true);
}
return () => {
document.removeEventListener("mousedown", handleOutside);
window.removeEventListener("scroll", handleScroll, true);
};
}, [open]);
function openDropdown() {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setDropdownStyle({
position: "fixed",
top: rect.bottom + 4,
left: rect.left,
width: Math.max(rect.width, 220),
zIndex: 9999,
maxHeight: 280,
overflowY: "auto",
});
}
setOpen((o) => !o);
}
function toggle(value: string) {
onChange(
selected.includes(value)
? selected.filter((v) => v !== value)
: [...selected, value],
);
}
const byId = new Map(tags.map((t) => [t.id, t]));
const summary = selected
.map((v) => (v === UNTAGGED_VALUE ? "Untagged" : byId.get(v)?.name))
.filter(Boolean)
.join(", ");
const options: { value: string; label: string; colour?: string }[] = [
...tags.map((t) => ({ value: t.id, label: t.name, colour: t.colour })),
{ value: UNTAGGED_VALUE, label: "Untagged" },
];
return (
<div className="relative" ref={ref}>
<button
ref={buttonRef}
type="button"
onClick={openDropdown}
className="w-full flex items-center justify-between rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-brandblue/20 transition"
>
<span className="flex items-center gap-1 flex-wrap min-h-[1.25rem] text-left">
{selected.length === 0 ? (
<span className="text-gray-400">Select tags</span>
) : (
<span className="text-gray-700 truncate">{summary}</span>
)}
</span>
<ChevronDown className={`h-4 w-4 text-gray-400 shrink-0 ml-1 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div ref={dropdownRef} style={dropdownStyle} className="rounded-md border border-gray-200 bg-white shadow-lg py-1">
{tags.length === 0 && (
<p className="px-3 py-1.5 text-xs text-gray-400">
No tags yet filter by Untagged, or create tags in settings.
</p>
)}
{options.map((opt) => {
const isSelected = selected.includes(opt.value);
const untagged = opt.value === UNTAGGED_VALUE;
return (
<button
key={opt.value}
type="button"
onClick={() => toggle(opt.value)}
className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-sm transition hover:bg-gray-50 text-left ${isSelected ? "bg-gray-50" : ""} ${untagged ? "mt-1 border-t border-dashed border-gray-200 pt-2" : ""}`}
>
<span
className={`h-3 w-3 shrink-0 rounded-full border ${untagged ? "border-dashed border-gray-400" : ""}`}
style={untagged ? undefined : { backgroundColor: tagDotColour(opt.colour!) }}
/>
<span className="flex-1 text-gray-700 leading-tight truncate">{opt.label}</span>
<span
className={`ml-auto w-4 h-4 rounded border flex items-center justify-center shrink-0 transition ${
isSelected ? "bg-brandmidblue border-brandmidblue" : "border-gray-300"
}`}
>
{isSelected && <Check className="h-2.5 w-2.5 text-white" />}
@ -394,7 +552,7 @@ function NumberFilterInput({
return (
<input
type="number"
className="w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-black/10"
className="w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brandblue/20"
placeholder="Enter value…"
value={value}
onChange={(e) => onChange(e.target.value)}
@ -407,17 +565,19 @@ function NumberFilterInput({
------------------------------------------------------------------------ */
interface AddFilterFormProps {
targetGroupId: string | null; // null = new group
tags: Tag[];
onConfirm: (groupId: string | null, condition: PropertyFilter) => void;
onCancel: () => void;
}
function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProps) {
function AddFilterForm({ targetGroupId, tags, onConfirm, onCancel }: AddFilterFormProps) {
const [field, setField] = useState<FilterField>("currentEpc");
const [operator, setOperator] = useState<FilterOperator>("epc_less_than");
const [epcSelected, setEpcSelected] = useState<string[]>([]);
const [dateValue, setDateValue] = useState("");
const [preset, setPreset] = useState<DatePreset>("expired");
const [enumSelected, setEnumSelected] = useState<string[]>([]);
const [tagSelected, setTagSelected] = useState<string[]>([]);
const [numValue, setNumValue] = useState("");
function handleFieldChange(newField: FilterField) {
@ -426,6 +586,7 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp
setEpcSelected([]);
setDateValue("");
setEnumSelected([]);
setTagSelected([]);
setNumValue("");
}
@ -436,6 +597,10 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp
if (field === "epcExpiryDate") {
return operator === "date_preset" ? preset : dateValue;
}
if (isTagField(field)) {
// Value = JSON of tag ids (+ optional Untagged bucket); server resolves it.
return tagSelected.length > 0 ? JSON.stringify(tagSelected) : "";
}
if (isEnumField(field)) {
return enumSelected.length > 0 ? JSON.stringify(enumSelected) : "";
}
@ -462,7 +627,7 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp
}
const selectClass =
"w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-black/10";
"w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brandblue/20";
const enumOptions = isEnumField(field) ? ENUM_FIELD_OPTIONS[field] : [];
@ -523,7 +688,7 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp
{field === "epcExpiryDate" && operator !== "date_preset" && (
<input
type="date"
className="w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-black/10"
className="w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brandblue/20"
value={dateValue}
onChange={(e) => setDateValue(e.target.value)}
/>
@ -537,6 +702,14 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp
/>
)}
{isTagField(field) && (
<TagMultiDropdown
tags={tags}
selected={tagSelected}
onChange={setTagSelected}
/>
)}
{isNumericField(field) && (
<NumberFilterInput value={numValue} onChange={setNumValue} />
)}
@ -548,7 +721,7 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp
type="button"
onClick={handleConfirm}
disabled={!canConfirm()}
className="flex-1 h-8 rounded-md bg-black text-xs font-medium text-white hover:bg-black/90 transition disabled:opacity-40 disabled:cursor-not-allowed"
className="flex-1 h-8 rounded-md bg-primary text-xs font-medium text-white hover:opacity-90 transition disabled:opacity-40 disabled:cursor-not-allowed"
>
Add
</button>
@ -569,15 +742,17 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp
------------------------------------------------------------------------ */
function ConditionRow({
condition,
tags,
onRemove,
}: {
condition: PropertyFilter;
tags: Tag[];
onRemove: () => void;
}) {
return (
<div className="flex items-start gap-1 group">
<div className="flex-1 text-xs text-gray-700 bg-white border border-gray-200 rounded px-2 py-1.5 leading-tight break-words min-w-0">
{conditionLabel(condition)}
{conditionLabel(condition, tags)}
</div>
<button
type="button"
@ -595,12 +770,15 @@ function ConditionRow({
Main Component
------------------------------------------------------------------------ */
export default function PropertyFilters({
portfolioId,
filterGroups,
onChange,
}: {
portfolioId: string;
filterGroups: FilterGroups;
onChange: (groups: FilterGroups) => void;
}) {
const { data: tags = [] } = usePortfolioTags(portfolioId);
// Draft state — only applied when user clicks Apply
const [draft, setDraft] = useState<FilterGroups>(filterGroups);
// "or" = new OR group, "and:<groupId>" = AND condition into existing group, null = hidden
@ -692,6 +870,7 @@ export default function PropertyFilters({
)}
<ConditionRow
condition={condition}
tags={tags}
onRemove={() => removeCondition(group.id, condition.id)}
/>
</div>
@ -701,6 +880,7 @@ export default function PropertyFilters({
{isAndTarget ? (
<AddFilterForm
targetGroupId={group.id}
tags={tags}
onConfirm={handleConfirm}
onCancel={() => setAddMode(null)}
/>
@ -723,6 +903,7 @@ export default function PropertyFilters({
{addMode === "or" ? (
<AddFilterForm
targetGroupId={null}
tags={tags}
onConfirm={handleConfirm}
onCancel={() => setAddMode(null)}
/>
@ -758,7 +939,7 @@ export default function PropertyFilters({
<button
type="button"
onClick={apply}
className="flex-1 h-9 rounded-md bg-black text-sm font-medium text-white hover:bg-black/90 transition"
className="flex-1 h-9 rounded-md bg-primary text-sm font-medium text-white hover:opacity-90 transition"
>
Apply
</button>

View file

@ -18,14 +18,14 @@ import {
} from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";
import { HomeIcon } from "@heroicons/react/24/outline";
import BulkUploadComingSoonModal from "@/app/components/portfolio/BulkUploadComingSoonModal";
import { sapToEpc } from "@/app/utils";
import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns";
import { PropertyWithRelations } from "@/app/db/schema/property";
import {
TENURE_OPTIONS,
MAINFUEL_OPTIONS,
EnumOption,
} from "@/app/utils/propertyFilters";
columns,
columnsWithoutSelect,
} from "@/app/portfolio/[slug]/components/propertyTableColumns";
import { PropertyWithRelations } from "@/app/db/schema/property";
import { MAINFUEL_OPTIONS, EnumOption } from "@/app/utils/propertyFilters";
import {
OPTIONAL_COLUMN_IDS,
OPTIONAL_COLUMN_LABELS,
@ -34,8 +34,12 @@ import {
VisibilityState,
Updater,
PaginationState,
RowSelectionState,
} from "@tanstack/react-table";
import { Tooltip } from "./Tooltip";
import { TagsMenu } from "./TagsMenu";
import { TagActionBar, TagMode } from "./TagActionBar";
import { BulkTagUploadModal } from "./BulkTagUploadModal";
import {
DropdownMenu,
@ -46,20 +50,19 @@ import {
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import { Button } from "@/app/shadcn_components/ui/button";
/* ----------------------------------------
Export helpers
----------------------------------------- */
const EXPORT_LIMIT = 1000;
const EXPORT_LIMIT = 6000;
// Exactly one navy CTA is primary at a time (impeccable distill): Add properties
// leads an empty portfolio, Run modelling leads a populated one; the other demotes
// to the neutral toolbar-button style so the toolbar has a single focal action.
const PRIMARY_CTA =
"flex items-center gap-1.5 h-8 px-3 rounded-lg bg-primary text-white text-xs font-semibold hover:opacity-90 transition";
const SECONDARY_CTA =
"flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-white text-primary text-xs font-semibold hover:bg-slate-50 transition";
function resolveEnumLabel(
options: EnumOption[],
@ -83,11 +86,13 @@ function exportToCsv(data: PropertyWithRelations[]) {
"Plan Cost (£)",
"Property Type",
"Built Form",
"Tenure",
"Year Built",
"Construction Age",
"Floor Area (m²)",
"CO₂ Emissions (kg/m²/yr)",
"Main Fuel",
"Wall Type",
"Roof Type",
"Heating System",
];
const rows = data.map((p) => {
@ -117,11 +122,13 @@ function exportToCsv(data: PropertyWithRelations[]) {
p.totalRecommendationCost ? p.totalRecommendationCost.toFixed(2) : "",
p.propertyType ?? "",
p.builtForm ?? "",
resolveEnumLabel(TENURE_OPTIONS, p.tenure),
p.yearBuilt ?? "",
p.totalFloorArea != null ? p.totalFloorArea.toFixed(1) : "",
p.co2Emissions != null ? p.co2Emissions.toFixed(1) : "",
resolveEnumLabel(MAINFUEL_OPTIONS, p.mainfuel),
p.wallType ?? "",
p.roofType ?? "",
p.heatingSystem ?? "",
];
});
@ -232,43 +239,44 @@ function QuickFilterDropdown({
return (
<div ref={containerRef} className="relative">
<button
onClick={onOpen}
{/* Pill is a container, not a button the open trigger and the clear
control are sibling buttons so no interactive element is nested. */}
<div
className={[
"flex items-center gap-1.5 h-8 px-3 rounded-lg border text-xs font-semibold transition shrink-0",
"flex items-center h-8 px-3 rounded-lg border text-xs font-semibold transition shrink-0",
isActive
? "border-brandblue bg-brandblue text-white"
: "border-slate-200 bg-white text-primary hover:bg-slate-50",
].join(" ")}
>
<span>{label}</span>
{isActive ? (
<>
<button
type="button"
onClick={onOpen}
className="flex min-w-0 items-center gap-1.5 text-left"
>
<span>{label}</span>
{isActive ? (
<span className="opacity-75 max-w-[120px] truncate">
: {committedValue}
</span>
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onClear();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.stopPropagation();
onClear();
}
}}
className="ml-0.5 rounded-full hover:opacity-75"
>
<XMarkIcon className="h-3.5 w-3.5" />
</span>
</>
) : (
<ChevronDownIcon className="h-3.5 w-3.5 opacity-50" />
) : (
<ChevronDownIcon className="h-3.5 w-3.5 opacity-50" />
)}
</button>
{isActive && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClear();
}}
aria-label={`Clear ${label} filter`}
className="ml-1 rounded-full hover:opacity-75"
>
<XMarkIcon className="h-3.5 w-3.5" />
</button>
)}
</button>
</div>
{isOpen && (
<div className="absolute left-0 top-full mt-1 z-30 bg-white border border-slate-200 rounded-lg shadow-md p-2 flex gap-1.5 items-center">
@ -308,6 +316,7 @@ export default function PropertyTable({
}) {
const router = useRouter();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [bulkImportOpen, setBulkImportOpen] = useState(false);
const [committedAddress, setCommittedAddress] = useState("");
const [committedPostcode, setCommittedPostcode] = useState("");
@ -315,12 +324,37 @@ export default function PropertyTable({
const [openFilter, setOpenFilter] = useState<QuickFilterKey | null>(null);
const [filterGroups, setFilterGroups] = useState<FilterGroups>([]);
// Row selection for bulk tagging — keyed by property id (see DataTable getRowId).
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const selectedIds = Object.keys(rowSelection).filter((id) => rowSelection[id]);
// Deliberate "select all matching" escalation (whole set, not just loaded rows).
const [selectAllMatching, setSelectAllMatching] = useState(false);
const clearSelection = () => {
setRowSelection({});
setSelectAllMatching(false);
};
// Any manual checkbox change drops the all-matching escalation back to the
// explicit (possibly now-smaller) row selection.
const handleRowSelectionChange = (
updater: Updater<RowSelectionState>,
) => {
setSelectAllMatching(false);
setRowSelection(updater);
};
// Bulk-tag flows launched from the Tags menu: a contextual assign/remove mode,
// and the file-upload modal.
const [tagMode, setTagMode] = useState<TagMode | null>(null);
const [tagUploadOpen, setTagUploadOpen] = useState(false);
// Column visibility — lifted up from DataTable
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
() => {
const init: VisibilityState = {};
OPTIONAL_COLUMN_IDS.forEach((id) => {
init[id] = false;
// Tags ship visible by default (the headline of ADR-0013); the rest hidden.
init[id] = id === "tags";
});
return init;
},
@ -406,8 +440,9 @@ export default function PropertyTable({
filterGroups: [],
});
const totalCount = allResponse?.total ?? 0;
const [modelNudgeDismissed, setModelNudgeDismissed] = useState(false);
const DISPLAY_LIMIT = 1000;
const DISPLAY_LIMIT = 250;
const LOAD_MORE_SIZE = 100;
// ── Extra rows (lazy-loaded pages beyond the initial 1 000) ──────────────
@ -432,6 +467,28 @@ export default function PropertyTable({
[queryData, extraRows],
);
// Properties with no default plan yet — the "not modelled" set. Counted from the
// loaded rows (cheap; avoids a hook per project convention): exact for a
// fully-loaded portfolio, a floor when more rows lazy-load beyond view.
const unmodelledInView = tableData.filter((p) => p.planId == null).length;
// Bulk-select scope: how many properties match the current view, whether every
// loaded row is ticked, and whether more match than are loaded (so "select all
// matching" is worth offering). matchingCount is filtered-total when a filter
// is active, else the whole portfolio.
const matchingCount = hasActiveFilters ? filteredTotal : totalCount;
const allLoadedSelected =
selectedIds.length > 0 && selectedIds.length === tableData.length;
const moreMatchThanLoaded = tableData.length < matchingCount;
// The not-modelled banner owns the Run-modelling CTA in this state, so the
// toolbar must NOT also show one — otherwise two identical navy buttons appear.
const showModelNudge =
!isLoading &&
totalCount > 0 &&
unmodelledInView > 0 &&
!modelNudgeDismissed;
// Controlled pagination (lifted from DataTable so we can detect the last page)
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
@ -444,6 +501,8 @@ export default function PropertyTable({
prevFilterKeyRef.current = filterKey;
if (pagination.pageIndex !== 0)
setPagination((p) => ({ ...p, pageIndex: 0 }));
// The selection is filter-scoped — a changed filter invalidates it.
if (selectedIds.length > 0 || selectAllMatching) clearSelection();
}
const [isFetchingMore, setIsFetchingMore] = useState(false);
@ -498,15 +557,35 @@ export default function PropertyTable({
if (isOnLastPage && hasMore) loadMore();
}, [isOnLastPage, hasMore, loadMore]);
/* ----------------------------------------
Delete preview state
----------------------------------------- */
const [deletePropertyId, setDeletePropertyId] = useState<number | null>(null);
const [deletePreview, setDeletePreview] = useState<
{ table: string; count: number }[] | null
>(null);
const [previewLoading] = useState(false);
const [previewError] = useState<string | null>(null);
// Export fetches its own batch (up to EXPORT_LIMIT) for the current filters,
// independent of the on-screen page — the display fetch is deliberately small.
const [isExporting, setIsExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const handleExport = useCallback(async () => {
if (isExporting) return;
setIsExporting(true);
setExportError(null);
try {
const res = await fetch("/api/properties", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
portfolioId,
filters: allFilterGroups,
limit: EXPORT_LIMIT,
offset: 0,
}),
});
if (!res.ok) throw new Error();
const json: { data: PropertyWithRelations[] } = await res.json();
exportToCsv(json.data);
} catch {
// Don't fail silently — a data-trust product must say when an export dies.
setExportError("Export failed — please try again.");
} finally {
setIsExporting(false);
}
}, [isExporting, portfolioId, allFilterGroups]);
return (
<div className="py-4 mx-4">
@ -532,6 +611,31 @@ export default function PropertyTable({
properties
</span>
)}
{(selectedIds.length > 0 || selectAllMatching) && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-brandmidblue/30 bg-brandlightblue/50 px-2 py-0.5 text-xs font-semibold text-brandblue">
{selectAllMatching
? `All ${matchingCount.toLocaleString()} selected`
: `${selectedIds.length.toLocaleString()} selected`}
<button
onClick={clearSelection}
className="text-brandmidblue hover:text-brandblue"
>
Clear
</button>
</span>
)}
{exportError && (
<span className="inline-flex items-center gap-1.5 rounded-md border border-red-200 bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">
{exportError}
<button
onClick={() => setExportError(null)}
aria-label="Dismiss"
className="text-red-400 hover:text-red-700"
>
<XMarkIcon className="h-3 w-3" />
</button>
</span>
)}
{hasActiveFilters && (
<button
onClick={clearAll}
@ -590,7 +694,7 @@ export default function PropertyTable({
type="checkbox"
checked={isVisible}
readOnly
className="h-3.5 w-3.5 accent-black"
className="h-3.5 w-3.5 accent-brandmidblue"
/>
<span className="text-xs">
{OPTIONAL_COLUMN_LABELS[colId]}
@ -619,19 +723,32 @@ export default function PropertyTable({
</Tooltip>
) : (
<button
onClick={() => exportToCsv(tableData)}
disabled={isLoading || tableData.length === 0}
onClick={handleExport}
disabled={isLoading || isExporting || filteredTotal === 0}
className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-slate-100 text-xs font-semibold text-primary hover:bg-slate-200 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
Export
{isExporting ? "Exporting…" : "Export"}
</button>
)}
{/* Add properties */}
{/* Tags — bulk assign / remove / upload (ADR-0013) */}
<TagsMenu
onUpload={() => setTagUploadOpen(true)}
onAssign={() => {
clearSelection();
setTagMode("assign");
}}
onRemove={() => {
clearSelection();
setTagMode("remove");
}}
/>
{/* Add properties — primary CTA only while the portfolio is empty */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1.5 h-8 px-3 rounded-lg bg-primary text-white text-xs font-semibold hover:opacity-90 transition">
<button className={totalCount > 0 ? SECONDARY_CTA : PRIMARY_CTA}>
<PlusIcon className="h-3.5 w-3.5" />
Add properties
<ChevronDownIcon className="h-3.5 w-3.5 opacity-70" />
@ -655,33 +772,42 @@ export default function PropertyTable({
</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled
className="flex items-start gap-3 rounded-lg p-3"
className="flex items-start gap-3 cursor-pointer rounded-lg p-3"
onSelect={() => setBulkImportOpen(true)}
>
<DocumentArrowUpIcon className="h-[18px] w-[18px] mt-0.5 text-gray-400 shrink-0" />
<DocumentArrowUpIcon className="h-[18px] w-[18px] mt-0.5 text-gray-700 shrink-0" />
<span className="flex-1">
<span className="block text-sm font-semibold text-gray-500">
<span className="block text-sm font-semibold text-gray-900">
Bulk excel import
</span>
<span className="block text-xs text-slate-400">
Upload a spreadsheet of addresses
</span>
</span>
<span className="text-[10px] font-bold uppercase tracking-wide bg-amber-100 text-amber-800 rounded-full px-2 py-0.5 shrink-0">
Soon
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Run modelling */}
<button
onClick={() => router.push(`/portfolio/${portfolioId}/modelling/run`)}
className="flex items-center gap-1.5 h-8 px-3 rounded-lg bg-primary text-white text-xs font-semibold hover:opacity-90 transition"
>
<PlayIcon className="h-3.5 w-3.5" />
Run modelling
</button>
<BulkUploadComingSoonModal
isOpen={bulkImportOpen}
onClose={() => setBulkImportOpen(false)}
portfolioId={portfolioId}
/>
{/* Run modelling primary CTA once there are properties to model.
Hidden while the not-modelled banner is up, since the banner already
carries this action (no duplicate navy CTA). */}
{!showModelNudge && (
<button
onClick={() =>
router.push(`/portfolio/${portfolioId}/modelling/run`)
}
className={totalCount > 0 ? PRIMARY_CTA : SECONDARY_CTA}
>
<PlayIcon className="h-3.5 w-3.5" />
Run modelling
</button>
)}
</div>
</div>
@ -736,6 +862,74 @@ export default function PropertyTable({
/>
</div>
{/* Contextual bulk assign/remove strip shown only while in that mode
(launched from the Tags menu); its target follows the live selection. */}
{tagMode && (
<TagActionBar
portfolioId={portfolioId}
mode={tagMode}
selectedIds={selectedIds}
selectAllMatching={selectAllMatching}
hasActiveFilters={hasActiveFilters}
filterGroups={allFilterGroups}
matchingCount={matchingCount}
allLoadedSelected={allLoadedSelected}
moreMatchThanLoaded={moreMatchThanLoaded}
onSelectAllMatching={() => setSelectAllMatching(true)}
onClearSelection={clearSelection}
onClose={() => {
setTagMode(null);
clearSelection();
}}
/>
)}
<BulkTagUploadModal
portfolioId={portfolioId}
open={tagUploadOpen}
onOpenChange={setTagUploadOpen}
/>
{/* Not-modelled nudge turns a wall of empty cells into a next step.
Mixed-safe: shown whenever any loaded property lacks a plan, so a
part-modelled portfolio still gets the prompt for the rest. */}
{showModelNudge && (
<div className="mb-3 flex flex-wrap items-center gap-3 rounded-xl border border-brandmidblue/25 bg-brandlightblue/40 px-4 py-3">
<div className="flex min-w-0 flex-1 items-start gap-3">
<PlayIcon className="mt-0.5 h-5 w-5 shrink-0 text-brandmidblue" />
<div className="min-w-0">
<p className="text-sm font-semibold text-brandblue">
{unmodelledInView === totalCount
? "None of these properties have been modelled yet"
: `${unmodelledInView.toLocaleString()} ${unmodelledInView === 1 ? "property hasn't" : "properties haven't"} been modelled yet`}
</p>
<p className="text-xs text-slate-600">
Run modelling to generate Expected EPC ratings and retrofit
costs for them.
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() =>
router.push(`/portfolio/${portfolioId}/modelling/run`)
}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold text-white transition hover:opacity-90"
>
<PlayIcon className="h-3.5 w-3.5" />
Run modelling
</button>
<button
onClick={() => setModelNudgeDismissed(true)}
aria-label="Dismiss"
className="rounded-md p-1.5 text-slate-400 transition hover:text-brandblue"
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
)}
{/* Display-limit notice */}
{isAtDisplayLimit && hasMore && (
<div className="mb-3 flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-500">
@ -770,6 +964,7 @@ export default function PropertyTable({
Curate Selection
</p>
<PropertyFilters
portfolioId={portfolioId}
filterGroups={filterGroups}
onChange={setFilterGroups}
/>
@ -803,8 +998,7 @@ export default function PropertyTable({
) : (
<DataTable
data={tableData}
columns={columns}
onDeleteProperty={(id) => setDeletePropertyId(id)}
columns={tagMode ? columns : columnsWithoutSelect}
columnVisibility={columnVisibility}
onColumnVisibilityChange={
setColumnVisibility as (
@ -815,61 +1009,12 @@ export default function PropertyTable({
onPaginationChange={
setPagination as (updater: Updater<PaginationState>) => void
}
rowSelection={rowSelection}
onRowSelectionChange={handleRowSelectionChange}
/>
)}
</div>
</div>
{/* Delete preview modal */}
<Dialog
open={deletePropertyId !== null}
onOpenChange={(open) => {
if (!open) {
setDeletePropertyId(null);
setDeletePreview(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete property?</DialogTitle>
<DialogDescription>
{previewLoading
? "Calculating what will be deleted…"
: "This action is permanent. Review the impact below."}
</DialogDescription>
</DialogHeader>
{previewError && (
<p className="text-sm text-red-600">{previewError}</p>
)}
{deletePreview && !previewLoading && (
<ul className="space-y-1 text-sm">
{deletePreview.map((row) => (
<li key={row.table} className="flex justify-between">
<span className="capitalize">
{row.table.replace(/_/g, " ")}
</span>
<span className="font-medium">{row.count}</span>
</li>
))}
</ul>
)}
<DialogFooter>
<Button
variant="ghost"
onClick={() => {
setDeletePropertyId(null);
setDeletePreview(null);
}}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,213 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { CheckIcon, ChevronDownIcon, XMarkIcon } from "@heroicons/react/24/outline";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import { FilterGroups } from "@/app/utils/propertyFilters";
import { tagDotColour } from "@/lib/tags/colour";
import { Tag, useInvalidateTagSurfaces, usePortfolioTags } from "./useTags";
export type TagMode = "assign" | "remove";
/**
* The contextual strip shown while assigning or removing a tag in bulk
* (ADR-0013). The target is explicit: the ticked rows, or after ticking every
* loaded row "all N matching", which the user escalates to deliberately and
* which resolves the whole matching set server-side (not just the loaded page).
* With nothing selected there is no target, so a tag can't be applied by
* accident. Picking a tag adds/removes memberships and reports the count.
*/
export function TagActionBar({
portfolioId,
mode,
selectedIds,
selectAllMatching,
hasActiveFilters,
filterGroups,
matchingCount,
allLoadedSelected,
moreMatchThanLoaded,
onSelectAllMatching,
onClearSelection,
onClose,
}: {
portfolioId: string;
mode: TagMode;
selectedIds: string[];
selectAllMatching: boolean;
hasActiveFilters: boolean;
filterGroups: FilterGroups;
matchingCount: number;
allLoadedSelected: boolean;
moreMatchThanLoaded: boolean;
onSelectAllMatching: () => void;
onClearSelection: () => void;
onClose: () => void;
}) {
const { data: tags = [] } = usePortfolioTags(portfolioId);
const invalidate = useInvalidateTagSurfaces(portfolioId);
const [result, setResult] = useState<string | null>(null);
const action = mode === "assign" ? "add" : "remove";
const verb = mode === "assign" ? "Assign" : "Remove";
const prep = mode === "assign" ? "to" : "from";
// The current target — explicit selection only (no implicit "all"). Selecting
// all matching is a deliberate escalation that switches to the filter path.
type Target = {
body:
| { mode: "properties"; propertyIds: string[] }
| { mode: "filter"; filterGroups: FilterGroups };
label: string;
};
const target: Target | null = selectAllMatching
? {
body: {
mode: "filter",
filterGroups: hasActiveFilters ? filterGroups : [],
},
label: hasActiveFilters
? `all ${matchingCount.toLocaleString()} matching your filter`
: `all ${matchingCount.toLocaleString()} properties`,
}
: selectedIds.length > 0
? {
body: { mode: "properties", propertyIds: selectedIds },
label: `${selectedIds.length.toLocaleString()} selected propert${selectedIds.length === 1 ? "y" : "ies"}`,
}
: null;
const apply = useMutation<{ changed: number }, Error, Tag>({
mutationFn: async (tag) => {
if (!target) throw new Error("Select some properties first");
const res = await fetch(
`/api/portfolio/${portfolioId}/tags/${tag.id}/assignments`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...target.body, action }),
},
);
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Couldn't update tags");
return body;
},
onSuccess: (data, tag) => {
setResult(
`${mode === "assign" ? "Added" : "Removed"}${tag.name}${prep} ${data.changed.toLocaleString()} propert${data.changed === 1 ? "y" : "ies"}.`,
);
invalidate();
},
onError: (e) => setResult(e.message),
});
const showEscalate =
!selectAllMatching && allLoadedSelected && moreMatchThanLoaded;
return (
<div className="mb-3 flex flex-wrap items-center gap-2.5 rounded-lg border border-brandmidblue/30 bg-brandlightblue/40 px-3 py-2">
<span className="text-xs font-bold text-brandblue">{verb} a tag</span>
<span className="text-xs text-slate-600">
{target ? (
<>
{prep}{" "}
<span className="font-semibold text-brandblue">{target.label}</span>
</>
) : (
<span className="text-slate-500">
Tick properties to {mode === "assign" ? "tag" : "untag"} them
</span>
)}
</span>
{/* Deliberate escalation to the whole matching set (Gmail pattern)
a clear secondary button (white fill + brand border) so it stands out
on the tinted bar without competing with the navy "Choose a tag". */}
{showEscalate && (
<button
onClick={onSelectAllMatching}
className="inline-flex items-center gap-1 rounded-lg border border-brandmidblue bg-white px-2.5 py-1 text-xs font-semibold text-brandblue shadow-sm transition hover:bg-brandmidblue hover:text-white"
>
<CheckIcon className="h-3.5 w-3.5" />
Select all {matchingCount.toLocaleString()}
{hasActiveFilters ? " matching" : ""}
</button>
)}
{selectAllMatching && (
<button
onClick={onClearSelection}
className="text-xs font-semibold text-slate-600 underline underline-offset-2 hover:text-brandblue"
>
Clear selection
</button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
disabled={apply.isLoading || !target}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
>
{apply.isLoading ? "Applying…" : "Choose a tag"}
<ChevronDownIcon className="h-3.5 w-3.5 opacity-70" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-72 w-56 overflow-y-auto">
<DropdownMenuLabel className="text-xs">
{verb} which tag?
</DropdownMenuLabel>
<DropdownMenuSeparator />
{tags.length === 0 ? (
<div className="px-2 py-2 text-xs text-slate-400">
No tags yet.{" "}
<a
href={`/portfolio/${portfolioId}/settings/tags`}
className="font-semibold text-brandmidblue hover:underline"
>
Create one
</a>
.
</div>
) : (
tags.map((t) => (
<DropdownMenuItem
key={t.id}
className="flex cursor-pointer items-center gap-2"
onSelect={() => apply.mutate(t)}
>
<span
className="h-3 w-3 shrink-0 rounded-full border"
style={{ backgroundColor: tagDotColour(t.colour) }}
/>
<span className="truncate text-xs">{t.name}</span>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
{result && (
<span className="rounded-lg border border-emerald-200 bg-emerald-50 px-2.5 py-1 text-xs text-emerald-800">
{result}
</span>
)}
<button
onClick={onClose}
className="ml-auto inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold text-slate-500 hover:text-brandblue"
>
<XMarkIcon className="h-3.5 w-3.5" />
Done
</button>
</div>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { X } from "lucide-react";
import { tagChipTokens } from "@/lib/tags/colour";
/**
* A Tag chip in the app's one calm, AA-safe chip vocabulary (ADR-0013,
* PRODUCT.md): a soft tint of the Tag's freeform hex, a colour dot, and dark
* hue-preserving ink that stays legible for any colour. Shared across the
* property table, tags filter, bulk bar, upload, settings, and run UI so every
* surface reads as one system.
*/
export function TagChip({
name,
colour,
size = "sm",
onRemove,
title,
}: {
name: string;
colour: string;
size?: "xs" | "sm";
onRemove?: () => void;
title?: string;
}) {
const tokens = tagChipTokens(colour);
const sizing =
size === "xs"
? "gap-1 px-1.5 py-0.5 text-[10px]"
: "gap-1.5 px-2 py-0.5 text-[11px]";
const dot = size === "xs" ? "h-1.5 w-1.5" : "h-2 w-2";
return (
<span
className={`inline-flex max-w-[10rem] items-center rounded-full border font-medium leading-none ${sizing}`}
style={{
backgroundColor: tokens.backgroundColor,
borderColor: tokens.borderColor,
color: tokens.color,
}}
title={title ?? name}
>
<span className={`${dot} shrink-0 rounded-full`} style={{ backgroundColor: tokens.dot }} />
<span className="truncate">{name}</span>
{onRemove && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="-mr-0.5 shrink-0 rounded-full opacity-70 transition hover:opacity-100"
aria-label={`Remove ${name}`}
>
<X className="h-3 w-3" />
</button>
)}
</span>
);
}

View file

@ -0,0 +1,208 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Check, Plus, Tag as TagIcon } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/app/shadcn_components/ui/popover";
import { PropertyWithRelations, PropertyTag } from "@/app/db/schema/property";
import { tagDotColour } from "@/lib/tags/colour";
import { TagChip } from "./TagChip";
import {
Tag,
usePortfolioTags,
portfolioTagsKey,
} from "./useTags";
import type { PropertiesResponse } from "./useProperties";
/** How many chips to show inline before collapsing the rest into "+N". */
const MAX_VISIBLE_CHIPS = 2;
type AssignVars = { tag: Tag; action: "add" | "remove" };
/**
* The property table's Tags cell (ADR-0013): shows the property's Tags as chips
* with a "+N" overflow, and an assign popover that toggles membership against
* POST /assignments {mode:"properties"}. Writes are optimistic across every
* cached property-table query for this portfolio, reconciled on settle.
*/
export function TagsCell({ property }: { property: PropertyWithRelations }) {
const portfolioId = String(property.portfolioId);
const propertyId = String(property.id);
const tags = property.tags ?? [];
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
const { data: allTags = [], isLoading, isError } = usePortfolioTags(portfolioId);
const assignedIds = new Set(tags.map((t) => t.id));
const assign = useMutation<unknown, Error, AssignVars, { snapshot: [readonly unknown[], PropertiesResponse | undefined][] }>({
mutationFn: async ({ tag, action }) => {
const res = await fetch(
`/api/portfolio/${portfolioId}/tags/${tag.id}/assignments`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "properties",
action,
propertyIds: [propertyId],
}),
},
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Couldn't update tags");
}
return res.json();
},
// Optimistically add/remove the chip on every cached property-table page.
onMutate: async ({ tag, action }) => {
await queryClient.cancelQueries({ queryKey: ["properties", portfolioId] });
const snapshot = queryClient.getQueriesData<PropertiesResponse>({
queryKey: ["properties", portfolioId],
});
const chip: PropertyTag = { id: tag.id, name: tag.name, colour: tag.colour };
queryClient.setQueriesData<PropertiesResponse>(
{ queryKey: ["properties", portfolioId] },
(prev) =>
prev
? {
...prev,
data: prev.data.map((row) =>
String(row.id) === propertyId
? { ...row, tags: nextTags(row.tags ?? [], chip, action) }
: row,
),
}
: prev,
);
return { snapshot };
},
onError: (_e, _vars, context) => {
context?.snapshot.forEach(([key, data]) =>
queryClient.setQueryData(key, data),
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["properties", portfolioId] });
queryClient.invalidateQueries({ queryKey: portfolioTagsKey(portfolioId) });
},
});
const visible = tags.slice(0, MAX_VISIBLE_CHIPS);
const overflow = tags.length - visible.length;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className="group flex min-h-[1.75rem] max-w-[13rem] flex-wrap items-center gap-1 rounded-lg px-1 py-0.5 text-left transition hover:bg-slate-50"
title="Edit tags"
>
{tags.length === 0 ? (
<span className="inline-flex items-center gap-1 rounded-full border border-dashed border-slate-300 px-2 py-0.5 text-[10px] font-semibold text-slate-400 group-hover:border-slate-400 group-hover:text-slate-600">
<Plus className="h-3 w-3" />
Tag
</span>
) : (
<>
{visible.map((t) => (
<TagChip key={t.id} name={t.name} colour={t.colour} size="xs" />
))}
{overflow > 0 && (
<span className="inline-flex items-center rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-bold text-slate-500">
+{overflow}
</span>
)}
</>
)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-0">
<div className="border-b border-slate-100 px-3 py-2">
<p className="text-xs font-bold text-slate-700">Assign tags</p>
<p className="truncate text-[11px] text-slate-400">
{property.address ?? "This property"}
</p>
</div>
<div className="max-h-64 overflow-y-auto p-1">
{isLoading ? (
<p className="px-2 py-3 text-xs text-slate-400">Loading tags</p>
) : isError ? (
<p className="px-2 py-3 text-xs text-red-500">Couldn&apos;t load tags.</p>
) : allTags.length === 0 ? (
<div className="px-2 py-3 text-xs text-slate-400">
No tags yet.{" "}
<a
href={`/portfolio/${portfolioId}/settings/tags`}
className="font-semibold text-brandmidblue hover:underline"
>
Create one
</a>
.
</div>
) : (
allTags.map((t) => {
const on = assignedIds.has(t.id);
return (
<button
key={t.id}
type="button"
onClick={() =>
assign.mutate({ tag: t, action: on ? "remove" : "add" })
}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition hover:bg-slate-50"
>
<span
className="h-3 w-3 shrink-0 rounded-full border"
style={{ backgroundColor: tagDotColour(t.colour) }}
/>
<span className="flex-1 truncate text-xs text-slate-700">
{t.name}
</span>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition ${
on
? "border-brandmidblue bg-brandmidblue text-white"
: "border-slate-300"
}`}
>
{on && <Check className="h-2.5 w-2.5" />}
</span>
</button>
);
})
)}
</div>
<div className="flex items-center justify-between border-t border-slate-100 px-3 py-2">
<a
href={`/portfolio/${portfolioId}/settings/tags`}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-500 hover:text-brandmidblue"
>
<TagIcon className="h-3 w-3" />
Manage tags
</a>
{assign.isError && (
<span className="text-[11px] text-red-500">{assign.error.message}</span>
)}
</div>
</PopoverContent>
</Popover>
);
}
/** Add or remove a chip from a property's tag list, keeping it name-ordered. */
function nextTags(
current: PropertyTag[],
chip: PropertyTag,
action: "add" | "remove",
): PropertyTag[] {
if (action === "remove") return current.filter((t) => t.id !== chip.id);
if (current.some((t) => t.id === chip.id)) return current;
return [...current, chip].sort((a, b) => a.name.localeCompare(b.name));
}

View file

@ -0,0 +1,88 @@
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import {
ChevronDownIcon,
DocumentArrowUpIcon,
PlusIcon,
MinusIcon,
TagIcon,
} from "@heroicons/react/24/outline";
/**
* The single Tags entry point in the property-table toolbar (ADR-0013): a
* dropdown offering the three bulk-tag paths. Upload opens the file modal;
* Assign / Remove enter a contextual mode (TagActionBar) that operates on the
* row selection or the active filter. Consolidates what was an isolated button.
*/
export function TagsMenu({
onUpload,
onAssign,
onRemove,
}: {
onUpload: () => void;
onAssign: () => void;
onRemove: () => void;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-white text-xs font-semibold text-primary hover:bg-slate-50 transition">
<TagIcon className="h-3.5 w-3.5" />
Tags
<ChevronDownIcon className="h-3.5 w-3.5 opacity-70" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72 p-1.5">
<Item
icon={<DocumentArrowUpIcon className="h-[18px] w-[18px]" />}
title="Upload tags from file"
desc="Match a spreadsheet of IDs to a tag"
onSelect={onUpload}
/>
<Item
icon={<PlusIcon className="h-[18px] w-[18px]" />}
title="Assign a tag"
desc="Apply a tag to selected rows or your filtered results"
onSelect={onAssign}
/>
<Item
icon={<MinusIcon className="h-[18px] w-[18px]" />}
title="Remove a tag"
desc="Take a tag off selected rows or your filtered results"
onSelect={onRemove}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
function Item({
icon,
title,
desc,
onSelect,
}: {
icon: React.ReactNode;
title: string;
desc: string;
onSelect: () => void;
}) {
return (
<DropdownMenuItem
className="flex items-start gap-3 cursor-pointer rounded-lg p-3"
onSelect={onSelect}
>
<span className="mt-0.5 shrink-0 text-gray-700">{icon}</span>
<span className="flex-1">
<span className="block text-sm font-semibold text-gray-900">{title}</span>
<span className="block text-xs text-slate-400">{desc}</span>
</span>
</DropdownMenuItem>
);
}

View file

@ -0,0 +1,92 @@
import {
ExclamationTriangleIcon,
CheckCircleIcon,
} from "@heroicons/react/24/outline";
import type { UnmatchedProperty } from "@/lib/properties/unmatched";
import MatchAddress from "./MatchAddress";
// The "Needs attention" tab: properties with no UPRN, separated out of the main
// table until they're matched. Each row lets the user correct the address /
// postcode (UPRN matching is a later step).
export default function UnmatchedProperties({
properties,
portfolioId,
}: {
properties: UnmatchedProperty[];
portfolioId: string;
}) {
const count = properties.length;
if (count === 0) {
return (
<div className="rounded-2xl border border-gray-100 bg-white px-6 py-16 text-center shadow-sm">
<CheckCircleIcon className="mx-auto h-9 w-9 text-emerald-500" />
<p className="mt-3 text-sm font-semibold text-gray-700">
All properties are matched
</p>
<p className="mt-1 text-xs text-gray-400">
Every property has a UPRN nothing needs attention.
</p>
</div>
);
}
return (
<section
aria-label="Properties without a UPRN"
className="overflow-hidden rounded-2xl border border-amber-200 bg-amber-50 shadow-sm"
>
<div className="flex items-start gap-3 border-b border-amber-100 px-6 py-4">
<ExclamationTriangleIcon className="mt-0.5 h-5 w-5 shrink-0 text-amber-500" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-sm font-bold text-amber-900">
Properties without a UPRN
</h2>
<span className="inline-flex items-center rounded-full bg-amber-200/70 px-2 py-0.5 text-xs font-semibold text-amber-800">
{count}
</span>
</div>
<p className="mt-0.5 max-w-xl text-xs text-amber-700">
{count === 1 ? "This property" : "These properties"} couldn&apos;t be
matched by AraAddressMatcher automatically. Double check the{" "}
{count === 1 ? "address" : "addresses"} and Ara will try again with
AraAddressMatcherPlus.
</p>
</div>
</div>
<div className="bg-white">
<div className="grid grid-cols-12 gap-3 border-b border-gray-100 bg-gray-50/80 px-6 py-2 text-[11px] font-semibold uppercase tracking-wider text-gray-500">
<div className="col-span-6">Address</div>
<div className="col-span-3">Postcode</div>
<div className="col-span-3 text-right">Action</div>
</div>
<ul className="divide-y divide-gray-50">
{properties.map((p) => (
<li
key={p.id}
className="grid grid-cols-12 items-center gap-3 px-6 py-3"
>
<p
className="col-span-6 truncate text-sm text-gray-800"
title={p.address ?? undefined}
>
{p.address ?? (
<span className="italic text-gray-400">No address</span>
)}
</p>
<p className="col-span-3 text-sm text-gray-600">
{p.postcode ?? <span className="text-gray-400"></span>}
</p>
<div className="col-span-3 flex justify-end">
<MatchAddress property={p} portfolioId={portfolioId} />
</div>
</li>
))}
</ul>
</div>
</section>
);
}

View file

@ -6,6 +6,7 @@ import {
SortingState,
PaginationState,
VisibilityState,
RowSelectionState,
Updater,
flexRender,
getCoreRowModel,
@ -50,6 +51,9 @@ interface DataTableProps<TData> {
// Controlled pagination — when omitted the table manages its own pagination state
pagination?: PaginationState;
onPaginationChange?: (updater: Updater<PaginationState>) => void;
// Controlled row selection — when omitted, selection is disabled/uncontrolled
rowSelection?: RowSelectionState;
onRowSelectionChange?: (updater: Updater<RowSelectionState>) => void;
}
export default function DataTable<TData extends Record<string, any>>({
@ -60,6 +64,8 @@ export default function DataTable<TData extends Record<string, any>>({
onColumnVisibilityChange,
pagination: controlledPagination,
onPaginationChange: controlledOnPaginationChange,
rowSelection,
onRowSelectionChange,
}: DataTableProps<TData>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@ -70,22 +76,35 @@ export default function DataTable<TData extends Record<string, any>>({
const pagination = isControlled ? controlledPagination : internalPagination;
const onPaginationChange = isControlled ? controlledOnPaginationChange! : setInternalPagination;
const selectionEnabled = rowSelection !== undefined;
const table = useReactTable({
data,
columns,
// Only override row identity when selection is in use: the property table
// needs id-keyed rows so a selection survives pagination + lazy-loaded pages.
// Other consumers (e.g. the plan table) keep TanStack's default index-based
// identity untouched. The idless fallback guards a selection-enabled table
// whose rows happen to lack an id.
getRowId: selectionEnabled
? (row, index) => (row.id != null ? String(row.id) : String(index))
: undefined,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
autoResetPageIndex: false,
enableRowSelection: selectionEnabled,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange,
onColumnVisibilityChange,
onRowSelectionChange,
globalFilterFn: fuzzyFilter,
@ -95,6 +114,7 @@ export default function DataTable<TData extends Record<string, any>>({
globalFilter,
pagination,
columnVisibility,
rowSelection: rowSelection ?? {},
},
meta: {

View file

@ -17,12 +17,10 @@ import { cn } from "@/lib/utils";
import { PropertyWithRelations } from "@/app/db/schema/property";
import { expectedEpcRating } from "./expectedEpc";
import { X } from "lucide-react";
import {
EnumOption,
TENURE_OPTIONS,
MAINFUEL_OPTIONS,
} from "@/app/utils/propertyFilters";
import { EnumOption, MAINFUEL_OPTIONS } from "@/app/utils/propertyFilters";
import { CurrentEpcTooltip } from "./CurrentEpcTooltip";
import { TagsCell } from "./TagsCell";
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
/* -----------------------------------------------------------------------
Helpers
@ -36,13 +34,6 @@ function resolveEnumLabel(
return opt?.label ?? dbValue;
}
function tenureBadgeClass(label: string): string {
if (label.toLowerCase().includes("owner")) return "bg-blue-50 text-blue-700";
if (label.toLowerCase().includes("private")) return "bg-violet-50 text-violet-700";
if (label.toLowerCase().includes("social")) return "bg-emerald-50 text-emerald-700";
return "bg-slate-100 text-slate-500";
}
function Pill({
children,
className = "bg-slate-100 text-slate-600",
@ -62,8 +53,29 @@ function Pill({
/* -----------------------------------------------------------------------
EPC letter bubble
------------------------------------------------------------------------ */
/**
* The one treatment for an absent value across the table (ADR-0013 impeccable
* pass): a single quiet, AA-legible em-dash with an accessible label replacing
* the four different empty renderings (blank div, slate-300 "—", italic
* "No cost", "Unknown") the critique flagged.
*/
const EmptyCell = ({ label = "No data" }: { label?: string }) => (
<span className="text-slate-400" title={label} aria-label={label}>
</span>
);
const EpcLetterBubble = ({ letter }: { letter: string }) => {
if (!letter) return <div className="w-5 h-5" />;
if (!letter)
return (
<div
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-slate-200 text-[11px] font-semibold text-slate-400"
title="No rating yet"
aria-label="No rating yet"
>
</div>
);
return (
<div
className={`inline-flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold text-white ${getEpcColorClass(letter)}`}
@ -166,25 +178,31 @@ export function DataTableFilterHeader<TData, TValue>({
Column metadata
------------------------------------------------------------------------ */
export const OPTIONAL_COLUMN_IDS = [
"tags",
"propertyType",
"builtForm",
"tenure",
"yearBuilt",
"totalFloorArea",
"co2Emissions",
"mainfuel",
"wallType",
"roofType",
"heatingSystem",
] as const;
export type OptionalColumnId = (typeof OPTIONAL_COLUMN_IDS)[number];
const OPTIONAL_COLUMN_LABELS: Record<OptionalColumnId, string> = {
tags: "Tags",
propertyType: "Property Type",
builtForm: "Built Form",
tenure: "Tenure",
yearBuilt: "Year Built",
yearBuilt: "Construction Age",
totalFloorArea: "Floor Area (m²)",
co2Emissions: "CO₂ Emissions",
mainfuel: "Main Fuel",
wallType: "Wall Type",
roofType: "Roof Type",
heatingSystem: "Heating System",
};
export { OPTIONAL_COLUMN_LABELS };
@ -193,6 +211,34 @@ export { OPTIONAL_COLUMN_LABELS };
Core columns
------------------------------------------------------------------------ */
const coreColumns: ColumnDef<PropertyWithRelations>[] = [
{
id: "select",
enableSorting: false,
enableHiding: false,
// Selects ALL loaded rows (not just the paginated page), so on a small page
// size it still covers everything in view. When more properties match than
// are loaded, the TagActionBar offers "Select all N matching" on top of this.
header: ({ table }) => (
<Checkbox
aria-label="Select all loaded rows"
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(v) => table.toggleAllRowsSelected(!!v)}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
/>
),
},
{
accessorKey: "address",
enableGlobalFilter: true,
@ -231,10 +277,18 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
header: () => <div className="text-xs font-medium">Property Ref</div>,
cell: ({ row }) => (
<div className="text-gray-700 text-sm">
{row.original.landlordPropertyId ?? "—"}
{row.original.landlordPropertyId ?? <EmptyCell />}
</div>
),
},
{
id: "tags",
// Toggle-able (in OPTIONAL_COLUMN_IDS) but default-visible — see PropertyTable.
// Not sortable/filterable here; the tags filter lives in PropertyFilters.
enableSorting: false,
header: () => <div className="text-xs">Tags</div>,
cell: ({ row }) => <TagsCell property={row.original} />,
},
{
accessorKey: "currentEpc",
header: () => (
@ -303,7 +357,11 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
const expired = row.original.epcIsExpired;
if (!dateStr) {
return <div className="text-center text-slate-400 text-xs"></div>;
return (
<div className="flex justify-center">
<EmptyCell label="No expiry date" />
</div>
);
}
const lodgementDate = new Date(dateStr);
@ -345,13 +403,13 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
}
return (
<div className="text-right">
<div className="flex justify-end text-right">
{cost ? (
<span className="text-xs font-bold text-primary">
£{formatNumber(cost)}
</span>
) : (
<span className="text-[10px] text-slate-300 italic">No cost</span>
<EmptyCell label="Not modelled yet" />
)}
</div>
);
@ -409,7 +467,7 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
header: () => <div className="text-xs">Property Type</div>,
cell: ({ row }) => {
const val = row.original.propertyType;
return val ? <Pill>{val}</Pill> : <span className="text-slate-300 text-xs"></span>;
return val ? <Pill>{val}</Pill> : <EmptyCell />;
},
},
{
@ -418,25 +476,17 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
header: () => <div className="text-xs">Built Form</div>,
cell: ({ row }) => {
const val = row.original.builtForm;
return val ? <Pill>{val}</Pill> : <span className="text-slate-300 text-xs"></span>;
},
},
{
id: "tenure",
accessorKey: "tenure",
header: () => <div className="text-xs">Tenure</div>,
cell: ({ row }) => {
const label = resolveEnumLabel(TENURE_OPTIONS, row.original.tenure);
if (!label) return <span className="text-slate-300 text-xs"></span>;
return <Pill className={tenureBadgeClass(label)}>{label}</Pill>;
return val ? <Pill>{val}</Pill> : <EmptyCell />;
},
},
{
id: "yearBuilt",
accessorKey: "yearBuilt",
header: () => <div className="text-xs">Year Built</div>,
header: () => <div className="text-xs">Construction Age</div>,
cell: ({ row }) => (
<div className="text-sm text-gray-700">{row.original.yearBuilt ?? "—"}</div>
<div className="text-sm text-gray-700">
{row.original.yearBuilt ?? <EmptyCell />}
</div>
),
},
{
@ -447,7 +497,7 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
const val = row.original.totalFloorArea;
return (
<div className="text-sm text-gray-700">
{val != null ? `${val.toFixed(1)}` : "—"}
{val != null ? `${val.toFixed(1)}` : <EmptyCell />}
</div>
);
},
@ -460,7 +510,7 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
const val = row.original.co2Emissions;
return (
<div className="text-sm text-gray-700">
{val != null ? `${val.toFixed(1)} kg/m²/yr` : "—"}
{val != null ? `${val.toFixed(1)} kg/m²/yr` : <EmptyCell />}
</div>
);
},
@ -471,12 +521,52 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
header: () => <div className="text-xs">Main Fuel</div>,
cell: ({ row }) => {
const label = resolveEnumLabel(MAINFUEL_OPTIONS, row.original.mainfuel);
return label ? <Pill>{label}</Pill> : <span className="text-slate-300 text-xs"></span>;
return label ? <Pill>{label}</Pill> : <EmptyCell />;
},
},
{
id: "wallType",
accessorKey: "wallType",
header: () => <div className="text-xs">Wall Type</div>,
cell: ({ row }) => <DescriptorCell value={row.original.wallType} />,
},
{
id: "roofType",
accessorKey: "roofType",
header: () => <div className="text-xs">Roof Type</div>,
cell: ({ row }) => <DescriptorCell value={row.original.roofType} />,
},
{
id: "heatingSystem",
accessorKey: "heatingSystem",
header: () => <div className="text-xs">Heating System</div>,
cell: ({ row }) => <DescriptorCell value={row.original.heatingSystem} />,
},
];
/** Long free-text descriptor (wall/roof/heating): truncate with a full-value tooltip; muted when Unknown. */
function DescriptorCell({ value }: { value: string | null }) {
const val = value ?? "Unknown";
return (
<div
className={cn(
"text-sm max-w-[220px] truncate",
val === "Unknown" ? "text-slate-500" : "text-gray-700",
)}
title={val}
>
{val}
</div>
);
}
export const columns: ColumnDef<PropertyWithRelations>[] = [
...coreColumns,
...optionalColumns,
];
// The same columns without the leading selection checkbox — row-select is only
// shown while bulk-tagging (ADR-0013 impeccable pass), so the default table has
// no persistent checkbox column. Precomputed (stable identity, no useMemo).
export const columnsWithoutSelect: ColumnDef<PropertyWithRelations>[] =
columns.filter((c) => c.id !== "select");

View file

@ -0,0 +1,45 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
/** A portfolio Tag as returned by GET /api/portfolio/[id]/tags (ADR-0013). */
export interface Tag {
id: string;
name: string;
colour: string;
propertyCount: number;
}
/** Shared query key so every tag surface (settings, table, filter) stays in sync. */
export function portfolioTagsKey(portfolioId: string) {
return ["portfolio-tags", portfolioId] as const;
}
export async function fetchTags(portfolioId: string): Promise<Tag[]> {
const res = await fetch(`/api/portfolio/${portfolioId}/tags`);
if (!res.ok) throw new Error("Failed to load tags");
return res.json();
}
/**
* The portfolio's Tags, deduped across every component that reads them (chips,
* assign popover, filter, run UI) via a shared query key. TanStack v4 reads
* with `isLoading`.
*/
export function usePortfolioTags(portfolioId: string) {
return useQuery({
queryKey: portfolioTagsKey(portfolioId),
queryFn: () => fetchTags(portfolioId),
refetchOnWindowFocus: false,
staleTime: 60_000,
});
}
/** Invalidate the tags list + the property table so freshly-assigned chips show. */
export function useInvalidateTagSurfaces(portfolioId: string) {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: portfolioTagsKey(portfolioId) });
queryClient.invalidateQueries({ queryKey: ["properties", portfolioId] });
};
}

View file

@ -15,6 +15,8 @@ import {
MAX_FILTER_POSTCODES,
selectionSummary,
} from "@/lib/modellingRuns/model";
import { usePortfolioTags } from "@/app/portfolio/[slug]/components/useTags";
import { tagDotColour } from "@/lib/tags/colour";
export interface ScenarioOption {
id: string;
@ -77,11 +79,13 @@ function FilterColumn({
options,
selected,
onToggle,
emptyHint,
}: {
title: string;
options: { value: string; count?: number }[];
options: { value: string; label?: string; count?: number; colour?: string }[];
selected: Set<string>;
onToggle: (value: string) => void;
emptyHint?: React.ReactNode;
}) {
return (
<div>
@ -94,28 +98,38 @@ function FilterColumn({
</span>
</div>
<div className="flex max-h-56 flex-col gap-1 overflow-y-auto pr-1.5">
{options.map((o) => {
const on = selected.has(o.value);
return (
<label
key={o.value}
className={`flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-[13.5px] transition ${
on ? "bg-brandlightblue" : "hover:bg-gray-50"
} ${o.value === "Unknown" ? "mt-1 border-t border-dashed border-gray-200 pt-2.5" : ""}`}
>
<input
type="checkbox"
checked={on}
onChange={() => onToggle(o.value)}
className="h-[15px] w-[15px] accent-brandmidblue"
/>
{o.value}
{o.count != null && (
<span className="ml-auto text-[11.5px] tabular-nums text-gray-400">{o.count}</span>
)}
</label>
);
})}
{options.length === 0 && emptyHint ? (
<p className="text-[12.5px] leading-relaxed text-gray-400">{emptyHint}</p>
) : (
options.map((o) => {
const on = selected.has(o.value);
return (
<label
key={o.value}
className={`flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-[13.5px] transition ${
on ? "bg-brandlightblue" : "hover:bg-gray-50"
} ${o.value === "Unknown" ? "mt-1 border-t border-dashed border-gray-200 pt-2.5" : ""}`}
>
<input
type="checkbox"
checked={on}
onChange={() => onToggle(o.value)}
className="h-[15px] w-[15px] accent-brandmidblue"
/>
{o.colour && (
<span
className="h-2.5 w-2.5 shrink-0 rounded-full border"
style={{ backgroundColor: tagDotColour(o.colour) }}
/>
)}
<span className="truncate">{o.label ?? o.value}</span>
{o.count != null && (
<span className="ml-auto text-[11.5px] tabular-nums text-gray-400">{o.count}</span>
)}
</label>
);
})
)}
</div>
</div>
);
@ -135,6 +149,7 @@ export default function RunModellingClient({
const [selPcs, setSelPcs] = useState<Set<string>>(new Set());
const [selTypes, setSelTypes] = useState<Set<string>>(new Set());
const [selForms, setSelForms] = useState<Set<string>>(new Set());
const [selTags, setSelTags] = useState<Set<string>>(new Set());
const [confirmOpen, setConfirmOpen] = useState(false);
const [done, setDone] = useState<{ properties: number; scenarios: number } | null>(null);
@ -142,9 +157,12 @@ export default function RunModellingClient({
...(selPcs.size ? { postcodes: [...selPcs].sort() } : {}),
...(selTypes.size ? { propertyTypes: [...selTypes] } : {}),
...(selForms.size ? { builtForms: [...selForms] } : {}),
...(selTags.size ? { tagIds: [...selTags].map(Number) } : {}),
};
const scenarioIds = [...selScenarios].sort();
const { data: tags = [] } = usePortfolioTags(portfolioId);
const postcodesQuery = useQuery<{ postcode: string; count: number }[], Error>({
queryKey: ["portfolio-postcodes", portfolioId],
staleTime: 5 * 60 * 1000,
@ -219,15 +237,21 @@ export default function RunModellingClient({
const remodel = (preview.data?.perScenario ?? []).filter((p) => p.alreadyModelled > 0);
const scenarioById = new Map(scenarios.map((s) => [s.id, s]));
const startRun = () => {
if (tagsBlockRun) return;
if (matched != null && isLargeRun(matched)) setConfirmOpen(true);
else run.mutate();
};
const filterCount = selPcs.size + selTypes.size + selForms.size;
const filterCount = selPcs.size + selTypes.size + selForms.size + selTags.size;
const clearFilters = () => {
setSelPcs(new Set());
setSelTypes(new Set());
setSelForms(new Set());
setSelTags(new Set());
};
// The distributor can't resolve tag_ids yet (backend ask pending, ADR-0013):
// preview counts tags in-app, but an actual run would model the unscoped set.
// Guard the run until the backend lands — remove this once it does.
const tagsBlockRun = selTags.size > 0;
return (
<div className="mx-auto max-w-4xl px-6 pb-48 pt-10">
@ -376,7 +400,7 @@ export default function RunModellingClient({
takes priority over its EPC; &ldquo;Unknown&rdquo; picks out homes where we have
neither.
</p>
<div className="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-3">
<div className="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{postcodesQuery.isError ? (
<div>
<span className="font-manrope text-[11px] font-extrabold uppercase tracking-[.1em] text-gray-600">
@ -422,10 +446,26 @@ export default function RunModellingClient({
selected={selForms}
onToggle={(v) => toggle(selForms, setSelForms, v)}
/>
<FilterColumn
title="Tags"
options={tags.map((t) => ({
value: t.id,
label: t.name,
colour: t.colour,
count: t.propertyCount,
}))}
selected={selTags}
onToggle={(v) => toggle(selTags, setSelTags, v)}
emptyHint={
<>
No tags yet create them from the property table to run a
scenario over a hand-picked group.
</>
}
/>
</div>
<p className="mt-3 text-[11.5px] leading-relaxed text-gray-400">
Filters are limited to {MAX_FILTER_POSTCODES} postcodes per run. Tags are coming
you&apos;ll be able to save a selection and reuse it.
Filters are limited to {MAX_FILTER_POSTCODES} postcodes per run.
</p>
</div>
@ -560,17 +600,34 @@ export default function RunModellingClient({
</span>
<button
onClick={startRun}
disabled={preview.isFetching || preview.isError || !matched || run.isLoading}
disabled={
preview.isFetching ||
preview.isError ||
!matched ||
run.isLoading ||
tagsBlockRun
}
className="ml-auto rounded-xl px-4 py-2 text-sm font-semibold text-white transition disabled:opacity-40"
style={{ background: "linear-gradient(135deg,#3943b7,#2d348f)" }}
>
{run.isLoading
? "Starting…"
: matched
? `Run modelling — ${planTotal!.toLocaleString()} plan${planTotal === 1 ? "" : "s"}`
: "No properties match"}
: tagsBlockRun
? "Tag runs coming soon"
: matched
? `Run modelling — ${planTotal!.toLocaleString()} plan${planTotal === 1 ? "" : "s"}`
: "No properties match"}
</button>
</div>
{tagsBlockRun && (
<div className="mt-2.5">
<span className="inline-block w-fit rounded-lg border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs text-amber-800">
You can preview a tag selection now, but running scenarios over
tags needs a modelling-engine update that&apos;s on the way. The
count above is exact the run button unlocks once it lands.
</span>
</div>
)}
{(remodel.length > 0 || run.isError) && (
<div className="mt-2.5 flex flex-col gap-1">
{remodel.map((p) => (

View file

@ -44,6 +44,7 @@ export default function AddPropertiesClient({
const [input, setInput] = useState("");
const [inputError, setInputError] = useState<string | null>(null);
const [search, setSearch] = useState<{ pc: string; refresh: number } | null>(null);
const [filter, setFilter] = useState("");
const [basket, setBasket] = useState<Basket>({});
const [done, setDone] = useState<{ added: number; skipped: number } | null>(null);
@ -110,6 +111,7 @@ export default function AddPropertiesClient({
}
setInputError(null);
setDone(null);
setFilter("");
setSearch({ pc, refresh: 0 });
};
@ -130,6 +132,12 @@ export default function AddPropertiesClient({
const addable = data
? data.candidates.filter((c) => c.selectable && !c.alreadyInPortfolio)
: [];
const needle = filter.trim().toLowerCase();
const visible = data
? needle
? data.candidates.filter((c) => c.address.toLowerCase().includes(needle))
: data.candidates
: [];
const totalSelected = countSelected(basket);
const postcodes = Object.keys(basket);
@ -267,7 +275,9 @@ export default function AddPropertiesClient({
<div className="flex items-baseline gap-3 flex-wrap mb-4">
<h2 className="text-lg font-bold text-gray-800">{data.postcode}</h2>
<span className="text-xs text-slate-400 font-medium">
{data.candidates.length}{" "}
{needle
? `${visible.length} of ${data.candidates.length}`
: data.candidates.length}{" "}
{data.candidates.length === 1 ? "address" : "addresses"}
</span>
{addable.length > 0 && (
@ -307,10 +317,40 @@ export default function AddPropertiesClient({
</span>
</div>
{/* Long postcodes can return ~100 addresses a filter lets the user
jump to a house number or street name without scrolling. */}
{data.candidates.length > 8 && (
<div className="relative mb-3">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 pointer-events-none" />
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter these addresses…"
spellCheck={false}
autoComplete="off"
className="w-full border border-slate-200 rounded-xl pl-9 pr-9 py-2 text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/10"
/>
{filter && (
<button
aria-label="Clear filter"
onClick={() => setFilter("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm leading-none hover:text-slate-700"
>
</button>
)}
</div>
)}
{/* Long postcodes can return ~100 addresses scroll inside the card
rather than growing the page. */}
<div className="max-h-[26rem] overflow-y-auto pr-1 -mr-1">
{data.candidates.map((c) => {
{visible.length === 0 ? (
<p className="py-8 text-center text-sm text-slate-400">
No addresses match &ldquo;{filter.trim()}&rdquo;.
</p>
) : (
visible.map((c) => {
const { label, note } = describeCandidate(c);
const disabled = !c.selectable || c.alreadyInPortfolio;
const isSelected = !!selected[c.uprn];
@ -361,7 +401,8 @@ export default function AddPropertiesClient({
</span>
</button>
);
})}
})
)}
</div>
</div>
)}

View file

@ -25,12 +25,25 @@ import {
totalFloorAreaSql,
lodgementDateSql,
isExpiredSql,
mainfuelSql,
effectiveSapSql,
effectiveEpcBandSql,
lodgedEpcBandSql,
lodgedSapSql,
provenanceSignalSql,
propertyTypeOverrideJoin,
resolvedPropertyTypeSql,
builtFormOverrideJoin,
resolvedBuiltFormSql,
constructionAgeOverrideJoin,
resolvedConstructionAgeSql,
mainFuelOverrideJoin,
resolvedMainFuelSql,
wallTypeOverrideJoin,
resolvedWallTypeSql,
roofTypeOverrideJoin,
resolvedRoofTypeSql,
mainHeatingOverrideJoin,
resolvedHeatingSystemSql,
} from "@/lib/services/epcSources";
import {
FilterGroups,
@ -38,18 +51,19 @@ import {
PropertyFilter,
PROPERTY_TYPE_OPTIONS,
BUILT_FORM_OPTIONS,
TENURE_OPTIONS,
YEAR_BUILT_OPTIONS,
MAINFUEL_OPTIONS,
PROVENANCE_OPTIONS,
EnumOption,
DESCRIPTOR_FILTER_CONFIG,
DescriptorBucket,
DescriptorMatch,
} from "@/app/utils/propertyFilters";
import { EPC_TO_SAP_MIN, EPC_TO_SAP_MAX } from "@/app/utils/epc";
const ENUM_FIELD_DB_OPTIONS: Record<string, EnumOption[]> = {
propertyType: PROPERTY_TYPE_OPTIONS,
builtForm: BUILT_FORM_OPTIONS,
tenure: TENURE_OPTIONS,
yearBuilt: YEAR_BUILT_OPTIONS,
provenance: PROVENANCE_OPTIONS,
mainfuel: MAINFUEL_OPTIONS,
@ -508,6 +522,50 @@ function buildEpcSapCondition(
return null;
}
/** An ILIKE pattern for a needle: prefix ("cavity%") or substring ("%mains gas%"). */
function descriptorLikePattern(needle: string, match: DescriptorMatch): string {
return match === "prefix" ? `${needle}%` : `%${needle}%`;
}
/**
* WHERE fragment matching the free-text wall/roof/heating column against the
* selected coarse buckets (mirrors classifyDescriptor). A non-empty bucket
* matches any of its needles; the "Unknown" bucket matches the complement
* rows matching NO known needle (which includes the resolved 'Unknown'
* terminal). NULL is impossible here (resolvedXSql COALESCEs to 'Unknown').
*/
function buildDescriptorBucketCondition(
col: ReturnType<typeof sql>,
buckets: DescriptorBucket[],
match: DescriptorMatch,
selectedLabels: string[],
): ReturnType<typeof sql> | null {
const allNeedles = buckets.flatMap((b) => b.needles);
const parts: ReturnType<typeof sql>[] = [];
for (const label of selectedLabels) {
const bucket = buckets.find((b) => b.label === label);
if (!bucket) continue;
if (bucket.needles.length === 0) {
// "Unknown" = the complement of every known needle.
if (allNeedles.length === 0) return null;
const nots = allNeedles.map(
(n) => sql`${col} NOT ILIKE ${descriptorLikePattern(n, match)}`,
);
parts.push(sql`(${sql.join(nots, sql` AND `)})`);
} else {
const likes = bucket.needles.map(
(n) => sql`${col} ILIKE ${descriptorLikePattern(n, match)}`,
);
parts.push(sql`(${sql.join(likes, sql` OR `)})`);
}
}
if (parts.length === 0) return null;
if (parts.length === 1) return parts[0];
return sql`(${sql.join(parts, sql` OR `)})`;
}
function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | null {
switch (filter.field) {
case "address":
@ -579,7 +637,6 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
case "propertyType":
case "builtForm":
case "tenure":
case "yearBuilt":
case "provenance":
case "mainfuel": {
@ -595,14 +652,15 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
const options = ENUM_FIELD_DB_OPTIONS[filter.field];
const colMap: Record<string, ReturnType<typeof sql>> = {
propertyType: sql`p.property_type`,
builtForm: sql`p.built_form`,
tenure: sql`p.tenure`,
yearBuilt: sql`p.year_built`,
// Override-resolved to match the column + the Run filter (ADR-0012).
propertyType: resolvedPropertyTypeSql,
builtForm: resolvedBuiltFormSql,
yearBuilt: resolvedConstructionAgeSql,
provenance: provenanceSignalSql,
// GAP: new-approach properties have no main-fuel string → NULL, so they
// only match the "no value" filter branch. See epcSources.ts.
mainfuel: mainfuelSql(sql`epc`),
// Override-resolved (ADR-0012): the new EPC graph has no main fuel, so a
// new-approach property matches a fuel filter only via a landlord override;
// otherwise it resolves to 'Unknown'.
mainfuel: resolvedMainFuelSql(sql`epc`),
};
const col = colMap[filter.field];
@ -656,6 +714,68 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
if (filter.operator === "num_equals") return sql`${carbon} = ${n}`;
return null;
}
case "tags": {
// Value is a JSON array of selected tag ids, optionally with the synthetic
// "__untagged__" bucket. Any-of within tags (ADR-0013). EXISTS on
// property_tag is PK-indexed by property_id — no join to add to the count.
if (filter.operator !== "enum_one_of") return null;
let selected: string[];
try {
selected = JSON.parse(filter.value);
} catch {
return null;
}
if (selected.length === 0) return null;
const untagged = selected.includes("__untagged__");
const ids = selected.filter((s) => /^\d+$/.test(s));
const parts: ReturnType<typeof sql>[] = [];
if (ids.length > 0) {
const placeholders = ids.map((id) => sql`${id}::bigint`);
parts.push(
sql`EXISTS (SELECT 1 FROM property_tag pt WHERE pt.property_id = p.id AND pt.tag_id IN (${sql.join(placeholders, sql`, `)}))`,
);
}
if (untagged) {
parts.push(
sql`NOT EXISTS (SELECT 1 FROM property_tag pt WHERE pt.property_id = p.id)`,
);
}
if (parts.length === 0) return null;
if (parts.length === 1) return parts[0];
return sql`(${sql.join(parts, sql` OR `)})`;
}
case "wallType":
case "roofType":
case "heatingSystem": {
// Free-text descriptors: bucket the resolved value by prefix/substring
// needles (ADR-0012), mirroring classifyDescriptor. The resolved SQL folds
// override → EPC element description → legacy → 'Unknown'.
if (filter.operator !== "enum_one_of") return null;
let selectedLabels: string[];
try {
selectedLabels = JSON.parse(filter.value);
} catch {
return null;
}
if (selectedLabels.length === 0) return null;
const { buckets, match } = DESCRIPTOR_FILTER_CONFIG[filter.field];
const colMap: Record<string, ReturnType<typeof sql>> = {
wallType: resolvedWallTypeSql(sql`epc`),
roofType: resolvedRoofTypeSql(sql`epc`),
heatingSystem: resolvedHeatingSystemSql(sql`epc`),
};
return buildDescriptorBucketCondition(
colMap[filter.field],
buckets,
match,
selectedLabels,
);
}
}
return null;
}
@ -695,6 +815,15 @@ const EPC_JOIN_FILTER_FIELDS = new Set<FilterField>([
"floorArea",
"epcExpiryDate",
"mainfuel",
// Override-resolved (ADR-0012): needs the EPC graph joins + the override join.
"propertyType",
"builtForm",
"yearBuilt",
// Free-text descriptors (ADR-0012): resolved SQL reads the EPC element
// description subquery + the override join.
"wallType",
"roofType",
"heatingSystem",
]);
const PLAN_JOIN_FILTER_FIELDS = new Set<FilterField>(["expectedEpc"]);
@ -720,7 +849,14 @@ export async function getPropertiesCount(
const epcJoins = needsEpcJoins
? sql`LEFT JOIN property_details_epc epc ON epc.property_id = p.id
${newApproachJoins}`
${newApproachJoins}
${propertyTypeOverrideJoin}
${builtFormOverrideJoin}
${constructionAgeOverrideJoin}
${mainFuelOverrideJoin}
${wallTypeOverrideJoin}
${roofTypeOverrideJoin}
${mainHeatingOverrideJoin}`
: sql``;
const planJoin = needsPlanJoin
? sql`LEFT JOIN LATERAL (
@ -739,12 +875,58 @@ export async function getPropertiesCount(
${epcJoins}
${planJoin}
WHERE p.portfolio_id = ${portfolioId}
-- Unmatched (no-UPRN) properties live in the "Needs attention" tab until
-- resolved, so they're excluded from the main table + its count.
AND p.uprn IS NOT NULL
${combinedWhere}
`);
return parseInt(result.rows[0]?.count ?? "0", 10);
}
/**
* The ids of every Property matching a filter selection used by filter-driven
* Bulk tag assignment ("tag all N matching the current filter", ADR-0013). Same
* resolution + conditional joins as getPropertiesCount, but returns ids across
* the whole matching set, not a display page. Mirrors the table's `uprn IS NOT
* NULL` exclusion so the count the user saw and the set that gets tagged agree.
*/
export async function getFilteredPropertyIds(
portfolioId: string,
filterGroups: FilterGroups = [],
): Promise<bigint[]> {
const combinedWhere = buildWhereClause(filterGroups);
const fields = filterFieldsInUse(filterGroups);
const needsEpcJoins = [...fields].some((f) => EPC_JOIN_FILTER_FIELDS.has(f));
const needsPlanJoin = [...fields].some((f) => PLAN_JOIN_FILTER_FIELDS.has(f));
const epcJoins = needsEpcJoins
? sql`LEFT JOIN property_details_epc epc ON epc.property_id = p.id
${newApproachJoins}`
: sql``;
const planJoin = needsPlanJoin
? sql`LEFT JOIN LATERAL (
SELECT id, post_sap_points FROM plan
WHERE property_id = p.id
AND portfolio_id = p.portfolio_id
AND is_default = true
ORDER BY created_at DESC
LIMIT 1
) pl ON true`
: sql``;
const result = await db.execute<{ id: string }>(sql`
SELECT DISTINCT p.id AS id
FROM property p
${epcJoins}
${planJoin}
WHERE p.portfolio_id = ${portfolioId}
AND p.uprn IS NOT NULL
${combinedWhere}
`);
return result.rows.map((r) => BigInt(r.id));
}
export async function getProperties(
portfolioId: string,
limit: number = 1000,
@ -786,16 +968,39 @@ export async function getProperties(
COALESCE(rec.cost, 0) AS "totalRecommendationCost",
p.landlord_property_id AS "landlordPropertyId",
p.original_sap_points AS "originalSapPoints",
p.property_type AS "propertyType",
p.built_form AS "builtForm",
p.tenure AS tenure,
p.year_built AS "yearBuilt",
-- Property Type is resolved through the landlord-override precedence
-- (override EPC-derived 'Unknown'), matching the modelling Run filter.
-- See resolvePropertyType / ADR-0012.
${resolvedPropertyTypeSql} AS "propertyType",
${resolvedBuiltFormSql} AS "builtForm",
-- Construction Age is band-native and override-resolved (ADR-0012); the
-- "yearBuilt" key is retained to avoid a wide rename. See resolveConstructionAge.
${resolvedConstructionAgeSql} AS "yearBuilt",
${lodgementDateSql(sql`epc`)}::text AS "epcLodgementDate",
${isExpiredSql(sql`epc`)} AS "epcIsExpired",
${totalFloorAreaSql(sql`epc`)} AS "totalFloorArea",
${carbonSql(sql`epc`)} AS "co2Emissions",
${mainfuelSql(sql`epc`)} AS mainfuel,
p.lexiscore AS lexiscore
-- Main Fuel: override EPC-derived 'Unknown'. The new EPC graph has no
-- main fuel, so overrides are the only new-approach source (ADR-0012).
${resolvedMainFuelSql(sql`epc`)} AS mainfuel,
-- Wall / roof / heating: override EPC element description legacy 'Unknown'
-- (ADR-0012). Per-part components (wall/roof) read the main building part.
${resolvedWallTypeSql(sql`epc`)} AS "wallType",
${resolvedRoofTypeSql(sql`epc`)} AS "roofType",
${resolvedHeatingSystemSql(sql`epc`)} AS "heatingSystem",
p.lexiscore AS lexiscore,
-- Portfolio Tags on this property, name-ordered, as JSON (ADR-0013). A
-- correlated subquery keeps the main query at one row per property (a JOIN
-- would multiply rows and break the LATERAL aggregates above).
COALESCE((
SELECT json_agg(
json_build_object('id', t.id::text, 'name', t.name, 'colour', t.colour)
ORDER BY t.name
)
FROM property_tag pt
JOIN portfolio_tag t ON t.id = pt.tag_id
WHERE pt.property_id = p.id
), '[]'::json) AS tags
FROM property p
-- LATERAL one-row lookups keep this query at "N properties × index probe"
-- instead of hash-joining the multi-million-row plan/recommendation tables.
@ -830,7 +1035,17 @@ export async function getProperties(
LEFT JOIN property_details_epc epc
ON epc.property_id = p.id
${newApproachJoins}
${propertyTypeOverrideJoin}
${builtFormOverrideJoin}
${constructionAgeOverrideJoin}
${mainFuelOverrideJoin}
${wallTypeOverrideJoin}
${roofTypeOverrideJoin}
${mainHeatingOverrideJoin}
WHERE p.portfolio_id = ${portfolioId}
-- Unmatched (no-UPRN) properties live in the "Needs attention" tab until
-- resolved, so they're excluded from the main table.
AND p.uprn IS NOT NULL
${combinedWhere}
LIMIT ${limit} OFFSET ${offset};
`);

View file

@ -8,12 +8,15 @@ export type FilterField =
| "propertyRef"
| "propertyType"
| "builtForm"
| "tenure"
| "yearBuilt"
| "provenance"
| "floorArea"
| "co2Emissions"
| "mainfuel";
| "mainfuel"
| "wallType"
| "roofType"
| "heatingSystem"
| "tags";
export type FilterOperator =
| "contains"
@ -64,13 +67,23 @@ export interface EnumOption {
dbValues: string[];
}
// dbValues are the label vocabulary emitted by resolvedPropertyTypeSql (override
// snapshot / EPC-derived label). "Park home" is reachable from an RdSAP code (4)
// or an override; "Unknown" is the never-null terminal + an explicit override —
// it selects properties with no resolvable value at all (ADR-0012).
export const PROPERTY_TYPE_OPTIONS: EnumOption[] = [
{ label: "House", dbValues: ["House"] },
{ label: "Flat", dbValues: ["Flat"] },
{ label: "Bungalow", dbValues: ["Bungalow"] },
{ label: "Maisonette", dbValues: ["Maisonette"] },
{ label: "Park home", dbValues: ["Park home"] },
{ label: "Unknown", dbValues: ["Unknown"] },
];
// dbValues are the label vocabulary emitted by resolvedBuiltFormSql (override
// snapshot / EPC-derived label). "Not Recorded" matches only an explicit
// override of that literal; "Unknown" is the never-null terminal + an explicit
// override — it selects properties with no resolvable value at all (ADR-0012).
export const BUILT_FORM_OPTIONS: EnumOption[] = [
{ label: "Detached", dbValues: ["Detached"] },
{ label: "Semi-Detached", dbValues: ["Semi-Detached"] },
@ -79,15 +92,7 @@ export const BUILT_FORM_OPTIONS: EnumOption[] = [
{ label: "Enclosed End-Terrace", dbValues: ["Enclosed End-Terrace"] },
{ label: "Enclosed Mid-Terrace", dbValues: ["Enclosed Mid-Terrace"] },
{ label: "Not Recorded", dbValues: ["Not Recorded"] },
];
export const TENURE_OPTIONS: EnumOption[] = [
{ label: "Owner-occupied", dbValues: ["Owner-occupied", "owner-occupied"] },
{ label: "Rented (Private)", dbValues: ["Rented (private)", "rental (private)", "rented (private)"] },
{ label: "Rented (Social)", dbValues: ["Rented (social)", "rental (social)"] },
{ label: "Not Defined", dbValues: ["Not defined - use in the case of a new dwelling for which the intended tenure in not known. It is not to be used for an existing dwelling"] },
{ label: "Unknown", dbValues: ["unknown"] },
{ label: "Not Recorded", dbValues: ["__null__"] },
{ label: "Unknown", dbValues: ["Unknown"] },
];
// Provenance signal (ADR-0002). dbValues are the raw signal strings emitted by
@ -98,26 +103,210 @@ export const PROVENANCE_OPTIONS: EnumOption[] = [
{ label: "Standard", dbValues: ["none"] },
];
// Construction Age is band-native (ADR-0012): labels + dbValues are the RdSAP
// band labels emitted by resolvedConstructionAgeSql. These MUST match
// CONSTRUCTION_AGE_BANDS[*].label in epcSources.ts exactly (note the en-dash);
// epcSources.test.ts guards against drift. "Unknown" is the never-null terminal.
export const YEAR_BUILT_OPTIONS: EnumOption[] = [
"1900","1930","1950","1967","1976","1983","1991","1996",
"2003","2007","2008","2009","2010","2011","2012","2013",
"2014","2015","2016","2017","2018","2019","2020","2021",
"2022","2023","2024","2025",
].map((y) => ({ label: y, dbValues: [y] }));
"before 1900", "19001929", "19301949", "19501966", "19671975",
"19761982", "19831990", "19911995", "19962002", "20032006",
"20072011", "20122022", "2023 onwards", "Unknown",
].map((label) => ({ label, dbValues: [label] }));
export const MAINFUEL_OPTIONS: EnumOption[] = [
{ label: "Mains Gas", dbValues: ["Gas mains gas", "Mains gas community", "Mains gas not community", "Mains gas this is for backwards compatibility only and should not be used"] },
{ label: "LPG", dbValues: ["Bottled lpg", "Lpg community", "Lpg not community", "Lpg this is for backwards compatibility only and should not be used"] },
{ label: "Oil", dbValues: ["Oil heating oil", "Oil community", "Oil not community", "Oil this is for backwards compatibility only and should not be used"] },
{ label: "Electricity", dbValues: ["Electricity electricity unspecified tariff", "Electricity community", "Electricity not community", "Electricity this is for backwards compatibility only and should not be used"] },
{ label: "Biomass", dbValues: ["Bulk wood pellets", "Wood chips", "Wood logs", "Biomass community", "Biomass this is for backwards compatibility only and should not be used"] },
{ label: "Coal", dbValues: ["Anthracite", "House coal not community", "House coal this is for backwards compatibility only and should not be used", "Smokeless coal", "Coal community"] },
{ label: "Dual Fuel (Mineral + Wood)", dbValues: ["Dual fuel mineral wood"] },
// RdSAP `main_fuel` code → coarse label. The new EPC graph stores the main fuel
// as this numeric code on epc_main_heating_detail.main_fuel_type (see mainfuelSql
// / resolveMainFuel), so every code that can appear must fold into a filter
// option or an overridden/EPC-derived fuel would display but be unfilterable
// (ADR-0012). Codes are the authoritative Model `epc_codes.csv` `main_fuel` table
// (RdSAP), cross-checked against production epc rows: 26→mains gas, 29/39→electric,
// 0/20/25/31/57→"Community scheme", 5/15/33→coal, 6→wood logs, 9→dual fuel. All
// "(community)"/heat-network codes bucket to "Community Heat Network" — being on a
// communal scheme is the salient fact; the scheme's own fuel is a secondary detail.
export const RDSAP_MAIN_FUEL_CODE_LABELS: Record<string, string> = {
// Mains gas
"1": "Mains Gas", "26": "Mains Gas",
// LPG
"2": "LPG", "3": "LPG", "17": "LPG", "27": "LPG", "38": "LPG",
// Oil
"4": "Oil", "28": "Oil",
// Electricity
"10": "Electricity", "29": "Electricity", "39": "Electricity",
// Coal / mineral solid fuel (anthracite, smokeless, house coal)
"5": "Coal", "14": "Coal", "15": "Coal", "33": "Coal",
// Biomass / wood (logs, pellets, chips)
"6": "Biomass", "7": "Biomass", "8": "Biomass", "12": "Biomass", "16": "Biomass",
// Dual fuel
"9": "Dual Fuel (Mineral + Wood)",
// Biogas
"13": "Biogas", "51": "Biogas",
// Bioethanol
"19": "Bioethanol", "76": "Bioethanol",
// Biodiesel (from biomass / used cooking oil / vegetable / rape seed oil)
"34": "Biodiesel", "35": "Biodiesel", "36": "Biodiesel", "37": "Biodiesel",
"71": "Biodiesel", "72": "Biodiesel", "73": "Biodiesel", "74": "Biodiesel",
// Biodiesel blend
"18": "B30k (Biodiesel blend)", "75": "B30k (Biodiesel blend)",
// Waste combustion
"11": "Waste Combustion",
// Community / heat-network (the dwelling is on a communal scheme)
"0": "Community Heat Network", "20": "Community Heat Network", "21": "Community Heat Network",
"22": "Community Heat Network", "23": "Community Heat Network", "24": "Community Heat Network",
"25": "Community Heat Network", "30": "Community Heat Network", "31": "Community Heat Network",
"32": "Community Heat Network", "41": "Community Heat Network", "42": "Community Heat Network",
"43": "Community Heat Network", "44": "Community Heat Network", "45": "Community Heat Network",
"46": "Community Heat Network", "47": "Community Heat Network", "48": "Community Heat Network",
"49": "Community Heat Network", "50": "Community Heat Network", "52": "Community Heat Network",
"53": "Community Heat Network", "54": "Community Heat Network", "55": "Community Heat Network",
"56": "Community Heat Network", "57": "Community Heat Network", "58": "Community Heat Network",
"99": "Community Heat Network",
};
// EPR free-text main_fuel_type strings (Elmhurst/PASHub, ~5% of new-approach
// rows) folded to the same labels. The empty-string sentinel is NULLIF'd to NULL
// upstream and resolves to 'Unknown'.
export const EPR_MAIN_FUEL_STRING_LABELS: Record<string, string> = {
"Mains gas": "Mains Gas",
"Electricity": "Electricity",
"Bulk LPG": "LPG",
"Heating oil": "Oil",
};
// dbValues fold several vocabularies onto one user-facing label: the raw legacy
// EPC mainfuel strings (property_details_epc), the landlord main_fuel override
// values ("mains gas", "LPG (bulk)", … — see MainFuelValues), the new-approach
// RdSAP numeric codes (RDSAP_MAIN_FUEL_CODE_LABELS) and the EPR strings
// (EPR_MAIN_FUEL_STRING_LABELS). resolvedMainFuelSql emits whichever applies; all
// must resolve to the same chip (ADR-0012). descriptorResolution.test asserts
// every override value, RdSAP code and EPR string is covered here.
const MAINFUEL_OPTIONS_BASE: EnumOption[] = [
{ label: "Mains Gas", dbValues: ["Gas mains gas", "Mains gas community", "Mains gas not community", "Mains gas this is for backwards compatibility only and should not be used", "mains gas", "mains gas (community)"] },
{ label: "LPG", dbValues: ["Bottled lpg", "Lpg community", "Lpg not community", "Lpg this is for backwards compatibility only and should not be used", "LPG (bulk)", "bottled LPG", "LPG special condition"] },
{ label: "Oil", dbValues: ["Oil heating oil", "Oil community", "Oil not community", "Oil this is for backwards compatibility only and should not be used", "oil"] },
{ label: "Electricity", dbValues: ["Electricity electricity unspecified tariff", "Electricity community", "Electricity not community", "Electricity this is for backwards compatibility only and should not be used", "electricity", "electricity (community)"] },
{ label: "Biomass", dbValues: ["Bulk wood pellets", "Wood chips", "Wood logs", "Biomass community", "Biomass this is for backwards compatibility only and should not be used", "biomass (community)", "wood logs"] },
{ label: "Coal", dbValues: ["Anthracite", "House coal not community", "House coal this is for backwards compatibility only and should not be used", "Smokeless coal", "Coal community", "house coal", "smokeless coal"] },
{ label: "Dual Fuel (Mineral + Wood)", dbValues: ["Dual fuel mineral wood", "dual fuel (mineral and wood)"] },
{ label: "Biogas", dbValues: ["Biogas not community"] },
{ label: "Bioethanol", dbValues: [] },
{ label: "Biodiesel", dbValues: ["Heat from boilers using biodiesel from any biomass source community"] },
{ label: "B30d (Biodiesel blend)", dbValues: ["B30d community"] },
{ label: "B30k (Biodiesel blend)", dbValues: ["B30k not community"] },
{ label: "Waste Combustion", dbValues: [] },
{ label: "Community Heat Network", dbValues: ["From heat network data community"] },
{ label: "No Heating System", dbValues: ["To be used only when there is no heatinghotwater system", "To be used only when there is no heatinghotwater system or data is from a community network"] },
{ label: "Unknown / No Data", dbValues: ["UNKNOWN", "NO DATA!"] },
{ label: "Unknown / No Data", dbValues: ["UNKNOWN", "NO DATA!", "Unknown"] },
];
/** Fold the RdSAP code + EPR string vocabularies into each label's dbValues. */
export const MAINFUEL_OPTIONS: EnumOption[] = MAINFUEL_OPTIONS_BASE.map((opt) => {
const codes = Object.entries(RDSAP_MAIN_FUEL_CODE_LABELS)
.filter(([, label]) => label === opt.label)
.map(([code]) => code);
const strings = Object.entries(EPR_MAIN_FUEL_STRING_LABELS)
.filter(([, label]) => label === opt.label)
.map(([str]) => str);
return { ...opt, dbValues: [...opt.dbValues, ...codes, ...strings] };
});
/* -----------------------------------------------------------------------
Free-text descriptor buckets: Wall Type / Roof Type / Heating System (ADR-0012)
Unlike the enum fields above (exact dbValues), the resolved wall/roof/heating
value is a free-text description (override vocabulary, EPC
epc_energy_element.description, or legacy property_details_epc column) with
HIGH cardinality 88+ distinct EPC wall descriptions, 245 legacy, plus
insulation-thickness variants, "Average thermal transmittance …" U-values and
encoding corruption. Exact matching can't scale, so each coarse bucket is a set
of case-insensitive NEEDLES matched by prefix (wall/roof) or substring
(heating, whose fuel follows a comma). classifyDescriptor is the pure twin of
the SQL bucketing in the portfolio query's buildConditionSql keep in sync.
Ordering matters: the first bucket whose needle matches wins.
------------------------------------------------------------------------ */
export interface DescriptorBucket {
label: string;
/** Lowercase needles, matched per the field's DescriptorMatch mode. Empty = the Unknown terminal (matches the complement). */
needles: string[];
}
export type DescriptorMatch = "prefix" | "contains";
// Wall construction. Prefix-matched on the leading construction term. "Stone"
// folds sandstone/limestone + granite/whinstone; "Average thermal transmittance …"
// (a U-value, not a construction type) and corruption fall through to Unknown.
export const WALL_TYPE_OPTIONS: DescriptorBucket[] = [
{ label: "Cavity", needles: ["cavity"] },
{ label: "Solid Brick", needles: ["solid brick"] },
{ label: "Timber Frame", needles: ["timber frame"] },
{ label: "Stone", needles: ["sandstone", "granite"] },
{ label: "System Built", needles: ["system built"] },
{ label: "Park Home", needles: ["park home"] },
{ label: "Cob", needles: ["cob"] },
{ label: "Curtain Wall", needles: ["curtain"] },
{ label: "Basement", needles: ["basement"] },
{ label: "Unknown", needles: [] },
];
// Roof form. Prefix-matched. "Premises Above" folds the "(another dwelling
// above)" / "(other premises above)" family (a flat with a dwelling above it, so
// no own roof). U-values fall through to Unknown.
export const ROOF_TYPE_OPTIONS: DescriptorBucket[] = [
{ label: "Pitched", needles: ["pitched"] },
{ label: "Flat", needles: ["flat"] },
{ label: "Room-in-Roof", needles: ["roof room"] },
{ label: "Thatched", needles: ["thatched"] },
{ label: "Premises Above", needles: ["(another", "(other", "(same", "another dwelling", "another premises", "other premises"] },
{ label: "Unknown", needles: [] },
];
// Heating system. Substring-matched (the fuel follows a comma, e.g. "Boiler and
// radiators, mains gas"). Order is load-bearing: Heat Pump / Community / Solid
// Fuel / Oil / LPG are checked before the gas + electric buckets so a
// heat-pump/community/wood/oil/LPG system isn't miscaught by "gas"/"electric";
// Gas Room Heater before Gas Boiler; Electric Storage before Direct Electric.
export const HEATING_SYSTEM_OPTIONS: DescriptorBucket[] = [
{ label: "Heat Pump", needles: ["heat pump"] },
{ label: "Community", needles: ["community"] },
{ label: "Solid Fuel", needles: ["coal", "anthracite", "smokeless", "wood", "dual fuel", "biomass", "solid fuel"] },
// NB: bare "oil" is a substring of "b(oil)er" — match the fuel token only (", oil"
// as in "Boiler and radiators, oil") and the leading override "Oil room heater".
{ label: "Oil", needles: [", oil", "oil room heater"] },
{ label: "LPG", needles: ["lpg"] },
{ label: "Gas Room Heater", needles: ["gas room heater", "room heaters, mains gas", "room heaters, gas"] },
{ label: "Gas Boiler", needles: ["mains gas", "gas boiler", "gas cpsu"] },
{ label: "Electric Storage", needles: ["storage heater"] },
{ label: "Direct Electric", needles: ["electric"] },
{ label: "Unknown", needles: [] },
];
/** Filter config per descriptor field: its buckets + how needles are matched. */
export const DESCRIPTOR_FILTER_CONFIG: Record<
"wallType" | "roofType" | "heatingSystem",
{ buckets: DescriptorBucket[]; match: DescriptorMatch }
> = {
wallType: { buckets: WALL_TYPE_OPTIONS, match: "prefix" },
roofType: { buckets: ROOF_TYPE_OPTIONS, match: "prefix" },
heatingSystem: { buckets: HEATING_SYSTEM_OPTIONS, match: "contains" },
};
/**
* Classify a resolved free-text descriptor into its coarse filter bucket: the
* first bucket with a matching needle, else "Unknown". Pure twin of the SQL
* bucketing in buildConditionSql (portfolio query) keep in sync.
*/
export function classifyDescriptor(
value: string | null | undefined,
buckets: DescriptorBucket[],
match: DescriptorMatch,
): string {
if (value == null) return "Unknown";
const v = value.toLowerCase();
for (const bucket of buckets) {
for (const needle of bucket.needles) {
if (match === "prefix" ? v.startsWith(needle) : v.includes(needle)) {
return bucket.label;
}
}
}
return "Unknown";
}

View file

@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest";
import {
MAX_BULK_DOWNLOAD_PROPERTIES,
buildBulkDownloadRecord,
buildSelectionConfig,
bulkDownloadRefetchInterval,
parseDelivery,
} from "./model";
describe("buildSelectionConfig — UI selection → stored config", () => {
const portfolioId = 814;
it("one project + include all → project_codes", () => {
const r = buildSelectionConfig({
scope: { kind: "project", projectCode: "ABRI-2024-01" },
includeAll: true,
landlordPropertyIds: [],
portfolioId,
});
expect(r).toEqual({
ok: true,
config: { project_codes: ["ABRI-2024-01"], portfolio_id: 814 },
});
});
it("one project + ticked subset → landlord_property_ids (scope ignored)", () => {
const r = buildSelectionConfig({
scope: { kind: "project", projectCode: "ABRI-2024-01" },
includeAll: false,
landlordPropertyIds: ["LP1023", "LP1044"],
portfolioId,
});
expect(r).toEqual({
ok: true,
config: { landlord_property_ids: ["LP1023", "LP1044"], portfolio_id: 814 },
});
});
it("all projects + include all → every project code", () => {
const r = buildSelectionConfig({
scope: { kind: "all-projects", projectCodes: ["A-1", "B-2"] },
includeAll: true,
landlordPropertyIds: [],
portfolioId,
});
expect(r).toEqual({
ok: true,
config: { project_codes: ["A-1", "B-2"], portfolio_id: 814 },
});
});
it("all projects + ticked subset → landlord_property_ids", () => {
const r = buildSelectionConfig({
scope: { kind: "all-projects", projectCodes: ["A-1", "B-2"] },
includeAll: false,
landlordPropertyIds: ["LP1"],
portfolioId,
});
expect(r).toEqual({
ok: true,
config: { landlord_property_ids: ["LP1"], portfolio_id: 814 },
});
});
it("de-duplicates ids and codes", () => {
expect(
buildSelectionConfig({
scope: { kind: "all-projects", projectCodes: ["A-1", "A-1"] },
includeAll: true,
landlordPropertyIds: [],
portfolioId,
}),
).toEqual({ ok: true, config: { project_codes: ["A-1"], portfolio_id: 814 } });
expect(
buildSelectionConfig({
scope: { kind: "project", projectCode: "A-1" },
includeAll: false,
landlordPropertyIds: ["LP1", "LP1", " LP1 "],
portfolioId,
}),
).toEqual({
ok: true,
config: { landlord_property_ids: ["LP1"], portfolio_id: 814 },
});
});
it("rejects an empty subset", () => {
const r = buildSelectionConfig({
scope: { kind: "project", projectCode: "A-1" },
includeAll: false,
landlordPropertyIds: [],
portfolioId,
});
expect(r.ok).toBe(false);
});
it("rejects include-all with no usable codes", () => {
const r = buildSelectionConfig({
scope: { kind: "all-projects", projectCodes: ["", " "] },
includeAll: true,
landlordPropertyIds: [],
portfolioId,
});
expect(r.ok).toBe(false);
});
it("rejects a subset over the 500 cap with the detail message", () => {
const ids = Array.from({ length: 501 }, (_, i) => `LP${i}`);
const r = buildSelectionConfig({
scope: { kind: "project", projectCode: "A-1" },
includeAll: false,
landlordPropertyIds: ids,
portfolioId,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain(`over the ${MAX_BULK_DOWNLOAD_PROPERTIES} limit`);
expect(r.error).toContain("501 properties");
}
});
});
describe("buildBulkDownloadRecord", () => {
it("writes the app-owned task marker and the config as JSON inputs", () => {
const record = buildBulkDownloadRecord({
portfolioId: "814",
config: { project_codes: ["A-1"], portfolio_id: 814 },
});
expect(record.taskSource).toBe("app:bulk_document_download");
expect(record.source).toBe("portfolio_id");
expect(record.sourceId).toBe("814");
expect(record.status).toBe("waiting");
expect(JSON.parse(record.inputs)).toEqual({
project_codes: ["A-1"],
portfolio_id: 814,
});
});
});
describe("parseDelivery", () => {
it("reads a finished delivery", () => {
const d = parseDelivery(
JSON.stringify({
presigned_url: "https://x",
package_s3_key: "k",
included: 137,
skipped: [{ landlord_property_id: "LP9", s3_key: "k", reason: "unreadable" }],
}),
);
expect(d).toEqual({
presignedUrl: "https://x",
packageS3Key: "k",
included: 137,
skipped: [{ landlord_property_id: "LP9", s3_key: "k", reason: "unreadable" }],
});
});
it("tolerates link-key naming variants", () => {
expect(parseDelivery(JSON.stringify({ presignedUrl: "https://a" }))?.presignedUrl).toBe(
"https://a",
);
expect(parseDelivery(JSON.stringify({ download_url: "https://b" }))?.presignedUrl).toBe(
"https://b",
);
expect(parseDelivery(JSON.stringify({ url: "https://c", s3_key: "k" }))).toMatchObject({
presignedUrl: "https://c",
packageS3Key: "k",
});
});
it("returns null for in-flight or malformed outputs", () => {
expect(parseDelivery(null)).toBeNull();
expect(parseDelivery("")).toBeNull();
expect(parseDelivery("{not json")).toBeNull();
expect(parseDelivery(JSON.stringify({ included: 0 }))).toBeNull();
});
});
describe("bulkDownloadRefetchInterval", () => {
it("polls while preparing, stops once resolved", () => {
expect(bulkDownloadRefetchInterval(undefined)).toBe(8_000);
expect(bulkDownloadRefetchInterval({ state: "preparing", delivery: null })).toBe(8_000);
expect(
bulkDownloadRefetchInterval({
state: "ready",
delivery: { presignedUrl: "x", packageS3Key: null, included: 1, skipped: [] },
}),
).toBe(false);
expect(bulkDownloadRefetchInterval({ state: "failed", delivery: null })).toBe(false);
});
});

View file

@ -0,0 +1,194 @@
/**
* Bulk-document-download domain model pure logic for the Documents bulk
* download. Mirrors the Modelling-run trigger convention (ADR-0008): one
* app-authored `tasks` row whose `inputs` carries the selection config, then
* the backend is handed only the task id.
*
* The backend resolves the union of (all properties in the given project
* codes) and (the hand-picked landlord property ids), de-duplicated. Rule of
* thumb: "include all" sends `project_codes` (so properties without a
* landlord_property_id are still swept in); a ticked subset sends
* `landlord_property_ids`. "All projects" enumerates the portfolio's project
* codes there is no server-side "whole portfolio" option by design.
*/
/** Backend cap; over this the trigger is rejected with a 400. */
export const MAX_BULK_DOWNLOAD_PROPERTIES = 500;
/**
* The selection config, exactly as it is stored (JSON) in `tasks.inputs` and
* read back by the worker. snake_case because the backend parses it directly
* do not add app-provenance keys here (the recipient email is resolved from
* the JWT at dispatch, not carried in the config).
*/
export interface BulkDownloadConfig {
/** HubSpot project codes (hubspot_deal_data.project_code). */
project_codes?: string[];
/** The hand-pick key (property.landlord_property_id). */
landlord_property_ids?: string[];
/** Names the ZIP only. */
portfolio_id?: number;
}
/** Which projects the download is scoped to. */
export type BulkDownloadScope =
| { kind: "project"; projectCode: string }
| { kind: "all-projects"; projectCodes: string[] };
/** The UI's selection intent, resolved into a config server-side. */
export interface BulkDownloadSelectionInput {
scope: BulkDownloadScope;
/** true → "include all" (send project_codes); false → send the ticked ids. */
includeAll: boolean;
/** The ticked landlord_property_ids — used only when includeAll is false. */
landlordPropertyIds: string[];
portfolioId: number;
}
export type BuildConfigResult =
| { ok: true; config: BulkDownloadConfig }
| { ok: false; error: string };
/**
* Map the UI selection onto the stored config:
* include all { project_codes, portfolio_id }
* ticked subset { landlord_property_ids, portfolio_id }
* At least one of project_codes / landlord_property_ids must be non-empty.
*/
export function buildSelectionConfig(
input: BulkDownloadSelectionInput,
): BuildConfigResult {
const portfolio_id = input.portfolioId;
if (input.includeAll) {
const rawCodes =
input.scope.kind === "project"
? [input.scope.projectCode]
: input.scope.projectCodes;
const project_codes = [
...new Set(rawCodes.map((c) => c.trim()).filter((c) => c.length > 0)),
];
if (project_codes.length === 0) {
return { ok: false, error: "Pick at least one project to download." };
}
return { ok: true, config: { project_codes, portfolio_id } };
}
const landlord_property_ids = [
...new Set(input.landlordPropertyIds.map((i) => i.trim()).filter(Boolean)),
];
if (landlord_property_ids.length === 0) {
return {
ok: false,
error: "Tick at least one property, or choose “include all”.",
};
}
if (landlord_property_ids.length > MAX_BULK_DOWNLOAD_PROPERTIES) {
return {
ok: false,
error: `The selection has ${landlord_property_ids.length} properties, over the ${MAX_BULK_DOWNLOAD_PROPERTIES} limit — narrow your selection.`,
};
}
return { ok: true, config: { landlord_property_ids, portfolio_id } };
}
/**
* The marker the worker keys off, in `task_source` (ADR-0011). It receives only
* the task id, re-reads this row, and guards on this value before assembling.
*/
export const BULK_DOWNLOAD_TASK_SOURCE = "app:bulk_document_download";
/**
* The row a bulk download persists: an app-authored task whose `inputs` carries
* the selection config. Single marker in `task_source` (ADR-0011) `service`
* is left null; `source`/`source_id` scope it to the Portfolio (and attach it
* to the portfolio's logs).
*/
export function buildBulkDownloadRecord(args: {
portfolioId: string;
config: BulkDownloadConfig;
}): {
taskSource: typeof BULK_DOWNLOAD_TASK_SOURCE;
source: "portfolio_id";
sourceId: string;
status: "waiting";
inputs: string;
} {
return {
taskSource: BULK_DOWNLOAD_TASK_SOURCE,
source: "portfolio_id",
sourceId: args.portfolioId,
status: "waiting",
inputs: JSON.stringify(args.config),
};
}
// ── Delivery / status (optional in-app polling) ──────────────────────────────
export interface BulkDownloadSkipped {
landlord_property_id: string;
s3_key: string;
reason: string;
}
export interface BulkDownloadDelivery {
/** ZIP link, valid 60 minutes. */
presignedUrl: string;
packageS3Key: string | null;
/** Documents included. */
included: number;
skipped: BulkDownloadSkipped[];
}
export type BulkDownloadState = "preparing" | "ready" | "failed";
export interface BulkDownloadStatus {
state: BulkDownloadState;
delivery: BulkDownloadDelivery | null;
}
/**
* Read the worker's `sub_task.outputs` back into the app's shape. Null for
* anything that isn't a finished delivery (in-flight, malformed) polling must
* never throw on an unfinished row.
*/
export function parseDelivery(outputs: string | null): BulkDownloadDelivery | null {
if (!outputs) return null;
try {
const raw = JSON.parse(outputs) as Record<string, unknown>;
// Tolerate link-key naming variants so a completed job surfaces its link.
const url = [raw.presigned_url, raw.presignedUrl, raw.download_url, raw.url].find(
(v): v is string => typeof v === "string" && v.length > 0,
);
if (!url) return null;
return {
presignedUrl: url,
packageS3Key:
typeof raw.package_s3_key === "string"
? raw.package_s3_key
: typeof raw.s3_key === "string"
? raw.s3_key
: null,
included: typeof raw.included === "number" ? raw.included : 0,
skipped: Array.isArray(raw.skipped)
? (raw.skipped as BulkDownloadSkipped[])
: [],
};
} catch {
return null;
}
}
/**
* Poll cadence: keep checking while the ZIP is being built, stop once it is
* ready or failed. Shape matches TanStack v4's refetchInterval callback, which
* receives the query data first.
*/
export function bulkDownloadRefetchInterval(
data: BulkDownloadStatus | undefined,
): number | false {
// Package assembly takes minutes, so a gentle cadence — email is the real
// delivery channel; this poll is just an in-session convenience.
if (!data) return 8_000;
return data.state === "preparing" ? 8_000 : false;
}

View file

@ -0,0 +1,171 @@
import { db } from "@/app/db/db";
import { tasks } from "@/app/db/schema/tasks/tasks";
import { subTasks } from "@/app/db/schema/tasks/subtask";
import { and, desc, eq } from "drizzle-orm";
import {
BULK_DOWNLOAD_TASK_SOURCE,
BulkDownloadSelectionInput,
BulkDownloadStatus,
buildBulkDownloadRecord,
buildSelectionConfig,
parseDelivery,
} from "./model";
// The document-service entry point on the backend. It resolves the selection
// from the task's `inputs`, assembles the ZIP, emails the link (recipient from
// the JWT), and writes the result to the task's sub_task.outputs.
const BULK_DOWNLOAD_ENDPOINT = "/v1/documents/bulk-download";
type Dispatch = { ok: true } | { ok: false; status: number; message: string };
function isFailed(status: string | null): boolean {
return status ? ["failed", "failure", "error"].includes(status.toLowerCase()) : false;
}
function defaultMessageForStatus(status: number): string {
if (status === 409) return "A download for this selection has already been started.";
if (status === 400) return "That selection cant be downloaded — please adjust it and try again.";
return "Couldnt start the download — please try again.";
}
/** Pull FastAPI's error detail so a 400 (e.g. over the 500 cap) reaches the user verbatim. */
async function extractDetail(res: Response): Promise<string> {
try {
const body = (await res.json()) as Record<string, unknown>;
const d = body.detail ?? body.message ?? body.error;
if (typeof d === "string" && d.trim()) return d;
if (Array.isArray(d) && typeof (d[0] as { msg?: unknown })?.msg === "string") {
return (d[0] as { msg: string }).msg;
}
} catch {
// not JSON — fall through to the status default
}
return defaultMessageForStatus(res.status);
}
async function dispatchBulkDownload(
taskId: string,
sessionToken: string | undefined,
): Promise<Dispatch> {
const url = process.env.FASTAPI_API_URL;
const key = process.env.FASTAPI_API_KEY;
if (!url || !key) {
console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set");
return { ok: false, status: 500, message: "Server misconfiguration" };
}
try {
const res = await fetch(`${url}${BULK_DOWNLOAD_ENDPOINT}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": key,
// The recipient email is resolved from this JWT; no email in the body.
Authorization: `Bearer ${sessionToken}`,
},
body: JSON.stringify({ task_id: taskId }),
});
if (res.ok) return { ok: true };
return { ok: false, status: res.status, message: await extractDetail(res) };
} catch (err) {
console.error(`Failed to reach FastAPI ${BULK_DOWNLOAD_ENDPOINT}:`, err);
return {
ok: false,
status: 502,
message: "Couldnt reach the document service — please try again.",
};
}
}
export type BulkDownloadTriggerOutcome =
| { kind: "ok"; taskId: string }
| { kind: "invalid_selection"; message: string }
| { kind: "backend_error"; status: number; message: string };
/**
* Record + dispatch a bulk document download (Modelling-run convention,
* ADR-0008): one app-authored task whose `inputs` carries the selection
* config, then the document service is handed only the task id so a large
* selection never travels in the HTTP body. A rejected dispatch marks the task
* failed so it never lingers as a ghost.
*/
export async function triggerBulkDocumentDownload(args: {
selection: BulkDownloadSelectionInput;
sessionToken: string | undefined;
}): Promise<BulkDownloadTriggerOutcome> {
const built = buildSelectionConfig(args.selection);
if (!built.ok) return { kind: "invalid_selection", message: built.error };
const record = buildBulkDownloadRecord({
portfolioId: String(args.selection.portfolioId),
config: built.config,
});
const now = new Date();
const [task] = await db
.insert(tasks)
.values({ ...record, jobStarted: now })
.returning();
const dispatch = await dispatchBulkDownload(task.id, args.sessionToken);
if (!dispatch.ok) {
await db.update(tasks).set({ status: "failed" }).where(eq(tasks.id, task.id));
return { kind: "backend_error", status: dispatch.status, message: dispatch.message };
}
await db.update(tasks).set({ status: "in progress" }).where(eq(tasks.id, task.id));
return { kind: "ok", taskId: task.id };
}
function isComplete(status: string | null): boolean {
return status
? ["completed", "complete", "success", "succeeded", "done"].includes(
status.toLowerCase(),
)
: false;
}
/**
* Status of a triggered download for in-app polling. The worker writes the
* presigned URL to the sub_task's `outputs` on success; a selection that yields
* zero documents fails the sub_task (no empty ZIP). Terminal state is taken from
* completion/failure *status* (task or sub_task) as well as the parsed outputs
* so a finished job never hangs on "preparing" just because the link couldn't be
* parsed. Portfolio-scoped so a task id can't be read against another portfolio.
*/
export async function getBulkDownloadStatus(
portfolioId: string,
taskId: string,
): Promise<BulkDownloadStatus | null> {
const rows = await db
.select({
taskStatus: tasks.status,
taskDone: tasks.jobCompleted,
subStatus: subTasks.status,
subDone: subTasks.jobCompleted,
outputs: subTasks.outputs,
})
.from(tasks)
.leftJoin(subTasks, eq(subTasks.taskId, tasks.id))
.where(
and(
eq(tasks.id, taskId),
eq(tasks.taskSource, BULK_DOWNLOAD_TASK_SOURCE),
eq(tasks.sourceId, portfolioId),
),
)
.orderBy(desc(subTasks.updatedAt));
if (rows.length === 0) return null;
const delivery = rows.map((r) => parseDelivery(r.outputs)).find(Boolean) ?? null;
const failed =
isFailed(rows[0].taskStatus) || rows.some((r) => isFailed(r.subStatus));
// A job whose sub_task (or task) reports completion is terminal even if the
// outputs couldn't be parsed into a link — the link is still emailed.
const complete =
isComplete(rows[0].taskStatus) ||
!!rows[0].taskDone ||
rows.some((r) => isComplete(r.subStatus) || !!r.subDone);
const state = delivery || complete ? "ready" : failed ? "failed" : "preparing";
return { state, delivery };
}

View file

@ -0,0 +1,246 @@
import { eq } from "drizzle-orm";
import * as XLSX from "xlsx";
import { db } from "@/app/db/db";
import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads";
import { bulkUploadUprnCorrections } from "@/app/db/schema/bulk_upload_uprn_corrections";
import { createRetrofitDataS3Client } from "@/app/utils/s3";
import { SOURCE_ROW_ID_COLUMN } from "./s3Keys";
// Columns address2uprn writes onto each combiner row (ADR-0057). `status` is the
// per-row outcome; a withheld/ambiguous/no-match row has an empty `uprn`.
const UPRN_COL = "address2uprn_uprn";
const STATUS_COL = "address2uprn_status";
const LEXISCORE_COL = "address2uprn_lexiscore";
// A combiner row is confidently matched only when address2uprn kept a UPRN AND
// did not flag it. Everything else — unmatched, ambiguous_duplicate,
// invalid_postcode, error, or a legacy row with no status but no UPRN — needs
// the user to resolve it on the Addresses tab before finalise.
function isConfidentlyMatched(row: Record<string, string>): boolean {
const uprn = (row[UPRN_COL] ?? "").trim();
const status = (row[STATUS_COL] ?? "").trim();
if (!uprn) return false;
return status === "" || status === "matched";
}
export interface FlaggedAddressRow {
sourceRowId: string;
address: string;
postcode: string;
status: string;
lexiscore: number | null;
// The candidate address2uprn found but did not trust (null when none).
suggestedUprn: string | null;
// The user's confirmation (from bulk_upload_uprn_corrections), if any.
resolvedUprn: string | null;
resolvedAddress: string | null;
markedNoUprn: boolean;
// Resolved = user picked a UPRN OR asserted there is none.
resolved: boolean;
}
function parseS3Uri(uri: string): { bucket: string; key: string } {
const url = new URL(uri); // s3://bucket/key...
const key = url.pathname.startsWith("/") ? url.pathname.slice(1) : url.pathname;
if (!url.hostname || !key) throw new Error(`Malformed S3 URI: ${uri}`);
return { bucket: url.hostname, key };
}
async function readCombinerRows(s3Uri: string): Promise<Record<string, string>[]> {
const { bucket, key } = parseS3Uri(s3Uri);
const s3 = createRetrofitDataS3Client();
const result = await s3.getObject({ Bucket: bucket, Key: key }).promise();
const body = result.Body?.toString("utf-8");
if (!body) return [];
const wb = XLSX.read(body, { type: "string" });
const sheet = wb.Sheets[wb.SheetNames[0]];
// Every cell as a string so a numeric UPRN never arrives as a float.
return XLSX.utils.sheet_to_json<Record<string, string>>(sheet, { defval: "", raw: false });
}
function buildAddress(row: Record<string, string>): string {
return ["Address 1", "Address 2", "Address 3"]
.map((c) => (row[c] ?? "").trim())
.filter(Boolean)
.join(", ");
}
/**
* The combiner rows that need user confirmation, joined with any saved
* correction. Reads the combiner CSV from `combinedOutputS3Uri` (ADR-0057).
* Returns [] until the combiner has run.
*/
export async function getFlaggedAddresses(
uploadId: string,
): Promise<FlaggedAddressRow[]> {
const [row] = await db
.select({ combinedOutputS3Uri: bulkAddressUploads.combinedOutputS3Uri })
.from(bulkAddressUploads)
.where(eq(bulkAddressUploads.id, uploadId));
if (!row?.combinedOutputS3Uri) return [];
const [rows, corrections] = await Promise.all([
readCombinerRows(row.combinedOutputS3Uri),
db
.select()
.from(bulkUploadUprnCorrections)
.where(eq(bulkUploadUprnCorrections.uploadId, uploadId)),
]);
const correctionByRow = new Map(corrections.map((c) => [c.sourceRowId, c]));
return rows
.filter((r) => !isConfidentlyMatched(r))
.map((r) => {
const sourceRowId = (r[SOURCE_ROW_ID_COLUMN] ?? "").trim();
const uprn = (r[UPRN_COL] ?? "").trim();
const rawScore = (r[LEXISCORE_COL] ?? "").trim();
const lexiscore = rawScore === "" ? null : Number(rawScore);
const c = correctionByRow.get(sourceRowId);
const resolvedUprn = c?.uprn ?? null;
const markedNoUprn = c?.markedNoUprn ?? false;
return {
sourceRowId,
address: buildAddress(r),
postcode: (r["postcode"] ?? "").trim(),
status: (r[STATUS_COL] ?? (uprn ? "matched" : "unmatched")).trim(),
lexiscore: lexiscore !== null && Number.isFinite(lexiscore) ? lexiscore : null,
suggestedUprn: uprn || null,
resolvedUprn,
resolvedAddress: c?.address ?? null,
markedNoUprn,
resolved: resolvedUprn !== null || markedNoUprn,
};
});
}
/** Count of flagged rows the user has not yet resolved — gates Finalise. */
export async function countUnresolvedFlaggedAddresses(uploadId: string): Promise<number> {
const flagged = await getFlaggedAddresses(uploadId);
return flagged.filter((f) => !f.resolved).length;
}
const ADDRESS_COL = "address2uprn_address";
const OVERLAY_STATUS_CONFIRMED = "user_confirmed";
const OVERLAY_STATUS_NO_UPRN = "user_no_uprn";
/**
* Overlay the user's saved corrections onto the combiner CSV and write the
* result to a sibling `…​.confirmed.csv`, returning its S3 URI (ADR-0057). The
* finaliser reads this so a confirmed UPRN drives both the identity insert and
* property_overrides in one pass. No corrections the original URI is returned
* unchanged (no rewrite). A `markedNoUprn` row keeps an empty UPRN (accepted
* terminal state the finaliser leaves it as a no-UPRN property).
*/
export async function buildConfirmedCombinerUri(
uploadId: string,
combinedOutputS3Uri: string,
): Promise<string> {
const corrections = await db
.select()
.from(bulkUploadUprnCorrections)
.where(eq(bulkUploadUprnCorrections.uploadId, uploadId));
if (corrections.length === 0) return combinedOutputS3Uri;
const byRow = new Map(corrections.map((c) => [c.sourceRowId, c]));
const { bucket, key } = parseS3Uri(combinedOutputS3Uri);
const s3 = createRetrofitDataS3Client();
const result = await s3.getObject({ Bucket: bucket, Key: key }).promise();
const body = result.Body?.toString("utf-8");
if (!body) return combinedOutputS3Uri;
const wb = XLSX.read(body, { type: "string" });
const sheet = wb.Sheets[wb.SheetNames[0]];
// Array-of-arrays so every column + its order is preserved exactly; we patch
// only the address2uprn cells of corrected rows.
const aoa = XLSX.utils.sheet_to_json<string[]>(sheet, {
header: 1,
defval: "",
raw: false,
blankrows: false,
});
const headers = (aoa[0] ?? []).map((h) => String(h));
const iRow = headers.indexOf(SOURCE_ROW_ID_COLUMN);
const iUprn = headers.indexOf(UPRN_COL);
const iStatus = headers.indexOf(STATUS_COL);
const iAddr = headers.indexOf(ADDRESS_COL);
if (iRow < 0 || iUprn < 0) return combinedOutputS3Uri; // not an address2uprn CSV
for (let r = 1; r < aoa.length; r++) {
const line = aoa[r];
const srid = String(line[iRow] ?? "").trim();
const c = byRow.get(srid);
if (!c) continue;
if (c.markedNoUprn) {
line[iUprn] = "";
if (iStatus >= 0) line[iStatus] = OVERLAY_STATUS_NO_UPRN;
} else {
line[iUprn] = c.uprn ?? "";
if (iStatus >= 0) line[iStatus] = OVERLAY_STATUS_CONFIRMED;
if (iAddr >= 0 && c.address) line[iAddr] = c.address;
}
}
const csv = XLSX.utils.sheet_to_csv(XLSX.utils.aoa_to_sheet(aoa));
const confirmedKey = `${key.replace(/\.csv$/i, "")}.confirmed.csv`;
await s3
.putObject({ Bucket: bucket, Key: confirmedKey, Body: csv, ContentType: "text/csv" })
.promise();
return `s3://${bucket}/${confirmedKey}`;
}
export type UprnCorrectionInput =
| {
sourceRowId: string;
uprn: string;
address: string;
postcode: string;
markedNoUprn?: false;
}
| { sourceRowId: string; markedNoUprn: true };
/** Upsert one combiner row's confirmation (assign a UPRN, or mark no-UPRN). */
export async function upsertUprnCorrection(
uploadId: string,
input: UprnCorrectionInput,
userId?: string,
): Promise<void> {
const values = input.markedNoUprn
? {
uploadId,
sourceRowId: input.sourceRowId,
uprn: null,
address: null,
postcode: null,
markedNoUprn: true as const,
userId: userId ?? null,
}
: {
uploadId,
sourceRowId: input.sourceRowId,
uprn: input.uprn,
address: input.address,
postcode: input.postcode,
markedNoUprn: false as const,
userId: userId ?? null,
};
await db
.insert(bulkUploadUprnCorrections)
.values(values)
.onConflictDoUpdate({
target: [
bulkUploadUprnCorrections.uploadId,
bulkUploadUprnCorrections.sourceRowId,
],
set: {
uprn: values.uprn,
address: values.address,
postcode: values.postcode,
markedNoUprn: values.markedNoUprn,
userId: values.userId,
updatedAt: new Date(),
},
});
}

View file

@ -150,6 +150,74 @@ export function useSampleClassifications(
});
}
// One combiner row that address2uprn could not confidently match (ADR-0057),
// plus any saved correction. Mirrors the server FlaggedAddressRow.
export interface FlaggedAddress {
sourceRowId: string;
address: string;
postcode: string;
status: string;
lexiscore: number | null;
suggestedUprn: string | null;
resolvedUprn: string | null;
resolvedAddress: string | null;
markedNoUprn: boolean;
resolved: boolean;
}
export interface AddressMatchesView {
flagged: FlaggedAddress[];
unresolved: number;
}
export function useAddressMatches(
portfolioId: string,
uploadId: string,
enabled: boolean,
) {
return useQuery<AddressMatchesView, Error>({
queryKey: [...bulkUploadKeys.progress(uploadId), "address-matches"],
enabled,
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/address-matches`,
);
if (!res.ok) throw await parseError(res, "Failed to load address matches.");
const body = await res.json();
return {
flagged: (body.flagged ?? []) as FlaggedAddress[],
unresolved: Number(body.unresolved ?? 0),
};
},
});
}
export type SaveCorrectionInput =
| { sourceRowId: string; uprn: string; address: string; postcode: string }
| { sourceRowId: string; markedNoUprn: true };
export function useSaveUprnCorrection(portfolioId: string, uploadId: string) {
const queryClient = useQueryClient();
return useMutation<void, Error, SaveCorrectionInput>({
mutationFn: async (input) => {
const res = await fetch(
`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/address-corrections`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
},
);
if (!res.ok) throw await parseError(res, "Failed to save address.");
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [...bulkUploadKeys.progress(uploadId), "address-matches"],
});
},
});
}
export function useConfirmMultiEntryOrdering(portfolioId: string, uploadId: string) {
const queryClient = useQueryClient();
return useMutation<BulkUpload, Error, { permutations: Record<string, number[]> }>({

View file

@ -16,6 +16,10 @@ import { retrofitDataS3Bucket } from "@/app/utils/s3";
import { SUBTASK_SERVICE } from "./types";
import type { MultiEntrySummary } from "./multiEntry";
import { isPermutation } from "./multiEntry";
import {
buildConfirmedCombinerUri,
countUnresolvedFlaggedAddresses,
} from "./addressMatches";
const REMAP_ALLOWED: ReadonlySet<BulkUploadStatus> = new Set([
"ready_for_processing",
@ -603,6 +607,7 @@ export type DispatchFinaliserOutcome =
| { kind: "not_yet_combined" }
| { kind: "wrong_state"; current: string }
| { kind: "missing_task" }
| { kind: "unresolved_addresses"; count: number }
| { kind: "trigger_failed"; status: number; message: string };
// Dispatch the async bulk_upload_finaliser (ADR-0005). Replaces the old
@ -631,6 +636,14 @@ export async function dispatchFinaliser(args: {
const upload = guarded.upload;
if (!upload.taskId) return { kind: "missing_task" };
// ADR-0057: every combiner row address2uprn could not confidently match must
// be resolved on the Addresses tab (a chosen UPRN or an explicit no-UPRN)
// before finalise — otherwise a withheld row would land as an unintended
// no-UPRN property. Gate before the CAS claim so a blocked upload stays in
// awaiting_review.
const unresolved = await countUnresolvedFlaggedAddresses(args.uploadId);
if (unresolved > 0) return { kind: "unresolved_addresses", count: unresolved };
// CAS: atomically claim the dispatch. Only the request that flips
// awaiting_review → finalising proceeds; a concurrent one updates 0 rows.
const claimed = await db
@ -674,10 +687,18 @@ export async function dispatchFinaliser(args: {
? `s3://${retrofitDataS3Bucket()}/${classifierCsvKey(upload.portfolioId, args.uploadId)}`
: null;
// Overlay the user's confirmed UPRNs onto the combiner CSV and point the
// finaliser at the corrected copy (ADR-0057). No corrections ⇒ the original
// URI is returned unchanged.
const confirmedS3Uri = await buildConfirmedCombinerUri(
args.uploadId,
upload.combinedOutputS3Uri!,
);
const payload = {
task_id: upload.taskId,
sub_task_id: subTask.id,
s3_uri: upload.combinedOutputS3Uri,
s3_uri: confirmedS3Uri,
portfolio_id: Number(upload.portfolioId),
bulk_upload_id: args.uploadId,
classifier_s3_uri: classifierS3Uri,

View file

@ -46,6 +46,24 @@ describe("normaliseRunFilters", () => {
});
});
describe("normaliseRunFilters — tags", () => {
it("normalises tag ids to a deduped, ascending number list", () => {
expect(normaliseRunFilters({ tagIds: [3, "1", 3, 2] })).toEqual({
ok: true,
filters: { tagIds: [1, 2, 3] },
});
});
it("rejects a non-numeric or non-positive tag id", () => {
expect(normaliseRunFilters({ tagIds: [1, "x"] }).ok).toBe(false);
expect(normaliseRunFilters({ tagIds: [0] }).ok).toBe(false);
});
it("treats an absent/empty tag selection as unconstrained (no key)", () => {
expect(normaliseRunFilters({ tagIds: [] })).toEqual({ ok: true, filters: {} });
});
});
describe("buildRunRecord", () => {
it("produces the app-authored task row carrying the run config as its inputs (ADR-0008)", () => {
const record = buildRunRecord({
@ -76,6 +94,18 @@ describe("buildRunRecord", () => {
filters: { postcodes: ["B93 8SU"], property_types: ["House"] },
});
});
it("carries tag ids into the task inputs, the dispatch payload, and back (ADR-0013)", () => {
const { task, dispatchPayload } = buildRunRecord({
portfolioId: "5",
scenarioIds: ["2"],
filters: { tagIds: [7, 3] },
previewedPropertyCount: 10,
triggeredBy: "u@x.io",
});
expect(dispatchPayload.filters).toEqual({ tag_ids: [7, 3] });
expect(parseRunConfig(task.inputs)?.filters.tagIds).toEqual([7, 3]);
});
});
describe("parseRunConfig", () => {
@ -113,6 +143,11 @@ describe("selectionSummary", () => {
}),
).toBe("Postcodes: B93 8SU, M20 4TF · Type: House · Built form: Detached, Unknown");
});
it("summarises a tag selection as a count (names aren't in the pure filters)", () => {
expect(selectionSummary({ tagIds: [7, 3] })).toBe("Tags: 2");
expect(selectionSummary({ tagIds: [7] })).toBe("Tags: 1");
});
});
describe("deriveRunStatus", () => {

View file

@ -21,6 +21,8 @@ export interface RunFilters {
postcodes?: string[];
propertyTypes?: string[];
builtForms?: string[];
/** Portfolio Tag ids (any-of); resolved against property_tag (ADR-0013). */
tagIds?: number[];
}
export type NormaliseFiltersResult =
@ -34,6 +36,7 @@ export function normaliseRunFilters(input: {
postcodes?: string[];
propertyTypes?: string[];
builtForms?: string[];
tagIds?: (number | string)[];
}): NormaliseFiltersResult {
const filters: RunFilters = {};
if (input.postcodes?.length) {
@ -65,6 +68,20 @@ export function normaliseRunFilters(input: {
}
filters[key] = [...new Set(values)];
}
// Tags are dynamic (portfolio-specific), so there's no vocabulary to check
// against — just coerce to positive integer ids, dedupe and sort. Ids that
// don't belong to the portfolio simply match nothing at resolution time.
if (input.tagIds?.length) {
const ids = new Set<number>();
for (const raw of input.tagIds) {
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) {
return { ok: false, error: `Invalid tag '${raw}'` };
}
ids.add(n);
}
filters.tagIds = [...ids].sort((a, b) => a - b);
}
return { ok: true, filters };
}
@ -91,6 +108,7 @@ export function parseRunConfig(inputs: string | null): RunConfigView | null {
if (raw.filters?.postcodes?.length) filters.postcodes = raw.filters.postcodes;
if (raw.filters?.property_types?.length) filters.propertyTypes = raw.filters.property_types;
if (raw.filters?.built_forms?.length) filters.builtForms = raw.filters.built_forms;
if (raw.filters?.tag_ids?.length) filters.tagIds = raw.filters.tag_ids;
return {
scenarioIds: raw.scenario_ids.map(String),
filters,
@ -167,6 +185,9 @@ export function selectionSummary(filters: RunFilters): string {
if (filters.postcodes?.length) bits.push(`Postcodes: ${filters.postcodes.join(", ")}`);
if (filters.propertyTypes?.length) bits.push(`Type: ${filters.propertyTypes.join(", ")}`);
if (filters.builtForms?.length) bits.push(`Built form: ${filters.builtForms.join(", ")}`);
// Tag names aren't carried in the pure filters (only ids), so summarise as a
// count; the run-history UI resolves names separately when it needs them.
if (filters.tagIds?.length) bits.push(`Tags: ${filters.tagIds.length}`);
return bits.length ? bits.join(" · ") : "All properties";
}
@ -186,6 +207,7 @@ export interface RunConfigInputs {
postcodes?: string[];
property_types?: string[];
built_forms?: string[];
tag_ids?: number[];
};
previewed_property_count: number;
triggered_by: string;
@ -212,6 +234,7 @@ export function buildRunRecord(req: RunRequest): {
if (req.filters.postcodes) filters.postcodes = req.filters.postcodes;
if (req.filters.propertyTypes) filters.property_types = req.filters.propertyTypes;
if (req.filters.builtForms) filters.built_forms = req.filters.builtForms;
if (req.filters.tagIds) filters.tag_ids = req.filters.tagIds;
const configInputs: RunConfigInputs = {
portfolio_id: Number(req.portfolioId),
scenario_ids: req.scenarioIds.map(Number),

View file

@ -11,7 +11,12 @@ import {
parseRunConfig,
selectionSummary,
} from "./model";
import { builtFormTypeSql, propertyTypeSql } from "@/lib/services/epcSources";
import {
builtFormOverrideJoin,
propertyTypeOverrideJoin,
resolvedBuiltFormSql,
resolvedPropertyTypeSql,
} from "@/lib/services/epcSources";
// Distributor entry point on the Model backend. It fans the run out to
// workers, attaching execution sub-tasks to the task_id we pass (ADR-0008).
@ -141,20 +146,27 @@ export async function previewModellingRun(args: {
if (args.filters.builtForms) {
conditions.push(sql`resolved.bform IN (${inList(args.filters.builtForms)})`);
}
if (args.filters.tagIds) {
// Tags resolve by direct membership (any-of), not the override→EPC rule —
// ADR-0013. The distributor mirrors this join against the shared DB.
conditions.push(
sql`resolved.id IN (SELECT pt.property_id FROM property_tag pt WHERE pt.tag_id IN (${inList(args.filters.tagIds)}))`,
);
}
const result = await db.execute<{
matched: number;
per_scenario: { scenario_id: string; n: number }[] | null;
}>(sql`
WITH resolved AS (
-- Resolution rule shared with the portfolio list via epcSources fragments
-- so the preview and the list can never drift (ADR-0008, ADR-0012).
SELECT p.id, p.postcode,
COALESCE(pot.override_value, ${propertyTypeSql}, 'Unknown') AS ptype,
COALESCE(pobf.override_value, ${builtFormTypeSql}, 'Unknown') AS bform
${resolvedPropertyTypeSql} AS ptype,
${resolvedBuiltFormSql} AS bform
FROM property p
LEFT JOIN property_overrides pot ON pot.property_id = p.id
AND pot.override_component = 'property_type' AND pot.building_part = 0
LEFT JOIN property_overrides pobf ON pobf.property_id = p.id
AND pobf.override_component = 'built_form_type' AND pobf.building_part = 0
${propertyTypeOverrideJoin}
${builtFormOverrideJoin}
LEFT JOIN epc_property epl ON epl.property_id = p.id AND epl.source = 'lodged'
LEFT JOIN epc_property epp ON epp.property_id = p.id AND epp.source = 'predicted'
WHERE p.portfolio_id = ${pid} AND p.marked_for_deletion = false

View file

@ -20,6 +20,13 @@ describe("classifyOsCode", () => {
expect(classifyOsCode("RD07")).toEqual({ selectable: true, propertyType: "House", builtForm: null });
});
it("maps the RH (HMO) family to House, like RD07", () => {
// e.g. UPRN 100031252876, 7 Leyton Avenue — RH03 must not read as non-residential.
expect(classifyOsCode("RH01")).toEqual({ selectable: true, propertyType: "House", builtForm: null });
expect(classifyOsCode("RH02")).toEqual({ selectable: true, propertyType: "House", builtForm: null });
expect(classifyOsCode("RH03")).toEqual({ selectable: true, propertyType: "House", builtForm: null });
});
it("keeps other residential codes selectable but writes no facts — never Unknown", () => {
for (const code of ["RD", "RD01", "RD08"]) {
expect(classifyOsCode(code)).toEqual({ selectable: true, propertyType: null, builtForm: null });
@ -63,6 +70,21 @@ describe("toAddressCandidates", () => {
expect(out[1]).toMatchObject({ uprn: "101", selectable: false });
});
it("classifies an RH03 HMO as a selectable House, not Non-residential", () => {
// Regression: 7 Leyton Avenue (UPRN 100031252876) came back RH03 ("HMO Not
// Further Divided") on a street of RD02/RD03 houses and rendered as
// "Non-residential", so it couldn't be added.
const hmo = [
{ DPA: { UPRN: "100031252876", ADDRESS: "7, LEYTON AVENUE, SUTTON-IN-ASHFIELD, NG17 3BD",
POSTCODE: "NG17 3BD", CLASSIFICATION_CODE: "RH03", LAT: 53.1352313, LNG: -1.2522665 } },
];
const out = toAddressCandidates(hmo as never, "NG17 3BD");
expect(out[0]).toMatchObject({
uprn: "100031252876", address: "7, Leyton Avenue, Sutton-In-Ashfield",
classificationCode: "RH03", selectable: true, propertyType: "House", builtForm: null,
});
});
it("orders addresses numerically, so Flat 2 comes before Flat 10", () => {
const flats = [
{ DPA: { UPRN: "1", ADDRESS: "FLAT 10, BIRCH COURT, M20 4TF", CLASSIFICATION_CODE: "RD06" } },
@ -102,6 +124,18 @@ describe("buildWritePlan", () => {
{ component: "built_form_type", value: "Detached", originalDescription: "RD02", buildingPart: 0 },
]);
});
it("writes a House property_type override for an RH03 HMO, keyed to the OS code", () => {
const plan = buildWritePlan(
{ uprn: "100031252876", address: "7, Leyton Avenue, Sutton-In-Ashfield", postcode: "NG17 3BD",
classificationCode: "RH03", lat: 53.1352313, lng: -1.2522665, selectable: true,
propertyType: "House", builtForm: null },
geo,
);
// HMO built form is unknowable, so only the property_type fact is written.
expect(plan.overrides).toEqual([
{ component: "property_type", value: "House", originalDescription: "RH03", buildingPart: 0 },
]);
});
it("writes no overrides when the code maps nothing — never Unknown", () => {
const plan = buildWritePlan(
{ uprn: "200", address: "Birch Court", postcode: "M20 4UD", classificationCode: "RD",

View file

@ -28,7 +28,11 @@ const OS_CODE_FACTS: Record<string, { propertyType: string; builtForm: string |
RD03: { propertyType: "House", builtForm: "Semi-Detached" },
RD04: { propertyType: "House", builtForm: null }, // terraced — mid/end unknowable
RD06: { propertyType: "Flat", builtForm: null },
RD07: { propertyType: "House", builtForm: null }, // HMO
RD07: { propertyType: "House", builtForm: null }, // HMO (RD-family code)
// RH is the dedicated HMO family; treat as House like RD07, built form unknowable.
RH01: { propertyType: "House", builtForm: null }, // HMO parent
RH02: { propertyType: "House", builtForm: null }, // HMO bedsit / non-self-contained
RH03: { propertyType: "House", builtForm: null }, // HMO not further divided
};
const CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
@ -181,7 +185,10 @@ export function cacheAgeDays(fetchedAt: Date, now: Date): number {
export function classifyOsCode(code: string | null): OsClassification {
if (!code) return { selectable: false, propertyType: null, builtForm: null };
const c = code.trim().toUpperCase();
const residential = c === "RD" || (c.startsWith("RD") && c !== "RD10");
// Addable dwellings: the RD (dwelling) family except RD10 (institutions),
// plus the RH (HMO) family. Everything else — commercial, institutional,
// land — is not addable.
const residential = (c.startsWith("RD") && c !== "RD10") || c.startsWith("RH");
if (!residential) return { selectable: false, propertyType: null, builtForm: null };
const facts = OS_CODE_FACTS[c];
return {

Some files were not shown because too many files have changed in this diff Show more