diff --git a/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx b/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx index 4ca24476c..c00777ef7 100644 --- a/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx +++ b/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx @@ -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(); - * - * {viewer} - */ -export function useFileViewer() { - const [file, setFile] = useState(null); - - const view = useCallback((f: ViewableFile) => setFile(f), []); - const close = useCallback(() => setFile(null), []); - - const viewer = ( - - ); - - 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"; diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx index 4d5c4b759..6fbcbe911 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx @@ -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 = { + 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>({}); + const [documentFiles, setDocumentFiles] = useState< + Record + >({}); 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 = + {}; + 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) => 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 ( - - - - Documents - - - Upload and manage required business documents - - - {docSettingQuery.isLoading ? ( -
- -
- ) : !docSettingQuery.data ? ( - - No document requirements configured for your account. + <> + + + + Documents + + + Upload and manage required business documents - ) : ( - - )} - {docSettingQuery.data && ( - - - {docUploadMutation.isSuccess && ( - - - - {mode === "onboarding" ? "Saved successfully" : "Documents uploaded successfully"} - - - )} - {docUploadMutation.isError && ( - - - Upload failed - + {docSettingQuery.isLoading ? ( +
+ +
+ ) : !docSettingQuery.data ? ( + + No document requirements configured for your account. + + ) : ( + + )} + + {docSettingQuery.data && ( + + + {docUploadMutation.isSuccess && ( + + + + {mode === "onboarding" + ? "Saved successfully" + : "Documents uploaded successfully"} + + + )} + {docUploadMutation.isError && ( + + + + Upload failed + + + )} + + {mode === "onboarding" ? ( + + ) : ( + )} - {mode === "onboarding" ? ( - - ) : ( - - )} -
+ )} +
+ + {licenseProfiles.length > 0 && ( + + + + Business licenses + + + License documents uploaded per operational profile + + + + {licenseProfiles.map((p) => ( + + + {ROLE_LABELS[p.type] ?? p.type} ยท {p.reference} + + {p.licenseFiles.map((f) => ( + + + + {f.name} + + + ))} + + ))} + + )} -
+ ); } diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index a09bd4b4e..1ba83ec30 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -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( - "train-scheduling", - "availableDaysForCargo", - (input) => bookingsService.getAvailableDaysForCargo(input), + getAvailableDaysForCargo: endpoint< + Freight.AvailableDaysForCargoQuery, + string[] + >("train-scheduling", "availableDaysForCargo", (input) => + bookingsService.getAvailableDaysForCargo(input), ), getMyBookingWindows: endpoint( diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index d326811b8..d3f584170 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -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 => { + create: async ( + payload: CreateCompanyPayload, + ): Promise => { const response = await client.post>( URL_CONSTANTS.COMPANIES_API.CREATE, payload, @@ -195,7 +214,9 @@ export const companiesService = { return unwrap(response.data); }, - updateProfile: async (payload: UpdateProfilePayload): Promise => { + updateProfile: async ( + payload: UpdateProfilePayload, + ): Promise => { const response = await client.patch>( 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 => { + const response = await client.get>( + URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), + ); + return unwrap(response.data); }, /** Upload business-license document(s) for a company profile (multi-file). */