fix: make the file sync work on onboarding

This commit is contained in:
Nathnael
2026-06-26 12:31:08 +00:00
parent f86bdb1c36
commit 552e6bcd16
3 changed files with 501 additions and 308 deletions

View File

@@ -164,7 +164,9 @@ export default function OnboardingWizardDialog({
(company?.company?.nationality as CompanyNationality | null) ?? null; (company?.company?.nationality as CompanyNationality | null) ?? null;
// Resume position from the backend-persisted step. // 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) ? (onboardingStep as FormStep)
: "company"; : "company";
@@ -275,7 +277,7 @@ export default function OnboardingWizardDialog({
const idx = FORM_STEPS.indexOf(step as FormStep); const idx = FORM_STEPS.indexOf(step as FormStep);
if (idx < 0 || idx <= furthestIdxRef.current) return; if (idx < 0 || idx <= furthestIdxRef.current) return;
furthestIdxRef.current = idx; 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. // 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) // 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. // 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 // 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). // 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 // Final confirm step → finalize onboarding (no company create; it already
// exists as a draft that's been filled in step-by-step). // exists as a draft that's been filled in step-by-step).
const handleSubmit = useCallback( const handleSubmit = useCallback(
@@ -383,6 +410,25 @@ export default function OnboardingWizardDialog({
requirementsQuery.data?.documentSettingCode ?? requirementsQuery.data?.documentSettingCode ??
documentSettingCode(effectiveNationality); 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 = { const formProps = {
documentSettingCode: resolvedDocumentSettingCode, documentSettingCode: resolvedDocumentSettingCode,
documentFiles, documentFiles,
@@ -392,7 +438,7 @@ export default function OnboardingWizardDialog({
isPending: finishMutation.isPending, isPending: finishMutation.isPending,
onBack: handleBackToRoles, onBack: handleBackToRoles,
hideFirstStepBack: true, hideFirstStepBack: true,
initialStep: resumeFormStep, initialStep: effectiveResumeStep,
resyncOpen: opened, resyncOpen: opened,
onStepChange: handleStepChange, onStepChange: handleStepChange,
onSaveStep: saveStep, onSaveStep: saveStep,
@@ -400,6 +446,8 @@ export default function OnboardingWizardDialog({
roleProfiles, roleProfiles,
licenseFiles, licenseFiles,
onLicenseChange: setLicenseFiles, onLicenseChange: setLicenseFiles,
uploadedDocumentKeys,
onUploadDocuments: handleUploadDocuments,
// Surface a failed final submit (license/document upload or complete) inside // 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 form — otherwise the server message (e.g. a 500) would be invisible on
// the submit step. // the submit step.
@@ -413,7 +461,7 @@ export default function OnboardingWizardDialog({
withCloseButton={!completed} withCloseButton={!completed}
closeOnClickOutside={false} closeOnClickOutside={false}
closeOnEscape={!completed} closeOnEscape={!completed}
size={720} size={1440}
radius="lg" radius="lg"
padding="xl" padding="xl"
centered centered
@@ -422,11 +470,11 @@ export default function OnboardingWizardDialog({
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }} overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
styles={{ styles={{
header: { header: {
alignItems:"flex-start" alignItems: "flex-start",
}, },
title: { title: {
flex: 1 flex: 1,
} },
}} }}
title={ title={
completed ? null : ( completed ? null : (
@@ -448,59 +496,64 @@ export default function OnboardingWizardDialog({
{completed ? ( {completed ? (
<OnboardingCompletePanel onClose={handleClose} /> <OnboardingCompletePanel onClose={handleClose} />
) : ( ) : (
<Stack gap="xl"> <Stack gap="xl">
{phase === "nationality" ? (
{phase === "nationality" ? ( <Stack gap="lg">
<Stack gap="lg"> <NationalitySelect
<NationalitySelect value={nationality}
value={nationality} onChange={setNationality}
onChange={setNationality} embedded
embedded />
/> <Group justify="flex-end" pt="xs">
<Group justify="flex-end" pt="xs"> <Button
<Button color="edr-green"
color="edr-green" onClick={handleNationalityContinue}
onClick={handleNationalityContinue} disabled={!nationality}
disabled={!nationality} rightSection={<ArrowRight size={16} />}
rightSection={<ArrowRight size={16} />} >
> Continue
Continue </Button>
</Button> </Group>
</Group> </Stack>
</Stack> ) : phase === "role" ? (
) : phase === "role" ? ( <Stack gap="lg">
<Stack gap="lg"> <OnboardingRoleSelect
<OnboardingRoleSelect value={roles} onChange={setRoles} embedded /> value={roles}
{startError && ( onChange={setRoles}
<Text size="sm" c="red"> embedded
{startError} />
</Text> {startError && (
)} <Text size="sm" c="red">
<Group justify="space-between" pt="xs"> {startError}
<Button </Text>
variant="default" )}
leftSection={<ArrowLeft size={16} />} <Group justify="space-between" pt="xs">
onClick={() => setPhase("nationality")} <Button
> variant="default"
Back leftSection={<ArrowLeft size={16} />}
</Button> onClick={() => setPhase("nationality")}
<Button >
color="edr-green" Back
onClick={handleRolesContinue} </Button>
disabled={!rolesValid} <Button
loading={startMutation.isPending} color="edr-green"
rightSection={ onClick={handleRolesContinue}
startMutation.isPending ? undefined : <ArrowRight size={16} /> disabled={!rolesValid}
} loading={startMutation.isPending}
> rightSection={
Continue startMutation.isPending ? undefined : (
</Button> <ArrowRight size={16} />
</Group> )
</Stack> }
) : ( >
<CompanyProfileForm {...formProps} /> Continue
)} </Button>
</Stack> </Group>
</Stack>
) : (
<CompanyProfileForm {...formProps} />
)}
</Stack>
)} )}
</Modal> </Modal>
); );
@@ -518,7 +571,10 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
className="flex h-16 w-16 items-center justify-center rounded-full" className="flex h-16 w-16 items-center justify-center rounded-full"
style={{ background: "var(--mantine-color-edr-green-1)" }} style={{ background: "var(--mantine-color-edr-green-1)" }}
> >
<PartyPopper size={32} className="text-[var(--mantine-color-edr-green-7)]" /> <PartyPopper
size={32}
className="text-[var(--mantine-color-edr-green-7)]"
/>
</Box> </Box>
<Box> <Box>
@@ -538,14 +594,20 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
style={{ background: "var(--mantine-color-edr-green-0)" }} style={{ background: "var(--mantine-color-edr-green-0)" }}
> >
<Group gap="sm" wrap="nowrap" align="flex-start"> <Group gap="sm" wrap="nowrap" align="flex-start">
<Clock size={18} className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]" /> <Clock
size={18}
className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]"
/>
<Text size="sm" ta="left"> <Text size="sm" ta="left">
Each operational profile (importer, exporter, freight forwarder) is Each operational profile (importer, exporter, freight forwarder) is
reviewed and approved individually. reviewed and approved individually.
</Text> </Text>
</Group> </Group>
<Group gap="sm" wrap="nowrap" align="flex-start"> <Group gap="sm" wrap="nowrap" align="flex-start">
<ShieldCheck size={18} className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]" /> <ShieldCheck
size={18}
className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]"
/>
<Text size="sm" ta="left"> <Text size="sm" ta="left">
You can start creating bookings under a profile as soon as it's You can start creating bookings under a profile as soon as it's
approved we'll let you know the moment that happens. approved we'll let you know the moment that happens.

View File

@@ -53,7 +53,8 @@ type CompanyStep =
| "additional"; | "additional";
/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */ /** 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 samePhone = (a?: string | null, b?: string | null) => {
const da = phoneDigits(a); const da = phoneDigits(a);
return da.length === 9 && da === phoneDigits(b); return da.length === 9 && da === phoneDigits(b);
@@ -92,7 +93,6 @@ const onboardingSchema = z.object({
woreda: z.string().min(1, "Woreda is required"), woreda: z.string().min(1, "Woreda is required"),
kebele: z.string().min(1, "Kebele is required"), kebele: z.string().min(1, "Kebele is required"),
houseNo: z.string().min(1, "House number 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"), contactPersonName: z.string().min(1, "Contact person name is required"),
contactPersonPosition: z.string().optional(), contactPersonPosition: z.string().optional(),
contactPersonEmail: z contactPersonEmail: z
@@ -143,7 +143,6 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"woreda", "woreda",
"kebele", "kebele",
"houseNo", "houseNo",
"etradePhone",
], ],
personnel: [ personnel: [
"generalManagerName", "generalManagerName",
@@ -216,7 +215,7 @@ function stepPayload(
woreda: d.woreda, woreda: d.woreda,
kebele: d.kebele, kebele: d.kebele,
houseNo: d.houseNo, houseNo: d.houseNo,
etradePhone: d.etradePhone, etradePhone: d.companyPhone,
}; };
case "personnel": case "personnel":
return { return {
@@ -268,7 +267,6 @@ function toFormValues(p: ProfileResponse): FormData {
woreda: p.woreda ?? "", woreda: p.woreda ?? "",
kebele: p.kebele ?? "", kebele: p.kebele ?? "",
houseNo: p.houseNo ?? "", houseNo: p.houseNo ?? "",
etradePhone: p.etradePhone ?? "",
contactPersonName: p.contactPersonName ?? "", contactPersonName: p.contactPersonName ?? "",
contactPersonPosition: p.contactPersonPosition ?? "", contactPersonPosition: p.contactPersonPosition ?? "",
contactPersonEmail: p.contactPersonEmail ?? "", contactPersonEmail: p.contactPersonEmail ?? "",
@@ -316,6 +314,8 @@ export default function CompanyProfileForm({
licenseFiles, licenseFiles,
onLicenseChange, onLicenseChange,
submitError, submitError,
uploadedDocumentKeys,
onUploadDocuments,
}: { }: {
documentSettingCode: string; documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>; documentFiles?: Record<string, File | File[] | null>;
@@ -345,6 +345,14 @@ export default function CompanyProfileForm({
onLicenseChange?: (value: Record<string, File[]>) => void; onLicenseChange?: (value: Record<string, File[]>) => void;
/** Server error from the final submit (uploads/complete), shown verbatim. */ /** Server error from the final submit (uploads/complete), shown verbatim. */
submitError?: string | null; 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<CompanyStep>(initialStep ?? "company"); const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -356,17 +364,40 @@ export default function CompanyProfileForm({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]); }, [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 // 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); const wasOpen = useRef(resyncOpen);
useEffect(() => { useEffect(() => {
if (resyncOpen && !wasOpen.current && initialStep) { if (resyncOpen && !wasOpen.current && initialStep) {
userNavigatedRef.current = false;
setStep(initialStep); setStep(initialStep);
setSaveError(null); setSaveError(null);
} }
wasOpen.current = resyncOpen; wasOpen.current = resyncOpen;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [resyncOpen]); }, [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< const [internalFiles, setInternalFiles] = useState<
Record<string, File | File[] | null> Record<string, File | File[] | null>
>({}); >({});
@@ -410,7 +441,6 @@ export default function CompanyProfileForm({
woreda: "", woreda: "",
kebele: "", kebele: "",
houseNo: "", houseNo: "",
etradePhone: "",
contactPersonName: "", contactPersonName: "",
contactPersonPosition: "", contactPersonPosition: "",
contactPersonEmail: "", contactPersonEmail: "",
@@ -482,7 +512,7 @@ export default function CompanyProfileForm({
setValue("kebele", data.kebele); setValue("kebele", data.kebele);
setValue("houseNo", data.houseNo); setValue("houseNo", data.houseNo);
setValue( setValue(
"etradePhone", "companyPhone",
toEthiopianE164(data.regularPhone || data.mobilePhone), toEthiopianE164(data.regularPhone || data.mobilePhone),
); );
// companyAddress is composed reactively from the address fields below, so // companyAddress is composed reactively from the address fields below, so
@@ -530,17 +560,6 @@ export default function CompanyProfileForm({
setValue("poaPhone", watch("contactPersonPhone")); 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 ----------------------------------- // --- Contact-phone SMS OTP verification -----------------------------------
// The phone we verify is the contact-person phone, normalised to E.164 so it // The phone we verify is the contact-person phone, normalised to E.164 so it
// matches what the backend persists as `contactVerifiedPhone`. // matches what the backend persists as `contactVerifiedPhone`.
@@ -612,7 +631,7 @@ export default function CompanyProfileForm({
setOtpSent(false); setOtpSent(false);
// Persist the verified phone so the step resumes as "done" after a refresh // Persist the verified phone so the step resumes as "done" after a refresh
// (best-effort — the OTP itself already succeeded server-side). // (best-effort — the OTP itself already succeeded server-side).
onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => {}); onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => { });
} catch (err) { } catch (err) {
setOtpError(extractApiError(err).message); setOtpError(extractApiError(err).message);
} finally { } finally {
@@ -675,6 +694,7 @@ export default function CompanyProfileForm({
); );
const nextStep = async () => { const nextStep = async () => {
userNavigatedRef.current = true;
if (step === "additional") { if (step === "additional") {
if (!licenseComplete) { if (!licenseComplete) {
setSaveError( setSaveError(
@@ -699,16 +719,34 @@ export default function CompanyProfileForm({
setStep(stepOrder[currentIdx + 1]); setStep(stepOrder[currentIdx + 1]);
return; return;
} }
// The documents step has nothing to persist; field steps validate + save // The documents step auto-uploads whatever the user selected as they
// before advancing. // continue (partial uploads are allowed — required-doc completeness is
if (step !== "documents") { // re-checked on resume). A failed upload holds them on the step.
const ok = await saveCurrentStep(); if (step === "documents") {
if (!ok) return; 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]); setStep(stepOrder[currentIdx + 1]);
}; };
const prevStep = () => { const prevStep = () => {
userNavigatedRef.current = true;
setSaveError(null); setSaveError(null);
if (currentIdx === 0) onBack(); if (currentIdx === 0) onBack();
else setStep(stepOrder[currentIdx - 1]); else setStep(stepOrder[currentIdx - 1]);
@@ -864,11 +902,6 @@ export default function CompanyProfileForm({
error={errors.houseNo?.message} error={errors.houseNo?.message}
{...register("houseNo")} {...register("houseNo")}
/> />
<ControlledPhoneField
control={control}
name="etradePhone"
label="Phone"
/>
</SimpleGrid> </SimpleGrid>
</> </>
)} )}
@@ -922,15 +955,6 @@ export default function CompanyProfileForm({
Contact Person Contact Person
</Text> </Text>
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}> <Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
<Button
variant="light"
color="edr-green"
size="xs"
leftSection={<UserCheck size={14} />}
onClick={useLoggedInUserAsContact}
>
Use me
</Button>
{watch("generalManagerName") && ( {watch("generalManagerName") && (
<Button <Button
variant="light" variant="light"
@@ -978,12 +1002,6 @@ export default function CompanyProfileForm({
{step === "verify" && ( {step === "verify" && (
<Stack gap="md"> <Stack gap="md">
<Group gap="xs" align="center">
<ShieldCheck size={18} className="text-[var(--mantine-color-edr-green-7)]" />
<Text fw={600} size="sm" c="edr-text">
Verify the contact person
</Text>
</Group>
<Text size="sm" c="edr-muted"> <Text size="sm" c="edr-muted">
We'll text a one-time code to the contact person's phone to We'll text a one-time code to the contact person's phone to
confirm it's reachable. This is required before you continue. confirm it's reachable. This is required before you continue.
@@ -1009,7 +1027,10 @@ export default function CompanyProfileForm({
) : ( ) : (
<Stack gap="sm"> <Stack gap="sm">
<Group gap="xs" align="center"> <Group gap="xs" align="center">
<Smartphone size={16} className="text-[var(--mantine-color-edr-muted)]" /> <Smartphone
size={16}
className="text-[var(--mantine-color-edr-muted)]"
/>
<Text size="sm" c="edr-text"> <Text size="sm" c="edr-text">
{maskPhone(contactPhoneE164)} {maskPhone(contactPhoneE164)}
</Text> </Text>
@@ -1028,15 +1049,17 @@ export default function CompanyProfileForm({
</Button> </Button>
) : ( ) : (
<Stack gap="sm"> <Stack gap="sm">
<Text size="sm" c="edr-muted">
Enter the 6-digit code we sent to{" "}
{maskPhone(contactPhoneE164)}.
</Text>
<PinInput <PinInput
length={6} length={6}
type="number" type="number"
oneTimeCode oneTimeCode
value={otpCode} value={otpCode}
placeholder="0"
styles={{
input: {
textAlign: "center",
},
}}
onChange={setOtpCode} onChange={setOtpCode}
/> />
<Group gap="sm"> <Group gap="sm">
@@ -1056,7 +1079,9 @@ export default function CompanyProfileForm({
disabled={resendIn > 0 || sendingOtp} disabled={resendIn > 0 || sendingOtp}
leftSection={<RotateCw size={14} />} leftSection={<RotateCw size={14} />}
> >
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"} {resendIn > 0
? `Resend in ${resendIn}s`
: "Resend code"}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -1147,6 +1172,8 @@ export default function CompanyProfileForm({
<SmartFileInput <SmartFileInput
file={uploadSetting} file={uploadSetting}
value={documentFiles} value={documentFiles}
uploadedKeys={uploadedDocumentKeys}
containerClassName="lg:grid grid-cols-2 items-stretch"
onChange={setDocumentFiles} onChange={setDocumentFiles}
/> />
)} )}
@@ -1210,19 +1237,12 @@ export default function CompanyProfileForm({
} }
loading={isPending || saving} loading={isPending || saving}
rightSection={ rightSection={
!isPending && !isPending && !saving && step !== "additional" ? (
!saving &&
step !== "additional" &&
step !== "documents" ? (
<ArrowRight size={16} /> <ArrowRight size={16} />
) : undefined ) : undefined
} }
> >
{step === "documents" || step === "verify" {step === "additional" ? "Submit for review" : "Continue"}
? "Continue"
: step === "additional"
? "Submit for review"
: "Save & Continue"}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -1,8 +1,5 @@
import React, { useState, useMemo, useRef } from "react"; import React, { useState, useMemo, useRef } from "react";
import { import { IFileUploadSetting, IFileUploadField } from "@edr/types/freight";
IFileUploadSetting,
IFileUploadField,
} from "@edr/types/freight";
import { import {
UploadCloud, UploadCloud,
FileText, FileText,
@@ -24,12 +21,20 @@ export interface SmartFileInputProps {
onChange?: (value: Record<string, File | File[] | null>) => void; onChange?: (value: Record<string, File | File[] | null>) => void;
/** External form errors mapped by fileKey. */ /** External form errors mapped by fileKey. */
errors?: Record<string, string>; errors?: Record<string, string>;
/**
* 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 state for the entire file input group. */
disabled?: boolean; disabled?: boolean;
/** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */ /** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */
variant?: "default" | "minimal"; variant?: "default" | "minimal";
/** Optional custom container CSS classes. */ /** Optional custom container CSS classes. */
className?: string; className?: string;
containerClassName?: string;
} }
/** Helper to format file sizes in bytes to a human-readable 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. */ /** Render a suitable icon based on file extension. */
function FileIcon({ name, className }: { name: string; className?: string }) { function FileIcon({ name, className }: { name: string; className?: string }) {
const ext = name.split(".").pop()?.toLowerCase() || ""; const ext = name.split(".").pop()?.toLowerCase() || "";
if (ext === "pdf") { if (ext === "pdf") {
return <FileText className={cn("text-red-500", className)} />; return <FileText className={cn("text-red-500", className)} />;
} }
if (["png", "jpg", "jpeg", "webp", "svg", "gif"].includes(ext)) { if (["png", "jpg", "jpeg", "webp", "svg", "gif"].includes(ext)) {
return <ImageIcon className={cn("text-blue-500", className)} />; return <ImageIcon className={cn("text-blue-500", className)} />;
} }
if (["csv", "xls", "xlsx"].includes(ext)) { if (["csv", "xls", "xlsx"].includes(ext)) {
return <FileText className={cn("text-emerald-500", className)} />; return <FileText className={cn("text-emerald-500", className)} />;
} }
if (["zip", "rar", "tar", "gz", "7z"].includes(ext)) { if (["zip", "rar", "tar", "gz", "7z"].includes(ext)) {
return <File className={cn("text-amber-500", className)} />; return <File className={cn("text-amber-500", className)} />;
} }
return <File className={cn("text-slate-400", className)} />; return <File className={cn("text-slate-400", className)} />;
} }
@@ -70,16 +75,20 @@ export function SmartFileInput({
value, value,
onChange, onChange,
errors, errors,
uploadedKeys,
disabled = false, disabled = false,
variant = "default", variant = "default",
className, className,
containerClassName,
}: SmartFileInputProps) { }: SmartFileInputProps) {
// Local state to manage files when the component is used in an uncontrolled manner // Local state to manage files when the component is used in an uncontrolled manner
const [internalFiles, setInternalFiles] = useState<Record<string, File[]>>({}); const [internalFiles, setInternalFiles] = useState<Record<string, File[]>>(
{},
);
// Local validation errors // Local validation errors
const [localErrors, setLocalErrors] = useState<Record<string, string>>({}); const [localErrors, setLocalErrors] = useState<Record<string, string>>({});
// Drag-and-drop state active per field // Drag-and-drop state active per field
const [dragActive, setDragActive] = useState<Record<string, boolean>>({}); const [dragActive, setDragActive] = useState<Record<string, boolean>>({});
@@ -93,10 +102,13 @@ export function SmartFileInput({
// Create a map of fields for quick lookup // Create a map of fields for quick lookup
const fieldsMap = useMemo(() => { const fieldsMap = useMemo(() => {
return file.fields.reduce((acc, currentField) => { return file.fields.reduce(
acc[currentField.fileKey] = currentField; (acc, currentField) => {
return acc; acc[currentField.fileKey] = currentField;
}, {} as Record<string, IFileUploadField>); return acc;
},
{} as Record<string, IFileUploadField>,
);
}, [file.fields]); }, [file.fields]);
// Resolve current files list for a field // Resolve current files list for a field
@@ -109,8 +121,8 @@ export function SmartFileInput({
const handleFilesChange = (fieldKey: string, newFiles: File[]) => { const handleFilesChange = (fieldKey: string, newFiles: File[]) => {
const field = fieldsMap[fieldKey]; const field = fieldsMap[fieldKey];
if (!field) return; if (!field) return;
const newValue = field.isMultiple ? newFiles : (newFiles[0] || null); const newValue = field.isMultiple ? newFiles : newFiles[0] || null;
if (onChange) { if (onChange) {
const updatedValues = { const updatedValues = {
@@ -129,10 +141,10 @@ export function SmartFileInput({
const processFiles = (field: IFileUploadField, incomingFiles: File[]) => { const processFiles = (field: IFileUploadField, incomingFiles: File[]) => {
const currentFiles = getFilesForField(field.fileKey); const currentFiles = getFilesForField(field.fileKey);
const maxAllowed = field.isMultiple ? Math.max(1, field.maxFiles) : 1; const maxAllowed = field.isMultiple ? Math.max(1, field.maxFiles) : 1;
// Clean up extensions (e.g. '.pdf' or 'pdf' -> 'pdf') // Clean up extensions (e.g. '.pdf' or 'pdf' -> 'pdf')
const allowedExts = field.allowedExtensions.map((ext) => const allowedExts = field.allowedExtensions.map((ext) =>
ext.toLowerCase().replace(/^\./, "") ext.toLowerCase().replace(/^\./, ""),
); );
let validIncoming: File[] = []; let validIncoming: File[] = [];
@@ -140,13 +152,12 @@ export function SmartFileInput({
for (const fileObj of incomingFiles) { for (const fileObj of incomingFiles) {
const ext = fileObj.name.split(".").pop()?.toLowerCase() || ""; const ext = fileObj.name.split(".").pop()?.toLowerCase() || "";
const isExtValid = const isExtValid = allowedExts.length === 0 || allowedExts.includes(ext);
allowedExts.length === 0 || allowedExts.includes(ext);
const isSizeValid = fileObj.size <= field.maxSizeMb * 1024 * 1024; const isSizeValid = fileObj.size <= field.maxSizeMb * 1024 * 1024;
if (!isExtValid) { if (!isExtValid) {
errorMsg = `Invalid file extension. Allowed: ${field.allowedExtensions.join( errorMsg = `Invalid file extension. Allowed: ${field.allowedExtensions.join(
", " ", ",
)}`; )}`;
break; break;
} }
@@ -187,7 +198,11 @@ export function SmartFileInput({
handleFilesChange(field.fileKey, newFilesList); 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.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (disabled) return; if (disabled) return;
@@ -208,7 +223,7 @@ export function SmartFileInput({
const handleFileSelect = ( const handleFileSelect = (
e: React.ChangeEvent<HTMLInputElement>, e: React.ChangeEvent<HTMLInputElement>,
field: IFileUploadField field: IFileUploadField,
) => { ) => {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0) {
const filesArray = Array.from(e.target.files); const filesArray = Array.from(e.target.files);
@@ -246,179 +261,275 @@ export function SmartFileInput({
{file.description} {file.description}
</div> </div>
)} )}
{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 <div className={cn("flex flex-col gap-6", containerClassName)}>
const acceptString = field.allowedExtensions {sortedFields.map((field) => {
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`)) const currentFiles = getFilesForField(field.fileKey);
.join(","); 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 ( // Format accepted files for the HTML input element
<div key={field.id || field.fileKey} className="flex flex-col gap-2"> const acceptString = field.allowedExtensions
{/* Field Header */} .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
<div className="flex flex-col md:flex-row md:items-baseline justify-between gap-1"> .join(",");
<label className="text-sm font-semibold text-foreground flex items-center gap-1">
{field.fileLabel}
{field.isRequired && (
<span className="text-destructive font-bold" aria-hidden="true">
*
</span>
)}
</label>
<span className="text-xs text-muted-foreground">
Max size: {field.maxSizeMb}MB
{field.isMultiple && ` • Files: ${currentFiles.length}/${maxFiles}`}
</span>
</div>
{/* Help / Description Text */} return (
{field.helpText && ( <div
<p className="text-xs text-muted-foreground">{field.helpText}</p> key={field.id || field.fileKey}
)} className="flex flex-col gap-2"
>
{/* Selected Files List */} {/* Field Header */}
{currentFiles.length > 0 && ( <div className="flex flex-col md:flex-row md:items-baseline justify-between gap-1">
<div className="flex flex-col gap-2"> <label className="text-sm font-semibold text-foreground flex items-center gap-1.5">
{currentFiles.map((fileObj, idx) => ( <span className="flex items-center gap-1">
<div {field.fileLabel}
key={`${fileObj.name}-${idx}`} {field.isRequired && (
className={cn( <span
"flex items-center justify-between p-3 rounded-lg border bg-card transition shadow-2xs hover:shadow-xs", className="text-destructive font-bold"
fieldError ? "border-destructive/30" : "border-border" aria-hidden="true"
>
*
</span>
)} )}
> </span>
<div className="flex items-center gap-3 min-w-0"> {isUploaded && (
<div className="p-2 bg-muted rounded-md flex items-center justify-center"> <span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400">
<FileIcon name={fileObj.name} className="h-5 w-5" /> <CheckCircle2 className="h-3 w-3" /> Already uploaded
</div> </span>
)}
<div className="min-w-0"> </label>
<p className="text-sm font-medium text-foreground truncate max-w-[200px] md:max-w-md" title={fileObj.name}>
{fileObj.name} <span className="text-xs text-muted-foreground">
</p> Max size: {field.maxSizeMb}MB
<div className="flex items-center gap-2 mt-0.5"> {field.isMultiple &&
<span className="text-xs text-muted-foreground"> ` • Files: ${currentFiles.length}/${maxFiles}`}
{formatBytes(fileObj.size)} </span>
</span> </div>
<span className="flex items-center gap-0.5 text-xs text-primary font-medium">
<CheckCircle2 className="h-3 w-3" /> Ready {/* Help / Description Text */}
</span> {field.helpText && (
<p className="text-xs text-muted-foreground">
{field.helpText}
</p>
)}
{/* Selected Files List */}
{currentFiles.length > 0 && (
<div className="flex flex-col gap-2">
{currentFiles.map((fileObj, idx) => (
<div
key={`${fileObj.name}-${idx}`}
className={cn(
"flex items-center justify-between p-3 rounded-lg border bg-card transition shadow-2xs hover:shadow-xs",
fieldError ? "border-destructive/30" : "border-border",
)}
>
<div className="flex items-center gap-3 min-w-0">
<div className="p-2 bg-muted rounded-md flex items-center justify-center">
<FileIcon name={fileObj.name} className="h-5 w-5" />
</div>
<div className="min-w-0">
<p
className="text-sm font-medium text-foreground truncate max-w-[200px] md:max-w-md"
title={fileObj.name}
>
{fileObj.name}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">
{formatBytes(fileObj.size)}
</span>
<span className="flex items-center gap-0.5 text-xs text-primary font-medium">
<CheckCircle2 className="h-3 w-3" /> Ready
</span>
</div>
</div> </div>
</div> </div>
<button
type="button"
disabled={disabled}
onClick={() => removeFile(field.fileKey, idx)}
className={cn(
"p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors",
disabled && "opacity-50 pointer-events-none",
)}
aria-label={`Remove file ${fileObj.name}`}
>
<Trash2 className="h-4 w-4" />
</button>
{/* Hidden inputs to represent file details in traditional form submissions */}
<input
type="hidden"
name={
field.isMultiple
? `${field.fileKey}[]`
: field.fileKey
}
value={fileObj.name}
/>
</div>
))}
</div>
)}
{/* Dropzone area */}
{!reachedLimit &&
(variant === "minimal" ? (
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={() =>
fileInputRefs.current[field.fileKey]?.click()
}
className="gap-1.5 cursor-pointer"
>
<UploadCloud className="h-4 w-4 text-muted-foreground" />
<span>{isUploaded ? "Replace File" : "Upload File"}</span>
</Button>
<input
type="file"
ref={(el) => {
if (fileInputRefs.current) {
fileInputRefs.current[field.fileKey] = el;
}
}}
multiple={field.isMultiple}
accept={acceptString}
disabled={disabled}
onChange={(e) => handleFileSelect(e, field)}
className="hidden"
/>
<span className="text-xs text-muted-foreground">
Accepts:{" "}
{field.allowedExtensions.join(", ").toUpperCase() ||
"All"}
</span>
</div>
) : isUploaded ? (
// Uploaded state: a solid success panel that still doubles as a
// replace target (click anywhere or drag a new file onto it).
<div
onDragOver={(e) => handleDrag(e, field.fileKey, true)}
onDragLeave={(e) => handleDrag(e, field.fileKey, false)}
onDrop={(e) => handleDrop(e, field)}
className={cn(
"group relative flex items-center gap-4 rounded-lg border p-4 transition-all",
isDragOver
? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10"
: "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10",
disabled &&
"opacity-50 pointer-events-none cursor-not-allowed",
)}
>
<input
type="file"
multiple={field.isMultiple}
accept={acceptString}
disabled={disabled}
onChange={(e) => handleFileSelect(e, field)}
id={`file-input-${field.fileKey}`}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
aria-label={`Replace ${field.fileLabel}`}
/>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400">
{isDragOver ? (
<UploadCloud className="h-5 w-5 animate-bounce" />
) : (
<CheckCircle2 className="h-5 w-5" />
)}
</div> </div>
<button <div className="min-w-0 flex-1">
type="button" <p className="text-sm font-semibold text-foreground">
disabled={disabled} {isDragOver ? "Drop to replace" : "Document uploaded"}
onClick={() => removeFile(field.fileKey, idx)} </p>
className={cn( <p className="mt-0.5 text-xs text-muted-foreground">
"p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors", {isDragOver
disabled && "opacity-50 pointer-events-none" ? "Release to replace the document on file."
)} : "Saved to your application. Drag a new file here or click to replace it."}
aria-label={`Remove file ${fileObj.name}`} </p>
> </div>
<Trash2 className="h-4 w-4" />
</button> <span className="hidden shrink-0 items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground shadow-2xs transition group-hover:border-primary/50 group-hover:text-primary sm:inline-flex">
<UploadCloud className="h-3.5 w-3.5" />
{/* Hidden inputs to represent file details in traditional form submissions */} Replace
</span>
</div>
) : (
<div
onDragOver={(e) => handleDrag(e, field.fileKey, true)}
onDragLeave={(e) => handleDrag(e, field.fileKey, false)}
onDrop={(e) => handleDrop(e, field)}
className={cn(
"relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50",
isDragOver
? "border-primary bg-primary/5 dark:bg-primary/10"
: "border-border hover:border-primary/50 hover:bg-muted/10",
fieldError &&
"border-destructive hover:border-destructive/80",
disabled &&
"opacity-50 pointer-events-none cursor-not-allowed",
)}
>
<input <input
type="hidden" type="file"
name={field.isMultiple ? `${field.fileKey}[]` : field.fileKey} multiple={field.isMultiple}
value={fileObj.name} accept={acceptString}
disabled={disabled}
onChange={(e) => handleFileSelect(e, field)}
id={`file-input-${field.fileKey}`}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
/> />
<div className="p-3 bg-muted rounded-full mb-3 text-muted-foreground transition group-hover:scale-110">
<UploadCloud
className={cn(
"h-6 w-6 text-muted-foreground",
isDragOver && "text-primary animate-bounce",
)}
/>
</div>
<p className="text-sm font-semibold text-foreground">
Drag & drop your file here, or{" "}
<span className="text-primary font-bold hover:underline">
browse
</span>
</p>
<p className="text-xs text-muted-foreground mt-1">
Supported formats:{" "}
{field.allowedExtensions.join(", ").toUpperCase() ||
"All"}
</p>
</div> </div>
))} ))}
</div>
)}
{/* Dropzone area */} {/* Validation Error Message */}
{!reachedLimit && ( {fieldError && (
variant === "minimal" ? ( <div className="flex items-center gap-1.5 mt-1 text-xs text-destructive animate-in fade-in slide-in-from-top-1 duration-200">
<div className="flex flex-wrap items-center gap-3"> <AlertCircle className="h-3.5 w-3.5" />
<Button <span>{fieldError}</span>
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={() => fileInputRefs.current[field.fileKey]?.click()}
className="gap-1.5 cursor-pointer"
>
<UploadCloud className="h-4 w-4 text-muted-foreground" />
<span>Upload File</span>
</Button>
<input
type="file"
ref={(el) => {
if (fileInputRefs.current) {
fileInputRefs.current[field.fileKey] = el;
}
}}
multiple={field.isMultiple}
accept={acceptString}
disabled={disabled}
onChange={(e) => handleFileSelect(e, field)}
className="hidden"
/>
<span className="text-xs text-muted-foreground">
Accepts: {field.allowedExtensions.join(", ").toUpperCase() || "All"}
</span>
</div> </div>
) : ( )}
<div </div>
onDragOver={(e) => handleDrag(e, field.fileKey, true)} );
onDragLeave={(e) => handleDrag(e, field.fileKey, false)} })}
onDrop={(e) => handleDrop(e, field)} </div>
className={cn(
"relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50",
isDragOver
? "border-primary bg-primary/5 dark:bg-primary/10"
: "border-border hover:border-primary/50 hover:bg-muted/10",
fieldError && "border-destructive hover:border-destructive/80",
disabled && "opacity-50 pointer-events-none cursor-not-allowed"
)}
>
<input
type="file"
multiple={field.isMultiple}
accept={acceptString}
disabled={disabled}
onChange={(e) => handleFileSelect(e, field)}
id={`file-input-${field.fileKey}`}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
/>
<div className="p-3 bg-muted rounded-full mb-3 text-muted-foreground transition group-hover:scale-110">
<UploadCloud className={cn("h-6 w-6 text-muted-foreground", isDragOver && "text-primary animate-bounce")} />
</div>
<p className="text-sm font-semibold text-foreground">
Drag & drop your file here, or <span className="text-primary font-bold hover:underline">browse</span>
</p>
<p className="text-xs text-muted-foreground mt-1">
Supported formats: {field.allowedExtensions.join(", ").toUpperCase() || "All"}
</p>
</div>
)
)}
{/* Validation Error Message */}
{fieldError && (
<div className="flex items-center gap-1.5 mt-1 text-xs text-destructive animate-in fade-in slide-in-from-top-1 duration-200">
<AlertCircle className="h-3.5 w-3.5" />
<span>{fieldError}</span>
</div>
)}
</div>
);
})}
</div> </div>
); );
} }