Merge pull request #389 from Hestia-Homes/feature/portfolio-tagging

feat: portfolio tagging system (ADR-0013)
This commit is contained in:
KhalimCK 2026-07-14 11:52:58 +01:00 committed by GitHub
commit fc5fc80d88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 3069 additions and 173 deletions

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

@ -113,7 +113,7 @@ One user-initiated act of triggering modelling: a set of Scenarios crossed with
_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)
### Live projects
@ -126,6 +126,16 @@ _Avoid_: programme, scheme, deal group, batch (a **Batch** is a separate per-dea
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:

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,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

@ -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,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

@ -418,6 +418,15 @@ export interface PropertyWithRelations extends Record<string, unknown> {
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<

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,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

@ -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,
@ -41,8 +43,12 @@ const FIELD_OPTIONS: { value: FilterField; label: string }[] = [
{ 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" },
@ -103,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 [];
}
@ -115,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 =
@ -225,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 ? (
@ -264,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" />}
@ -349,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 ? (
@ -375,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" />}
@ -402,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)}
@ -415,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) {
@ -434,6 +586,7 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp
setEpcSelected([]);
setDateValue("");
setEnumSelected([]);
setTagSelected([]);
setNumValue("");
}
@ -444,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) : "";
}
@ -470,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] : [];
@ -531,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)}
/>
@ -545,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} />
)}
@ -556,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>
@ -577,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"
@ -603,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
@ -700,6 +870,7 @@ export default function PropertyFilters({
)}
<ConditionRow
condition={condition}
tags={tags}
onRemove={() => removeCondition(group.id, condition.id)}
/>
</div>
@ -709,6 +880,7 @@ export default function PropertyFilters({
{isAndTarget ? (
<AddFilterForm
targetGroupId={group.id}
tags={tags}
onConfirm={handleConfirm}
onCancel={() => setAddMode(null)}
/>
@ -731,6 +903,7 @@ export default function PropertyFilters({
{addMode === "or" ? (
<AddFilterForm
targetGroupId={null}
tags={tags}
onConfirm={handleConfirm}
onCancel={() => setAddMode(null)}
/>
@ -766,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

@ -20,7 +20,10 @@ 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 {
columns,
columnsWithoutSelect,
} from "@/app/portfolio/[slug]/components/propertyTableColumns";
import { PropertyWithRelations } from "@/app/db/schema/property";
import { MAINFUEL_OPTIONS, EnumOption } from "@/app/utils/propertyFilters";
import {
@ -31,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,
@ -43,21 +50,20 @@ 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 = 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[],
dbValue: string | null | undefined,
@ -233,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">
@ -317,12 +324,22 @@ 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]);
// 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;
},
@ -408,6 +425,7 @@ export default function PropertyTable({
filterGroups: [],
});
const totalCount = allResponse?.total ?? 0;
const [modelNudgeDismissed, setModelNudgeDismissed] = useState(false);
const DISPLAY_LIMIT = 250;
const LOAD_MORE_SIZE = 100;
@ -434,6 +452,18 @@ 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;
// 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,
@ -446,6 +476,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) setRowSelection({});
}
const [isFetchingMore, setIsFetchingMore] = useState(false);
@ -503,9 +535,11 @@ export default function PropertyTable({
// 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",
@ -517,24 +551,17 @@ export default function PropertyTable({
offset: 0,
}),
});
if (!res.ok) return;
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]);
/* ----------------------------------------
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);
return (
<div className="py-4 mx-4">
{/* Action bar */}
@ -559,6 +586,29 @@ export default function PropertyTable({
properties
</span>
)}
{selectedIds.length > 0 && (
<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">
{selectedIds.length.toLocaleString()} selected
<button
onClick={() => setRowSelection({})}
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}
@ -617,7 +667,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]}
@ -655,10 +705,23 @@ export default function PropertyTable({
</button>
)}
{/* Add properties */}
{/* Tags — bulk assign / remove / upload (ADR-0013) */}
<TagsMenu
onUpload={() => setTagUploadOpen(true)}
onAssign={() => {
setRowSelection({});
setTagMode("assign");
}}
onRemove={() => {
setRowSelection({});
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" />
@ -704,14 +767,20 @@ export default function PropertyTable({
portfolioId={portfolioId}
/>
{/* 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>
{/* 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>
@ -766,6 +835,70 @@ 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}
hasActiveFilters={hasActiveFilters}
filterGroups={allFilterGroups}
filteredTotal={filteredTotal}
totalCount={totalCount}
onClose={() => {
setTagMode(null);
setRowSelection({});
}}
/>
)}
<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">
@ -800,6 +933,7 @@ export default function PropertyTable({
Curate Selection
</p>
<PropertyFilters
portfolioId={portfolioId}
filterGroups={filterGroups}
onChange={setFilterGroups}
/>
@ -833,8 +967,7 @@ export default function PropertyTable({
) : (
<DataTable
data={tableData}
columns={columns}
onDeleteProperty={(id) => setDeletePropertyId(id)}
columns={tagMode ? columns : columnsWithoutSelect}
columnVisibility={columnVisibility}
onColumnVisibilityChange={
setColumnVisibility as (
@ -845,61 +978,16 @@ export default function PropertyTable({
onPaginationChange={
setPagination as (updater: Updater<PaginationState>) => void
}
rowSelection={rowSelection}
onRowSelectionChange={
setRowSelection as (
updater: Updater<RowSelectionState>,
) => void
}
/>
)}
</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,161 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { 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). Its target follows the live table state: the row selection if any,
* else every property matching the active filter, else the whole portfolio
* so ticking rows or changing filters re-scopes it in place. Picking a tag adds
* (assign) or deletes (remove) memberships server-side and reports the count.
*/
export function TagActionBar({
portfolioId,
mode,
selectedIds,
hasActiveFilters,
filterGroups,
filteredTotal,
totalCount,
onClose,
}: {
portfolioId: string;
mode: TagMode;
selectedIds: string[];
hasActiveFilters: boolean;
filterGroups: FilterGroups;
filteredTotal: number;
totalCount: number;
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";
// Target precedence: explicit selection → active filter set → whole portfolio.
const target =
selectedIds.length > 0
? {
body: { mode: "properties" as const, propertyIds: selectedIds },
label: `${selectedIds.length.toLocaleString()} selected propert${selectedIds.length === 1 ? "y" : "ies"}`,
}
: hasActiveFilters
? {
body: { mode: "filter" as const, filterGroups },
label: `all ${filteredTotal.toLocaleString()} matching your filters`,
}
: {
body: { mode: "filter" as const, filterGroups: [] as FilterGroups },
label: `all ${totalCount.toLocaleString()} properties`,
};
const apply = useMutation<{ changed: number }, Error, Tag>({
mutationFn: async (tag) => {
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),
});
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">
{prep} <span className="font-semibold text-brandblue">{target.label}</span>
<span className="text-slate-400"> tick rows or apply filters to change this</span>
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
disabled={apply.isLoading}
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:opacity-50"
>
{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

@ -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

@ -19,6 +19,8 @@ import { expectedEpcRating } from "./expectedEpc";
import { X } from "lucide-react";
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
@ -51,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)}`}
@ -155,6 +178,7 @@ export function DataTableFilterHeader<TData, TValue>({
Column metadata
------------------------------------------------------------------------ */
export const OPTIONAL_COLUMN_IDS = [
"tags",
"propertyType",
"builtForm",
"yearBuilt",
@ -169,6 +193,7 @@ export const OPTIONAL_COLUMN_IDS = [
export type OptionalColumnId = (typeof OPTIONAL_COLUMN_IDS)[number];
const OPTIONAL_COLUMN_LABELS: Record<OptionalColumnId, string> = {
tags: "Tags",
propertyType: "Property Type",
builtForm: "Built Form",
yearBuilt: "Construction Age",
@ -186,6 +211,31 @@ export { OPTIONAL_COLUMN_LABELS };
Core columns
------------------------------------------------------------------------ */
const coreColumns: ColumnDef<PropertyWithRelations>[] = [
{
id: "select",
enableSorting: false,
enableHiding: false,
header: ({ table }) => (
<Checkbox
aria-label="Select all on this page"
checked={
table.getIsAllPageRowsSelected()
? true
: table.getIsSomePageRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
/>
),
},
{
accessorKey: "address",
enableGlobalFilter: true,
@ -224,10 +274,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: () => (
@ -296,7 +354,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);
@ -338,13 +400,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>
);
@ -402,7 +464,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 />;
},
},
{
@ -411,7 +473,7 @@ 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>;
return val ? <Pill>{val}</Pill> : <EmptyCell />;
},
},
{
@ -419,7 +481,9 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
accessorKey: "yearBuilt",
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>
),
},
{
@ -430,7 +494,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>
);
},
@ -443,7 +507,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>
);
},
@ -454,7 +518,7 @@ 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 />;
},
},
{
@ -484,7 +548,7 @@ function DescriptorCell({ value }: { value: string | null }) {
<div
className={cn(
"text-sm max-w-[220px] truncate",
val === "Unknown" ? "text-slate-300" : "text-gray-700",
val === "Unknown" ? "text-slate-500" : "text-gray-700",
)}
title={val}
>
@ -497,3 +561,9 @@ 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

@ -715,6 +715,38 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
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": {
@ -852,6 +884,49 @@ export async function getPropertiesCount(
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,
@ -913,7 +988,19 @@ export async function getProperties(
${resolvedWallTypeSql(sql`epc`)} AS "wallType",
${resolvedRoofTypeSql(sql`epc`)} AS "roofType",
${resolvedHeatingSystemSql(sql`epc`)} AS "heatingSystem",
p.lexiscore AS lexiscore
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.

View file

@ -15,7 +15,8 @@ export type FilterField =
| "mainfuel"
| "wallType"
| "roofType"
| "heatingSystem";
| "heatingSystem"
| "tags";
export type FilterOperator =
| "contains"

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

@ -146,6 +146,13 @@ 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;

View file

@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { parseTagIdentifierRows, MAX_BULK_TAG_ROWS } from "./bulkAssign";
describe("parseTagIdentifierRows", () => {
it("extracts landlord property ids from a landlord_property_id column", () => {
const rows = [["landlord_property_id"], ["ABC-001"], ["ABC-002"]];
expect(parseTagIdentifierRows(rows)).toEqual({
ok: true,
key: "landlord_property_id",
identifiers: ["ABC-001", "ABC-002"],
});
});
it("detects a UPRN column (header normalised case-insensitively)", () => {
const rows = [["UPRN"], [100023336956], [100023336957]];
expect(parseTagIdentifierRows(rows)).toEqual({
ok: true,
key: "uprn",
identifiers: ["100023336956", "100023336957"],
});
});
it("normalises a spaced/cased header and trims + dedupes + drops blank ids", () => {
const rows = [
["Landlord Property ID"],
[" ABC-001 "],
["ABC-001"],
[""],
["ABC-002"],
];
expect(parseTagIdentifierRows(rows)).toEqual({
ok: true,
key: "landlord_property_id",
identifiers: ["ABC-001", "ABC-002"],
});
});
it("errors when neither identifier column is present", () => {
const rows = [["address", "postcode"], ["12 Oak St", "SW1"]];
expect(parseTagIdentifierRows(rows)).toEqual({
ok: false,
error:
"The file needs a 'landlord_property_id' or 'uprn' column in the first row",
});
});
it("errors on an empty file (no data rows)", () => {
expect(parseTagIdentifierRows([["uprn"]])).toEqual({
ok: false,
error: "The file has no rows to assign",
});
});
it("errors when the file exceeds the row cap", () => {
const rows: (string | number)[][] = [["uprn"]];
for (let i = 0; i < MAX_BULK_TAG_ROWS + 1; i++) rows.push([`1000${i}`]);
const result = parseTagIdentifierRows(rows);
expect(result.ok).toBe(false);
expect(result.ok === false && result.error).toContain(
String(MAX_BULK_TAG_ROWS),
);
});
});

View file

@ -0,0 +1,61 @@
/**
* Pure parsing for Bulk tag assignment (ADR-0013). The client reads the uploaded
* CSV/Excel into a 2D array of rows (header row first, via SheetJS) and this
* turns it into the identifier column to match on either `landlord_property_id`
* or `uprn`, whichever header the file carries. Kept DB-free so it unit-tests and
* runs in the browser before anything is sent to the server.
*/
/** Upper bound on rows per upload ("relatively small file", ADR-0013). */
export const MAX_BULK_TAG_ROWS = 10_000;
export type IdentifierKey = "landlord_property_id" | "uprn";
export type ParsedIdentifiers =
| { ok: true; key: IdentifierKey; identifiers: string[] }
| { ok: false; error: string };
type Cell = string | number | null | undefined;
/** Normalise a header cell: trimmed, lowercased, spaces/hyphens → underscores. */
function normaliseHeader(cell: Cell): string {
return String(cell ?? "")
.trim()
.toLowerCase()
.replace(/[\s-]+/g, "_");
}
export function parseTagIdentifierRows(rows: Cell[][]): ParsedIdentifiers {
const [header, ...dataRows] = rows;
const headers = (header ?? []).map(normaliseHeader);
const keyIndex = headers.findIndex(
(h) => h === "landlord_property_id" || h === "uprn",
);
if (keyIndex === -1) {
return {
ok: false,
error:
"The file needs a 'landlord_property_id' or 'uprn' column in the first row",
};
}
const key = headers[keyIndex] as IdentifierKey;
const seen = new Set<string>();
for (const row of dataRows) {
const value = String(row[keyIndex] ?? "").trim();
if (value.length > 0) seen.add(value);
}
if (seen.size === 0) {
return { ok: false, error: "The file has no rows to assign" };
}
if (seen.size > MAX_BULK_TAG_ROWS) {
return {
ok: false,
error: `The file has more than ${MAX_BULK_TAG_ROWS} properties — split it into smaller files`,
};
}
return { ok: true, key, identifiers: [...seen] };
}

View file

@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import {
normaliseHex,
tagDotColour,
tagChipTokens,
luminance,
} from "./colour";
/** WCAG relative-luminance + contrast for asserting chip legibility in tests. */
function lum(hex: string): number {
const n = hex.replace("#", "");
const [r, g, b] = [0, 2, 4].map((i) => {
const s = parseInt(n.slice(i, i + 2), 16) / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a: string, b: string): number {
const [la, lb] = [lum(a), lum(b)];
const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
return (hi + 0.05) / (lo + 0.05);
}
describe("normaliseHex", () => {
it("lowercases, hashes, and expands 3-digit hex", () => {
expect(normaliseHex(" #FDE047 ")).toBe("#fde047");
expect(normaliseHex("#FFF")).toBe("#ffffff");
});
it("returns null for an unparseable colour", () => {
expect(normaliseHex("not-a-colour")).toBeNull();
});
});
describe("tagDotColour", () => {
it("returns the normalised hex", () => {
expect(tagDotColour("#7C3AED")).toBe("#7c3aed");
});
it("falls back to slate for garbage input", () => {
expect(tagDotColour("nope")).toBe("#64748b");
});
});
describe("luminance", () => {
it("ranks black below white", () => {
expect(luminance("#000000")).toBeLessThan(luminance("#ffffff"));
});
});
describe("tagChipTokens", () => {
it("builds a soft tint, border, dot, and readable ink", () => {
const t = tagChipTokens("#7c3aed");
expect(t.dot).toBe("#7c3aed");
// Both tint and border are blended over white, so lighter than the source;
// the border carries more of the colour, so it sits between tint and source.
expect(luminance(t.backgroundColor)).toBeGreaterThan(luminance("#7c3aed"));
expect(luminance(t.borderColor)).toBeGreaterThan(luminance("#7c3aed"));
expect(luminance(t.borderColor)).toBeLessThan(luminance(t.backgroundColor));
});
it("guarantees AA (≥4.5:1) ink for a hard, pale colour (yellow)", () => {
const t = tagChipTokens("#fde047");
expect(contrast(t.color, t.backgroundColor)).toBeGreaterThanOrEqual(4.5);
});
it("guarantees AA ink across a spread of freeform hues", () => {
for (const c of ["#ef4444", "#22c55e", "#3b82f6", "#000000", "#ffffff", "#14163d"]) {
const t = tagChipTokens(c);
expect(contrast(t.color, t.backgroundColor)).toBeGreaterThanOrEqual(4.5);
}
});
it("keeps a dark tag's ink close to its own hue (minimal darkening)", () => {
// Navy is already dark enough — ink should not collapse to pure black.
expect(tagChipTokens("#14163d").color).not.toBe("#000000");
});
it("falls back to a neutral chip for an unparseable colour", () => {
const t = tagChipTokens("garbage");
expect(t.dot).toBe("#64748b");
expect(contrast(t.color, t.backgroundColor)).toBeGreaterThanOrEqual(4.5);
});
});

113
src/lib/tags/colour.ts Normal file
View file

@ -0,0 +1,113 @@
/**
* Pure colour helpers for rendering Tag chips (ADR-0013). A Tag's colour is a
* freeform hex, so chips must stay calm and legible for ANY hue the product is
* institutional and WCAG-AA (PRODUCT.md). The chip vocabulary is one system
* everywhere: a soft tint of the colour, a colour dot, and hue-preserving dark
* ink whose contrast against the tint is guaranteed 4.5:1. Kept DB-free +
* side-effect-free so it unit-tests cleanly and is shared by every chip surface.
*/
type Rgb = [number, number, number];
/** Slate-500 — a neutral fallback for an unparseable colour. */
const FALLBACK = "#64748b";
/** Minimum WCAG contrast for the chip's ink against its tint background. */
const MIN_CONTRAST = 4.5;
/** Parse `#rgb` / `#rrggbb` (any case, surrounding space) → [r,g,b] 0-255, or null. */
function parseHex(input: string): Rgb | null {
const hex = input.trim().toLowerCase().replace(/^#/, "");
const full =
hex.length === 3
? hex.split("").map((c) => c + c).join("")
: hex;
if (!/^[0-9a-f]{6}$/.test(full)) return null;
return [
parseInt(full.slice(0, 2), 16),
parseInt(full.slice(2, 4), 16),
parseInt(full.slice(4, 6), 16),
];
}
function toHex(rgb: Rgb): string {
return "#" + rgb.map((c) => Math.round(c).toString(16).padStart(2, "0")).join("");
}
/** Normalise a hex to lowercase `#rrggbb`, or null if unparseable. */
export function normaliseHex(input: string): string | null {
const rgb = parseHex(input);
return rgb ? toHex(rgb) : null;
}
/** The dot colour for a Tag — its normalised hex, or a neutral fallback. */
export function tagDotColour(colour: string): string {
return normaliseHex(colour) ?? FALLBACK;
}
function luminanceRgb([r, g, b]: Rgb): number {
const lin = [r, g, b].map((c) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
}
/** Relative luminance (WCAG) of a hex colour, 0 → 1; unparseable → 1 (light). */
export function luminance(input: string): number {
const rgb = parseHex(input);
return rgb ? luminanceRgb(rgb) : 1;
}
function contrast(a: Rgb, b: Rgb): number {
const la = luminanceRgb(a);
const lb = luminanceRgb(b);
const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
return (hi + 0.05) / (lo + 0.05);
}
/** Blend `colour` over white at `alpha` (0-1) → the resulting opaque rgb. */
function overWhite(rgb: Rgb, alpha: number): Rgb {
return rgb.map((c) => c * alpha + 255 * (1 - alpha)) as Rgb;
}
/** Mix `rgb` toward black by `t` (0 = unchanged, 1 = black). */
function towardBlack(rgb: Rgb, t: number): Rgb {
return rgb.map((c) => c * (1 - t)) as Rgb;
}
/**
* A dark, hue-preserving ink for `colour` that reads at 4.5:1 on `bg`. Starts
* from the colour itself (dark tags keep their exact hue) and darkens toward
* black only as far as legibility requires.
*/
function readableInk(rgb: Rgb, bg: Rgb): string {
for (let t = 0; t <= 1.0001; t += 0.05) {
const candidate = towardBlack(rgb, t);
if (contrast(candidate, bg) >= MIN_CONTRAST) return toHex(candidate);
}
return "#000000";
}
export interface ChipTokens {
backgroundColor: string;
borderColor: string;
color: string;
dot: string;
}
/**
* Inline tokens for a soft Tag chip: a light tint of the colour, a slightly
* stronger border, a colour dot, and dark hue-preserving ink guaranteed legible
* on the tint. One quiet, AA-safe vocabulary for every chip in the app.
*/
export function tagChipTokens(colour: string): ChipTokens {
const rgb = parseHex(colour) ?? parseHex(FALLBACK)!;
const tint = overWhite(rgb, 0.14);
const border = overWhite(rgb, 0.34);
return {
backgroundColor: toHex(tint),
borderColor: toHex(border),
color: readableInk(rgb, tint),
dot: toHex(rgb),
};
}

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { validateTagDraft } from "./model";
describe("validateTagDraft", () => {
it("accepts a valid name + hex colour, trimming the name and lowercasing the hex", () => {
expect(validateTagDraft({ name: " Void ", colour: "#7C3AED" }, [])).toEqual({
ok: true,
tag: { name: "Void", colour: "#7c3aed" },
});
});
it("rejects a blank name (empty or whitespace-only)", () => {
expect(validateTagDraft({ name: " ", colour: "#7c3aed" }, [])).toEqual({
ok: false,
error: "Tag name is required",
});
});
it("rejects a name longer than 30 characters (after trimming)", () => {
const long = "a".repeat(31);
expect(validateTagDraft({ name: long, colour: "#7c3aed" }, [])).toEqual({
ok: false,
error: "Tag name must be 30 characters or fewer",
});
});
it("accepts a name of exactly 30 characters (boundary)", () => {
const name = "a".repeat(30);
expect(validateTagDraft({ name, colour: "#7c3aed" }, [])).toEqual({
ok: true,
tag: { name, colour: "#7c3aed" },
});
});
it("rejects a name that duplicates an existing one, case-insensitively", () => {
expect(
validateTagDraft({ name: " VOID ", colour: "#7c3aed" }, ["Void", "Phase 1"]),
).toEqual({ ok: false, error: "A tag with this name already exists" });
});
it("accepts a name that clashes with no existing tag (edit excludes its own name)", () => {
// On edit the caller passes the OTHER tags' names, so keeping/recasing the
// tag's own name is allowed.
expect(
validateTagDraft({ name: "Void", colour: "#7c3aed" }, ["Phase 1"]),
).toEqual({ ok: true, tag: { name: "Void", colour: "#7c3aed" } });
});
it("rejects a colour that is not a hex code", () => {
expect(validateTagDraft({ name: "Void", colour: "red" }, [])).toEqual({
ok: false,
error: "Colour must be a hex code like #7c3aed",
});
});
it("accepts a 3-digit hex shorthand, normalised to lowercase", () => {
expect(validateTagDraft({ name: "Void", colour: "#ABC" }, [])).toEqual({
ok: true,
tag: { name: "Void", colour: "#abc" },
});
});
});

43
src/lib/tags/model.ts Normal file
View file

@ -0,0 +1,43 @@
/**
* Pure Tag domain logic validation + normalisation for creating/editing a
* portfolio Tag (ADR-0013). Kept free of DB access so it unit-tests cleanly and
* is shared by the tag routes. The DB's case-insensitive unique index is the
* ultimate guard against duplicate names; this gives a friendly pre-check.
*/
export const MAX_TAG_NAME_LENGTH = 30;
/** A hex colour: #rgb or #rrggbb (case-insensitive). */
const HEX_COLOUR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
export interface TagDraft {
name: string;
colour: string;
}
export type TagValidation =
| { ok: true; tag: TagDraft }
| { ok: false; error: string };
export function validateTagDraft(
input: TagDraft,
existingNames: string[],
): TagValidation {
const name = input.name.trim();
if (name.length === 0) return { ok: false, error: "Tag name is required" };
if (name.length > MAX_TAG_NAME_LENGTH) {
return {
ok: false,
error: `Tag name must be ${MAX_TAG_NAME_LENGTH} characters or fewer`,
};
}
const lower = name.toLowerCase();
if (existingNames.some((existing) => existing.trim().toLowerCase() === lower)) {
return { ok: false, error: "A tag with this name already exists" };
}
const colour = input.colour.trim().toLowerCase();
if (!HEX_COLOUR.test(colour)) {
return { ok: false, error: "Colour must be a hex code like #7c3aed" };
}
return { ok: true, tag: { name, colour } };
}

48
src/lib/tags/server.ts Normal file
View file

@ -0,0 +1,48 @@
/**
* Server-side helpers shared by the tag routes: portfolio-role lookup and the
* write gate. Tags are portfolio-scoped; per ADR-0013 (Q11) any member with a
* write-capable role manages and assigns them, read members are view-only.
*/
import { db } from "@/app/db/db";
import { portfolioUsers } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { and, eq } from "drizzle-orm";
/** Roles allowed to create/edit/delete/assign tags (read is view-only). */
const WRITE_ROLES = new Set(["creator", "admin", "write"]);
export async function getPortfolioRole(
portfolioId: bigint,
email: string,
): Promise<string | null> {
const rows = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.innerJoin(user, eq(user.id, portfolioUsers.userId))
.where(
and(eq(portfolioUsers.portfolioId, portfolioId), eq(user.email, email)),
)
.limit(1);
return rows[0]?.role ?? null;
}
/** A member (any role) may read; only write-capable roles may mutate. */
export function canReadTags(role: string | null): boolean {
return role != null;
}
export function canWriteTags(role: string | null): boolean {
return role != null && WRITE_ROLES.has(role);
}
/**
* True when `err` is a Postgres unique-violation (SQLSTATE 23505) the
* case-insensitive tag-name index racing a concurrent create/rename. node-postgres
* surfaces the code on the thrown error (or its `.cause`). Everything else is a
* real failure the caller must not mask as a duplicate-name 409.
*/
export function isUniqueViolation(err: unknown): boolean {
const code = (err as { code?: unknown; cause?: { code?: unknown } })?.code
?? (err as { cause?: { code?: unknown } })?.cause?.code;
return code === "23505";
}