diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index b1e05ab3d..0d8a4fa29 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -22,7 +22,7 @@ import { LayoutGrid, Package, } from "lucide-react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { useNavigate, useParams } from "react-router-dom"; @@ -84,9 +84,6 @@ export default function CustomerDetailPage() { enabled: Boolean(id), }), ); - const approveMutation = useMutation( - api.customers.setCompanyStatus.mutationOptions(), - ); const bookingsQuery = useQuery( api.customers.bookings.queryOptions({ input: { id: id ?? "" }, @@ -394,28 +391,12 @@ export default function CustomerDetailPage() { ]} backTo="/dashboard/customers" title={company.name} - subtitle={`TIN ${company.tin}${ - company.country ? ` · ${company.country}` : "" - }`} + subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : "" + }`} meta={ - {company.status === "pending" && ( - - )} } /> @@ -546,9 +527,9 @@ export default function CustomerDetailPage() { error={ bookingsQuery.isError ? { - message: "Failed to load bookings.", - onRetry: () => void bookingsQuery.refetch(), - } + message: "Failed to load bookings.", + onRetry: () => void bookingsQuery.refetch(), + } : undefined } /> @@ -567,9 +548,9 @@ export default function CustomerDetailPage() { error={ documentsQuery.isError ? { - message: "Failed to load documents.", - onRetry: () => void documentsQuery.refetch(), - } + message: "Failed to load documents.", + onRetry: () => void documentsQuery.refetch(), + } : undefined } /> @@ -588,9 +569,9 @@ export default function CustomerDetailPage() { error={ paymentsQuery.isError ? { - message: "Failed to load payments.", - onRetry: () => void paymentsQuery.refetch(), - } + message: "Failed to load payments.", + onRetry: () => void paymentsQuery.refetch(), + } : undefined } /> 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/components/onboarding/RoleLicenseStep.tsx b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx index 5206a4b32..451222841 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx @@ -1,14 +1,8 @@ -import { - Anchor, - Badge, - Card, - FileInput, - Group, - Stack, - Text, - ThemeIcon, -} from "@mantine/core"; -import { FileText, Paperclip, Upload } from "lucide-react"; +import { Anchor, Group, Stack, Text } from "@mantine/core"; +import { Paperclip } from "lucide-react"; + +import { SmartFileInput } from "@edr/ui-common"; +import type { IFileUploadSetting } from "@edr/types/freight"; import type { LicenseFile } from "@/services/companies.service"; @@ -20,6 +14,48 @@ const ROLE_LABELS: Record = { transporter: "Transporter", }; +/** Field key the synthesized per-profile upload setting is keyed on. */ +const LICENSE_FILE_KEY = "business_license"; + +/** + * Build a single-field upload setting so each profile's license input can reuse + * the shared SmartFileInput (same dropzone + "uploaded" state as the documents + * step), instead of a bespoke file picker. + */ +function buildLicenseSetting( + profileId: string, + profileName: string, +): IFileUploadSetting { + return { + id: `license-setting-${profileId}`, + createdAt: "", + updatedAt: "", + deletedAt: null, + code: "business_license", + label: "Business license", + description: null, + entity: "customer", + fields: [ + { + id: `${LICENSE_FILE_KEY}-${profileId}`, + createdAt: "", + updatedAt: "", + deletedAt: null, + settingId: `license-setting-${profileId}`, + fileKey: LICENSE_FILE_KEY, + fileLabel: `Upload ${profileName} Business license file(s)`, + helpText: null, + isRequired: true, + isMultiple: true, + maxFiles: 10, + allowedExtensions: ["pdf", "png", "jpg", "jpeg"], + maxSizeMb: 10, + order: 1, + }, + ], + }; +} + export interface RoleLicenseProfile { id: string; type: string; @@ -38,8 +74,9 @@ interface RoleLicenseStepProps { /** * Final onboarding step: collect a business license (one or more files) for - * each operational role the company holds. Each role gets its own multi-file - * input; already-uploaded files are listed for context. + * each operational role the company holds. Each role gets its own SmartFileInput + * dropzone; already-uploaded files are listed (with download links) for context + * and surface the input's "uploaded" state. */ export default function RoleLicenseStep({ profiles, @@ -60,37 +97,11 @@ export default function RoleLicenseStep({ {profiles.map((profile) => { const label = ROLE_LABELS[profile.type] ?? profile.type; const selected = value[profile.id] ?? []; - const hasAny = selected.length > 0 || profile.existingFiles.length > 0; + const hasExisting = profile.existingFiles.length > 0; return ( - - - - - - -
- - {label} — Business License - - - {profile.reference} - -
-
- {hasAny && ( - - Provided - - )} -
- - {profile.existingFiles.length > 0 && ( + <> + {hasExisting && ( {profile.existingFiles.map((f) => ( @@ -108,20 +119,17 @@ export default function RoleLicenseStep({ )} - } - placeholder={ - profile.existingFiles.length > 0 - ? "Upload more / replace files" - : "Select license file(s)" - } - value={selected} - onChange={(files) => setFiles(profile.id, files ?? [])} + { + const next = v[LICENSE_FILE_KEY]; + const files = Array.isArray(next) ? next : next ? [next] : []; + setFiles(profile.id, files); + }} /> -
+ ); })} 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..bfde5c43b 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -18,23 +18,17 @@ import { ArrowRight, CheckCircle2, RotateCw, - ShieldCheck, Smartphone, UserCheck, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; -import { z } from "zod"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { CompanyRegistrationData } from "@edr/types"; -import { - ControlledPhoneField, - isValidPhone, - toEthiopianE164, -} from "@/components/PhoneField"; +import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; import RoleLicenseStep, { @@ -42,261 +36,21 @@ import RoleLicenseStep, { } from "@/components/onboarding/RoleLicenseStep"; import ETradeInfo from "@/components/onboarding/ETradeInfo"; import { extractApiError } from "@/utils/result"; - -type CompanyStep = - | "company" - | "personnel" - | "contact" - | "verify" - | "poa" - | "documents" - | "additional"; - -/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */ -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); -}; -/** Mask all but the first 7 chars of an E.164 phone for display. */ -const maskPhone = (p: string) => - p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; - -const onboardingSchema = z.object({ - companyName: z.string().min(1, "Company name is required"), - companyEmail: z.string().email("Invalid email address"), - companyPhone: z - .string() - .min(1, "Company phone is required") - .refine(isValidPhone, "Enter a valid phone number"), - companyLocation: z.string().min(1, "Location is required"), - // Derived from the eTrade address parts (kebele/woreda/zone/region); no - // standalone input — the granular fields live in the registration section. - companyAddress: z.string().optional(), - tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), - vatNumber: z - .string() - .min(1, "VAT number is required") - .length(10, "VAT number must be exactly 10 digits"), - fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), - licenceNumber: z.string().optional(), - statusDescription: z.string().optional(), - dateRegistered: z.string().optional(), - renewedFrom: z.string().optional(), - renewalDate: z.string().optional(), - renewedTo: z.string().optional(), - // Address fields are user-entered and required (the registration/license - // fields above are read-only confirmations pulled from eTrade). - region: z.string().min(1, "Region is required"), - zone: z.string().min(1, "Zone is required"), - 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 - .string() - .email("Invalid email address") - .optional() - .or(z.literal("")), - contactPersonPhone: z - .string() - .min(1, "Contact person phone is required") - .refine(isValidPhone, "Enter a valid phone number"), - generalManagerName: z.string().min(1, "Manager name is required"), - generalManagerEmail: z.string().email("Invalid Manager email"), - generalManagerPhone: z - .string() - .min(1, "Manager phone is required") - .refine(isValidPhone, "Enter a valid phone number"), - poaName: z.string().optional(), - poaPhone: z - .string() - .optional() - .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), - poaAddress: z.string().optional(), - poaEmail: z.string().optional(), - poaLocation: z.string().optional(), -}); - -type FormData = z.infer; - -const stepFields: Record = { - company: [ - "companyName", - "companyEmail", - "companyPhone", - "companyLocation", - "companyAddress", - "tinNumber", - "vatNumber", - "fanNumber", - "licenceNumber", - "statusDescription", - "dateRegistered", - "renewedFrom", - "renewalDate", - "renewedTo", - "region", - "zone", - "woreda", - "kebele", - "houseNo", - "etradePhone", - ], - personnel: [ - "generalManagerName", - "generalManagerEmail", - "generalManagerPhone", - ], - contact: [ - "contactPersonName", - "contactPersonPosition", - "contactPersonEmail", - "contactPersonPhone", - ], - verify: [], - poa: [], - documents: [], - additional: [], -}; - -function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { - return { - companyName: data.companyName, - companyEmail: data.companyEmail, - companyPhone: data.companyPhone, - companyLocation: data.companyLocation, - companyAddress: data.companyAddress, - tin: data.tinNumber, - vatNumber: data.vatNumber, - fanNumber: data.fanNumber, - attributes: { - contactPersonName: data.contactPersonName, - contactPersonPosition: data.contactPersonPosition || undefined, - contactPersonEmail: data.contactPersonEmail || undefined, - contactPersonPhone: data.contactPersonPhone, - generalManagerName: data.generalManagerName, - generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: data.generalManagerPhone, - poaName: data.poaName || undefined, - poaPhone: data.poaPhone || undefined, - poaAddress: data.poaAddress || undefined, - poaEmail: data.poaEmail || undefined, - poaLocation: data.poaLocation || undefined, - }, - }; -} - -/** Map one wizard step's form values to the profile-update payload it saves. */ -function stepPayload( - step: CompanyStep, - d: FormData, -): Partial { - switch (step) { - case "company": - return { - companyName: d.companyName, - companyEmail: d.companyEmail, - companyPhone: d.companyPhone, - companyLocation: d.companyLocation, - companyAddress: d.companyAddress, - tin: d.tinNumber, - vatNumber: d.vatNumber, - fanNumber: d.fanNumber, - licenceNumber: d.licenceNumber, - statusDescription: d.statusDescription, - dateRegistered: d.dateRegistered, - renewedFrom: d.renewedFrom, - renewalDate: d.renewalDate, - renewedTo: d.renewedTo, - region: d.region, - zone: d.zone, - woreda: d.woreda, - kebele: d.kebele, - houseNo: d.houseNo, - etradePhone: d.etradePhone, - }; - case "personnel": - return { - generalManagerName: d.generalManagerName, - generalManagerEmail: d.generalManagerEmail, - generalManagerPhone: d.generalManagerPhone, - }; - case "contact": - return { - contactPersonName: d.contactPersonName, - contactPersonPosition: d.contactPersonPosition || undefined, - contactPersonEmail: d.contactPersonEmail || undefined, - contactPersonPhone: d.contactPersonPhone, - }; - case "poa": - return { - poaName: d.poaName || undefined, - poaPhone: d.poaPhone || undefined, - poaEmail: d.poaEmail || undefined, - poaLocation: d.poaLocation || undefined, - poaAddress: d.poaAddress || undefined, - }; - default: - return {}; - } -} - -/** Seed the form from previously-saved profile data. */ -function toFormValues(p: ProfileResponse): FormData { - // The draft placeholder TIN ("D…") shouldn't show as a real value. - const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : ""; - return { - companyName: p.companyName ?? "", - companyEmail: p.companyEmail ?? "", - companyPhone: p.companyPhone ?? "", - companyLocation: p.companyLocation ?? "", - companyAddress: p.companyAddress ?? "", - tinNumber: tin, - vatNumber: p.vatNumber ?? "", - fanNumber: p.fanNumber ?? "", - licenceNumber: p.licenceNumber ?? "", - statusDescription: p.statusDescription ?? "", - dateRegistered: p.dateRegistered ?? "", - renewedFrom: p.renewedFrom ?? "", - renewalDate: p.renewalDate ?? "", - renewedTo: p.renewedTo ?? "", - region: p.region ?? "", - zone: p.zone ?? "", - woreda: p.woreda ?? "", - kebele: p.kebele ?? "", - houseNo: p.houseNo ?? "", - etradePhone: p.etradePhone ?? "", - contactPersonName: p.contactPersonName ?? "", - contactPersonPosition: p.contactPersonPosition ?? "", - contactPersonEmail: p.contactPersonEmail ?? "", - contactPersonPhone: p.contactPersonPhone ?? "", - generalManagerName: p.generalManagerName ?? "", - generalManagerEmail: p.generalManagerEmail ?? "", - generalManagerPhone: p.generalManagerPhone ?? "", - poaName: p.poaName ?? "", - poaPhone: p.poaPhone ?? "", - poaAddress: p.poaAddress ?? "", - poaEmail: p.poaEmail ?? "", - poaLocation: p.poaLocation ?? "", - }; -} - -/** A single read-only registration value rendered as a label/value pair. */ -function ReadOnlyField({ label, value }: { label: string; value?: string }) { - return ( - - - {label} - - - {value && value.trim() ? value : "—"} - - - ); -} +import { + type CompanyStep, + type FormData, + onboardingSchema, + stepFields, +} from "./companyProfileForm/schema"; +import { + buildPayload, + maskPhone, + samePhone, + stepPayload, + toFormValues, +} from "./companyProfileForm/helpers"; +import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard"; +import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField"; export default function CompanyProfileForm({ documentSettingCode, @@ -316,6 +70,8 @@ export default function CompanyProfileForm({ licenseFiles, onLicenseChange, submitError, + uploadedDocumentKeys, + onUploadDocuments, }: { documentSettingCode: string; documentFiles?: Record; @@ -345,6 +101,16 @@ 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 +122,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 +199,6 @@ export default function CompanyProfileForm({ woreda: "", kebele: "", houseNo: "", - etradePhone: "", contactPersonName: "", contactPersonPosition: "", contactPersonEmail: "", @@ -482,7 +270,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 @@ -512,33 +300,54 @@ export default function CompanyProfileForm({ }); }; - /** Copy the General Manager into the Contact Person fields (still editable). */ - const useGmAsContact = () => { - setValue("contactPersonName", watch("generalManagerName"), { - shouldValidate: true, - }); - setValue("contactPersonEmail", watch("generalManagerEmail")); - setValue("contactPersonPhone", watch("generalManagerPhone"), { - shouldValidate: true, - }); + // "Same as …" links. A checked card prefills the target step's fields from the + // source step and disables them (kept mirrored while linked); unchecking clears + // them and re-enables editing. + const [contactSameAsGm, setContactSameAsGm] = useState(false); + const [poaSameAsContact, setPoaSameAsContact] = useState(false); + + const gmName = watch("generalManagerName"); + const gmEmail = watch("generalManagerEmail"); + const gmPhone = watch("generalManagerPhone"); + const contactName = watch("contactPersonName"); + const contactEmail = watch("contactPersonEmail"); + const contactPhone = watch("contactPersonPhone"); + + // While linked, mirror the source values into the (disabled) target fields so + // the copy stays current even if the user goes back and edits the source. + useEffect(() => { + if (!contactSameAsGm) return; + setValue("contactPersonName", gmName ?? "", { shouldValidate: true }); + setValue("contactPersonEmail", gmEmail ?? ""); + setValue("contactPersonPhone", gmPhone ?? "", { shouldValidate: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contactSameAsGm, gmName, gmEmail, gmPhone]); + + useEffect(() => { + if (!poaSameAsContact) return; + setValue("poaName", contactName ?? ""); + setValue("poaEmail", contactEmail ?? ""); + setValue("poaPhone", contactPhone ?? ""); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [poaSameAsContact, contactName, contactEmail, contactPhone]); + + const toggleContactSameAsGm = (checked: boolean) => { + setContactSameAsGm(checked); + // Checked → the mirror effect fills the fields; unchecked → reset them. + if (!checked) { + setValue("contactPersonName", ""); + setValue("contactPersonEmail", ""); + setValue("contactPersonPhone", ""); + } }; - /** Copy the Contact Person into the PoA fields (still editable). */ - const useContactAsPoa = () => { - setValue("poaName", watch("contactPersonName")); - setValue("poaEmail", watch("contactPersonEmail")); - 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, - }); + const togglePoaSameAsContact = (checked: boolean) => { + setPoaSameAsContact(checked); + if (!checked) { + setValue("poaName", ""); + setValue("poaEmail", ""); + setValue("poaPhone", ""); + } }; // --- Contact-phone SMS OTP verification ----------------------------------- @@ -612,7 +421,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 +484,7 @@ export default function CompanyProfileForm({ ); const nextStep = async () => { + userNavigatedRef.current = true; if (step === "additional") { if (!licenseComplete) { setSaveError( @@ -699,16 +509,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 +692,6 @@ export default function CompanyProfileForm({ error={errors.houseNo?.message} {...register("houseNo")} /> - )} @@ -917,33 +740,17 @@ export default function CompanyProfileForm({ {step === "contact" && ( <> - - - Contact Person - - - - {watch("generalManagerName") && ( - - )} - - + + Contact Person + + {watch("generalManagerName") && ( + + )} - - - - Verify the contact person - - We'll text a one-time code to the contact person's phone to confirm it's reachable. This is required before you continue. @@ -1009,7 +810,10 @@ export default function CompanyProfileForm({ ) : ( - + {maskPhone(contactPhoneE164)} @@ -1028,15 +832,17 @@ export default function CompanyProfileForm({ ) : ( - - Enter the 6-digit code we sent to{" "} - {maskPhone(contactPhoneE164)}. - @@ -1056,7 +862,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"} @@ -1078,24 +886,18 @@ export default function CompanyProfileForm({ {step === "poa" && ( <> - - - Power of Attorney details are optional. Fill them in if you - have them, or skip to continue. - - {watch("contactPersonName") && ( - - )} - + + Power of Attorney details are optional. Fill them in if you have + them, or skip to continue. + + {watch("contactPersonName") && ( + + )} )} @@ -1210,19 +1014,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/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/LinkCheckboxCard.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/LinkCheckboxCard.tsx new file mode 100644 index 000000000..da0dd6254 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/LinkCheckboxCard.tsx @@ -0,0 +1,51 @@ +import { Group, Text, UnstyledButton } from "@mantine/core"; +import { Check } from "lucide-react"; + +/** + * A card styled as a large checkbox: clicking it toggles `checked`, which the + * caller uses to prefill + lock a set of fields (and clear them on uncheck). + */ +export function LinkCheckboxCard({ + checked, + onToggle, + title, + description, +}: { + checked: boolean; + onToggle: (checked: boolean) => void; + title: string; + description: string; +}) { + return ( + onToggle(!checked)} + role="checkbox" + aria-checked={checked} + className={`w-full rounded-lg border! p-3! text-left transition-colors! ${checked + ? "border-[var(--mantine-color-edr-green-6)]! bg-[var(--mantine-color-edr-green-0)]!" + : "border-[var(--mantine-color-gray-3)]! hover:border-[var(--mantine-color-edr-green-4)]! hover:bg-[var(--mantine-color-edr-green-0)]!" + }`} + > + +
+ {checked && } +
+
+ + {title} + + + {description} + +
+
+
+ ); +} + +export default LinkCheckboxCard; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ReadOnlyField.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ReadOnlyField.tsx new file mode 100644 index 000000000..6465375e7 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/ReadOnlyField.tsx @@ -0,0 +1,23 @@ +import { Stack, Text } from "@mantine/core"; + +/** A single read-only registration value rendered as a label/value pair. */ +export function ReadOnlyField({ + label, + value, +}: { + label: string; + value?: string; +}) { + return ( + + + {label} + + + {value && value.trim() ? value : "—"} + + + ); +} + +export default ReadOnlyField; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts new file mode 100644 index 000000000..8f8a24eca --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts @@ -0,0 +1,142 @@ +import type { AuthUser } from "@/types/auth"; +import type { CreateCompanyPayload } from "@/services/companies.service"; +import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; + +import type { CompanyStep, FormData } from "./schema"; + +/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */ +export const phoneDigits = (p?: string | null) => + (p ?? "").replace(/\D/g, "").slice(-9); + +export const samePhone = (a?: string | null, b?: string | null) => { + const da = phoneDigits(a); + return da.length === 9 && da === phoneDigits(b); +}; + +/** Mask all but the first 7 chars of an E.164 phone for display. */ +export const maskPhone = (p: string) => + p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; + +export function buildPayload( + data: FormData, + _user: AuthUser, +): CreateCompanyPayload { + return { + companyName: data.companyName, + companyEmail: data.companyEmail, + companyPhone: data.companyPhone, + companyLocation: data.companyLocation, + companyAddress: data.companyAddress, + tin: data.tinNumber, + vatNumber: data.vatNumber, + fanNumber: data.fanNumber, + attributes: { + contactPersonName: data.contactPersonName, + contactPersonPosition: data.contactPersonPosition || undefined, + contactPersonEmail: data.contactPersonEmail || undefined, + contactPersonPhone: data.contactPersonPhone, + generalManagerName: data.generalManagerName, + generalManagerEmail: data.generalManagerEmail, + generalManagerPhone: data.generalManagerPhone, + poaName: data.poaName || undefined, + poaPhone: data.poaPhone || undefined, + poaAddress: data.poaAddress || undefined, + poaEmail: data.poaEmail || undefined, + poaLocation: data.poaLocation || undefined, + }, + }; +} + +/** Map one wizard step's form values to the profile-update payload it saves. */ +export function stepPayload( + step: CompanyStep, + d: FormData, +): Partial { + switch (step) { + case "company": + return { + companyName: d.companyName, + companyEmail: d.companyEmail, + companyPhone: d.companyPhone, + companyLocation: d.companyLocation, + companyAddress: d.companyAddress, + tin: d.tinNumber, + vatNumber: d.vatNumber, + fanNumber: d.fanNumber, + licenceNumber: d.licenceNumber, + statusDescription: d.statusDescription, + dateRegistered: d.dateRegistered, + renewedFrom: d.renewedFrom, + renewalDate: d.renewalDate, + renewedTo: d.renewedTo, + region: d.region, + zone: d.zone, + woreda: d.woreda, + kebele: d.kebele, + houseNo: d.houseNo, + etradePhone: d.companyPhone, + }; + case "personnel": + return { + generalManagerName: d.generalManagerName, + generalManagerEmail: d.generalManagerEmail, + generalManagerPhone: d.generalManagerPhone, + }; + case "contact": + return { + contactPersonName: d.contactPersonName, + contactPersonPosition: d.contactPersonPosition || undefined, + contactPersonEmail: d.contactPersonEmail || undefined, + contactPersonPhone: d.contactPersonPhone, + }; + case "poa": + return { + poaName: d.poaName || undefined, + poaPhone: d.poaPhone || undefined, + poaEmail: d.poaEmail || undefined, + poaLocation: d.poaLocation || undefined, + poaAddress: d.poaAddress || undefined, + }; + default: + return {}; + } +} + +/** Seed the form from previously-saved profile data. */ +export function toFormValues(p: ProfileResponse): FormData { + // The draft placeholder TIN ("D…") shouldn't show as a real value. + const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : ""; + return { + companyName: p.companyName ?? "", + companyEmail: p.companyEmail ?? "", + companyPhone: p.companyPhone ?? "", + companyLocation: p.companyLocation ?? "", + companyAddress: p.companyAddress ?? "", + tinNumber: tin, + vatNumber: p.vatNumber ?? "", + fanNumber: p.fanNumber ?? "", + licenceNumber: p.licenceNumber ?? "", + statusDescription: p.statusDescription ?? "", + dateRegistered: p.dateRegistered ?? "", + renewedFrom: p.renewedFrom ?? "", + renewalDate: p.renewalDate ?? "", + renewedTo: p.renewedTo ?? "", + region: p.region ?? "", + zone: p.zone ?? "", + woreda: p.woreda ?? "", + kebele: p.kebele ?? "", + houseNo: p.houseNo ?? "", + contactPersonName: p.contactPersonName ?? "", + contactPersonPosition: p.contactPersonPosition ?? "", + contactPersonEmail: p.contactPersonEmail ?? "", + contactPersonPhone: p.contactPersonPhone ?? "", + generalManagerName: p.generalManagerName ?? "", + generalManagerEmail: p.generalManagerEmail ?? "", + generalManagerPhone: p.generalManagerPhone ?? "", + poaName: p.poaName ?? "", + poaPhone: p.poaPhone ?? "", + poaAddress: p.poaAddress ?? "", + poaEmail: p.poaEmail ?? "", + poaLocation: p.poaLocation ?? "", + }; +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts new file mode 100644 index 000000000..31ee21ced --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -0,0 +1,110 @@ +import { z } from "zod"; + +import { isValidPhone } from "@/components/PhoneField"; + +export type CompanyStep = + | "company" + | "personnel" + | "contact" + | "verify" + | "poa" + | "documents" + | "additional"; + +export const onboardingSchema = z.object({ + companyName: z.string().min(1, "Company name is required"), + companyEmail: z.string().email("Invalid email address"), + companyPhone: z + .string() + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), + companyLocation: z.string().min(1, "Location is required"), + // Derived from the eTrade address parts (kebele/woreda/zone/region); no + // standalone input — the granular fields live in the registration section. + companyAddress: z.string().optional(), + tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), + vatNumber: z + .string() + .min(1, "VAT number is required") + .length(10, "VAT number must be exactly 10 digits"), + fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), + licenceNumber: z.string().optional(), + statusDescription: z.string().optional(), + dateRegistered: z.string().optional(), + renewedFrom: z.string().optional(), + renewalDate: z.string().optional(), + renewedTo: z.string().optional(), + // Address fields are user-entered and required (the registration/license + // fields above are read-only confirmations pulled from eTrade). + region: z.string().min(1, "Region is required"), + zone: z.string().min(1, "Zone is required"), + 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"), + contactPersonName: z.string().min(1, "Contact person name is required"), + contactPersonPosition: z.string().optional(), + contactPersonEmail: z + .string() + .email("Invalid email address") + .optional() + .or(z.literal("")), + contactPersonPhone: z + .string() + .min(1, "Contact person phone is required") + .refine(isValidPhone, "Enter a valid phone number"), + generalManagerName: z.string().min(1, "Manager name is required"), + generalManagerEmail: z.string().email("Invalid Manager email"), + generalManagerPhone: z + .string() + .min(1, "Manager phone is required") + .refine(isValidPhone, "Enter a valid phone number"), + poaName: z.string().optional(), + poaPhone: z + .string() + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), + poaAddress: z.string().optional(), + poaEmail: z.string().optional(), + poaLocation: z.string().optional(), +}); + +export type FormData = z.infer; + +export const stepFields: Record = { + company: [ + "companyName", + "companyEmail", + "companyPhone", + "companyLocation", + "companyAddress", + "tinNumber", + "vatNumber", + "fanNumber", + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewedFrom", + "renewalDate", + "renewedTo", + "region", + "zone", + "woreda", + "kebele", + "houseNo", + ], + personnel: [ + "generalManagerName", + "generalManagerEmail", + "generalManagerPhone", + ], + contact: [ + "contactPersonName", + "contactPersonPosition", + "contactPersonEmail", + "contactPersonPhone", + ], + verify: [], + poa: [], + documents: [], + additional: [], +}; 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 */} +
+
); }