mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 07:08:18 +00:00
563 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|