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";
import { FileViewerModal, type ViewableFile } 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 };
}
// Re-export of the shared hook, now living in @edr/ui-common. Kept so existing
// `@/hooks/useFileViewer` imports keep working.
export { useFileViewer } from "@edr/ui-common";

View File

@@ -4,10 +4,12 @@ import { getMinFiles } from "@/types/fileUploadSettings";
import type { ProfileResponse } from "@/types/profile";
import { SmartFileInput } from "@edr/ui-common";
import {
Anchor,
Button,
Card,
Center,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
@@ -17,10 +19,19 @@ import {
CheckCircle2,
FileCheck,
Loader2,
Paperclip,
UploadCloud,
XCircle,
} 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 {
profile: ProfileResponse;
@@ -28,21 +39,59 @@ interface TabDocumentsProps {
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 [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({});
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
const docSettingQuery = useQuery(
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({
mutationFn: (files: Record<string, File | File[] | null>) =>
companiesService.uploadDocuments(profile.companyId, files),
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 ?? []) {
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) {
@@ -82,94 +132,141 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
return errs;
};
const licenseProfiles = profile.companyProfiles.filter(
(p) => p.licenseFiles && p.licenseFiles.length > 0,
);
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>
) : !docSettingQuery.data ? (
<Text c="edr-muted" size="sm" ta="center" py="md">
No document requirements configured for your account.
<>
<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>
) : (
<SmartFileInput
file={docSettingQuery.data}
value={documentFiles}
onChange={handleFilesChange}
errors={fieldErrors}
/>
)}
{docSettingQuery.data && (
<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>
{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>
) : (
<SmartFileInput
file={docSettingQuery.data}
value={documentFiles}
onChange={handleFilesChange}
errors={fieldErrors}
uploadedKeys={uploadedKeys}
existingFiles={existingFilesByKey}
/>
)}
{docSettingQuery.data && (
<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>
{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">
License documents uploaded per operational profile
</Text>
<Stack gap="md">
{licenseProfiles.map((p) => (
<Stack key={p.id} gap={4}>
<Text size="sm" fw={600} c="edr-text">
{ROLE_LABELS[p.type] ?? p.type} · {p.reference}
</Text>
{p.licenseFiles.map((f) => (
<Group key={f.url} gap={6} wrap="nowrap">
<Paperclip size={13} className="text-edr-muted" />
<Anchor
href={f.url}
target="_blank"
rel="noopener noreferrer"
size="xs"
>
{f.name}
</Anchor>
</Group>
))}
</Stack>
))}
</Stack>
</Card>
)}
</Card>
</>
);
}

View File

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

View File

@@ -82,6 +82,18 @@ export interface CompanyInfoResponse {
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. */
export interface OnboardingDocumentField {
fileKey: string;
@@ -124,7 +136,12 @@ export interface OnboardingRequirements {
}
export interface CompanyProfileInput {
type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter";
type:
| "importer"
| "exporter"
| "freight_forwarder"
| "dj_freight_forwarder"
| "transporter";
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>>(
URL_CONSTANTS.COMPANIES_API.CREATE,
payload,
@@ -195,7 +214,9 @@ export const companiesService = {
return unwrap(response.data);
},
updateProfile: async (payload: UpdateProfilePayload): Promise<ProfileResponse> => {
updateProfile: async (
payload: UpdateProfilePayload,
): Promise<ProfileResponse> => {
const response = await client.patch<ApiResponse<ProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.PROFILE,
payload,
@@ -293,7 +314,18 @@ export const companiesService = {
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). */