mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
fix: make the file sync work on onboarding
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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. */
|
||||||
@@ -70,12 +75,16 @@ 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>>({});
|
||||||
@@ -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
|
||||||
@@ -110,7 +122,7 @@ export function SmartFileInput({
|
|||||||
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 = {
|
||||||
@@ -132,7 +144,7 @@ export function SmartFileInput({
|
|||||||
|
|
||||||
// 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);
|
||||||
@@ -247,178 +262,274 @@ export function SmartFileInput({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sortedFields.map((field) => {
|
<div className={cn("flex flex-col gap-6", containerClassName)}>
|
||||||
const currentFiles = getFilesForField(field.fileKey);
|
{sortedFields.map((field) => {
|
||||||
const maxFiles = field.isMultiple ? Math.max(1, field.maxFiles) : 1;
|
const currentFiles = getFilesForField(field.fileKey);
|
||||||
const reachedLimit = currentFiles.length >= maxFiles;
|
const maxFiles = field.isMultiple ? Math.max(1, field.maxFiles) : 1;
|
||||||
const fieldError = errors?.[field.fileKey] || localErrors[field.fileKey];
|
const reachedLimit = currentFiles.length >= maxFiles;
|
||||||
const isDragOver = dragActive[field.fileKey];
|
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;
|
||||||
|
|
||||||
// Format accepted files for the HTML input element
|
// Format accepted files for the HTML input element
|
||||||
const acceptString = field.allowedExtensions
|
const acceptString = field.allowedExtensions
|
||||||
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
|
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
|
||||||
.join(",");
|
.join(",");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={field.id || field.fileKey} className="flex flex-col gap-2">
|
<div
|
||||||
{/* Field Header */}
|
key={field.id || field.fileKey}
|
||||||
<div className="flex flex-col md:flex-row md:items-baseline justify-between gap-1">
|
className="flex flex-col gap-2"
|
||||||
<label className="text-sm font-semibold text-foreground flex items-center gap-1">
|
>
|
||||||
{field.fileLabel}
|
{/* Field Header */}
|
||||||
{field.isRequired && (
|
<div className="flex flex-col md:flex-row md:items-baseline justify-between gap-1">
|
||||||
<span className="text-destructive font-bold" aria-hidden="true">
|
<label className="text-sm font-semibold text-foreground flex items-center gap-1.5">
|
||||||
*
|
<span className="flex items-center gap-1">
|
||||||
</span>
|
{field.fileLabel}
|
||||||
)}
|
{field.isRequired && (
|
||||||
</label>
|
<span
|
||||||
|
className="text-destructive font-bold"
|
||||||
<span className="text-xs text-muted-foreground">
|
aria-hidden="true"
|
||||||
Max size: {field.maxSizeMb}MB
|
>
|
||||||
{field.isMultiple && ` • Files: ${currentFiles.length}/${maxFiles}`}
|
*
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Help / Description Text */}
|
|
||||||
{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"
|
|
||||||
)}
|
)}
|
||||||
>
|
</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>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<span className="text-xs text-muted-foreground">
|
||||||
<p className="text-sm font-medium text-foreground truncate max-w-[200px] md:max-w-md" title={fileObj.name}>
|
Max size: {field.maxSizeMb}MB
|
||||||
{fileObj.name}
|
{field.isMultiple &&
|
||||||
</p>
|
` • Files: ${currentFiles.length}/${maxFiles}`}
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
</div>
|
||||||
{formatBytes(fileObj.size)}
|
|
||||||
</span>
|
{/* Help / Description Text */}
|
||||||
<span className="flex items-center gap-0.5 text-xs text-primary font-medium">
|
{field.helpText && (
|
||||||
<CheckCircle2 className="h-3 w-3" /> Ready
|
<p className="text-xs text-muted-foreground">
|
||||||
</span>
|
{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>
|
|
||||||
|
|
||||||
{/* Hidden inputs to represent file details in traditional form submissions */}
|
<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" />
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user