mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
Merge pull request #389 from Hestia-Homes/feature/portfolio-tagging
feat: portfolio tagging system (ADR-0013)
This commit is contained in:
commit
fc5fc80d88
37 changed files with 3069 additions and 173 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 `<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?
|
||||
12
CONTEXT.md
12
CONTEXT.md
|
|
@ -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:
|
||||
|
|
|
|||
101
docs/adr/0013-tags-are-app-owned-property-groupings.md
Normal file
101
docs/adr/0013-tags-are-app-owned-property-groupings.md
Normal 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.
|
||||
73
docs/backend-asks/tag-ids-run-filter.md
Normal file
73
docs/backend-asks/tag-ids-run-filter.md
Normal 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.
|
||||
|
|
@ -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({}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
113
src/app/api/portfolio/[portfolioId]/tags/[tagId]/route.ts
Normal file
113
src/app/api/portfolio/[portfolioId]/tags/[tagId]/route.ts
Normal 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 });
|
||||
}
|
||||
135
src/app/api/portfolio/[portfolioId]/tags/route.ts
Normal file
135
src/app/api/portfolio/[portfolioId]/tags/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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`}
|
||||
|
|
|
|||
237
src/app/portfolio/[slug]/(portfolio)/settings/tags/TagsCard.tsx
Normal file
237
src/app/portfolio/[slug]/(portfolio)/settings/tags/TagsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/app/portfolio/[slug]/(portfolio)/settings/tags/page.tsx
Normal file
12
src/app/portfolio/[slug]/(portfolio)/settings/tags/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
324
src/app/portfolio/[slug]/components/BulkTagUploadModal.tsx
Normal file
324
src/app/portfolio/[slug]/components/BulkTagUploadModal.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
161
src/app/portfolio/[slug]/components/TagActionBar.tsx
Normal file
161
src/app/portfolio/[slug]/components/TagActionBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/app/portfolio/[slug]/components/TagChip.tsx
Normal file
59
src/app/portfolio/[slug]/components/TagChip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
src/app/portfolio/[slug]/components/TagsCell.tsx
Normal file
208
src/app/portfolio/[slug]/components/TagsCell.tsx
Normal 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'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));
|
||||
}
|
||||
88
src/app/portfolio/[slug]/components/TagsMenu.tsx
Normal file
88
src/app/portfolio/[slug]/components/TagsMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)} m²` : "—"}
|
||||
{val != null ? `${val.toFixed(1)} m²` : <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");
|
||||
|
|
|
|||
45
src/app/portfolio/[slug]/components/useTags.ts
Normal file
45
src/app/portfolio/[slug]/components/useTags.ts
Normal 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] });
|
||||
};
|
||||
}
|
||||
|
|
@ -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; “Unknown” 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'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'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) => (
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ export type FilterField =
|
|||
| "mainfuel"
|
||||
| "wallType"
|
||||
| "roofType"
|
||||
| "heatingSystem";
|
||||
| "heatingSystem"
|
||||
| "tags";
|
||||
|
||||
export type FilterOperator =
|
||||
| "contains"
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
63
src/lib/tags/bulkAssign.test.ts
Normal file
63
src/lib/tags/bulkAssign.test.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
61
src/lib/tags/bulkAssign.ts
Normal file
61
src/lib/tags/bulkAssign.ts
Normal 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] };
|
||||
}
|
||||
82
src/lib/tags/colour.test.ts
Normal file
82
src/lib/tags/colour.test.ts
Normal 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
113
src/lib/tags/colour.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
62
src/lib/tags/model.test.ts
Normal file
62
src/lib/tags/model.test.ts
Normal 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
43
src/lib/tags/model.ts
Normal 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
48
src/lib/tags/server.ts
Normal 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";
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue