mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
Customer Truck Assignment and Portal Delivary Approval
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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="PDF or image."
|
||||
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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,9 +31,6 @@ export interface WarehouseHandoverPdfContext {
|
||||
const escapePdfText = (value: string) =>
|
||||
value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
|
||||
const money = (amount: unknown, currency = 'USD') =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
|
||||
|
||||
const fmtDate = (value: unknown) => {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value as string | Date);
|
||||
@@ -96,12 +93,6 @@ const textOp = (
|
||||
color = '0 0 0',
|
||||
) => `BT\n${color} rg\n/${bold ? 'F2' : 'F1'} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
|
||||
|
||||
const buildAuthorizationBand = (label: 'PAID' | 'CLEARED') => [
|
||||
lineOp(60, 242, 535, 242),
|
||||
textOp('AUTHORIZED SEAL', 382, 218, 9, true, GREEN),
|
||||
buildCircularSeal(452, 155, label),
|
||||
];
|
||||
|
||||
const buildWarehouseOfficerSealBand = () => [
|
||||
lineOp(60, 218, 535, 218),
|
||||
textOp('WAREHOUSE OFFICER SEAL', 92, 194, 9, true, GREEN),
|
||||
@@ -146,57 +137,6 @@ function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob {
|
||||
return new Blob([pdf], { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
export function buildWarehouseInvoicePdf(invoice: WarehouseFeeInvoice, kind: 'INVOICE' | 'RECEIPT') {
|
||||
const paid = kind === 'RECEIPT' || invoice.status === 'PAID';
|
||||
const title = `Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}`;
|
||||
const bookingReference = firstText(invoice.bookingReference);
|
||||
const customerName = firstText(invoice.customerName);
|
||||
const inventoryReference = firstText(invoice.inventoryReference);
|
||||
const inventoryInfo = firstText(invoice.inventoryInfo, invoice.containerNumber, invoice.cargoDescription);
|
||||
const clearanceStatus = firstText(
|
||||
invoice.clearanceStatus,
|
||||
paid ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT',
|
||||
);
|
||||
const lines: PdfLine[] = [
|
||||
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
|
||||
{ text: title, size: 23, bold: true, yGap: 28, align: 'center' },
|
||||
{ text: `Document No: ${invoice.invoiceNumber}`, size: 12, bold: true, yGap: 32, align: 'center' },
|
||||
{ text: `Status: ${invoice.status.replace(/_/g, ' ')} Type: ${invoice.invoiceType.replace(/_/g, ' ')}`, align: 'center' },
|
||||
{ text: `Booking Reference: ${bookingReference} Customer: ${customerName}`, align: 'center' },
|
||||
{ text: `Inventory Reference: ${inventoryReference} Inventory Info: ${inventoryInfo}`, align: 'center' },
|
||||
{ text: `Clearance: ${clearanceStatus}`, align: 'center' },
|
||||
{ text: `Issued: ${fmtDate(invoice.issuedAt)} Paid At: ${fmtDate(invoice.paidAt)}`, align: 'center' },
|
||||
{ text: 'ITEMS', size: 13, bold: true, yGap: 30, align: 'center' },
|
||||
...(invoice.items ?? []).flatMap((item) => [
|
||||
{ text: item.description, bold: true, align: 'center' as const },
|
||||
{
|
||||
text: `${item.feeType.replace(/_/g, ' ')} | Qty ${Number(item.quantity ?? 0).toLocaleString()} | Rate ${money(item.unitRate, item.currency)} | Amount ${money(item.amount, item.currency)}`,
|
||||
yGap: 13,
|
||||
align: 'center' as const,
|
||||
},
|
||||
]),
|
||||
{ text: 'TOTALS', size: 13, bold: true, yGap: 30, align: 'center' },
|
||||
{ text: `Subtotal: ${money(invoice.subtotalAmount, invoice.currency)}`, align: 'center' },
|
||||
{ text: `Tax: ${money(invoice.taxAmount, invoice.currency)}`, align: 'center' },
|
||||
{ text: `Total: ${money(invoice.totalAmount, invoice.currency)}`, bold: true, align: 'center' },
|
||||
{ text: `Paid: ${money(invoice.paidAmount, invoice.currency)}`, align: 'center' },
|
||||
{ text: `Balance: ${money(invoice.balanceAmount, invoice.currency)}`, bold: true, align: 'center' },
|
||||
];
|
||||
const authorizationOps = [
|
||||
...buildAuthorizationBand('PAID'),
|
||||
textOp('Prepared by EDR warehouse finance', 72, 196, 10),
|
||||
textOp('Finance officer name / signature / date:', 72, 164, 10),
|
||||
lineOp(245, 162, 360, 162, '0 0 0'),
|
||||
];
|
||||
const invoiceOps = [
|
||||
lineOp(60, 242, 535, 242),
|
||||
textOp('Prepared by EDR warehouse finance', 72, 196, 10),
|
||||
textOp('Finance officer name / signature / date:', 72, 164, 10),
|
||||
lineOp(245, 162, 360, 162, '0 0 0'),
|
||||
];
|
||||
return buildSimplePdf(lines, paid ? authorizationOps : invoiceOps);
|
||||
}
|
||||
|
||||
const firstText = (...values: Array<unknown>) => {
|
||||
for (const value of values) {
|
||||
if (value !== null && value !== undefined && String(value).trim()) return String(value);
|
||||
|
||||
Reference in New Issue
Block a user