From 552e6bcd16f27a86dea24d07e634799324d4953d Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 26 Jun 2026 12:31:08 +0000 Subject: [PATCH] fix: make the file sync work on onboarding --- .../onboarding/OnboardingWizardDialog.tsx | 190 ++++--- .../src/pages/accounts/CompanyProfileForm.tsx | 140 ++--- .../src/components/SmartFileInput/index.tsx | 479 +++++++++++------- 3 files changed, 501 insertions(+), 308 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 8d6bc3d93..4d4c8664a 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -164,7 +164,9 @@ export default function OnboardingWizardDialog({ (company?.company?.nationality as CompanyNationality | null) ?? null; // Resume position from the backend-persisted step. - const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep) + const resumeFormStep: FormStep = FORM_STEPS.includes( + onboardingStep as FormStep, + ) ? (onboardingStep as FormStep) : "company"; @@ -275,7 +277,7 @@ export default function OnboardingWizardDialog({ const idx = FORM_STEPS.indexOf(step as FormStep); if (idx < 0 || idx <= furthestIdxRef.current) return; furthestIdxRef.current = idx; - api.companies.setOnboardingStep.call({ step }).catch(() => {}); + api.companies.setOnboardingStep.call({ step }).catch(() => { }); }, []); // Mirror the form's step locally (for the header/pill) and persist it. @@ -321,7 +323,7 @@ export default function OnboardingWizardDialog({ // Note: no "back to role selection" — once the draft is created the role(s) // are fixed; the form's first-step Back is a no-op so progress never resets. - const handleBackToRoles = useCallback(() => {}, []); + const handleBackToRoles = useCallback(() => { }, []); // Save the current step's fields to the draft (PATCH /profile). Returns the // server error message on failure so the form can show it (e.g. duplicate TIN). @@ -339,6 +341,31 @@ export default function OnboardingWizardDialog({ [], ); + // Auto-upload the documents the user just selected as they leave the documents + // step. Only the in-memory selections are sent; once uploaded they're cleared + // (so the final submit never re-uploads them) and the requirements query is + // refreshed so the "Already uploaded" badges light up. Partial uploads are + // allowed — the user may continue even with required docs still outstanding. + const handleUploadDocuments = useCallback(async (): Promise< + { ok: true } | { ok: false; error: string } + > => { + const companyId = company?.company?.id; + const hasNew = Object.values(documentFiles).some( + (f) => f !== null && (Array.isArray(f) ? f.length > 0 : true), + ); + if (!companyId || !hasNew) return { ok: true }; + try { + await companiesService.uploadDocuments(companyId, documentFiles); + setDocumentFiles({}); + await queryClient.invalidateQueries({ + queryKey: api.companies.onboardingRequirements.queryKey(), + }); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractApiError(err).message }; + } + }, [company?.company?.id, documentFiles, queryClient]); + // Final confirm step → finalize onboarding (no company create; it already // exists as a draft that's been filled in step-by-step). const handleSubmit = useCallback( @@ -383,6 +410,25 @@ export default function OnboardingWizardDialog({ requirementsQuery.data?.documentSettingCode ?? documentSettingCode(effectiveNationality); + // Server-confirmed document state, used both to badge already-uploaded fields + // and to keep a refreshed resume from over-shooting the documents step. + const requirementDocuments = requirementsQuery.data?.documents ?? []; + const uploadedDocumentKeys = requirementDocuments + .filter((d) => d.uploaded) + .map((d) => d.fileKey); + // If any REQUIRED document is still missing, the resume must not rest past the + // documents step (don't skip to Business License) — clamp it back. This only + // changes the target once requirements load; the form follows the correction + // as long as the user hasn't navigated yet. + const requiredDocsMissing = requirementDocuments.some( + (d) => d.isRequired && !d.uploaded, + ); + const effectiveResumeStep: FormStep = + requiredDocsMissing && + FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents") + ? "documents" + : resumeFormStep; + const formProps = { documentSettingCode: resolvedDocumentSettingCode, documentFiles, @@ -392,7 +438,7 @@ export default function OnboardingWizardDialog({ isPending: finishMutation.isPending, onBack: handleBackToRoles, hideFirstStepBack: true, - initialStep: resumeFormStep, + initialStep: effectiveResumeStep, resyncOpen: opened, onStepChange: handleStepChange, onSaveStep: saveStep, @@ -400,6 +446,8 @@ export default function OnboardingWizardDialog({ roleProfiles, licenseFiles, onLicenseChange: setLicenseFiles, + uploadedDocumentKeys, + onUploadDocuments: handleUploadDocuments, // Surface a failed final submit (license/document upload or complete) inside // the form — otherwise the server message (e.g. a 500) would be invisible on // the submit step. @@ -413,7 +461,7 @@ export default function OnboardingWizardDialog({ withCloseButton={!completed} closeOnClickOutside={false} closeOnEscape={!completed} - size={720} + size={1440} radius="lg" padding="xl" centered @@ -422,11 +470,11 @@ export default function OnboardingWizardDialog({ overlayProps={{ backgroundOpacity: 0.55, blur: 4 }} styles={{ header: { - alignItems:"flex-start" + alignItems: "flex-start", }, title: { - flex: 1 - } + flex: 1, + }, }} title={ completed ? null : ( @@ -448,59 +496,64 @@ export default function OnboardingWizardDialog({ {completed ? ( ) : ( - - - {phase === "nationality" ? ( - - - - - - - ) : phase === "role" ? ( - - - {startError && ( - - {startError} - - )} - - - - - - ) : ( - - )} - + + {phase === "nationality" ? ( + + + + + + + ) : phase === "role" ? ( + + + {startError && ( + + {startError} + + )} + + + + + + ) : ( + + )} + )} ); @@ -518,7 +571,10 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) { className="flex h-16 w-16 items-center justify-center rounded-full" style={{ background: "var(--mantine-color-edr-green-1)" }} > - + @@ -538,14 +594,20 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) { style={{ background: "var(--mantine-color-edr-green-0)" }} > - + Each operational profile (importer, exporter, freight forwarder) is reviewed and approved individually. - + You can start creating bookings under a profile as soon as it's approved — we'll let you know the moment that happens. diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 4f61c7a61..3f07d7059 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -53,7 +53,8 @@ type CompanyStep = | "additional"; /** Last 9 digits (Ethiopian national significant number) for tolerant compare. */ -const phoneDigits = (p?: string | null) => (p ?? "").replace(/\D/g, "").slice(-9); +const phoneDigits = (p?: string | null) => + (p ?? "").replace(/\D/g, "").slice(-9); const samePhone = (a?: string | null, b?: string | null) => { const da = phoneDigits(a); return da.length === 9 && da === phoneDigits(b); @@ -92,7 +93,6 @@ const onboardingSchema = z.object({ woreda: z.string().min(1, "Woreda is required"), kebele: z.string().min(1, "Kebele is required"), houseNo: z.string().min(1, "House number is required"), - etradePhone: z.string().optional(), contactPersonName: z.string().min(1, "Contact person name is required"), contactPersonPosition: z.string().optional(), contactPersonEmail: z @@ -143,7 +143,6 @@ const stepFields: Record = { "woreda", "kebele", "houseNo", - "etradePhone", ], personnel: [ "generalManagerName", @@ -216,7 +215,7 @@ function stepPayload( woreda: d.woreda, kebele: d.kebele, houseNo: d.houseNo, - etradePhone: d.etradePhone, + etradePhone: d.companyPhone, }; case "personnel": return { @@ -268,7 +267,6 @@ function toFormValues(p: ProfileResponse): FormData { woreda: p.woreda ?? "", kebele: p.kebele ?? "", houseNo: p.houseNo ?? "", - etradePhone: p.etradePhone ?? "", contactPersonName: p.contactPersonName ?? "", contactPersonPosition: p.contactPersonPosition ?? "", contactPersonEmail: p.contactPersonEmail ?? "", @@ -316,6 +314,8 @@ export default function CompanyProfileForm({ licenseFiles, onLicenseChange, submitError, + uploadedDocumentKeys, + onUploadDocuments, }: { documentSettingCode: string; documentFiles?: Record; @@ -345,6 +345,14 @@ export default function CompanyProfileForm({ onLicenseChange?: (value: Record) => void; /** Server error from the final submit (uploads/complete), shown verbatim. */ submitError?: string | null; + /** fileKeys whose company document is already uploaded server-side (resume). */ + uploadedDocumentKeys?: string[]; + /** + * Auto-upload the currently-selected company documents (the Documents step's + * "Continue" action). Resolves to an error message string on failure so the + * step can surface it and hold the user in place. + */ + onUploadDocuments?: () => Promise<{ ok: true } | { ok: false; error: string }>; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); @@ -356,17 +364,40 @@ export default function CompanyProfileForm({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [step]); + // Tracks whether the user has manually navigated the form this session. While + // false, the form still follows the parent's resume target (initialStep) — + // which can shift to an earlier step once server data lands (e.g. a required + // document turns out to be un-uploaded, so we must not rest on a later step). + const userNavigatedRef = useRef(false); + // On reopen, jump to the furthest step reached (initialStep) so progress - // never appears to reset. + // never appears to reset. Re-arm the follow-the-parent behaviour too. const wasOpen = useRef(resyncOpen); useEffect(() => { if (resyncOpen && !wasOpen.current && initialStep) { + userNavigatedRef.current = false; setStep(initialStep); setSaveError(null); } wasOpen.current = resyncOpen; // eslint-disable-next-line react-hooks/exhaustive-deps }, [resyncOpen]); + + // Follow a parent-driven resume correction: if initialStep changes (the wizard + // re-clamps it back once onboarding requirements load — e.g. a required + // document is still missing, so it must not skip ahead to Business License), + // adopt it, but only while the user hasn't started navigating themselves. + const lastInitialStep = useRef(initialStep); + useEffect(() => { + if (initialStep && initialStep !== lastInitialStep.current) { + lastInitialStep.current = initialStep; + if (!userNavigatedRef.current) { + setStep(initialStep); + setSaveError(null); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialStep]); const [internalFiles, setInternalFiles] = useState< Record >({}); @@ -410,7 +441,6 @@ export default function CompanyProfileForm({ woreda: "", kebele: "", houseNo: "", - etradePhone: "", contactPersonName: "", contactPersonPosition: "", contactPersonEmail: "", @@ -482,7 +512,7 @@ export default function CompanyProfileForm({ setValue("kebele", data.kebele); setValue("houseNo", data.houseNo); setValue( - "etradePhone", + "companyPhone", toEthiopianE164(data.regularPhone || data.mobilePhone), ); // companyAddress is composed reactively from the address fields below, so @@ -530,17 +560,6 @@ export default function CompanyProfileForm({ setValue("poaPhone", watch("contactPersonPhone")); }; - /** Populate the Contact Person from the currently logged-in user. */ - const useLoggedInUserAsContact = () => { - setValue("contactPersonName", user?.name?.en ?? "", { - shouldValidate: true, - }); - if (user?.email) setValue("contactPersonEmail", user.email); - setValue("contactPersonPhone", user?.phoneNumber ?? "", { - shouldValidate: true, - }); - }; - // --- Contact-phone SMS OTP verification ----------------------------------- // The phone we verify is the contact-person phone, normalised to E.164 so it // matches what the backend persists as `contactVerifiedPhone`. @@ -612,7 +631,7 @@ export default function CompanyProfileForm({ setOtpSent(false); // Persist the verified phone so the step resumes as "done" after a refresh // (best-effort — the OTP itself already succeeded server-side). - onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => {}); + onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => { }); } catch (err) { setOtpError(extractApiError(err).message); } finally { @@ -675,6 +694,7 @@ export default function CompanyProfileForm({ ); const nextStep = async () => { + userNavigatedRef.current = true; if (step === "additional") { if (!licenseComplete) { setSaveError( @@ -699,16 +719,34 @@ export default function CompanyProfileForm({ setStep(stepOrder[currentIdx + 1]); return; } - // The documents step has nothing to persist; field steps validate + save - // before advancing. - if (step !== "documents") { - const ok = await saveCurrentStep(); - if (!ok) return; + // The documents step auto-uploads whatever the user selected as they + // continue (partial uploads are allowed — required-doc completeness is + // re-checked on resume). A failed upload holds them on the step. + if (step === "documents") { + if (onUploadDocuments) { + setSaving(true); + try { + const res = await onUploadDocuments(); + if (!res.ok) { + setSaveError(res.error); + return; + } + } finally { + setSaving(false); + } + } + setSaveError(null); + setStep(stepOrder[currentIdx + 1]); + return; } + // Field steps validate + save before advancing. + const ok = await saveCurrentStep(); + if (!ok) return; setStep(stepOrder[currentIdx + 1]); }; const prevStep = () => { + userNavigatedRef.current = true; setSaveError(null); if (currentIdx === 0) onBack(); else setStep(stepOrder[currentIdx - 1]); @@ -864,11 +902,6 @@ export default function CompanyProfileForm({ error={errors.houseNo?.message} {...register("houseNo")} /> - )} @@ -922,15 +955,6 @@ export default function CompanyProfileForm({ Contact Person - {watch("generalManagerName") && ( ) : ( - - Enter the 6-digit code we sent to{" "} - {maskPhone(contactPhoneE164)}. - @@ -1056,7 +1079,9 @@ export default function CompanyProfileForm({ disabled={resendIn > 0 || sendingOtp} leftSection={} > - {resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"} + {resendIn > 0 + ? `Resend in ${resendIn}s` + : "Resend code"} @@ -1147,6 +1172,8 @@ export default function CompanyProfileForm({ )} @@ -1210,19 +1237,12 @@ export default function CompanyProfileForm({ } loading={isPending || saving} rightSection={ - !isPending && - !saving && - step !== "additional" && - step !== "documents" ? ( + !isPending && !saving && step !== "additional" ? ( ) : undefined } > - {step === "documents" || step === "verify" - ? "Continue" - : step === "additional" - ? "Submit for review" - : "Save & Continue"} + {step === "additional" ? "Submit for review" : "Continue"} diff --git a/packages/ui-common/src/components/SmartFileInput/index.tsx b/packages/ui-common/src/components/SmartFileInput/index.tsx index f025e2465..a6c8a202b 100644 --- a/packages/ui-common/src/components/SmartFileInput/index.tsx +++ b/packages/ui-common/src/components/SmartFileInput/index.tsx @@ -1,8 +1,5 @@ import React, { useState, useMemo, useRef } from "react"; -import { - IFileUploadSetting, - IFileUploadField, -} from "@edr/types/freight"; +import { IFileUploadSetting, IFileUploadField } from "@edr/types/freight"; import { UploadCloud, FileText, @@ -24,12 +21,20 @@ export interface SmartFileInputProps { onChange?: (value: Record) => void; /** External form errors mapped by fileKey. */ errors?: Record; + /** + * fileKeys whose document is already uploaded on the server. Such fields show + * an "Already uploaded" badge and a replace-oriented dropzone hint, even when + * no in-memory File is currently selected for them. + */ + uploadedKeys?: string[]; /** Disabled state for the entire file input group. */ disabled?: boolean; /** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */ variant?: "default" | "minimal"; /** Optional custom container CSS classes. */ className?: string; + + containerClassName?: string; } /** Helper to format file sizes in bytes to a human-readable string. */ @@ -45,23 +50,23 @@ function formatBytes(bytes: number, decimals = 2) { /** Render a suitable icon based on file extension. */ function FileIcon({ name, className }: { name: string; className?: string }) { const ext = name.split(".").pop()?.toLowerCase() || ""; - + if (ext === "pdf") { return ; } - + if (["png", "jpg", "jpeg", "webp", "svg", "gif"].includes(ext)) { return ; } - + if (["csv", "xls", "xlsx"].includes(ext)) { return ; } - + if (["zip", "rar", "tar", "gz", "7z"].includes(ext)) { return ; } - + return ; } @@ -70,16 +75,20 @@ export function SmartFileInput({ value, onChange, errors, + uploadedKeys, disabled = false, variant = "default", className, + containerClassName, }: SmartFileInputProps) { // Local state to manage files when the component is used in an uncontrolled manner - const [internalFiles, setInternalFiles] = useState>({}); - + const [internalFiles, setInternalFiles] = useState>( + {}, + ); + // Local validation errors const [localErrors, setLocalErrors] = useState>({}); - + // Drag-and-drop state active per field const [dragActive, setDragActive] = useState>({}); @@ -93,10 +102,13 @@ export function SmartFileInput({ // Create a map of fields for quick lookup const fieldsMap = useMemo(() => { - return file.fields.reduce((acc, currentField) => { - acc[currentField.fileKey] = currentField; - return acc; - }, {} as Record); + return file.fields.reduce( + (acc, currentField) => { + acc[currentField.fileKey] = currentField; + return acc; + }, + {} as Record, + ); }, [file.fields]); // Resolve current files list for a field @@ -109,8 +121,8 @@ export function SmartFileInput({ const handleFilesChange = (fieldKey: string, newFiles: File[]) => { const field = fieldsMap[fieldKey]; if (!field) return; - - const newValue = field.isMultiple ? newFiles : (newFiles[0] || null); + + const newValue = field.isMultiple ? newFiles : newFiles[0] || null; if (onChange) { const updatedValues = { @@ -129,10 +141,10 @@ export function SmartFileInput({ const processFiles = (field: IFileUploadField, incomingFiles: File[]) => { const currentFiles = getFilesForField(field.fileKey); const maxAllowed = field.isMultiple ? Math.max(1, field.maxFiles) : 1; - + // Clean up extensions (e.g. '.pdf' or 'pdf' -> 'pdf') const allowedExts = field.allowedExtensions.map((ext) => - ext.toLowerCase().replace(/^\./, "") + ext.toLowerCase().replace(/^\./, ""), ); let validIncoming: File[] = []; @@ -140,13 +152,12 @@ export function SmartFileInput({ for (const fileObj of incomingFiles) { const ext = fileObj.name.split(".").pop()?.toLowerCase() || ""; - const isExtValid = - allowedExts.length === 0 || allowedExts.includes(ext); + const isExtValid = allowedExts.length === 0 || allowedExts.includes(ext); const isSizeValid = fileObj.size <= field.maxSizeMb * 1024 * 1024; if (!isExtValid) { errorMsg = `Invalid file extension. Allowed: ${field.allowedExtensions.join( - ", " + ", ", )}`; break; } @@ -187,7 +198,11 @@ export function SmartFileInput({ handleFilesChange(field.fileKey, newFilesList); }; - const handleDrag = (e: React.DragEvent, fieldKey: string, active: boolean) => { + const handleDrag = ( + e: React.DragEvent, + fieldKey: string, + active: boolean, + ) => { e.preventDefault(); e.stopPropagation(); if (disabled) return; @@ -208,7 +223,7 @@ export function SmartFileInput({ const handleFileSelect = ( e: React.ChangeEvent, - field: IFileUploadField + field: IFileUploadField, ) => { if (e.target.files && e.target.files.length > 0) { const filesArray = Array.from(e.target.files); @@ -246,179 +261,275 @@ export function SmartFileInput({ {file.description} )} - - {sortedFields.map((field) => { - const currentFiles = getFilesForField(field.fileKey); - const maxFiles = field.isMultiple ? Math.max(1, field.maxFiles) : 1; - const reachedLimit = currentFiles.length >= maxFiles; - const fieldError = errors?.[field.fileKey] || localErrors[field.fileKey]; - const isDragOver = dragActive[field.fileKey]; - // Format accepted files for the HTML input element - const acceptString = field.allowedExtensions - .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`)) - .join(","); +
+ {sortedFields.map((field) => { + const currentFiles = getFilesForField(field.fileKey); + const maxFiles = field.isMultiple ? Math.max(1, field.maxFiles) : 1; + const reachedLimit = currentFiles.length >= maxFiles; + const fieldError = + errors?.[field.fileKey] || localErrors[field.fileKey]; + const isDragOver = dragActive[field.fileKey]; + // Already uploaded server-side and nothing newly picked to replace it. + const isUploaded = + (uploadedKeys?.includes(field.fileKey) ?? false) && + currentFiles.length === 0; - return ( -
- {/* Field Header */} -
- - - - Max size: {field.maxSizeMb}MB - {field.isMultiple && ` • Files: ${currentFiles.length}/${maxFiles}`} - -
+ // Format accepted files for the HTML input element + const acceptString = field.allowedExtensions + .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`)) + .join(","); - {/* Help / Description Text */} - {field.helpText && ( -

{field.helpText}

- )} - - {/* Selected Files List */} - {currentFiles.length > 0 && ( -
- {currentFiles.map((fileObj, idx) => ( -
+ {/* Field Header */} +
+
); }