This commit is contained in:
Marshal
2026-06-06 06:32:04 +00:00
parent f8270175a2
commit 053b4f35c5
15 changed files with 894 additions and 661 deletions

View File

@@ -14,6 +14,9 @@
"dependencies": { "dependencies": {
"@edr/types": "workspace:*", "@edr/types": "workspace:*",
"@edr/ui-common": "workspace:*", "@edr/ui-common": "workspace:*",
"@mantine/core": "^9.3.0",
"@mantine/hooks": "^9.3.0",
"@tabler/icons-react": "^3.44.0",
"@tanstack/react-query": "^5.100.11", "@tanstack/react-query": "^5.100.11",
"@tria-plc/iamui-common": "1.1.1", "@tria-plc/iamui-common": "1.1.1",
"axios": "^1.7.7", "axios": "^1.7.7",

View File

@@ -1,21 +1,23 @@
import { Badge } from "@mantine/core";
export function BookingPriorityBadge({ score }: { score: number }) { export function BookingPriorityBadge({ score }: { score: number }) {
if (score >= 1000) { if (score >= 1000) {
return ( return (
<span className="rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-red-700"> <Badge color="red" variant="filled" size="sm" radius="lg" tt="uppercase">
Urgent Urgent
</span> </Badge>
); );
} }
if (score >= 500) { if (score >= 500) {
return ( return (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700"> <Badge color="yellow" variant="filled" size="sm" radius="lg" tt="uppercase">
High High
</span> </Badge>
); );
} }
return ( return (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600"> <Badge color="gray" variant="light" size="sm" radius="lg" tt="uppercase">
Normal Normal
</span> </Badge>
); );
} }

View File

@@ -1,6 +1,5 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { bookingGlass } from "./booking-ui.styles"; import { Card, Group, Stack, Text, SimpleGrid } from "@mantine/core";
import { cn } from "@/lib/utils";
export interface StatItem { export interface StatItem {
label: string; label: string;
@@ -10,54 +9,76 @@ export interface StatItem {
accent?: "default" | "amber" | "emerald" | "rose"; accent?: "default" | "amber" | "emerald" | "rose";
} }
const iconAccentStyles = { const accentColors = {
default: "text-foreground/70", default: { bg: "var(--mantine-color-gray-1)", color: "var(--mantine-color-gray-6)" },
amber: "text-amber-600 dark:text-amber-400", amber: { bg: "var(--mantine-color-yellow-1)", color: "var(--mantine-color-yellow-6)" },
emerald: "text-emerald-600 dark:text-emerald-400", emerald: { bg: "var(--mantine-color-green-1)", color: "var(--mantine-color-green-6)" },
rose: "text-rose-600 dark:text-rose-400", rose: { bg: "var(--mantine-color-red-1)", color: "var(--mantine-color-red-6)" },
}; };
export function BookingStatGrid({ items }: { items: StatItem[] }) { export function BookingStatGrid({ items }: { items: StatItem[] }) {
return ( return (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
{items.map((item) => { {items.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const accent = item.accent ?? "default"; const accent = item.accent ?? "default";
const accentStyle = accentColors[accent];
return ( return (
<div <Card
key={item.label} key={item.label}
className={cn( p="lg"
"group relative overflow-hidden rounded-xl p-5 transition-all duration-200 hover:shadow-md", radius="lg"
bookingGlass.card, withBorder
)} style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
transition: "all 0.2s ease",
cursor: "pointer",
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.08)";
e.currentTarget.style.borderColor = "var(--mantine-color-gray-3)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = "none";
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
}}
> >
<div className="flex items-start justify-between gap-3"> <Group justify="space-between" align="flex-start">
<div className="min-w-0 flex-1"> <Stack gap="xs" style={{ flex: 1 }}>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> <Text size="xs" fw={600} c="dimmed" tt="uppercase">
{item.label} {item.label}
</p> </Text>
<p className="mt-2 text-3xl font-semibold tabular-nums tracking-tight text-foreground"> <Text size="32px" fw={700} style={{ lineHeight: 1, letterSpacing: "-0.02em" }}>
{item.value} {item.value}
</p> </Text>
{item.hint && ( {item.hint && (
<p className="mt-1 text-xs leading-relaxed text-muted-foreground"> <Text size="xs" c="dimmed">
{item.hint} {item.hint}
</p> </Text>
)} )}
</div> </Stack>
<div <div
className={cn( style={{
"flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-[1.02]", display: "flex",
bookingGlass.iconWellGreen, alignItems: "center",
iconAccentStyles[accent], justifyContent: "center",
)} width: "44px",
height: "44px",
borderRadius: "10px",
background: accentStyle.bg,
color: accentStyle.color,
flexShrink: 0,
transition: "transform 0.2s ease",
}}
> >
<Icon className="size-[18px]" strokeWidth={1.75} /> <Icon size={22} strokeWidth={1.75} />
</div> </div>
</div> </Group>
</div> </Card>
); );
})} })}
</div> </SimpleGrid>
); );
} }

View File

@@ -1,19 +1,43 @@
import { Badge } from "@edr/ui-common"; import { Badge } from "@mantine/core";
import { cn } from "@/lib/utils";
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config"; import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
const statusColorMap: Record<string, string> = {
DRAFT: "gray",
SUBMITTED: "yellow",
CHANGES_REQUESTED: "orange",
PENDING_APPROVAL: "yellow",
APPROVED_PENDING_SIGNATURE: "cyan",
APPROVED: "green",
CONTRACT_READY: "indigo",
SIGNED_CUSTOMER: "cyan",
FULLY_EXECUTED: "indigo",
PNR_GENERATED: "violet",
PAYMENT_VERIFICATION_IN_PROGRESS: "yellow",
PAID: "green",
IN_TRANSIT: "cyan",
COMPLETED: "indigo",
REJECTED: "red",
CANCELLED: "red",
PENDING_CONSOLIDATION: "yellow",
CONSOLIDATED: "indigo",
};
export function BookingStatusBadge({ status }: { status: string }) { export function BookingStatusBadge({ status }: { status: string }) {
const style = BOOKING_STATUS_STYLES[status] ?? { const style = BOOKING_STATUS_STYLES[status] ?? {
label: status, label: status,
color: "bg-muted text-muted-foreground border-border", color: "gray",
}; };
const color = statusColorMap[status] ?? "gray";
return ( return (
<Badge <Badge
variant="outline" color={color}
className={cn( variant="light"
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider", size="sm"
style.color, radius="md"
)} tt="uppercase"
fw={600}
style={{ fontSize: "0.7rem", letterSpacing: "0.05em" }}
> >
{style.label} {style.label}
</Badge> </Badge>

View File

@@ -8,27 +8,24 @@ import {
Wallet, Wallet,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { Group, Badge, UnstyledButton, Stack, Text } from "@mantine/core";
import { import {
BOOKING_LIST_TABS, BOOKING_LIST_TABS,
type BookingStatusTabKey, type BookingStatusTabKey,
} from "@/features/bookings/booking-status.config"; } from "@/features/bookings/booking-status.config";
import { bookingGlass } from "./booking-ui.styles";
import { cn } from "@/lib/utils";
const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = { const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
all: <LayoutGrid className="size-3.5" strokeWidth={1.75} />, all: <LayoutGrid size={18} strokeWidth={1.75} />,
intake: <Inbox className="size-3.5" strokeWidth={1.75} />, intake: <Inbox size={18} strokeWidth={1.75} />,
in_approval: <ClipboardCheck className="size-3.5" strokeWidth={1.75} />, in_approval: <ClipboardCheck size={18} strokeWidth={1.75} />,
approved_contract: <FileSignature className="size-3.5" strokeWidth={1.75} />, approved_contract: <FileSignature size={18} strokeWidth={1.75} />,
payment: <Wallet className="size-3.5" strokeWidth={1.75} />, payment: <Wallet size={18} strokeWidth={1.75} />,
operations: <Train className="size-3.5" strokeWidth={1.75} />, operations: <Train size={18} strokeWidth={1.75} />,
completed: <CheckCircle className="size-3.5" strokeWidth={1.75} />, completed: <CheckCircle size={18} strokeWidth={1.75} />,
closed: <XCircle className="size-3.5" strokeWidth={1.75} />, closed: <XCircle size={18} strokeWidth={1.75} />,
}; };
const activeTabText = "text-black";
interface BookingStatusTabsProps { interface BookingStatusTabsProps {
active: BookingStatusTabKey; active: BookingStatusTabKey;
onChange: (tab: BookingStatusTabKey) => void; onChange: (tab: BookingStatusTabKey) => void;
@@ -41,66 +38,68 @@ export function BookingStatusTabs({
counts, counts,
}: BookingStatusTabsProps) { }: BookingStatusTabsProps) {
return ( return (
<div className={bookingGlass.tabRail}> <Group
<div gap="sm"
className="flex flex-nowrap gap-1.5 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden" wrap="wrap"
role="tablist" p="md"
aria-label="Booking status filters" style={{
> background: "var(--mantine-color-gray-0)",
{BOOKING_LIST_TABS.map((tab) => { borderRadius: "12px",
const isActive = active === tab.key; border: "1px solid var(--mantine-color-gray-2)",
const count = counts?.[tab.key]; }}
return ( >
<button {BOOKING_LIST_TABS.map((tab) => {
key={tab.key} const isActive = active === tab.key;
type="button" const count = counts?.[tab.key];
role="tab" return (
aria-selected={isActive} <UnstyledButton
onClick={() => onChange(tab.key)} key={tab.key}
className={cn( onClick={() => onChange(tab.key)}
"flex min-w-[8rem] shrink-0 flex-col items-start gap-0.5 rounded-lg px-3 py-2.5 text-left transition-all duration-200", style={{
isActive background: isActive ? "white" : "transparent",
? bookingGlass.activeTab border: isActive ? "1px solid var(--mantine-color-blue-3)" : "1px solid var(--mantine-color-gray-2)",
: "text-muted-foreground hover:bg-emerald-500/5 hover:text-foreground", borderRadius: "10px",
)} padding: "10px 16px",
> transition: "all 0.2s ease",
<span className="flex w-full items-center justify-between gap-2"> cursor: "pointer",
<span boxShadow: isActive ? "0 2px 8px rgba(59, 130, 246, 0.1)" : "none",
className={cn( }}
"flex items-center gap-2 text-sm font-medium", >
isActive ? activeTabText : "text-muted-foreground", <Group gap="sm" justify="space-between" wrap="nowrap">
)} <Group gap={8}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "32px",
height: "32px",
borderRadius: "8px",
background: isActive ? "var(--mantine-color-blue-1)" : "var(--mantine-color-gray-1)",
color: isActive ? "var(--mantine-color-blue-7)" : "var(--mantine-color-gray-6)",
}}
> >
<span {TAB_ICONS[tab.key]}
className={cn( </div>
"flex size-7 shrink-0 items-center justify-center rounded-md", <Text size="sm" fw={600}>
isActive {tab.label}
? cn(bookingGlass.iconWellGreen, "text-black") </Text>
: "border border-transparent bg-muted/30", </Group>
)} {count !== undefined && count > 0 && (
> <Badge
{TAB_ICONS[tab.key]} size="sm"
</span> variant={isActive ? "filled" : "light"}
<span className="whitespace-nowrap">{tab.label}</span> color={isActive ? "blue" : "gray"}
</span> radius="lg"
{count !== undefined && count > 0 && ( >
<span {count}
className={cn( </Badge>
"rounded-full px-2 py-0.5 text-[10px] font-semibold tabular-nums", )}
isActive </Group>
? cn("bg-emerald-500/15", activeTabText) </UnstyledButton>
: "bg-muted/50 text-muted-foreground", );
)} })}
> </Group>
{count}
</span>
)}
</span>
</button>
);
})}
</div>
</div>
); );
} }

View File

@@ -1,12 +1,11 @@
import { Stack, Group, Text, Pagination, Card, SimpleGrid } from "@mantine/core";
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources"; import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
import type { RuleEngineRecord } from "@/types/rule-engine"; import type { RuleEngineRecord } from "@/types/rule-engine";
import { DataTableFooter } from "@edr/ui-common";
import type { Table } from "@edr/ui-common";
import RuleEngineRecordActions from "./RuleEngineRecordActions"; import RuleEngineRecordActions from "./RuleEngineRecordActions";
import { cardInitials, resolveCardPresentation } from "./ruleEngineCardMeta"; import { cardInitials, resolveCardPresentation } from "./ruleEngineCardMeta";
import { formatCell } from "./ruleEngineFormat"; import { formatCell } from "./ruleEngineFormat";
import { ruleEngineCard } from "./ruleEngineStyles";
export interface RuleEngineCardGridProps { export interface RuleEngineCardGridProps {
config: RuleEngineResourceConfig; config: RuleEngineResourceConfig;
@@ -14,7 +13,6 @@ export interface RuleEngineCardGridProps {
status: "loading" | "error" | "success"; status: "loading" | "error" | "success";
emptyMessage: string; emptyMessage: string;
itemLabel: string; itemLabel: string;
table: Table<RuleEngineRecord>;
pagination: { pagination: {
pageIndex: number; pageIndex: number;
pageSize: number; pageSize: number;
@@ -29,13 +27,49 @@ export interface RuleEngineCardGridProps {
onApproveRate?: (record: RuleEngineRecord) => void; onApproveRate?: (record: RuleEngineRecord) => void;
} }
const extractLabel = (value: unknown): string => {
if (!value || typeof value !== "object") {
return String(value || "");
}
const obj = value as Record<string, unknown>;
return (
(typeof obj.label === "string" ? obj.label : null) ||
(typeof obj.cargoTypeName === "string" ? obj.cargoTypeName : null) ||
(typeof obj.serviceName === "string" ? obj.serviceName : null) ||
(typeof obj.code === "string" ? obj.code : null) ||
(typeof obj.name === "string" ? obj.name : null) ||
(typeof obj.actionLabel === "string" ? obj.actionLabel : null) ||
String(value)
);
};
const getSmartValue = (record: RuleEngineRecord, key: string): unknown => {
const value = record[key as keyof RuleEngineRecord];
// If the value is already an object, use it directly
if (value && typeof value === "object") {
return value;
}
// If the key ends with "Id" and there's a corresponding non-Id key, use that
if (typeof key === "string" && key.endsWith("Id")) {
const relatedKey = key.slice(0, -2); // Remove "Id" suffix
const relatedValue = record[relatedKey as keyof RuleEngineRecord];
if (relatedValue && typeof relatedValue === "object") {
return relatedValue;
}
}
return value;
};
const RuleEngineCardGrid = ({ const RuleEngineCardGrid = ({
config, config,
rows, rows,
status, status,
emptyMessage, emptyMessage,
itemLabel, itemLabel,
table,
pagination, pagination,
onEdit, onEdit,
onDelete, onDelete,
@@ -48,109 +82,156 @@ const RuleEngineCardGrid = ({
if (status === "error") { if (status === "error") {
return ( return (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center"> <Stack align="center" justify="center" p="xl" style={{ minHeight: "400px" }}>
<p className="text-sm font-medium text-foreground">Failed to load data</p> <Text size="lg" fw={600} c="red">Failed to load data</Text>
<p className="mt-1 text-sm text-muted-foreground"> <Text size="sm" c="dimmed">
Please refresh the page or try again later. Please refresh the page or try again later.
</p> </Text>
</div> </Stack>
); );
} }
if (status === "loading") { if (status === "loading") {
return ( return (
<div className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3 xl:gap-4"> <Stack gap="md" p="md">
{Array.from({ length: 6 }).map((_, index) => ( <SimpleGrid cols={{ base: 1, sm: 2, md: 2, lg: 3 }} spacing="md">
<div key={index} className={ruleEngineCard.skeleton}> {Array.from({ length: 6 }).map((_, index) => (
<div className="flex gap-3"> <Card key={index} p="md" radius="lg" withBorder style={{ height: "280px", background: "var(--mantine-color-gray-0)" }}>
<div className="h-10 w-10 rounded-md bg-muted" /> <div style={{ animation: "pulse 2s infinite", opacity: 0.5 }}>
<div className="flex-1 space-y-2"> <div style={{ height: "20px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "12px" }} />
<div className="h-4 w-2/3 rounded-sm bg-muted" /> <div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "20px", width: "80%" }} />
<div className="h-3 w-1/3 rounded-sm bg-muted" /> <div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "8px" }} />
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px" }} />
</div> </div>
</div> </Card>
<div className="mt-4 space-y-2"> ))}
<div className="h-3 w-full rounded-sm bg-muted" /> </SimpleGrid>
<div className="h-3 w-4/5 rounded-sm bg-muted" /> </Stack>
</div>
</div>
))}
</div>
); );
} }
if (status === "success" && rows.length === 0) { if (status === "success" && rows.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center"> <Stack align="center" justify="center" p="xl" style={{ minHeight: "400px" }}>
<p className="text-sm font-medium text-foreground">{emptyMessage}</p> <Text size="lg" fw={600}>{emptyMessage}</Text>
<p className="mt-1 text-sm text-muted-foreground"> <Text size="sm" c="dimmed">
Try adjusting your search or add a new record. Try adjusting your search or add a new record.
</p> </Text>
</div> </Stack>
); );
} }
const avatarColors = ["blue", "cyan", "grape", "green", "lime", "orange", "pink", "red", "teal", "violet", "yellow"];
const getAvatarColor = (title: string) => avatarColors[title.charCodeAt(0) % avatarColors.length];
return ( return (
<> <Stack gap="md" p="md">
<div className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3 xl:gap-4"> <SimpleGrid cols={{ base: 1, sm: 2, md: 2, lg: 3 }} spacing="md">
{rows.map((record) => { {rows.map((record) => {
const title = String(record[presentation.titleKey] ?? "Untitled"); const titleValue = getSmartValue(record, presentation.titleKey);
const subtitle = presentation.subtitleKey const title = extractLabel(titleValue);
? String(record[presentation.subtitleKey] ?? "")
: ""; const subtitleValue = presentation.subtitleKey
const code = presentation.codeKey ? getSmartValue(record, presentation.subtitleKey)
? String(record[presentation.codeKey] ?? "") : null;
: ""; const subtitle = subtitleValue ? extractLabel(subtitleValue) : "";
const codeValue = presentation.codeKey
? getSmartValue(record, presentation.codeKey)
: null;
const code = codeValue ? extractLabel(codeValue) : "";
const statusValue = presentation.statusKey const statusValue = presentation.statusKey
? record[presentation.statusKey] ? record[presentation.statusKey]
: undefined; : undefined;
return ( return (
<article key={record.id} className={ruleEngineCard.article}> <Card
<div className={ruleEngineCard.header}> key={record.id}
<div className="flex items-start gap-3"> p="lg"
<div className={ruleEngineCard.avatar} aria-hidden> radius="lg"
withBorder
style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
display: "flex",
flexDirection: "column",
transition: "all 0.2s ease",
cursor: "pointer",
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.1)";
e.currentTarget.style.borderColor = "var(--mantine-color-blue-3)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = "none";
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
}}
>
<Group justify="space-between" align="flex-start" mb="md">
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "44px",
height: "44px",
borderRadius: "8px",
background: `var(--mantine-color-${getAvatarColor(title)}-1)`,
fontSize: "16px",
fontWeight: 700,
color: `var(--mantine-color-${getAvatarColor(title)}-7)`,
flexShrink: 0,
}}
>
{cardInitials(title)} {cardInitials(title)}
</div> </div>
<div className="min-w-0 flex-1"> <div style={{ minWidth: 0, flex: 1 }}>
<div className="flex flex-wrap items-center gap-2"> <Text size="sm" fw={700} truncate title={title}>
<h3 className={ruleEngineCard.title}>{title}</h3> {title}
{presentation.statusKey </Text>
? formatCell(statusValue, "activeBadge") {code && (
: null} <Text size="xs" c="dimmed" style={{ marginTop: "4px" }}>
</div> {code}
{(code || subtitle) && ( </Text>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
{code ? formatCell(code, "code") : null}
{subtitle ? (
<span className={ruleEngineCard.meta}>
{presentation.subtitleKey === "stepOrder"
? `Step ${subtitle}`
: subtitle}
</span>
) : null}
</div>
)} )}
</div> </div>
</div> </Group>
</div> {presentation.statusKey && (
<div>{formatCell(statusValue, "activeBadge")}</div>
)}
</Group>
{presentation.detailColumns.length > 0 ? ( {(subtitle || presentation.detailColumns.length > 0) && (
<dl className="grid flex-1 gap-x-4 gap-y-3 px-4 py-3.5 sm:grid-cols-2"> <Stack gap="xs" style={{ flex: 1, marginBottom: "md" }}>
{presentation.detailColumns.map((col) => ( {subtitle && (
<div key={col.id} className="min-w-0"> <Group gap="xs">
<dt className={ruleEngineCard.detailLabel}>{col.header}</dt> <Text size="xs" c="dimmed" fw={500}>
<dd className={ruleEngineCard.detailValue}> {presentation.subtitleKey === "stepOrder" ? "Step" : "Type"}:
{formatCell(record[col.accessorKey], col.format)} </Text>
</dd> <Text size="xs" fw={500}>
</div> {subtitle}
))} </Text>
</dl> </Group>
) : ( )}
<div className="flex-1 px-4 py-2" /> {presentation.detailColumns.map((col) => {
const displayValue = getSmartValue(record, col.accessorKey);
return (
<Group key={col.id} justify="space-between" gap="xs" align="flex-start">
<Text size="xs" c="dimmed" fw={500}>
{col.header}:
</Text>
<div style={{ textAlign: "right", flex: 1 }}>
{formatCell(displayValue, col.format)}
</div>
</Group>
);
})}
</Stack>
)} )}
<div className={ruleEngineCard.footer}> <Group justify="flex-end" gap="xs" style={{ borderTop: "1px solid var(--mantine-color-gray-1)", paddingTop: "md" }}>
<RuleEngineRecordActions <RuleEngineRecordActions
record={record} record={record}
config={config} config={config}
@@ -162,26 +243,26 @@ const RuleEngineCardGrid = ({
onSubmitRate={onSubmitRate} onSubmitRate={onSubmitRate}
onApproveRate={onApproveRate} onApproveRate={onApproveRate}
/> />
</div> </Group>
</article> </Card>
); );
})} })}
</div> </SimpleGrid>
<div className="border-t border-border bg-card"> {pagination.pageCount > 1 && (
<DataTableFooter <Group justify="space-between" align="center" p="md" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
table={table} <Text size="sm" c="dimmed">
pagination={pagination} Showing {Math.min(rows.length, pagination.pageSize)} of {pagination.totalCount} {itemLabel}
options={{ </Text>
labels: { <Pagination
showing: "Showing", value={pagination.pageIndex + 1}
ofLabel: "of", total={pagination.pageCount}
items: itemLabel, size="sm"
}, radius="md"
}} />
/> </Group>
</div> )}
</> </Stack>
); );
}; };

View File

@@ -1,27 +1,18 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { import {
Modal,
Button, Button,
Dialog, TextInput,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Field,
FieldContent,
FieldLabel,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Switch,
Textarea, Textarea,
} from "@edr/ui-common"; Select,
Switch,
Stack,
Group,
Divider,
Text,
Box,
} from "@mantine/core";
import { import {
RULE_ENGINE_SELECT_NONE, RULE_ENGINE_SELECT_NONE,
@@ -29,8 +20,6 @@ import {
} from "@/pages/ruleEngine/config/resources"; } from "@/pages/ruleEngine/config/resources";
import type { RuleEngineRecord } from "@/types/rule-engine"; import type { RuleEngineRecord } from "@/types/rule-engine";
import { ruleEngineField, ruleEngineSurface } from "./ruleEngineStyles";
export interface RuleEngineFormDialogProps { export interface RuleEngineFormDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -142,133 +131,174 @@ const RuleEngineFormDialog = ({
}; };
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Modal
<DialogContent className={ruleEngineSurface.dialog}> opened={open}
<DialogHeader className="space-y-1"> onClose={() => onOpenChange(false)}
<DialogTitle className="text-lg font-semibold">{title}</DialogTitle> title={title}
<DialogDescription>{description}</DialogDescription> centered
</DialogHeader> size="md"
radius="lg"
styles={{
header: {
paddingBottom: "20px",
borderBottom: "1px solid var(--mantine-color-gray-2)",
},
body: {
paddingTop: "24px",
paddingBottom: "24px",
},
title: {
fontSize: "1.25rem",
fontWeight: 700,
color: "var(--mantine-color-gray-9)",
},
}}
>
<form onSubmit={handleSubmit}>
<Stack gap="lg">
<Text size="sm" c="dimmed">
{description}
</Text>
<form onSubmit={handleSubmit} className="space-y-1"> <div style={{ maxHeight: "calc(60vh - 150px)", overflowY: "auto", paddingRight: "12px" }}>
<div className="max-h-[min(60vh,28rem)] space-y-4 overflow-y-auto pr-1"> <Stack gap="lg">
{fields.map((field) => ( {fields.map((field) => (
<Field key={field.name} orientation="vertical" className="gap-1.5"> <Box key={field.name}>
{field.type === "boolean" ? ( {field.type === "boolean" ? (
<div className={ruleEngineField.switchRow}> <Group
<div className="min-w-0"> justify="space-between"
<FieldLabel htmlFor={field.name} className={ruleEngineField.label}> align="center"
{field.label} p="lg"
</FieldLabel> style={{
<p className={ruleEngineField.switchHint}> background: "linear-gradient(135deg, var(--mantine-color-blue-0) 0%, var(--mantine-color-cyan-0) 100%)",
{Boolean(values[field.name]) ? "Enabled" : "Disabled"} borderRadius: "12px",
</p> border: "1px solid var(--mantine-color-blue-2)",
</div> }}
<Switch >
id={field.name} <div>
checked={Boolean(values[field.name])} <Text size="sm" fw={600} mb="4px">
onCheckedChange={(checked) => setField(field.name, checked)} {field.label}
</Text>
<Text size="xs" c="dimmed">
{Boolean(values[field.name]) ? "✓ Enabled" : "○ Disabled"}
</Text>
</div>
<Switch
checked={Boolean(values[field.name])}
onChange={(e) => setField(field.name, e.currentTarget.checked)}
size="lg"
/>
</Group>
) : field.type === "select" ? (
<Select
label={
<Group gap="4px">
<span>{field.label}</span>
{field.required && <span style={{ color: "var(--mantine-color-red-6)" }}>*</span>}
</Group>
}
placeholder={
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
}
value={resolveSelectValue(field, values)}
onChange={(v) =>
setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)
}
disabled={selectOptionsLoading}
data={(field.options ?? []).filter((opt) => opt.value !== "").map((opt) => ({
label: opt.label,
value: opt.value,
}))}
searchable
clearable
size="md"
radius="lg"
styles={{
input: {
borderColor: "var(--mantine-color-gray-3)",
},
}}
/> />
</div> ) : field.type === "textarea" ? (
) : ( <Textarea
<> label={
<FieldLabel htmlFor={field.name} className={ruleEngineField.label}> <Group gap="4px">
{field.label} <span>{field.label}</span>
{field.required ? ( {field.required && <span style={{ color: "var(--mantine-color-red-6)" }}>*</span>}
<span className={ruleEngineField.requiredMark}> *</span> </Group>
) : null} }
</FieldLabel> value={String(values[field.name] ?? "")}
<FieldContent> onChange={(e) => setField(field.name, e.currentTarget.value)}
{field.type === "select" ? ( placeholder={field.placeholder}
<Select required={field.required}
value={resolveSelectValue(field, values)} minRows={5}
onValueChange={(v) => size="md"
setField( radius="lg"
field.name, styles={{
v === RULE_ENGINE_SELECT_NONE ? "" : v, input: {
) borderColor: "var(--mantine-color-gray-3)",
} },
disabled={selectOptionsLoading} }}
> />
<SelectTrigger ) : (
id={field.name} <TextInput
className={ruleEngineField.selectTrigger} label={
> <Group gap="4px">
<SelectValue <span>{field.label}</span>
placeholder={ {field.required && <span style={{ color: "var(--mantine-color-red-6)" }}>*</span>}
selectOptionsLoading </Group>
? "Loading options..." }
: (field.placeholder ?? "Select an option") type={
} field.type === "number"
/> ? "number"
</SelectTrigger> : field.type === "date"
<SelectContent className={ruleEngineField.selectContent}> ? "date"
{(field.options ?? []) : "text"
.filter((opt) => opt.value !== "") }
.map((opt) => ( value={String(values[field.name] ?? "")}
<SelectItem key={opt.value} value={opt.value}> onChange={(e) => setField(field.name, e.currentTarget.value)}
{opt.label} placeholder={field.placeholder}
</SelectItem> required={field.required}
))} size="md"
</SelectContent> radius="lg"
</Select> styles={{
) : field.type === "textarea" ? ( input: {
<Textarea borderColor: "var(--mantine-color-gray-3)",
id={field.name} },
value={String(values[field.name] ?? "")} }}
onChange={(e) => setField(field.name, e.target.value)} />
placeholder={field.placeholder} )}
className={ruleEngineField.textarea} </Box>
/> ))}
) : ( </Stack>
<Input
id={field.name}
type={
field.type === "number"
? "number"
: field.type === "date"
? "date"
: "text"
}
value={String(values[field.name] ?? "")}
onChange={(e) => setField(field.name, e.target.value)}
placeholder={field.placeholder}
className={ruleEngineField.input}
required={field.required}
/>
)}
</FieldContent>
</>
)}
</Field>
))}
</div> </div>
<Separator className="my-4" /> <Divider />
<DialogFooter className="gap-2 sm:gap-2"> <Group justify="flex-end" gap="sm">
<Button <Button
type="button" variant="light"
variant="outline"
className="rounded-md"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={isSubmitting} disabled={isSubmitting}
radius="lg"
size="md"
> >
Cancel Cancel
</Button> </Button>
<Button type="submit" className="rounded-md" disabled={isSubmitting}> <Button
{isSubmitting ? ( type="submit"
<> disabled={isSubmitting}
<Loader2 className="h-4 w-4 animate-spin" /> leftSection={isSubmitting && <Loader2 size={18} style={{ animation: "spin 1s linear infinite" }} />}
Saving... radius="lg"
</> color="blue"
) : ( size="md"
"Save" >
)} {isSubmitting ? "Saving..." : "Save"}
</Button> </Button>
</DialogFooter> </Group>
</form> </Stack>
</DialogContent> </form>
</Dialog> </Modal>
); );
}; };

View File

@@ -6,16 +6,10 @@ import {
Send, Send,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { Button, Menu, Group } from "@mantine/core";
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources"; import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
import type { RuleEngineRecord } from "@/types/rule-engine"; import type { RuleEngineRecord } from "@/types/rule-engine";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@edr/ui-common";
export interface RuleEngineRecordActionsProps { export interface RuleEngineRecordActionsProps {
record: RuleEngineRecord; record: RuleEngineRecord;
@@ -44,93 +38,83 @@ const RuleEngineRecordActions = ({
const hasRateActions = const hasRateActions =
config.slug === "rates" && (status === "DRAFT" || status === "PENDING_APPROVAL"); config.slug === "rates" && (status === "DRAFT" || status === "PENDING_APPROVAL");
const iconBtnClass =
layout === "compact"
? "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
: "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground";
if (readOnly) { if (readOnly) {
return onViewChain ? ( return onViewChain ? (
<Button <Button
type="button" variant="subtle"
variant="ghost" size="xs"
size="icon"
className={iconBtnClass}
onClick={onViewChain} onClick={onViewChain}
aria-label="View chain" leftSection={<Eye size={16} />}
> >
<Eye className="h-4 w-4" /> View
</Button> </Button>
) : null; ) : null;
} }
return ( return (
<div className="flex items-center justify-end gap-0.5"> <Group gap={2} justify="flex-end">
<Button <Button
type="button" variant="subtle"
variant="ghost" size="xs"
size="icon"
className={iconBtnClass}
onClick={() => onEdit(record)} onClick={() => onEdit(record)}
aria-label="Edit" leftSection={<Pencil size={16} />}
> >
<Pencil className="h-4 w-4" /> Edit
</Button> </Button>
{config.slug === "approval-rules" && onViewChain ? ( {config.slug === "approval-rules" && onViewChain ? (
<Button <Button
type="button" variant="subtle"
variant="ghost" size="xs"
size="icon"
className={iconBtnClass}
onClick={onViewChain} onClick={onViewChain}
aria-label="View approval chain" leftSection={<Eye size={16} />}
> >
<Eye className="h-4 w-4" /> View Chain
</Button> </Button>
) : null} ) : null}
{hasRateActions ? ( {hasRateActions ? (
<DropdownMenu modal={false}> <Menu position="bottom-end" shadow="md">
<DropdownMenuTrigger asChild> <Menu.Target>
<Button <Button
type="button" variant="subtle"
variant="ghost" size="xs"
size="icon" leftSection={<MoreHorizontal size={16} />}
className={iconBtnClass}
aria-label="More actions"
> >
<MoreHorizontal className="h-4 w-4" /> More
</Button> </Button>
</DropdownMenuTrigger> </Menu.Target>
<DropdownMenuContent align="end"> <Menu.Dropdown>
{status === "DRAFT" && onSubmitRate ? ( {status === "DRAFT" && onSubmitRate ? (
<DropdownMenuItem onSelect={() => onSubmitRate(record.id)}> <Menu.Item
<Send /> leftSection={<Send size={14} />}
onClick={() => onSubmitRate(record.id)}
>
Submit for approval Submit for approval
</DropdownMenuItem> </Menu.Item>
) : null} ) : null}
{status === "PENDING_APPROVAL" && onApproveRate ? ( {status === "PENDING_APPROVAL" && onApproveRate ? (
<DropdownMenuItem onSelect={() => onApproveRate(record)}> <Menu.Item
<CheckCircle2 /> leftSection={<CheckCircle2 size={14} />}
onClick={() => onApproveRate(record)}
>
Approve Approve
</DropdownMenuItem> </Menu.Item>
) : null} ) : null}
</DropdownMenuContent> </Menu.Dropdown>
</DropdownMenu> </Menu>
) : null} ) : null}
<Button <Button
type="button" variant="subtle"
variant="ghost" size="xs"
size="icon"
className={`${iconBtnClass} hover:bg-red-50 hover:text-red-600`}
onClick={() => onDelete(record)} onClick={() => onDelete(record)}
aria-label="Delete" leftSection={<Trash2 size={16} />}
color="red"
> >
<Trash2 className="h-4 w-4" /> Delete
</Button> </Button>
</div> </Group>
); );
}; };

View File

@@ -1,10 +1,7 @@
import { Filter, LayoutGrid, Plus, Search, Table2 } from "lucide-react"; import { LayoutGrid, Plus, Search, Table2 } from "lucide-react";
import { Button, TextInput, Group, SegmentedControl } from "@mantine/core";
import { cn } from "@/lib/utils";
import { Button, Input } from "@edr/ui-common";
import type { RuleEngineViewMode } from "./useRuleEngineViewMode"; import type { RuleEngineViewMode } from "./useRuleEngineViewMode";
import { ruleEngineToolbar } from "./ruleEngineStyles";
export interface RuleEngineToolbarProps { export interface RuleEngineToolbarProps {
search: string; search: string;
@@ -25,70 +22,69 @@ const RuleEngineToolbar = ({
viewMode, viewMode,
onViewModeChange, onViewModeChange,
}: RuleEngineToolbarProps) => ( }: RuleEngineToolbarProps) => (
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <Group gap="md" justify="space-between" align="center" wrap="nowrap">
<div className="relative min-w-0 flex-1 lg:max-w-md"> <TextInput
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> placeholder={searchPlaceholder}
<Input value={search}
type="search" onChange={(e) => onSearchChange(e.currentTarget.value)}
value={search} leftSection={<Search size={18} />}
onChange={(e) => onSearchChange(e.target.value)} size="md"
placeholder={searchPlaceholder} radius="lg"
className={ruleEngineToolbar.search} style={{ flex: 1, minWidth: 0 }}
styles={{
input: {
borderColor: "var(--mantine-color-gray-3)",
},
}}
/>
<Group gap="md" align="center" justify="flex-end" wrap="nowrap">
<SegmentedControl
value={viewMode}
onChange={(value) => onViewModeChange(value as RuleEngineViewMode)}
size="sm"
radius="lg"
data={[
{
value: "table",
label: (
<Group gap={6} justify="center" wrap="nowrap">
<Table2 size={16} />
<span>Table</span>
</Group>
),
},
{
value: "cards",
label: (
<Group gap={6} justify="center" wrap="nowrap">
<LayoutGrid size={16} />
<span>Cards</span>
</Group>
),
},
]}
styles={{
root: {
background: "var(--mantine-color-gray-1)",
},
}}
/> />
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<div
className={ruleEngineToolbar.viewToggleGroup}
role="group"
aria-label="View mode"
>
<button
type="button"
onClick={() => onViewModeChange("table")}
className={cn(
ruleEngineToolbar.viewToggleBtn,
viewMode === "table"
? ruleEngineToolbar.viewToggleActive
: ruleEngineToolbar.viewToggleIdle,
)}
aria-pressed={viewMode === "table"}
>
<Table2 className="h-4 w-4" />
Table
</button>
<button
type="button"
onClick={() => onViewModeChange("cards")}
className={cn(
ruleEngineToolbar.viewToggleBtn,
viewMode === "cards"
? ruleEngineToolbar.viewToggleActive
: ruleEngineToolbar.viewToggleIdle,
)}
aria-pressed={viewMode === "cards"}
>
<LayoutGrid className="h-4 w-4" />
Cards
</button>
</div>
<Button
type="button"
variant="outline"
className={cn(ruleEngineToolbar.actionBtn, "gap-2 px-3")}
>
<Filter className="h-4 w-4" />
Filter
</Button>
{onAdd ? ( {onAdd ? (
<Button type="button" className={ruleEngineToolbar.primaryBtn} onClick={onAdd}> <Button
<Plus className="h-4 w-4" /> onClick={onAdd}
leftSection={<Plus size={18} />}
size="sm"
radius="lg"
color="blue"
style={{ whiteSpace: "nowrap" }}
>
{addLabel} {addLabel}
</Button> </Button>
) : null} ) : null}
</div> </Group>
</div> </Group>
); );
export default RuleEngineToolbar; export default RuleEngineToolbar;

View File

@@ -1,30 +1,49 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Badge, Text } from "@mantine/core";
import { cn } from "@/lib/utils";
import type { ColumnFormat } from "@/pages/ruleEngine/config/resources"; import type { ColumnFormat } from "@/pages/ruleEngine/config/resources";
import { Badge } from "@edr/ui-common";
const statusBadgeClass = (active: boolean) => const extractLabel = (value: unknown): string | null => {
cn( if (!value || typeof value !== "object") return null;
"rounded-sm px-2 py-0.5 text-xs font-medium",
active const obj = value as Record<string, unknown>;
? "border-emerald-200 bg-emerald-50 text-emerald-800" return (
: "border-border bg-muted text-muted-foreground", (typeof obj.label === "string" ? obj.label : null) ||
(typeof obj.cargoTypeName === "string" ? obj.cargoTypeName : null) ||
(typeof obj.code === "string" ? obj.code : null) ||
(typeof obj.name === "string" ? obj.name : null) ||
(typeof obj.actionLabel === "string" ? obj.actionLabel : null) ||
null
); );
};
export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode => { export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode => {
if (value === null || value === undefined || value === "") { if (value === null || value === undefined || value === "") {
return <span className="text-muted-foreground"></span>; return <Text size="sm" c="dimmed"></Text>;
}
// Handle stringified objects (e.g., "[object Object]")
if (typeof value === "string" && value.trim() === "[object Object]") {
return <Text size="sm" c="dimmed"></Text>;
} }
if (format === "boolean") { if (format === "boolean") {
return value ? "Yes" : "No"; if (typeof value === "string") {
const boolVal = value.toLowerCase() === "true" || value === "1";
return <Text size="sm">{boolVal ? "✓ Yes" : "✗ No"}</Text>;
}
return <Text size="sm">{value ? "✓ Yes" : "✗ No"}</Text>;
} }
if (format === "activeBadge") { if (format === "activeBadge") {
const active = Boolean(value); const active = Boolean(value);
return ( return (
<Badge variant="outline" className={statusBadgeClass(active)}> <Badge
color={active ? "green" : "gray"}
variant={active ? "filled" : "light"}
size="sm"
radius="md"
>
{active ? "Active" : "Inactive"} {active ? "Active" : "Inactive"}
</Badge> </Badge>
); );
@@ -32,14 +51,16 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
if (format === "rateStatus") { if (format === "rateStatus") {
const status = String(value); const status = String(value);
const tone = const color =
status === "LIVE" status === "LIVE"
? "border-emerald-200 bg-emerald-50 text-emerald-800" ? "green"
: status === "DRAFT" : status === "DRAFT"
? "border-amber-200 bg-amber-50 text-amber-800" ? "yellow"
: "border-sky-200 bg-sky-50 text-sky-800"; : status === "PENDING_APPROVAL"
? "orange"
: "blue";
return ( return (
<Badge variant="outline" className={cn("rounded-sm font-medium", tone)}> <Badge color={color} variant="filled" size="sm" radius="md">
{status} {status}
</Badge> </Badge>
); );
@@ -48,38 +69,44 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
if (format === "code") { if (format === "code") {
return ( return (
<Badge <Badge
variant="secondary" variant="light"
className="rounded-sm border border-border bg-muted/80 font-mono text-[11px] font-medium text-foreground" color="blue"
size="sm"
radius="md"
style={{
fontFamily: "monospace",
fontSize: "0.75rem",
fontWeight: 600,
letterSpacing: "0.05em",
}}
> >
{String(value)} {String(value).toUpperCase()}
</Badge> </Badge>
); );
} }
if (format === "date") { if (format === "date") {
const d = new Date(String(value)); const d = new Date(String(value));
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleDateString(); if (Number.isNaN(d.getTime())) return <Text size="sm">{String(value)}</Text>;
return <Text size="sm">{d.toLocaleDateString()}</Text>;
} }
if (format === "entityLabel" && value && typeof value === "object") { if (format === "entityLabel" && value && typeof value === "object") {
const entity = value as { label?: string; code?: string; cargoTypeName?: string }; const label = extractLabel(value);
const label = if (label) {
entity.label?.trim() || return <Text size="sm">{label}</Text>;
entity.cargoTypeName?.trim() || }
entity.code?.trim(); return <Text size="sm" c="dimmed"></Text>;
return label ? (
<span>{label}</span>
) : (
<span className="text-muted-foreground"></span>
);
} }
if (format === "rateLabel") { if (format === "rateLabel") {
if (!value || typeof value !== "object") { if (!value || typeof value !== "object") {
return value ? ( return value ? (
<span className="font-mono text-xs text-muted-foreground">{String(value)}</span> <Text size="sm" c="dimmed" style={{ fontFamily: "monospace" }}>
{String(value)}
</Text>
) : ( ) : (
<span className="text-muted-foreground"></span> <Text size="sm" c="dimmed"></Text>
); );
} }
const rate = value as { const rate = value as {
@@ -95,11 +122,17 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
rate.rateUnit?.replace(/_/g, " "), rate.rateUnit?.replace(/_/g, " "),
].filter(Boolean); ].filter(Boolean);
return parts.length > 0 ? ( return parts.length > 0 ? (
<span>{parts.join(" · ")}</span> <Text size="sm">{parts.join(" · ")}</Text>
) : ( ) : (
<span className="text-muted-foreground"></span> <Text size="sm" c="dimmed"></Text>
); );
} }
return String(value); if (typeof value === "object") {
const label = extractLabel(value);
if (label) return <Text size="sm">{label}</Text>;
return <Text size="sm" c="dimmed"></Text>;
}
return <Text size="sm">{String(value)}</Text>;
}; };

View File

@@ -3,7 +3,7 @@
export const ruleEngineSurface = { export const ruleEngineSurface = {
pageCard: pageCard:
"overflow-hidden rounded-lg border border-border bg-card shadow-sm", "overflow-hidden rounded-lg border border-border bg-card shadow-sm",
pageCardToolbar: "border-b border-border bg-muted/30 px-4 py-3 sm:px-5 sm:py-3.5", pageCardToolbar: "border-b border-border bg-muted/30 px-3 py-2.5 sm:px-4 sm:py-3",
dialog: "max-h-[90vh] overflow-y-auto rounded-lg border-border sm:max-w-lg", dialog: "max-h-[90vh] overflow-y-auto rounded-lg border-border sm:max-w-lg",
dialogSm: "rounded-lg border-border sm:max-w-md", dialogSm: "rounded-lg border-border sm:max-w-md",
} as const; } as const;
@@ -24,30 +24,30 @@ export const ruleEngineField = {
export const ruleEngineToolbar = { export const ruleEngineToolbar = {
search: search:
"h-10 rounded-md border border-input bg-background pl-10 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30", "h-9 rounded-md border border-input bg-background pl-9 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 sm:h-10 sm:pl-10",
viewToggleGroup: viewToggleGroup:
"flex h-10 items-center rounded-md border border-border bg-muted/40 p-0.5", "flex h-9 items-center rounded-md border border-border bg-muted/40 p-0.5 sm:h-10",
viewToggleBtn: viewToggleBtn:
"inline-flex h-8 items-center gap-1.5 rounded-sm px-3 text-sm font-medium transition-colors", "inline-flex h-7 items-center gap-1 rounded-sm px-2 text-xs font-medium transition-colors sm:h-8 sm:gap-1.5 sm:px-3 sm:text-sm",
viewToggleActive: "bg-background text-foreground shadow-sm", viewToggleActive: "bg-background text-foreground shadow-sm",
viewToggleIdle: "text-muted-foreground hover:bg-background/60 hover:text-foreground", viewToggleIdle: "text-muted-foreground hover:bg-background/60 hover:text-foreground",
actionBtn: "h-10 rounded-md shadow-xs", actionBtn: "h-9 rounded-md shadow-xs sm:h-10",
primaryBtn: "h-10 gap-2 rounded-md px-4 text-sm font-medium shadow-xs", primaryBtn: "h-9 gap-1.5 rounded-md px-3 text-xs font-medium shadow-xs sm:h-10 sm:gap-2 sm:px-4 sm:text-sm",
} as const; } as const;
export const ruleEngineCard = { export const ruleEngineCard = {
article: article:
"group flex flex-col overflow-hidden rounded-lg border border-border bg-card shadow-sm transition-shadow duration-200 hover:shadow-md", "group flex flex-col overflow-hidden rounded-lg border border-border bg-card shadow-sm transition-shadow duration-200 hover:shadow-md",
header: "border-b border-border bg-muted/25 px-4 py-3.5", header: "border-b border-border bg-muted/25 px-3 py-2.5 sm:px-4 sm:py-3.5",
avatar: avatar:
"flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-primary/12 text-sm font-semibold text-primary", "flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/12 text-xs font-semibold text-primary sm:h-10 sm:w-10 sm:text-sm",
title: "truncate text-[15px] font-semibold text-foreground", title: "truncate text-sm font-semibold text-foreground sm:text-[15px]",
meta: "text-xs text-muted-foreground", meta: "text-xs text-muted-foreground",
detailLabel: detailLabel:
"text-[11px] font-medium uppercase tracking-wide text-muted-foreground", "text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-[11px]",
detailValue: "mt-0.5 text-sm text-foreground", detailValue: "mt-0.5 text-xs text-foreground sm:text-sm",
footer: "mt-auto border-t border-border bg-muted/20 px-3 py-2.5", footer: "mt-auto border-t border-border bg-muted/20 px-2 py-2 sm:px-3 sm:py-2.5",
skeleton: "animate-pulse rounded-lg border border-border bg-muted/30 p-4", skeleton: "animate-pulse rounded-lg border border-border bg-muted/30 p-3 sm:p-4",
} as const; } as const;
export const ruleEngineTable = { export const ruleEngineTable = {

View File

@@ -1,6 +1,8 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { MantineProvider } from "@mantine/core";
import "@mantine/core/styles.css";
import "@edr/ui-common/styles.css"; import "@edr/ui-common/styles.css";
import "../index.css"; import "../index.css";
import "@edr/ui-common/theme.css"; import "@edr/ui-common/theme.css";
@@ -41,14 +43,15 @@ if (!rootElement) {
createRoot(rootElement).render( createRoot(rootElement).render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MantineProvider>
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>
<App /> <App />
<Toaster position="top-right" /> <Toaster position="top-right" />
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>
</StrictMode>, </StrictMode>
</MantineProvider>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@@ -4,16 +4,14 @@ import { useAuth } from "@/auth/useAuth";
import { canAccessRuleEngineResource } from "@/lib/permissions"; import { canAccessRuleEngineResource } from "@/lib/permissions";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { Card, Button, Modal, Stack, Group, Text, List } from "@mantine/core";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid"; import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog"; import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordActions"; import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordActions";
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar"; import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat"; import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
import { import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
ruleEngineSurface,
ruleEngineTable,
} from "@/components/ruleEngine/ruleEngineStyles";
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode"; import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
import { import {
DEFAULT_CONFIGURATION_SLUG, DEFAULT_CONFIGURATION_SLUG,
@@ -34,15 +32,8 @@ import {
} from "@/hooks/rule-engine/useRuleEngine"; } from "@/hooks/rule-engine/useRuleEngine";
import type { RuleEngineRecord } from "@/types/rule-engine"; import type { RuleEngineRecord } from "@/types/rule-engine";
import { import {
Button,
Card,
DataTable, DataTable,
DataTableFooter, DataTableFooter,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
getCoreRowModel, getCoreRowModel,
usePagination, usePagination,
useReactTable, useReactTable,
@@ -177,15 +168,6 @@ const RuleEngineResourcePage = () => {
[filteredRows.length, meta?.total, pageCount, pagination.pageIndex, pagination.pageSize], [filteredRows.length, meta?.total, pageCount, pagination.pageIndex, pagination.pageSize],
); );
const cardTable = useReactTable({
data: filteredRows,
columns: [] as ColumnDef<RuleEngineRecord>[],
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
});
const handleApproveRate = useCallback( const handleApproveRate = useCallback(
(record: RuleEngineRecord) => { (record: RuleEngineRecord) => {
@@ -284,9 +266,9 @@ const RuleEngineResourcePage = () => {
const itemLabel = config.label.toLowerCase(); const itemLabel = config.label.toLowerCase();
return ( return (
<div> <Stack gap="lg">
<Card className={ruleEngineSurface.pageCard}> <Card p="lg" radius="lg" withBorder style={{ background: "white", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)" }}>
<div className={ruleEngineSurface.pageCardToolbar}> <Stack gap="md">
<RuleEngineToolbar <RuleEngineToolbar
search={search} search={search}
onSearchChange={(v) => { onSearchChange={(v) => {
@@ -299,65 +281,64 @@ const RuleEngineResourcePage = () => {
viewMode={viewMode} viewMode={viewMode}
onViewModeChange={setViewMode} onViewModeChange={setViewMode}
/> />
</div>
{viewMode === "table" ? ( {viewMode === "table" ? (
<DataTable <DataTable
columns={columns} columns={columns}
data={filteredRows} data={filteredRows}
status={tableStatus} status={tableStatus}
error={ error={
isError isError
? { ? {
message: "Failed to load data", message: "Failed to load data",
description: description:
error instanceof Error ? error.message : "Unknown error", error instanceof Error ? error.message : "Unknown error",
} }
: undefined : undefined
} }
emptyMessage={`No ${itemLabel} found.`} emptyMessage={`No ${itemLabel} found.`}
pagination={paginationState} pagination={paginationState}
tableOptions={{ tableOptions={{
manualPagination: true, manualPagination: true,
pageCount, pageCount,
state: { pagination }, state: { pagination },
onPaginationChange: setPagination, onPaginationChange: setPagination,
}} }}
containerClassName="border-0 shadow-none [&_[data-slot=table-row]]:border-border" containerClassName="border-0 shadow-none [&_[data-slot=table-row]]:border-border"
footerClassName="border-t border-border bg-card" footerClassName="border-t border-border bg-card"
footer={({ table, pagination: footerPagination }) => ( footer={({ table, pagination: footerPagination }) => (
<DataTableFooter <DataTableFooter
table={table} table={table}
pagination={footerPagination} pagination={footerPagination}
options={{ options={{
labels: { labels: {
showing: "Showing", showing: "Showing",
ofLabel: "of", ofLabel: "of",
items: itemLabel, items: itemLabel,
}, },
}} }}
/> />
)} )}
/> />
) : ( ) : (
<RuleEngineCardGrid <RuleEngineCardGrid
config={config} config={config}
rows={filteredRows} rows={filteredRows}
status={tableStatus} status={tableStatus}
emptyMessage={`No ${itemLabel} found.`} emptyMessage={`No ${itemLabel} found.`}
itemLabel={itemLabel} itemLabel={itemLabel}
table={cardTable} pagination={paginationState}
pagination={paginationState} readOnly={!canManage}
readOnly={!canManage} onEdit={canManage ? openEdit : undefined}
onEdit={canManage ? openEdit : undefined} onDelete={canManage ? setDeleteTarget : undefined}
onDelete={canManage ? setDeleteTarget : undefined} onViewChain={
onViewChain={ config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined }
} onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined} onApproveRate={canManage ? handleApproveRate : undefined}
onApproveRate={canManage ? handleApproveRate : undefined} />
/> )}
)} </Stack>
</Card> </Card>
<RuleEngineFormDialog <RuleEngineFormDialog
@@ -380,20 +361,23 @@ const RuleEngineResourcePage = () => {
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
/> />
<Dialog open={Boolean(deleteTarget)} onOpenChange={(o) => !o && setDeleteTarget(null)}> <Modal
<DialogContent className={ruleEngineSurface.dialogSm}> opened={Boolean(deleteTarget)}
<DialogHeader> onClose={() => setDeleteTarget(null)}
<DialogTitle>Delete record?</DialogTitle> title="Delete record?"
<DialogDescription> centered
This will soft-delete the selected {config.label.toLowerCase()} record. size="sm"
</DialogDescription> >
</DialogHeader> <Stack gap="md">
<div className="flex justify-end gap-2"> <Text size="sm">
<Button variant="outline" onClick={() => setDeleteTarget(null)}> This will soft-delete the selected {config.label.toLowerCase()} record.
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDeleteTarget(null)}>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="destructive" color="red"
disabled={remove.isPending} disabled={remove.isPending}
onClick={() => { onClick={() => {
if (!deleteTarget) return; if (!deleteTarget) return;
@@ -401,47 +385,51 @@ const RuleEngineResourcePage = () => {
onSuccess: () => setDeleteTarget(null), onSuccess: () => setDeleteTarget(null),
}); });
}} }}
leftSection={remove.isPending && <Loader2 size={16} />}
> >
{remove.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Delete"} {remove.isPending ? "Deleting..." : "Delete"}
</Button> </Button>
</div> </Group>
</DialogContent> </Stack>
</Dialog> </Modal>
<Dialog open={chainOpen} onOpenChange={setChainOpen}> <Modal
<DialogContent className={ruleEngineSurface.dialog}> opened={chainOpen}
<DialogHeader> onClose={() => setChainOpen(false)}
<DialogTitle>Approval chain</DialogTitle> title="Approval chain"
<DialogDescription>Configured approval steps from the API.</DialogDescription> centered
</DialogHeader> size="md"
>
<Stack gap="md">
{chainLoading ? ( {chainLoading ? (
<div className="flex justify-center py-8"> <Group justify="center" p="xl">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Loader2 size={32} style={{ animation: "spin 1s linear infinite" }} />
</div> </Group>
) : ( ) : (
<ol className="space-y-3"> <>
{(chainData ?? []).length === 0 ? ( {(chainData ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">No approval rules configured.</p> <Text size="sm" c="dimmed">No approval rules configured.</Text>
) : ( ) : (
(chainData ?? []).map((step, index) => ( <List spacing="md">
<li {(chainData ?? []).map((step, index) => (
key={String(step.id ?? index)} <List.Item key={String(step.id ?? index)}>
className="rounded-md border border-border bg-muted/30 px-4 py-3 text-sm" <Stack gap="xs">
> <Text size="sm" fw={500}>
<p className="font-medium text-foreground"> Step {String(step.stepOrder ?? index + 1)}: {String(step.actionLabel ?? "")}
Step {String(step.stepOrder ?? index + 1)}: {String(step.actionLabel ?? "")} </Text>
</p> <Text size="sm" c="dimmed">
<p className="text-muted-foreground"> Role: {String(step.requiredRole ?? "—")}
Role: {String(step.requiredRole ?? "—")} </Text>
</p> </Stack>
</li> </List.Item>
)) ))}
</List>
)} )}
</ol> </>
)} )}
</DialogContent> </Stack>
</Dialog> </Modal>
</div> </Stack>
); );
}; };

View File

@@ -270,6 +270,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
category: "rules", category: "rules",
subtitle: "VGM limits by container and trade direction", subtitle: "VGM limits by container and trade direction",
searchPlaceholder: "Search weight limit rules...", searchPlaceholder: "Search weight limit rules...",
cardTitleKey: "containerType",
cardSubtitleKey: "tradeDirection",
columns: [ columns: [
{ {
id: "containerType", id: "containerType",

95
pnpm-lock.yaml generated
View File

@@ -35,7 +35,7 @@ importers:
version: 2.9.14 version: 2.9.14
typeorm: typeorm:
specifier: 0.3.30 specifier: 0.3.30
version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)) version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@24.13.0)(typescript@5.9.3))
typescript: typescript:
specifier: ^5.5.4 specifier: ^5.5.4
version: 5.9.3 version: 5.9.3
@@ -184,6 +184,15 @@ importers:
'@edr/ui-common': '@edr/ui-common':
specifier: workspace:* specifier: workspace:*
version: link:../../../packages/ui-common version: link:../../../packages/ui-common
'@mantine/core':
specifier: ^9.3.0
version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.29)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks':
specifier: ^9.3.0
version: 9.3.0(react@19.2.6)
'@tabler/icons-react':
specifier: ^3.44.0
version: 3.44.0(react@19.2.6)
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.100.11 specifier: ^5.100.11
version: 5.100.11(react@19.2.6) version: 5.100.11(react@19.2.6)
@@ -1939,6 +1948,13 @@ packages:
react: 19.2.6 react: 19.2.6
react-dom: 19.2.6 react-dom: 19.2.6
'@mantine/core@9.3.0':
resolution: {integrity: sha512-mHVCm61YVW9ipy9eHiKMqsRUm3TkOErbdw7zHs0HRw5g403nf7tSTqNGvaYE+aX1Py874qMkrUzeQfj4bjiiBA==}
peerDependencies:
'@mantine/hooks': 9.3.0
react: 19.2.6
react-dom: 19.2.6
'@mantine/dates@8.3.18': '@mantine/dates@8.3.18':
resolution: {integrity: sha512-FHx5teJOhupI0gO2o5evtVYQEdqOjayOkLRhEQfB5Nc5DvcysfPfmNILGkc1Nrp9ZQeQWKLT9qr+CkcCXwHOaw==} resolution: {integrity: sha512-FHx5teJOhupI0gO2o5evtVYQEdqOjayOkLRhEQfB5Nc5DvcysfPfmNILGkc1Nrp9ZQeQWKLT9qr+CkcCXwHOaw==}
peerDependencies: peerDependencies:
@@ -1953,6 +1969,11 @@ packages:
peerDependencies: peerDependencies:
react: 19.2.6 react: 19.2.6
'@mantine/hooks@9.3.0':
resolution: {integrity: sha512-QoSr9WI4WsKWrM3qFYYizHUn3+n+CVcFMYe4sdlnmFPStvs6BacPODKJSbFlYl73Z20t82JIy0eKqt4noHQI2g==}
peerDependencies:
react: 19.2.6
'@mapbox/node-pre-gyp@1.0.11': '@mapbox/node-pre-gyp@1.0.11':
resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==}
hasBin: true hasBin: true
@@ -5167,6 +5188,8 @@ packages:
axios@1.16.1: axios@1.16.1:
resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==}
axios@1.17.0: {}
b4a@1.8.1: b4a@1.8.1:
resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==}
peerDependencies: peerDependencies:
@@ -6285,15 +6308,16 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'} engines: {node: '>=10'}
escodegen@2.1.0:
resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==}
engines: {node: '>=6.0'}
hasBin: true
eslint-config-prettier@10.1.8: eslint-config-prettier@10.1.8:
resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
eslint: '>=7.0.0' eslint: '>=7.0.0'
escodegen@2.1.0:
resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==}
engines: {node: '>=6.0'}
hasBin: true
eslint-config-prettier@9.1.2: eslint-config-prettier@9.1.2:
resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==} resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==}
@@ -12558,7 +12582,7 @@ snapshots:
'@jest/pattern@30.4.0': '@jest/pattern@30.4.0':
dependencies: dependencies:
'@types/node': 24.13.0 '@types/node': 20.19.41
jest-regex-util: 30.4.0 jest-regex-util: 30.4.0
'@jest/reporters@29.7.0': '@jest/reporters@29.7.0':
@@ -12727,7 +12751,7 @@ snapshots:
'@jest/schemas': 30.4.1 '@jest/schemas': 30.4.1
'@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4 '@types/istanbul-reports': 3.0.4
'@types/node': 24.13.0 '@types/node': 20.19.41
'@types/yargs': 17.0.35 '@types/yargs': 17.0.35
chalk: 4.1.2 chalk: 4.1.2
@@ -12781,6 +12805,19 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@types/react' - '@types/react'
'@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.29)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks': 9.3.0(react@19.2.6)
clsx: 2.1.1
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-number-format: 5.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@19.2.6)
type-fest: 5.6.0
transitivePeerDependencies:
- '@types/react'
'@mantine/dates@8.3.18(@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.6))(@types/react@18.3.29)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@8.3.18(react@19.2.6))(dayjs@1.11.20)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': '@mantine/dates@8.3.18(@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.6))(@types/react@18.3.29)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@8.3.18(react@19.2.6))(dayjs@1.11.20)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies: dependencies:
'@mantine/core': 8.3.18(@mantine/hooks@8.3.18(react@19.2.6))(@types/react@18.3.29)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/core': 8.3.18(@mantine/hooks@8.3.18(react@19.2.6))(@types/react@18.3.29)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -12794,6 +12831,10 @@ snapshots:
dependencies: dependencies:
react: 19.2.6 react: 19.2.6
'@mantine/hooks@9.3.0(react@19.2.6)':
dependencies:
react: 19.2.6
'@mapbox/node-pre-gyp@1.0.11': '@mapbox/node-pre-gyp@1.0.11':
dependencies: dependencies:
detect-libc: 2.1.2 detect-libc: 2.1.2
@@ -15222,7 +15263,7 @@ snapshots:
'@types/jsonwebtoken@9.0.10': '@types/jsonwebtoken@9.0.10':
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
'@types/node': 24.13.0 '@types/node': 20.19.41
'@types/methods@1.1.4': {} '@types/methods@1.1.4': {}
@@ -16756,6 +16797,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
- supports-color - supports-color
b4a@1.8.1: {} b4a@1.8.1: {}
babel-jest@29.7.0(@babel/core@7.29.0): babel-jest@29.7.0(@babel/core@7.29.0):
@@ -17966,9 +18008,6 @@ snapshots:
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.7.0)):
dependencies:
eslint: 9.39.4(jiti@2.7.0)
escodegen@2.1.0: escodegen@2.1.0:
dependencies: dependencies:
esprima: 4.0.1 esprima: 4.0.1
@@ -17977,6 +18016,10 @@ snapshots:
optionalDependencies: optionalDependencies:
source-map: 0.6.1 source-map: 0.6.1
eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.7.0)):
dependencies:
eslint: 9.39.4(jiti@2.7.0)
eslint-config-prettier@9.1.2(eslint@8.57.1): eslint-config-prettier@9.1.2(eslint@8.57.1):
dependencies: dependencies:
eslint: 8.57.1 eslint: 8.57.1
@@ -19708,7 +19751,7 @@ snapshots:
jest-haste-map@30.4.1: jest-haste-map@30.4.1:
dependencies: dependencies:
'@jest/types': 30.4.1 '@jest/types': 30.4.1
'@types/node': 24.13.0 '@types/node': 20.19.41
anymatch: 3.1.3 anymatch: 3.1.3
fb-watchman: 2.0.2 fb-watchman: 2.0.2
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@@ -20000,7 +20043,7 @@ snapshots:
jest-util@30.4.1: jest-util@30.4.1:
dependencies: dependencies:
'@jest/types': 30.4.1 '@jest/types': 30.4.1
'@types/node': 24.13.0 '@types/node': 20.19.41
chalk: 4.1.2 chalk: 4.1.2
ci-info: 4.4.0 ci-info: 4.4.0
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@@ -20061,7 +20104,7 @@ snapshots:
jest-worker@30.4.1: jest-worker@30.4.1:
dependencies: dependencies:
'@types/node': 24.13.0 '@types/node': 20.19.41
'@ungap/structured-clone': 1.3.1 '@ungap/structured-clone': 1.3.1
jest-util: 30.4.1 jest-util: 30.4.1
merge-stream: 2.0.0 merge-stream: 2.0.0
@@ -23151,6 +23194,30 @@ snapshots:
- babel-plugin-macros - babel-plugin-macros
- supports-color - supports-color
typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@24.13.0)(typescript@5.9.3)):
dependencies:
'@sqltools/formatter': 1.2.5
ansis: 4.3.0
app-root-path: 3.1.0
buffer: 6.0.3
dayjs: 1.11.20
debug: 4.4.3
dedent: 1.7.2(babel-plugin-macros@3.1.0)
dotenv: 16.6.1
glob: 10.5.0
reflect-metadata: 0.2.2
sha.js: 2.4.12
sql-highlight: 6.1.0
tslib: 2.8.1
uuid: 11.1.1
yargs: 17.7.2
optionalDependencies:
pg: 8.21.0
ts-node: 10.9.2(@types/node@24.13.0)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
typescript-eslint@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): typescript-eslint@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.60.1(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/eslint-plugin': 8.60.1(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)