mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 01:18:18 +00:00
finilize gl
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { Box, Button, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone";
|
||||
import { INK } from "@/pages/contracts/contract-ui";
|
||||
|
||||
export type AdHocDoc = { name: string; file: File | null };
|
||||
|
||||
export function ClearanceAdHocUploadSection({
|
||||
rows,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onNameChange,
|
||||
onFileChange,
|
||||
onPreview,
|
||||
}: {
|
||||
rows: AdHocDoc[];
|
||||
onAdd: () => void;
|
||||
onRemove: (index: number) => void;
|
||||
onNameChange: (index: number, name: string) => void;
|
||||
onFileChange: (index: number, file: File | null) => void;
|
||||
onPreview?: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||
}) {
|
||||
return (
|
||||
<Box mt="lg">
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<Box>
|
||||
<Text fz={13} fw={700} style={{ color: INK }}>
|
||||
Additional documents
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed">
|
||||
Optional supporting files not listed above.
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={14} />}
|
||||
onClick={onAdd}
|
||||
>
|
||||
Add document
|
||||
</Button>
|
||||
</Group>
|
||||
<Stack gap="md">
|
||||
{rows.map((row, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
p="md"
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: "1px solid #E6ECF2",
|
||||
background: "#FAFCFE",
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<TextInput
|
||||
label="Document name"
|
||||
placeholder="e.g. Special permit"
|
||||
value={row.name}
|
||||
onChange={(e) => onNameChange(i, e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
radius="md"
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
mt={24}
|
||||
leftSection={<Trash2 size={14} />}
|
||||
onClick={() => onRemove(i)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
<PortalFileDropzone
|
||||
label="File"
|
||||
value={row.file}
|
||||
onChange={(file) => onFileChange(i, file)}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { BORDER, GREEN, INK } from "@/pages/contracts/contract-ui";
|
||||
|
||||
type ReviewStatus = "PENDING" | "APPROVED" | "QUERIED" | null;
|
||||
|
||||
export interface ClearanceDocumentUploadCardProps {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
reviewStatus?: ReviewStatus;
|
||||
note?: string | null;
|
||||
uploadedFile?: { id: string; name: string } | null;
|
||||
stagedFile?: File | null;
|
||||
canUpload?: boolean;
|
||||
onStageFile?: (file: File | null) => void;
|
||||
onPreview?: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||
}
|
||||
|
||||
function StatusBadge({ status, hasFile }: { status?: ReviewStatus; hasFile: boolean }) {
|
||||
if (status === "APPROVED") {
|
||||
return (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={12} />}
|
||||
radius="sm"
|
||||
>
|
||||
Approved
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status === "QUERIED") {
|
||||
return (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<AlertCircle size={12} />}
|
||||
radius="sm"
|
||||
>
|
||||
Needs correction
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (hasFile) {
|
||||
return (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<Clock size={12} />}
|
||||
radius="sm"
|
||||
>
|
||||
Under review
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="gray" radius="sm">
|
||||
Not uploaded
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClearanceDocumentUploadCard({
|
||||
label,
|
||||
required = false,
|
||||
reviewStatus = null,
|
||||
note,
|
||||
uploadedFile,
|
||||
stagedFile = null,
|
||||
canUpload = false,
|
||||
onStageFile,
|
||||
onPreview,
|
||||
}: ClearanceDocumentUploadCardProps) {
|
||||
const queried = reviewStatus === "QUERIED";
|
||||
const approved = reviewStatus === "APPROVED";
|
||||
const showUpload = canUpload && !approved && onStageFile;
|
||||
const viewUrl = uploadedFile ? fileViewUrl(uploadedFile.id) : null;
|
||||
const canPreviewUploaded =
|
||||
uploadedFile &&
|
||||
viewUrl &&
|
||||
isViewable({ name: uploadedFile.name, url: viewUrl });
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="md"
|
||||
style={{
|
||||
borderColor: queried ? "#F0B4B4" : uploadedFile ? `${GREEN}55` : BORDER,
|
||||
background: queried
|
||||
? "linear-gradient(160deg, #FDF4F4 0%, #FFFFFF 70%)"
|
||||
: uploadedFile
|
||||
? "linear-gradient(160deg, #F6FBF8 0%, #FFFFFF 70%)"
|
||||
: "#fff",
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={queried ? "red" : uploadedFile ? "edr-green" : "gray"}
|
||||
radius="md"
|
||||
size={42}
|
||||
>
|
||||
<FileText size={20} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||
{label}
|
||||
{required ? (
|
||||
<Text component="span" c="red" inherit>
|
||||
{" "}
|
||||
*
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
{uploadedFile && !stagedFile ? (
|
||||
<Text fz={12} c="dimmed" truncate mt={2}>
|
||||
{uploadedFile.name}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Group>
|
||||
<StatusBadge status={reviewStatus} hasFile={Boolean(uploadedFile)} />
|
||||
</Group>
|
||||
|
||||
{queried && note ? (
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: "#FEF2F2",
|
||||
border: "1px solid #F0B4B4",
|
||||
}}
|
||||
>
|
||||
<Text fz={12} fw={600} c="red.8">
|
||||
Reviewer note
|
||||
</Text>
|
||||
<Text fz={12} c="red.7" mt={4}>
|
||||
{note}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{uploadedFile && !stagedFile ? (
|
||||
<Group gap={8}>
|
||||
{canPreviewUploaded && onPreview ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Eye size={14} />}
|
||||
onClick={() =>
|
||||
onPreview({ name: uploadedFile.name, url: viewUrl! })
|
||||
}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
component="a"
|
||||
href={fileViewUrl(uploadedFile.id, true)}
|
||||
download={uploadedFile.name}
|
||||
leftSection={<Download size={14} />}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{showUpload ? (
|
||||
<PortalFileDropzone
|
||||
label={uploadedFile || stagedFile ? "Replace file" : "Upload file"}
|
||||
description="PDF or image — drag and drop or browse."
|
||||
value={stagedFile}
|
||||
onChange={onStageFile}
|
||||
replaceMode={Boolean(uploadedFile)}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Download, Eye, FileText, Receipt, Ship, Truck } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { BORDER, GREEN, INK } from "@/pages/contracts/contract-ui";
|
||||
|
||||
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:
|
||||
"Global Logistics will upload your customs declaration documents here once they are ready.",
|
||||
},
|
||||
{
|
||||
value: "djibouti",
|
||||
label: "Release order",
|
||||
icon: Ship,
|
||||
emptyHint: "The release order will appear here once Global Logistics Djibouti uploads it.",
|
||||
},
|
||||
{
|
||||
value: "transit",
|
||||
label: "Transit Permit",
|
||||
icon: Truck,
|
||||
emptyHint:
|
||||
"The transit permit will appear here after your booking is created and wagons are allocated.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
value: "declaration",
|
||||
label: "Declaration",
|
||||
icon: FileText,
|
||||
emptyHint:
|
||||
"Global Logistics will upload your customs declaration documents here once they are ready.",
|
||||
},
|
||||
{
|
||||
value: "duty",
|
||||
label: "Duty notice",
|
||||
icon: Receipt,
|
||||
emptyHint: "Your duty/tax notice and payment slip will appear here when available.",
|
||||
},
|
||||
{
|
||||
value: "transit",
|
||||
label: "Transit Permit",
|
||||
icon: Truck,
|
||||
emptyHint: "The transit permit will appear here once Global Logistics uploads it.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function defaultSubtitle(tradeDirection: string): string {
|
||||
return tradeDirection === "EXPORT"
|
||||
? "Declaration, release order, and transit permit shared during your clearance."
|
||||
: "Declaration, duty notice, and transit permit shared during your clearance.";
|
||||
}
|
||||
|
||||
const OWNER_LABELS: Record<Freight.ClearanceWorkflowFileOwner, string> = {
|
||||
customer: "You",
|
||||
gl_et: "Global Logistics Ethiopia",
|
||||
gl_dj: "Global Logistics Djibouti",
|
||||
};
|
||||
|
||||
function fileTypeChip(name: string): { ext: string; color: string } {
|
||||
const dot = name.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? name.slice(dot + 1).toUpperCase() : "FILE";
|
||||
const color =
|
||||
ext === "PDF"
|
||||
? "#D64545"
|
||||
: ["PNG", "JPG", "JPEG", "GIF", "WEBP", "SVG"].includes(ext)
|
||||
? "#2F9E6E"
|
||||
: "#6B7C8E";
|
||||
return { ext: ext.slice(0, 4), color };
|
||||
}
|
||||
|
||||
export interface ClearanceUploadedDocumentsPanelProps {
|
||||
files: Freight.ClearanceWorkflowFile[];
|
||||
tradeDirection?: string;
|
||||
onView: (file: { name: string; url: string; mimeType?: string }) => void;
|
||||
onDownload?: (file: { id: string; name: string }) => void;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function ClearanceUploadedDocumentsPanel({
|
||||
files,
|
||||
tradeDirection = "IMPORT",
|
||||
onView,
|
||||
onDownload,
|
||||
title = "Customs documents",
|
||||
subtitle,
|
||||
embedded = false,
|
||||
}: 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";
|
||||
|
||||
const resolvedSubtitle = subtitle ?? defaultSubtitle(tradeDirection);
|
||||
|
||||
const content = (
|
||||
<Tabs defaultValue={defaultTab} keepMounted={false}>
|
||||
<Tabs.List mb="md" style={{ flexWrap: "wrap", gap: 6 }}>
|
||||
{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
|
||||
}
|
||||
styles={{
|
||||
tab: { borderRadius: 10, fontWeight: 600, padding: "8px 14px" },
|
||||
}}
|
||||
>
|
||||
{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={10}>
|
||||
{items.map((item) => (
|
||||
<WorkflowFileRow
|
||||
key={item.code}
|
||||
item={item}
|
||||
onView={onView}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<EmptyTabState icon={Icon} hint={tab.emptyHint} />
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
if (embedded) return content;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="lg"
|
||||
style={{
|
||||
borderColor: "#CDEBDD",
|
||||
background: "linear-gradient(160deg, #F6FBF8 0%, #FFFFFF 55%)",
|
||||
boxShadow: "0 4px 18px rgba(14,163,113,0.06)",
|
||||
}}
|
||||
>
|
||||
<Group gap={10} mb={4} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: `${GREEN}18`,
|
||||
color: GREEN,
|
||||
}}
|
||||
>
|
||||
<FileText size={18} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={700} fz={15} style={{ color: INK }}>
|
||||
{title}
|
||||
</Text>
|
||||
{uploadedCount > 0 ? (
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
{uploadedCount} file{uploadedCount === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text fz={12} c="dimmed" mt={2}>
|
||||
{resolvedSubtitle}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Box mt="md">{content}</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowFileRow({
|
||||
item,
|
||||
onView,
|
||||
onDownload,
|
||||
}: {
|
||||
item: Freight.ClearanceWorkflowFile;
|
||||
onView: (file: { name: string; url: string; mimeType?: string }) => void;
|
||||
onDownload?: (file: { id: string; name: string }) => void;
|
||||
}) {
|
||||
const file = item.file;
|
||||
if (!file) return null;
|
||||
|
||||
const { ext, color } = fileTypeChip(file.name);
|
||||
const canPreview = isViewable({ name: file.name, url: file.url });
|
||||
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1px solid ${GREEN}44`,
|
||||
background: "linear-gradient(135deg, #F2FBF6 0%, #FFFFFF 72%)",
|
||||
}}
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
flexShrink: 0,
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: `${color}14`,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
<FileText size={16} />
|
||||
<Text fz={8} fw={800} mt={1} style={{ letterSpacing: "0.04em" }}>
|
||||
{ext}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={600} style={{ color: INK }} truncate>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap" mt={3}>
|
||||
<Badge size="xs" variant="light" color="edr-green" radius="sm">
|
||||
Uploaded
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm" tt="none">
|
||||
{OWNER_LABELS[item.uploadedBy]}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text fz={12} c="dimmed" truncate mt={2}>
|
||||
{file.name}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{canPreview ? (
|
||||
<Tooltip label="Preview">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() => onView({ name: file.name, url: file.url })}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{onDownload ? (
|
||||
<Tooltip label="Download">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<Download size={13} />}
|
||||
onClick={() => onDownload({ id: file.id, name: file.name })}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyTabState({
|
||||
icon: Icon,
|
||||
hint,
|
||||
}: {
|
||||
icon: typeof FileText;
|
||||
hint: string;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
py={36}
|
||||
px="md"
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1px dashed ${BORDER}`,
|
||||
background: "#FAFCFE",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Stack gap={8} align="center">
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={44}>
|
||||
<Icon size={20} />
|
||||
</ThemeIcon>
|
||||
<Text fz={13} c="dimmed" maw={360}>
|
||||
{hint}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Use ClearanceUploadedDocumentsPanel for phased customs UI. */
|
||||
export function ClearanceWorkflowFilesSection({
|
||||
files,
|
||||
onView,
|
||||
onDownload,
|
||||
title,
|
||||
}: {
|
||||
files: Freight.ClearanceWorkflowFile[];
|
||||
onView: (file: { name: string; url: string; mimeType?: string }) => void;
|
||||
onDownload?: (file: { id: string; name: string }) => void;
|
||||
title?: string;
|
||||
}) {
|
||||
if (files.length === 0) return null;
|
||||
return (
|
||||
<ClearanceUploadedDocumentsPanel
|
||||
files={files}
|
||||
onView={onView}
|
||||
onDownload={onDownload}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
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";
|
||||
|
||||
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: "You",
|
||||
gl_et: "GL Ethiopia",
|
||||
gl_dj: "GL Djibouti",
|
||||
};
|
||||
|
||||
export function ClearanceWorkflowFilesSection({
|
||||
files,
|
||||
onView,
|
||||
onDownload,
|
||||
title = "Customs documents",
|
||||
}: {
|
||||
files: Freight.ClearanceWorkflowFile[];
|
||||
onView: (file: { name: string; url: string; mimeType?: string }) => void;
|
||||
onDownload?: (file: { id: string; name: string }) => void;
|
||||
title?: string;
|
||||
}) {
|
||||
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 (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Text fw={700} size="sm" mb="md">
|
||||
{title}
|
||||
</Text>
|
||||
<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) => {
|
||||
const file = item.file;
|
||||
if (!file) return null;
|
||||
const canPreview = isViewable({ name: file.name, url: file.url });
|
||||
return (
|
||||
<Paper key={item.code} withBorder radius="md" p="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<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: file.url })
|
||||
}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{onDownload ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<Download size={13} />}
|
||||
onClick={() => onDownload({ id: file.id, name: file.name })}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
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";
|
||||
|
||||
import { BORDER, GREEN, INK } from "@/pages/contracts/contract-ui";
|
||||
|
||||
export interface PortalFileDropzoneProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: File | null;
|
||||
onChange: (file: File | null) => void;
|
||||
accept?: string;
|
||||
onPreview?: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||
disabled?: boolean;
|
||||
replaceMode?: 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 PortalFileDropzone({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
accept = "application/pdf,image/*",
|
||||
onPreview,
|
||||
disabled = false,
|
||||
replaceMode = false,
|
||||
}: PortalFileDropzoneProps) {
|
||||
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 fz={13} fw={700} style={{ color: INK }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Group
|
||||
gap={12}
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1px solid ${GREEN}`,
|
||||
background: "linear-gradient(135deg, #F2FBF6 0%, #fff 75%)",
|
||||
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 ${BORDER}`,
|
||||
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, flex: 1 }}>
|
||||
<Text fz={11} fw={700} c="edr-green" tt="uppercase">
|
||||
Ready to upload
|
||||
</Text>
|
||||
<Text fz={13} fw={600} style={{ color: INK }} truncate>
|
||||
{value.name}
|
||||
</Text>
|
||||
<Text fz={11} c="dimmed">
|
||||
{formatBytes(value.size)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{canPreview ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
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>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Text fz={13} fw={700} style={{ color: INK }}>
|
||||
{label}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Text fz={12} 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: 14,
|
||||
border: `2px dashed ${dragOver ? GREEN : BORDER}`,
|
||||
background: dragOver ? "#F2FBF6" : "#FAFCFE",
|
||||
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 fz={13} fw={600} style={{ color: INK }}>
|
||||
{dragOver
|
||||
? "Drop to upload"
|
||||
: replaceMode
|
||||
? "Drag & drop to replace"
|
||||
: "Drag & drop your file here"}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed" mt={4}>
|
||||
or{" "}
|
||||
<span style={{ color: GREEN, fontWeight: 700 }}>browse</span> — PDF or
|
||||
image
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user