/** * Saved views: the index, and the table/board surface for one view. * * Unsaved filter, sort, grouping, and presentation changes live in the URL * search params and nowhere else — a reload reverts to the stored view, and a * shared view is never autosaved. Committing a change is an explicit three-way * fork: save it, fork it into a new view, or discard it. */ import { useActionMutation, useActionQuery, } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { IconArrowLeft, IconBookmark, IconLayoutKanban, IconLock, IconPlus, IconTable, IconUsers, } from "@tabler/icons-react"; import { useCallback, useState } from "react"; import { Link, useNavigate, useSearchParams } from "react-router"; import { toast } from "sonner"; import { CrmBoard, type CrmBoardTarget } from "@/components/crm/board/CrmBoard"; import { buildSaveFork, clearBoardDraft, draftIsDirty, effectiveView, groupSavedViews, readBoardDraft, writeBoardDraft, type BoardDraft, type SavedViewShape, } from "@/components/crm/board/view-draft"; import { LoadingRows, PageHeader, SetupEmptyState, } from "@/components/crm/Surface"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; interface SavedViewsResponse { views: SavedViewShape[]; } interface ListsResponse { lists: Array<{ id: string; name: string; parentObjectType: string; defaultViewId: string | null; archived: boolean; }>; } const OBJECT_TYPE_BY_KIND: Record = { account: "accounts", person: "people", opportunity: "opportunities", }; function objectTypeFor(view: SavedViewShape): string { if (view.targetId) return view.targetId; return OBJECT_TYPE_BY_KIND[view.kind ?? ""] ?? "accounts"; } function boardTargetFor(view: SavedViewShape): CrmBoardTarget { if (view.targetKind === "list" && view.targetId) { return { kind: "list", id: view.targetId }; } return { kind: "object", id: objectTypeFor(view), recordKind: view.kind, }; } export default function SavedViewsRoute() { const [params] = useSearchParams(); const viewsQuery = useActionQuery( "list-crm-saved-views" as never, { limit: 100 } as never, ); const listsQuery = useActionQuery( "list-crm-lists" as never, { limit: 100 } as never, ); const viewId = params.get("view"); const listId = params.get("list"); const views = viewsQuery.data?.views ?? []; const lists = listsQuery.data?.lists ?? []; if (viewsQuery.isLoading || listsQuery.isLoading) return ; if (viewId) { const view = views.find((entry) => entry.id === viewId); return view ? ( void viewsQuery.refetch()} /> ) : ( ); } if (listId) { const list = lists.find((entry) => entry.id === listId); if (!list) return ; const defaultView = list.defaultViewId ? views.find((entry) => entry.id === list.defaultViewId) : undefined; return defaultView ? ( void viewsQuery.refetch()} /> ) : ( ); } return ; } // --------------------------------------------------------------------------- // Index // --------------------------------------------------------------------------- function ViewsIndex({ views, lists, }: { views: SavedViewShape[]; lists: ListsResponse["lists"]; }) { const t = useT(); const groups = groupSavedViews(views); const listNames = new Map(lists.map((list) => [list.id, list.name])); return ( <> } /> {groups.length ? (
{groups.map((group) => (

{group.targetKind === "list" ? (listNames.get(group.targetId) ?? t("views.groupList", { target: group.targetId })) : t("views.groupObject", { target: group.targetId })}

{group.views.map((view) => (
{view.viewKind === "board" ? ( ) : ( )}

{view.name}

{view.description ? (

{view.description}

) : null}
{view.dataProgramId ? ( {t("views.dataProgram")} ) : null}
))}
))}
) : ( )} ); } function AudienceBadge({ audience }: { audience: "personal" | "shared" }) { const t = useT(); return ( {audience === "shared" ? ( ) : ( )} {t(`views.audience.${audience}`)} ); } function MissingSurface({ kind }: { kind: "view" | "list" }) { const t = useT(); return ( <> } /> ); } function BackToViews() { const t = useT(); return ( ); } // --------------------------------------------------------------------------- // One saved view // --------------------------------------------------------------------------- function SavedViewSurface({ view, onSaved, }: { view: SavedViewShape; onSaved: () => void; }) { const t = useT(); const navigate = useNavigate(); const [params, setParams] = useSearchParams(); const [resolvedGroupId, setResolvedGroupId] = useState(null); const [statusAttributes, setStatusAttributes] = useState< Array<{ id: string; label: string }> >([]); const save = useActionMutation<{ id: string }, Record>( "save-crm-saved-view" as never, ); const onGrouping = useCallback( (state: { statusAttributes: Array<{ id: string; label: string }>; groupAttributeId: string | null; }) => { setStatusAttributes(state.statusAttributes); setResolvedGroupId(state.groupAttributeId); }, [], ); let draft: BoardDraft; try { draft = readBoardDraft(params); } catch (error) { return ( <> setParams(clearBoardDraft(params), { replace: true }) } > {t("views.resetToSaved")} } /> ); } const effective = effectiveView(view, draft); const dirty = draftIsDirty(view, draft); // A board view must persist an explicit grouping, so a mode switch carries // whatever the board actually grouped by. const saveDraft: BoardDraft = { ...draft, ...(effective.viewKind === "board" ? { groupByAttributeId: draft.groupByAttributeId ?? view.groupByAttributeId ?? resolvedGroupId ?? undefined, } : {}), }; function updateDraft(next: BoardDraft) { setParams(writeBoardDraft(params, { ...draft, ...next }), { replace: true, }); } async function commitFork(branch: "update" | "new", name?: string) { const mutation = buildSaveFork(branch, { view, draft: saveDraft, ...(name === undefined ? {} : { name }), }); if (!mutation) return; try { const saved = await save.mutateAsync(mutation.input); onSaved(); toast.success(t("views.savedToast")); if (branch === "new") { navigate(`/views?view=${encodeURIComponent(saved.id)}`); } else { setParams(clearBoardDraft(params), { replace: true }); } } catch (error) { toast.error( error instanceof Error ? error.message : t("views.saveFailedToast"), ); } } return ( <> updateDraft({ mode })} /> } /> {effective.viewKind === "board" && statusAttributes.length > 1 ? (
{t("views.groupBy")}
) : null} {dirty ? ( void commitFork("update")} onSaveAsNew={(name) => void commitFork("new", name)} onDiscard={() => setParams(clearBoardDraft(params), { replace: true }) } /> ) : null} typeof column === "string" ? [column] : typeof (column as { attributeId?: unknown }).attributeId === "string" ? [(column as { attributeId: string }).attributeId] : [], )} onGrouping={onGrouping} /> ); } function ModeToggle({ mode, onChange, }: { mode: "table" | "board"; onChange: (mode: "table" | "board") => void; }) { const t = useT(); return (
); } function SaveFork({ audience, isSaving, onUpdate, onSaveAsNew, onDiscard, }: { audience: "personal" | "shared"; isSaving: boolean; onUpdate: () => void; onSaveAsNew: (name: string) => void; onDiscard: () => void; }) { const t = useT(); const [open, setOpen] = useState(false); const [name, setName] = useState(""); return (
{t("views.unsavedChanges")} {t("views.saveAsNewTitle")} {t("views.saveAsNewDescription")}
setName(event.target.value)} />
); } // --------------------------------------------------------------------------- // A list with no default view yet // --------------------------------------------------------------------------- function AdHocListSurface({ listId, name }: { listId: string; name: string }) { const t = useT(); const [params, setParams] = useSearchParams(); const mode = params.get("mode") === "table" ? "table" : "board"; return ( <> { const updated = new URLSearchParams(params); updated.set("mode", next); setParams(updated, { replace: true }); }} /> } /> ); } // --------------------------------------------------------------------------- // Create // --------------------------------------------------------------------------- function CreateSavedViewDialog() { const t = useT(); const navigate = useNavigate(); const [open, setOpen] = useState(false); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [kind, setKind] = useState("account"); const save = useActionMutation<{ id: string }, Record>( "save-crm-saved-view" as never, ); async function createView() { try { const saved = await save.mutateAsync({ name: name.trim(), ...(description.trim() ? { description: description.trim() } : {}), kind, viewKind: "table", targetKind: "object", targetId: OBJECT_TYPE_BY_KIND[kind] ?? "accounts", audience: "personal", }); setOpen(false); navigate(`/views?view=${encodeURIComponent(saved.id)}`); } catch (error) { toast.error( error instanceof Error ? error.message : t("views.saveFailedToast"), ); } } return ( {t("views.createTitle")} {t("views.createDescription")}
setName(event.target.value)} />
setDescription(event.target.value)} />
); }