mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 13:10:56 +00:00
feat: add existing files to customer documet tab on portal
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[]>(
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user