feat(tags): consolidate bulk-tag actions into a toolbar Tags menu

Replaces the isolated "Assign tags from file" button with a single Tags ▾
dropdown in the toolbar (grouped with Export / Add properties), offering the
three bulk paths: upload from file, assign a tag, remove a tag.

Assign/Remove enter a lightweight contextual mode (TagActionBar) whose target
follows the live table — the row selection if any, else the active filter set,
else the whole portfolio — so ticking rows or changing filters re-scopes it in
place, and it supports removal, not just assignment. Row checkboxes stay visible
at all times; a "N selected" chip now sits by the results count. The upload
modal gains downloadable CSV templates (Property Ref / UPRN).

Removes TagBulkBar (superseded by TagsMenu + TagActionBar).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-13 21:17:18 +00:00
parent 47fd733b45
commit 5872e7c335
5 changed files with 330 additions and 214 deletions

View file

@ -34,6 +34,23 @@ const KEY_LABEL: Record<IdentifierKey, string> = {
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
@ -199,6 +216,26 @@ export function BulkTagUploadModal({
/>
</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>
)}

View file

@ -34,7 +34,9 @@ import {
RowSelectionState,
} from "@tanstack/react-table";
import { Tooltip } from "./Tooltip";
import { TagBulkBar } from "./TagBulkBar";
import { TagsMenu } from "./TagsMenu";
import { TagActionBar, TagMode } from "./TagActionBar";
import { BulkTagUploadModal } from "./BulkTagUploadModal";
import {
DropdownMenu,
@ -323,6 +325,11 @@ export default function PropertyTable({
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>(
() => {
@ -568,6 +575,17 @@ 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>
)}
{hasActiveFilters && (
<button
onClick={clearAll}
@ -664,6 +682,13 @@ export default function PropertyTable({
</button>
)}
{/* Tags — bulk assign / remove / upload (ADR-0013) */}
<TagsMenu
onUpload={() => setTagUploadOpen(true)}
onAssign={() => setTagMode("assign")}
onRemove={() => setTagMode("remove")}
/>
{/* Add properties */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -775,14 +800,25 @@ export default function PropertyTable({
/>
</div>
{/* Bulk tag actions (selection-driven, filter-driven, file upload) */}
<TagBulkBar
{/* 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)}
/>
)}
<BulkTagUploadModal
portfolioId={portfolioId}
selectedIds={selectedIds}
onClearSelection={() => setRowSelection({})}
hasActiveFilters={hasActiveFilters}
filterGroups={allFilterGroups}
filteredTotal={filteredTotal}
open={tagUploadOpen}
onOpenChange={setTagUploadOpen}
/>
{/* Display-limit notice */}

View file

@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { ChevronDownIcon, XMarkIcon } from "@heroicons/react/24/outline";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import { FilterGroups } from "@/app/utils/propertyFilters";
import { tagDotColour } from "@/lib/tags/colour";
import { Tag, useInvalidateTagSurfaces, usePortfolioTags } from "./useTags";
export type TagMode = "assign" | "remove";
/**
* The contextual strip shown while assigning or removing a tag in bulk
* (ADR-0013). Its target follows the live table state: the row selection if any,
* else every property matching the active filter, else the whole portfolio
* so ticking rows or changing filters re-scopes it in place. Picking a tag adds
* (assign) or deletes (remove) memberships server-side and reports the count.
*/
export function TagActionBar({
portfolioId,
mode,
selectedIds,
hasActiveFilters,
filterGroups,
filteredTotal,
totalCount,
onClose,
}: {
portfolioId: string;
mode: TagMode;
selectedIds: string[];
hasActiveFilters: boolean;
filterGroups: FilterGroups;
filteredTotal: number;
totalCount: number;
onClose: () => void;
}) {
const { data: tags = [] } = usePortfolioTags(portfolioId);
const invalidate = useInvalidateTagSurfaces(portfolioId);
const [result, setResult] = useState<string | null>(null);
const action = mode === "assign" ? "add" : "remove";
const verb = mode === "assign" ? "Assign" : "Remove";
const prep = mode === "assign" ? "to" : "from";
// Target precedence: explicit selection → active filter set → whole portfolio.
const target =
selectedIds.length > 0
? {
body: { mode: "properties" as const, propertyIds: selectedIds },
label: `${selectedIds.length.toLocaleString()} selected propert${selectedIds.length === 1 ? "y" : "ies"}`,
}
: hasActiveFilters
? {
body: { mode: "filter" as const, filterGroups },
label: `all ${filteredTotal.toLocaleString()} matching your filters`,
}
: {
body: { mode: "filter" as const, filterGroups: [] as FilterGroups },
label: `all ${totalCount.toLocaleString()} properties`,
};
const apply = useMutation<{ changed: number }, Error, Tag>({
mutationFn: async (tag) => {
const res = await fetch(
`/api/portfolio/${portfolioId}/tags/${tag.id}/assignments`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...target.body, action }),
},
);
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Couldn't update tags");
return body;
},
onSuccess: (data, tag) => {
setResult(
`${mode === "assign" ? "Added" : "Removed"}${tag.name}${prep} ${data.changed.toLocaleString()} propert${data.changed === 1 ? "y" : "ies"}.`,
);
invalidate();
},
onError: (e) => setResult(e.message),
});
return (
<div className="mb-3 flex flex-wrap items-center gap-2.5 rounded-lg border border-brandmidblue/30 bg-brandlightblue/40 px-3 py-2">
<span className="text-xs font-bold text-brandblue">{verb} a tag</span>
<span className="text-xs text-slate-600">
{prep} <span className="font-semibold text-brandblue">{target.label}</span>
<span className="text-slate-400"> tick rows or apply filters to change this</span>
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
disabled={apply.isLoading}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold text-white transition hover:opacity-90 disabled:opacity-50"
>
{apply.isLoading ? "Applying…" : "Choose a tag"}
<ChevronDownIcon className="h-3.5 w-3.5 opacity-70" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-72 w-56 overflow-y-auto">
<DropdownMenuLabel className="text-xs">
{verb} which tag?
</DropdownMenuLabel>
<DropdownMenuSeparator />
{tags.length === 0 ? (
<div className="px-2 py-2 text-xs text-slate-400">
No tags yet.{" "}
<a
href={`/portfolio/${portfolioId}/settings/tags`}
className="font-semibold text-brandmidblue hover:underline"
>
Create one
</a>
.
</div>
) : (
tags.map((t) => (
<DropdownMenuItem
key={t.id}
className="flex cursor-pointer items-center gap-2"
onSelect={() => apply.mutate(t)}
>
<span
className="h-3 w-3 shrink-0 rounded-full border"
style={{ backgroundColor: tagDotColour(t.colour) }}
/>
<span className="truncate text-xs">{t.name}</span>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
{result && (
<span className="rounded-lg border border-emerald-200 bg-emerald-50 px-2.5 py-1 text-xs text-emerald-800">
{result}
</span>
)}
<button
onClick={onClose}
className="ml-auto inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold text-slate-500 hover:text-brandblue"
>
<XMarkIcon className="h-3.5 w-3.5" />
Done
</button>
</div>
);
}

View file

@ -1,206 +0,0 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { ChevronDownIcon, DocumentArrowUpIcon, 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";
import { BulkTagUploadModal } from "./BulkTagUploadModal";
type Target =
| { mode: "properties"; propertyIds: string[] }
| { mode: "filter"; filterGroups: FilterGroups };
/**
* Bulk tag actions for the property table (ADR-0013): tag the current row
* selection ({mode:"properties"}), tag every property matching the active
* filter ({mode:"filter"} the whole set, resolved server-side, not just the
* loaded page), and the file-upload entry point. Renders quietly when idle.
*/
export function TagBulkBar({
portfolioId,
selectedIds,
onClearSelection,
hasActiveFilters,
filterGroups,
filteredTotal,
}: {
portfolioId: string;
selectedIds: string[];
onClearSelection: () => void;
hasActiveFilters: boolean;
filterGroups: FilterGroups;
filteredTotal: number;
}) {
const { data: tags = [] } = usePortfolioTags(portfolioId);
const invalidate = useInvalidateTagSurfaces(portfolioId);
const [uploadOpen, setUploadOpen] = useState(false);
const [result, setResult] = useState<string | null>(null);
const assign = useMutation<{ changed: number }, Error, { tag: Tag; target: Target }>({
mutationFn: async ({ tag, target }) => {
const res = await fetch(
`/api/portfolio/${portfolioId}/tags/${tag.id}/assignments`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...target, action: "add" }),
},
);
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Couldn't apply the tag");
return body;
},
onSuccess: (data, { tag, target }) => {
setResult(
`Tagged ${data.changed.toLocaleString()} propert${data.changed === 1 ? "y" : "ies"} with “${tag.name}”.`,
);
invalidate();
if (target.mode === "properties") onClearSelection();
},
onError: (e) => setResult(e.message),
});
const hasSelection = selectedIds.length > 0;
return (
<div className="mb-3 flex flex-wrap items-center gap-2">
{hasSelection && (
<div className="flex items-center gap-2 rounded-lg border border-brandmidblue/30 bg-brandlightblue/40 px-2.5 py-1.5">
<span className="text-xs font-bold text-brandblue">
{selectedIds.length.toLocaleString()} selected
</span>
<TagMenu
label="Tag selected"
tags={tags}
portfolioId={portfolioId}
disabled={assign.isLoading}
onPick={(tag) =>
assign.mutate({ tag, target: { mode: "properties", propertyIds: selectedIds } })
}
/>
<button
onClick={onClearSelection}
className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-xs font-semibold text-slate-500 hover:text-brandblue"
>
<XMarkIcon className="h-3.5 w-3.5" />
Clear
</button>
</div>
)}
{hasActiveFilters && (
<TagMenu
label={`Tag all ${filteredTotal.toLocaleString()} matching`}
tags={tags}
portfolioId={portfolioId}
disabled={assign.isLoading}
onPick={(tag) =>
assign.mutate({ tag, target: { mode: "filter", filterGroups } })
}
variant="solid"
/>
)}
<button
onClick={() => setUploadOpen(true)}
className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-primary transition hover:bg-slate-50"
>
<DocumentArrowUpIcon className="h-3.5 w-3.5" />
Assign tags from file
</button>
{result && (
<span className="inline-flex items-center gap-1.5 rounded-lg border border-emerald-200 bg-emerald-50 px-2.5 py-1 text-xs text-emerald-800">
{result}
<button
onClick={() => setResult(null)}
className="rounded hover:text-emerald-950"
aria-label="Dismiss"
>
<XMarkIcon className="h-3 w-3" />
</button>
</span>
)}
<BulkTagUploadModal
portfolioId={portfolioId}
open={uploadOpen}
onOpenChange={setUploadOpen}
/>
</div>
);
}
function TagMenu({
label,
tags,
portfolioId,
disabled,
onPick,
variant = "outline",
}: {
label: string;
tags: Tag[];
portfolioId: string;
disabled?: boolean;
onPick: (tag: Tag) => void;
variant?: "outline" | "solid";
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
disabled={disabled}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold transition disabled:opacity-50 ${
variant === "solid"
? "bg-primary text-white hover:opacity-90"
: "border border-brandmidblue/40 bg-white text-brandblue hover:bg-white"
}`}
>
{label}
<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">Apply 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={() => onPick(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>
);
}

View file

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