feat: add existing files to customer documet tab on portal

This commit is contained in:
Nathnael
2026-07-03 06:57:56 +00:00
parent 33ca30461e
commit 300afb4c77
4 changed files with 245 additions and 127 deletions

View File

@@ -1,24 +1,3 @@
import { useCallback, useState } from "react"; // Re-export of the shared hook, now living in @edr/ui-common. Kept so existing
import { FileViewerModal, type ViewableFile } from "@edr/ui-common"; // `@/hooks/useFileViewer` imports keep working.
export { useFileViewer } from "@edr/ui-common";
/**
* Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
* from any file row to open the document inline (pdf / image / video / office /
* text); render `viewer` once near the page root.
*
* const { view, viewer } = useFileViewer();
* <Button onClick={() => view({ name, url, mimeType })}>View</Button>
* {viewer}
*/
export function useFileViewer() {
const [file, setFile] = useState<ViewableFile | null>(null);
const view = useCallback((f: ViewableFile) => setFile(f), []);
const close = useCallback(() => setFile(null), []);
const viewer = (
<FileViewerModal open={file !== null} file={file} onClose={close} />
);
return { view, close, viewer };
}

View File

@@ -4,10 +4,12 @@ import { getMinFiles } from "@/types/fileUploadSettings";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
import { SmartFileInput } from "@edr/ui-common"; import { SmartFileInput } from "@edr/ui-common";
import { import {
Anchor,
Button, Button,
Card, Card,
Center, Center,
Group, Group,
Stack,
Text, Text,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
@@ -17,10 +19,19 @@ import {
CheckCircle2, CheckCircle2,
FileCheck, FileCheck,
Loader2, Loader2,
Paperclip,
UploadCloud, UploadCloud,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useMemo, 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 { interface TabDocumentsProps {
profile: ProfileResponse; profile: ProfileResponse;
@@ -28,21 +39,59 @@ interface TabDocumentsProps {
onContinue?: () => void; onContinue?: () => void;
} }
export default function TabDocuments({ profile, mode = "edit", onContinue }: TabDocumentsProps) { function documentSettingCode(nationality: string | null | undefined): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
export default function TabDocuments({
profile,
mode = "edit",
onContinue,
}: TabDocumentsProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({}); const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
const docSettingQuery = useQuery( const docSettingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({ api.fileUploadSettings.getByCode.queryOptions({
input: { code: "customer_file_documents" }, input: { code: documentSettingCode(profile.nationality) },
}), }),
); );
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 }[]> =
{};
for (const doc of docsQuery.data ?? []) {
(map[doc.code] ??= []).push({
name: doc.name,
url: doc.url,
size: doc.size,
});
}
return map;
}, [docsQuery.data]);
const docUploadMutation = useMutation({ const docUploadMutation = useMutation({
mutationFn: (files: Record<string, File | File[] | null>) => mutationFn: (files: Record<string, File | File[] | null>) =>
companiesService.uploadDocuments(profile.companyId, files), companiesService.uploadDocuments(profile.companyId, files),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
}, },
}); });
@@ -73,6 +122,7 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
for (const field of docSettingQuery.data?.fields ?? []) { for (const field of docSettingQuery.data?.fields ?? []) {
const min = getMinFiles(field); const min = getMinFiles(field);
if (min <= 0) continue; if (min <= 0) continue;
if (uploadedKeys.includes(field.fileKey)) continue;
const v = documentFiles[field.fileKey]; const v = documentFiles[field.fileKey];
const count = Array.isArray(v) ? v.length : v ? 1 : 0; const count = Array.isArray(v) ? v.length : v ? 1 : 0;
if (count < min) { if (count < min) {
@@ -82,94 +132,141 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
return errs; return errs;
}; };
const licenseProfiles = profile.companyProfiles.filter(
(p) => p.licenseFiles && p.licenseFiles.length > 0,
);
return ( return (
<Card padding="lg"> <>
<Group gap="sm" mb="xs"> <Card padding="lg">
<FileCheck size={20} /> <Group gap="sm" mb="xs">
<Title order={3}>Documents</Title> <FileCheck size={20} />
</Group> <Title order={3}>Documents</Title>
<Text c="edr-muted" size="sm" mb="lg"> </Group>
Upload and manage required business documents <Text c="edr-muted" size="sm" mb="lg">
</Text> Upload and manage required business documents
{docSettingQuery.isLoading ? (
<Center py="xl">
<Loader2 size={24} className="animate-spin" />
</Center>
) : !docSettingQuery.data ? (
<Text c="edr-muted" size="sm" ta="center" py="md">
No document requirements configured for your account.
</Text> </Text>
) : (
<SmartFileInput
file={docSettingQuery.data}
value={documentFiles}
onChange={handleFilesChange}
errors={fieldErrors}
/>
)}
{docSettingQuery.data && ( {docSettingQuery.isLoading ? (
<Group <Center py="xl">
justify="space-between" <Loader2 size={24} className="animate-spin" />
mt="lg" </Center>
pt="md" ) : !docSettingQuery.data ? (
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }} <Text c="edr-muted" size="sm" ta="center" py="md">
> No document requirements configured for your account.
<Group gap="xs"> </Text>
{docUploadMutation.isSuccess && ( ) : (
<Group gap={6} c="green"> <SmartFileInput
<CheckCircle2 size={16} /> file={docSettingQuery.data}
<Text size="sm" fw={500}> value={documentFiles}
{mode === "onboarding" ? "Saved successfully" : "Documents uploaded successfully"} onChange={handleFilesChange}
</Text> errors={fieldErrors}
</Group> uploadedKeys={uploadedKeys}
)} existingFiles={existingFilesByKey}
{docUploadMutation.isError && ( />
<Group gap={6} c="red"> )}
<XCircle size={16} />
<Text size="sm" fw={500}>Upload failed</Text> {docSettingQuery.data && (
</Group> <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> </Group>
{mode === "onboarding" ? ( )}
<Button </Card>
type="button"
leftSection={<ArrowRight size={16} />} {licenseProfiles.length > 0 && (
loading={docUploadMutation.isPending} <Card padding="lg" mt="lg">
onClick={() => { <Group gap="sm" mb="xs">
const validationErrors = validateRequired(); <Paperclip size={20} />
if (Object.keys(validationErrors).length > 0) { <Title order={3}>Business licenses</Title>
setFieldErrors(validationErrors); </Group>
return; <Text c="edr-muted" size="sm" mb="lg">
} License documents uploaded per operational profile
if (hasFiles) { </Text>
docUploadMutation.mutate(documentFiles, {
onSuccess: () => onContinue?.(), <Stack gap="md">
}); {licenseProfiles.map((p) => (
} else { <Stack key={p.id} gap={4}>
onContinue?.(); <Text size="sm" fw={600} c="edr-text">
} {ROLE_LABELS[p.type] ?? p.type} · {p.reference}
}} </Text>
> {p.licenseFiles.map((f) => (
Continue <Group key={f.url} gap={6} wrap="nowrap">
</Button> <Paperclip size={13} className="text-edr-muted" />
) : ( <Anchor
<Button href={f.url}
type="button" target="_blank"
leftSection={<UploadCloud size={16} />} rel="noopener noreferrer"
loading={docUploadMutation.isPending} size="xs"
disabled={!hasFiles} >
onClick={() => { {f.name}
if (!hasFiles) return; </Anchor>
docUploadMutation.mutate(documentFiles); </Group>
}} ))}
> </Stack>
Upload Documents ))}
</Button> </Stack>
)} </Card>
</Group>
)} )}
</Card> </>
); );
} }

View File

@@ -53,6 +53,7 @@ import {
UpdateDropdownSettingDto, UpdateDropdownSettingDto,
} from "@/types/dropdownSettings"; } from "@/types/dropdownSettings";
import type { import type {
CompanyDocument,
CompanyInfoResponse, CompanyInfoResponse,
CompanyNationality, CompanyNationality,
CompanyProfileResponse, CompanyProfileResponse,
@@ -158,7 +159,11 @@ export const api = {
createCompanyProfile: endpoint< createCompanyProfile: endpoint<
{ type: ProfileTypeValue; businessLicense?: string }, { type: ProfileTypeValue; businessLicense?: string },
CompanyProfileResponse CompanyProfileResponse
>("companies", "createCompanyProfile", companiesService.createCompanyProfile), >(
"companies",
"createCompanyProfile",
companiesService.createCompanyProfile,
),
startOnboarding: endpoint< startOnboarding: endpoint<
{ {
@@ -192,6 +197,12 @@ export const api = {
"onboardingRequirements", "onboardingRequirements",
companiesService.getOnboardingRequirements, companiesService.getOnboardingRequirements,
), ),
documents: endpoint<{ companyId: string }, CompanyDocument[]>(
"companies",
"documents",
({ companyId }) => companiesService.getDocuments(companyId),
),
}, },
bookings: { bookings: {
@@ -228,7 +239,8 @@ export const api = {
downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>( downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>(
"bookings", "bookings",
"downloadHandoverDocument", "downloadHandoverDocument",
({ inventoryId }) => bookingsService.downloadHandoverDocument(inventoryId), ({ inventoryId }) =>
bookingsService.downloadHandoverDocument(inventoryId),
), ),
create: endpoint< create: endpoint<
@@ -312,11 +324,8 @@ export const api = {
proceedToOperation: endpoint< proceedToOperation: endpoint<
{ id: string; scheduledDate: string }, { id: string; scheduledDate: string },
Freight.IBooking Freight.IBooking
>( >("bookings", "proceedToOperation", ({ id, scheduledDate }) =>
"bookings", bookingsService.proceedToOperation(id, scheduledDate),
"proceedToOperation",
({ id, scheduledDate }) =>
bookingsService.proceedToOperation(id, scheduledDate),
), ),
checkPayment: endpoint<{ orderId: string }, { status: string }>( checkPayment: endpoint<{ orderId: string }, { status: string }>(
@@ -354,10 +363,11 @@ export const api = {
bookingsService.getAvailableDays({ originYardId, destinationYardId }), bookingsService.getAvailableDays({ originYardId, destinationYardId }),
), ),
getAvailableDaysForCargo: endpoint<Freight.AvailableDaysForCargoQuery, string[]>( getAvailableDaysForCargo: endpoint<
"train-scheduling", Freight.AvailableDaysForCargoQuery,
"availableDaysForCargo", string[]
(input) => bookingsService.getAvailableDaysForCargo(input), >("train-scheduling", "availableDaysForCargo", (input) =>
bookingsService.getAvailableDaysForCargo(input),
), ),
getMyBookingWindows: endpoint<void, MyBookingWindow[]>( getMyBookingWindows: endpoint<void, MyBookingWindow[]>(

View File

@@ -82,6 +82,18 @@ export interface CompanyInfoResponse {
company: CompanyResponse; company: CompanyResponse;
} }
/** A single company-level document uploaded against a `file_upload_settings` field. */
export interface CompanyDocument {
id: string;
name: string;
/** The `fileKey` of the setting field it was uploaded against. */
code: string;
mimeType: string;
size: number;
uploadedAt: string;
url: string;
}
/** A single onboarding document field, as resolved and described by the backend. */ /** A single onboarding document field, as resolved and described by the backend. */
export interface OnboardingDocumentField { export interface OnboardingDocumentField {
fileKey: string; fileKey: string;
@@ -124,7 +136,12 @@ export interface OnboardingRequirements {
} }
export interface CompanyProfileInput { export interface CompanyProfileInput {
type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter"; type:
| "importer"
| "exporter"
| "freight_forwarder"
| "dj_freight_forwarder"
| "transporter";
businessLicense?: string; businessLicense?: string;
} }
@@ -180,7 +197,9 @@ export const companiesService = {
} }
}, },
create: async (payload: CreateCompanyPayload): Promise<CompanyInfoResponse> => { create: async (
payload: CreateCompanyPayload,
): Promise<CompanyInfoResponse> => {
const response = await client.post<ApiResponse<CompanyInfoResponse>>( const response = await client.post<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.CREATE, URL_CONSTANTS.COMPANIES_API.CREATE,
payload, payload,
@@ -195,7 +214,9 @@ export const companiesService = {
return unwrap(response.data); return unwrap(response.data);
}, },
updateProfile: async (payload: UpdateProfilePayload): Promise<ProfileResponse> => { updateProfile: async (
payload: UpdateProfilePayload,
): Promise<ProfileResponse> => {
const response = await client.patch<ApiResponse<ProfileResponse>>( const response = await client.patch<ApiResponse<ProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.PROFILE, URL_CONSTANTS.COMPANIES_API.PROFILE,
payload, payload,
@@ -293,7 +314,18 @@ export const companiesService = {
formData.append(fieldName, fileOrFiles); formData.append(fieldName, fileOrFiles);
} }
} }
await client.post(URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), formData); await client.post(
URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId),
formData,
);
},
/** List documents already uploaded for a company (settings-driven, by fileKey). */
getDocuments: async (companyId: string): Promise<CompanyDocument[]> => {
const response = await client.get<ApiResponse<CompanyDocument[]>>(
URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId),
);
return unwrap(response.data);
}, },
/** Upload business-license document(s) for a company profile (multi-file). */ /** Upload business-license document(s) for a company profile (multi-file). */