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

View File

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

View File

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

View File

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

View File

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