diff --git a/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx b/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx index 96aaaa22d..154ce3155 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx @@ -6,8 +6,10 @@ import type { FilterDef, SortOption } from "./types"; import type { UseFilters } from "./useFilters"; import { FilterPill } from "./FilterPill"; import { MoreFiltersMenu } from "./MoreFiltersMenu"; -import { SavedViews } from "./SavedViews"; +import { SaveViewButton } from "./SaveViewButton"; +import { SavedViewCards } from "./SavedViewCards"; import { SortControl } from "./SortControl"; +import { useSavedViews } from "./useSavedViews"; export interface FilterBarProps { defs: FilterDef[]; @@ -44,22 +46,32 @@ export function FilterBar({ ...pinned.filter((d) => !controls.values[d.key]), ]; - return ( - // Two independent flex zones, not one big wrapping Group: the left side - // (search + pills + more filters + clear) wraps to as many lines as it - // needs; the right side (sort) stays put on the first line — `nowrap` + - // `flexShrink: 0` on the right zone stop it from ever getting pushed - // down when the left side overflows. -
- - {viewId && ( - - )} + // Unconditional call (rules of hooks) — viewId is a per-page constant, and + // the hook is a no-op storage key when saved views aren't wired up. + const savedViews = useSavedViews(viewId ?? "__unset__"); + const activeQuery = controls.currentQueryString(); + const hasMatchingView = savedViews.views.some((v) => v.query === activeQuery); + const canSaveView = Boolean(viewId) && activeQuery.length > 0 && !hasMatchingView; + return ( +
+ {viewId && ( + + )} + + {/* Two independent flex zones, not one big wrapping Group: the left side + (search + pills + more filters + clear) wraps to as many lines as it + needs; the right side (sort) stays put on the first line — `nowrap` + + `flexShrink: 0` on the right zone stop it from ever getting pushed + down when the left side overflows. */} +
+ {showSearch && ( 0 && ( { @@ -98,7 +110,7 @@ export function FilterBar({ }} style={{ display: "inline-flex", alignItems: "center", gap: 4 }} > - + Clear )} @@ -115,7 +127,14 @@ export function FilterBar({ )} + {canSaveView && ( + <> + + + + )} +
); } diff --git a/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx b/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx index 40ba372f8..7a4a76998 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx +++ b/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx @@ -3,6 +3,7 @@ import { ActionIcon, Button, Popover } from "@mantine/core"; import { Plus, X } from "lucide-react"; import type { FilterDef, FilterValue } from "./types"; +import { formatFilterValue } from "./format"; import { BooleanBody } from "./bodies/BooleanBody"; import { DateBody } from "./bodies/DateBody"; import { EnumBody } from "./bodies/EnumBody"; @@ -17,18 +18,6 @@ const BODIES: Record> = { boolean: BooleanBody, }; -function formatValue(def: FilterDef, value: FilterValue): string { - if (def.format) return def.format(value, def); - if (def.type === "enum") { - const labels = value.v.map((v) => def.options.find((o) => o.value === v)?.label ?? v); - return labels.join(", "); - } - if (def.type === "date" && value.v.length === 2) { - return `${value.v[0].slice(0, 10)} → ${value.v[1].slice(0, 10)}`; - } - return value.v.join(", "); -} - export interface FilterPillProps { def: FilterDef; value: FilterValue | undefined; @@ -77,7 +66,7 @@ export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps) } onClick={() => setOpened((o) => !o)} > - {active ? `${def.label} | ${formatValue(def, value!)}` : def.label} + {active ? `${def.label} | ${formatFilterValue(def, value!)}` : def.label} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/MoreFiltersMenu.tsx b/apps/edr-freight-web/backoffice/src/components/filters/MoreFiltersMenu.tsx index 2dc6c1240..c557bbbc3 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/MoreFiltersMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/filters/MoreFiltersMenu.tsx @@ -29,8 +29,9 @@ export function MoreFiltersMenu({ defs, onPick }: MoreFiltersMenuProps) { + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/SavedViewCards.tsx b/apps/edr-freight-web/backoffice/src/components/filters/SavedViewCards.tsx new file mode 100644 index 000000000..bce4c235e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/SavedViewCards.tsx @@ -0,0 +1,67 @@ +import { ActionIcon, Card, SimpleGrid, Text } from "@mantine/core"; +import { Trash2 } from "lucide-react"; + +import { useToast } from "@/hooks/use-toast"; +import type { FilterDef } from "./types"; +import { describeQuery } from "./format"; +import type { SavedView } from "./useSavedViews"; + +export interface SavedViewCardsProps { + defs: FilterDef[]; + views: SavedView[]; + activeQuery: string; + applyQueryString: (query: string) => void; + onRemove: (id: string) => void; +} + +/** Saved views up front as a grid of cards — not one more item buried in a + * dropdown nobody opens. Renders nothing until there's at least one saved. */ +export function SavedViewCards({ defs, views, activeQuery, applyQueryString, onRemove }: SavedViewCardsProps) { + const { toast } = useToast(); + if (views.length === 0) return null; + + return ( + + {views.map((v) => { + const active = v.query === activeQuery; + const label = describeQuery(defs, v.query); + return ( + { + applyQueryString(v.query); + toast({ title: `Switched to "${label}"` }); + }} + style={{ + cursor: "pointer", + borderColor: active ? "var(--mantine-color-edr-green-6)" : undefined, + borderWidth: active ? 2 : 1, + backgroundColor: active ? "var(--mantine-color-edr-green-0)" : undefined, + }} + > +
+ + {label} + + { + e.stopPropagation(); + onRemove(v.id); + toast({ title: "View deleted", description: label, variant: "destructive" }); + }} + > + + +
+
+ ); + })} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/SavedViews.tsx b/apps/edr-freight-web/backoffice/src/components/filters/SavedViews.tsx deleted file mode 100644 index 826356159..000000000 --- a/apps/edr-freight-web/backoffice/src/components/filters/SavedViews.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useState } from "react"; -import { ActionIcon, Button, Menu, Modal, Stack, Text, TextInput } from "@mantine/core"; -import { useLocalStorage } from "@mantine/hooks"; -import { Bookmark, Check, Save, Trash2 } from "lucide-react"; - -interface SavedView { - id: string; - name: string; - query: string; -} - -export interface SavedViewsProps { - /** localStorage namespace — one page, not one user (single staff login per - * browser profile). ponytail: add ":" if shared-terminal login appears. */ - viewId: string; - currentQueryString: () => string; - applyQueryString: (query: string) => void; -} - -/** URL always wins: this menu only ever WRITES the URL, on click. Nothing - * reads a saved view at mount, so a shared link always beats a saved view — - * there is no "which one applies" branch to get wrong. */ -export function SavedViews({ viewId, currentQueryString, applyQueryString }: SavedViewsProps) { - const [views, setViews] = useLocalStorage({ - key: `edr:saved-views:${viewId}`, - defaultValue: [], - }); - const [saveOpen, setSaveOpen] = useState(false); - const [name, setName] = useState(""); - - const activeQuery = currentQueryString(); - const active = views.find((v) => v.query === activeQuery); - - const save = () => { - if (!name.trim()) return; - setViews((prev) => [ - ...prev, - { id: crypto.randomUUID(), name: name.trim(), query: currentQueryString() }, - ]); - setName(""); - setSaveOpen(false); - }; - - const remove = (id: string) => setViews((prev) => prev.filter((v) => v.id !== id)); - - return ( - <> - - - - - - {views.length === 0 && ( - - - No saved views yet - - - )} - {views.map((v) => ( - : } - rightSection={ - { - e.stopPropagation(); - remove(v.id); - }} - > - - - } - onClick={() => applyQueryString(v.query)} - > - {v.name} - - ))} - - } onClick={() => setSaveOpen(true)}> - Save current view… - - - - - setSaveOpen(false)} title="Save current view" size="sm"> - - setName(e.currentTarget.value)} - onKeyDown={(e) => e.key === "Enter" && save()} - autoFocus - /> - - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/format.ts b/apps/edr-freight-web/backoffice/src/components/filters/format.ts new file mode 100644 index 000000000..c3ca16798 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/format.ts @@ -0,0 +1,36 @@ +import { parseFilters } from "./url"; +import type { FilterDef, FilterValue } from "./types"; + +/** Human-readable text for one filter's current value — same text a + * FilterPill shows, and what a saved view's auto-generated label is built + * from, so both read identically with zero duplicated logic. */ +export function formatFilterValue(def: FilterDef, value: FilterValue): string { + if (def.format) return def.format(value, def); + if (def.type === "enum") { + const labels = value.v.map((v) => def.options.find((o) => o.value === v)?.label ?? v); + return labels.join(", "); + } + if (def.type === "date" && value.v.length === 2) { + return `${value.v[0].slice(0, 10)} → ${value.v[1].slice(0, 10)}`; + } + return value.v.join(", "); +} + +/** + * Auto-generated label for a saved view — "Status: Active, Draft · Direction: + * Import" — built straight from the filters it holds, instead of asking the + * user to type a name (which drifts out of sync with what the view actually + * filters the moment they edit it). Falls back to "All" when nothing decodes, + * though a view is only ever offered for saving with at least one active filter. + * + * Namespace-aware pages (`useFilters({ ns })`, for a second table on the same + * page) aren't decoded here — every current saved-view page is single-table. + * Thread `ns` through if/when that changes. + */ +export function describeQuery(defs: FilterDef[], query: string): string { + const values = parseFilters(defs, new URLSearchParams(query)); + const parts = defs + .filter((d) => values[d.key]) + .map((d) => `${d.label}: ${formatFilterValue(d, values[d.key])}`); + return parts.length ? parts.join(" · ") : "All"; +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/index.ts b/apps/edr-freight-web/backoffice/src/components/filters/index.ts index 78fceb9ff..0e3e533bd 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/filters/index.ts @@ -1,10 +1,13 @@ export * from "./types"; export * from "./url"; export * from "./dates"; +export * from "./format"; export * from "./useFilters"; +export * from "./useSavedViews"; export { FilterBar } from "./FilterBar"; export type { FilterBarProps } from "./FilterBar"; export { FilterPill } from "./FilterPill"; export { SortControl } from "./SortControl"; -export { SavedViews } from "./SavedViews"; +export { SaveViewButton } from "./SaveViewButton"; +export { SavedViewCards } from "./SavedViewCards"; export { MoreFiltersMenu } from "./MoreFiltersMenu"; diff --git a/apps/edr-freight-web/backoffice/src/components/filters/useSavedViews.ts b/apps/edr-freight-web/backoffice/src/components/filters/useSavedViews.ts new file mode 100644 index 000000000..f08738562 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/useSavedViews.ts @@ -0,0 +1,32 @@ +import { useLocalStorage } from "@mantine/hooks"; + +export interface SavedView { + id: string; + /** Raw query string ("statuses=ACTIVE&sort=createdAt:DESC") — the label is + * derived from this at render time (see format.ts's describeQuery), so + * there's nothing else to keep in sync. */ + query: string; + savedAt: number; +} + +/** + * localStorage namespace is per PAGE (viewId), not per user — this is a + * backoffice, one staff login per browser profile. + * ponytail: add ":" if shared-terminal login appears. + */ +export function useSavedViews(viewId: string) { + const [views, setViews] = useLocalStorage({ + key: `edr:saved-views:${viewId}`, + defaultValue: [], + }); + + const save = (query: string): SavedView => { + const view: SavedView = { id: crypto.randomUUID(), query, savedAt: Date.now() }; + setViews((prev) => [...prev, view]); + return view; + }; + + const remove = (id: string) => setViews((prev) => prev.filter((v) => v.id !== id)); + + return { views, save, remove }; +}