This commit is contained in:
yaschalew
2026-07-02 17:56:17 +03:00
273 changed files with 22748 additions and 5431 deletions

View File

@@ -65,7 +65,6 @@ export function BookingActionsMenu({
};
const hasMenu = listRowHasActions(row, user);
const primary = actions.find((a) => a.primary) ?? actions[0];
if (!hasMenu && variant === "table") {
return (
@@ -117,19 +116,6 @@ export function BookingActionsMenu({
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{variant === "table" && primary && (
<Button
size="compact-sm"
color="edr-green"
visibleFrom="lg"
leftSection={<primary.icon size={14} />}
disabled={mutations.isPending}
onClick={() => handleAction(primary)}
>
{primary.shortLabel}
</Button>
)}
<Menu position="bottom-end" width={220} withinPortal>
<Menu.Target>
<ActionIcon

View File

@@ -41,6 +41,12 @@ export interface ClearanceReviewSectionProps {
onChanged?: () => void;
/** Hide the inline progress summary (e.g. when the parent renders its own). */
hideSummary?: boolean;
/** Lock approve actions after document review phase completes. */
approvalsLocked?: boolean;
/** Block new queries after pre-clearance finalization. */
queriesLocked?: boolean;
/** Read-only audit view — no approve/query actions. */
readOnly?: boolean;
}
const STATUS_META: Record<
@@ -64,6 +70,9 @@ export function ClearanceReviewSection({
bookingId,
onChanged,
hideSummary,
approvalsLocked = false,
queriesLocked = false,
readOnly = false,
}: ClearanceReviewSectionProps) {
const qc = useQueryClient();
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
@@ -147,6 +156,11 @@ export function ClearanceReviewSection({
return { total, approved, queried, pending, pct };
}, [customerDocs]);
const hasDocsAwaitingApproval = customerDocs.some(
(d) => d.file && d.reviewStatus !== "APPROVED",
);
const effectiveApprovalsLocked = approvalsLocked && !hasDocsAwaitingApproval;
if (isLoading || !clearance) {
return (
<Group justify="center" py="xl" gap={10}>
@@ -194,6 +208,9 @@ export function ClearanceReviewSection({
<DocReviewCard
key={`${doc.settingCode}:${doc.fileKey}`}
doc={doc}
approvalsLocked={effectiveApprovalsLocked}
queriesLocked={queriesLocked}
readOnly={readOnly}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
@@ -397,6 +414,9 @@ function StatPill({
function DocReviewCard({
doc,
approvalsLocked,
queriesLocked,
readOnly,
note,
queryOpen,
onToggleQuery,
@@ -407,6 +427,9 @@ function DocReviewCard({
busy,
}: {
doc: Freight.ClearanceDocument;
approvalsLocked: boolean;
queriesLocked: boolean;
readOnly: boolean;
note: string;
queryOpen: boolean;
onToggleQuery: (open: boolean) => void;
@@ -419,6 +442,7 @@ function DocReviewCard({
const status = doc.reviewStatus ?? "PENDING";
const meta = STATUS_META[status];
const hasFile = !!doc.file;
const isApproved = status === "APPROVED";
return (
<Paper
@@ -499,31 +523,35 @@ function DocReviewCard({
</Alert>
)}
{hasFile && (
{hasFile && !readOnly && (
<Box mt="sm">
{!queryOpen ? (
<Group justify="flex-end" gap={8}>
<Button
size="compact-sm"
variant="light"
color="red"
radius="md"
leftSection={<MessageSquareWarning size={14} />}
disabled={busy}
onClick={() => onToggleQuery(true)}
>
Open query
</Button>
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={14} />}
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
{!queriesLocked && (
<Button
size="compact-sm"
variant="light"
color="red"
radius="md"
leftSection={<MessageSquareWarning size={14} />}
disabled={busy}
onClick={() => onToggleQuery(true)}
>
Open query
</Button>
)}
{!isApproved && !approvalsLocked && (
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={14} />}
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
)}
</Group>
) : (
<Box

View File

@@ -0,0 +1,128 @@
import type { ReactNode } from "react";
import { Badge, Stack, Tabs, Text } from "@mantine/core";
import { AlertTriangle, FileText, ShieldAlert } from "lucide-react";
import type { Freight } from "@edr/types";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard";
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
export interface ClearanceOpsTabsProps {
bookingId: string | undefined;
milestones?: Freight.IClearanceMilestone[];
/** When false, only the clearance tab content is rendered (no tab bar). */
showOpsTabs?: boolean;
clearanceTab: ReactNode;
/** Phased customs workflow files — enables the Uploaded documents tab. */
workflowFiles?: Freight.ClearanceWorkflowFile[];
showWorkflowFilesTab?: boolean;
tradeDirection?: string;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}
function findMilestone(
milestones: Freight.IClearanceMilestone[] | undefined,
code: string,
): Freight.IClearanceMilestone | undefined {
return milestones?.find((m) => m.milestoneCode === code);
}
/**
* Document Clearance detail layout: primary clearance workflow plus optional
* uploaded documents, post-booking risk assignment, and incident reporting tabs.
*/
export function ClearanceOpsTabs({
bookingId,
milestones,
showOpsTabs = true,
clearanceTab,
workflowFiles = [],
showWorkflowFilesTab = false,
tradeDirection = "IMPORT",
onViewFile,
onDownloadFile,
}: ClearanceOpsTabsProps) {
const riskMs = findMilestone(milestones, "RISK_ASSIGNED");
const hasOps = Boolean(bookingId);
const isExport = tradeDirection === "EXPORT";
const uploadedDocCount = workflowFiles.filter((f) => {
if (!f.file) return false;
if (isExport) return f.category !== "duty";
return true;
}).length;
const showDocuments = showWorkflowFilesTab && Boolean(onViewFile);
const hasTabs = (showOpsTabs && hasOps) || showDocuments;
if (!hasTabs) {
return <>{clearanceTab}</>;
}
return (
<Tabs defaultValue="clearance" keepMounted={false}>
<Tabs.List mb="md">
<Tabs.Tab value="clearance">Clearance</Tabs.Tab>
{showDocuments ? (
<Tabs.Tab
value="documents"
leftSection={<FileText size={14} />}
rightSection={
uploadedDocCount > 0 ? (
<Badge size="xs" variant="light" color="edr-green" circle>
{uploadedDocCount}
</Badge>
) : undefined
}
>
Uploaded documents
</Tabs.Tab>
) : null}
{showOpsTabs && riskMs ? (
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
Risk assignment
</Tabs.Tab>
) : null}
{showOpsTabs && bookingId ? (
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
Incidents
</Tabs.Tab>
) : null}
</Tabs.List>
<Tabs.Panel value="clearance">{clearanceTab}</Tabs.Panel>
{showDocuments ? (
<Tabs.Panel value="documents">
<ClearanceUploadedDocumentsPanel
files={workflowFiles}
tradeDirection={tradeDirection}
onView={onViewFile!}
onDownload={onDownloadFile}
/>
</Tabs.Panel>
) : null}
{showOpsTabs && riskMs && bookingId ? (
<Tabs.Panel value="risk">
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
</SectionCard>
</Tabs.Panel>
) : null}
{showOpsTabs && bookingId ? (
<Tabs.Panel value="incidents">
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">
<Stack gap="sm">
<Text size="sm" c="dimmed">
Log container or seal issues discovered during clearance handling.
</Text>
<IncidentReportCard bookingId={bookingId} />
</Stack>
</SectionCard>
</Tabs.Panel>
) : null}
</Tabs>
);
}

View File

@@ -0,0 +1,112 @@
import { Check } from "lucide-react";
import { Box, Group, Stack, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
const BRAND_GREEN = "var(--freight-brand, #0A6F4D)";
const IMPORT_PHASES = [
"CUSTOMER_INTAKE",
"GL_ET_REVIEW",
"GL_ET_OUTPUT",
"CUSTOMER_DUTY",
"GL_ET_POST_CLEARANCE",
"GL_DJ_COLLECTION",
] as const;
const PHASE_LABELS: Record<string, string> = {
CUSTOMER_INTAKE: "Customer docs",
GL_ET_REVIEW: "GL ET review",
GL_DJ_COLLECTION: "GL Djibouti DO",
GL_ET_OUTPUT: "Declaration",
CUSTOMER_DUTY: "Duty / customer pays",
GL_ET_POST_CLEARANCE: "Transit & finalize",
GL_DJ_LOADING: "Loading",
POST_TRANSIT: "Transit",
};
const EXPORT_PHASES = [
"CUSTOMER_INTAKE",
"GL_ET_REVIEW",
"GL_DJ_COLLECTION",
"GL_ET_OUTPUT",
"GL_ET_POST_CLEARANCE",
] as const;
function phaseIndex(phases: readonly string[], current?: string | null): number {
if (!current) return 0;
const idx = phases.indexOf(current);
return idx >= 0 ? idx : 0;
}
export function ClearancePhaseStepper({
clearance,
tradeDirection,
compact = false,
}: {
clearance?: Freight.ContractClearanceView | Freight.ClearanceView | null;
tradeDirection?: string;
compact?: boolean;
}) {
const phases = tradeDirection === "EXPORT" ? EXPORT_PHASES : IMPORT_PHASES;
const current = clearance?.phase ?? phases[0];
const activeIdx = phaseIndex(phases, current);
return (
<Group gap={0} wrap="nowrap" align="flex-start" style={{ overflowX: "auto" }}>
{phases.map((phase, index) => {
const isComplete = index < activeIdx;
const isActive = index === activeIdx;
const isLast = index === phases.length - 1;
return (
<Box key={phase} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: compact ? 72 : 88 }}>
<Group gap={0} wrap="nowrap" align="center">
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: compact ? 28 : 34,
height: compact ? 28 : 34,
borderRadius: "50%",
background: isComplete ? BRAND_GREEN : isActive ? "white" : "var(--mantine-color-gray-1)",
border: isActive
? `2px solid ${BRAND_GREEN}`
: isComplete
? "2px solid transparent"
: "2px solid var(--mantine-color-gray-3)",
color: isComplete ? "white" : isActive ? BRAND_GREEN : "var(--mantine-color-gray-5)",
}}
>
{isComplete ? <Check size={compact ? 14 : 16} strokeWidth={3} /> : null}
</Box>
<Text
size={compact ? "10px" : "xs"}
fw={isActive ? 600 : 500}
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
ta="center"
style={{ whiteSpace: "nowrap" }}
>
{PHASE_LABELS[phase] ?? phase}
</Text>
</Stack>
{!isLast && (
<Box
style={{
flex: 1,
height: 2,
marginInline: 6,
marginBottom: compact ? 16 : 20,
borderRadius: 2,
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
}}
/>
)}
</Group>
</Box>
);
})}
</Group>
);
}

View File

@@ -0,0 +1,205 @@
import { useMemo } from "react";
import { Badge, Box, Stack, Tabs, Text, ThemeIcon } from "@mantine/core";
import { FileText, Receipt, Ship, Truck } from "lucide-react";
import type { Freight } from "@edr/types";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { PhasedUploadedFileRow } from "@/components/contracts/PhasedUploadedFileRow";
type TabValue = Freight.ClearanceWorkflowFileCategory;
type TabConfig = {
value: TabValue;
label: string;
icon: typeof FileText;
emptyHint: string;
};
function tabConfigForTradeDirection(tradeDirection: string): TabConfig[] {
if (tradeDirection === "EXPORT") {
return [
{
value: "declaration",
label: "Declaration",
icon: FileText,
emptyHint: "No declaration uploaded yet.",
},
{
value: "djibouti",
label: "Release order",
icon: Ship,
emptyHint: "No release order uploaded yet.",
},
{
value: "transit",
label: "Transit Permit",
icon: Truck,
emptyHint: "No transit permit uploaded yet.",
},
];
}
return [
{
value: "declaration",
label: "Declaration",
icon: FileText,
emptyHint: "No declaration uploaded yet.",
},
{
value: "duty",
label: "Duty notice",
icon: Receipt,
emptyHint: "No duty notice or payment slip uploaded yet.",
},
{
value: "transit",
label: "Transit permit",
icon: Truck,
emptyHint: "No transit permit uploaded yet.",
},
];
}
function subtitleForTradeDirection(tradeDirection: string): string {
return tradeDirection === "EXPORT"
? "Declaration, release order, and transit permit files for this clearance."
: "Declaration, duty notice, and transit permit files for this clearance.";
}
function footerHintForTradeDirection(tradeDirection: string): string {
return tradeDirection === "EXPORT"
? "Files appear here once GL uploads the declaration and release order, and after booking when the transit permit is uploaded."
: "Files appear here once GL Ethiopia uploads declaration, duty notice, or transit permit documents.";
}
export interface ClearanceUploadedDocumentsPanelProps {
files: Freight.ClearanceWorkflowFile[];
tradeDirection?: string;
onView: (file: { name: string; url: string }) => void;
onDownload?: (file: { id: string; name: string }) => void;
}
export function ClearanceUploadedDocumentsPanel({
files,
tradeDirection = "IMPORT",
onView,
onDownload,
}: ClearanceUploadedDocumentsPanelProps) {
const tabConfig = useMemo(
() => tabConfigForTradeDirection(tradeDirection),
[tradeDirection],
);
const isExport = tradeDirection === "EXPORT";
const visibleFiles = useMemo(
() =>
isExport ? files.filter((f) => f.category !== "duty") : files,
[files, isExport],
);
const uploadedCount = visibleFiles.filter((f) => f.file).length;
const defaultTab =
tabConfig.find((tab) =>
visibleFiles.some((f) => f.category === tab.value && f.file),
)?.value ?? tabConfig[0]?.value ?? "declaration";
return (
<SectionCard
icon={FileText}
title="Uploaded customs documents"
subtitle={subtitleForTradeDirection(tradeDirection)}
accent="edr-green"
>
<Tabs defaultValue={defaultTab} keepMounted={false}>
<Tabs.List mb="md">
{tabConfig.map((tab) => {
const count = visibleFiles.filter(
(f) => f.category === tab.value && f.file,
).length;
const Icon = tab.icon;
return (
<Tabs.Tab
key={tab.value}
value={tab.value}
leftSection={<Icon size={14} />}
rightSection={
count > 0 ? (
<Badge size="xs" variant="light" color="edr-green" circle>
{count}
</Badge>
) : undefined
}
>
{tab.label}
</Tabs.Tab>
);
})}
</Tabs.List>
{tabConfig.map((tab) => {
const items = visibleFiles.filter(
(f) => f.category === tab.value && f.file,
);
const Icon = tab.icon;
return (
<Tabs.Panel key={tab.value} value={tab.value}>
{items.length > 0 ? (
<Stack gap="sm">
{items.map((item) => (
<PhasedUploadedFileRow
key={item.code}
label={item.label}
file={item.file!}
onView={onView}
onDownload={onDownload}
/>
))}
</Stack>
) : (
<EmptyTabState icon={Icon} hint={tab.emptyHint} />
)}
</Tabs.Panel>
);
})}
</Tabs>
{uploadedCount === 0 ? (
<Text size="xs" c="dimmed" mt="md">
{footerHintForTradeDirection(tradeDirection)}
</Text>
) : null}
</SectionCard>
);
}
function EmptyTabState({
icon: Icon,
hint,
}: {
icon: typeof FileText;
hint: string;
}) {
return (
<Box
py={40}
style={{
borderRadius: 12,
border: "1px dashed var(--mantine-color-gray-4)",
background: "var(--mantine-color-gray-0)",
textAlign: "center",
}}
>
<Stack gap={8} align="center">
<ThemeIcon variant="light" color="gray" radius="xl" size={44}>
<Icon size={20} />
</ThemeIcon>
<Text size="sm" c="dimmed" maw={320}>
{hint}
</Text>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,155 @@
import {
Badge,
Box,
Button,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { Download, Eye, FileText } from "lucide-react";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { fileViewUrl } from "@/constants/apiConfig";
const CATEGORY_LABELS: Record<
Freight.ClearanceWorkflowFileCategory,
string
> = {
declaration: "Declaration",
duty: "Duty & taxes",
transit: "Transit",
djibouti: "Djibouti",
};
const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [
"declaration",
"duty",
"transit",
"djibouti",
];
const OWNER_LABELS: Record<Freight.ClearanceWorkflowFileOwner, string> = {
customer: "Customer",
gl_et: "GL Ethiopia",
gl_dj: "GL Djibouti",
};
export interface ClearanceWorkflowFilesPanelProps {
files: Freight.ClearanceWorkflowFile[];
onView: (file: { name: string; url: string }) => void;
onDownload?: (file: { id: string; name: string }) => void;
title?: string;
}
export function ClearanceWorkflowFilesPanel({
files,
onView,
onDownload,
title = "Customs workflow documents",
}: ClearanceWorkflowFilesPanelProps) {
if (files.length === 0) return null;
const grouped = CATEGORY_ORDER.map((category) => ({
category,
label: CATEGORY_LABELS[category],
items: files.filter((f) => f.category === category),
})).filter((g) => g.items.length > 0);
return (
<SectionCard icon={FileText} title={title} accent="edr-green">
<Stack gap="md">
{grouped.map((group) => (
<Box key={group.category}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb={8}>
{group.label}
</Text>
<Stack gap={8}>
{group.items.map((item) => (
<WorkflowFileRow
key={item.code}
item={item}
onView={onView}
onDownload={onDownload}
/>
))}
</Stack>
</Box>
))}
</Stack>
</SectionCard>
);
}
function WorkflowFileRow({
item,
onView,
onDownload,
}: {
item: Freight.ClearanceWorkflowFile;
onView: (file: { name: string; url: string }) => void;
onDownload?: (file: { id: string; name: string }) => void;
}) {
const file = item.file;
if (!file) return null;
const viewUrl = fileViewUrl(file.id);
const canPreview = isViewable({ name: file.name, url: viewUrl });
return (
<Paper withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap" align="center">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={36}>
<FileText size={17} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{item.label}
</Text>
<Group gap={6} wrap="nowrap" mt={2}>
<Badge size="xs" variant="light" color="gray" radius="sm" tt="none">
{OWNER_LABELS[item.uploadedBy]}
</Badge>
<Text size="xs" c="dimmed" truncate>
{file.name}
</Text>
</Group>
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{canPreview ? (
<Tooltip label="Preview">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() => onView({ name: file.name, url: viewUrl })}
>
View
</Button>
</Tooltip>
) : null}
{onDownload ? (
<Tooltip label="Download">
<Button
size="compact-xs"
variant="light"
radius="md"
leftSection={<Download size={13} />}
onClick={() => onDownload({ id: file.id, name: file.name })}
>
Download
</Button>
</Tooltip>
) : null}
</Group>
</Group>
</Paper>
);
}

View File

@@ -1,6 +1,14 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Check, ShieldCheck } from "lucide-react";
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
import {
Stack,
Group,
Text,
Badge,
Button,
Box,
Modal,
} from "@mantine/core";
import type { Freight } from "@edr/types";
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
@@ -19,6 +27,10 @@ export function ContractApprovalStepsCard({
contract,
mutations,
}: ContractApprovalStepsCardProps) {
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] =
useState<Freight.IContractApprovalStep | null>(null);
const steps = useMemo(
() =>
[...(contract.approvalSteps ?? [])].sort(
@@ -30,6 +42,24 @@ export function ContractApprovalStepsCard({
const nextPending = steps.find((s) => s.status === "PENDING");
const summary = formatContractApprovalProgress(contract.status, steps);
const openApprove = (step: Freight.IContractApprovalStep) => {
setPendingStep(step);
setConfirmOpen(true);
};
const closeApprove = () => {
setConfirmOpen(false);
setPendingStep(null);
};
const runApprove = () => {
if (!pendingStep) return;
mutations.approveStep.mutate(
{ stepId: pendingStep.id, requiredRole: pendingStep.requiredRole },
{ onSuccess: () => closeApprove() },
);
};
const subtitle =
summary.detail ||
(nextPending
@@ -39,54 +69,87 @@ export function ContractApprovalStepsCard({
: "Accept submission to begin");
return (
<SectionCard
icon={ShieldCheck}
title="Approval chain"
extra={
<Badge color="edr-green" variant="light" radius="sm">
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
</Badge>
}
>
<Text size="xs" c="dimmed" mb="sm">
{subtitle}
</Text>
{steps.length === 0 ? (
<Text
size="sm"
c="dimmed"
ta="center"
py="lg"
px="md"
style={{
borderRadius: 8,
border: "1px dashed var(--mantine-color-gray-3)",
background: "var(--mantine-color-gray-0)",
}}
>
Use <strong>Accept for approval</strong> in staff actions to
instantiate steps.
<>
<SectionCard
icon={ShieldCheck}
title="Approval chain"
extra={
<Badge color="edr-green" variant="light" radius="sm">
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
</Badge>
}
>
<Text size="xs" c="dimmed" mb="sm">
{subtitle}
</Text>
) : (
<Stack gap="xs">
{steps.map((step) => (
<StepRow
key={step.id}
step={step}
isNext={nextPending?.id === step.id}
isPending={mutations.approveStep.isPending}
onApprove={() =>
mutations.approveStep.mutate({
stepId: step.id,
requiredRole: step.requiredRole,
})
}
/>
))}
{steps.length === 0 ? (
<Text
size="sm"
c="dimmed"
ta="center"
py="lg"
px="md"
style={{
borderRadius: 8,
border: "1px dashed var(--mantine-color-gray-3)",
background: "var(--mantine-color-gray-0)",
}}
>
Use <strong>Accept for approval</strong> in staff actions to
instantiate steps.
</Text>
) : (
<Stack gap="xs">
{steps.map((step) => (
<StepRow
key={step.id}
step={step}
isNext={nextPending?.id === step.id}
isPending={mutations.approveStep.isPending}
onApprove={() => openApprove(step)}
/>
))}
</Stack>
)}
</SectionCard>
<Modal
opened={confirmOpen}
onClose={closeApprove}
title="Approve this step?"
radius="md"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
You are about to approve the{" "}
<Text span fw={600} c="dark">
{pendingStep?.requiredRole}
</Text>{" "}
step for contract{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>
. This action cannot be undone from this screen.
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={closeApprove}>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Check size={16} />}
loading={mutations.approveStep.isPending}
onClick={runApprove}
>
Confirm approval
</Button>
</Group>
</Stack>
)}
</SectionCard>
</Modal>
</>
);
}

View File

@@ -54,6 +54,21 @@ export interface ContractClearanceReviewSectionProps {
* by whom, when) but hide all approve / query / finalize actions.
*/
readOnly?: boolean;
/**
* Document approvals are locked (e.g. after all docs approved in phased flow)
* but queries remain available until {@link queriesLocked} or {@link readOnly}.
*/
approvalsLocked?: boolean;
/**
* Pre-clearance finalized — block opening new queries on customer documents.
*/
queriesLocked?: boolean;
/**
* ONE_TIME customs contracts use the phased milestone workflow. Hides the
* legacy "Finalize clearance" shortcut; booking readiness follows delivery
* order (import) or export release.
*/
phasedCustoms?: boolean;
}
const STATUS_META: Record<
@@ -89,6 +104,9 @@ export function ContractClearanceReviewSection({
hideSummary,
selfClear = false,
readOnly = false,
phasedCustoms = false,
approvalsLocked = false,
queriesLocked = false,
}: ContractClearanceReviewSectionProps) {
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
@@ -137,6 +155,11 @@ export function ContractClearanceReviewSection({
.filter((d) => d.file && d.reviewStatus !== "APPROVED")
.map((d) => d.fileKey);
const hasDocsAwaitingApproval = customerDocs.some(
(d) => d.file && d.reviewStatus !== "APPROVED",
);
const effectiveApprovalsLocked = approvalsLocked && !hasDocsAwaitingApproval;
if (isLoading || !clearance) {
return (
<Group justify="center" py="xl" gap={10}>
@@ -170,14 +193,16 @@ export function ContractClearanceReviewSection({
subtitle={
readOnly
? `Reviewed by the ${reviewerTeam} team.`
: "Approve each document, or open a query to tell the customer what to fix."
: effectiveApprovalsLocked
? "Documents are approved — you can still open a query if something needs fixing."
: "Approve each document, or open a query to tell the customer what to fix."
}
extra={
<Group gap={10} wrap="nowrap" align="center">
<Text size="xs" c="dimmed" fw={600}>
{stats.approved}/{stats.total} approved
</Text>
{!readOnly && approvableKeys.length > 0 && (
{!readOnly && !effectiveApprovalsLocked && approvableKeys.length > 0 && (
<Button
size="compact-sm"
color="edr-green"
@@ -221,6 +246,8 @@ export function ContractClearanceReviewSection({
doc={doc}
reviewerTeam={reviewerTeam}
readOnly={readOnly}
approvalsLocked={effectiveApprovalsLocked}
queriesLocked={queriesLocked}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
@@ -241,7 +268,7 @@ export function ContractClearanceReviewSection({
</Stack>
</SectionCard>
{glDocs.length > 0 && (
{glDocs.length > 0 && !phasedCustoms && (
<SectionCard
icon={Upload}
title="GL output documents"
@@ -372,8 +399,42 @@ export function ContractClearanceReviewSection({
<CheckCircle2 size={15} />
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
Clearance was finalized by the {reviewerTeam} team. This is a
read-only record of the approved documents.
{phasedCustoms
? "Document review is complete. Continue customs milestones in the action panel."
: `Clearance was finalized by the ${reviewerTeam} team. This is a read-only record of the approved documents.`}
</Text>
</Group>
</Paper>
) : phasedCustoms && effectiveApprovalsLocked ? (
<Paper withBorder radius="md" p="md">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={28}>
<CheckCircle2 size={15} />
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
Document review is complete. Use the action panel for declaration, duty, and
transit steps
{queriesLocked
? ". Pre-clearance is finalized — customer documents can no longer be queried."
: " — or open a query above if a customer document needs correction."}
</Text>
</Group>
</Paper>
) : phasedCustoms ? (
<Paper withBorder radius="md" p="md">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={clearance.allApproved ? "edr-green" : "gray"}
radius="md"
size={28}
>
<FileCheck2 size={15} />
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
{clearance.allApproved
? "All required documents are approved. Continue declaration, duty, and transit in the action panel."
: "Approve every required document to unlock the customs milestone steps."}
</Text>
</Group>
</Paper>
@@ -450,6 +511,8 @@ function DocReviewCard({
doc,
reviewerTeam,
readOnly,
approvalsLocked,
queriesLocked,
note,
queryOpen,
onToggleQuery,
@@ -462,6 +525,8 @@ function DocReviewCard({
doc: Freight.ContractClearanceDocument;
reviewerTeam: string;
readOnly: boolean;
approvalsLocked: boolean;
queriesLocked: boolean;
note: string;
queryOpen: boolean;
onToggleQuery: (open: boolean) => void;
@@ -583,31 +648,35 @@ function DocReviewCard({
</Alert>
)}
{!readOnly && hasFile && !isApproved && (
{!readOnly && hasFile && (
<Box mt="sm">
{!queryOpen ? (
<Group justify="flex-end" gap={8}>
<Button
size="sm"
variant="light"
color="red"
radius="md"
leftSection={<MessageSquareWarning size={15} />}
disabled={busy}
onClick={() => onToggleQuery(true)}
>
Open query
</Button>
<Button
size="sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={15} />}
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
{!queriesLocked && (
<Button
size="sm"
variant="light"
color="red"
radius="md"
leftSection={<MessageSquareWarning size={15} />}
disabled={busy}
onClick={() => onToggleQuery(true)}
>
Open query
</Button>
)}
{!isApproved && !approvalsLocked && (
<Button
size="sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={15} />}
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
)}
</Group>
) : (
<Box

View File

@@ -0,0 +1,43 @@
import { Button, Group, Modal, Stack, Text, ThemeIcon } from "@mantine/core";
import { CheckCircle2 } from "lucide-react";
export interface ContractSignSuccessModalProps {
opened: boolean;
onClose: () => void;
reference: string;
message?: string;
confirmLabel?: string;
}
export function ContractSignSuccessModal({
opened,
onClose,
reference,
message = "The contract has been signed and recorded.",
confirmLabel = "Back to contract request",
}: ContractSignSuccessModalProps) {
return (
<Modal
opened={opened}
onClose={onClose}
title="Contract signed successfully"
centered
radius="lg"
>
<Stack gap="md" align="center" ta="center">
<ThemeIcon size={56} radius="xl" color="edr-green" variant="light">
<CheckCircle2 size={28} />
</ThemeIcon>
<Text fw={600}>{reference}</Text>
<Text size="sm" c="dimmed">
{message}
</Text>
<Group justify="center" mt="xs">
<Button color="edr-green" onClick={onClose}>
{confirmLabel}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,155 @@
import { useState } from "react";
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { Ship, Upload } from "lucide-react";
import toast from "react-hot-toast";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
import { contractsService } from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
export type GlClearanceUploadKind = "do" | "ro";
export interface GlClearanceUploadModalProps {
opened: boolean;
kind: GlClearanceUploadKind | null;
onClose: () => void;
entityId: string;
isBooking: boolean;
workflowFiles?: Freight.ClearanceWorkflowFile[];
vesselDepartureDate?: string | null;
onSuccess?: () => void;
onPreview?: (file: { name: string; url: string }) => void;
}
export function GlClearanceUploadModal({
opened,
kind,
onClose,
entityId,
isBooking,
workflowFiles = [],
vesselDepartureDate,
onSuccess,
onPreview,
}: GlClearanceUploadModalProps) {
const [file, setFile] = useState<File | null>(null);
const [vesselDate, setVesselDate] = useState<Date | null>(
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
);
const [loading, setLoading] = useState(false);
const isDo = kind === "do";
const isRo = kind === "ro";
const replaceMode = isDo
? Boolean(findWorkflowFile(workflowFiles, "delivery_order"))
: Boolean(findWorkflowFile(workflowFiles, "release_order"));
const close = () => {
setFile(null);
onClose();
};
const submit = async () => {
if (!file || !kind) return;
if (isRo && !vesselDate) {
toast.error("Vessel departure date is required.");
return;
}
setLoading(true);
try {
if (isDo) {
if (isBooking) {
await bookingsService.uploadDeliveryOrder(entityId, file);
} else {
await contractsService.uploadDeliveryOrder(entityId, file);
}
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
} else {
const iso = vesselDate!.toISOString().slice(0, 10);
const result = isBooking
? await bookingsService.uploadReleaseOrder(entityId, file, iso)
: await contractsService.uploadReleaseOrder(entityId, file, iso);
if (result.hold) {
toast.error(result.holdReason ?? "Vessel date too soon");
} else {
toast.success(replaceMode ? "Release Order updated" : "Release Order uploaded");
}
}
setFile(null);
onSuccess?.();
close();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
};
return (
<Modal
opened={opened && kind != null}
onClose={close}
title={
<Group gap={8}>
<Ship size={18} />
<Text fw={700}>{isDo ? "Upload Delivery Order" : "Upload Release Order"}</Text>
</Group>
}
radius="md"
size="md"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{isDo
? "Upload the Djibouti Delivery Order (DO) for this import shipment."
: "Upload the Release Order and confirm the vessel departure date."}
</Text>
{isRo ? (
<DateInput
label="Vessel departure date"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
size="sm"
required
/>
) : null}
<PhasedFileDropzone
label={isDo ? "Delivery Order file" : "Release Order file"}
description={isDo ? "Any file type." : "PDF or image."}
accept={isDo ? "*/*" : undefined}
value={file}
onChange={setFile}
replaceMode={replaceMode}
onPreview={onPreview}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={close} disabled={loading}>
Cancel
</Button>
<Button
color="edr-green"
loading={loading}
disabled={!file || (isRo && !vesselDate)}
leftSection={<Upload size={16} />}
onClick={() => void submit()}
>
{replaceMode
? isDo
? "Replace DO"
: "Replace RO"
: isDo
? "Upload DO"
: "Upload RO"}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,122 @@
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { FileText, Upload } from "lucide-react";
import type { Freight } from "@edr/types";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { PhasedUploadedFileRow, findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
export interface PhasedDocumentUploadFieldProps {
fields: Array<{ key: string; label: string }>;
files: Record<string, File | null>;
onChange: (key: string, file: File | null) => void;
workflowFiles?: Freight.ClearanceWorkflowFile[];
helperText: string;
replaceMode?: boolean;
loading?: boolean;
disabled?: boolean;
submitLabel?: string;
onSubmit: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}
/** Consistent phased customs document upload with drag-and-drop, preview, and uploaded rows. */
export function PhasedDocumentUploadField({
fields,
files,
onChange,
workflowFiles = [],
helperText,
replaceMode = false,
loading = false,
disabled = false,
submitLabel,
onSubmit,
onViewFile,
onDownloadFile,
}: PhasedDocumentUploadFieldProps) {
const hasStaged = Object.values(files).some(Boolean);
const uploaded = fields
.map((f) => ({ ...f, file: findWorkflowFile(workflowFiles, f.key) }))
.filter((f) => f.file);
const multiField = fields.length > 1;
return (
<Stack gap="md">
{uploaded.length > 0 ? (
<Stack gap={8}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
Current file{uploaded.length > 1 ? "s" : ""}
</Text>
{uploaded.map((row) => (
<PhasedUploadedFileRow
key={row.key}
label={row.label}
file={row.file!}
onView={onViewFile}
onDownload={onDownloadFile}
/>
))}
</Stack>
) : null}
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
<Group gap={8} mb="sm" wrap="nowrap">
<Box
c="edr-green"
style={{
width: 32,
height: 32,
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--mantine-color-edr-green-1)",
}}
>
<FileText size={16} />
</Box>
<Box>
<Text size="sm" fw={700}>
{replaceMode ? "Replace document" : "Upload document"}
</Text>
<Text size="xs" c="dimmed">
{helperText}
</Text>
</Box>
</Group>
<Stack gap="md">
{fields.map((f) => (
<PhasedFileDropzone
key={f.key}
label={multiField ? f.label : "Choose file"}
description={
multiField
? uploaded.some((u) => u.key === f.key)
? "Drop a new file to replace the current one."
: `Upload ${f.label} (optional if another declaration type is provided).`
: undefined
}
value={files[f.key] ?? null}
onChange={(file) => onChange(f.key, file)}
replaceMode={replaceMode || uploaded.some((u) => u.key === f.key)}
onPreview={onViewFile}
/>
))}
</Stack>
</Paper>
<Button
color="edr-green"
loading={loading}
disabled={disabled || !hasStaged}
leftSection={<Upload size={16} />}
onClick={onSubmit}
fullWidth
>
{submitLabel ?? (replaceMode ? "Replace document" : "Upload document")}
</Button>
</Stack>
);
}

View File

@@ -0,0 +1,403 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
ActionIcon,
Box,
Button,
Group,
Stack,
Text,
ThemeIcon,
UnstyledButton,
} from "@mantine/core";
import { Eye, FileText, Trash2, UploadCloud } from "lucide-react";
import { isViewable } from "@edr/ui-common";
export interface PhasedFileDropzoneProps {
label: string;
description?: string;
value: File | null;
onChange: (file: File | null) => void;
accept?: string;
replaceMode?: boolean;
onPreview?: (file: { name: string; url: string; mimeType?: string | null }) => void;
disabled?: boolean;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
}
function isImageFile(file: File): boolean {
if (file.type.startsWith("image/")) return true;
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
return ["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg"].includes(ext);
}
export function PhasedFileDropzone({
label,
description,
value,
onChange,
accept = "application/pdf,image/*",
replaceMode = false,
onPreview,
disabled = false,
}: PhasedFileDropzoneProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [dragOver, setDragOver] = useState(false);
const previewUrl = useMemo(
() => (value ? URL.createObjectURL(value) : null),
[value],
);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
const pickFile = (file: File | null) => {
if (disabled) return;
onChange(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
if (disabled) return;
const file = e.dataTransfer.files[0];
if (file) pickFile(file);
};
if (value && previewUrl) {
const canPreview = onPreview && isViewable({ name: value.name, url: previewUrl, mimeType: value.type });
const image = isImageFile(value);
return (
<Stack gap={6}>
<Text size="sm" fw={600}>
{label}
</Text>
<Box
p="sm"
style={{
borderRadius: 12,
border: "1px solid var(--mantine-color-edr-green-4)",
background:
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #fff 70%)",
}}
>
<Group justify="space-between" wrap="nowrap" align="center">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
{image ? (
<UnstyledButton
onClick={() =>
canPreview && onPreview?.({ name: value.name, url: previewUrl, mimeType: value.type })
}
style={{
width: 52,
height: 52,
flexShrink: 0,
borderRadius: 10,
overflow: "hidden",
border: "1px solid var(--mantine-color-gray-3)",
cursor: canPreview ? "pointer" : "default",
}}
>
<img
src={previewUrl}
alt=""
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</UnstyledButton>
) : (
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
<FileText size={22} />
</ThemeIcon>
)}
<Box style={{ minWidth: 0 }}>
<Text size="xs" fw={700} c="edr-green" tt="uppercase">
Ready to upload
</Text>
<Text size="sm" fw={600} truncate>
{value.name}
</Text>
<Text size="xs" c="dimmed">
{formatBytes(value.size)}
</Text>
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{canPreview ? (
<Button
size="compact-xs"
variant="default"
leftSection={<Eye size={13} />}
onClick={() =>
onPreview({ name: value.name, url: previewUrl, mimeType: value.type })
}
>
Preview
</Button>
) : null}
<ActionIcon
variant="subtle"
color="red"
radius="md"
aria-label="Remove file"
onClick={() => pickFile(null)}
disabled={disabled}
>
<Trash2 size={15} />
</ActionIcon>
</Group>
</Group>
</Box>
</Stack>
);
}
return (
<Stack gap={6}>
<Text size="sm" fw={600}>
{label}
</Text>
{description ? (
<Text size="xs" c="dimmed">
{description}
</Text>
) : null}
<Box
onDragOver={(e) => {
e.preventDefault();
if (!disabled) setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => !disabled && inputRef.current?.click()}
style={{
borderRadius: 12,
border: `2px dashed ${
dragOver
? "var(--mantine-color-edr-green-5)"
: "var(--mantine-color-gray-4)"
}`,
background: dragOver
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-gray-0)",
padding: "28px 20px",
textAlign: "center",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
transition: "border-color 120ms ease, background 120ms ease",
}}
>
<input
ref={inputRef}
type="file"
accept={accept}
hidden
disabled={disabled}
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
/>
<Stack gap={8} align="center">
<ThemeIcon
variant="light"
color={dragOver ? "edr-green" : "gray"}
radius="xl"
size={48}
>
<UploadCloud size={24} />
</ThemeIcon>
<Box>
<Text size="sm" fw={600}>
{dragOver
? "Drop to upload"
: replaceMode
? "Drag & drop to replace"
: "Drag & drop your file here"}
</Text>
<Text size="xs" c="dimmed" mt={4}>
or <span style={{ color: "var(--mantine-color-edr-green-7)", fontWeight: 600 }}>browse</span>{" "}
PDF or image
</Text>
</Box>
</Stack>
</Box>
</Stack>
);
}
export interface PhasedMultiFileDropzoneProps {
label: string;
description?: string;
value: File[];
onChange: (files: File[]) => void;
accept?: string;
replaceMode?: boolean;
disabled?: boolean;
}
export function PhasedMultiFileDropzone({
label,
description,
value,
onChange,
accept = "application/pdf,image/*",
replaceMode = false,
disabled = false,
}: PhasedMultiFileDropzoneProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [dragOver, setDragOver] = useState(false);
const addFiles = (incoming: FileList | File[]) => {
if (disabled) return;
const next = [...value];
for (const file of Array.from(incoming)) {
if (!next.some((f) => f.name === file.name && f.size === file.size)) {
next.push(file);
}
}
onChange(next);
};
const removeAt = (index: number) => {
if (disabled) return;
onChange(value.filter((_, i) => i !== index));
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files);
};
return (
<Stack gap={6}>
<Text size="sm" fw={600}>
{label}
</Text>
{description ? (
<Text size="xs" c="dimmed">
{description}
</Text>
) : null}
{value.length > 0 ? (
<Stack gap={8}>
{value.map((file, index) => (
<Box
key={`${file.name}-${file.size}-${index}`}
p="sm"
style={{
borderRadius: 12,
border: "1px solid var(--mantine-color-edr-green-4)",
background:
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #fff 70%)",
}}
>
<Group justify="space-between" wrap="nowrap" align="center">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={44}>
<FileText size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text size="xs" fw={700} c="edr-green" tt="uppercase">
Ready to upload
</Text>
<Text size="sm" fw={600} truncate>
{file.name}
</Text>
<Text size="xs" c="dimmed">
{formatBytes(file.size)}
</Text>
</Box>
</Group>
<ActionIcon
variant="subtle"
color="red"
radius="md"
aria-label="Remove file"
onClick={() => removeAt(index)}
disabled={disabled}
>
<Trash2 size={15} />
</ActionIcon>
</Group>
</Box>
))}
</Stack>
) : null}
<Box
onDragOver={(e) => {
e.preventDefault();
if (!disabled) setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => !disabled && inputRef.current?.click()}
style={{
borderRadius: 12,
border: `2px dashed ${
dragOver
? "var(--mantine-color-edr-green-5)"
: "var(--mantine-color-gray-4)"
}`,
background: dragOver
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-gray-0)",
padding: "28px 20px",
textAlign: "center",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
transition: "border-color 120ms ease, background 120ms ease",
}}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple
hidden
disabled={disabled}
onChange={(e) => {
if (e.target.files?.length) addFiles(e.target.files);
e.target.value = "";
}}
/>
<Stack gap={8} align="center">
<ThemeIcon
variant="light"
color={dragOver ? "edr-green" : "gray"}
radius="xl"
size={48}
>
<UploadCloud size={24} />
</ThemeIcon>
<Box>
<Text size="sm" fw={600}>
{dragOver
? "Drop to add files"
: replaceMode
? "Drag & drop to replace declaration files"
: "Drag & drop declaration files here"}
</Text>
<Text size="xs" c="dimmed" mt={4}>
or{" "}
<span style={{ color: "var(--mantine-color-edr-green-7)", fontWeight: 600 }}>
browse
</span>{" "}
select one or more PDF or image files
</Text>
</Box>
</Stack>
</Box>
</Stack>
);
}

View File

@@ -0,0 +1,94 @@
import { Badge, Box, Button, Group, Paper, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { Download, Eye, FileText } from "lucide-react";
import { isViewable } from "@edr/ui-common";
import { fileViewUrl } from "@/constants/apiConfig";
export interface PhasedUploadedFileRowProps {
label: string;
file: { id: string; name: string };
onView?: (file: { name: string; url: string }) => void;
onDownload?: (file: { id: string; name: string }) => void;
compact?: boolean;
}
/** Inline preview row for a phased customs upload (declaration, transit permit, DO, etc.). */
export function PhasedUploadedFileRow({
label,
file,
onView,
onDownload,
compact = false,
}: PhasedUploadedFileRowProps) {
const viewUrl = fileViewUrl(file.id);
const canPreview = isViewable({ name: file.name, url: viewUrl });
return (
<Paper
withBorder
radius="md"
p={compact ? "xs" : "sm"}
style={{
borderColor: "var(--mantine-color-edr-green-3)",
background:
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #FFFFFF 75%)",
}}
>
<Group justify="space-between" wrap="nowrap" align="center">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={compact ? 32 : 36}>
<FileText size={compact ? 15 : 17} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{label}
</Text>
<Group gap={6} wrap="nowrap" mt={2}>
<Badge size="xs" variant="light" color="edr-green" radius="sm">
Uploaded
</Badge>
<Text size="xs" c="dimmed" truncate>
{file.name}
</Text>
</Group>
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{canPreview && onView ? (
<Tooltip label="Preview">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() => onView({ name: file.name, url: viewUrl })}
>
View
</Button>
</Tooltip>
) : null}
{onDownload ? (
<Tooltip label="Download">
<Button
size="compact-xs"
variant="light"
radius="md"
leftSection={<Download size={13} />}
onClick={() => onDownload({ id: file.id, name: file.name })}
>
Download
</Button>
</Tooltip>
) : null}
</Group>
</Group>
</Paper>
);
}
export function findWorkflowFile(
files: Array<{ code: string; file: { id: string; name: string } | null }> | undefined,
code: string,
): { id: string; name: string } | null {
return files?.find((f) => f.code === code)?.file ?? null;
}

View File

@@ -0,0 +1,106 @@
import { useState } from "react";
import { Button, Paper, Stack, Text } from "@mantine/core";
import { Upload } from "lucide-react";
import { PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { PhasedUploadedFileRow } from "@/components/contracts/PhasedUploadedFileRow";
export type TransitPermitUploadedRow = {
code: string;
label: string;
file: { id: string; name: string };
};
export interface TransitPermitMultiUploadProps {
title?: string;
uploaded?: TransitPermitUploadedRow[];
replaceMode?: boolean;
submitLabel?: string;
fileFieldPrefix: string;
disabled?: boolean;
onSubmit: (files: Record<string, File>) => Promise<void>;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}
/** Multi-file transit permit upload — import pre-booking and export post-booking. */
export function TransitPermitMultiUpload({
title = "Transit Permit",
uploaded = [],
replaceMode = false,
submitLabel,
fileFieldPrefix,
disabled = false,
onSubmit,
onViewFile,
onDownloadFile,
}: TransitPermitMultiUploadProps) {
const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const label = submitLabel ?? (replaceMode ? "Replace transit permit" : "Upload transit permit");
return (
<Stack gap="md">
<Text size="sm" fw={700}>
{title}
</Text>
{uploaded.length > 0 ? (
<Stack gap={8}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
Current file{uploaded.length > 1 ? "s" : ""}
</Text>
{uploaded.map((row) => (
<PhasedUploadedFileRow
key={row.code}
label={row.label}
file={row.file}
onView={onViewFile}
onDownload={onDownloadFile}
/>
))}
</Stack>
) : null}
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
<PhasedMultiFileDropzone
label="Transit permit documents"
description={
replaceMode
? "Replace transit permit files — upload one or more documents (PDF or image)."
: "Upload one or more transit permit documents (PDF or image)."
}
value={files}
onChange={setFiles}
replaceMode={replaceMode}
disabled={disabled || loading}
/>
</Paper>
<Button
color="edr-green"
loading={loading}
disabled={disabled || files.length === 0}
leftSection={<Upload size={16} />}
fullWidth
onClick={async () => {
setLoading(true);
try {
const payload = Object.fromEntries(
files.map((file, index) => [`${fileFieldPrefix}_${index}`, file]),
) as Record<string, File>;
await onSubmit(payload);
setFiles([]);
} catch {
// Caller shows toast for upload errors.
} finally {
setLoading(false);
}
}}
>
{label}
</Button>
</Stack>
);
}

View File

@@ -0,0 +1,322 @@
import type { LucideIcon } from "lucide-react";
import {
Building2,
Download,
Eye,
FileCheck,
FileText,
Globe,
Hash,
Mail,
MapPin,
Phone,
ShieldCheck,
User,
} from "lucide-react";
import {
ActionIcon,
Badge,
Divider,
Group,
Loader,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import type { Freight } from "@edr/types";
import { clearanceWorkflowFileLabel } from "@edr/types";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
import { customersService } from "@/services/customers.service";
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
// ── shared bits ──────────────────────────────────────────────────────────────
interface InfoRowProps {
icon: LucideIcon;
label: string;
value?: string | null;
}
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
return (
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
<Group gap="xs" wrap="nowrap">
<Icon size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
{value || "—"}
</Text>
</Group>
);
}
function InfoRows({ rows }: { rows: InfoRowProps[] }) {
const visible = rows.filter((r) => r.value);
if (visible.length === 0) {
return (
<Text size="sm" c="dimmed">
No details available.
</Text>
);
}
return (
<Stack gap={0}>
{visible.map((row, i) => (
<div key={row.label}>
{i > 0 && <Divider color="var(--mantine-color-gray-2)" />}
<InfoRow {...row} />
</div>
))}
</Stack>
);
}
// ── Customer tab ─────────────────────────────────────────────────────────────
/**
* Customer info for the contract. The contract detail payload only carries a
* `companyId`, so we fetch the full company record to surface contact + manager
* details (mirrors the booking-request customer card).
*/
export function ContractCustomerCard({
contract,
}: {
contract: Freight.IContract;
}) {
const companyId = contract.companyId ?? undefined;
const { data: company, isLoading } = useQuery({
queryKey: ["companies", "byId", companyId],
queryFn: () => customersService.getById(companyId!),
enabled: Boolean(companyId) && !contract.isGovernment,
});
// Government contracts carry an institution name instead of a company.
if (contract.isGovernment) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<InfoRows
rows={[
{
icon: Building2,
label: "Government",
value: contract.governmentInstitution ?? "Government",
},
]}
/>
</SectionCard>
);
}
if (isLoading) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<Group gap="sm" py="sm">
<Loader size="sm" color="gray" />
<Text size="sm" c="dimmed">
Loading customer
</Text>
</Group>
</SectionCard>
);
}
if (!company) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<Text size="sm" c="dimmed">
No customer linked to this contract.
</Text>
</SectionCard>
);
}
return (
<Stack gap="lg">
<SectionCard
icon={Building2}
title="Customer"
subtitle={company.name}
accent="blue"
>
<InfoRows
rows={[
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Hash, label: "VAT number", value: company.vatNumber },
{ icon: ShieldCheck, label: "FAN number", value: company.fanNumber },
{ icon: Globe, label: "Country", value: company.country },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: Globe, label: "Website", value: company.website },
]}
/>
</SectionCard>
<SectionCard icon={User} title="Contact person" accent="teal">
<InfoRows
rows={[
{ icon: User, label: "Name", value: company.contactPersonName },
{ icon: Phone, label: "Phone", value: company.contactPersonPhone },
]}
/>
</SectionCard>
<SectionCard icon={User} title="General manager" accent="grape">
<InfoRows
rows={[
{ icon: User, label: "Name", value: company.generalManagerName },
{ icon: Mail, label: "Email", value: company.generalManagerEmail },
{ icon: Phone, label: "Phone", value: company.generalManagerPhone },
]}
/>
</SectionCard>
</Stack>
);
}
// ── Documents tab ────────────────────────────────────────────────────────────
function formatBytes(bytes?: number | null): string {
if (!bytes || bytes <= 0) return "—";
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
}
/** Human label for a file's `code` (e.g. "business_license" → "Business license"). */
function codeLabel(code?: string | null): string | null {
if (!code) return null;
return (
clearanceWorkflowFileLabel(code) ??
code.replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
);
}
export interface ContractDocumentsCardProps {
files: ContractFile[];
/** Open the file inline in a viewer modal. */
onView?: (file: ContractFile) => void;
/** Download the file to disk. */
onDownload?: (file: ContractFile) => void;
}
/** Rich list of the contract's attached documents: type, size, view + download. */
export function ContractDocumentsCard({
files,
onView,
onDownload,
}: ContractDocumentsCardProps) {
return (
<SectionCard
icon={FileText}
title="Documents"
accent="indigo"
extra={
<Badge color="gray" variant="light" radius="sm">
{files.length}
</Badge>
}
>
{files.length === 0 ? (
<Text size="sm" c="dimmed">
No documents attached to this contract.
</Text>
) : (
<Stack gap="xs">
{files.map((file) => {
const label = codeLabel(file.code);
return (
<Group
key={file.id}
justify="space-between"
wrap="nowrap"
p="xs"
style={detailStyles.fileRow}
onMouseEnter={(e) => {
e.currentTarget.style.background =
"var(--mantine-color-gray-0)";
e.currentTarget.style.borderColor =
"var(--freight-brand-border)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
e.currentTarget.style.borderColor =
"var(--mantine-color-gray-2)";
}}
>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={36} radius="md" variant="light" color="indigo">
<FileText size={17} />
</ThemeIcon>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{file.name}
</Text>
<Group gap={6} wrap="nowrap">
{label ? (
<Badge
variant="light"
color="gray"
radius="sm"
size="xs"
tt="none"
>
{label}
</Badge>
) : null}
<Text size="xs" c="dimmed">
{formatBytes(file.size)}
</Text>
</Group>
</Stack>
</Group>
<Group gap={4} wrap="nowrap">
{onView ? (
<Tooltip label="View" withArrow>
<ActionIcon
variant="subtle"
color="indigo"
radius="md"
onClick={() => onView(file)}
aria-label={`View ${file.name}`}
>
<Eye size={16} />
</ActionIcon>
</Tooltip>
) : null}
{onDownload ? (
<Tooltip label="Download" withArrow>
<ActionIcon
variant="subtle"
color="gray"
radius="md"
onClick={() => onDownload(file)}
aria-label={`Download ${file.name}`}
>
<Download size={16} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
</Group>
);
})}
</Stack>
)}
</SectionCard>
);
}

View File

@@ -0,0 +1,275 @@
import type { LucideIcon } from "lucide-react";
import {
Building2,
FileCheck,
FileText,
Mail,
MapPin,
Package,
Phone,
Ship,
Truck,
User,
Warehouse,
} from "lucide-react";
import { Badge, Box, Divider, Group, Stack, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
type ReqContract = NonNullable<Freight.IBookingRequest["contract"]>;
interface InfoRowProps {
icon: LucideIcon;
label: string;
value?: string | null;
}
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
return (
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
<Group gap="xs" wrap="nowrap">
<Icon size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
{value || "—"}
</Text>
</Group>
);
}
function InfoRows({ rows }: { rows: InfoRowProps[] }) {
const visible = rows.filter((r) => r.value);
if (visible.length === 0) {
return (
<Text size="sm" c="dimmed">
No details available.
</Text>
);
}
return (
<Stack gap={0}>
{visible.map((row, i) => (
<div key={row.label}>
{i > 0 && <Divider color="var(--mantine-color-gray-2)" />}
<InfoRow {...row} />
</div>
))}
</Stack>
);
}
/** Customer (company) on the request's contract. */
export function RequestCustomerCard({ contract }: { contract?: ReqContract | null }) {
const company = contract?.company;
if (!company) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<Text size="sm" c="dimmed">
No customer linked to this request.
</Text>
</SectionCard>
);
}
return (
<SectionCard
icon={Building2}
title="Customer"
subtitle={company.name ?? undefined}
accent="blue"
>
<InfoRows
rows={[
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: User, label: "Contact", value: company.contactPersonName },
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
]}
/>
</SectionCard>
);
}
const fmtDate = (iso?: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(iso))
: "—";
const titleCase = (s?: string | null) =>
s ? s.charAt(0) + s.slice(1).toLowerCase() : "—";
/** Contract identity + commercial terms. */
export function RequestContractSummaryCard({
contract,
}: {
contract?: ReqContract | null;
}) {
if (!contract) return null;
return (
<SectionCard
icon={FileText}
title="Contract"
subtitle={contract.reference}
accent="grape"
>
<InfoRows
rows={[
{
icon: FileText,
label: "Kind",
value: contract.contractKind === "GENERAL" ? "General" : "One-time",
},
{
icon: Package,
label: "Cargo",
value: contract.freightType === "CONTAINER" ? "Container" : "Bulk",
},
{ icon: Ship, label: "Trade", value: titleCase(contract.tradeDirection) },
{ icon: FileCheck, label: "Currency", value: contract.paymentCurrency },
{
icon: FileCheck,
label: "Customs",
value: contract.customsClearingEnabled
? "Included (Global Logistics)"
: "Not included",
},
{
icon: FileText,
label: "Valid until",
value: contract.contractValidUntil
? fmtDate(contract.contractValidUntil)
: "Not active yet",
},
]}
/>
</SectionCard>
);
}
/** Routes + cargo scope of the contract. */
export function RequestRouteCargoCard({
contract,
}: {
contract?: ReqContract | null;
}) {
const routes = contract?.routes ?? [];
const cargo = contract?.cargoScope ?? [];
const isContainer = contract?.freightType === "CONTAINER";
return (
<SectionCard icon={MapPin} title="Route & cargo" accent="teal">
<Stack gap="md">
<Box>
<Text size="xs" c="dimmed" fw={600} mb={6} tt="uppercase">
Routes
</Text>
{routes.length === 0 ? (
<Text size="sm" c="dimmed">
No routes recorded.
</Text>
) : (
<Stack gap={6}>
{routes.map((r) => (
<Group key={r.id} gap={8} wrap="nowrap">
<MapPin size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={500} truncate>
{r.originYard?.label ?? r.originYardId} {" "}
{r.destinationYard?.label ?? r.destinationYardId}
</Text>
</Group>
))}
</Stack>
)}
</Box>
<Box>
<Text size="xs" c="dimmed" fw={600} mb={6} tt="uppercase">
Cargo scope
</Text>
{cargo.length === 0 ? (
<Text size="sm" c="dimmed">
No cargo scope recorded.
</Text>
) : (
<Group gap={6} wrap="wrap">
{cargo.map((c) => (
<Badge
key={c.id}
variant="light"
color="teal"
radius="sm"
leftSection={<Package size={11} />}
>
{c.containerSize ??
c.cargoFreeText ??
(isContainer ? "Container" : "Bulk commodity")}
</Badge>
))}
</Group>
)}
</Box>
</Stack>
</SectionCard>
);
}
/** Service type — what the contracted service bundles (rail-only vs logistics/customs). */
export function RequestServiceTypeCard({
contract,
}: {
contract?: ReqContract | null;
}) {
const st = contract?.serviceType;
if (!st) return null;
const firstMile = st.includesFirstMile ?? false;
const lastMile = st.includesLastMile ?? false;
const customs = st.includesCustoms ?? false;
const railOnly = !firstMile && !lastMile && !customs;
const chips: Array<{ label: string; color: string; icon: LucideIcon }> = [];
if (railOnly) chips.push({ label: "Rail only", color: "blue", icon: Ship });
if (firstMile)
chips.push({ label: "First-mile pickup", color: "teal", icon: Truck });
if (lastMile)
chips.push({ label: "Last-mile delivery", color: "teal", icon: Warehouse });
if (customs)
chips.push({ label: "Customs clearance (GL)", color: "grape", icon: FileCheck });
return (
<SectionCard
icon={Ship}
title="Service"
subtitle={st.serviceName}
accent="indigo"
>
<Stack gap="sm">
<Group gap={6} wrap="wrap">
{chips.map((c) => (
<Badge
key={c.label}
variant="light"
color={c.color}
radius="sm"
leftSection={<c.icon size={11} />}
>
{c.label}
</Badge>
))}
</Group>
{st.description ? (
<Text size="sm" c="dimmed">
{st.description}
</Text>
) : null}
</Stack>
</SectionCard>
);
}

View File

@@ -9,6 +9,7 @@ import { AssignStationCard } from "./AssignStationCard";
import { AssignRiskCard } from "./AssignRiskCard";
import { AdviseDutyCard } from "./AdviseDutyCard";
import { GlDocumentUploadCard } from "./GlDocumentUploadCard";
import { TransportDocumentCard } from "./TransportDocumentCard";
import { IncidentReportCard } from "./IncidentReportCard";
export interface GlActionsPanelProps {
@@ -39,6 +40,16 @@ export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
() => findMilestone(milestones, "DUTY_TAXES_ADVISED"),
[milestones],
);
const wagonMs = useMemo(
() => findMilestone(milestones, "WAGON_ALLOCATED"),
[milestones],
);
const transportMs = useMemo(
() => findMilestone(milestones, "EXPORT_TRANSPORT_ISSUED"),
[milestones],
);
const showTransport =
wagonMs?.status === "COMPLETED" && transportMs?.status !== "COMPLETED";
return (
<SectionCard icon={Flag} title="Global Logistics actions" accent="edr-green">
@@ -56,6 +67,8 @@ export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
<GlDocumentUploadCard bookingId={bookingId} />
{showTransport ? <TransportDocumentCard bookingId={bookingId} /> : null}
{riskMs ? (
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
) : null}

View File

@@ -0,0 +1,49 @@
import { useState } from "react";
import { Button, FileInput, Stack } from "@mantine/core";
import { FileText } from "lucide-react";
import toast from "react-hot-toast";
import { ActionShell } from "./ActionShell";
import { contractsService } from "@/services/contracts.service";
export function TransportDocumentCard({ bookingId }: { bookingId: string }) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
return (
<ActionShell
icon={FileText}
title="Transit permit"
subtitle="Upload after wagon allocation (GL Ethiopia)"
done={false}
>
<Stack gap="sm">
<FileInput
label="Transit permit"
value={file}
onChange={setFile}
size="sm"
/>
<Button
color="edr-green"
loading={loading}
disabled={!file}
onClick={async () => {
if (!file) return;
setLoading(true);
try {
await contractsService.uploadTransportDocument(bookingId, file);
toast.success("Transport document uploaded");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
Upload document
</Button>
</Stack>
</ActionShell>
);
}

View File

@@ -0,0 +1,52 @@
import { Alert, Badge, Group, Stack, Text } from "@mantine/core";
import { Boxes } from "lucide-react";
import { useContractCapacity } from "@/hooks/contracts/useContracts";
export function ContractCapacityNotice({
contractId,
isContainer,
}: {
contractId: string;
isContainer: boolean;
}) {
const { data: lines = [] } = useContractCapacity(contractId);
if (lines.length === 0) return null;
const allFull = lines.every((l) => l.remaining === 0);
const unit = isContainer ? "" : " tons";
return (
<Alert
color={allFull ? "red" : "edr-green"}
variant="light"
radius="md"
icon={<Boxes size={16} />}
title={allFull ? "Contract capacity reached" : "Remaining contract capacity"}
>
{allFull ? (
<Text fz={13}>
This contract has been fully booked. No further shipments can be created
against it.
</Text>
) : (
<Stack gap={6} mt={4}>
{lines.map((l, i) => (
<Group key={i} justify="space-between" wrap="nowrap">
<Text fz={13}>{l.containerSize ?? "Bulk"}</Text>
<Badge
color={l.remaining === 0 ? "red" : "edr-green"}
variant="light"
radius="sm"
>
{l.remaining}
{unit} of {l.cap} left
</Badge>
</Group>
))}
</Stack>
)}
</Alert>
);
}

View File

@@ -0,0 +1,93 @@
import { Box, Group, Paper, Text, Title } from "@mantine/core";
import type { ReactNode } from "react";
const INK = "#10202F";
const MUTED = "#6B7C8E";
const BORDER = "#E6ECF2";
const GREEN_DARK = "#0A6F4D";
export const fieldStyles = {
label: { fontWeight: 600, fontSize: 13, color: INK, marginBottom: 6 },
input: {
borderRadius: 12,
minHeight: 46,
height: 46,
fontSize: 14,
borderColor: BORDER,
},
};
export function StepLabel({ children }: { children: ReactNode }) {
return (
<Text
fz={11}
fw={700}
tt="uppercase"
c={MUTED}
style={{ letterSpacing: "0.07em" }}
>
{children}
</Text>
);
}
export function StepCard({
children,
eyebrow,
}: {
children: ReactNode;
eyebrow?: ReactNode;
}) {
return (
<Paper
radius={20}
p={{ base: "lg", sm: 28 }}
withBorder
bg="white"
style={{ borderColor: BORDER, boxShadow: "0 2px 14px rgba(16,24,40,0.04)" }}
>
{eyebrow}
{children}
</Paper>
);
}
export function StepHeader({
title,
description,
icon,
}: {
title: string;
description: string;
icon?: ReactNode;
}) {
return (
<Group gap={14} align="flex-start" wrap="nowrap" mb={22}>
{icon ? (
<Box
style={{
flexShrink: 0,
width: 44,
height: 44,
borderRadius: 13,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #ECF6F1, #E4F3EC)",
color: GREEN_DARK,
}}
>
{icon}
</Box>
) : null}
<Box>
<Title order={3} fz={20} fw={800} c={INK} style={{ letterSpacing: "-0.01em" }}>
{title}
</Title>
<Text size="sm" c={MUTED} mt={4} style={{ lineHeight: 1.5 }}>
{description}
</Text>
</Box>
</Group>
);
}

View File

@@ -184,6 +184,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Manage dropdown options used across the platform",
},
},
{
prefix: "/dashboard/configuration/contract-validity-periods",
meta: {
title: "Contract validity periods",
subtitle: "Validity options staff choose when accepting a submitted contract",
},
},
{
prefix: "/dashboard/configuration/train-scheduling-rules",
meta: {

View File

@@ -307,6 +307,14 @@ const RuleEngineFormDialog = ({
size="md"
radius="md"
styles={inputStyles}
rightSection={
field.suffix ? (
<Text size="sm" c="dimmed" fw={600} pr={4}>
{field.suffix}
</Text>
) : undefined
}
rightSectionWidth={field.suffix ? 52 : undefined}
/>
);
};

View File

@@ -94,6 +94,15 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
return <Text size="sm">{Number.isNaN(num) ? String(value) : num.toLocaleString()}</Text>;
}
if (format === "currency") {
const num = Number(value);
return (
<Text size="sm" fw={500}>
{Number.isNaN(num) ? String(value) : `USD ${num.toLocaleString()}`}
</Text>
);
}
if (format === "date") {
const d = new Date(String(value));
if (Number.isNaN(d.getTime())) return <Text size="sm">{String(value)}</Text>;

View File

@@ -34,6 +34,7 @@ import {
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { formatRouteLabel } from "@/services/routes.service";
import { useToast } from "@/hooks/use-toast";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import type { BookingDetail } from "@/types/booking";
@@ -138,7 +139,9 @@ export function AllocateBookingWizard({
const schedulesQuery = useQuery(
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
);
const routesQuery = useQuery(api.routes.list.queryOptions());
const routesQuery = useQuery(
api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }),
);
const locomotivesQuery = useQuery(
api.trainScheduling.availableLocomotives.queryOptions({
input: {
@@ -244,7 +247,7 @@ export function AllocateBookingWizard({
}, [containerUnits, containerSlots, savedPlacementsFromSchedule]);
const activeRoutes = useMemo(
() => (routesQuery.data ?? []).filter((r) => r.isActive),
() => routesQuery.data ?? [],
[routesQuery.data],
);
@@ -522,7 +525,7 @@ export function AllocateBookingWizard({
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<Select
label="Route"
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
data={activeRoutes.map((r) => ({ value: r.id, label: formatRouteLabel(r) }))}
value={routeId || null}
onChange={(v) => setRouteId(v ?? "")}
searchable

View File

@@ -72,6 +72,13 @@ function FeeCard({ fee }: { fee: FeePreview }) {
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
<Row label="Containers" value={String(fee.containerCount ?? 1)} />
<Row label="Billable units" value={`${fee.billableUnits ?? fee.chargeableDays} container-day(s)`} />
{(fee.tiers ?? []).map((tier) => (
<Row
key={`${tier.appliedFromDay}-${tier.appliedToDay}-${tier.ratePerDay}`}
label={`Days ${tier.appliedFromDay}-${tier.appliedToDay}`}
value={`${tier.days} x ${money(tier.ratePerDay, fee.currency)} = ${money(tier.amount, fee.currency)}`}
/>
))}
</Stack>
)}
</Card>

View File

@@ -41,7 +41,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/services/api';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { useToast } from '@/hooks/use-toast';
import { useInventoryInquiry } from '@/hooks/useWarehouses';
import { useAllWarehouseYards, useAllWarehouseZones, useInventoryInquiry } from '@/hooks/useWarehouses';
import { firstMileService } from '@/services/first-mile.service';
import { warehouseService } from '@/services/warehouse.service';
import type {
@@ -54,7 +54,10 @@ import type {
ReadyToLoadRow,
ReceiveInventoryPayload,
TruckEntrancePayload,
Warehouse,
WarehouseInventoryItem,
WarehouseYard,
WarehouseZone,
} from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { DeliverInventoryModal } from './DeliverInventoryModal';
@@ -70,12 +73,17 @@ import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions }
import { openPdfBlob } from './pdf';
import '@/components/overview/overview.css';
type ImportUnloadAssignment = { bookingId: string; warehouseId: string; yardId: string; zoneId: string };
type ImportUnloadAssignmentDraft = Partial<Omit<ImportUnloadAssignment, 'bookingId'>>;
interface ReceiveInventoryModalProps {
opened: boolean;
onClose: () => void;
/** When supplied the modal locks to a single booking (legacy single-receive). */
bookingId?: string;
bookingLabel?: string;
mode?: 'single' | 'bulk';
direction?: WarehouseFlowDirection;
onReceived?: () => void;
}
@@ -142,6 +150,7 @@ interface TruckEntranceFormState {
packagingType: string;
unitCount: number | '';
grossWeightKg: number | '';
weighingRequired: boolean | null;
netWeightKg: number | '';
volumeDimensions: string;
conditionAtReceipt: string;
@@ -163,11 +172,17 @@ interface LockedTruckEntranceFields {
tin?: boolean;
edrDigitalBookingId?: boolean;
customerPhone?: boolean;
truckPlateNumber?: boolean;
trailerPlateNumber?: boolean;
assignedEquipmentNumber?: boolean;
itemDescription?: boolean;
packagingType?: boolean;
unitCount?: boolean;
grossWeightKg?: boolean;
driverName?: boolean;
driverPhone?: boolean;
driverLicenseNumber?: boolean;
truckType?: boolean;
}
type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED';
@@ -190,6 +205,7 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
packagingType: '',
unitCount: '',
grossWeightKg: '',
weighingRequired: null,
netWeightKg: '',
volumeDimensions: '',
conditionAtReceipt: '',
@@ -206,13 +222,24 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
});
const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({
ownerName: form.ownerName.trim() || undefined,
consigneeDetails: form.consigneeDetails.trim() || undefined,
edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined,
tin: form.tin.trim() || undefined,
customerPhone: form.customerPhone.trim() || undefined,
truckPlateNumber: form.truckPlateNumber.trim(),
trailerPlateNumber: form.trailerPlateNumber.trim() || undefined,
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
customsSealNumber: form.customsSealNumber.trim() || undefined,
declarationNumber: form.declarationNumber.trim() || undefined,
incoterms: form.incoterms.trim() || undefined,
hsCodes: form.hsCodes.trim() || undefined,
itemCode: form.itemCode.trim() || undefined,
itemDescription: form.itemDescription.trim() || undefined,
packagingType: form.packagingType.trim() || undefined,
unitCount: form.unitCount === '' ? undefined : Number(form.unitCount),
weighingRequired: form.weighingRequired ?? undefined,
grossWeightKg: form.weighingRequired && form.grossWeightKg !== '' ? Number(form.grossWeightKg) : undefined,
netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg),
volumeDimensions: form.volumeDimensions.trim() || undefined,
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
@@ -222,8 +249,11 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
driverPhone: form.driverPhone.trim(),
driverLicenseNumber: form.driverLicenseNumber.trim() || undefined,
truckType: form.truckType.trim() || undefined,
entranceTareWeightKg: Number(form.entranceTareWeightKg),
exitTareWeightKg: form.exitTareWeightKg === '' ? undefined : Number(form.exitTareWeightKg),
entranceTareWeightKg:
form.entranceTareWeightKg === ''
? undefined
: Number(form.entranceTareWeightKg),
exitTareWeightKg: form.weighingRequired && form.exitTareWeightKg !== '' ? Number(form.exitTareWeightKg) : undefined,
driverSignatoryName: form.driverSignatoryName.trim() || undefined,
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
});
@@ -242,22 +272,31 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
const tin = commonNonEmptyValue(bookings.map((booking) => booking.customerTin));
const customerPhone = commonNonEmptyValue(bookings.map((booking) => booking.customerPhone));
const consigneeDetails = commonNonEmptyValue(bookings.map((booking) => booking.customer));
const assignedEquipmentNumber = commonNonEmptyValue(bookings.map((booking) => booking.containerNumber));
const assignedEquipmentNumber = commonNonEmptyValue(
bookings.map((booking) => booking.customerTruckContainerNumber || booking.containerNumber),
);
const itemDescription = commonNonEmptyValue(bookings.map((booking) => booking.cargoDescription ?? booking.cargo));
const packagingType = commonNonEmptyValue(bookings.map((booking) => booking.containerPackagingType));
const truckPlateNumber = commonNonEmptyValue(
bookings.map((booking) => booking.firstMileTruckPlateNumber || booking.customerTruckPlateNumber),
);
const trailerPlateNumber = commonNonEmptyValue(bookings.map((booking) => booking.firstMileTrailerPlateNumber));
const driverName = commonNonEmptyValue(
bookings.map((booking) => booking.firstMileDriverName || booking.customerTruckDriverName),
);
const driverPhone = commonNonEmptyValue(bookings.map((booking) => booking.firstMileDriverPhone));
const driverLicenseNumber = commonNonEmptyValue(bookings.map((booking) => booking.firstMileDriverLicenseNumber));
const truckType = commonNonEmptyValue(
bookings.map((booking) => booking.firstMileTruckType || booking.customerTruckType),
);
const edrDigitalBookingId =
bookings.length === 1
? bookings[0]?.reference ?? bookings[0]?.id ?? ''
: commonNonEmptyValue(bookings.map((booking) => booking.reference));
const firstMileBooking = bookings.length === 1 ? bookings[0] : null;
const unitCount =
bookings.length === 1 && bookings[0]?.containerQuantity != null
? Number(bookings[0].containerQuantity)
: '';
const grossWeightKg =
bookings.length === 1 && bookings[0]?.weight != null
? Number(bookings[0].weight)
: '';
const freightTypes = [...new Set(bookings.map((booking) => booking.freightType).filter(Boolean))];
const packagingFreightType =
freightTypes.length === 1 && freightTypes[0] === 'CONTAINER'
@@ -278,13 +317,13 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
itemDescription,
packagingType,
unitCount,
grossWeightKg,
truckPlateNumber: firstMileBooking?.firstMileTruckPlateNumber ?? '',
trailerPlateNumber: firstMileBooking?.firstMileTrailerPlateNumber ?? '',
driverName: firstMileBooking?.firstMileDriverName ?? '',
driverPhone: firstMileBooking?.firstMileDriverPhone ?? '',
driverLicenseNumber: firstMileBooking?.firstMileDriverLicenseNumber ?? '',
truckType: firstMileBooking?.firstMileTruckType ?? '',
grossWeightKg: '',
truckPlateNumber,
trailerPlateNumber,
driverName,
driverPhone,
driverLicenseNumber,
truckType,
},
lockedFields: {
ownerName: Boolean(ownerName),
@@ -296,7 +335,13 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
itemDescription: Boolean(itemDescription),
packagingType: Boolean(packagingType),
unitCount: unitCount !== '',
grossWeightKg: grossWeightKg !== '',
grossWeightKg: false,
truckPlateNumber: Boolean(truckPlateNumber),
trailerPlateNumber: Boolean(trailerPlateNumber),
driverName: Boolean(driverName),
driverPhone: Boolean(driverPhone),
driverLicenseNumber: Boolean(driverLicenseNumber),
truckType: Boolean(truckType),
},
packagingFreightType,
};
@@ -338,11 +383,13 @@ function TruckEntranceFields({
onChange,
lockedFields,
packagingFreightType = 'MIXED',
allowTruckWeighing = true,
}: {
value: TruckEntranceFormState;
onChange: (next: TruckEntranceFormState) => void;
lockedFields?: LockedTruckEntranceFields;
packagingFreightType?: PackagingFreightType;
allowTruckWeighing?: boolean;
}) {
const packagingOptions = packagingOptionsFor(packagingFreightType);
const quantityLabel =
@@ -396,11 +443,13 @@ function TruckEntranceFields({
label="Truck plate number"
required
value={value.truckPlateNumber}
readOnly={lockedFields?.truckPlateNumber}
onChange={(e) => onChange({ ...value, truckPlateNumber: e.currentTarget.value })}
/>
<TextInput
label="Trailer plate number"
value={value.trailerPlateNumber}
readOnly={lockedFields?.trailerPlateNumber}
onChange={(e) => onChange({ ...value, trailerPlateNumber: e.currentTarget.value })}
/>
</Group>
@@ -422,12 +471,14 @@ function TruckEntranceFields({
label="Driver name"
required
value={value.driverName}
readOnly={lockedFields?.driverName}
onChange={(e) => onChange({ ...value, driverName: e.currentTarget.value })}
/>
<TextInput
label="Driver phone"
required
value={value.driverPhone}
readOnly={lockedFields?.driverPhone}
onChange={(e) => onChange({ ...value, driverPhone: e.currentTarget.value })}
/>
</Group>
@@ -435,29 +486,61 @@ function TruckEntranceFields({
<TextInput
label="Driver license number"
value={value.driverLicenseNumber}
readOnly={lockedFields?.driverLicenseNumber}
onChange={(e) => onChange({ ...value, driverLicenseNumber: e.currentTarget.value })}
/>
<TextInput
label="Truck type"
value={value.truckType}
readOnly={lockedFields?.truckType}
onChange={(e) => onChange({ ...value, truckType: e.currentTarget.value })}
/>
</Group>
<Group grow>
<NumberInput
label="Entrance tare weight (kg)"
required
min={0}
value={value.entranceTareWeightKg}
onChange={(v) => onChange({ ...value, entranceTareWeightKg: v === '' ? '' : Number(v) })}
/>
<NumberInput
label="Exit tare weight (kg)"
min={0}
value={value.exitTareWeightKg}
onChange={(v) => onChange({ ...value, exitTareWeightKg: v === '' ? '' : Number(v) })}
/>
</Group>
{allowTruckWeighing ? (
<>
<Select
label="Weighing"
required
data={[
{ value: 'YES', label: 'Yes' },
{ value: 'NO', label: 'No' },
]}
value={value.weighingRequired == null ? null : value.weighingRequired ? 'YES' : 'NO'}
onChange={(next) =>
onChange({
...value,
weighingRequired: next === 'YES' ? true : next === 'NO' ? false : null,
grossWeightKg: next === 'YES' ? value.grossWeightKg : '',
exitTareWeightKg: next === 'YES' ? value.exitTareWeightKg : '',
})
}
/>
{value.weighingRequired && (
<Group grow>
<NumberInput
label="Gross weight (kg)"
required
min={0}
value={value.grossWeightKg}
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
/>
<NumberInput
label="Exit tare weight (kg)"
required
min={0}
value={value.exitTareWeightKg}
onChange={(v) => onChange({ ...value, exitTareWeightKg: v === '' ? '' : Number(v) })}
/>
</Group>
)}
</>
) : (
<Alert icon={<Info size={16} />} color="green" variant="light">
<Text size="sm">
Truck weighing is not required for a received first-mile arrival. The GRN uses the booking weight.
</Text>
</Alert>
)}
<Text size="sm" fw={600} mt="xs">Customs and compliance</Text>
<Group grow>
@@ -510,21 +593,12 @@ function TruckEntranceFields({
onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })}
/>
</Group>
<Group grow>
<NumberInput
label="Gross weight (kg)"
min={0}
value={value.grossWeightKg}
readOnly={lockedFields?.grossWeightKg}
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
/>
<NumberInput
label="Net weight (kg)"
min={0}
value={value.netWeightKg}
onChange={(v) => onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })}
/>
</Group>
<NumberInput
label="Net weight (kg)"
min={0}
value={value.netWeightKg}
onChange={(v) => onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })}
/>
<TextInput
label="Volume / dimensions"
value={value.volumeDimensions}
@@ -650,11 +724,15 @@ function EligibleTab({
location,
enabled,
onChanged,
focusedBookingId,
focusedBookingLabel,
}: {
direction: 'IMPORT' | 'EXPORT';
location: Location;
enabled: boolean;
onChanged?: () => void;
focusedBookingId?: string;
focusedBookingLabel?: string;
}) {
const { toast } = useToast();
const qc = useQueryClient();
@@ -664,7 +742,15 @@ function EligibleTab({
enabled,
}),
);
const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]);
const rows = useMemo(
() =>
allRows.filter(
(r) =>
r.direction === direction &&
(!focusedBookingId || r.id === focusedBookingId),
),
[allRows, direction, focusedBookingId],
);
const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions());
const requestFirstMile = useMutation({
mutationFn: (reference: string) => firstMileService.accept(reference),
@@ -766,6 +852,8 @@ function EligibleTab({
[pendingReceiveIds, rows],
);
const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile);
const pendingUsesFirstMile =
pendingReceiveRows.length > 0 && pendingReceiveRows.every((row) => row.hasFirstMile);
const toggleAll = () =>
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
@@ -788,6 +876,18 @@ function EligibleTab({
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined),
});
const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber);
if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadGrnDocument(firstGrn.inventoryId);
const opened = openPdfBlob(response.data, `grn-${firstGrn.grnNumber}.pdf`, pdfWindow);
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
}
}
setSelected(new Set());
setTruckOpen(false);
setPendingReceiveIds([]);
@@ -822,32 +922,65 @@ function EligibleTab({
void receiveBookings(filteredIds);
return;
}
const hasFirstMileRows = selectedRows.some((row) => row.hasFirstMile);
const hasCustomerTruckRows = selectedRows.some((row) => !row.hasFirstMile);
if (hasFirstMileRows && hasCustomerTruckRows) {
toast({
variant: 'destructive',
title: 'Receive separately',
description: 'First-mile arrivals and customer-truck arrivals use different truck evidence. Select one group at a time.',
});
return;
}
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
const totalContainerQuantity = selectedRows.reduce(
(sum, row) => sum + Number(row.containerQuantity ?? 0),
0,
);
const usesFirstMile = selectedRows.length > 0 && selectedRows.every((row) => row.hasFirstMile);
const usesCustomerAssignedTruck =
selectedRows.length > 0 &&
selectedRows.every((row) => !row.hasFirstMile && Boolean(row.customerTruckAssignedAt));
const normalizedForm =
nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0
? {
...form,
unitCount: totalContainerQuantity,
}
: form;
: {
...form,
};
setPendingReceiveIds(filteredIds);
setReceivedAt(new Date().toISOString());
setTruckForm(normalizedForm);
setLockedTruckFields({
...lockedFields,
unitCount: nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0,
assignedEquipmentNumber: usesCustomerAssignedTruck
? lockedFields.assignedEquipmentNumber
: lockedFields.assignedEquipmentNumber,
truckPlateNumber: (usesFirstMile || usesCustomerAssignedTruck) && lockedFields.truckPlateNumber,
trailerPlateNumber: usesFirstMile && lockedFields.trailerPlateNumber,
driverName: (usesFirstMile || usesCustomerAssignedTruck) && lockedFields.driverName,
driverPhone: usesFirstMile && lockedFields.driverPhone,
driverLicenseNumber: usesFirstMile && lockedFields.driverLicenseNumber,
truckType: (usesFirstMile || usesCustomerAssignedTruck) && lockedFields.truckType,
});
setPackagingFreightType(nextPackagingFreightType);
setTruckOpen(true);
};
const receive = async () => {
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') {
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim()) {
toast({ variant: 'destructive', title: 'Truck and driver information are required' });
return;
}
if (!pendingUsesFirstMile && truckForm.weighingRequired == null) {
toast({ variant: 'destructive', title: 'Select whether customer truck weighing is required' });
return;
}
if (truckForm.weighingRequired && (truckForm.grossWeightKg === '' || truckForm.exitTareWeightKg === '')) {
toast({ variant: 'destructive', title: 'Gross weight and exit tare weight are required when weighing is Yes' });
return;
}
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
@@ -906,7 +1039,9 @@ function EligibleTab({
</Group>
) : statusFilteredRows.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
No eligible PAID {direction.toLowerCase()} bookings to receive.
{focusedBookingLabel
? `${focusedBookingLabel} is not eligible for warehouse receiving yet.`
: `No eligible PAID ${direction.toLowerCase()} bookings to receive.`}
</Text>
) : (
<Table.ScrollContainer minWidth={1700}>
@@ -1053,8 +1188,8 @@ function EligibleTab({
<Stack gap="md">
<Alert icon={<Truck size={16} />} color={pendingHasFirstMile ? 'green' : 'blue'} variant="light">
<Text size="sm">
{pendingHasFirstMile
? 'Assigned first-mile truck and driver details are prefilled. Confirm or update the actual arrival details before GRN.'
{pendingUsesFirstMile
? 'Received first-mile truck and driver details are pulled from the first-mile record. GRN uses booking cargo, quantity and weight.'
: 'Register the customer or third-party truck and driver before export receiving and GRN.'}
</Text>
</Alert>
@@ -1109,6 +1244,7 @@ function EligibleTab({
onChange={setTruckForm}
lockedFields={lockedTruckFields}
packagingFreightType={packagingFreightType}
allowTruckWeighing={!pendingUsesFirstMile}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
@@ -1611,14 +1747,59 @@ function LoadedExportTab({
);
}
/** Assigned bookings/items for an arrived import train (read-only detail view). */
function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
const importLocationTypesForFreight = (freightType: string | null | undefined) => {
const normalized = (freightType ?? '').toUpperCase();
if (normalized === 'CONTAINER') {
return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] };
}
return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] };
};
const isImportContainerFreight = (freightType: string | null | undefined) =>
(freightType ?? '').toUpperCase() === 'CONTAINER';
const isImportUnloadPending = (item: ImportTrainItem) =>
!item.currentStatus || item.currentStatus === 'RECEIVED';
/** Assigned bookings/items for an arrived import train with per-booking unload locations. */
function ImportTrainDetailTable({
train,
warehouses,
yards,
zones,
assignments,
onAssignmentChange,
onReadyChange,
}: {
train: ImportTrain;
warehouses: Warehouse[];
yards: WarehouseYard[];
zones: WarehouseZone[];
assignments: Record<string, ImportUnloadAssignmentDraft>;
onAssignmentChange: (bookingId: string, draft: ImportUnloadAssignmentDraft) => void;
onReadyChange: (ready: boolean) => void;
}) {
const { data: items = [], isLoading } = useQuery(
api.warehouses.importTrainItems.queryOptions({
input: { scheduleId: train.scheduleId },
enabled: Boolean(train.scheduleId),
}),
);
const warehouseOptions = useMemo(
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
[warehouses],
);
useEffect(() => {
const pending = items.filter(isImportUnloadPending);
onReadyChange(
pending.length > 0 &&
pending.every((item) => {
const draft = assignments[item.bookingId];
return Boolean(draft?.warehouseId && draft.yardId && draft.zoneId);
}),
);
}, [assignments, items]);
if (isLoading) {
return (
@@ -1648,6 +1829,9 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
<Table.Th>Cargo Type</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Arrival</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Inspection</Table.Th>
<Table.Th>Current Status</Table.Th>
<Table.Th>Last Mile</Table.Th>
@@ -1655,7 +1839,18 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((it: ImportTrainItem) => (
{items.map((it: ImportTrainItem) => {
const draft = assignments[it.bookingId] ?? {};
const { yardTypes, zoneTypes } = importLocationTypesForFreight(it.freightType);
const yardOptions = yards
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type))
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
const zoneOptions = zones
.filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type))
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
const pending = isImportUnloadPending(it);
return (
<Table.Tr key={`${it.bookingId}-${it.sequenceNo ?? 'wagon'}`}>
<Table.Td>
<Text size="xs" fw={600}>
@@ -1676,6 +1871,41 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
<Table.Td>{it.cargoType ?? '—'}</Table.Td>
<Table.Td>{formatNumber(Number(it.weight))}</Table.Td>
<Table.Td>{formatDate(it.arrivalTime)}</Table.Td>
<Table.Td>
<Select
placeholder="Warehouse"
data={warehouseOptions}
value={draft.warehouseId ?? null}
onChange={(value) => onAssignmentChange(it.bookingId, { warehouseId: value ?? undefined })}
searchable
disabled={!pending}
w={210}
/>
</Table.Td>
<Table.Td>
<Select
placeholder={isImportContainerFreight(it.freightType) ? 'Container yard' : 'Bulk yard'}
data={yardOptions}
value={draft.yardId ?? null}
onChange={(value) =>
onAssignmentChange(it.bookingId, { ...draft, yardId: value ?? undefined, zoneId: undefined })
}
searchable
disabled={!pending || !draft.warehouseId}
w={190}
/>
</Table.Td>
<Table.Td>
<Select
placeholder={isImportContainerFreight(it.freightType) ? 'Container zone' : 'Bulk zone'}
data={zoneOptions}
value={draft.zoneId ?? null}
onChange={(value) => onAssignmentChange(it.bookingId, { ...draft, zoneId: value ?? undefined })}
searchable
disabled={!pending || !draft.yardId}
w={190}
/>
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={it.inspectionStatus === 'PASSED' ? 'green' : 'gray'}>
{it.inspectionStatus ?? 'Not inspected'}
@@ -1691,7 +1921,8 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
</Table.Td>
<Table.Td>{it.pickupOption}</Table.Td>
</Table.Tr>
))}
);
})}
</Table.Tbody>
</Table>
);
@@ -1715,13 +1946,42 @@ function ImportArriveQueueTab({
const { data: trains = [], isLoading } = useQuery(
api.warehouses.importArriveQueue.queryOptions({ enabled }),
);
const { data: warehouses = [], isLoading: warehousesLoading } = useQuery(
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } }, enabled }),
);
const { data: yards = [] } = useAllWarehouseYards();
const { data: zones = [] } = useAllWarehouseZones();
const autoUnloadMutation = useMutation(
api.warehouses.autoUnloadArrivedBookings.mutationOptions(),
);
const [openId, setOpenId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [assignmentsBySchedule, setAssignmentsBySchedule] = useState<
Record<string, Record<string, ImportUnloadAssignmentDraft>>
>({});
const [readyBySchedule, setReadyBySchedule] = useState<Record<string, boolean>>({});
const autoUnload = async (train: ImportTrain) => {
const assignments = Object.entries(assignmentsBySchedule[train.scheduleId] ?? {})
.filter((entry): entry is [string, Required<ImportUnloadAssignmentDraft>] =>
Boolean(entry[1].warehouseId && entry[1].yardId && entry[1].zoneId),
)
.map(([bookingId, draft]) => ({
bookingId,
warehouseId: draft.warehouseId,
yardId: draft.yardId,
zoneId: draft.zoneId,
}));
if (!readyBySchedule[train.scheduleId] || assignments.length === 0) {
toast({
variant: 'destructive',
title: 'Assign locations',
description: 'Select warehouse, yard and zone for each pending booking before unloading.',
});
return;
}
if (isFullyUnloaded(train)) {
toast({
title: 'Already unloaded',
@@ -1732,7 +1992,7 @@ function ImportArriveQueueTab({
setBusyId(train.scheduleId);
try {
const r = await autoUnloadMutation.mutateAsync(train.scheduleId);
const r = await autoUnloadMutation.mutateAsync({ scheduleId: train.scheduleId, assignments });
const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0;
const firstReason = r.results.find((item) => item.reason)?.reason;
const extra = [
@@ -1831,7 +2091,7 @@ function ImportArriveQueueTab({
color={fullyUnloaded ? 'gray' : 'indigo'}
leftSection={<Truck size={14} />}
loading={busyId === t.scheduleId}
disabled={fullyUnloaded || t.totalBookings === 0}
disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading}
onClick={() => autoUnload(t)}
>
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'}
@@ -1842,7 +2102,25 @@ function ImportArriveQueueTab({
{isOpen && (
<Table.Tr>
<Table.Td colSpan={11} bg="var(--mantine-color-gray-0)">
<ImportTrainDetailTable train={t} />
<ImportTrainDetailTable
train={t}
warehouses={warehouses}
yards={yards}
zones={zones}
assignments={assignmentsBySchedule[t.scheduleId] ?? {}}
onAssignmentChange={(bookingId, draft) =>
setAssignmentsBySchedule((current) => ({
...current,
[t.scheduleId]: {
...(current[t.scheduleId] ?? {}),
[bookingId]: draft.warehouseId ? draft : {},
},
}))
}
onReadyChange={(ready) =>
setReadyBySchedule((current) => ({ ...current, [t.scheduleId]: ready }))
}
/>
</Table.Td>
</Table.Tr>
)}
@@ -1934,6 +2212,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
reference: row.bookingReference ?? row.bookingId,
tradeDirection: 'IMPORT',
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
customerTruckPlateNumber: row.customerTruckPlateNumber,
customerTruckDriverName: row.customerTruckDriverName,
customerTruckType: row.customerTruckType,
customerTruckContainerNumber: row.customerTruckContainerNumber,
customerTruckAssignedAt: row.customerTruckAssignedAt,
}
: null,
}) as unknown as WarehouseInventoryItem;
@@ -2262,6 +2545,8 @@ interface WarehouseFlowWorkbenchProps {
direction?: WarehouseFlowDirection;
enabled?: boolean;
onChanged?: () => void;
focusedBookingId?: string;
focusedBookingLabel?: string;
}
function WarehouseQueueTabs<TValue extends string>({
@@ -2481,10 +2766,14 @@ function ExportWarehouseTabs({
enabled,
location,
onChanged,
focusedBookingId,
focusedBookingLabel,
}: {
enabled: boolean;
location: Location;
onChanged?: () => void;
focusedBookingId?: string;
focusedBookingLabel?: string;
}) {
const [activeTab, setActiveTab] = useState<ExportWarehouseTab>('receive-queue');
const { data: eligibleRows = [] } = useQuery(
@@ -2543,7 +2832,14 @@ function ExportWarehouseTabs({
<WarehouseQueueTabs value={activeTab} onChange={setActiveTab} tabs={tabs} />
{activeTab === 'receive-queue' && (
<EligibleTab direction="EXPORT" location={location} enabled={enabled} onChanged={onChanged} />
<EligibleTab
direction="EXPORT"
location={location}
enabled={enabled}
onChanged={onChanged}
focusedBookingId={focusedBookingId}
focusedBookingLabel={focusedBookingLabel}
/>
)}
{activeTab === 'received' && (
<ExportReceivedTab enabled={enabled} onChanged={onChanged} />
@@ -2568,6 +2864,8 @@ export function WarehouseFlowWorkbench({
direction = 'BOTH',
enabled = true,
onChanged,
focusedBookingId,
focusedBookingLabel,
}: WarehouseFlowWorkbenchProps) {
const [location, setLocation] = useState<Location>({ warehouseId: '', yardId: '', zoneId: '' });
const [tab, setTab] = useState<Exclude<WarehouseFlowDirection, 'BOTH'>>(
@@ -2600,24 +2898,42 @@ export function WarehouseFlowWorkbench({
<ImportWarehouseTabs enabled={enabled} onChanged={onChanged} />
</Tabs.Panel>
<Tabs.Panel value="EXPORT">
<ExportWarehouseTabs enabled={enabled} location={location} onChanged={onChanged} />
<ExportWarehouseTabs
enabled={enabled}
location={location}
onChanged={onChanged}
focusedBookingId={focusedBookingId}
focusedBookingLabel={focusedBookingLabel}
/>
</Tabs.Panel>
</Tabs>
) : activeDirection === 'IMPORT' ? (
<ImportWarehouseTabs enabled={enabled} onChanged={onChanged} />
) : (
<ExportWarehouseTabs enabled={enabled} location={location} onChanged={onChanged} />
<ExportWarehouseTabs
enabled={enabled}
location={location}
onChanged={onChanged}
focusedBookingId={focusedBookingId}
focusedBookingLabel={focusedBookingLabel}
/>
)}
</Stack>
);
}
/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */
function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) {
function BulkReceiveModal({ opened, onClose, onReceived, bookingId, bookingLabel, direction = 'BOTH' }: ReceiveInventoryModalProps) {
return (
<Modal opened={opened} onClose={onClose} title="Receive at warehouse" centered size="80rem">
<Stack gap="md">
<WarehouseFlowWorkbench enabled={opened} direction="BOTH" onChanged={onReceived} />
<WarehouseFlowWorkbench
enabled={opened}
direction={direction}
onChanged={onReceived}
focusedBookingId={bookingId}
focusedBookingLabel={bookingLabel}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose}>
@@ -2763,5 +3079,5 @@ function SingleBookingReceiveModal({
export function ReceiveInventoryModal(props: ReceiveInventoryModalProps) {
// Locked to a booking → legacy single receive; otherwise the Import/Export bulk flow.
return props.bookingId ? <SingleBookingReceiveModal {...props} /> : <BulkReceiveModal {...props} />;
return props.bookingId && props.mode !== 'bulk' ? <SingleBookingReceiveModal {...props} /> : <BulkReceiveModal {...props} />;
}

View File

@@ -15,6 +15,17 @@ interface ReleaseOrderModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
truckPrefill?: ReleaseOrderTruckPrefill | null;
}
export interface ReleaseOrderTruckPrefill {
truckPlateNumber?: string | null;
trailerPlateNumber?: string | null;
driverName?: string | null;
driverLicense?: string | null;
driverPhone?: string | null;
truckType?: string | null;
containerNumber?: string | null;
}
const REGISTERED_FIRST_LAST_MILE_TRUCKS = [
@@ -82,6 +93,9 @@ const splitContainerNumbers = (value: string | null | undefined) =>
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
const assignedTruckValue = (item: WarehouseInventoryItem | null, key: keyof NonNullable<WarehouseInventoryItem['booking']>) =>
item?.booking?.[key] == null ? '' : String(item.booking[key]);
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
?.booking?.freightType;
@@ -117,7 +131,7 @@ const parseInspectionNote = (notes: string | null | undefined) => {
};
};
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
const { toast } = useToast();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
const [reference, setReference] = useState('');
@@ -138,25 +152,34 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
useEffect(() => {
if (opened) {
const inspection = parseInspectionNote(item?.notes);
const assignedTruckPlate = assignedTruckValue(item, 'customerTruckPlateNumber');
const assignedDriverName = assignedTruckValue(item, 'customerTruckDriverName');
const assignedTruckType = assignedTruckValue(item, 'customerTruckType');
const assignedContainerNumber = assignedTruckValue(item, 'customerTruckContainerNumber');
const prefillContainerNumber = truckPrefill?.containerNumber ?? '';
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
setTruckPlateNumber(inspection.truckPlateNumber);
setTrailerPlateNumber(inspection.trailerPlateNumber);
setDriverName(inspection.driverName);
setDriverLicense(inspection.driverLicense);
setDriverPhone(inspection.driverPhone);
setTruckType(inspection.truckType);
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber));
setTruckPlateNumber(inspection.truckPlateNumber || truckPrefill?.truckPlateNumber || assignedTruckPlate || '');
setTrailerPlateNumber(inspection.trailerPlateNumber || truckPrefill?.trailerPlateNumber || '');
setDriverName(inspection.driverName || truckPrefill?.driverName || assignedDriverName || '');
setDriverLicense(inspection.driverLicense || truckPrefill?.driverLicense || '');
setDriverPhone(inspection.driverPhone || truckPrefill?.driverPhone || '');
setTruckType(inspection.truckType || truckPrefill?.truckType || assignedTruckType || '');
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
setGateInTime(inspection.gateInTime);
setTareWeight(inspection.tareWeight);
setGrossWeight(inspection.grossWeight);
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
setGateOutTime(inspection.gateOutTime);
}
}, [opened, item]);
}, [opened, item, truckPrefill]);
const savedInspection = parseInspectionNote(item?.notes);
const isExitStep = savedInspection.tareWeight !== '';
const isEntranceLocked = isExitStep;
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
@@ -269,7 +292,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
searchable
clearable
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
disabled={isEntranceLocked}
disabled={isTruckIdentityLocked}
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
@@ -283,7 +306,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
required
value={truckPlateNumber}
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
readOnly={isEntranceLocked}
readOnly={isTruckIdentityLocked}
/>
<TextInput
label="Trailer plate number"
@@ -293,12 +316,12 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
/>
</Group>
<Group grow>
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isDriverNameLocked} />
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group>
<Group grow>
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
</Group>
<Group grow>
<Stack gap={6}>
@@ -313,7 +336,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
)
}
readOnly={isEntranceLocked}
readOnly={isTruckIdentityLocked}
/>
))}
</SimpleGrid>