Merge pull request #306 from Tria-plc/freight/fix/file-syncing

file syncing and some improvement to the onboarding
This commit is contained in:
Nathnael Wondisha
2026-06-27 09:29:19 +03:00
committed by GitHub
9 changed files with 986 additions and 701 deletions

View File

@@ -22,7 +22,7 @@ import {
LayoutGrid, LayoutGrid,
Package, Package,
} from "lucide-react"; } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react"; import { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
@@ -84,9 +84,6 @@ export default function CustomerDetailPage() {
enabled: Boolean(id), enabled: Boolean(id),
}), }),
); );
const approveMutation = useMutation(
api.customers.setCompanyStatus.mutationOptions(),
);
const bookingsQuery = useQuery( const bookingsQuery = useQuery(
api.customers.bookings.queryOptions({ api.customers.bookings.queryOptions({
input: { id: id ?? "" }, input: { id: id ?? "" },
@@ -394,28 +391,12 @@ export default function CustomerDetailPage() {
]} ]}
backTo="/dashboard/customers" backTo="/dashboard/customers"
title={company.name} title={company.name}
subtitle={`TIN ${company.tin}${ subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
company.country ? ` · ${company.country}` : "" }`}
}`}
meta={ meta={
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<CompanyTypeBadge type={company.type} /> <CompanyTypeBadge type={company.type} />
<CompanyStatusBadge status={company.status} /> <CompanyStatusBadge status={company.status} />
{company.status === "pending" && (
<Button
size="xs"
color="green"
loading={approveMutation.isPending}
onClick={() =>
approveMutation.mutate({
companyId: company.id,
status: "active",
})
}
>
Approve
</Button>
)}
</Group> </Group>
} }
/> />
@@ -546,9 +527,9 @@ export default function CustomerDetailPage() {
error={ error={
bookingsQuery.isError bookingsQuery.isError
? { ? {
message: "Failed to load bookings.", message: "Failed to load bookings.",
onRetry: () => void bookingsQuery.refetch(), onRetry: () => void bookingsQuery.refetch(),
} }
: undefined : undefined
} }
/> />
@@ -567,9 +548,9 @@ export default function CustomerDetailPage() {
error={ error={
documentsQuery.isError documentsQuery.isError
? { ? {
message: "Failed to load documents.", message: "Failed to load documents.",
onRetry: () => void documentsQuery.refetch(), onRetry: () => void documentsQuery.refetch(),
} }
: undefined : undefined
} }
/> />
@@ -588,9 +569,9 @@ export default function CustomerDetailPage() {
error={ error={
paymentsQuery.isError paymentsQuery.isError
? { ? {
message: "Failed to load payments.", message: "Failed to load payments.",
onRetry: () => void paymentsQuery.refetch(), onRetry: () => void paymentsQuery.refetch(),
} }
: undefined : undefined
} }
/> />

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

@@ -1,14 +1,8 @@
import { import { Anchor, Group, Stack, Text } from "@mantine/core";
Anchor, import { Paperclip } from "lucide-react";
Badge,
Card, import { SmartFileInput } from "@edr/ui-common";
FileInput, import type { IFileUploadSetting } from "@edr/types/freight";
Group,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { FileText, Paperclip, Upload } from "lucide-react";
import type { LicenseFile } from "@/services/companies.service"; import type { LicenseFile } from "@/services/companies.service";
@@ -20,6 +14,48 @@ const ROLE_LABELS: Record<string, string> = {
transporter: "Transporter", transporter: "Transporter",
}; };
/** Field key the synthesized per-profile upload setting is keyed on. */
const LICENSE_FILE_KEY = "business_license";
/**
* Build a single-field upload setting so each profile's license input can reuse
* the shared SmartFileInput (same dropzone + "uploaded" state as the documents
* step), instead of a bespoke file picker.
*/
function buildLicenseSetting(
profileId: string,
profileName: string,
): IFileUploadSetting {
return {
id: `license-setting-${profileId}`,
createdAt: "",
updatedAt: "",
deletedAt: null,
code: "business_license",
label: "Business license",
description: null,
entity: "customer",
fields: [
{
id: `${LICENSE_FILE_KEY}-${profileId}`,
createdAt: "",
updatedAt: "",
deletedAt: null,
settingId: `license-setting-${profileId}`,
fileKey: LICENSE_FILE_KEY,
fileLabel: `Upload ${profileName} Business license file(s)`,
helpText: null,
isRequired: true,
isMultiple: true,
maxFiles: 10,
allowedExtensions: ["pdf", "png", "jpg", "jpeg"],
maxSizeMb: 10,
order: 1,
},
],
};
}
export interface RoleLicenseProfile { export interface RoleLicenseProfile {
id: string; id: string;
type: string; type: string;
@@ -38,8 +74,9 @@ interface RoleLicenseStepProps {
/** /**
* Final onboarding step: collect a business license (one or more files) for * Final onboarding step: collect a business license (one or more files) for
* each operational role the company holds. Each role gets its own multi-file * each operational role the company holds. Each role gets its own SmartFileInput
* input; already-uploaded files are listed for context. * dropzone; already-uploaded files are listed (with download links) for context
* and surface the input's "uploaded" state.
*/ */
export default function RoleLicenseStep({ export default function RoleLicenseStep({
profiles, profiles,
@@ -60,37 +97,11 @@ export default function RoleLicenseStep({
{profiles.map((profile) => { {profiles.map((profile) => {
const label = ROLE_LABELS[profile.type] ?? profile.type; const label = ROLE_LABELS[profile.type] ?? profile.type;
const selected = value[profile.id] ?? []; const selected = value[profile.id] ?? [];
const hasAny = selected.length > 0 || profile.existingFiles.length > 0; const hasExisting = profile.existingFiles.length > 0;
return ( return (
<Card key={profile.id} padding="lg" withBorder> <>
<Group justify="space-between" mb="sm" wrap="nowrap"> {hasExisting && (
<Group gap="sm" wrap="nowrap">
<ThemeIcon
size={40}
radius="md"
variant="light"
color="edr-green"
>
<FileText size={20} />
</ThemeIcon>
<div>
<Text fw={700} c="edr-text" fz={15}>
{label} Business License
</Text>
<Text size="xs" c="edr-muted" ff="monospace">
{profile.reference}
</Text>
</div>
</Group>
{hasAny && (
<Badge color="edr-green" variant="light">
Provided
</Badge>
)}
</Group>
{profile.existingFiles.length > 0 && (
<Stack gap={4} mb="sm"> <Stack gap={4} mb="sm">
{profile.existingFiles.map((f) => ( {profile.existingFiles.map((f) => (
<Group key={f.url} gap={6} wrap="nowrap"> <Group key={f.url} gap={6} wrap="nowrap">
@@ -108,20 +119,17 @@ export default function RoleLicenseStep({
</Stack> </Stack>
)} )}
<FileInput <SmartFileInput
multiple file={buildLicenseSetting(profile.id, label)}
clearable value={{ [LICENSE_FILE_KEY]: selected }}
accept="application/pdf,image/png,image/jpeg" uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined}
leftSection={<Upload size={16} />} onChange={(v) => {
placeholder={ const next = v[LICENSE_FILE_KEY];
profile.existingFiles.length > 0 const files = Array.isArray(next) ? next : next ? [next] : [];
? "Upload more / replace files" setFiles(profile.id, files);
: "Select license file(s)" }}
}
value={selected}
onChange={(files) => setFiles(profile.id, files ?? [])}
/> />
</Card> </>
); );
})} })}
</Stack> </Stack>

View File

@@ -18,23 +18,17 @@ import {
ArrowRight, ArrowRight,
CheckCircle2, CheckCircle2,
RotateCw, RotateCw,
ShieldCheck,
Smartphone, Smartphone,
UserCheck, UserCheck,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod";
import type { AuthUser } from "@/types/auth"; import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service"; import type { CreateCompanyPayload } from "@/services/companies.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type { CompanyRegistrationData } from "@edr/types"; import type { CompanyRegistrationData } from "@edr/types";
import { import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
ControlledPhoneField,
isValidPhone,
toEthiopianE164,
} from "@/components/PhoneField";
import { SmartFileInput } from "@edr/ui-common"; import { SmartFileInput } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import RoleLicenseStep, { import RoleLicenseStep, {
@@ -42,261 +36,21 @@ import RoleLicenseStep, {
} from "@/components/onboarding/RoleLicenseStep"; } from "@/components/onboarding/RoleLicenseStep";
import ETradeInfo from "@/components/onboarding/ETradeInfo"; import ETradeInfo from "@/components/onboarding/ETradeInfo";
import { extractApiError } from "@/utils/result"; import { extractApiError } from "@/utils/result";
import {
type CompanyStep = type CompanyStep,
| "company" type FormData,
| "personnel" onboardingSchema,
| "contact" stepFields,
| "verify" } from "./companyProfileForm/schema";
| "poa" import {
| "documents" buildPayload,
| "additional"; maskPhone,
samePhone,
/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */ stepPayload,
const phoneDigits = (p?: string | null) => (p ?? "").replace(/\D/g, "").slice(-9); toFormValues,
const samePhone = (a?: string | null, b?: string | null) => { } from "./companyProfileForm/helpers";
const da = phoneDigits(a); import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
return da.length === 9 && da === phoneDigits(b); import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField";
};
/** Mask all but the first 7 chars of an E.164 phone for display. */
const maskPhone = (p: string) =>
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z
.string()
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z.string().min(1, "Location is required"),
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
// standalone input — the granular fields live in the registration section.
companyAddress: z.string().optional(),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
vatNumber: z
.string()
.min(1, "VAT number is required")
.length(10, "VAT number must be exactly 10 digits"),
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
licenceNumber: z.string().optional(),
statusDescription: z.string().optional(),
dateRegistered: z.string().optional(),
renewedFrom: z.string().optional(),
renewalDate: z.string().optional(),
renewedTo: z.string().optional(),
// Address fields are user-entered and required (the registration/license
// fields above are read-only confirmations pulled from eTrade).
region: z.string().min(1, "Region is required"),
zone: z.string().min(1, "Zone is required"),
woreda: z.string().min(1, "Woreda is required"),
kebele: z.string().min(1, "Kebele is required"),
houseNo: z.string().min(1, "House number is required"),
etradePhone: z.string().optional(),
contactPersonName: z.string().min(1, "Contact person name is required"),
contactPersonPosition: z.string().optional(),
contactPersonEmail: z
.string()
.email("Invalid email address")
.optional()
.or(z.literal("")),
contactPersonPhone: z
.string()
.min(1, "Contact person phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
generalManagerName: z.string().min(1, "Manager name is required"),
generalManagerEmail: z.string().email("Invalid Manager email"),
generalManagerPhone: z
.string()
.min(1, "Manager phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
poaName: z.string().optional(),
poaPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaAddress: z.string().optional(),
poaEmail: z.string().optional(),
poaLocation: z.string().optional(),
});
type FormData = z.infer<typeof onboardingSchema>;
const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
company: [
"companyName",
"companyEmail",
"companyPhone",
"companyLocation",
"companyAddress",
"tinNumber",
"vatNumber",
"fanNumber",
"licenceNumber",
"statusDescription",
"dateRegistered",
"renewedFrom",
"renewalDate",
"renewedTo",
"region",
"zone",
"woreda",
"kebele",
"houseNo",
"etradePhone",
],
personnel: [
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
],
contact: [
"contactPersonName",
"contactPersonPosition",
"contactPersonEmail",
"contactPersonPhone",
],
verify: [],
poa: [],
documents: [],
additional: [],
};
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
return {
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: data.companyPhone,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
tin: data.tinNumber,
vatNumber: data.vatNumber,
fanNumber: data.fanNumber,
attributes: {
contactPersonName: data.contactPersonName,
contactPersonPosition: data.contactPersonPosition || undefined,
contactPersonEmail: data.contactPersonEmail || undefined,
contactPersonPhone: data.contactPersonPhone,
generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: data.generalManagerPhone,
poaName: data.poaName || undefined,
poaPhone: data.poaPhone || undefined,
poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined,
},
};
}
/** Map one wizard step's form values to the profile-update payload it saves. */
function stepPayload(
step: CompanyStep,
d: FormData,
): Partial<UpdateProfilePayload> {
switch (step) {
case "company":
return {
companyName: d.companyName,
companyEmail: d.companyEmail,
companyPhone: d.companyPhone,
companyLocation: d.companyLocation,
companyAddress: d.companyAddress,
tin: d.tinNumber,
vatNumber: d.vatNumber,
fanNumber: d.fanNumber,
licenceNumber: d.licenceNumber,
statusDescription: d.statusDescription,
dateRegistered: d.dateRegistered,
renewedFrom: d.renewedFrom,
renewalDate: d.renewalDate,
renewedTo: d.renewedTo,
region: d.region,
zone: d.zone,
woreda: d.woreda,
kebele: d.kebele,
houseNo: d.houseNo,
etradePhone: d.etradePhone,
};
case "personnel":
return {
generalManagerName: d.generalManagerName,
generalManagerEmail: d.generalManagerEmail,
generalManagerPhone: d.generalManagerPhone,
};
case "contact":
return {
contactPersonName: d.contactPersonName,
contactPersonPosition: d.contactPersonPosition || undefined,
contactPersonEmail: d.contactPersonEmail || undefined,
contactPersonPhone: d.contactPersonPhone,
};
case "poa":
return {
poaName: d.poaName || undefined,
poaPhone: d.poaPhone || undefined,
poaEmail: d.poaEmail || undefined,
poaLocation: d.poaLocation || undefined,
poaAddress: d.poaAddress || undefined,
};
default:
return {};
}
}
/** Seed the form from previously-saved profile data. */
function toFormValues(p: ProfileResponse): FormData {
// The draft placeholder TIN ("D…") shouldn't show as a real value.
const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : "";
return {
companyName: p.companyName ?? "",
companyEmail: p.companyEmail ?? "",
companyPhone: p.companyPhone ?? "",
companyLocation: p.companyLocation ?? "",
companyAddress: p.companyAddress ?? "",
tinNumber: tin,
vatNumber: p.vatNumber ?? "",
fanNumber: p.fanNumber ?? "",
licenceNumber: p.licenceNumber ?? "",
statusDescription: p.statusDescription ?? "",
dateRegistered: p.dateRegistered ?? "",
renewedFrom: p.renewedFrom ?? "",
renewalDate: p.renewalDate ?? "",
renewedTo: p.renewedTo ?? "",
region: p.region ?? "",
zone: p.zone ?? "",
woreda: p.woreda ?? "",
kebele: p.kebele ?? "",
houseNo: p.houseNo ?? "",
etradePhone: p.etradePhone ?? "",
contactPersonName: p.contactPersonName ?? "",
contactPersonPosition: p.contactPersonPosition ?? "",
contactPersonEmail: p.contactPersonEmail ?? "",
contactPersonPhone: p.contactPersonPhone ?? "",
generalManagerName: p.generalManagerName ?? "",
generalManagerEmail: p.generalManagerEmail ?? "",
generalManagerPhone: p.generalManagerPhone ?? "",
poaName: p.poaName ?? "",
poaPhone: p.poaPhone ?? "",
poaAddress: p.poaAddress ?? "",
poaEmail: p.poaEmail ?? "",
poaLocation: p.poaLocation ?? "",
};
}
/** A single read-only registration value rendered as a label/value pair. */
function ReadOnlyField({ label, value }: { label: string; value?: string }) {
return (
<Stack gap={2}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" c="edr-text" fw={500}>
{value && value.trim() ? value : "—"}
</Text>
</Stack>
);
}
export default function CompanyProfileForm({ export default function CompanyProfileForm({
documentSettingCode, documentSettingCode,
@@ -316,6 +70,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 +101,16 @@ 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 +122,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 +199,6 @@ export default function CompanyProfileForm({
woreda: "", woreda: "",
kebele: "", kebele: "",
houseNo: "", houseNo: "",
etradePhone: "",
contactPersonName: "", contactPersonName: "",
contactPersonPosition: "", contactPersonPosition: "",
contactPersonEmail: "", contactPersonEmail: "",
@@ -482,7 +270,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
@@ -512,33 +300,54 @@ export default function CompanyProfileForm({
}); });
}; };
/** Copy the General Manager into the Contact Person fields (still editable). */ // "Same as …" links. A checked card prefills the target step's fields from the
const useGmAsContact = () => { // source step and disables them (kept mirrored while linked); unchecking clears
setValue("contactPersonName", watch("generalManagerName"), { // them and re-enables editing.
shouldValidate: true, const [contactSameAsGm, setContactSameAsGm] = useState(false);
}); const [poaSameAsContact, setPoaSameAsContact] = useState(false);
setValue("contactPersonEmail", watch("generalManagerEmail"));
setValue("contactPersonPhone", watch("generalManagerPhone"), { const gmName = watch("generalManagerName");
shouldValidate: true, const gmEmail = watch("generalManagerEmail");
}); const gmPhone = watch("generalManagerPhone");
const contactName = watch("contactPersonName");
const contactEmail = watch("contactPersonEmail");
const contactPhone = watch("contactPersonPhone");
// While linked, mirror the source values into the (disabled) target fields so
// the copy stays current even if the user goes back and edits the source.
useEffect(() => {
if (!contactSameAsGm) return;
setValue("contactPersonName", gmName ?? "", { shouldValidate: true });
setValue("contactPersonEmail", gmEmail ?? "");
setValue("contactPersonPhone", gmPhone ?? "", { shouldValidate: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
useEffect(() => {
if (!poaSameAsContact) return;
setValue("poaName", contactName ?? "");
setValue("poaEmail", contactEmail ?? "");
setValue("poaPhone", contactPhone ?? "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [poaSameAsContact, contactName, contactEmail, contactPhone]);
const toggleContactSameAsGm = (checked: boolean) => {
setContactSameAsGm(checked);
// Checked → the mirror effect fills the fields; unchecked → reset them.
if (!checked) {
setValue("contactPersonName", "");
setValue("contactPersonEmail", "");
setValue("contactPersonPhone", "");
}
}; };
/** Copy the Contact Person into the PoA fields (still editable). */ const togglePoaSameAsContact = (checked: boolean) => {
const useContactAsPoa = () => { setPoaSameAsContact(checked);
setValue("poaName", watch("contactPersonName")); if (!checked) {
setValue("poaEmail", watch("contactPersonEmail")); setValue("poaName", "");
setValue("poaPhone", watch("contactPersonPhone")); setValue("poaEmail", "");
}; setValue("poaPhone", "");
}
/** 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 -----------------------------------
@@ -612,7 +421,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 +484,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 +509,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 +692,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>
</> </>
)} )}
@@ -917,33 +740,17 @@ export default function CompanyProfileForm({
{step === "contact" && ( {step === "contact" && (
<> <>
<Group justify="space-between" align="center" wrap="nowrap"> <Text fw={600} size="sm" c="edr-text">
<Text fw={600} size="sm" c="edr-text"> Contact Person
Contact Person </Text>
</Text> {watch("generalManagerName") && (
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}> <LinkCheckboxCard
<Button checked={contactSameAsGm}
variant="light" onToggle={toggleContactSameAsGm}
color="edr-green" title="Same as General Manager"
size="xs" description="Reuse the general manager's name, email and phone. Uncheck to enter different details."
leftSection={<UserCheck size={14} />} />
onClick={useLoggedInUserAsContact} )}
>
Use me
</Button>
{watch("generalManagerName") && (
<Button
variant="light"
color="edr-green"
size="xs"
leftSection={<UserCheck size={14} />}
onClick={useGmAsContact}
>
Use General Manager
</Button>
)}
</Group>
</Group>
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="Name" label="Name"
@@ -978,12 +785,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 +810,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 +832,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 +862,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>
@@ -1078,24 +886,18 @@ export default function CompanyProfileForm({
{step === "poa" && ( {step === "poa" && (
<> <>
<Group justify="space-between" align="center" wrap="nowrap"> <Text size="sm" c="edr-muted">
<Text size="sm" c="edr-muted"> Power of Attorney details are optional. Fill them in if you have
Power of Attorney details are optional. Fill them in if you them, or skip to continue.
have them, or skip to continue. </Text>
</Text> {watch("contactPersonName") && (
{watch("contactPersonName") && ( <LinkCheckboxCard
<Button checked={poaSameAsContact}
variant="light" onToggle={togglePoaSameAsContact}
color="edr-green" title="Same as contact person"
size="xs" description="Reuse the contact person's name, email and phone. Uncheck to enter different details."
leftSection={<UserCheck size={14} />} />
onClick={useContactAsPoa} )}
style={{ flexShrink: 0 }}
>
Use contact person
</Button>
)}
</Group>
<TextInput <TextInput
label="PoA Name" label="PoA Name"
placeholder="Authorized Representative Name" placeholder="Authorized Representative Name"
@@ -1147,6 +949,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 +1014,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

@@ -0,0 +1,51 @@
import { Group, Text, UnstyledButton } from "@mantine/core";
import { Check } from "lucide-react";
/**
* A card styled as a large checkbox: clicking it toggles `checked`, which the
* caller uses to prefill + lock a set of fields (and clear them on uncheck).
*/
export function LinkCheckboxCard({
checked,
onToggle,
title,
description,
}: {
checked: boolean;
onToggle: (checked: boolean) => void;
title: string;
description: string;
}) {
return (
<UnstyledButton
onClick={() => onToggle(!checked)}
role="checkbox"
aria-checked={checked}
className={`w-full rounded-lg border! p-3! text-left transition-colors! ${checked
? "border-[var(--mantine-color-edr-green-6)]! bg-[var(--mantine-color-edr-green-0)]!"
: "border-[var(--mantine-color-gray-3)]! hover:border-[var(--mantine-color-edr-green-4)]! hover:bg-[var(--mantine-color-edr-green-0)]!"
}`}
>
<Group gap="sm" wrap="nowrap" align="flex-start">
<div
className={`mt-px flex h-5 w-5 shrink-0 items-center justify-center rounded-[6px] border transition-colors ${checked
? "border-[var(--mantine-color-edr-green-6)] bg-[var(--mantine-color-edr-green-6)] text-white"
: "border-[var(--mantine-color-gray-4)] bg-white"
}`}
>
{checked && <Check size={14} strokeWidth={3} />}
</div>
<div>
<Text fw={600} size="sm" c="edr-text" lh={1.25}>
{title}
</Text>
<Text size="xs" c="dimmed" mt={2}>
{description}
</Text>
</div>
</Group>
</UnstyledButton>
);
}
export default LinkCheckboxCard;

View File

@@ -0,0 +1,23 @@
import { Stack, Text } from "@mantine/core";
/** A single read-only registration value rendered as a label/value pair. */
export function ReadOnlyField({
label,
value,
}: {
label: string;
value?: string;
}) {
return (
<Stack gap={2}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" c="edr-text" fw={500}>
{value && value.trim() ? value : "—"}
</Text>
</Stack>
);
}
export default ReadOnlyField;

View File

@@ -0,0 +1,142 @@
import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type { CompanyStep, FormData } from "./schema";
/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */
export const phoneDigits = (p?: string | null) =>
(p ?? "").replace(/\D/g, "").slice(-9);
export const samePhone = (a?: string | null, b?: string | null) => {
const da = phoneDigits(a);
return da.length === 9 && da === phoneDigits(b);
};
/** Mask all but the first 7 chars of an E.164 phone for display. */
export const maskPhone = (p: string) =>
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
export function buildPayload(
data: FormData,
_user: AuthUser,
): CreateCompanyPayload {
return {
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: data.companyPhone,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
tin: data.tinNumber,
vatNumber: data.vatNumber,
fanNumber: data.fanNumber,
attributes: {
contactPersonName: data.contactPersonName,
contactPersonPosition: data.contactPersonPosition || undefined,
contactPersonEmail: data.contactPersonEmail || undefined,
contactPersonPhone: data.contactPersonPhone,
generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: data.generalManagerPhone,
poaName: data.poaName || undefined,
poaPhone: data.poaPhone || undefined,
poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined,
},
};
}
/** Map one wizard step's form values to the profile-update payload it saves. */
export function stepPayload(
step: CompanyStep,
d: FormData,
): Partial<UpdateProfilePayload> {
switch (step) {
case "company":
return {
companyName: d.companyName,
companyEmail: d.companyEmail,
companyPhone: d.companyPhone,
companyLocation: d.companyLocation,
companyAddress: d.companyAddress,
tin: d.tinNumber,
vatNumber: d.vatNumber,
fanNumber: d.fanNumber,
licenceNumber: d.licenceNumber,
statusDescription: d.statusDescription,
dateRegistered: d.dateRegistered,
renewedFrom: d.renewedFrom,
renewalDate: d.renewalDate,
renewedTo: d.renewedTo,
region: d.region,
zone: d.zone,
woreda: d.woreda,
kebele: d.kebele,
houseNo: d.houseNo,
etradePhone: d.companyPhone,
};
case "personnel":
return {
generalManagerName: d.generalManagerName,
generalManagerEmail: d.generalManagerEmail,
generalManagerPhone: d.generalManagerPhone,
};
case "contact":
return {
contactPersonName: d.contactPersonName,
contactPersonPosition: d.contactPersonPosition || undefined,
contactPersonEmail: d.contactPersonEmail || undefined,
contactPersonPhone: d.contactPersonPhone,
};
case "poa":
return {
poaName: d.poaName || undefined,
poaPhone: d.poaPhone || undefined,
poaEmail: d.poaEmail || undefined,
poaLocation: d.poaLocation || undefined,
poaAddress: d.poaAddress || undefined,
};
default:
return {};
}
}
/** Seed the form from previously-saved profile data. */
export function toFormValues(p: ProfileResponse): FormData {
// The draft placeholder TIN ("D…") shouldn't show as a real value.
const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : "";
return {
companyName: p.companyName ?? "",
companyEmail: p.companyEmail ?? "",
companyPhone: p.companyPhone ?? "",
companyLocation: p.companyLocation ?? "",
companyAddress: p.companyAddress ?? "",
tinNumber: tin,
vatNumber: p.vatNumber ?? "",
fanNumber: p.fanNumber ?? "",
licenceNumber: p.licenceNumber ?? "",
statusDescription: p.statusDescription ?? "",
dateRegistered: p.dateRegistered ?? "",
renewedFrom: p.renewedFrom ?? "",
renewalDate: p.renewalDate ?? "",
renewedTo: p.renewedTo ?? "",
region: p.region ?? "",
zone: p.zone ?? "",
woreda: p.woreda ?? "",
kebele: p.kebele ?? "",
houseNo: p.houseNo ?? "",
contactPersonName: p.contactPersonName ?? "",
contactPersonPosition: p.contactPersonPosition ?? "",
contactPersonEmail: p.contactPersonEmail ?? "",
contactPersonPhone: p.contactPersonPhone ?? "",
generalManagerName: p.generalManagerName ?? "",
generalManagerEmail: p.generalManagerEmail ?? "",
generalManagerPhone: p.generalManagerPhone ?? "",
poaName: p.poaName ?? "",
poaPhone: p.poaPhone ?? "",
poaAddress: p.poaAddress ?? "",
poaEmail: p.poaEmail ?? "",
poaLocation: p.poaLocation ?? "",
};
}

View File

@@ -0,0 +1,110 @@
import { z } from "zod";
import { isValidPhone } from "@/components/PhoneField";
export type CompanyStep =
| "company"
| "personnel"
| "contact"
| "verify"
| "poa"
| "documents"
| "additional";
export const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z
.string()
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z.string().min(1, "Location is required"),
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
// standalone input — the granular fields live in the registration section.
companyAddress: z.string().optional(),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
vatNumber: z
.string()
.min(1, "VAT number is required")
.length(10, "VAT number must be exactly 10 digits"),
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
licenceNumber: z.string().optional(),
statusDescription: z.string().optional(),
dateRegistered: z.string().optional(),
renewedFrom: z.string().optional(),
renewalDate: z.string().optional(),
renewedTo: z.string().optional(),
// Address fields are user-entered and required (the registration/license
// fields above are read-only confirmations pulled from eTrade).
region: z.string().min(1, "Region is required"),
zone: z.string().min(1, "Zone is required"),
woreda: z.string().min(1, "Woreda is required"),
kebele: z.string().min(1, "Kebele is required"),
houseNo: z.string().min(1, "House number is required"),
contactPersonName: z.string().min(1, "Contact person name is required"),
contactPersonPosition: z.string().optional(),
contactPersonEmail: z
.string()
.email("Invalid email address")
.optional()
.or(z.literal("")),
contactPersonPhone: z
.string()
.min(1, "Contact person phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
generalManagerName: z.string().min(1, "Manager name is required"),
generalManagerEmail: z.string().email("Invalid Manager email"),
generalManagerPhone: z
.string()
.min(1, "Manager phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
poaName: z.string().optional(),
poaPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaAddress: z.string().optional(),
poaEmail: z.string().optional(),
poaLocation: z.string().optional(),
});
export type FormData = z.infer<typeof onboardingSchema>;
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
company: [
"companyName",
"companyEmail",
"companyPhone",
"companyLocation",
"companyAddress",
"tinNumber",
"vatNumber",
"fanNumber",
"licenceNumber",
"statusDescription",
"dateRegistered",
"renewedFrom",
"renewalDate",
"renewedTo",
"region",
"zone",
"woreda",
"kebele",
"houseNo",
],
personnel: [
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
],
contact: [
"contactPersonName",
"contactPersonPosition",
"contactPersonEmail",
"contactPersonPhone",
],
verify: [],
poa: [],
documents: [],
additional: [],
};

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>
); );
} }