Migrates Tailwind v3 -> v4 (CSS-first, theme tokens for brand, workflow states and charts) and adds 23 shadcn/ui components on Radix. - lib/workflow.ts is now the single source of truth for state name, colour, icon and available actions; "Financial Review" is the canonical served name, with "Finance Review" kept as an alias so those rows stop falling through to the unstyled fallback and dropping out of every count - record view: TanStack Table with server-side sort/paginate, company column with initials avatar, page totals, density and a URL-synced query state - dashboard: one cached analytics fetch shared by tiles and charts, donut + aging + vendor spend, click-to-filter - detail view: rebuilt against the live detailview payload — restores the PO number and payment terms that the old key heuristics silently dropped, parses match_summary into a 3-way-match checklist, and separates the department's and reviewer's own words from AI-generated content - form modal on Radix Dialog (focus trap, scroll lock, escape handling)
154 lines
4.9 KiB
TypeScript
154 lines
4.9 KiB
TypeScript
import { useEffect } from "react";
|
|
import { useQueryStates } from "nuqs";
|
|
import { FileText, ListFilter, Moon, Search, Sun, X } from "lucide-react";
|
|
import {
|
|
CommandDialog,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
CommandSeparator,
|
|
CommandShortcut,
|
|
} from "@/components/ui/command";
|
|
import { useTheme } from "@/hooks/ThemeContext";
|
|
import { useHeaderConfig } from "@/hooks/useHeaderConfig";
|
|
import { tableParams, tableParamsOptions } from "@/lib/tableParams";
|
|
import { WORKFLOW_STATES } from "@/lib/workflow";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onNavigate: (screenUuid: string) => void;
|
|
}
|
|
|
|
/**
|
|
* ⌘K palette — jump between screens and apply status filters without leaving
|
|
* the keyboard. This is a queue-triage app; reaching for the mouse to change a
|
|
* filter is the slow path.
|
|
*/
|
|
export default function CommandPalette({ open, onOpenChange, onNavigate }: Props) {
|
|
const { theme, toggleTheme } = useTheme();
|
|
const { navItems } = useHeaderConfig();
|
|
const [params, setParams] = useQueryStates(tableParams, tableParamsOptions);
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
onOpenChange(!open);
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [open, onOpenChange]);
|
|
|
|
const run = (fn: () => void) => {
|
|
fn();
|
|
onOpenChange(false);
|
|
};
|
|
|
|
const filtersActive = params.status.length > 0 || !!params.q;
|
|
|
|
return (
|
|
<CommandDialog
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
title="Command palette"
|
|
description="Jump to a screen or filter the queue"
|
|
>
|
|
<CommandInput placeholder="Jump to a screen, or filter by status…" />
|
|
<CommandList>
|
|
<CommandEmpty>No matches.</CommandEmpty>
|
|
|
|
{navItems.length > 0 && (
|
|
<CommandGroup heading="Screens">
|
|
{navItems.map((item) => (
|
|
<CommandItem
|
|
key={item.id}
|
|
value={`screen ${item.label}`}
|
|
onSelect={() => run(() => onNavigate(item.screen_uuid))}
|
|
>
|
|
<FileText className="size-3.5" aria-hidden />
|
|
{item.label}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
|
|
<CommandSeparator />
|
|
|
|
<CommandGroup heading="Filter by status">
|
|
{WORKFLOW_STATES.map((s) => {
|
|
const selected = params.status.includes(s.name);
|
|
return (
|
|
<CommandItem
|
|
key={s.key}
|
|
value={`filter ${s.label}`}
|
|
onSelect={() =>
|
|
run(() =>
|
|
setParams({
|
|
status: selected
|
|
? params.status.filter((x) => x !== s.name)
|
|
: [...params.status, s.name],
|
|
page: 1,
|
|
}),
|
|
)
|
|
}
|
|
>
|
|
<span className={cn("size-2 shrink-0 rounded-full", s.tone.solid)} aria-hidden />
|
|
{s.label}
|
|
{selected && <CommandShortcut>active</CommandShortcut>}
|
|
</CommandItem>
|
|
);
|
|
})}
|
|
{filtersActive && (
|
|
<CommandItem
|
|
value="clear filters"
|
|
onSelect={() => run(() => setParams({ status: [], q: "", page: 1 }))}
|
|
>
|
|
<X className="size-3.5" aria-hidden />
|
|
Clear all filters
|
|
</CommandItem>
|
|
)}
|
|
</CommandGroup>
|
|
|
|
<CommandSeparator />
|
|
|
|
<CommandGroup heading="Actions">
|
|
<CommandItem
|
|
value="focus search"
|
|
onSelect={() =>
|
|
run(() => {
|
|
// Defer so the dialog has released focus first.
|
|
requestAnimationFrame(() => {
|
|
document
|
|
.querySelector<HTMLInputElement>('input[aria-label="Search records"]')
|
|
?.focus();
|
|
});
|
|
})
|
|
}
|
|
>
|
|
<Search className="size-3.5" aria-hidden />
|
|
Search records
|
|
<CommandShortcut>/</CommandShortcut>
|
|
</CommandItem>
|
|
<CommandItem value="toggle theme" onSelect={() => run(toggleTheme)}>
|
|
{theme === "dark" ? (
|
|
<Sun className="size-3.5" aria-hidden />
|
|
) : (
|
|
<Moon className="size-3.5" aria-hidden />
|
|
)}
|
|
Switch to {theme === "dark" ? "light" : "dark"} mode
|
|
</CommandItem>
|
|
<CommandItem value="filters help" disabled>
|
|
<ListFilter className="size-3.5" aria-hidden />
|
|
Tip: tiles on the dashboard also filter the queue
|
|
</CommandItem>
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</CommandDialog>
|
|
);
|
|
}
|