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) {
}
onClick={() => setOpened((o) => !o)}
>
diff --git a/apps/edr-freight-web/backoffice/src/components/filters/SaveViewButton.tsx b/apps/edr-freight-web/backoffice/src/components/filters/SaveViewButton.tsx
new file mode 100644
index 000000000..54db1da70
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/filters/SaveViewButton.tsx
@@ -0,0 +1,46 @@
+import { useState } from "react";
+import { Button } from "@mantine/core";
+import { Check, Save } 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 SaveViewButtonProps {
+ defs: FilterDef[];
+ query: string;
+ onSave: (query: string) => SavedView;
+}
+
+/** Filled, not outline — this is the one action-y button in the bar (every
+ * other control here is a filter), so it needs to actually look like a
+ * button. One click, no name prompt: the card grid's label is generated
+ * from the active filters (see `describeQuery`). */
+export function SaveViewButton({ defs, query, onSave }: SaveViewButtonProps) {
+ const { toast } = useToast();
+ const [justSaved, setJustSaved] = useState(false);
+
+ const handleSave = () => {
+ onSave(query);
+ toast({ title: "View saved", description: describeQuery(defs, query), duration: 4000 });
+ // The toast is in the corner; this flash is right where the eye already
+ // is — the actual confirmation that "the saving" registered.
+ setJustSaved(true);
+ setTimeout(() => setJustSaved(false), 1500);
+ };
+
+ return (
+ : }
+ onClick={handleSave}
+ disabled={justSaved}
+ >
+ {justSaved ? "Saved" : "Save"}
+
+ );
+}
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 (
- <>
-
-
- }>
- {active?.name ?? "All"}
-
-
-
- {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
- />
-
- Save
-
-
-
- >
- );
-}
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 };
+}