Files
edr-platform/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
2026-07-10 12:10:17 +00:00

563 lines
17 KiB
TypeScript

import { fileViewUrl } from "@/constants/apiConfig";
import { api } from "@/services/api";
import {
companiesService,
type CompanyProfileResponse,
type LicenseFileStatus,
} from "@/services/companies.service";
import { getMinFiles } from "@/types/fileUploadSettings";
import type { ProfileResponse } from "@/types/profile";
import {
SmartFileInput,
useFileViewer,
type ViewableFile,
} from "@edr/ui-common";
import {
ActionIcon,
Anchor,
Badge,
Button,
Card,
Center,
Group,
Stack,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
CheckCircle2,
Clock,
FileCheck,
FileText,
Loader2,
Paperclip,
RefreshCw,
Trash2,
UploadCloud,
XCircle,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
const ROLE_LABELS: Record<string, string> = {
importer: "Importer",
exporter: "Exporter",
freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter",
};
interface TabDocumentsProps {
profile: ProfileResponse;
mode?: "edit" | "onboarding";
onContinue?: () => void;
}
function documentSettingCode(nationality: string | null | undefined): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
/**
* The delegation letter ships in the same nationality document set, but it is
* edited on the Power of Attorney tab (where it is staged for review alongside
* the PoA details), so it is excluded from this tab's uploader.
*/
const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
export default function TabDocuments({
profile,
mode = "edit",
onContinue,
}: TabDocumentsProps) {
const queryClient = useQueryClient();
const { view, viewer } = useFileViewer();
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
const docSettingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode(profile.nationality) },
}),
);
const docSetting = useMemo(() => {
const setting = docSettingQuery.data;
if (!setting) return setting;
return {
...setting,
fields: setting.fields.filter(
(f) => f.fileKey !== POA_DELEGATION_FILE_KEY,
),
};
}, [docSettingQuery.data]);
const docsQuery = useQuery(
api.companies.documents.queryOptions({
input: { companyId: profile.companyId },
}),
);
const uploadedKeys = useMemo(
() => (docsQuery.data ?? []).map((d) => d.code),
[docsQuery.data],
);
const existingFilesByKey = useMemo(() => {
const map: Record<
string,
{ name: string; url: string; size?: number; mimeType?: string | null }[]
> = {};
for (const doc of docsQuery.data ?? []) {
(map[doc.code] ??= []).push({
name: doc.name,
url: fileViewUrl(doc.id),
size: doc.size,
mimeType: doc.mimeType,
});
}
return map;
}, [docsQuery.data]);
const docUploadMutation = useMutation({
mutationFn: (files: Record<string, File | File[] | null>) =>
companiesService.uploadDocuments(profile.companyId, files),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
},
});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const handleFilesChange = (next: Record<string, File | File[] | null>) => {
setDocumentFiles(next);
// Clear required-field errors for any field that now has a file.
setFieldErrors((prev) => {
if (Object.keys(prev).length === 0) return prev;
const updated = { ...prev };
for (const key of Object.keys(updated)) {
const v = next[key];
const hasValue = Array.isArray(v) ? v.length > 0 : v != null;
if (hasValue) delete updated[key];
}
return updated;
});
};
// Array-aware: an emptied multi-file field is `[]`, which must not count.
const hasFiles = Object.values(documentFiles).some((f) =>
Array.isArray(f) ? f.length > 0 : f != null,
);
const validateRequired = (): Record<string, string> => {
const errs: Record<string, string> = {};
for (const field of docSetting?.fields ?? []) {
const min = getMinFiles(field);
if (min <= 0) continue;
if (uploadedKeys.includes(field.fileKey)) continue;
const v = documentFiles[field.fileKey];
const count = Array.isArray(v) ? v.length : v ? 1 : 0;
if (count < min) {
errs[field.fileKey] = `${field.fileLabel} is required`;
}
}
return errs;
};
const licenseProfiles = profile.companyProfiles;
return (
<>
<Card padding="lg">
<Group gap="sm" mb="xs">
<FileCheck size={20} />
<Title order={3}>Documents</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Upload and manage required business documents
</Text>
{docSettingQuery.isLoading ? (
<Center py="xl">
<Loader2 size={24} className="animate-spin" />
</Center>
) : !docSetting ? (
<Text c="edr-muted" size="sm" ta="center" py="md">
No document requirements configured for your account.
</Text>
) : (
<SmartFileInput
file={docSetting}
value={documentFiles}
onChange={handleFilesChange}
errors={fieldErrors}
uploadedKeys={uploadedKeys}
existingFiles={existingFilesByKey}
onViewFile={view}
/>
)}
{docSetting && (
<Group
justify="space-between"
mt="lg"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{docUploadMutation.isSuccess && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
{mode === "onboarding"
? "Saved successfully"
: "Documents uploaded successfully"}
</Text>
</Group>
)}
{docUploadMutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
Upload failed
</Text>
</Group>
)}
</Group>
{mode === "onboarding" ? (
<Button
type="button"
leftSection={<ArrowRight size={16} />}
loading={docUploadMutation.isPending}
onClick={() => {
const validationErrors = validateRequired();
if (Object.keys(validationErrors).length > 0) {
setFieldErrors(validationErrors);
return;
}
if (hasFiles) {
docUploadMutation.mutate(documentFiles, {
onSuccess: () => onContinue?.(),
});
} else {
onContinue?.();
}
}}
>
Continue
</Button>
) : (
<Button
type="button"
leftSection={<UploadCloud size={16} />}
loading={docUploadMutation.isPending}
disabled={!hasFiles}
onClick={() => {
if (!hasFiles) return;
docUploadMutation.mutate(documentFiles);
}}
>
Upload Documents
</Button>
)}
</Group>
)}
</Card>
{licenseProfiles.length > 0 && (
<Card padding="lg" mt="lg">
<Group gap="sm" mb="xs">
<Paperclip size={20} />
<Title order={3}>Business licenses</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Add, replace or remove the license documents for each operational
profile. Changes are submitted to EDR for review before they take
effect.
</Text>
<Stack gap="xl">
{licenseProfiles.map((p) => (
<ProfileLicenseRow
key={p.id}
profile={p}
onViewFile={view}
reviewPending={profile.reviewStatus === "pending"}
/>
))}
</Stack>
</Card>
)}
{viewer}
</>
);
}
const LICENSE_ACCEPT = ".pdf,.png,.jpg,.jpeg";
function formatBytes(bytes: number): string {
if (!bytes) return "";
const units = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(1))} ${units[i]}`;
}
const STATUS_BADGE: Record<
LicenseFileStatus,
{ label: string; color: string; bg: string; fg: string } | null
> = {
live: null,
pending_add: {
label: "Pending approval",
color: "edr-amber",
bg: "var(--mantine-color-edr-amber-soft-0)",
fg: "var(--mantine-color-edr-amber-text-0)",
},
pending_remove: {
label: "Removal pending",
color: "edr-red",
bg: "var(--mantine-color-edr-red-soft-0)",
fg: "var(--mantine-color-edr-red-0)",
},
};
/**
* One operational profile's business-license documents. Lists each file (click
* to preview via the file proxy) with its review state, and lets the customer
* add / replace / remove files. Every mutation opens a change request the
* backoffice must approve; while one is open the parent locks this whole tab.
*/
function ProfileLicenseRow({
profile,
onViewFile,
reviewPending,
}: {
profile: CompanyProfileResponse;
onViewFile: (file: ViewableFile) => void;
reviewPending: boolean;
}) {
const queryClient = useQueryClient();
const addInputRef = useRef<HTMLInputElement>(null);
const replaceInputRef = useRef<HTMLInputElement>(null);
const replaceTargetId = useRef<string | null>(null);
const [error, setError] = useState<string | null>(null);
const invalidate = () => {
setError(null);
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
};
const addMutation = useMutation({
mutationFn: (files: File[]) =>
companiesService.uploadProfileLicense(profile.id, files),
onSuccess: invalidate,
onError: () => setError("Upload failed. Please try again."),
});
const replaceMutation = useMutation({
mutationFn: ({ fileId, file }: { fileId: string; file: File }) =>
companiesService.replaceProfileLicense(profile.id, fileId, file),
onSuccess: invalidate,
onError: () => setError("Replace failed. Please try again."),
});
const removeMutation = useMutation({
mutationFn: (fileId: string) =>
companiesService.removeProfileLicense(profile.id, fileId),
onSuccess: invalidate,
onError: () => setError("Remove failed. Please try again."),
});
const busy =
addMutation.isPending ||
replaceMutation.isPending ||
removeMutation.isPending;
const files = profile.licenseFiles ?? [];
return (
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text size="sm" fw={700} c="edr-text">
{ROLE_LABELS[profile.type] ?? profile.type}
{profile.reference ? ` · ${profile.reference}` : ""}
</Text>
<Button
variant="light"
size="xs"
leftSection={<UploadCloud size={14} />}
loading={addMutation.isPending}
disabled={busy}
onClick={() => addInputRef.current?.click()}
>
Add document
</Button>
</Group>
{files.length === 0 ? (
<Card
padding="md"
radius="md"
style={{
borderStyle: "dashed",
backgroundColor: "var(--mantine-color-edr-bg-0)",
}}
>
<Text size="sm" c="edr-muted" ta="center">
No license documents yet.
</Text>
</Card>
) : (
<Stack gap="xs">
{files.map((f) => {
const badge = STATUS_BADGE[f.status];
const isPending = f.status !== "live";
return (
<Card
key={f.id}
padding="sm"
radius="md"
withBorder
style={{ backgroundColor: "var(--mantine-color-edr-card-0)" }}
>
<Group gap="sm" wrap="nowrap">
<FileText
size={18}
className="text-edr-muted"
style={{ flexShrink: 0 }}
/>
<Stack gap={0} style={{ minWidth: 0, flex: 1 }}>
<Anchor
component="button"
type="button"
size="sm"
fw={600}
onClick={() =>
onViewFile({
name: f.name,
url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
}
style={{
textAlign: "left",
textDecoration:
f.status === "pending_remove"
? "line-through"
: undefined,
}}
lineClamp={1}
>
{f.name}
</Anchor>
{f.size > 0 && (
<Text size="xs" c="edr-muted">
{formatBytes(f.size)}
</Text>
)}
</Stack>
{badge && (
<Badge
size="sm"
radius="sm"
variant="light"
leftSection={<Clock size={11} />}
style={{
backgroundColor: badge.bg,
color: badge.fg,
flexShrink: 0,
}}
>
{badge.label}
</Badge>
)}
<Tooltip label="Replace" withArrow>
<ActionIcon
variant="subtle"
color="gray"
aria-label={`Replace ${f.name}`}
disabled={busy || isPending}
onClick={() => {
replaceTargetId.current = f.id;
replaceInputRef.current?.click();
}}
>
<RefreshCw size={15} />
</ActionIcon>
</Tooltip>
<Tooltip label="Remove" withArrow>
<ActionIcon
variant="subtle"
color="red"
aria-label={`Remove ${f.name}`}
disabled={busy || isPending}
loading={
removeMutation.isPending &&
removeMutation.variables === f.id
}
onClick={() => removeMutation.mutate(f.id)}
>
<Trash2 size={15} />
</ActionIcon>
</Tooltip>
</Group>
</Card>
);
})}
</Stack>
)}
{reviewPending && (
<Group gap={6} c="edr-amber-text">
<Clock size={13} />
<Text size="xs" fw={500}>
Awaiting EDR review further changes are disabled until it clears.
</Text>
</Group>
)}
{error && (
<Group gap={6} c="red">
<XCircle size={13} />
<Text size="xs" fw={500}>
{error}
</Text>
</Group>
)}
<input
ref={addInputRef}
type="file"
multiple
accept={LICENSE_ACCEPT}
style={{ display: "none" }}
onChange={(e) => {
const picked = e.target.files ? Array.from(e.target.files) : [];
if (picked.length > 0) addMutation.mutate(picked);
e.target.value = "";
}}
/>
<input
ref={replaceInputRef}
type="file"
accept={LICENSE_ACCEPT}
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
const fileId = replaceTargetId.current;
if (file && fileId) replaceMutation.mutate({ fileId, file });
replaceTargetId.current = null;
e.target.value = "";
}}
/>
</Stack>
);
}