mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
47fd733b45
commit
5872e7c335
5 changed files with 330 additions and 214 deletions
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue