mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Anchor, Divider, Group, TextInput } from "@mantine/core";
|
||||
import { Search, Trash2 } from "lucide-react";
|
||||
|
||||
import type { FilterDef, SortOption } from "./types";
|
||||
import type { UseFilters } from "./useFilters";
|
||||
import { FilterPill } from "./FilterPill";
|
||||
import { MoreFiltersMenu } from "./MoreFiltersMenu";
|
||||
import { SaveViewButton } from "./SaveViewButton";
|
||||
import { SavedViewCards } from "./SavedViewCards";
|
||||
import { SortControl } from "./SortControl";
|
||||
import { useSavedViews } from "./useSavedViews";
|
||||
|
||||
export interface FilterBarProps {
|
||||
defs: FilterDef[];
|
||||
controls: UseFilters;
|
||||
searchPlaceholder?: string;
|
||||
showSearch?: boolean;
|
||||
/** value already "field:DIR" — the page's existing SORT_OPTIONS, moved not rewritten. */
|
||||
sortOptions?: SortOption[];
|
||||
/** localStorage namespace for saved views. Omit to hide the control. */
|
||||
viewId?: string;
|
||||
/** Escape hatch: tabs, row count, a "New" button — rendered at the far right. */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function FilterBar({
|
||||
defs,
|
||||
controls,
|
||||
searchPlaceholder = "Search…",
|
||||
showSearch = true,
|
||||
sortOptions,
|
||||
viewId,
|
||||
children,
|
||||
}: FilterBarProps) {
|
||||
// Filters just picked from "More filters" render as an already-open pill
|
||||
// until the popover closes, then fall back to the ordinary pinned/active split.
|
||||
const [justPicked, setJustPicked] = useState<string[]>([]);
|
||||
|
||||
const pinned = defs.filter((d) => !d.secondary || controls.values[d.key] || justPicked.includes(d.key));
|
||||
const secondary = defs.filter((d) => !pinned.includes(d));
|
||||
// Applied filters read first, left to right — a stable partition keeps
|
||||
// each group in its original def order rather than resorting on every apply.
|
||||
const orderedPinned = [
|
||||
...pinned.filter((d) => controls.values[d.key]),
|
||||
...pinned.filter((d) => !controls.values[d.key]),
|
||||
];
|
||||
|
||||
// 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 (
|
||||
<div>
|
||||
{viewId && (
|
||||
<SavedViewCards
|
||||
defs={defs}
|
||||
views={savedViews.views}
|
||||
activeQuery={activeQuery}
|
||||
applyQueryString={controls.applyQueryString}
|
||||
onRemove={savedViews.remove}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Two independent zones on wide screens — left (search + pills + more
|
||||
filters + clear) wraps to as many lines as it needs, right (sort +
|
||||
save) stays pinned on the first line via `sm:flex-nowrap` +
|
||||
`sm:shrink-0`. `nowrap` unconditionally (the old inline style) forced
|
||||
that same two-column layout on a phone too: neither zone had room and
|
||||
both got squeezed/clipped. Below the `sm` breakpoint this stacks to a
|
||||
single column instead — full-width left row, full-width right row.
|
||||
*/}
|
||||
<div className="flex flex-col sm:flex-row sm:flex-nowrap items-start gap-2">
|
||||
<Group gap="xs" wrap="wrap" align="center" className="flex-1 min-w-0 w-full">
|
||||
{showSearch && (
|
||||
<TextInput
|
||||
placeholder={searchPlaceholder}
|
||||
leftSection={<Search size={14} />}
|
||||
value={controls.searchText}
|
||||
onChange={(e) => controls.setSearchText(e.currentTarget.value)}
|
||||
size="xs"
|
||||
radius="lg"
|
||||
// Regular weight (not the Button-driven 600 the rest of the bar
|
||||
// uses) and a solid, fully-opaque border/text — same "opaque, not
|
||||
// faint" fix the inactive pill trigger got.
|
||||
styles={{
|
||||
input: {
|
||||
fontWeight: 400,
|
||||
borderColor: "var(--mantine-color-gray-6)",
|
||||
color: "var(--mantine-color-gray-9)",
|
||||
},
|
||||
}}
|
||||
style={{ minWidth: 160, flex: "1 1 160px" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{orderedPinned.map((def) => (
|
||||
<FilterPill
|
||||
key={def.key}
|
||||
def={def}
|
||||
value={controls.values[def.key]}
|
||||
onChange={(v) => controls.setFilter(def.key, v)}
|
||||
autoOpen={justPicked.includes(def.key)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<MoreFiltersMenu
|
||||
defs={secondary}
|
||||
onPick={(key) => setJustPicked((prev) => [...prev, key])}
|
||||
/>
|
||||
|
||||
{controls.activeCount > 0 && (
|
||||
<Anchor
|
||||
size="sm"
|
||||
c="red.6"
|
||||
underline="never"
|
||||
onClick={() => {
|
||||
controls.clearFilters();
|
||||
setJustPicked([]);
|
||||
}}
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Clear
|
||||
</Anchor>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Sorting is a different kind of control (view order, not scope) —
|
||||
cut off from the filter pills by a vertical divider and pinned to
|
||||
the right, independent of how the left side wraps. */}
|
||||
{/*
|
||||
Plain div, not <Group>: Group's `wrap` prop sets an inline
|
||||
flex-wrap style, which always beats a Tailwind class regardless of
|
||||
breakpoint — `sm:flex-nowrap` would never win against `wrap="wrap"`.
|
||||
Wrap on mobile (own row, room is tight), pinned nowrap from `sm` up.
|
||||
*/}
|
||||
<div className="flex flex-wrap sm:flex-nowrap items-center gap-2 shrink-0">
|
||||
{children}
|
||||
{sortOptions && sortOptions.length > 0 && (
|
||||
<>
|
||||
<Divider orientation="vertical" />
|
||||
<SortControl options={sortOptions} value={controls.sort} onChange={controls.setSort} />
|
||||
</>
|
||||
)}
|
||||
{canSaveView && (
|
||||
<>
|
||||
<Divider orientation="vertical" />
|
||||
<SaveViewButton defs={defs} query={activeQuery} onSave={savedViews.save} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from "react";
|
||||
import { ActionIcon, Button, Popover } from "@mantine/core";
|
||||
import { ChevronDown, 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";
|
||||
import { NumberBody } from "./bodies/NumberBody";
|
||||
import { RouteBody } from "./bodies/RouteBody";
|
||||
import { TextBody } from "./bodies/TextBody";
|
||||
|
||||
const BODIES: Record<FilterDef["type"], React.ComponentType<any>> = {
|
||||
text: TextBody,
|
||||
enum: EnumBody,
|
||||
date: DateBody,
|
||||
number: NumberBody,
|
||||
boolean: BooleanBody,
|
||||
route: RouteBody,
|
||||
};
|
||||
|
||||
// Most bodies fit a narrow popover; a date range needs room for the presets
|
||||
// sidebar next to the calendar, so it gets a wider minimum.
|
||||
const DROPDOWN_WIDTH: Partial<Record<FilterDef["type"], number>> = { date: 340 };
|
||||
|
||||
export interface FilterPillProps {
|
||||
def: FilterDef;
|
||||
value: FilterValue | undefined;
|
||||
onChange: (v: FilterValue | undefined) => void;
|
||||
/** Opened immediately (used when picked from "More filters"). */
|
||||
autoOpen?: boolean;
|
||||
}
|
||||
|
||||
export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps) {
|
||||
const [opened, setOpened] = useState(Boolean(autoOpen));
|
||||
const Body = BODIES[def.type];
|
||||
const active = Boolean(value);
|
||||
|
||||
return (
|
||||
<Popover position="bottom-start" withinPortal shadow="md" opened={opened} onChange={setOpened}>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="xl"
|
||||
// Inactive: styled like a closed Mantine Select trigger — solid
|
||||
// (opaque, not dashed) border, label + trailing chevron, no leading
|
||||
// "+" — just a smaller/pill-shaped version of that same control.
|
||||
// Active: "light" (soft tinted fill), not "filled" — a whole row of
|
||||
// solid green buttons was the "too loud" complaint; light keeps the
|
||||
// active/inactive contrast without shouting. Popover side/position
|
||||
// is untouched either way.
|
||||
variant={active ? "light" : "default"}
|
||||
color={active ? "edr-green" : undefined}
|
||||
styles={
|
||||
active
|
||||
? undefined
|
||||
: { root: { borderColor: "var(--mantine-color-gray-6)", color: "var(--mantine-color-gray-9)" } }
|
||||
}
|
||||
rightSection={
|
||||
active ? (
|
||||
<ActionIcon
|
||||
component="span"
|
||||
size={22}
|
||||
radius="xl"
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(undefined);
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
)
|
||||
}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
>
|
||||
{active ? `${def.label} | ${formatFilterValue(def, value!)}` : def.label}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown miw={DROPDOWN_WIDTH[def.type] ?? 260} p="xs">
|
||||
<Body def={def} value={value} onChange={onChange} onClose={() => setOpened(false)} />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Popover, ScrollArea, Stack, Text, TextInput, UnstyledButton } from "@mantine/core";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
|
||||
import type { FilterDef } from "./types";
|
||||
|
||||
export interface MoreFiltersMenuProps {
|
||||
defs: FilterDef[];
|
||||
/** Called with the picked def's key — the caller pins it and opens its popover. */
|
||||
onPick: (key: string) => void;
|
||||
}
|
||||
|
||||
/** Searchable list over the page's secondary/inactive filters. Plain filter + list,
|
||||
* not cmdk — a handful of static strings doesn't need a Combobox store. */
|
||||
export function MoreFiltersMenu({ defs, onPick }: MoreFiltersMenuProps) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const visible = useMemo(
|
||||
() => defs.filter((d) => d.label.toLowerCase().includes(query.toLowerCase())),
|
||||
[defs, query],
|
||||
);
|
||||
|
||||
if (defs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Popover position="bottom-start" withinPortal shadow="md" opened={opened} onChange={setOpened}>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="xl"
|
||||
// "default" (opaque border + solid text), not "outline" (faint
|
||||
// color-tinted border/text) — same fix as the inactive filter pill.
|
||||
variant="default"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
>
|
||||
More filters
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown miw={220} p="xs">
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
placeholder="Search filters…"
|
||||
leftSection={<Search size={14} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
size="sm"
|
||||
autoFocus
|
||||
/>
|
||||
<ScrollArea.Autosize mah={280}>
|
||||
<Stack gap={2}>
|
||||
{visible.map((d) => (
|
||||
<UnstyledButton
|
||||
key={d.key}
|
||||
px="xs"
|
||||
py={6}
|
||||
className="hover:bg-gray-100 transition-colors"
|
||||
style={{ borderRadius: 6, display: "flex", alignItems: "center", gap: 8 }}
|
||||
onClick={() => {
|
||||
setOpened(false);
|
||||
setQuery("");
|
||||
onPick(d.key);
|
||||
}}
|
||||
>
|
||||
<Plus size={14} className="text-[var(--mantine-color-edr-green-6)]" />
|
||||
<Text size="sm" c="edr-green.7">
|
||||
{d.label}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
{visible.length === 0 && (
|
||||
<Text size="xs" c="dimmed" px="xs" py={6}>
|
||||
No matching filters
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { SegmentedControl } from "@mantine/core";
|
||||
import { DEFAULT_OP, OPERATOR_LABELS, type FilterDef, type Operator } from "./types";
|
||||
|
||||
export interface OperatorSelectProps {
|
||||
def: FilterDef;
|
||||
value: Operator;
|
||||
onChange: (op: Operator) => void;
|
||||
}
|
||||
|
||||
/** Renders nothing when a def has <= 1 operator — most defs, by design: type-aware
|
||||
* operators are a capability, not a dropdown forced into every popover. */
|
||||
export function OperatorSelect({ def, value, onChange }: OperatorSelectProps) {
|
||||
const operators = def.operators ?? [DEFAULT_OP[def.type]];
|
||||
if (operators.length <= 1) return null;
|
||||
return (
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
fullWidth
|
||||
value={value}
|
||||
onChange={(v) => onChange(v as Operator)}
|
||||
data={operators.map((op) => ({ value: op, label: OPERATOR_LABELS[op] }))}
|
||||
mb="xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Button
|
||||
size="sm"
|
||||
radius="xl"
|
||||
variant="filled"
|
||||
color={justSaved ? "teal" : "edr-green"}
|
||||
leftSection={justSaved ? <Check size={15} /> : <Save size={15} />}
|
||||
onClick={handleSave}
|
||||
disabled={justSaved}
|
||||
>
|
||||
{justSaved ? "Saved" : "Save"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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 (
|
||||
// base: 1 — a phone-width viewport forcing 2 columns is what clipped
|
||||
// card text and overflowed the row; one full-width card per row until
|
||||
// there's actually room for more.
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3, md: 4, lg: 5 }} spacing="xs" mb="sm">
|
||||
{views.map((v) => {
|
||||
const active = v.query === activeQuery;
|
||||
const label = describeQuery(defs, v.query);
|
||||
return (
|
||||
<Card
|
||||
key={v.id}
|
||||
withBorder
|
||||
padding="xs"
|
||||
radius="md"
|
||||
onClick={() => {
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 6 }}>
|
||||
<Text size="xs" fw={500} lineClamp={2} style={{ flex: 1 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(v.id);
|
||||
toast({ title: "View deleted", description: label, variant: "destructive" });
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Button, Menu } from "@mantine/core";
|
||||
import { ArrowUpDown, Check } from "lucide-react";
|
||||
|
||||
import type { SortOption } from "./types";
|
||||
|
||||
export interface SortControlProps {
|
||||
options: SortOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/** A control, not a form field — Menu (not Select) gives the check-mark +
|
||||
* trigger-label read Stripe's sort control has. Rendered only when a page
|
||||
* passes sortOptions; inventing options for an endpoint without sortBy
|
||||
* support would ship a control that silently does nothing. */
|
||||
export function SortControl({ options, value, onChange }: SortControlProps) {
|
||||
if (options.length === 0) return null;
|
||||
const current = options.find((o) => o.value === value);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end" withinPortal shadow="md">
|
||||
<Menu.Target>
|
||||
<Button size="xs" variant="outline" color="gray" leftSection={<ArrowUpDown size={14} />}>
|
||||
{current?.label ?? "Sort"}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{options.map((o) => (
|
||||
<Menu.Item
|
||||
key={o.value}
|
||||
leftSection={o.value === value ? <Check size={14} /> : <span style={{ width: 14 }} />}
|
||||
onClick={() => onChange(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState } from "react";
|
||||
import { Radio, Stack } from "@mantine/core";
|
||||
|
||||
import { DEFAULT_OP } from "../types";
|
||||
import type { BooleanFilterDef, Operator } from "../types";
|
||||
import { OperatorSelect } from "../OperatorSelect";
|
||||
import type { FilterBodyProps } from "./TextBody";
|
||||
|
||||
export function BooleanBody({ def, value, onChange, onClose }: FilterBodyProps<BooleanFilterDef>) {
|
||||
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.boolean);
|
||||
const [v, setV] = useState(value?.v[0] ?? "");
|
||||
|
||||
// Two mutually-exclusive options — apply the moment one is picked, same as
|
||||
// EnumBody's single-select radio. No Apply button needed.
|
||||
const pick = (next: string) => {
|
||||
setV(next);
|
||||
onChange({ op, v: [next] });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<OperatorSelect def={def} value={op} onChange={setOp} />
|
||||
<Radio.Group value={v} onChange={pick}>
|
||||
<Stack gap={6}>
|
||||
<Radio value="true" label={def.trueLabel ?? "Yes"} size="sm" />
|
||||
<Radio value="false" label={def.falseLabel ?? "No"} size="sm" />
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
|
||||
import { getDateRangePresets } from "@/components/common/dateRangePresets";
|
||||
import { startOfDayIso, endOfDayIso, parseDateStr } from "../dates";
|
||||
import { DEFAULT_OP } from "../types";
|
||||
import type { DateFilterDef, Operator } from "../types";
|
||||
import { OperatorSelect } from "../OperatorSelect";
|
||||
import type { FilterBodyProps } from "./TextBody";
|
||||
|
||||
// ponytail: Gregorian only. Record-management pages need the Ethiopian
|
||||
// calendar (see shared/common/form/fields/AmharicDatePicker.tsx) — add an
|
||||
// i18n.language !== "en" branch here when this body is first wired into a
|
||||
// record-management page (Phase 4 of the filter-bar rollout).
|
||||
export function DateBody({ def, value, onChange, onClose }: FilterBodyProps<DateFilterDef>) {
|
||||
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.date);
|
||||
// Mantine 9's date inputs speak `YYYY-MM-DD` strings, not Date objects.
|
||||
const [from, setFrom] = useState<string | null>(value?.v[0]?.slice(0, 10) ?? null);
|
||||
const [to, setTo] = useState<string | null>(value?.v[1]?.slice(0, 10) ?? null);
|
||||
|
||||
const apply = () => {
|
||||
if (op === "between") {
|
||||
onChange(
|
||||
from && to
|
||||
? { op, v: [startOfDayIso(parseDateStr(from)), endOfDayIso(parseDateStr(to))] }
|
||||
: undefined,
|
||||
);
|
||||
} else {
|
||||
onChange(
|
||||
from
|
||||
? {
|
||||
op,
|
||||
v: [
|
||||
op === "before"
|
||||
? startOfDayIso(parseDateStr(from))
|
||||
: endOfDayIso(parseDateStr(from)),
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
// This popover already lives inside FilterPill's own Popover. Mantine's
|
||||
// DatePickerInput opens ITS calendar in a separate portal by default, so a
|
||||
// click on a day registers as "outside" the outer Popover and closes the
|
||||
// whole filter before the range can be picked (or Apply reached) — the
|
||||
// reported "date picker doesn't work". Keeping the calendar un-portalled
|
||||
// renders it inside the outer popover's own DOM subtree instead, so
|
||||
// outside-click detection sees it as inside.
|
||||
const nestedPopoverProps = { withinPortal: false } as const;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<OperatorSelect def={def} value={op} onChange={setOp} />
|
||||
{op === "between" ? (
|
||||
<DatePickerInput
|
||||
type="range"
|
||||
size="sm"
|
||||
leftSection={<CalendarDays size={14} />}
|
||||
placeholder="Any"
|
||||
value={[from, to]}
|
||||
onChange={([f, t]) => {
|
||||
setFrom(f);
|
||||
setTo(t);
|
||||
}}
|
||||
presets={getDateRangePresets()}
|
||||
popoverProps={nestedPopoverProps}
|
||||
clearable
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<DatePickerInput
|
||||
size="sm"
|
||||
leftSection={<CalendarDays size={14} />}
|
||||
placeholder="Any"
|
||||
value={from}
|
||||
onChange={setFrom}
|
||||
popoverProps={nestedPopoverProps}
|
||||
clearable
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
<Button size="sm" onClick={apply} disabled={op === "between" ? !(from && to) : !from}>
|
||||
Apply
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Checkbox, Group, Radio, Stack, Text, TextInput, UnstyledButton } from "@mantine/core";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { DEFAULT_OP } from "../types";
|
||||
import type { EnumFilterDef, Operator } from "../types";
|
||||
import { OperatorSelect } from "../OperatorSelect";
|
||||
import type { FilterBodyProps } from "./TextBody";
|
||||
|
||||
/** How many options before a search box appears above the list. */
|
||||
const SEARCH_THRESHOLD = 8;
|
||||
|
||||
/**
|
||||
* Stretches the Checkbox/Radio's native <label> across the full popover
|
||||
* width and pads it, so the clickable/tappable area is the whole row —
|
||||
* not just the ~14px input square — plus a hover cue. `body`/`labelWrapper`
|
||||
* are Mantine's part names for this; `cursor: pointer` on the row (not just
|
||||
* the input) makes the affordance visible before you even click.
|
||||
*/
|
||||
const ROW_STYLES = {
|
||||
root: { padding: "10px 10px", borderRadius: 6 },
|
||||
body: { alignItems: "center" as const },
|
||||
labelWrapper: { flex: 1 },
|
||||
label: { cursor: "pointer", paddingLeft: 8 },
|
||||
};
|
||||
|
||||
function OptionLabel({ label, count }: { label: string; count?: number }) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Text size="sm">{label}</Text>
|
||||
{count !== undefined && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{count}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps<EnumFilterDef>) {
|
||||
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.enum);
|
||||
const [selected, setSelected] = useState<string[]>(value?.v ?? []);
|
||||
const [query, setQuery] = useState("");
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const multiple = def.multiple ?? true;
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const byQuery = query
|
||||
? def.options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
|
||||
: def.options;
|
||||
if (!def.counts || showAll) return byQuery;
|
||||
// Hide zero-count options, but never hide one the user already picked —
|
||||
// otherwise a filter that narrows to zero rows becomes impossible to un-select.
|
||||
return byQuery.filter((o) => (def.counts![o.value] ?? 0) > 0 || selected.includes(o.value));
|
||||
}, [def.options, def.counts, query, showAll, selected]);
|
||||
|
||||
const hiddenCount = def.options.length - visible.length;
|
||||
|
||||
const apply = (v: string[] = selected) => {
|
||||
onChange(v.length ? { op, v } : undefined);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Single-select is a radio pick, not a build-up-a-set gesture — apply the
|
||||
// instant one is chosen, same as picking an option in a plain Select.
|
||||
// Checkbox (multiple) still needs the explicit Apply: picking several
|
||||
// options is a multi-step gesture the popover shouldn't close mid-way through.
|
||||
const applyRadio = (v: string) => {
|
||||
setSelected([v]);
|
||||
apply([v]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<OperatorSelect def={def} value={op} onChange={setOp} />
|
||||
{def.options.length > SEARCH_THRESHOLD && (
|
||||
<TextInput
|
||||
placeholder="Search options…"
|
||||
leftSection={<Search size={14} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
<Stack gap={0} mah={260} style={{ overflowY: "auto" }}>
|
||||
{multiple ? (
|
||||
<Checkbox.Group value={selected} onChange={setSelected} aria-label={`Filter by ${def.label}`}>
|
||||
<Stack gap={0}>
|
||||
{visible.map((o) => (
|
||||
<Checkbox
|
||||
key={o.value}
|
||||
value={o.value}
|
||||
size="sm"
|
||||
// The count sits INSIDE the label, so it's part of the
|
||||
// native <label> the input is bound to — clicking it (not
|
||||
// just the tiny checkbox square) toggles the option too.
|
||||
label={<OptionLabel label={o.label} count={def.counts?.[o.value]} />}
|
||||
styles={ROW_STYLES}
|
||||
classNames={{ root: "hover:bg-gray-100 transition-colors" }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Checkbox.Group>
|
||||
) : (
|
||||
<Radio.Group
|
||||
value={selected[0] ?? ""}
|
||||
onChange={(v) => v && applyRadio(v)}
|
||||
aria-label={`Filter by ${def.label}`}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{visible.map((o) => (
|
||||
<Radio
|
||||
key={o.value}
|
||||
value={o.value}
|
||||
size="sm"
|
||||
label={<OptionLabel label={o.label} count={def.counts?.[o.value]} />}
|
||||
styles={ROW_STYLES}
|
||||
classNames={{ root: "hover:bg-gray-100 transition-colors" }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
)}
|
||||
{!showAll && hiddenCount > 0 && (
|
||||
<UnstyledButton onClick={() => setShowAll(true)}>
|
||||
<Text size="sm" c="edr-green.6">
|
||||
Show all {def.options.length} options
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</Stack>
|
||||
{multiple && (
|
||||
<Button size="sm" onClick={() => apply()}>
|
||||
Apply
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Group, NumberInput, Stack } from "@mantine/core";
|
||||
|
||||
import { DEFAULT_OP } from "../types";
|
||||
import type { NumberFilterDef, Operator } from "../types";
|
||||
import { OperatorSelect } from "../OperatorSelect";
|
||||
import type { FilterBodyProps } from "./TextBody";
|
||||
|
||||
export function NumberBody({ def, value, onChange, onClose }: FilterBodyProps<NumberFilterDef>) {
|
||||
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.number);
|
||||
const [from, setFrom] = useState<number | "">(value?.v[0] ? Number(value.v[0]) : "");
|
||||
const [to, setTo] = useState<number | "">(op === "between" ? (Number(value?.v[1]) || "") : "");
|
||||
|
||||
const apply = () => {
|
||||
if (op === "between") {
|
||||
onChange(from !== "" && to !== "" ? { op, v: [String(from), String(to)] } : undefined);
|
||||
} else {
|
||||
onChange(from !== "" ? { op, v: [String(from)] } : undefined);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<OperatorSelect def={def} value={op} onChange={setOp} />
|
||||
{op === "between" ? (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<NumberInput placeholder="Min" value={from} onChange={(v) => setFrom(v as number | "")} rightSection={def.unit} autoFocus />
|
||||
<NumberInput placeholder="Max" value={to} onChange={(v) => setTo(v as number | "")} rightSection={def.unit} />
|
||||
</Group>
|
||||
) : (
|
||||
<NumberInput placeholder="Value" value={from} onChange={(v) => setFrom(v as number | "")} rightSection={def.unit} autoFocus />
|
||||
)}
|
||||
<Button size="sm" onClick={apply}>
|
||||
Apply
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Select, Stack } from "@mantine/core";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
import type { RouteFilterDef } from "../types";
|
||||
import type { FilterBodyProps } from "./TextBody";
|
||||
|
||||
/**
|
||||
* Origin + destination picked together, each a searchable `Select` over the
|
||||
* page's yard list — typing filters by yard name, same as any Mantine
|
||||
* Select. No `OperatorSelect`: a route pair has exactly one operator ("is"),
|
||||
* which is why DEFAULT_OP.route is the only entry the generic bar needs.
|
||||
*/
|
||||
export function RouteBody({ def, value, onChange, onClose }: FilterBodyProps<RouteFilterDef>) {
|
||||
const [origin, setOrigin] = useState<string | null>(value?.v[0] ?? null);
|
||||
const [destination, setDestination] = useState<string | null>(value?.v[1] ?? null);
|
||||
|
||||
const apply = () => {
|
||||
onChange(origin && destination ? { op: "is", v: [origin, destination] } : undefined);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// This popover already lives inside FilterPill's own Popover. A Select's
|
||||
// dropdown portals separately by default, so a click on an option registers
|
||||
// as "outside" the outer Popover and closes the whole filter before a pick
|
||||
// lands — same nested-portal bug DateBody had. Un-portalling keeps it
|
||||
// inside the outer popover's DOM subtree instead.
|
||||
const comboboxProps = { withinPortal: false } as const;
|
||||
|
||||
return (
|
||||
<Stack gap="xs" w={240}>
|
||||
<Select
|
||||
label="Origin"
|
||||
placeholder="Any"
|
||||
data={def.options}
|
||||
value={origin}
|
||||
onChange={setOrigin}
|
||||
comboboxProps={comboboxProps}
|
||||
searchable
|
||||
clearable
|
||||
autoFocus
|
||||
/>
|
||||
<ArrowRight size={14} className="text-gray-400" style={{ alignSelf: "center" }} />
|
||||
<Select
|
||||
label="Destination"
|
||||
placeholder="Any"
|
||||
data={def.options}
|
||||
value={destination}
|
||||
onChange={setDestination}
|
||||
comboboxProps={comboboxProps}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Button size="sm" onClick={apply} disabled={!(origin && destination)}>
|
||||
Apply
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import { DEFAULT_OP } from "../types";
|
||||
import type { TextFilterDef, FilterValue, Operator } from "../types";
|
||||
import { OperatorSelect } from "../OperatorSelect";
|
||||
|
||||
export interface FilterBodyProps<Def> {
|
||||
def: Def;
|
||||
value: FilterValue | undefined;
|
||||
onChange: (v: FilterValue | undefined) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TextBody({ def, value, onChange, onClose }: FilterBodyProps<TextFilterDef>) {
|
||||
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.text);
|
||||
const [text, setText] = useState(value?.v[0] ?? "");
|
||||
|
||||
const apply = () => {
|
||||
onChange(text.trim() ? { op, v: [text.trim()] } : undefined);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<OperatorSelect def={def} value={op} onChange={setOp} />
|
||||
<TextInput
|
||||
placeholder={def.placeholder ?? `Filter by ${def.label.toLowerCase()}…`}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && apply()}
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" onClick={apply}>
|
||||
Apply
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { matchesDayRange, toDayString } from "@/hooks/useListControls";
|
||||
import type { FilterDef, FilterValue } from "./types";
|
||||
|
||||
export { matchesDayRange, toDayString };
|
||||
|
||||
const readField = (row: unknown, key: string): unknown =>
|
||||
row && typeof row === "object" ? (row as Record<string, unknown>)[key] : undefined;
|
||||
|
||||
export interface ClientFilterOptions<T> {
|
||||
/** Row fields matched against the free-text search box. */
|
||||
searchKeys?: (keyof T)[];
|
||||
/** Custom search extractor when the value isn't a top-level field. */
|
||||
searchValue?: (row: T) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side bridge for pages whose endpoint doesn't (yet) accept
|
||||
* filter/sort/pagination params — the Family-B pages this app inherited from
|
||||
* `ListControls`/`useListControls`. Same idea, generalized: instead of one
|
||||
* hardcoded search box + one date range, every `FilterDef` is matched
|
||||
* against `row[def.key]` (override the def's `key` to line up with the row
|
||||
* shape, or filter/map the rows before calling this).
|
||||
*
|
||||
* Flip a page to server mode later by deleting the `applyClientFilters` call
|
||||
* and passing `controls.params` straight to the API — `useFilters`'s output
|
||||
* shape doesn't change either way.
|
||||
*
|
||||
* ponytail: linear scan per keystroke, no debounce — matches
|
||||
* `useListControls`'s existing behavior at this data size (~1k rows,
|
||||
* `useListControls.ts:4-18`). Move to server-side filtering if a list
|
||||
* outgrows that.
|
||||
*/
|
||||
export function applyClientFilters<T>(
|
||||
rows: T[],
|
||||
defs: FilterDef[],
|
||||
values: Record<string, FilterValue>,
|
||||
searchText: string,
|
||||
options: ClientFilterOptions<T> = {},
|
||||
): T[] {
|
||||
const term = searchText.trim().toLowerCase();
|
||||
const { searchKeys = [], searchValue } = options;
|
||||
|
||||
return rows.filter((row) => {
|
||||
if (term) {
|
||||
const haystack = searchValue
|
||||
? searchValue(row)
|
||||
: searchKeys.map((k) => String(readField(row, String(k)) ?? "")).join(" ");
|
||||
if (!haystack.toLowerCase().includes(term)) return false;
|
||||
}
|
||||
for (const def of defs) {
|
||||
const value = values[def.key];
|
||||
if (!value) continue;
|
||||
if (!matchesFilter(def, value, readField(row, def.key))) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function matchesFilter(def: FilterDef, value: FilterValue, raw: unknown): boolean {
|
||||
switch (def.type) {
|
||||
case "enum": {
|
||||
const inSet = value.v.includes(String(raw ?? ""));
|
||||
return value.op === "isNot" ? !inSet : inSet;
|
||||
}
|
||||
case "date": {
|
||||
if (value.op === "between") {
|
||||
return matchesDayRange(raw, value.v[0]?.slice(0, 10) ?? null, value.v[1]?.slice(0, 10) ?? null);
|
||||
}
|
||||
const day = toDayString(raw);
|
||||
const target = value.v[0]?.slice(0, 10);
|
||||
if (!day || !target) return false;
|
||||
return value.op === "before" ? day <= target : day >= target;
|
||||
}
|
||||
case "number": {
|
||||
const num = Number(raw);
|
||||
if (Number.isNaN(num)) return false;
|
||||
if (value.op === "between") {
|
||||
const [min, max] = value.v.map(Number);
|
||||
return num >= min && num <= max;
|
||||
}
|
||||
return value.op === "isNot" ? num !== Number(value.v[0]) : num === Number(value.v[0]);
|
||||
}
|
||||
case "boolean":
|
||||
return Boolean(raw) === (value.v[0] === "true");
|
||||
case "text": {
|
||||
const rawStr = String(raw ?? "").toLowerCase();
|
||||
const target = (value.v[0] ?? "").toLowerCase();
|
||||
return value.op === "isNot" ? !rawStr.includes(target) : rawStr.includes(target);
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { FilterValue } from "./types";
|
||||
|
||||
/** Local start-of-day -> ISO, for inclusive "from" date filters. Lifted out of
|
||||
* ContractRequestsPage (where it was duplicated into BookingRequestsPage) so
|
||||
* every date filter shares one definition. */
|
||||
export function startOfDayIso(d: Date): string {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x.toISOString();
|
||||
}
|
||||
|
||||
/** Local end-of-day -> ISO, for inclusive "to" date filters. */
|
||||
export function endOfDayIso(d: Date): string {
|
||||
const x = new Date(d);
|
||||
x.setHours(23, 59, 59, 999);
|
||||
return x.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `YYYY-MM-DD` date-picker string into a LOCAL-midnight Date.
|
||||
*
|
||||
* `new Date("2026-01-01")` is a date-ONLY ISO string, which the spec parses
|
||||
* as UTC midnight, not local midnight. For anyone east of UTC (Ethiopia is
|
||||
* UTC+3) that instant already falls on the PREVIOUS local day, so
|
||||
* `startOfDayIso`/`endOfDayIso` built from it silently shift the picked date
|
||||
* back by one — the picker looks fine, the filtered results are wrong. This
|
||||
* constructor form (`new Date(y, m, d)`) is local by definition; use it for
|
||||
* every date-only string instead of `new Date(dateString)`.
|
||||
*/
|
||||
export function parseDateStr(dateStr: string): Date {
|
||||
const [y, m, d] = dateStr.split("-").map(Number);
|
||||
return new Date(y, (m || 1) - 1, d || 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* `toParams` for a date `FilterDef` widened to `["between", "before", "after"]`
|
||||
* operators. `DateBody` always emits a single-element `v` for before/after —
|
||||
* a plain positional `{[fromKey]: v[0], [toKey]: v[1]}` mapping (the
|
||||
* between-only default) would wrongly land a "before" pick in `fromKey`
|
||||
* instead of `toKey`. This routes each operator to the right bound.
|
||||
*/
|
||||
export function dateRangeParams(
|
||||
fromKey: string,
|
||||
toKey: string,
|
||||
): (v: FilterValue) => Record<string, string | undefined> {
|
||||
return (value) => {
|
||||
if (value.op === "before") return { [toKey]: value.v[0] };
|
||||
if (value.op === "after") return { [fromKey]: value.v[0] };
|
||||
return { [fromKey]: value.v[0], [toKey]: value.v[1] };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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)}`;
|
||||
}
|
||||
if (def.type === "route" && value.v.length === 2) {
|
||||
const label = (id: string) => def.options.find((o) => o.value === id)?.label ?? id;
|
||||
return `${label(value.v[0])} → ${label(value.v[1])}`;
|
||||
}
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export * from "./types";
|
||||
export * from "./url";
|
||||
export * from "./dates";
|
||||
export * from "./format";
|
||||
export * from "./clientFilter";
|
||||
export * from "./ruleEngineFooterProps";
|
||||
export * from "./useFilters";
|
||||
export * from "./useSavedViews";
|
||||
export { FilterBar } from "./FilterBar";
|
||||
export type { FilterBarProps } from "./FilterBar";
|
||||
export { FilterPill } from "./FilterPill";
|
||||
export { SortControl } from "./SortControl";
|
||||
export { SaveViewButton } from "./SaveViewButton";
|
||||
export { SavedViewCards } from "./SavedViewCards";
|
||||
export { MoreFiltersMenu } from "./MoreFiltersMenu";
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { OnChangeFn, PaginationState } from "@edr/ui-common";
|
||||
import type { UseFilters } from "./useFilters";
|
||||
|
||||
/**
|
||||
* Adapts `useFilters`'s URL-backed page/pageSize to `RuleEngineListFooter`'s
|
||||
* prop shape, for the client-bridge pages that render a plain `<Table>` +
|
||||
* that footer instead of `<DataTable>` (which has `tableProps()` for this).
|
||||
* Routes page-index vs page-size changes to the right setter — the same
|
||||
* pageSize-gets-silently-dropped bug `tableProps()` had before it was fixed.
|
||||
*/
|
||||
export function toRuleEngineFooterProps(
|
||||
controls: Pick<UseFilters, "page" | "pageSize" | "setPage" | "setPageSize">,
|
||||
totalCount: number,
|
||||
): {
|
||||
pagination: PaginationState;
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
} {
|
||||
const { page, pageSize, setPage, setPageSize } = controls;
|
||||
return {
|
||||
pagination: { pageIndex: page - 1, pageSize },
|
||||
pageCount: Math.max(1, Math.ceil(totalCount / pageSize)),
|
||||
totalCount,
|
||||
onPaginationChange: (updater) => {
|
||||
const current = { pageIndex: page - 1, pageSize };
|
||||
const next = typeof updater === "function" ? updater(current) : updater;
|
||||
if (next.pageSize !== pageSize) setPageSize(next.pageSize);
|
||||
else if (next.pageIndex !== current.pageIndex) setPage(next.pageIndex + 1);
|
||||
},
|
||||
};
|
||||
}
|
||||
115
apps/edr-freight-web/backoffice/src/components/filters/types.ts
Normal file
115
apps/edr-freight-web/backoffice/src/components/filters/types.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
export type FilterType = "text" | "enum" | "date" | "number" | "boolean" | "route";
|
||||
|
||||
export type Operator = "is" | "isNot" | "contains" | "between" | "before" | "after";
|
||||
|
||||
/** Operator implied by a filter's type when the def doesn't say otherwise. */
|
||||
export const DEFAULT_OP: Record<FilterType, Operator> = {
|
||||
text: "contains",
|
||||
enum: "is",
|
||||
date: "between",
|
||||
number: "is",
|
||||
boolean: "is",
|
||||
route: "is",
|
||||
};
|
||||
|
||||
export const OPERATOR_LABELS: Record<Operator, string> = {
|
||||
is: "is",
|
||||
isNot: "is not",
|
||||
contains: "contains",
|
||||
between: "is between",
|
||||
before: "is before",
|
||||
after: "is after",
|
||||
};
|
||||
|
||||
/**
|
||||
* A filter's current value. `v` holds:
|
||||
* - 1 entry for is / isNot / contains / before / after
|
||||
* - 2 entries for between (range)
|
||||
* - n entries for a multi-select enum (isAnyOf is expressed as op "is" + n values)
|
||||
*/
|
||||
export interface FilterValue {
|
||||
op: Operator;
|
||||
v: string[];
|
||||
}
|
||||
|
||||
export interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface FacetBucket {
|
||||
value: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface FilterDefBase {
|
||||
/** URL key and, by default, the API param name. */
|
||||
key: string;
|
||||
/** Plain string — the caller applies i18n's t() before passing it in. */
|
||||
label: string;
|
||||
type: FilterType;
|
||||
/** Defaults to `[DEFAULT_OP[type]]`. Widen only where the endpoint implements it. */
|
||||
operators?: Operator[];
|
||||
/** Pill text override. Default: "Label | value(s)". */
|
||||
format?: (v: FilterValue, def: FilterDef) => string;
|
||||
/** Map to API query params. Default `{ [key]: v.join(",") }`. */
|
||||
toParams?: (v: FilterValue) => Record<string, string | undefined>;
|
||||
/** Lives behind "More filters" until it has a value. Default false. */
|
||||
secondary?: boolean;
|
||||
}
|
||||
|
||||
export interface TextFilterDef extends FilterDefBase {
|
||||
type: "text";
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface EnumFilterDef extends FilterDefBase {
|
||||
type: "enum";
|
||||
options: FilterOption[];
|
||||
/** Default true — checkbox list. false renders a single-select radio list. */
|
||||
multiple?: boolean;
|
||||
/** value -> count in the current (filtered) result set. Absent = no counts, hide nothing. */
|
||||
counts?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface DateFilterDef extends FilterDefBase {
|
||||
type: "date";
|
||||
calendar?: "gregorian" | "ethiopian";
|
||||
}
|
||||
|
||||
export interface NumberFilterDef extends FilterDefBase {
|
||||
type: "number";
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface BooleanFilterDef extends FilterDefBase {
|
||||
type: "boolean";
|
||||
trueLabel?: string;
|
||||
falseLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Origin + destination picked together as one pill — `v` is always the
|
||||
* 2-slot pair `[originYardId, destinationYardId]`, never partial (the body's
|
||||
* Apply button stays disabled until both sides are chosen, same rule
|
||||
* `DateBody` uses for a `between` range). One shared `options` list drives
|
||||
* both selects.
|
||||
*/
|
||||
export interface RouteFilterDef extends FilterDefBase {
|
||||
type: "route";
|
||||
options: FilterOption[];
|
||||
}
|
||||
|
||||
export type FilterDef =
|
||||
| TextFilterDef
|
||||
| EnumFilterDef
|
||||
| DateFilterDef
|
||||
| NumberFilterDef
|
||||
| BooleanFilterDef
|
||||
| RouteFilterDef;
|
||||
|
||||
/** A page's sort options — value is already `"field:DIR"`, the codebase's existing convention. */
|
||||
export interface SortOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { FilterDef, FilterValue } from "./types";
|
||||
import {
|
||||
decodeFilterValue,
|
||||
encodeFilterValue,
|
||||
parseFilters,
|
||||
parseSort,
|
||||
toApiParams,
|
||||
writeFilter,
|
||||
} from "./url";
|
||||
|
||||
const STATUS: FilterDef = {
|
||||
key: "statuses",
|
||||
label: "Status",
|
||||
type: "enum",
|
||||
options: [
|
||||
{ value: "ACTIVE", label: "Active" },
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
],
|
||||
};
|
||||
|
||||
const DIRECTION: FilterDef = {
|
||||
key: "tradeDirection",
|
||||
label: "Direction",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: [{ value: "IMPORT", label: "Import" }],
|
||||
};
|
||||
|
||||
const SEARCH: FilterDef = { key: "q", label: "Search", type: "text" };
|
||||
|
||||
const CREATED: FilterDef = {
|
||||
key: "created",
|
||||
label: "Created",
|
||||
type: "date",
|
||||
toParams: ({ v }) => ({ createdFrom: v[0], createdTo: v[1] }),
|
||||
};
|
||||
|
||||
describe("encodeFilterValue / decodeFilterValue round-trip", () => {
|
||||
const cases: Array<{ name: string; type: FilterDef["type"]; value: FilterValue }> = [
|
||||
{ name: "text contains (default op omitted)", type: "text", value: { op: "contains", v: ["maersk"] } },
|
||||
{ name: "enum is (default op omitted, multi value)", type: "enum", value: { op: "is", v: ["ACTIVE", "DRAFT"] } },
|
||||
{ name: "enum isNot (non-default op prefixed)", type: "enum", value: { op: "isNot", v: ["GOV"] } },
|
||||
{ name: "date between (default op omitted)", type: "date", value: { op: "between", v: ["2026-01-01", "2026-03-01"] } },
|
||||
{ name: "date before (non-default op prefixed)", type: "date", value: { op: "before", v: ["2026-01-01"] } },
|
||||
{ name: "number is", type: "number", value: { op: "is", v: ["42"] } },
|
||||
{ name: "boolean is", type: "boolean", value: { op: "is", v: ["true"] } },
|
||||
];
|
||||
|
||||
for (const { name, type, value } of cases) {
|
||||
it(`round-trips: ${name}`, () => {
|
||||
const encoded = encodeFilterValue(type, value);
|
||||
const decoded = decodeFilterValue(type, encoded);
|
||||
expect(decoded).toEqual(value);
|
||||
});
|
||||
}
|
||||
|
||||
it("omits the operator prefix only when it is the type default", () => {
|
||||
expect(encodeFilterValue("enum", { op: "is", v: ["ACTIVE"] })).toBe("ACTIVE");
|
||||
expect(encodeFilterValue("enum", { op: "isNot", v: ["ACTIVE"] })).toBe("isNot:ACTIVE");
|
||||
});
|
||||
|
||||
it("never comma-splits a text value, so a literal comma survives", () => {
|
||||
const encoded = encodeFilterValue("text", { op: "contains", v: ["Addis, Ethiopia"] });
|
||||
expect(decodeFilterValue("text", encoded)).toEqual({ op: "contains", v: ["Addis, Ethiopia"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeFilterValue malformed-input tolerance", () => {
|
||||
it("returns null for an empty string", () => {
|
||||
expect(decodeFilterValue("text", "")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not treat an unknown prefix as an operator", () => {
|
||||
// "foo" isn't a known Operator, so "foo:bar" is a literal text value, not op:value.
|
||||
expect(decodeFilterValue("text", "foo:bar")).toEqual({ op: "contains", v: ["foo:bar"] });
|
||||
});
|
||||
|
||||
it("degrades a one-sided 'between' to null (not applied) instead of guessing a half-open range", () => {
|
||||
expect(decodeFilterValue("date", "between:2026-01-01")).toBeNull();
|
||||
});
|
||||
|
||||
it("never throws on garbage input", () => {
|
||||
expect(() => decodeFilterValue("enum", "isNot:")).not.toThrow();
|
||||
expect(() => decodeFilterValue("date", "between:")).not.toThrow();
|
||||
expect(() => decodeFilterValue("number", ":::")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFilters / writeFilter", () => {
|
||||
it("parses only the defs present, ignoring unrelated params", () => {
|
||||
const params = new URLSearchParams("statuses=ACTIVE,DRAFT&unrelated=x&q=addis");
|
||||
const values = parseFilters([STATUS, SEARCH], params);
|
||||
expect(values).toEqual({
|
||||
statuses: { op: "is", v: ["ACTIVE", "DRAFT"] },
|
||||
q: { op: "contains", v: ["addis"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("writeFilter deletes the param when value is undefined", () => {
|
||||
const params = new URLSearchParams("statuses=ACTIVE");
|
||||
const next = writeFilter(params, STATUS, undefined);
|
||||
expect(next.has("statuses")).toBe(false);
|
||||
});
|
||||
|
||||
it("writeFilter round-trips through parseFilters", () => {
|
||||
const value: FilterValue = { op: "is", v: ["IMPORT"] };
|
||||
const next = writeFilter(new URLSearchParams(), DIRECTION, value);
|
||||
expect(parseFilters([DIRECTION], next)).toEqual({ tradeDirection: value });
|
||||
});
|
||||
|
||||
it("namespaces keys when ns is given, so two tables on one page don't collide", () => {
|
||||
const next = writeFilter(new URLSearchParams(), STATUS, { op: "is", v: ["ACTIVE"] }, "a");
|
||||
expect(next.get("a.statuses")).toBe("ACTIVE");
|
||||
expect(parseFilters([STATUS], new URLSearchParams(), "b")).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("existing deep-link backward compatibility", () => {
|
||||
it("parses the BookingRequestsPage-style ?statuses=A,B&tradeDirection=IMPORT link unchanged", () => {
|
||||
const params = new URLSearchParams("statuses=SUBMITTED,APPROVED&tradeDirection=IMPORT");
|
||||
expect(parseFilters([STATUS, DIRECTION], params)).toEqual({
|
||||
statuses: { op: "is", v: ["SUBMITTED", "APPROVED"] },
|
||||
tradeDirection: { op: "is", v: ["IMPORT"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSort", () => {
|
||||
const options = [
|
||||
{ value: "createdAt:DESC", label: "Newest first" },
|
||||
{ value: "createdAt:ASC", label: "Oldest first" },
|
||||
];
|
||||
|
||||
it("returns the fallback when sort is absent", () => {
|
||||
expect(parseSort(new URLSearchParams(), options, "createdAt:DESC")).toBe("createdAt:DESC");
|
||||
});
|
||||
|
||||
it("returns the fallback for an unrecognized sort value", () => {
|
||||
expect(parseSort(new URLSearchParams("sort=bogus:DESC"), options, "createdAt:DESC")).toBe(
|
||||
"createdAt:DESC",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the URL value when it is a known option", () => {
|
||||
expect(parseSort(new URLSearchParams("sort=createdAt:ASC"), options, "createdAt:DESC")).toBe(
|
||||
"createdAt:ASC",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toApiParams", () => {
|
||||
it("uses the default mapping (key: joined csv) when toParams is absent", () => {
|
||||
const values = { statuses: { op: "is" as const, v: ["ACTIVE", "DRAFT"] } };
|
||||
expect(toApiParams([STATUS], values)).toEqual({ statuses: "ACTIVE,DRAFT" });
|
||||
});
|
||||
|
||||
it("uses a custom toParams to reproduce an existing API's exact param names", () => {
|
||||
const values = { created: { op: "between" as const, v: ["2026-01-01", "2026-03-01"] } };
|
||||
expect(toApiParams([CREATED], values)).toEqual({
|
||||
createdFrom: "2026-01-01",
|
||||
createdTo: "2026-03-01",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits defs with no value", () => {
|
||||
expect(toApiParams([STATUS, SEARCH], {})).toEqual({});
|
||||
});
|
||||
});
|
||||
117
apps/edr-freight-web/backoffice/src/components/filters/url.ts
Normal file
117
apps/edr-freight-web/backoffice/src/components/filters/url.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { DEFAULT_OP, type FilterDef, type FilterValue, type Operator } from "./types";
|
||||
|
||||
const OPERATORS: readonly Operator[] = ["is", "isNot", "contains", "between", "before", "after"];
|
||||
|
||||
/**
|
||||
* Encode one filter value as `[op:]csv`, omitting the operator prefix when it
|
||||
* matches the type's default — that keeps the common case short and, more
|
||||
* importantly, keeps the existing `?statuses=A,B` deep links this app already
|
||||
* generates (e.g. the header document-review alarm) parsing identically.
|
||||
*
|
||||
* `,` and `:` are structural inside an encoded value. A `text` filter is
|
||||
* never comma-split (its `v` always has exactly one entry), which is what
|
||||
* lets a free-text search contain a literal comma safely.
|
||||
* ponytail: if a value ever legitimately needs a literal "op:" prefix or a
|
||||
* comma inside a multi-value filter, switch that filter to a JSON-in-one-param
|
||||
* encoding rather than trying to escape these two characters.
|
||||
*/
|
||||
export function encodeFilterValue(type: FilterDef["type"], value: FilterValue): string {
|
||||
const csv = value.v.map(encodeURIComponent).join(",");
|
||||
return value.op === DEFAULT_OP[type] ? csv : `${value.op}:${csv}`;
|
||||
}
|
||||
|
||||
/** Inverse of `encodeFilterValue`. Returns null for anything malformed — a bad
|
||||
* URL is user input and must degrade to "filter not applied", never throw. */
|
||||
export function decodeFilterValue(type: FilterDef["type"], raw: string): FilterValue | null {
|
||||
if (!raw) return null;
|
||||
const firstColon = raw.indexOf(":");
|
||||
let op: Operator = DEFAULT_OP[type];
|
||||
let rest = raw;
|
||||
if (firstColon > 0) {
|
||||
const prefix = raw.slice(0, firstColon);
|
||||
if ((OPERATORS as string[]).includes(prefix)) {
|
||||
op = prefix as Operator;
|
||||
rest = raw.slice(firstColon + 1);
|
||||
}
|
||||
}
|
||||
// Text filters are single-value and never comma-split, so a literal comma
|
||||
// in a search term round-trips unchanged.
|
||||
const v =
|
||||
type === "text"
|
||||
? [decodeURIComponent(rest)]
|
||||
: rest.split(",").filter(Boolean).map(decodeURIComponent);
|
||||
if (v.length === 0) return null;
|
||||
// A malformed range (wrong arity) has no safe single-sided interpretation —
|
||||
// "between:2026-01-01" doesn't say whether that's the from or the to — so
|
||||
// it degrades to "filter not applied" rather than guessing a half-open range.
|
||||
if (op === "between" && v.length !== 2) return null;
|
||||
return { op, v };
|
||||
}
|
||||
|
||||
/** Every FilterDef's current value, parsed from the URL. Unknown/malformed entries are dropped. */
|
||||
export function parseFilters(
|
||||
defs: FilterDef[],
|
||||
params: URLSearchParams,
|
||||
ns?: string,
|
||||
): Record<string, FilterValue> {
|
||||
const out: Record<string, FilterValue> = {};
|
||||
for (const def of defs) {
|
||||
const raw = params.get(nsKey(def.key, ns));
|
||||
if (!raw) continue;
|
||||
const value = decodeFilterValue(def.type, raw);
|
||||
if (value) out[def.key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Write (or delete) one filter's value into a URLSearchParams, returning a new instance. */
|
||||
export function writeFilter(
|
||||
params: URLSearchParams,
|
||||
def: FilterDef,
|
||||
value: FilterValue | undefined,
|
||||
ns?: string,
|
||||
): URLSearchParams {
|
||||
const next = new URLSearchParams(params);
|
||||
const key = nsKey(def.key, ns);
|
||||
if (!value || value.v.length === 0) next.delete(key);
|
||||
else next.set(key, encodeFilterValue(def.type, value));
|
||||
return next;
|
||||
}
|
||||
|
||||
function nsKey(key: string, ns?: string): string {
|
||||
return ns ? `${ns}.${key}` : key;
|
||||
}
|
||||
|
||||
/** `?sort=field:DIR` -> `"field:DIR"`, defaulting when absent/unrecognized. */
|
||||
export function parseSort(params: URLSearchParams, options: { value: string }[], fallback: string): string {
|
||||
const raw = params.get("sort");
|
||||
if (raw && options.some((o) => o.value === raw)) return raw;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten every def's parsed value into the flat param object a page's
|
||||
* react-query filter object / axios params already expect. `toParams`
|
||||
* defaults to `{ [key]: v.join(",") }`, which reproduces exactly what
|
||||
* `?statuses=A,B` meant before this bar existed.
|
||||
*/
|
||||
export function toApiParams(
|
||||
defs: FilterDef[],
|
||||
values: Record<string, FilterValue>,
|
||||
): Record<string, string | undefined> {
|
||||
const out: Record<string, string | undefined> = {};
|
||||
for (const def of defs) {
|
||||
const value = values[def.key];
|
||||
if (!value) continue;
|
||||
const mapped = def.toParams ? def.toParams(value) : { [def.key]: value.v.join(",") };
|
||||
Object.assign(out, mapped);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Delete emptystring/undefined/null entries — never send them, never write them to the URL. */
|
||||
export function cleanParams<T extends Record<string, unknown>>(params: T): Partial<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== ""),
|
||||
) as Partial<T>;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
|
||||
import type { DataTablePagination, DataTableProps } from "@edr/ui-common";
|
||||
import type { FilterDef, FilterValue } from "./types";
|
||||
import { cleanParams, parseFilters, toApiParams, writeFilter } from "./url";
|
||||
|
||||
export interface UseFiltersOptions {
|
||||
/** value = "field:DIR", matching the codebase's existing sort convention. */
|
||||
defaultSort?: string;
|
||||
pageSize?: number;
|
||||
/** "page" (freight: {page,pageSize}) or "skip" (record-management: {skip,take}). */
|
||||
paginationStyle?: "page" | "skip";
|
||||
/** Namespaces URL keys ("<ns>.<key>") for pages with two independent tables. */
|
||||
ns?: string;
|
||||
/** Search box debounce, ms. */
|
||||
searchDebounceMs?: number;
|
||||
}
|
||||
|
||||
export interface UseFilters {
|
||||
values: Record<string, FilterValue>;
|
||||
/** Flat params ready for the react-query key + axios `params` — IS the query key input. */
|
||||
params: Record<string, string | number>;
|
||||
sort: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
searchText: string;
|
||||
setSearchText: (s: string) => void;
|
||||
setFilter: (key: string, value: FilterValue | undefined) => void;
|
||||
removeFilter: (key: string) => void;
|
||||
clearFilters: () => void;
|
||||
setSort: (s: string) => void;
|
||||
setPage: (p: number) => void;
|
||||
setPageSize: (size: number) => void;
|
||||
activeCount: number;
|
||||
/** Spread onto <DataTable/>. Same shape useListControls.tableProps returns today. */
|
||||
tableProps: (total: number) => Pick<DataTableProps<any, any>, "pagination" | "tableOptions">;
|
||||
/** Replace the whole URL (saved-view restore). Pushes, so Back undoes it. */
|
||||
applyQueryString: (query: string) => void;
|
||||
/** Current filter state as a raw query string, for saving as a view (page stripped). */
|
||||
currentQueryString: () => string;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
export function useFilters(defs: FilterDef[], options: UseFiltersOptions = {}): UseFilters {
|
||||
const {
|
||||
defaultSort = "",
|
||||
pageSize: defaultPageSize = DEFAULT_PAGE_SIZE,
|
||||
paginationStyle = "page",
|
||||
ns,
|
||||
searchDebounceMs = 300,
|
||||
} = options;
|
||||
|
||||
const [sp, setSp] = useSearchParams();
|
||||
const searchKey = ns ? `${ns}.q` : "q";
|
||||
const pageKey = ns ? `${ns}.page` : "page";
|
||||
const sizeKey = ns ? `${ns}.size` : "size";
|
||||
|
||||
const values = useMemo(() => parseFilters(defs, sp, ns), [defs, sp, ns]);
|
||||
const sort = sp.get(ns ? `${ns}.sort` : "sort") ?? defaultSort;
|
||||
const page = Math.max(1, Number(sp.get(pageKey)) || 1);
|
||||
const pageSize = Math.max(1, Number(sp.get(sizeKey)) || defaultPageSize);
|
||||
|
||||
// Free text: local draft debounced into the URL with `replace`, so typing
|
||||
// leaves exactly one history entry instead of one per keystroke.
|
||||
const [searchText, setSearchTextState] = useState(() => sp.get(searchKey) ?? "");
|
||||
const [debouncedSearch] = useDebouncedValue(searchText, searchDebounceMs);
|
||||
|
||||
useEffect(() => {
|
||||
const urlValue = sp.get(searchKey) ?? "";
|
||||
if (urlValue === debouncedSearch) return;
|
||||
setSp(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (debouncedSearch) next.set(searchKey, debouncedSearch);
|
||||
else next.delete(searchKey);
|
||||
next.delete(pageKey);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
// External navigation (back/forward, saved-view restore, deep link) —
|
||||
// sync the local draft from the URL. Comparing against the debounced
|
||||
// value (not `searchText`) is what stops this from clobbering an
|
||||
// in-flight keystroke: mid-type, urlValue !== debouncedSearch is expected.
|
||||
const urlValue = sp.get(searchKey) ?? "";
|
||||
if (urlValue !== debouncedSearch) setSearchTextState(urlValue);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sp]);
|
||||
|
||||
const setFilter = useCallback(
|
||||
(key: string, value: FilterValue | undefined) => {
|
||||
const def = defs.find((d) => d.key === key);
|
||||
if (!def) return;
|
||||
setSp((prev) => {
|
||||
const next = writeFilter(prev, def, value, ns);
|
||||
next.delete(pageKey);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[defs, ns, pageKey, setSp],
|
||||
);
|
||||
|
||||
const removeFilter = useCallback((key: string) => setFilter(key, undefined), [setFilter]);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setSp((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
for (const def of defs) next.delete(ns ? `${ns}.${def.key}` : def.key);
|
||||
next.delete(searchKey);
|
||||
next.delete(pageKey);
|
||||
return next;
|
||||
});
|
||||
setSearchTextState("");
|
||||
}, [defs, ns, pageKey, searchKey, setSp]);
|
||||
|
||||
const setSort = useCallback(
|
||||
(value: string) => {
|
||||
setSp((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
const key = ns ? `${ns}.sort` : "sort";
|
||||
if (value === defaultSort) next.delete(key);
|
||||
else next.set(key, value);
|
||||
next.delete(pageKey);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[defaultSort, ns, pageKey, setSp],
|
||||
);
|
||||
|
||||
const setPage = useCallback(
|
||||
(p: number) => {
|
||||
setSp((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (p <= 1) next.delete(pageKey);
|
||||
else next.set(pageKey, String(p));
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[pageKey, setSp],
|
||||
);
|
||||
|
||||
const setPageSize = useCallback(
|
||||
(size: number) => {
|
||||
setSp((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (size === defaultPageSize) next.delete(sizeKey);
|
||||
else next.set(sizeKey, String(size));
|
||||
next.delete(pageKey); // a different page size invalidates the current page index
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[defaultPageSize, sizeKey, pageKey, setSp],
|
||||
);
|
||||
|
||||
const params = useMemo(() => {
|
||||
const filterParams = toApiParams(defs, values);
|
||||
const base: Record<string, string | number | undefined> =
|
||||
paginationStyle === "skip"
|
||||
? { skip: (page - 1) * pageSize, take: pageSize }
|
||||
: { page, pageSize };
|
||||
if (debouncedSearch) base.search = debouncedSearch;
|
||||
if (sort) {
|
||||
if (paginationStyle === "skip") base.orderBy = sort;
|
||||
else {
|
||||
const [sortBy, sortOrder] = sort.split(":");
|
||||
base.sortBy = sortBy;
|
||||
base.sortOrder = sortOrder;
|
||||
}
|
||||
}
|
||||
return cleanParams({ ...filterParams, ...base }) as Record<string, string | number>;
|
||||
}, [defs, values, paginationStyle, page, pageSize, debouncedSearch, sort]);
|
||||
|
||||
const activeCount = Object.keys(values).length + (debouncedSearch ? 1 : 0);
|
||||
|
||||
const tableProps = useCallback(
|
||||
(total: number): Pick<DataTableProps<any, any>, "pagination" | "tableOptions"> => {
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||
const pagination: DataTablePagination = { pageIndex: page - 1, pageSize, pageCount, totalCount: total };
|
||||
return {
|
||||
pagination,
|
||||
tableOptions: {
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
state: { pagination: { pageIndex: page - 1, pageSize } },
|
||||
onPaginationChange: (updater) => {
|
||||
const current = { pageIndex: page - 1, pageSize };
|
||||
const next = typeof updater === "function" ? updater(current) : updater;
|
||||
if (next.pageSize !== pageSize) setPageSize(next.pageSize);
|
||||
else if (next.pageIndex !== current.pageIndex) setPage(next.pageIndex + 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
[page, pageSize, setPage, setPageSize],
|
||||
);
|
||||
|
||||
const applyQueryString = useCallback(
|
||||
(query: string) => setSp(new URLSearchParams(query)),
|
||||
[setSp],
|
||||
);
|
||||
|
||||
const currentQueryString = useCallback(() => {
|
||||
const next = new URLSearchParams(sp);
|
||||
next.delete(pageKey);
|
||||
return next.toString();
|
||||
}, [sp, pageKey]);
|
||||
|
||||
return {
|
||||
values,
|
||||
params,
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
searchText,
|
||||
setSearchText: setSearchTextState,
|
||||
setFilter,
|
||||
removeFilter,
|
||||
clearFilters,
|
||||
setSort,
|
||||
setPage,
|
||||
setPageSize,
|
||||
activeCount,
|
||||
tableProps,
|
||||
applyQueryString,
|
||||
currentQueryString,
|
||||
};
|
||||
}
|
||||
|
||||
export { toApiParams } from "./url";
|
||||
@@ -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 ":<userId>" if shared-terminal login appears.
|
||||
*/
|
||||
export function useSavedViews(viewId: string) {
|
||||
const [views, setViews] = useLocalStorage<SavedView[]>({
|
||||
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 };
|
||||
}
|
||||
@@ -1,11 +1,30 @@
|
||||
import { Alert, Badge, Button, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Radio,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, RefreshCw, Send, ShieldCheck } from "lucide-react";
|
||||
import { AlertTriangle, Ban, Download, FileText, RefreshCw, Send, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type { EimsInvoiceStatus } from "@/types/eims";
|
||||
import { eimsService } from "@/services/eims.service";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
import { EIMS_MODE_OF_PAYMENT, type EimsInvoiceStatus, type EimsModeOfPayment } from "@/types/eims";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
|
||||
@@ -14,6 +33,7 @@ const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
|
||||
REGISTERED: "edr-green",
|
||||
FAILED: "red",
|
||||
UNKNOWN: "orange",
|
||||
CANCELLED: "gray",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
|
||||
@@ -22,6 +42,7 @@ const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
|
||||
REGISTERED: "Filed",
|
||||
FAILED: "Rejected",
|
||||
UNKNOWN: "Unacknowledged",
|
||||
CANCELLED: "Cancelled",
|
||||
};
|
||||
|
||||
function Field({ label, value }: { label: string; value?: string | number | null }) {
|
||||
@@ -37,6 +58,367 @@ function Field({ label, value }: { label: string; value?: string | number | null
|
||||
);
|
||||
}
|
||||
|
||||
/** Reason codes from the collection docs, e.g. "1" (Duplicate), "6" (Calculation Error). */
|
||||
const CANCEL_REASON_CODES = [
|
||||
{ value: "1", label: "1 — Duplicate" },
|
||||
{ value: "2", label: "2 — Buyer request" },
|
||||
{ value: "3", label: "3 — Data entry error" },
|
||||
{ value: "6", label: "6 — Calculation error" },
|
||||
];
|
||||
|
||||
function CancelModal({
|
||||
invoiceId,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [reasonCode, setReasonCode] = useState<string | null>(null);
|
||||
const [remark, setRemark] = useState("");
|
||||
|
||||
const cancel = useMutation(
|
||||
api.invoices.eimsCancel.mutationOptions({
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
toast({ title: "Cancelled with MoR" });
|
||||
},
|
||||
onError: (error) => toast({ title: "Could not cancel", description: error.message, variant: "destructive" }),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Cancel EIMS registration" centered>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Cancels this invoice's registered document at MoR. Irreversible — an already-cancelled
|
||||
invoice refuses a second attempt.
|
||||
</Text>
|
||||
<Select
|
||||
label="Reason code"
|
||||
withAsterisk
|
||||
data={CANCEL_REASON_CODES}
|
||||
value={reasonCode}
|
||||
onChange={setReasonCode}
|
||||
placeholder="Select a reason"
|
||||
/>
|
||||
<Textarea
|
||||
label="Remark"
|
||||
placeholder="Optional note"
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Button
|
||||
color="red"
|
||||
leftSection={<Ban size={16} />}
|
||||
loading={cancel.isPending}
|
||||
disabled={!reasonCode}
|
||||
onClick={() => cancel.mutate({ id: invoiceId, reasonCode: reasonCode!, remark: remark.trim() || undefined })}
|
||||
>
|
||||
Cancel with MoR
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SalesReceiptModal({
|
||||
invoiceId,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [modeOfPayment, setModeOfPayment] = useState<EimsModeOfPayment | null>(null);
|
||||
const [collectedAmount, setCollectedAmount] = useState<number | "">("");
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const register = useMutation(
|
||||
api.invoices.eimsRegisterSalesReceipt.mutationOptions({
|
||||
onSuccess: (receipt) => {
|
||||
onClose();
|
||||
toast({ title: "Sales receipt filed", description: `RRN ${receipt.rrn ?? "—"}` });
|
||||
},
|
||||
onError: (error) => toast({ title: "Could not file receipt", description: error.message, variant: "destructive" }),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="File sales receipt" centered>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Mode of payment"
|
||||
withAsterisk
|
||||
data={EIMS_MODE_OF_PAYMENT.map((v) => ({ value: v, label: v }))}
|
||||
value={modeOfPayment}
|
||||
onChange={(v) => setModeOfPayment(v as EimsModeOfPayment)}
|
||||
placeholder="Select"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Collected amount"
|
||||
placeholder="Defaults to the invoice's paid amount"
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={collectedAmount}
|
||||
onChange={(v) => setCollectedAmount(v === "" ? "" : Number(v))}
|
||||
/>
|
||||
<TextInput
|
||||
label="Reason"
|
||||
placeholder='Defaults to "Payment received"'
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Send size={16} />}
|
||||
loading={register.isPending}
|
||||
disabled={!modeOfPayment}
|
||||
onClick={() =>
|
||||
register.mutate({
|
||||
id: invoiceId,
|
||||
modeOfPayment: modeOfPayment!,
|
||||
collectedAmount: collectedAmount === "" ? undefined : collectedAmount,
|
||||
reason: reason.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
File with MoR
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function WithholdingReceiptModal({
|
||||
invoiceId,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [type, setType] = useState("TWHT");
|
||||
const [preTaxAmount, setPreTaxAmount] = useState<number | "">("");
|
||||
const [withholdingAmount, setWithholdingAmount] = useState<number | "">("");
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const register = useMutation(
|
||||
api.invoices.eimsRegisterWithholdingReceipt.mutationOptions({
|
||||
onSuccess: (receipt) => {
|
||||
onClose();
|
||||
toast({ title: "Withholding receipt filed", description: `RRN ${receipt.rrn ?? "—"}` });
|
||||
},
|
||||
onError: (error) => toast({ title: "Could not file receipt", description: error.message, variant: "destructive" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const valid = preTaxAmount !== "" && withholdingAmount !== "";
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="File withholding receipt" centered>
|
||||
<Stack gap="md">
|
||||
<TextInput label="Type" withAsterisk value={type} onChange={(e) => setType(e.currentTarget.value)} />
|
||||
<NumberInput
|
||||
label="Pre-tax amount"
|
||||
withAsterisk
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={preTaxAmount}
|
||||
onChange={(v) => setPreTaxAmount(v === "" ? "" : Number(v))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Withholding amount"
|
||||
withAsterisk
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={withholdingAmount}
|
||||
onChange={(v) => setWithholdingAmount(v === "" ? "" : Number(v))}
|
||||
/>
|
||||
<TextInput
|
||||
label="Reason"
|
||||
placeholder='Defaults to "Withholding"'
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Send size={16} />}
|
||||
loading={register.isPending}
|
||||
disabled={!valid}
|
||||
onClick={() =>
|
||||
register.mutate({
|
||||
id: invoiceId,
|
||||
type,
|
||||
preTaxAmount: preTaxAmount as number,
|
||||
withholdingAmount: withholdingAmount as number,
|
||||
reason: reason.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
File with MoR
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function MemoModal({
|
||||
invoiceId,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [type, setType] = useState<"CRE" | "DEB">("CRE");
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const issue = useMutation(
|
||||
api.invoices.issueMemo.mutationOptions({
|
||||
onSuccess: (memo) => {
|
||||
onClose();
|
||||
toast({ title: "Memo issued", description: `${memo.invoiceNumber} — file it with MoR separately` });
|
||||
},
|
||||
onError: (error) => toast({ title: "Could not issue memo", description: error.message, variant: "destructive" }),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Issue credit/debit memo" centered>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Creates a new invoice linked to this one, with every line copied verbatim. Filing it with
|
||||
MoR is a separate step — it does not happen automatically here.
|
||||
</Text>
|
||||
<Radio.Group value={type} onChange={(v) => setType(v as "CRE" | "DEB")} label="Type">
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Radio value="CRE" label="Credit memo" description="Reduces what the buyer owes; created settled." />
|
||||
<Radio value="DEB" label="Debit memo" description="An additional charge; created as a new open invoice." />
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
withAsterisk
|
||||
placeholder="Why this memo is being issued"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={issue.isPending}
|
||||
disabled={!reason.trim()}
|
||||
onClick={() => issue.mutate({ id: invoiceId, type, reason: reason.trim() })}
|
||||
>
|
||||
Issue memo
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ReceiptsSection({ invoiceId, canFile }: { invoiceId: string; canFile: boolean }) {
|
||||
const { toast } = useToast();
|
||||
const { data: receipts } = useQuery(api.invoices.eimsReceipts.queryOptions({ input: { id: invoiceId } }));
|
||||
const [salesOpen, setSalesOpen] = useState(false);
|
||||
const [withholdingOpen, setWithholdingOpen] = useState(false);
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
|
||||
const download = async (receiptId: string, receiptNumber: string) => {
|
||||
setDownloadingId(receiptId);
|
||||
try {
|
||||
const { data } = await eimsService.downloadReceiptDocument(invoiceId, receiptId);
|
||||
openPdfBlob(data, `${receiptNumber}.pdf`);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Could not download receipt",
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
Receipts
|
||||
</Text>
|
||||
{canFile && (
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" onClick={() => setSalesOpen(true)}>
|
||||
File sales receipt
|
||||
</Button>
|
||||
<Button size="xs" variant="light" onClick={() => setWithholdingOpen(true)}>
|
||||
File withholding receipt
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{receipts && receipts.length > 0 ? (
|
||||
<Table striped withTableBorder={false} verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Kind</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>RRN</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{receipts.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>{r.kind}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLOR[r.status] ?? "gray"} variant="light" size="sm">
|
||||
{STATUS_LABEL[r.status] ?? r.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td style={{ fontFamily: "monospace" }}>{r.rrn ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
{r.status === "REGISTERED" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<Download size={14} />}
|
||||
loading={downloadingId === r.id}
|
||||
onClick={() => void download(r.id, r.receiptNumber)}
|
||||
>
|
||||
PDF
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No receipts filed yet.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<SalesReceiptModal invoiceId={invoiceId} opened={salesOpen} onClose={() => setSalesOpen(false)} />
|
||||
<WithholdingReceiptModal invoiceId={invoiceId} opened={withholdingOpen} onClose={() => setWithholdingOpen(false)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MoR EIMS filing state for one invoice, with the manual actions.
|
||||
*
|
||||
@@ -48,6 +430,12 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const canFile = hasPermission(user, FREIGHT_PERMS.invoices.eimsRegister);
|
||||
const canCancel = hasPermission(user, FREIGHT_PERMS.invoices.eimsCancel);
|
||||
const canFileReceipt = hasPermission(user, FREIGHT_PERMS.invoices.eimsReceiptRegister);
|
||||
const canIssueMemo = hasPermission(user, FREIGHT_PERMS.invoices.memoIssue);
|
||||
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [memoOpen, setMemoOpen] = useState(false);
|
||||
|
||||
const { data: eims, isLoading } = useQuery(
|
||||
api.invoices.eimsStatus.queryOptions({ input: { id: invoiceId }, enabled: Boolean(invoiceId) }),
|
||||
@@ -118,39 +506,78 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{canFile && (
|
||||
<Group gap="sm">
|
||||
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
|
||||
{status !== "REGISTERED" && status !== "UNKNOWN" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
loading={register.isPending}
|
||||
disabled={busy}
|
||||
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
|
||||
onClick={() => register.mutate({ id: invoiceId })}
|
||||
>
|
||||
{status === "FAILED" ? "File again" : "File with MoR"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{eims.eimsIrn && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
loading={verify.isPending}
|
||||
disabled={busy}
|
||||
leftSection={<ShieldCheck size={14} />}
|
||||
onClick={() => verify.mutate({ id: invoiceId })}
|
||||
>
|
||||
Verify with MoR
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
{status === "CANCELLED" && (
|
||||
<Alert color="gray" icon={<Ban size={16} />} title="Cancelled with MoR">
|
||||
{eims.eimsCancellationDate ? `Confirmed ${eims.eimsCancellationDate}. ` : ""}
|
||||
{eims.eimsCancellationRemark}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group gap="sm">
|
||||
{canFile && (
|
||||
<>
|
||||
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
|
||||
{status !== "REGISTERED" && status !== "UNKNOWN" && status !== "CANCELLED" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
loading={register.isPending}
|
||||
disabled={busy}
|
||||
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
|
||||
onClick={() => register.mutate({ id: invoiceId })}
|
||||
>
|
||||
{status === "FAILED" ? "File again" : "File with MoR"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{eims.eimsIrn && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
loading={verify.isPending}
|
||||
disabled={busy}
|
||||
leftSection={<ShieldCheck size={14} />}
|
||||
onClick={() => verify.mutate({ id: invoiceId })}
|
||||
>
|
||||
Verify with MoR
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{canCancel && eims.eimsIrn && status !== "CANCELLED" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<Ban size={14} />}
|
||||
onClick={() => setCancelOpen(true)}
|
||||
>
|
||||
Cancel with MoR
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canIssueMemo && status === "REGISTERED" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<FileText size={14} />}
|
||||
onClick={() => setMemoOpen(true)}
|
||||
>
|
||||
Issue credit/debit memo
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{eims.eimsIrn && <ReceiptsSection invoiceId={invoiceId} canFile={canFileReceipt} />}
|
||||
</Stack>
|
||||
|
||||
<CancelModal invoiceId={invoiceId} opened={cancelOpen} onClose={() => setCancelOpen(false)} />
|
||||
<MemoModal invoiceId={invoiceId} opened={memoOpen} onClose={() => setMemoOpen(false)} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user