mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
FolderOpen,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
BookingContractSummaryCard,
|
||||
BookingContainerUnitsCard,
|
||||
ClearanceReviewSection,
|
||||
BookingDocumentsPanel,
|
||||
ContractOrdersPanel,
|
||||
} from "@/components/bookings/detail";
|
||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||
@@ -142,14 +144,17 @@ export default function BookingRequestDetailPage() {
|
||||
// A general contract drives an "Orders" tab: each drawdown order spawns a
|
||||
// child booking that staff manage (clearance/approval) independently.
|
||||
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
const showTabs = showClearanceTab || isGeneralContract;
|
||||
// The Documents tab is always available — every booking can accrue clearance,
|
||||
// customs-workflow, invoice or notice files — so the tab bar always renders.
|
||||
const requestedTab = searchParams.get("tab");
|
||||
const activeTab =
|
||||
requestedTab === "clearance" && showClearanceTab
|
||||
? "clearance"
|
||||
: requestedTab === "orders" && isGeneralContract
|
||||
? "orders"
|
||||
: "overview";
|
||||
: requestedTab === "documents"
|
||||
? "documents"
|
||||
: "overview";
|
||||
const setActiveTab = (tab: string | null) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (tab && tab !== "overview") next.set("tab", tab);
|
||||
@@ -187,10 +192,10 @@ export default function BookingRequestDetailPage() {
|
||||
)}
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content, split into tabs to keep each view focused */}
|
||||
{/* LEFT — primary content, split into tabs to keep each view focused.
|
||||
The Documents tab is always present, so the tab bar always renders. */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
{showTabs ? (
|
||||
<Tabs
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
variant="pills"
|
||||
@@ -217,6 +222,12 @@ export default function BookingRequestDetailPage() {
|
||||
Customer clearance
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
<Tabs.Tab
|
||||
value="documents"
|
||||
leftSection={<FolderOpen size={16} />}
|
||||
>
|
||||
Documents
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
@@ -238,10 +249,10 @@ export default function BookingRequestDetailPage() {
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
<Tabs.Panel value="documents">
|
||||
<BookingDocumentsPanel bookingId={booking.id} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
) : (
|
||||
<OverviewPanel booking={booking} row={row} />
|
||||
)}
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT — sticky action / summary rail */}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
@@ -199,6 +200,10 @@ export default function ContractClearanceDetailPage() {
|
||||
|
||||
<ClearanceHero contract={contract} stats={stats} />
|
||||
|
||||
{/* Windows on this contract's routes/direction only — tells GL ET when
|
||||
it can actually create the booking without checking the schedule board. */}
|
||||
{id ? <GlUpcomingWindowsSection contractId={id} /> : null}
|
||||
|
||||
{bookingAlreadyCreated ? (
|
||||
<Alert
|
||||
color="blue"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
@@ -32,6 +33,8 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
BookingStatusBadge,
|
||||
ChangeRequestPendingBadge,
|
||||
ChangeRequestReview,
|
||||
CompanyStatusBadge,
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
@@ -161,25 +164,85 @@ export default function CustomerDetailPage() {
|
||||
{
|
||||
id: "type",
|
||||
header: "Role",
|
||||
cell: ({ row }) => <ProfileTypeBadge type={row.original.type} />,
|
||||
},
|
||||
{
|
||||
id: "reference",
|
||||
header: "Reference",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
<div className="space-y-2">
|
||||
<ProfileTypeBadge type={row.original.type} />
|
||||
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "businessLicense",
|
||||
header: "Business license",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{row.original.businessLicense || "—"}
|
||||
</Text>
|
||||
),
|
||||
id: "licenseFiles",
|
||||
header: "License documents",
|
||||
cell: ({ row }) => {
|
||||
const files = row.original.licenseFiles ?? [];
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
{files.map((f) => (
|
||||
<Group key={f.id} gap={6} wrap="nowrap">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`View ${f.name}`}
|
||||
onClick={() =>
|
||||
view({
|
||||
name: f.name,
|
||||
url: fileViewUrl(f.id),
|
||||
mimeType: f.mimeType,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Eye size={14} />
|
||||
</ActionIcon>
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
lineClamp={1}
|
||||
onClick={() =>
|
||||
view({
|
||||
name: f.name,
|
||||
url: fileViewUrl(f.id),
|
||||
mimeType: f.mimeType,
|
||||
})
|
||||
}
|
||||
style={{
|
||||
maxWidth: 170,
|
||||
textAlign: "left",
|
||||
textDecoration:
|
||||
f.status === "pending_remove"
|
||||
? "line-through"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{f.name}
|
||||
</Anchor>
|
||||
{f.status === "pending_add" && (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
{f.status === "pending_remove" && (
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
Removing
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
@@ -207,7 +270,7 @@ export default function CustomerDetailPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[view],
|
||||
);
|
||||
|
||||
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
|
||||
@@ -501,13 +564,13 @@ export default function CustomerDetailPage() {
|
||||
]}
|
||||
backTo="/dashboard/customers"
|
||||
title={company.name}
|
||||
subtitle={`TIN ${company.tin}${
|
||||
company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
meta={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CompanyTypeBadge type={company.type} />
|
||||
<CompanyStatusBadge status={company.status} />
|
||||
<ChangeRequestPendingBadge companyId={company.id} />
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
@@ -534,6 +597,8 @@ export default function CustomerDetailPage() {
|
||||
{/* OVERVIEW */}
|
||||
<Tabs.Panel value="overview" pt="lg">
|
||||
<Stack gap="lg">
|
||||
<ChangeRequestReview company={company} />
|
||||
|
||||
<KpiStrip
|
||||
items={[
|
||||
{
|
||||
@@ -614,7 +679,7 @@ export default function CustomerDetailPage() {
|
||||
<ProfileChips profiles={company.companyProfiles} />
|
||||
</Group>
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={860}>
|
||||
<Box miw={1040}>
|
||||
<DataTable
|
||||
columns={profileColumns}
|
||||
data={company.companyProfiles}
|
||||
@@ -641,9 +706,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
bookingsQuery.isError
|
||||
? {
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -663,9 +728,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
documentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -679,12 +744,12 @@ export default function CustomerDetailPage() {
|
||||
</Text>
|
||||
<Stack gap="md">
|
||||
{licenseProfiles.map((p) => (
|
||||
<Stack key={p.id} gap={4}>
|
||||
<Stack key={p.id} gap={6}>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{humanize(p.type)} · {p.reference}
|
||||
</Text>
|
||||
{(p.licenseFiles ?? []).map((f) => (
|
||||
<Group key={f.url} gap={6} wrap="nowrap">
|
||||
<Group key={f.id} gap={8} wrap="nowrap">
|
||||
<Paperclip size={13} className="text-edr-muted" />
|
||||
<Anchor
|
||||
component="button"
|
||||
@@ -692,16 +757,37 @@ export default function CustomerDetailPage() {
|
||||
onClick={() =>
|
||||
view({
|
||||
name: f.name,
|
||||
url: f.url,
|
||||
url: fileViewUrl(f.id),
|
||||
mimeType: f.mimeType,
|
||||
})
|
||||
}
|
||||
size="xs"
|
||||
style={{
|
||||
textDecoration:
|
||||
f.status === "pending_remove"
|
||||
? "line-through"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{f.name}
|
||||
</Anchor>
|
||||
{f.status === "pending_add" && (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
Pending approval
|
||||
</Badge>
|
||||
)}
|
||||
{f.status === "pending_remove" && (
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
Removal pending
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
{(p.licenseFiles ?? []).length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
No license documents.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -723,9 +809,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
paymentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -746,9 +832,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
invoicesQuery.isError
|
||||
? {
|
||||
message: "Failed to load invoices.",
|
||||
onRetry: () => void invoicesQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load invoices.",
|
||||
onRetry: () => void invoicesQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
|
||||
@@ -18,14 +18,11 @@ import {
|
||||
HardDrive,
|
||||
Layers,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -33,9 +30,7 @@ import { api } from "@/services/api";
|
||||
import { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import EditFileUploadSettingDialog from "./EditFileUploadSettingDialog";
|
||||
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
|
||||
import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog";
|
||||
|
||||
export default function FileUploadSettingsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -43,7 +38,6 @@ export default function FileUploadSettingsPage() {
|
||||
const { data, isLoading, isError, error, refetch } = useQuery(
|
||||
api.fileUploadSettings.list.queryOptions(),
|
||||
);
|
||||
const deleteMutation = useMutation(api.fileUploadSettings.remove.mutationOptions());
|
||||
|
||||
const fileUploadSettings = useMemo(
|
||||
() => (Array.isArray(data) ? data : []),
|
||||
@@ -178,6 +172,8 @@ export default function FileUploadSettingsPage() {
|
||||
headerClassName,
|
||||
cellClassName: `${cellClassName} whitespace-nowrap`,
|
||||
},
|
||||
// Settings are seeded/fixed — staff may only update a setting's fields,
|
||||
// not create, edit, or delete the settings themselves.
|
||||
cell: ({ row }) => {
|
||||
const setting = row.original;
|
||||
return (
|
||||
@@ -191,33 +187,12 @@ export default function FileUploadSettingsPage() {
|
||||
Fields
|
||||
</Button>
|
||||
</ManageFileUploadFieldsDialog>
|
||||
|
||||
<EditFileUploadSettingDialog mode="edit" setting={setting}>
|
||||
<ActionIcon variant="default" aria-label="Edit setting">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</EditFileUploadSettingDialog>
|
||||
|
||||
<DeleteFileUploadSettingDialog
|
||||
settingLabel={setting.label}
|
||||
settingCode={setting.code}
|
||||
onConfirm={() => deleteMutation.mutate({ id: setting.id })}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
color="red"
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-label="Delete setting"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</DeleteFileUploadSettingDialog>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [deleteMutation]);
|
||||
}, []);
|
||||
|
||||
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
|
||||
@@ -226,11 +201,6 @@ export default function FileUploadSettingsPage() {
|
||||
<PageHeader
|
||||
title="File upload settings"
|
||||
subtitle="Define the file inputs every form in the platform should render — required/optional, single/multiple, allowed types and size."
|
||||
action={
|
||||
<EditFileUploadSettingDialog mode="create">
|
||||
<Button leftSection={<Plus size={18} />}>New setting</Button>
|
||||
</EditFileUploadSettingDialog>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStrip
|
||||
@@ -286,7 +256,7 @@ export default function FileUploadSettingsPage() {
|
||||
emptyMessage={
|
||||
query.trim()
|
||||
? "No file upload settings match your search."
|
||||
: 'No file upload settings yet. Click "New setting" to add one.'
|
||||
: "No file upload settings configured."
|
||||
}
|
||||
containerClassName="border-0 shadow-none bg-transparent min-w-[920px]"
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
||||
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
|
||||
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
||||
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -585,12 +586,20 @@ const FleetResourcePage = () => {
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<FleetHistoryModal
|
||||
opened={Boolean(historyTarget)}
|
||||
onClose={() => setHistoryTarget(null)}
|
||||
entity={slug === "vehicles" ? "vehicle" : "driver"}
|
||||
record={historyTarget}
|
||||
/>
|
||||
{slug === "wagons" ? (
|
||||
<WagonMovementHistoryModal
|
||||
opened={Boolean(historyTarget)}
|
||||
onClose={() => setHistoryTarget(null)}
|
||||
record={historyTarget}
|
||||
/>
|
||||
) : (
|
||||
<FleetHistoryModal
|
||||
opened={Boolean(historyTarget)}
|
||||
onClose={() => setHistoryTarget(null)}
|
||||
entity={slug === "vehicles" ? "vehicle" : "driver"}
|
||||
record={historyTarget}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -286,6 +286,9 @@ const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem
|
||||
handoverDocumentReference: row.handoverDocumentReference,
|
||||
handoverDocumentDate: row.handoverDocumentDate,
|
||||
deliveredAt: row.deliveredAt,
|
||||
// Carries the saved [Exit Inspection] block so truck-leaving prefills the
|
||||
// details captured at arrival (plate, driver, tare, gate-in).
|
||||
notes: row.notes,
|
||||
booking: row.bookingId
|
||||
? {
|
||||
id: row.bookingId,
|
||||
@@ -1282,6 +1285,17 @@ const LastMilePage = () => {
|
||||
Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate && !pastTransit;
|
||||
return (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
{pastTransit && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<Receipt size={13} />}
|
||||
onClick={() => setDetentionRecord(row.original)}
|
||||
>
|
||||
Detention
|
||||
</Button>
|
||||
)}
|
||||
<Menu
|
||||
position="bottom-end"
|
||||
width={200}
|
||||
|
||||
@@ -92,6 +92,12 @@ const TRADE_DIRECTIONS = [
|
||||
{ label: "Both", value: "BOTH" },
|
||||
];
|
||||
|
||||
// Mirrors the YardCountry enum in @edr/types — the only two countries on the line.
|
||||
const YARD_COUNTRIES = [
|
||||
{ label: "Ethiopia", value: "Ethiopia" },
|
||||
{ label: "Djibouti", value: "Djibouti" },
|
||||
];
|
||||
|
||||
const APPROVAL_ROLES = [
|
||||
{ label: "Line staff", value: "LINE_STAFF" },
|
||||
{ label: "Director", value: "DIRECTOR" },
|
||||
@@ -181,6 +187,7 @@ const CURRENCIES = [
|
||||
const PRIORITY_CONFIG_TYPES = [
|
||||
{ label: "Wagon count", value: "WAGON" },
|
||||
{ label: "Payment currency", value: "CURRENCY" },
|
||||
{ label: "Customs clearance", value: "CUSTOMS" },
|
||||
];
|
||||
|
||||
const codeColumn = (key: string, header = "Code"): ResourceColumn => ({
|
||||
@@ -304,7 +311,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
slug: "priority-configs",
|
||||
label: "Priority Rules",
|
||||
category: "rules",
|
||||
subtitle: "Wagon-count and payment-currency scoring rules",
|
||||
subtitle: "Wagon-count, payment-currency, and customs scoring rules",
|
||||
searchPlaceholder: "Search priority rules...",
|
||||
orderConfig: { field: "displayOrder", label: "Display order" },
|
||||
columns: [
|
||||
@@ -331,7 +338,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
optional: true,
|
||||
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...CURRENCIES],
|
||||
placeholder: "Select a currency",
|
||||
hideWhen: { field: "type", equals: ["WAGON"] },
|
||||
hideWhen: { field: "type", equals: ["WAGON", "CUSTOMS"] },
|
||||
},
|
||||
{ name: "minWagonCount", label: "Min wagon count", type: "number", required: true },
|
||||
{ name: "maxWagonCount", label: "Max wagon count", type: "number", required: true },
|
||||
@@ -351,7 +358,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
codeColumn("code"),
|
||||
{ id: "serviceName", header: "Service name", accessorKey: "serviceName" },
|
||||
{ id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" },
|
||||
{ id: "priorityBonusPoints", header: "Bonus pts", accessorKey: "priorityBonusPoints", format: "number" },
|
||||
activeColumn,
|
||||
],
|
||||
formFields: [
|
||||
@@ -361,7 +367,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "includesFirstMile", label: "Includes first mile", type: "boolean" },
|
||||
{ name: "includesLastMile", label: "Includes last mile", type: "boolean" },
|
||||
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
|
||||
{ name: "priorityBonusPoints", label: "Priority bonus points", type: "number" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
@@ -431,7 +436,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
],
|
||||
formFields: [
|
||||
{ name: "label", label: "Label", type: "text", required: true },
|
||||
{ name: "country", label: "Country", type: "text", required: true },
|
||||
{
|
||||
name: "country",
|
||||
label: "Country",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: YARD_COUNTRIES,
|
||||
},
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
RefreshCw,
|
||||
Ruler,
|
||||
TrainFront,
|
||||
Trophy,
|
||||
Weight,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
@@ -55,6 +56,8 @@ import {
|
||||
WindowStatusPill,
|
||||
} from "@/components/trainScheduling/batchVisuals";
|
||||
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { PriorityTrackingTab } from "@/components/trainScheduling/PriorityTrackingTab";
|
||||
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
|
||||
import { BookingsManager } from "./BookingsManager";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
@@ -578,9 +581,23 @@ export default function BatchScheduleDetailPage() {
|
||||
api.trainScheduling.batchBoardDetail.queryOptions({
|
||||
input: { scheduleId: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId),
|
||||
refetchInterval: 30_000,
|
||||
// Poll fast while a window cycle is actively moving (open / doc-review /
|
||||
// payment) so the priority ranking + pay countdowns stay live; back off to
|
||||
// 30s once the cycle is idle (pre-window / closed / done).
|
||||
refetchInterval: (query) => {
|
||||
const phase = (query.state.data as BatchBoardScheduleDetail | undefined)
|
||||
?.windowPhase;
|
||||
return phase === "OPEN" ||
|
||||
phase === "DOC_REVIEW" ||
|
||||
phase === "PAYMENT"
|
||||
? 5_000
|
||||
: 30_000;
|
||||
},
|
||||
}),
|
||||
);
|
||||
// Keep the board in sync with server-pushed window-phase transitions too
|
||||
// (invalidates the batch-board list + patches window carousels).
|
||||
useBookingWindowSocket(Boolean(scheduleId));
|
||||
const runAllocation = useMutation(
|
||||
api.trainScheduling.runAllocation.mutationOptions(),
|
||||
);
|
||||
@@ -735,6 +752,13 @@ export default function BatchScheduleDetailPage() {
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="overview">Overview</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="priority"
|
||||
leftSection={<Trophy size={14} />}
|
||||
>
|
||||
Priority Tracking{" "}
|
||||
{allBookings.length > 0 && `(${allBookings.length})`}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="composition">
|
||||
Train Composition{" "}
|
||||
{scheduleDetailQuery.data?.trainSet?.wagons &&
|
||||
@@ -1095,6 +1119,10 @@ export default function BatchScheduleDetailPage() {
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="priority" pt="lg">
|
||||
<PriorityTrackingTab data={data} bookings={allBookings} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="composition" pt="lg">
|
||||
{scheduleDetailQuery.data && scheduleDetailQuery.data.trainSet ? (
|
||||
<Group align="stretch" gap="md" wrap="nowrap">
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
@@ -26,7 +26,11 @@ import {
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
|
||||
import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals";
|
||||
import {
|
||||
RouteCorridor,
|
||||
StatusPill,
|
||||
scheduleBrand,
|
||||
} from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
@@ -52,8 +56,11 @@ function formatDateTime(iso?: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Compact icon + label + value cell used in the header meta strip. */
|
||||
function MetaStat({
|
||||
/**
|
||||
* A single fact in the hero's glass meta strip — icon chip + uppercase label +
|
||||
* value, laid on the translucent panel over the gradient.
|
||||
*/
|
||||
function HeroStat({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
@@ -63,15 +70,33 @@ function MetaStat({
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon size={32} radius="md" variant="light" color="edr-green">
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(255,255,255,0.16)",
|
||||
border: "1px solid rgba(255,255,255,0.24)",
|
||||
color: "white",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="10px" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.5 }}>
|
||||
</Box>
|
||||
<Stack gap={1} style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
size="10px"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 0.6, color: "rgba(255,255,255,0.72)" }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={700} c="dark.5" truncate>
|
||||
<Text size="sm" fw={700} c="white" truncate>
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -79,6 +104,38 @@ function MetaStat({
|
||||
);
|
||||
}
|
||||
|
||||
/** Section header — icon chip + title + one-line hint. Shared by the cards. */
|
||||
function SectionHead({
|
||||
icon,
|
||||
title,
|
||||
hint,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
hint: string;
|
||||
}) {
|
||||
return (
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={36} radius="md" variant="light" color="edr-green">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={800} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{hint}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const CARD_STYLE = {
|
||||
borderColor: scheduleBrand.mutedBorder,
|
||||
boxShadow: scheduleBrand.shadowSm,
|
||||
} as const;
|
||||
|
||||
export default function TrainScheduleTrackPage() {
|
||||
const { scheduleId } = useParams<{ scheduleId: string }>();
|
||||
const { toast } = useToast();
|
||||
@@ -116,9 +173,13 @@ export default function TrainScheduleTrackPage() {
|
||||
const canLog = track.status === "DISPATCHED";
|
||||
const totalStations = track.stations.length;
|
||||
const reached = Math.min(track.currentSequenceNo + 1, totalStations);
|
||||
const progressPct = totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0;
|
||||
const progressPct =
|
||||
totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0;
|
||||
const clampedPct = Math.min(100, Math.max(0, progressPct));
|
||||
const currentStation = track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—";
|
||||
const currentStation =
|
||||
track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—";
|
||||
const inTransit = track.status === "DISPATCHED";
|
||||
const arrived = track.status === "ARRIVED";
|
||||
|
||||
const handleLog = (sequenceNo: number) => {
|
||||
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
|
||||
@@ -156,160 +217,259 @@ export default function TrainScheduleTrackPage() {
|
||||
Back to schedule
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<Paper radius="lg" withBorder p="lg">
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group gap="md" align="flex-start" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: freightBrand.gradient,
|
||||
color: "white",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Navigation size={24} />
|
||||
</Box>
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={3} fw={800}>
|
||||
Train tracking
|
||||
</Title>
|
||||
{track.trainNumber ? (
|
||||
<Badge variant="light" color="edr-green" radius="sm">
|
||||
{track.trainNumber}
|
||||
</Badge>
|
||||
) : null}
|
||||
{track.direction ? (
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
{track.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Box maw={360}>
|
||||
<RouteCorridor origin={track.origin} destination={track.destination} variant="compact" />
|
||||
{/* ── Hero: gradient wash, route + a bold progress ring woven together ── */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
p={0}
|
||||
style={{ overflow: "hidden", boxShadow: scheduleBrand.shadow }}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
background: scheduleBrand.heroGradient,
|
||||
padding: "26px 28px",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* soft decorative glow, purely artistic */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -80,
|
||||
right: -60,
|
||||
width: 260,
|
||||
height: 260,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.10)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="wrap"
|
||||
gap="xl"
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
{/* left — identity + route */}
|
||||
<Stack gap={14} style={{ minWidth: 260, flex: 1 }}>
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 14,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(255,255,255,0.16)",
|
||||
border: "1px solid rgba(255,255,255,0.26)",
|
||||
color: "white",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Navigation size={26} />
|
||||
</Box>
|
||||
</Stack>
|
||||
</Group>
|
||||
<StatusPill status={track.status} />
|
||||
</Group>
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={3} fw={800} c="white">
|
||||
Train tracking
|
||||
</Title>
|
||||
{track.trainNumber ? (
|
||||
<Badge
|
||||
variant="white"
|
||||
color="dark"
|
||||
radius="sm"
|
||||
styles={{ root: { color: freightBrand.primaryDark } }}
|
||||
>
|
||||
{track.trainNumber}
|
||||
</Badge>
|
||||
) : null}
|
||||
{track.direction ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
radius="sm"
|
||||
styles={{
|
||||
root: {
|
||||
color: "white",
|
||||
borderColor: "rgba(255,255,255,0.5)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{track.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Box maw={380}>
|
||||
<RouteCorridor
|
||||
origin={track.origin}
|
||||
destination={track.destination}
|
||||
variant="compact"
|
||||
onDark
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{/* Journey progress */}
|
||||
<Box>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="xs" fw={700} c="gray.7" tt="uppercase" style={{ letterSpacing: 0.4 }}>
|
||||
Journey progress
|
||||
</Text>
|
||||
<Text size="xs" fw={700} c="edr-green.8">
|
||||
{reached} / {totalStations} stations · {Math.round(clampedPct)}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={clampedPct}
|
||||
size="lg"
|
||||
radius="xl"
|
||||
color="edr-green"
|
||||
striped={track.status === "DISPATCHED"}
|
||||
animated={track.status === "DISPATCHED"}
|
||||
/>
|
||||
</Box>
|
||||
<Group gap="sm">
|
||||
<StatusPill status={track.status} size="md" />
|
||||
<Box
|
||||
px={12}
|
||||
py={5}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.16)",
|
||||
border: "1px solid rgba(255,255,255,0.24)",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={700} c="white" style={{ letterSpacing: 0.2 }}>
|
||||
{arrived
|
||||
? "Journey complete"
|
||||
: inTransit
|
||||
? `En route · ${currentStation}`
|
||||
: "Awaiting dispatch"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Meta strip */}
|
||||
<Group justify="space-between" wrap="wrap" gap="lg">
|
||||
<MetaStat icon={<MapPin size={16} />} label="Current" value={currentStation} />
|
||||
<MetaStat
|
||||
icon={<CalendarClock size={16} />}
|
||||
label="Departed"
|
||||
value={formatDateTime(track.actualDepartureAt)}
|
||||
/>
|
||||
<MetaStat
|
||||
icon={<Flag size={16} />}
|
||||
label="Arrived"
|
||||
value={formatDateTime(track.actualArrivalAt)}
|
||||
/>
|
||||
<MetaStat
|
||||
icon={<Train size={16} />}
|
||||
label="Stations"
|
||||
value={`${reached} of ${totalStations}`}
|
||||
{/* right — progress ring, the artistic focal point */}
|
||||
<RingProgress
|
||||
size={132}
|
||||
thickness={11}
|
||||
roundCaps
|
||||
sections={[{ value: clampedPct, color: "white" }]}
|
||||
rootColor="rgba(255,255,255,0.22)"
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1} c="white">
|
||||
{Math.round(clampedPct)}%
|
||||
</Text>
|
||||
<Text
|
||||
size="10px"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 0.6, color: "rgba(255,255,255,0.8)" }}
|
||||
>
|
||||
{reached}/{totalStations} stops
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* glass meta strip below the wash */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="wrap"
|
||||
gap="lg"
|
||||
px={28}
|
||||
py="md"
|
||||
style={{
|
||||
background: freightBrand.primaryDark,
|
||||
borderTop: "1px solid rgba(255,255,255,0.12)",
|
||||
}}
|
||||
>
|
||||
<HeroStat icon={<MapPin size={16} />} label="Current" value={currentStation} />
|
||||
<HeroStat
|
||||
icon={<CalendarClock size={16} />}
|
||||
label="Departed"
|
||||
value={formatDateTime(track.actualDepartureAt)}
|
||||
/>
|
||||
<HeroStat
|
||||
icon={<Flag size={16} />}
|
||||
label="Arrived"
|
||||
value={formatDateTime(track.actualArrivalAt)}
|
||||
/>
|
||||
<HeroStat
|
||||
icon={<Train size={16} />}
|
||||
label="Stations"
|
||||
value={`${reached} of ${totalStations}`}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Corridor */}
|
||||
<Paper radius="lg" p="lg" withBorder>
|
||||
{/* ── Route corridor ── */}
|
||||
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={34} radius="md" variant="light" color="edr-green">
|
||||
<Navigation size={17} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Text fw={800} size="sm">
|
||||
Route corridor
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{canLog
|
||||
? "Log the train passing each station; the final station marks arrival."
|
||||
: track.status === "ARRIVED"
|
||||
? "This train has arrived at its destination."
|
||||
: "Tracking becomes available once the train is dispatched."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<SectionHead
|
||||
icon={<Navigation size={17} />}
|
||||
title="Route corridor"
|
||||
hint={
|
||||
canLog
|
||||
? "Log the train passing each station; the final station marks arrival."
|
||||
: arrived
|
||||
? "This train has arrived at its destination."
|
||||
: "Tracking becomes available once the train is dispatched."
|
||||
}
|
||||
/>
|
||||
<RouteCorridorTrack
|
||||
stations={track.stations}
|
||||
currentSequenceNo={track.currentSequenceNo}
|
||||
checkpoints={track.checkpoints}
|
||||
canLog={canLog}
|
||||
loggingSeq={
|
||||
recordCheckpoint.isPending ? recordCheckpoint.variables?.payload.sequenceNo : null
|
||||
recordCheckpoint.isPending
|
||||
? recordCheckpoint.variables?.payload.sequenceNo
|
||||
: null
|
||||
}
|
||||
onLogCheckpoint={handleLog}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Checkpoint log */}
|
||||
<Paper radius="lg" p="lg" withBorder>
|
||||
<Group gap="sm" align="center" wrap="nowrap" mb="md">
|
||||
<ThemeIcon size={34} radius="md" variant="light" color="edr-green">
|
||||
<CheckCircle2 size={17} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Text fw={800} size="sm">
|
||||
Checkpoint log
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{track.checkpoints.length} event{track.checkpoints.length === 1 ? "" : "s"} recorded
|
||||
</Text>
|
||||
</Stack>
|
||||
{/* ── Checkpoint log ── */}
|
||||
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
|
||||
<Group justify="space-between" wrap="nowrap" mb="md">
|
||||
<SectionHead
|
||||
icon={<CheckCircle2 size={17} />}
|
||||
title="Checkpoint log"
|
||||
hint={`${track.checkpoints.length} event${
|
||||
track.checkpoints.length === 1 ? "" : "s"
|
||||
} recorded`}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{track.checkpoints.length === 0 ? (
|
||||
<Stack align="center" gap="xs" py="xl">
|
||||
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
|
||||
<MapPin size={20} />
|
||||
<Stack
|
||||
align="center"
|
||||
gap="xs"
|
||||
py={40}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1px dashed ${scheduleBrand.mutedBorder}`,
|
||||
background: scheduleBrand.softSurface,
|
||||
}}
|
||||
>
|
||||
<ThemeIcon size={48} radius="xl" variant="light" color="edr-green">
|
||||
<MapPin size={22} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} c="gray.7">
|
||||
<Text size="sm" fw={700} c="gray.7">
|
||||
No checkpoints yet
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" maw={300}>
|
||||
Each station the train passes will be logged here with its timestamp.
|
||||
<Text size="xs" c="dimmed" ta="center" maw={320}>
|
||||
Each station the train passes will be logged here with its
|
||||
timestamp.
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Timeline active={track.checkpoints.length} bulletSize={22} lineWidth={2} color="edr-green">
|
||||
<Timeline
|
||||
active={track.checkpoints.length}
|
||||
bulletSize={24}
|
||||
lineWidth={2}
|
||||
color="edr-green"
|
||||
>
|
||||
{track.checkpoints.map((cp) => (
|
||||
<Timeline.Item
|
||||
key={cp.id}
|
||||
bullet={cp.kind === "ARRIVED" ? <CheckCircle2 size={13} /> : <MapPin size={12} />}
|
||||
bullet={
|
||||
cp.kind === "ARRIVED" ? (
|
||||
<CheckCircle2 size={13} />
|
||||
) : (
|
||||
<MapPin size={12} />
|
||||
)
|
||||
}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<Text fw={700} size="sm">
|
||||
@@ -319,7 +479,13 @@ export default function TrainScheduleTrackPage() {
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={cp.kind === "ARRIVED" ? "teal" : cp.kind === "DEPARTED" ? "blue" : "edr-green"}
|
||||
color={
|
||||
cp.kind === "ARRIVED"
|
||||
? "teal"
|
||||
: cp.kind === "DEPARTED"
|
||||
? "blue"
|
||||
: "edr-green"
|
||||
}
|
||||
>
|
||||
{cp.kind}
|
||||
</Badge>
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
|
||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||
@@ -863,6 +864,16 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</ThemeIcon>
|
||||
<Stack gap={6}>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
{schedule.reference ? (
|
||||
<Badge
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
style={{ fontWeight: 700, fontFamily: "monospace" }}
|
||||
>
|
||||
{schedule.reference}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
||||
{schedule.route?.name ?? "Train schedule"}
|
||||
</Title>
|
||||
@@ -1131,6 +1142,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
void detailQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
{scheduleId ? <YardWorkPanel scheduleId={scheduleId} /> : null}
|
||||
{scheduleId ? (
|
||||
<IntercityRideAlongPanel
|
||||
scheduleId={scheduleId}
|
||||
|
||||
@@ -89,6 +89,13 @@ export default function TrainScheduleV2ListPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||
const [freightFilter, setFreightFilter] = useState("ALL");
|
||||
const [originFilter, setOriginFilter] = useState("ALL");
|
||||
const [destinationFilter, setDestinationFilter] = useState("ALL");
|
||||
// Default: newest-created first, matching the API's default order.
|
||||
const [sortBy, setSortBy] = useState<"createdAt" | "scheduleDate" | "reference">(
|
||||
"createdAt",
|
||||
);
|
||||
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
|
||||
const [editDateSchedule, setEditDateSchedule] =
|
||||
@@ -152,13 +159,32 @@ export default function TrainScheduleV2ListPage() {
|
||||
return base;
|
||||
}, [allSchedules]);
|
||||
|
||||
// Distinct origins/destinations present in the loaded schedules, for the
|
||||
// corridor filters. Sorted A→Z; "ALL" prepended by the Select data below.
|
||||
const originOptions = useMemo(
|
||||
() =>
|
||||
[...new Set(allSchedules.map((s) => s.origin).filter(Boolean))].sort() as string[],
|
||||
[allSchedules],
|
||||
);
|
||||
const destinationOptions = useMemo(
|
||||
() =>
|
||||
[
|
||||
...new Set(allSchedules.map((s) => s.destination).filter(Boolean)),
|
||||
].sort() as string[],
|
||||
[allSchedules],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return allSchedules.filter((s) => {
|
||||
const matched = allSchedules.filter((s) => {
|
||||
if (statusFilter !== "ALL" && s.status !== statusFilter) return false;
|
||||
if (freightFilter !== "ALL" && s.freightType !== freightFilter) return false;
|
||||
if (originFilter !== "ALL" && s.origin !== originFilter) return false;
|
||||
if (destinationFilter !== "ALL" && s.destination !== destinationFilter)
|
||||
return false;
|
||||
if (!query) return true;
|
||||
const haystack = [
|
||||
s.reference,
|
||||
s.trainNumber,
|
||||
s.routeName,
|
||||
s.origin,
|
||||
@@ -173,7 +199,31 @@ export default function TrainScheduleV2ListPage() {
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [allSchedules, search, statusFilter, freightFilter]);
|
||||
|
||||
const dir = sortDir === "asc" ? 1 : -1;
|
||||
const sorted = [...matched].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortBy === "reference") {
|
||||
cmp = (a.reference ?? "").localeCompare(b.reference ?? "");
|
||||
} else {
|
||||
// createdAt or scheduleDate — compare as timestamps (missing sorts last).
|
||||
const av = new Date(a[sortBy] ?? 0).getTime();
|
||||
const bv = new Date(b[sortBy] ?? 0).getTime();
|
||||
cmp = av - bv;
|
||||
}
|
||||
return cmp * dir;
|
||||
});
|
||||
return sorted;
|
||||
}, [
|
||||
allSchedules,
|
||||
search,
|
||||
statusFilter,
|
||||
freightFilter,
|
||||
originFilter,
|
||||
destinationFilter,
|
||||
sortBy,
|
||||
sortDir,
|
||||
]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
|
||||
const paged = useMemo(() => {
|
||||
@@ -185,6 +235,16 @@ export default function TrainScheduleV2ListPage() {
|
||||
const headerClassName = ruleEngineTable.headerCell;
|
||||
const cellClassName = ruleEngineTable.bodyCell;
|
||||
return [
|
||||
{
|
||||
id: "reference",
|
||||
header: "Ref",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600} ff="monospace" c="edr-green.8">
|
||||
{row.original.reference ?? "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "date",
|
||||
header: "Departure",
|
||||
@@ -471,6 +531,58 @@ export default function TrainScheduleV2ListPage() {
|
||||
w={140}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
placeholder="Origin"
|
||||
searchable
|
||||
value={originFilter}
|
||||
onChange={(v) => setOriginFilter(v ?? "ALL")}
|
||||
data={[
|
||||
{ value: "ALL", label: "All origins" },
|
||||
...originOptions.map((o) => ({ value: o, label: o })),
|
||||
]}
|
||||
w={160}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
placeholder="Destination"
|
||||
searchable
|
||||
value={destinationFilter}
|
||||
onChange={(v) => setDestinationFilter(v ?? "ALL")}
|
||||
data={[
|
||||
{ value: "ALL", label: "All destinations" },
|
||||
...destinationOptions.map((d) => ({ value: d, label: d })),
|
||||
]}
|
||||
w={170}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={`${sortBy}:${sortDir}`}
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
const [by, dir] = v.split(":") as [
|
||||
typeof sortBy,
|
||||
typeof sortDir,
|
||||
];
|
||||
setSortBy(by);
|
||||
setSortDir(dir);
|
||||
}}
|
||||
data={[
|
||||
{ value: "createdAt:desc", label: "Newest created" },
|
||||
{ value: "createdAt:asc", label: "Oldest created" },
|
||||
{ value: "scheduleDate:desc", label: "Departure ↓" },
|
||||
{ value: "scheduleDate:asc", label: "Departure ↑" },
|
||||
{ value: "reference:asc", label: "Reference ↑" },
|
||||
{ value: "reference:desc", label: "Reference ↓" },
|
||||
]}
|
||||
w={170}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -693,9 +805,16 @@ function ScheduleCard({
|
||||
<Train size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" lineClamp={1}>
|
||||
{schedule.routeName ?? "Train schedule"}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{schedule.reference ? (
|
||||
<Text size="xs" fw={700} ff="monospace" c="edr-green.8">
|
||||
{schedule.reference}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text fw={600} size="sm" lineClamp={1}>
|
||||
{schedule.routeName ?? "Train schedule"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{day} · {time}
|
||||
</Text>
|
||||
|
||||
@@ -38,7 +38,7 @@ const columns: ColumnDef<Loading>[] = [
|
||||
},
|
||||
{
|
||||
id: 'weight',
|
||||
header: 'Loaded Weight (kg)',
|
||||
header: 'Loaded Weight (t)',
|
||||
cell: ({ row }) => formatNumber(row.original.loadedWeight),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -203,7 +203,7 @@ function PendingPaymentTable({ items, onNavigate }: PendingPaymentTableProps) {
|
||||
},
|
||||
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' },
|
||||
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' },
|
||||
{ id: 'weight', header: 'Weight (kg)', cell: ({ row }) => formatNumber(row.original.weight) },
|
||||
{ id: 'weight', header: 'Weight (t)', cell: ({ row }) => formatNumber(row.original.weight) },
|
||||
{
|
||||
id: 'payment',
|
||||
header: 'Payment',
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { Info, Plus, Trash2 } from 'lucide-react';
|
||||
import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
useDeleteAllocationRule,
|
||||
useDeleteFeeRule,
|
||||
useFeeRules,
|
||||
useUpdateAllocationRule,
|
||||
useUpdateFeeRule,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { api } from '@/services/api';
|
||||
import {
|
||||
@@ -37,6 +39,9 @@ import {
|
||||
FEE_RULE_BASIS_LABELS,
|
||||
FEE_RULE_TYPES,
|
||||
FEE_RULE_TYPE_LABELS,
|
||||
VEHICLE_TYPES,
|
||||
type AllocationRule,
|
||||
type FeeRule,
|
||||
type FeeRuleBasis,
|
||||
type FeeRuleType,
|
||||
} from '@/types/warehouse';
|
||||
@@ -120,8 +125,10 @@ function AllocationRules() {
|
||||
const { data, isLoading } = useAllocationRules();
|
||||
const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards();
|
||||
const create = useCreateAllocationRule();
|
||||
const update = useUpdateAllocationRule();
|
||||
const remove = useDeleteAllocationRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
priority: 100,
|
||||
@@ -141,7 +148,8 @@ function AllocationRules() {
|
||||
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
|
||||
}));
|
||||
|
||||
const resetForm = () =>
|
||||
const resetForm = () => {
|
||||
setEditingId(null);
|
||||
setForm({
|
||||
name: '',
|
||||
priority: 100,
|
||||
@@ -152,6 +160,22 @@ function AllocationRules() {
|
||||
targetYardCode: '',
|
||||
storageType: '',
|
||||
});
|
||||
};
|
||||
|
||||
const startEdit = (rule: AllocationRule) => {
|
||||
setForm({
|
||||
name: rule.name,
|
||||
priority: rule.priority ?? 100,
|
||||
freightType: rule.freightType ?? '',
|
||||
tradeDirection: rule.tradeDirection ?? '',
|
||||
cargoTypeCode: rule.cargoTypeCode ?? '',
|
||||
containerStatus: rule.containerStatus ?? '',
|
||||
targetYardCode: rule.targetYardCode ?? '',
|
||||
storageType: rule.storageType ?? '',
|
||||
});
|
||||
setEditingId(rule.id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim() || !form.targetYardCode.trim()) {
|
||||
@@ -159,7 +183,7 @@ function AllocationRules() {
|
||||
return;
|
||||
}
|
||||
|
||||
await create.mutateAsync({
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
priority: form.priority,
|
||||
freightType: clean(form.freightType) ?? null,
|
||||
@@ -169,10 +193,20 @@ function AllocationRules() {
|
||||
targetYardCode: form.targetYardCode.trim(),
|
||||
storageType: clean(form.storageType) ?? null,
|
||||
isActive: true,
|
||||
} as never);
|
||||
toast({ title: 'Allocation rule created' });
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
};
|
||||
try {
|
||||
if (editingId) {
|
||||
await update.mutateAsync({ id: editingId, payload: payload as never });
|
||||
toast({ title: 'Allocation rule updated' });
|
||||
} else {
|
||||
await create.mutateAsync(payload as never);
|
||||
toast({ title: 'Allocation rule created' });
|
||||
}
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -181,7 +215,7 @@ function AllocationRules() {
|
||||
<Text c="dimmed" size="sm">
|
||||
{rules.length} rule(s) matched by ascending priority
|
||||
</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
||||
New allocation rule
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -228,14 +262,19 @@ function AllocationRules() {
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -244,7 +283,7 @@ function AllocationRules() {
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
|
||||
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Card withBorder radius="md" padding="sm" bg="gray.0">
|
||||
<Stack gap={4}>
|
||||
@@ -339,11 +378,11 @@ function AllocationRules() {
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
<Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>
|
||||
Create
|
||||
<Button loading={create.isPending || update.isPending} onClick={submit}>
|
||||
{editingId ? 'Save changes' : 'Create'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -362,8 +401,10 @@ function FeeRules() {
|
||||
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
const create = useCreateFeeRule();
|
||||
const update = useUpdateFeeRule();
|
||||
const remove = useDeleteFeeRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
|
||||
@@ -372,6 +413,7 @@ function FeeRules() {
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
containerType: '',
|
||||
vehicleType: '',
|
||||
freeDays: 3,
|
||||
freeHours: 3,
|
||||
ratePerDay: 0,
|
||||
@@ -389,8 +431,11 @@ function FeeRules() {
|
||||
// Truck detention: per truck per day after an HOURS-based grace (default 3h),
|
||||
// with day tiers. Uses "free hours" instead of "free days".
|
||||
const isTruckDetention = form.ruleType === 'TRUCK_DETENTION_FEE';
|
||||
// Double handling + truck detention apply to IMPORT only — trade direction is locked.
|
||||
const isImportOnly = isDoubleHandling || isTruckDetention;
|
||||
|
||||
const resetForm = () =>
|
||||
const resetForm = () => {
|
||||
setEditingId(null);
|
||||
setForm({
|
||||
name: '',
|
||||
ruleType: 'DEMURRAGE_FEE',
|
||||
@@ -399,12 +444,34 @@ function FeeRules() {
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
containerType: '',
|
||||
vehicleType: '',
|
||||
freeDays: 3,
|
||||
freeHours: 3,
|
||||
ratePerDay: 0,
|
||||
tiers: [],
|
||||
currency: 'USD',
|
||||
});
|
||||
};
|
||||
|
||||
const startEdit = (rule: FeeRule) => {
|
||||
setForm({
|
||||
name: rule.name,
|
||||
ruleType: rule.ruleType,
|
||||
basis: (rule.basis as FeeRuleBasis) ?? 'PER_CONTAINER',
|
||||
freightType: rule.freightType ?? '',
|
||||
tradeDirection: rule.tradeDirection ?? '',
|
||||
cargoTypeCode: rule.cargoTypeCode ?? '',
|
||||
containerType: rule.containerType ?? '',
|
||||
vehicleType: rule.vehicleType ?? '',
|
||||
freeDays: rule.freeDays ?? 3,
|
||||
freeHours: rule.freeHours ?? 3,
|
||||
ratePerDay: rule.ratePerDay ?? 0,
|
||||
tiers: (rule.tiers ?? []).map((t) => ({ fromDay: t.fromDay, toDay: t.toDay, ratePerDay: t.ratePerDay })),
|
||||
currency: rule.currency ?? 'USD',
|
||||
});
|
||||
setEditingId(rule.id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const addTier = () =>
|
||||
setForm((f) => {
|
||||
@@ -461,7 +528,7 @@ function FeeRules() {
|
||||
name: form.name.trim(),
|
||||
ruleType: form.ruleType,
|
||||
freightType: clean(form.freightType) ?? null,
|
||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
||||
tradeDirection: isImportOnly ? 'IMPORT' : clean(form.tradeDirection) ?? null,
|
||||
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
|
||||
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
|
||||
// Double handling: flat basis × rate. Truck detention: HOURS-based grace.
|
||||
@@ -469,17 +536,22 @@ function FeeRules() {
|
||||
ratePerDay: form.ratePerDay,
|
||||
currency: form.currency || 'USD',
|
||||
...(isDoubleHandling ? { basis: form.basis } : {}),
|
||||
...(isTruckDetention ? { freeHours: form.freeHours } : {}),
|
||||
...(isTruckDetention ? { freeHours: form.freeHours, vehicleType: clean(form.vehicleType) ?? null } : {}),
|
||||
...(!isDoubleHandling && tiers.length ? { tiers } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
await create.mutateAsync(payload as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
if (editingId) {
|
||||
await update.mutateAsync({ id: editingId, payload: payload as never });
|
||||
toast({ title: 'Fee rule updated' });
|
||||
} else {
|
||||
await create.mutateAsync(payload as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
}
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
if (tiers.length && isUnknownTiersError(error)) {
|
||||
if (!editingId && tiers.length && isUnknownTiersError(error)) {
|
||||
const legacyPayload: Omit<typeof payload, 'tiers'> = {
|
||||
name: payload.name,
|
||||
ruleType: payload.ruleType,
|
||||
@@ -500,7 +572,7 @@ function FeeRules() {
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
toast({ variant: 'destructive', title: 'Create failed', description: extractErrorMessage(error) });
|
||||
toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -510,7 +582,7 @@ function FeeRules() {
|
||||
<Text c="dimmed" size="sm">
|
||||
{rules.length} rule(s) - most specific match applies
|
||||
</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
||||
New fee rule
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -572,14 +644,19 @@ function FeeRules() {
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -588,7 +665,7 @@ function FeeRules() {
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
|
||||
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<TextInput
|
||||
@@ -633,11 +710,23 @@ function FeeRules() {
|
||||
/>
|
||||
<Select
|
||||
label="Trade direction"
|
||||
description={isImportOnly ? 'Import only for this fee type' : undefined}
|
||||
data={TRADE}
|
||||
value={form.tradeDirection || null}
|
||||
value={isImportOnly ? 'IMPORT' : form.tradeDirection || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))}
|
||||
clearable
|
||||
disabled={isImportOnly}
|
||||
clearable={!isImportOnly}
|
||||
/>
|
||||
{isTruckDetention && (
|
||||
<Select
|
||||
label="Truck type"
|
||||
placeholder="Any truck type"
|
||||
data={VEHICLE_TYPES.map((v) => ({ value: v, label: v.charAt(0) + v.slice(1).toLowerCase() }))}
|
||||
value={form.vehicleType || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, vehicleType: selectValue(value) }))}
|
||||
clearable
|
||||
/>
|
||||
)}
|
||||
{isBulkRule && (
|
||||
<Select
|
||||
label="Cargo type"
|
||||
@@ -758,11 +847,11 @@ function FeeRules() {
|
||||
</Stack>
|
||||
)}
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
<Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>
|
||||
Create
|
||||
<Button loading={create.isPending || update.isPending} onClick={submit}>
|
||||
{editingId ? 'Save changes' : 'Create'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
Reference in New Issue
Block a user