finilize gl

This commit is contained in:
marshal
2026-07-02 12:06:17 +03:00
parent 9c18d086d7
commit 4fefe4f827
50 changed files with 5150 additions and 1793 deletions

View File

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

View File

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

View File

@@ -56,9 +56,13 @@ export interface ContractClearanceReviewSectionProps {
readOnly?: boolean;
/**
* Document approvals are locked (e.g. after all docs approved in phased flow)
* but queries remain available until {@link readOnly}.
* 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
@@ -102,6 +106,7 @@ export function ContractClearanceReviewSection({
readOnly = false,
phasedCustoms = false,
approvalsLocked = false,
queriesLocked = false,
}: ContractClearanceReviewSectionProps) {
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
@@ -150,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}>
@@ -183,7 +193,7 @@ export function ContractClearanceReviewSection({
subtitle={
readOnly
? `Reviewed by the ${reviewerTeam} team.`
: approvalsLocked
: 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."
}
@@ -192,7 +202,7 @@ export function ContractClearanceReviewSection({
<Text size="xs" c="dimmed" fw={600}>
{stats.approved}/{stats.total} approved
</Text>
{!readOnly && !approvalsLocked && approvableKeys.length > 0 && (
{!readOnly && !effectiveApprovalsLocked && approvableKeys.length > 0 && (
<Button
size="compact-sm"
color="edr-green"
@@ -236,7 +246,8 @@ export function ContractClearanceReviewSection({
doc={doc}
reviewerTeam={reviewerTeam}
readOnly={readOnly}
approvalsLocked={approvalsLocked}
approvalsLocked={effectiveApprovalsLocked}
queriesLocked={queriesLocked}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
@@ -394,7 +405,7 @@ export function ContractClearanceReviewSection({
</Text>
</Group>
</Paper>
) : phasedCustoms && approvalsLocked ? (
) : 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}>
@@ -402,7 +413,10 @@ export function ContractClearanceReviewSection({
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
Document review is complete. Use the action panel for declaration, duty, and
transit steps or open a query above if a customer document needs correction.
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>
@@ -419,7 +433,7 @@ export function ContractClearanceReviewSection({
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
{clearance.allApproved
? "All required documents are approved. Upload declaration, duty, transit permit, and delivery order in the action panel."
? "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>
@@ -498,6 +512,7 @@ function DocReviewCard({
reviewerTeam,
readOnly,
approvalsLocked,
queriesLocked,
note,
queryOpen,
onToggleQuery,
@@ -511,6 +526,7 @@ function DocReviewCard({
reviewerTeam: string;
readOnly: boolean;
approvalsLocked: boolean;
queriesLocked: boolean;
note: string;
queryOpen: boolean;
onToggleQuery: (open: boolean) => void;
@@ -636,17 +652,19 @@ function DocReviewCard({
<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>
{!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"

View File

@@ -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>
);
}

View File

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

View File

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

View File

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

View File

@@ -13,13 +13,13 @@ export function TransportDocumentCard({ bookingId }: { bookingId: string }) {
return (
<ActionShell
icon={FileText}
title="Export transport document"
title="Transit permit"
subtitle="Upload after wagon allocation (GL Ethiopia)"
done={false}
>
<Stack gap="sm">
<FileInput
label="Transport document"
label="Transit permit"
value={file}
onChange={setFile}
size="sm"

View File

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

View File

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