diff --git a/src/app/portfolio/[slug]/components/BulkTagUploadModal.tsx b/src/app/portfolio/[slug]/components/BulkTagUploadModal.tsx index 992f8e14..21e4ffed 100644 --- a/src/app/portfolio/[slug]/components/BulkTagUploadModal.tsx +++ b/src/app/portfolio/[slug]/components/BulkTagUploadModal.tsx @@ -34,6 +34,23 @@ const KEY_LABEL: Record = { 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({ /> +

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

+ {parseError && (

{parseError}

)} diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index 15c0a9a0..8c8b86c9 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -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({}); const selectedIds = Object.keys(rowSelection).filter((id) => rowSelection[id]); + // Bulk-tag flows launched from the Tags menu: a contextual assign/remove mode, + // and the file-upload modal. + const [tagMode, setTagMode] = useState(null); + const [tagUploadOpen, setTagUploadOpen] = useState(false); + // Column visibility — lifted up from DataTable const [columnVisibility, setColumnVisibility] = useState( () => { @@ -568,6 +575,17 @@ export default function PropertyTable({ properties )} + {selectedIds.length > 0 && ( + + {selectedIds.length.toLocaleString()} selected + + + )} {hasActiveFilters && ( + + + + {verb} which tag? + + + {tags.length === 0 ? ( +
+ No tags yet.{" "} + + Create one + + . +
+ ) : ( + tags.map((t) => ( + apply.mutate(t)} + > + + {t.name} + + )) + )} +
+ + + {result && ( + + {result} + + )} + + + + ); +} diff --git a/src/app/portfolio/[slug]/components/TagBulkBar.tsx b/src/app/portfolio/[slug]/components/TagBulkBar.tsx deleted file mode 100644 index 5315f9ee..00000000 --- a/src/app/portfolio/[slug]/components/TagBulkBar.tsx +++ /dev/null @@ -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(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 ( -
- {hasSelection && ( -
- - {selectedIds.length.toLocaleString()} selected - - - assign.mutate({ tag, target: { mode: "properties", propertyIds: selectedIds } }) - } - /> - -
- )} - - {hasActiveFilters && ( - - assign.mutate({ tag, target: { mode: "filter", filterGroups } }) - } - variant="solid" - /> - )} - - - - {result && ( - - {result} - - - )} - - -
- ); -} - -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 ( - - - - - - Apply tag - - {tags.length === 0 ? ( -
- No tags yet.{" "} - - Create one - - . -
- ) : ( - tags.map((t) => ( - onPick(t)} - > - - {t.name} - - )) - )} -
-
- ); -} diff --git a/src/app/portfolio/[slug]/components/TagsMenu.tsx b/src/app/portfolio/[slug]/components/TagsMenu.tsx new file mode 100644 index 00000000..ad1f02a5 --- /dev/null +++ b/src/app/portfolio/[slug]/components/TagsMenu.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/app/shadcn_components/ui/dropdown-menu"; +import { + ChevronDownIcon, + DocumentArrowUpIcon, + PlusIcon, + MinusIcon, + TagIcon, +} from "@heroicons/react/24/outline"; + +/** + * The single Tags entry point in the property-table toolbar (ADR-0013): a + * dropdown offering the three bulk-tag paths. Upload opens the file modal; + * Assign / Remove enter a contextual mode (TagActionBar) that operates on the + * row selection or the active filter. Consolidates what was an isolated button. + */ +export function TagsMenu({ + onUpload, + onAssign, + onRemove, +}: { + onUpload: () => void; + onAssign: () => void; + onRemove: () => void; +}) { + return ( + + + + + + } + title="Upload tags from file" + desc="Match a spreadsheet of IDs to a tag" + onSelect={onUpload} + /> + } + title="Assign a tag" + desc="Apply a tag to selected rows or your filtered results" + onSelect={onAssign} + /> + } + title="Remove a tag" + desc="Take a tag off selected rows or your filtered results" + onSelect={onRemove} + /> + + + ); +} + +function Item({ + icon, + title, + desc, + onSelect, +}: { + icon: React.ReactNode; + title: string; + desc: string; + onSelect: () => void; +}) { + return ( + + {icon} + + {title} + {desc} + + + ); +}