diff --git a/.gitignore b/.gitignore index 04f701b5..237fa406 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.impeccable/critique/2026-07-13T21-29-24Z__rc-app-portfolio-slug-components-propertytable-tsx.md b/.impeccable/critique/2026-07-13T21-29-24Z__rc-app-portfolio-slug-components-propertytable-tsx.md new file mode 100644 index 00000000..73736a11 --- /dev/null +++ b/.impeccable/critique/2026-07-13T21-29-24Z__rc-app-portfolio-slug-components-propertytable-tsx.md @@ -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 5–6 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 5–6 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 50–100 rows + persistent EPC/cost sort. + +**Sam (accessibility):** slate-300 "No cost"/"Unknown" fail AA outright; the quick-filter clear control is a `` nested *inside* a ` + + {formError &&

{formError}

} + + {/* List */} + {isLoading ? ( +

Loading…

+ ) : tags.length === 0 ? ( +

No tags yet.

+ ) : ( + + )} + + ); +} + +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 ( +
  • + + + {tag.propertyCount} propert{tag.propertyCount === 1 ? "y" : "ies"} + +
    + + +
    +
  • + ); +} + +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(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 ( +
  • + 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" + /> + setName(e.target.value)} + className="flex-1" + /> + {error && {error}} + + +
  • + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/settings/tags/page.tsx b/src/app/portfolio/[slug]/(portfolio)/settings/tags/page.tsx new file mode 100644 index 00000000..d2042156 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/settings/tags/page.tsx @@ -0,0 +1,12 @@ +import TagsCard from "./TagsCard"; + +export default async function TagsSettingsPage(props: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await props.params; + return ( +
    + +
    + ); +} diff --git a/src/app/portfolio/[slug]/components/BulkTagUploadModal.tsx b/src/app/portfolio/[slug]/components/BulkTagUploadModal.tsx new file mode 100644 index 00000000..21e4ffed --- /dev/null +++ b/src/app/portfolio/[slug]/components/BulkTagUploadModal.tsx @@ -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 = { + 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(null); + const [parsed, setParsed] = useState(null); + const [parseError, setParseError] = useState(null); + const [targetTag, setTargetTag] = useState(null); + const [summary, setSummary] = useState(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({ + 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 ( + { + if (!o) reset(); + onOpenChange(o); + }} + > + + + Assign a tag from a file + + Upload a CSV or Excel file with a{" "} + landlord_property_id or{" "} + uprn column. We'll match + those to properties in this portfolio and apply the tag you pick. + + + + {summary ? ( +
    +
    +

    + Tagged {summary.taggedCount.toLocaleString()}{" "} + propert{summary.taggedCount === 1 ? "y" : "ies"} + {targetTag ? ( + <> + {" "} + with{" "} + + + ) : null} + . +

    +
      +
    • {summary.matchedPropertyCount.toLocaleString()} matched the file
    • + {summary.alreadyTaggedCount > 0 && ( +
    • + {summary.alreadyTaggedCount.toLocaleString()} already had the tag +
    • + )} + {summary.unmatchedCount > 0 && ( +
    • + {summary.unmatchedCount.toLocaleString()} identifier + {summary.unmatchedCount === 1 ? "" : "s"} matched no property +
    • + )} +
    +
    + {summary.unmatchedIdentifiers.length > 0 && ( +
    + + Show unmatched identifiers + +

    + {summary.unmatchedIdentifiers.join(", ")} + {summary.unmatchedCount > summary.unmatchedIdentifiers.length && + " …"} +

    +
    + )} +
    + ) : ( +
    + + +

    + Need a starting point? Download a template for{" "} + {" "} + or{" "} + + . +

    + + {parseError && ( +

    {parseError}

    + )} + + {parsed && ( +
    +

    + Found{" "} + + {parsed.identifiers.length.toLocaleString()} + {" "} + identifiers, matching on{" "} + + {KEY_LABEL[parsed.key]} + + . +

    +
    +

    + Apply which tag? +

    + {tags.length === 0 ? ( +

    + No tags yet —{" "} + + create one first + + . +

    + ) : ( +
    + {tags.map((t) => ( + + ))} +
    + )} +
    +
    + )} + + {assign.isError && ( +

    {assign.error.message}

    + )} +
    + )} + + + {summary ? ( + <> + + + + ) : ( + <> + + + + )} + +
    +
    + ); +} diff --git a/src/app/portfolio/[slug]/components/PropertyFilters.tsx b/src/app/portfolio/[slug]/components/PropertyFilters.tsx index a77295b8..f8df4b12 100644 --- a/src/app/portfolio/[slug]/components/PropertyFilters.tsx +++ b/src/app/portfolio/[slug]/components/PropertyFilters.tsx @@ -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" > {selected.length === 0 ? ( @@ -264,7 +287,7 @@ function EpcDropdown({ {multi ? ( {isSelected && } @@ -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" > {selectedLabels.length === 0 ? ( @@ -375,7 +398,134 @@ function EnumMultiDropdown({ {opt.label} + {isSelected && } + + + ); + })} + + )} + + ); +} + +/* ----------------------------------------------------------------------- + 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({}); + const ref = useRef(null); + const buttonRef = useRef(null); + const dropdownRef = useRef(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 ( +
    + + + {open && ( +
    + {tags.length === 0 && ( +

    + No tags yet — filter by Untagged, or create tags in settings. +

    + )} + {options.map((opt) => { + const isSelected = selected.includes(opt.value); + const untagged = opt.value === UNTAGGED_VALUE; + return ( + @@ -577,15 +742,17 @@ function AddFilterForm({ targetGroupId, onConfirm, onCancel }: AddFilterFormProp ------------------------------------------------------------------------ */ function ConditionRow({ condition, + tags, onRemove, }: { condition: PropertyFilter; + tags: Tag[]; onRemove: () => void; }) { return (
    - {conditionLabel(condition)} + {conditionLabel(condition, tags)}
    @@ -709,6 +880,7 @@ export default function PropertyFilters({ {isAndTarget ? ( setAddMode(null)} /> @@ -731,6 +903,7 @@ export default function PropertyFilters({ {addMode === "or" ? ( setAddMode(null)} /> @@ -766,7 +939,7 @@ export default function PropertyFilters({ diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index 4e788d22..6cbf399c 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -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 (
    - + {isActive && ( + )} - +
    {isOpen && (
    @@ -317,12 +324,22 @@ export default function PropertyTable({ const [openFilter, setOpenFilter] = useState(null); const [filterGroups, setFilterGroups] = useState([]); + // Row selection for bulk tagging — keyed by property id (see DataTable getRowId). + const [rowSelection, setRowSelection] = useState({}); + 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(null); + const [tagUploadOpen, setTagUploadOpen] = useState(false); + // Column visibility — lifted up from DataTable const [columnVisibility, setColumnVisibility] = useState( () => { 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({ 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(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(null); - const [deletePreview, setDeletePreview] = useState< - { table: string; count: number }[] | null - >(null); - const [previewLoading] = useState(false); - const [previewError] = useState(null); - return (
    {/* Action bar */} @@ -559,6 +586,29 @@ export default function PropertyTable({ properties )} + {selectedIds.length > 0 && ( + + {selectedIds.length.toLocaleString()} selected + + + )} + {exportError && ( + + {exportError} + + + )} {hasActiveFilters && ( )} - {/* Add properties */} + {/* Tags — bulk assign / remove / upload (ADR-0013) */} + setTagUploadOpen(true)} + onAssign={() => { + setRowSelection({}); + setTagMode("assign"); + }} + onRemove={() => { + setRowSelection({}); + setTagMode("remove"); + }} + /> + + {/* Add properties — primary CTA only while the portfolio is empty */} - + {/* 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 && ( + + )}
    @@ -766,6 +835,70 @@ export default function PropertyTable({ />
    + {/* Contextual bulk assign/remove strip — shown only while in that mode + (launched from the Tags menu); its target follows the live selection. */} + {tagMode && ( + { + setTagMode(null); + setRowSelection({}); + }} + /> + )} + + + + {/* 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 && ( +
    +
    + +
    +

    + {unmodelledInView === totalCount + ? "None of these properties have been modelled yet" + : `${unmodelledInView.toLocaleString()} ${unmodelledInView === 1 ? "property hasn't" : "properties haven't"} been modelled yet`} +

    +

    + Run modelling to generate Expected EPC ratings and retrofit + costs for them. +

    +
    +
    +
    + + +
    +
    + )} + {/* Display-limit notice */} {isAtDisplayLimit && hasMore && (
    @@ -800,6 +933,7 @@ export default function PropertyTable({ Curate Selection

    @@ -833,8 +967,7 @@ export default function PropertyTable({ ) : ( setDeletePropertyId(id)} + columns={tagMode ? columns : columnsWithoutSelect} columnVisibility={columnVisibility} onColumnVisibilityChange={ setColumnVisibility as ( @@ -845,61 +978,16 @@ export default function PropertyTable({ onPaginationChange={ setPagination as (updater: Updater) => void } + rowSelection={rowSelection} + onRowSelectionChange={ + setRowSelection as ( + updater: Updater, + ) => void + } /> )}
    - - {/* Delete preview modal */} - { - if (!open) { - setDeletePropertyId(null); - setDeletePreview(null); - } - }} - > - - - Delete property? - - {previewLoading - ? "Calculating what will be deleted…" - : "This action is permanent. Review the impact below."} - - - - {previewError && ( -

    {previewError}

    - )} - - {deletePreview && !previewLoading && ( -
      - {deletePreview.map((row) => ( -
    • - - {row.table.replace(/_/g, " ")} - - {row.count} -
    • - ))} -
    - )} - - - - -
    -
    ); } diff --git a/src/app/portfolio/[slug]/components/TagActionBar.tsx b/src/app/portfolio/[slug]/components/TagActionBar.tsx new file mode 100644 index 00000000..a5c80771 --- /dev/null +++ b/src/app/portfolio/[slug]/components/TagActionBar.tsx @@ -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(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 ( +
    + {verb} a tag + + {prep} {target.label} + — tick rows or apply filters to change this + + + + + + + + + {verb} which tag? + + + {tags.length === 0 ? ( +
    + No tags yet.{" "} + + Create one + + . +
    + ) : ( + tags.map((t) => ( + apply.mutate(t)} + > + + {t.name} + + )) + )} +
    +
    + + {result && ( + + {result} + + )} + + +
    + ); +} diff --git a/src/app/portfolio/[slug]/components/TagChip.tsx b/src/app/portfolio/[slug]/components/TagChip.tsx new file mode 100644 index 00000000..f2bb8f9c --- /dev/null +++ b/src/app/portfolio/[slug]/components/TagChip.tsx @@ -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 ( + + + {name} + {onRemove && ( + + )} + + ); +} diff --git a/src/app/portfolio/[slug]/components/TagsCell.tsx b/src/app/portfolio/[slug]/components/TagsCell.tsx new file mode 100644 index 00000000..004a2df1 --- /dev/null +++ b/src/app/portfolio/[slug]/components/TagsCell.tsx @@ -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({ + 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({ + queryKey: ["properties", portfolioId], + }); + const chip: PropertyTag = { id: tag.id, name: tag.name, colour: tag.colour }; + queryClient.setQueriesData( + { 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 ( + + + + + +
    +

    Assign tags

    +

    + {property.address ?? "This property"} +

    +
    +
    + {isLoading ? ( +

    Loading tags…

    + ) : isError ? ( +

    Couldn't load tags.

    + ) : allTags.length === 0 ? ( +
    + No tags yet.{" "} + + Create one + + . +
    + ) : ( + allTags.map((t) => { + const on = assignedIds.has(t.id); + return ( + + ); + }) + )} +
    +
    + + + Manage tags + + {assign.isError && ( + {assign.error.message} + )} +
    +
    +
    + ); +} + +/** 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)); +} diff --git a/src/app/portfolio/[slug]/components/TagsMenu.tsx b/src/app/portfolio/[slug]/components/TagsMenu.tsx new file mode 100644 index 00000000..ad1f02a5 --- /dev/null +++ b/src/app/portfolio/[slug]/components/TagsMenu.tsx @@ -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 ( + + + + + + } + title="Upload tags from file" + desc="Match a spreadsheet of IDs to a tag" + onSelect={onUpload} + /> + } + title="Assign a tag" + desc="Apply a tag to selected rows or your filtered results" + onSelect={onAssign} + /> + } + title="Remove a tag" + desc="Take a tag off selected rows or your filtered results" + onSelect={onRemove} + /> + + + ); +} + +function Item({ + icon, + title, + desc, + onSelect, +}: { + icon: React.ReactNode; + title: string; + desc: string; + onSelect: () => void; +}) { + return ( + + {icon} + + {title} + {desc} + + + ); +} diff --git a/src/app/portfolio/[slug]/components/dataTable.tsx b/src/app/portfolio/[slug]/components/dataTable.tsx index 18acbb79..a2a9b456 100644 --- a/src/app/portfolio/[slug]/components/dataTable.tsx +++ b/src/app/portfolio/[slug]/components/dataTable.tsx @@ -6,6 +6,7 @@ import { SortingState, PaginationState, VisibilityState, + RowSelectionState, Updater, flexRender, getCoreRowModel, @@ -50,6 +51,9 @@ interface DataTableProps { // Controlled pagination — when omitted the table manages its own pagination state pagination?: PaginationState; onPaginationChange?: (updater: Updater) => void; + // Controlled row selection — when omitted, selection is disabled/uncontrolled + rowSelection?: RowSelectionState; + onRowSelectionChange?: (updater: Updater) => void; } export default function DataTable>({ @@ -60,6 +64,8 @@ export default function DataTable>({ onColumnVisibilityChange, pagination: controlledPagination, onPaginationChange: controlledOnPaginationChange, + rowSelection, + onRowSelectionChange, }: DataTableProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); @@ -70,22 +76,35 @@ export default function DataTable>({ 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>({ globalFilter, pagination, columnVisibility, + rowSelection: rowSelection ?? {}, }, meta: { diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx index 11db3e2d..e8b8346c 100644 --- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx @@ -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 }) => ( + + — + +); + const EpcLetterBubble = ({ letter }: { letter: string }) => { - if (!letter) return
    ; + if (!letter) + return ( +
    + – +
    + ); return (
    ({ 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 = { + tags: "Tags", propertyType: "Property Type", builtForm: "Built Form", yearBuilt: "Construction Age", @@ -186,6 +211,31 @@ export { OPTIONAL_COLUMN_LABELS }; Core columns ------------------------------------------------------------------------ */ const coreColumns: ColumnDef[] = [ + { + id: "select", + enableSorting: false, + enableHiding: false, + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!v)} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!v)} + /> + ), + }, { accessorKey: "address", enableGlobalFilter: true, @@ -224,10 +274,18 @@ const coreColumns: ColumnDef[] = [ header: () =>
    Property Ref
    , cell: ({ row }) => (
    - {row.original.landlordPropertyId ?? "—"} + {row.original.landlordPropertyId ?? }
    ), }, + { + 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: () =>
    Tags
    , + cell: ({ row }) => , + }, { accessorKey: "currentEpc", header: () => ( @@ -296,7 +354,11 @@ const coreColumns: ColumnDef[] = [ const expired = row.original.epcIsExpired; if (!dateStr) { - return
    ; + return ( +
    + +
    + ); } const lodgementDate = new Date(dateStr); @@ -338,13 +400,13 @@ const coreColumns: ColumnDef[] = [ } return ( -
    +
    {cost ? ( £{formatNumber(cost)} ) : ( - No cost + )}
    ); @@ -402,7 +464,7 @@ const optionalColumns: ColumnDef[] = [ header: () =>
    Property Type
    , cell: ({ row }) => { const val = row.original.propertyType; - return val ? {val} : ; + return val ? {val} : ; }, }, { @@ -411,7 +473,7 @@ const optionalColumns: ColumnDef[] = [ header: () =>
    Built Form
    , cell: ({ row }) => { const val = row.original.builtForm; - return val ? {val} : ; + return val ? {val} : ; }, }, { @@ -419,7 +481,9 @@ const optionalColumns: ColumnDef[] = [ accessorKey: "yearBuilt", header: () =>
    Construction Age
    , cell: ({ row }) => ( -
    {row.original.yearBuilt ?? "—"}
    +
    + {row.original.yearBuilt ?? } +
    ), }, { @@ -430,7 +494,7 @@ const optionalColumns: ColumnDef[] = [ const val = row.original.totalFloorArea; return (
    - {val != null ? `${val.toFixed(1)} m²` : "—"} + {val != null ? `${val.toFixed(1)} m²` : }
    ); }, @@ -443,7 +507,7 @@ const optionalColumns: ColumnDef[] = [ const val = row.original.co2Emissions; return (
    - {val != null ? `${val.toFixed(1)} kg/m²/yr` : "—"} + {val != null ? `${val.toFixed(1)} kg/m²/yr` : }
    ); }, @@ -454,7 +518,7 @@ const optionalColumns: ColumnDef[] = [ header: () =>
    Main Fuel
    , cell: ({ row }) => { const label = resolveEnumLabel(MAINFUEL_OPTIONS, row.original.mainfuel); - return label ? {label} : ; + return label ? {label} : ; }, }, { @@ -484,7 +548,7 @@ function DescriptorCell({ value }: { value: string | null }) {
    @@ -497,3 +561,9 @@ export const columns: ColumnDef[] = [ ...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[] = + columns.filter((c) => c.id !== "select"); diff --git a/src/app/portfolio/[slug]/components/useTags.ts b/src/app/portfolio/[slug]/components/useTags.ts new file mode 100644 index 00000000..582f48c9 --- /dev/null +++ b/src/app/portfolio/[slug]/components/useTags.ts @@ -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 { + 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] }); + }; +} diff --git a/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx b/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx index 3d4808d4..fa074bc8 100644 --- a/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx +++ b/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx @@ -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; onToggle: (value: string) => void; + emptyHint?: React.ReactNode; }) { return (
    @@ -94,28 +98,38 @@ function FilterColumn({
    - {options.map((o) => { - const on = selected.has(o.value); - return ( - - ); - })} + {options.length === 0 && emptyHint ? ( +

    {emptyHint}

    + ) : ( + options.map((o) => { + const on = selected.has(o.value); + return ( + + ); + }) + )}
    ); @@ -135,6 +149,7 @@ export default function RunModellingClient({ const [selPcs, setSelPcs] = useState>(new Set()); const [selTypes, setSelTypes] = useState>(new Set()); const [selForms, setSelForms] = useState>(new Set()); + const [selTags, setSelTags] = useState>(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 (
    @@ -376,7 +400,7 @@ export default function RunModellingClient({ takes priority over its EPC; “Unknown” picks out homes where we have neither.

    -
    +
    {postcodesQuery.isError ? (
    @@ -422,10 +446,26 @@ export default function RunModellingClient({ selected={selForms} onToggle={(v) => toggle(selForms, setSelForms, v)} /> + ({ + 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. + + } + />

    - Filters are limited to {MAX_FILTER_POSTCODES} postcodes per run. Tags are coming — - you'll be able to save a selection and reuse it. + Filters are limited to {MAX_FILTER_POSTCODES} postcodes per run.

    @@ -560,17 +600,34 @@ export default function RunModellingClient({
    + {tagsBlockRun && ( +
    + + You can preview a tag selection now, but running scenarios over + tags needs a modelling-engine update that's on the way. The + count above is exact — the run button unlocks once it lands. + +
    + )} {(remodel.length > 0 || run.isError) && (
    {remodel.map((p) => ( diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index c789b6b3..745a0e4f 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -715,6 +715,38 @@ function buildConditionSql(filter: PropertyFilter): ReturnType | 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[] = []; + 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 { + 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. diff --git a/src/app/utils/propertyFilters.ts b/src/app/utils/propertyFilters.ts index f07f135f..56b178a6 100644 --- a/src/app/utils/propertyFilters.ts +++ b/src/app/utils/propertyFilters.ts @@ -15,7 +15,8 @@ export type FilterField = | "mainfuel" | "wallType" | "roofType" - | "heatingSystem"; + | "heatingSystem" + | "tags"; export type FilterOperator = | "contains" diff --git a/src/lib/modellingRuns/model.test.ts b/src/lib/modellingRuns/model.test.ts index 5bd39f8c..b37789bc 100644 --- a/src/lib/modellingRuns/model.test.ts +++ b/src/lib/modellingRuns/model.test.ts @@ -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", () => { diff --git a/src/lib/modellingRuns/model.ts b/src/lib/modellingRuns/model.ts index 179af4a7..103858f9 100644 --- a/src/lib/modellingRuns/model.ts +++ b/src/lib/modellingRuns/model.ts @@ -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(); + 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), diff --git a/src/lib/modellingRuns/server.ts b/src/lib/modellingRuns/server.ts index 078abeb2..f6c77b01 100644 --- a/src/lib/modellingRuns/server.ts +++ b/src/lib/modellingRuns/server.ts @@ -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; diff --git a/src/lib/tags/bulkAssign.test.ts b/src/lib/tags/bulkAssign.test.ts new file mode 100644 index 00000000..a93c1a1a --- /dev/null +++ b/src/lib/tags/bulkAssign.test.ts @@ -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), + ); + }); +}); diff --git a/src/lib/tags/bulkAssign.ts b/src/lib/tags/bulkAssign.ts new file mode 100644 index 00000000..10cd1cb0 --- /dev/null +++ b/src/lib/tags/bulkAssign.ts @@ -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(); + 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] }; +} diff --git a/src/lib/tags/colour.test.ts b/src/lib/tags/colour.test.ts new file mode 100644 index 00000000..90d5524d --- /dev/null +++ b/src/lib/tags/colour.test.ts @@ -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); + }); +}); diff --git a/src/lib/tags/colour.ts b/src/lib/tags/colour.ts new file mode 100644 index 00000000..88986148 --- /dev/null +++ b/src/lib/tags/colour.ts @@ -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), + }; +} diff --git a/src/lib/tags/model.test.ts b/src/lib/tags/model.test.ts new file mode 100644 index 00000000..1b012fc0 --- /dev/null +++ b/src/lib/tags/model.test.ts @@ -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" }, + }); + }); +}); diff --git a/src/lib/tags/model.ts b/src/lib/tags/model.ts new file mode 100644 index 00000000..49e8f39e --- /dev/null +++ b/src/lib/tags/model.ts @@ -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 } }; +} diff --git a/src/lib/tags/server.ts b/src/lib/tags/server.ts new file mode 100644 index 00000000..c4b00183 --- /dev/null +++ b/src/lib/tags/server.ts @@ -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 { + 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"; +}