mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/vehicle_2
This commit is contained in:
@@ -22,7 +22,7 @@ import {
|
||||
LayoutGrid,
|
||||
Package,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
@@ -84,9 +84,6 @@ export default function CustomerDetailPage() {
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
const approveMutation = useMutation(
|
||||
api.customers.setCompanyStatus.mutationOptions(),
|
||||
);
|
||||
const bookingsQuery = useQuery(
|
||||
api.customers.bookings.queryOptions({
|
||||
input: { id: id ?? "" },
|
||||
@@ -394,28 +391,12 @@ export default function CustomerDetailPage() {
|
||||
]}
|
||||
backTo="/dashboard/customers"
|
||||
title={company.name}
|
||||
subtitle={`TIN ${company.tin}${
|
||||
company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
meta={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CompanyTypeBadge type={company.type} />
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
@@ -546,9 +527,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
bookingsQuery.isError
|
||||
? {
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -567,9 +548,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
documentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -588,9 +569,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
paymentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -164,7 +164,9 @@ export default function OnboardingWizardDialog({
|
||||
(company?.company?.nationality as CompanyNationality | null) ?? null;
|
||||
|
||||
// Resume position from the backend-persisted step.
|
||||
const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep)
|
||||
const resumeFormStep: FormStep = FORM_STEPS.includes(
|
||||
onboardingStep as FormStep,
|
||||
)
|
||||
? (onboardingStep as FormStep)
|
||||
: "company";
|
||||
|
||||
@@ -275,7 +277,7 @@ export default function OnboardingWizardDialog({
|
||||
const idx = FORM_STEPS.indexOf(step as FormStep);
|
||||
if (idx < 0 || idx <= furthestIdxRef.current) return;
|
||||
furthestIdxRef.current = idx;
|
||||
api.companies.setOnboardingStep.call({ step }).catch(() => {});
|
||||
api.companies.setOnboardingStep.call({ step }).catch(() => { });
|
||||
}, []);
|
||||
|
||||
// Mirror the form's step locally (for the header/pill) and persist it.
|
||||
@@ -321,7 +323,7 @@ export default function OnboardingWizardDialog({
|
||||
|
||||
// Note: no "back to role selection" — once the draft is created the role(s)
|
||||
// are fixed; the form's first-step Back is a no-op so progress never resets.
|
||||
const handleBackToRoles = useCallback(() => {}, []);
|
||||
const handleBackToRoles = useCallback(() => { }, []);
|
||||
|
||||
// Save the current step's fields to the draft (PATCH /profile). Returns the
|
||||
// server error message on failure so the form can show it (e.g. duplicate TIN).
|
||||
@@ -339,6 +341,31 @@ export default function OnboardingWizardDialog({
|
||||
[],
|
||||
);
|
||||
|
||||
// Auto-upload the documents the user just selected as they leave the documents
|
||||
// step. Only the in-memory selections are sent; once uploaded they're cleared
|
||||
// (so the final submit never re-uploads them) and the requirements query is
|
||||
// refreshed so the "Already uploaded" badges light up. Partial uploads are
|
||||
// allowed — the user may continue even with required docs still outstanding.
|
||||
const handleUploadDocuments = useCallback(async (): Promise<
|
||||
{ ok: true } | { ok: false; error: string }
|
||||
> => {
|
||||
const companyId = company?.company?.id;
|
||||
const hasNew = Object.values(documentFiles).some(
|
||||
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
|
||||
);
|
||||
if (!companyId || !hasNew) return { ok: true };
|
||||
try {
|
||||
await companiesService.uploadDocuments(companyId, documentFiles);
|
||||
setDocumentFiles({});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: api.companies.onboardingRequirements.queryKey(),
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractApiError(err).message };
|
||||
}
|
||||
}, [company?.company?.id, documentFiles, queryClient]);
|
||||
|
||||
// Final confirm step → finalize onboarding (no company create; it already
|
||||
// exists as a draft that's been filled in step-by-step).
|
||||
const handleSubmit = useCallback(
|
||||
@@ -383,6 +410,25 @@ export default function OnboardingWizardDialog({
|
||||
requirementsQuery.data?.documentSettingCode ??
|
||||
documentSettingCode(effectiveNationality);
|
||||
|
||||
// Server-confirmed document state, used both to badge already-uploaded fields
|
||||
// and to keep a refreshed resume from over-shooting the documents step.
|
||||
const requirementDocuments = requirementsQuery.data?.documents ?? [];
|
||||
const uploadedDocumentKeys = requirementDocuments
|
||||
.filter((d) => d.uploaded)
|
||||
.map((d) => d.fileKey);
|
||||
// If any REQUIRED document is still missing, the resume must not rest past the
|
||||
// documents step (don't skip to Business License) — clamp it back. This only
|
||||
// changes the target once requirements load; the form follows the correction
|
||||
// as long as the user hasn't navigated yet.
|
||||
const requiredDocsMissing = requirementDocuments.some(
|
||||
(d) => d.isRequired && !d.uploaded,
|
||||
);
|
||||
const effectiveResumeStep: FormStep =
|
||||
requiredDocsMissing &&
|
||||
FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents")
|
||||
? "documents"
|
||||
: resumeFormStep;
|
||||
|
||||
const formProps = {
|
||||
documentSettingCode: resolvedDocumentSettingCode,
|
||||
documentFiles,
|
||||
@@ -392,7 +438,7 @@ export default function OnboardingWizardDialog({
|
||||
isPending: finishMutation.isPending,
|
||||
onBack: handleBackToRoles,
|
||||
hideFirstStepBack: true,
|
||||
initialStep: resumeFormStep,
|
||||
initialStep: effectiveResumeStep,
|
||||
resyncOpen: opened,
|
||||
onStepChange: handleStepChange,
|
||||
onSaveStep: saveStep,
|
||||
@@ -400,6 +446,8 @@ export default function OnboardingWizardDialog({
|
||||
roleProfiles,
|
||||
licenseFiles,
|
||||
onLicenseChange: setLicenseFiles,
|
||||
uploadedDocumentKeys,
|
||||
onUploadDocuments: handleUploadDocuments,
|
||||
// Surface a failed final submit (license/document upload or complete) inside
|
||||
// the form — otherwise the server message (e.g. a 500) would be invisible on
|
||||
// the submit step.
|
||||
@@ -413,7 +461,7 @@ export default function OnboardingWizardDialog({
|
||||
withCloseButton={!completed}
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={!completed}
|
||||
size={720}
|
||||
size={1440}
|
||||
radius="lg"
|
||||
padding="xl"
|
||||
centered
|
||||
@@ -422,11 +470,11 @@ export default function OnboardingWizardDialog({
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
|
||||
styles={{
|
||||
header: {
|
||||
alignItems:"flex-start"
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
title: {
|
||||
flex: 1
|
||||
}
|
||||
flex: 1,
|
||||
},
|
||||
}}
|
||||
title={
|
||||
completed ? null : (
|
||||
@@ -448,59 +496,64 @@ export default function OnboardingWizardDialog({
|
||||
{completed ? (
|
||||
<OnboardingCompletePanel onClose={handleClose} />
|
||||
) : (
|
||||
<Stack gap="xl">
|
||||
|
||||
{phase === "nationality" ? (
|
||||
<Stack gap="lg">
|
||||
<NationalitySelect
|
||||
value={nationality}
|
||||
onChange={setNationality}
|
||||
embedded
|
||||
/>
|
||||
<Group justify="flex-end" pt="xs">
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleNationalityContinue}
|
||||
disabled={!nationality}
|
||||
rightSection={<ArrowRight size={16} />}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : phase === "role" ? (
|
||||
<Stack gap="lg">
|
||||
<OnboardingRoleSelect value={roles} onChange={setRoles} embedded />
|
||||
{startError && (
|
||||
<Text size="sm" c="red">
|
||||
{startError}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="space-between" pt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => setPhase("nationality")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleRolesContinue}
|
||||
disabled={!rolesValid}
|
||||
loading={startMutation.isPending}
|
||||
rightSection={
|
||||
startMutation.isPending ? undefined : <ArrowRight size={16} />
|
||||
}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<CompanyProfileForm {...formProps} />
|
||||
)}
|
||||
</Stack>
|
||||
<Stack gap="xl">
|
||||
{phase === "nationality" ? (
|
||||
<Stack gap="lg">
|
||||
<NationalitySelect
|
||||
value={nationality}
|
||||
onChange={setNationality}
|
||||
embedded
|
||||
/>
|
||||
<Group justify="flex-end" pt="xs">
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleNationalityContinue}
|
||||
disabled={!nationality}
|
||||
rightSection={<ArrowRight size={16} />}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : phase === "role" ? (
|
||||
<Stack gap="lg">
|
||||
<OnboardingRoleSelect
|
||||
value={roles}
|
||||
onChange={setRoles}
|
||||
embedded
|
||||
/>
|
||||
{startError && (
|
||||
<Text size="sm" c="red">
|
||||
{startError}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="space-between" pt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => setPhase("nationality")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleRolesContinue}
|
||||
disabled={!rolesValid}
|
||||
loading={startMutation.isPending}
|
||||
rightSection={
|
||||
startMutation.isPending ? undefined : (
|
||||
<ArrowRight size={16} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<CompanyProfileForm {...formProps} />
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
@@ -518,7 +571,10 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
|
||||
className="flex h-16 w-16 items-center justify-center rounded-full"
|
||||
style={{ background: "var(--mantine-color-edr-green-1)" }}
|
||||
>
|
||||
<PartyPopper size={32} className="text-[var(--mantine-color-edr-green-7)]" />
|
||||
<PartyPopper
|
||||
size={32}
|
||||
className="text-[var(--mantine-color-edr-green-7)]"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
@@ -538,14 +594,20 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
|
||||
style={{ background: "var(--mantine-color-edr-green-0)" }}
|
||||
>
|
||||
<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">
|
||||
Each operational profile (importer, exporter, freight forwarder) is
|
||||
reviewed and approved individually.
|
||||
</Text>
|
||||
</Group>
|
||||
<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">
|
||||
You can start creating bookings under a profile as soon as it's
|
||||
approved — we'll let you know the moment that happens.
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Card,
|
||||
FileInput,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { FileText, Paperclip, Upload } from "lucide-react";
|
||||
import { Anchor, Group, Stack, Text } from "@mantine/core";
|
||||
import { Paperclip } from "lucide-react";
|
||||
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import type { IFileUploadSetting } from "@edr/types/freight";
|
||||
|
||||
import type { LicenseFile } from "@/services/companies.service";
|
||||
|
||||
@@ -20,6 +14,48 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
transporter: "Transporter",
|
||||
};
|
||||
|
||||
/** Field key the synthesized per-profile upload setting is keyed on. */
|
||||
const LICENSE_FILE_KEY = "business_license";
|
||||
|
||||
/**
|
||||
* Build a single-field upload setting so each profile's license input can reuse
|
||||
* the shared SmartFileInput (same dropzone + "uploaded" state as the documents
|
||||
* step), instead of a bespoke file picker.
|
||||
*/
|
||||
function buildLicenseSetting(
|
||||
profileId: string,
|
||||
profileName: string,
|
||||
): IFileUploadSetting {
|
||||
return {
|
||||
id: `license-setting-${profileId}`,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
deletedAt: null,
|
||||
code: "business_license",
|
||||
label: "Business license",
|
||||
description: null,
|
||||
entity: "customer",
|
||||
fields: [
|
||||
{
|
||||
id: `${LICENSE_FILE_KEY}-${profileId}`,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
deletedAt: null,
|
||||
settingId: `license-setting-${profileId}`,
|
||||
fileKey: LICENSE_FILE_KEY,
|
||||
fileLabel: `Upload ${profileName} Business license file(s)`,
|
||||
helpText: null,
|
||||
isRequired: true,
|
||||
isMultiple: true,
|
||||
maxFiles: 10,
|
||||
allowedExtensions: ["pdf", "png", "jpg", "jpeg"],
|
||||
maxSizeMb: 10,
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export interface RoleLicenseProfile {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -38,8 +74,9 @@ interface RoleLicenseStepProps {
|
||||
|
||||
/**
|
||||
* Final onboarding step: collect a business license (one or more files) for
|
||||
* each operational role the company holds. Each role gets its own multi-file
|
||||
* input; already-uploaded files are listed for context.
|
||||
* each operational role the company holds. Each role gets its own SmartFileInput
|
||||
* dropzone; already-uploaded files are listed (with download links) for context
|
||||
* and surface the input's "uploaded" state.
|
||||
*/
|
||||
export default function RoleLicenseStep({
|
||||
profiles,
|
||||
@@ -60,37 +97,11 @@ export default function RoleLicenseStep({
|
||||
{profiles.map((profile) => {
|
||||
const label = ROLE_LABELS[profile.type] ?? profile.type;
|
||||
const selected = value[profile.id] ?? [];
|
||||
const hasAny = selected.length > 0 || profile.existingFiles.length > 0;
|
||||
const hasExisting = profile.existingFiles.length > 0;
|
||||
|
||||
return (
|
||||
<Card key={profile.id} padding="lg" withBorder>
|
||||
<Group justify="space-between" mb="sm" wrap="nowrap">
|
||||
<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 && (
|
||||
<>
|
||||
{hasExisting && (
|
||||
<Stack gap={4} mb="sm">
|
||||
{profile.existingFiles.map((f) => (
|
||||
<Group key={f.url} gap={6} wrap="nowrap">
|
||||
@@ -108,20 +119,17 @@ export default function RoleLicenseStep({
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<FileInput
|
||||
multiple
|
||||
clearable
|
||||
accept="application/pdf,image/png,image/jpeg"
|
||||
leftSection={<Upload size={16} />}
|
||||
placeholder={
|
||||
profile.existingFiles.length > 0
|
||||
? "Upload more / replace files"
|
||||
: "Select license file(s)"
|
||||
}
|
||||
value={selected}
|
||||
onChange={(files) => setFiles(profile.id, files ?? [])}
|
||||
<SmartFileInput
|
||||
file={buildLicenseSetting(profile.id, label)}
|
||||
value={{ [LICENSE_FILE_KEY]: selected }}
|
||||
uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined}
|
||||
onChange={(v) => {
|
||||
const next = v[LICENSE_FILE_KEY];
|
||||
const files = Array.isArray(next) ? next : next ? [next] : [];
|
||||
setFiles(profile.id, files);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
@@ -18,23 +18,17 @@ import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
RotateCw,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
import {
|
||||
ControlledPhoneField,
|
||||
isValidPhone,
|
||||
toEthiopianE164,
|
||||
} from "@/components/PhoneField";
|
||||
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
import RoleLicenseStep, {
|
||||
@@ -42,261 +36,21 @@ import RoleLicenseStep, {
|
||||
} from "@/components/onboarding/RoleLicenseStep";
|
||||
import ETradeInfo from "@/components/onboarding/ETradeInfo";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
type CompanyStep =
|
||||
| "company"
|
||||
| "personnel"
|
||||
| "contact"
|
||||
| "verify"
|
||||
| "poa"
|
||||
| "documents"
|
||||
| "additional";
|
||||
|
||||
/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */
|
||||
const phoneDigits = (p?: string | null) => (p ?? "").replace(/\D/g, "").slice(-9);
|
||||
const samePhone = (a?: string | null, b?: string | null) => {
|
||||
const da = phoneDigits(a);
|
||||
return da.length === 9 && da === phoneDigits(b);
|
||||
};
|
||||
/** Mask all but the first 7 chars of an E.164 phone for display. */
|
||||
const maskPhone = (p: string) =>
|
||||
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
companyEmail: z.string().email("Invalid email address"),
|
||||
companyPhone: z
|
||||
.string()
|
||||
.min(1, "Company phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
companyLocation: z.string().min(1, "Location is required"),
|
||||
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
|
||||
// standalone input — the granular fields live in the registration section.
|
||||
companyAddress: z.string().optional(),
|
||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||
vatNumber: z
|
||||
.string()
|
||||
.min(1, "VAT number is required")
|
||||
.length(10, "VAT number must be exactly 10 digits"),
|
||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||
licenceNumber: z.string().optional(),
|
||||
statusDescription: z.string().optional(),
|
||||
dateRegistered: z.string().optional(),
|
||||
renewedFrom: z.string().optional(),
|
||||
renewalDate: z.string().optional(),
|
||||
renewedTo: z.string().optional(),
|
||||
// Address fields are user-entered and required (the registration/license
|
||||
// fields above are read-only confirmations pulled from eTrade).
|
||||
region: z.string().min(1, "Region is required"),
|
||||
zone: z.string().min(1, "Zone is required"),
|
||||
woreda: z.string().min(1, "Woreda is required"),
|
||||
kebele: z.string().min(1, "Kebele is required"),
|
||||
houseNo: z.string().min(1, "House number is required"),
|
||||
etradePhone: z.string().optional(),
|
||||
contactPersonName: z.string().min(1, "Contact person name is required"),
|
||||
contactPersonPosition: z.string().optional(),
|
||||
contactPersonEmail: z
|
||||
.string()
|
||||
.email("Invalid email address")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
contactPersonPhone: z
|
||||
.string()
|
||||
.min(1, "Contact person phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
generalManagerName: z.string().min(1, "Manager name is required"),
|
||||
generalManagerEmail: z.string().email("Invalid Manager email"),
|
||||
generalManagerPhone: z
|
||||
.string()
|
||||
.min(1, "Manager phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
poaName: z.string().optional(),
|
||||
poaPhone: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||
poaAddress: z.string().optional(),
|
||||
poaEmail: z.string().optional(),
|
||||
poaLocation: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<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>
|
||||
);
|
||||
}
|
||||
import {
|
||||
type CompanyStep,
|
||||
type FormData,
|
||||
onboardingSchema,
|
||||
stepFields,
|
||||
} from "./companyProfileForm/schema";
|
||||
import {
|
||||
buildPayload,
|
||||
maskPhone,
|
||||
samePhone,
|
||||
stepPayload,
|
||||
toFormValues,
|
||||
} from "./companyProfileForm/helpers";
|
||||
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
||||
import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField";
|
||||
|
||||
export default function CompanyProfileForm({
|
||||
documentSettingCode,
|
||||
@@ -316,6 +70,8 @@ export default function CompanyProfileForm({
|
||||
licenseFiles,
|
||||
onLicenseChange,
|
||||
submitError,
|
||||
uploadedDocumentKeys,
|
||||
onUploadDocuments,
|
||||
}: {
|
||||
documentSettingCode: string;
|
||||
documentFiles?: Record<string, File | File[] | null>;
|
||||
@@ -345,6 +101,16 @@ export default function CompanyProfileForm({
|
||||
onLicenseChange?: (value: Record<string, File[]>) => void;
|
||||
/** Server error from the final submit (uploads/complete), shown verbatim. */
|
||||
submitError?: string | null;
|
||||
/** fileKeys whose company document is already uploaded server-side (resume). */
|
||||
uploadedDocumentKeys?: string[];
|
||||
/**
|
||||
* Auto-upload the currently-selected company documents (the Documents step's
|
||||
* "Continue" action). Resolves to an error message string on failure so the
|
||||
* step can surface it and hold the user in place.
|
||||
*/
|
||||
onUploadDocuments?: () => Promise<
|
||||
{ ok: true } | { ok: false; error: string }
|
||||
>;
|
||||
}) {
|
||||
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -356,17 +122,40 @@ export default function CompanyProfileForm({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step]);
|
||||
|
||||
// Tracks whether the user has manually navigated the form this session. While
|
||||
// false, the form still follows the parent's resume target (initialStep) —
|
||||
// which can shift to an earlier step once server data lands (e.g. a required
|
||||
// document turns out to be un-uploaded, so we must not rest on a later step).
|
||||
const userNavigatedRef = useRef(false);
|
||||
|
||||
// On reopen, jump to the furthest step reached (initialStep) so progress
|
||||
// never appears to reset.
|
||||
// never appears to reset. Re-arm the follow-the-parent behaviour too.
|
||||
const wasOpen = useRef(resyncOpen);
|
||||
useEffect(() => {
|
||||
if (resyncOpen && !wasOpen.current && initialStep) {
|
||||
userNavigatedRef.current = false;
|
||||
setStep(initialStep);
|
||||
setSaveError(null);
|
||||
}
|
||||
wasOpen.current = resyncOpen;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resyncOpen]);
|
||||
|
||||
// Follow a parent-driven resume correction: if initialStep changes (the wizard
|
||||
// re-clamps it back once onboarding requirements load — e.g. a required
|
||||
// document is still missing, so it must not skip ahead to Business License),
|
||||
// adopt it, but only while the user hasn't started navigating themselves.
|
||||
const lastInitialStep = useRef(initialStep);
|
||||
useEffect(() => {
|
||||
if (initialStep && initialStep !== lastInitialStep.current) {
|
||||
lastInitialStep.current = initialStep;
|
||||
if (!userNavigatedRef.current) {
|
||||
setStep(initialStep);
|
||||
setSaveError(null);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialStep]);
|
||||
const [internalFiles, setInternalFiles] = useState<
|
||||
Record<string, File | File[] | null>
|
||||
>({});
|
||||
@@ -410,7 +199,6 @@ export default function CompanyProfileForm({
|
||||
woreda: "",
|
||||
kebele: "",
|
||||
houseNo: "",
|
||||
etradePhone: "",
|
||||
contactPersonName: "",
|
||||
contactPersonPosition: "",
|
||||
contactPersonEmail: "",
|
||||
@@ -482,7 +270,7 @@ export default function CompanyProfileForm({
|
||||
setValue("kebele", data.kebele);
|
||||
setValue("houseNo", data.houseNo);
|
||||
setValue(
|
||||
"etradePhone",
|
||||
"companyPhone",
|
||||
toEthiopianE164(data.regularPhone || data.mobilePhone),
|
||||
);
|
||||
// companyAddress is composed reactively from the address fields below, so
|
||||
@@ -512,33 +300,54 @@ export default function CompanyProfileForm({
|
||||
});
|
||||
};
|
||||
|
||||
/** Copy the General Manager into the Contact Person fields (still editable). */
|
||||
const useGmAsContact = () => {
|
||||
setValue("contactPersonName", watch("generalManagerName"), {
|
||||
shouldValidate: true,
|
||||
});
|
||||
setValue("contactPersonEmail", watch("generalManagerEmail"));
|
||||
setValue("contactPersonPhone", watch("generalManagerPhone"), {
|
||||
shouldValidate: true,
|
||||
});
|
||||
// "Same as …" links. A checked card prefills the target step's fields from the
|
||||
// source step and disables them (kept mirrored while linked); unchecking clears
|
||||
// them and re-enables editing.
|
||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||
const [poaSameAsContact, setPoaSameAsContact] = useState(false);
|
||||
|
||||
const gmName = watch("generalManagerName");
|
||||
const gmEmail = watch("generalManagerEmail");
|
||||
const gmPhone = watch("generalManagerPhone");
|
||||
const contactName = watch("contactPersonName");
|
||||
const contactEmail = watch("contactPersonEmail");
|
||||
const contactPhone = watch("contactPersonPhone");
|
||||
|
||||
// While linked, mirror the source values into the (disabled) target fields so
|
||||
// the copy stays current even if the user goes back and edits the source.
|
||||
useEffect(() => {
|
||||
if (!contactSameAsGm) return;
|
||||
setValue("contactPersonName", gmName ?? "", { shouldValidate: true });
|
||||
setValue("contactPersonEmail", gmEmail ?? "");
|
||||
setValue("contactPersonPhone", gmPhone ?? "", { shouldValidate: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!poaSameAsContact) return;
|
||||
setValue("poaName", contactName ?? "");
|
||||
setValue("poaEmail", contactEmail ?? "");
|
||||
setValue("poaPhone", contactPhone ?? "");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [poaSameAsContact, contactName, contactEmail, contactPhone]);
|
||||
|
||||
const toggleContactSameAsGm = (checked: boolean) => {
|
||||
setContactSameAsGm(checked);
|
||||
// Checked → the mirror effect fills the fields; unchecked → reset them.
|
||||
if (!checked) {
|
||||
setValue("contactPersonName", "");
|
||||
setValue("contactPersonEmail", "");
|
||||
setValue("contactPersonPhone", "");
|
||||
}
|
||||
};
|
||||
|
||||
/** Copy the Contact Person into the PoA fields (still editable). */
|
||||
const useContactAsPoa = () => {
|
||||
setValue("poaName", watch("contactPersonName"));
|
||||
setValue("poaEmail", watch("contactPersonEmail"));
|
||||
setValue("poaPhone", watch("contactPersonPhone"));
|
||||
};
|
||||
|
||||
/** Populate the Contact Person from the currently logged-in user. */
|
||||
const useLoggedInUserAsContact = () => {
|
||||
setValue("contactPersonName", user?.name?.en ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
if (user?.email) setValue("contactPersonEmail", user.email);
|
||||
setValue("contactPersonPhone", user?.phoneNumber ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
const togglePoaSameAsContact = (checked: boolean) => {
|
||||
setPoaSameAsContact(checked);
|
||||
if (!checked) {
|
||||
setValue("poaName", "");
|
||||
setValue("poaEmail", "");
|
||||
setValue("poaPhone", "");
|
||||
}
|
||||
};
|
||||
|
||||
// --- Contact-phone SMS OTP verification -----------------------------------
|
||||
@@ -612,7 +421,7 @@ export default function CompanyProfileForm({
|
||||
setOtpSent(false);
|
||||
// Persist the verified phone so the step resumes as "done" after a refresh
|
||||
// (best-effort — the OTP itself already succeeded server-side).
|
||||
onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => {});
|
||||
onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => { });
|
||||
} catch (err) {
|
||||
setOtpError(extractApiError(err).message);
|
||||
} finally {
|
||||
@@ -675,6 +484,7 @@ export default function CompanyProfileForm({
|
||||
);
|
||||
|
||||
const nextStep = async () => {
|
||||
userNavigatedRef.current = true;
|
||||
if (step === "additional") {
|
||||
if (!licenseComplete) {
|
||||
setSaveError(
|
||||
@@ -699,16 +509,34 @@ export default function CompanyProfileForm({
|
||||
setStep(stepOrder[currentIdx + 1]);
|
||||
return;
|
||||
}
|
||||
// The documents step has nothing to persist; field steps validate + save
|
||||
// before advancing.
|
||||
if (step !== "documents") {
|
||||
const ok = await saveCurrentStep();
|
||||
if (!ok) return;
|
||||
// The documents step auto-uploads whatever the user selected as they
|
||||
// continue (partial uploads are allowed — required-doc completeness is
|
||||
// re-checked on resume). A failed upload holds them on the step.
|
||||
if (step === "documents") {
|
||||
if (onUploadDocuments) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await onUploadDocuments();
|
||||
if (!res.ok) {
|
||||
setSaveError(res.error);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
setSaveError(null);
|
||||
setStep(stepOrder[currentIdx + 1]);
|
||||
return;
|
||||
}
|
||||
// Field steps validate + save before advancing.
|
||||
const ok = await saveCurrentStep();
|
||||
if (!ok) return;
|
||||
setStep(stepOrder[currentIdx + 1]);
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
userNavigatedRef.current = true;
|
||||
setSaveError(null);
|
||||
if (currentIdx === 0) onBack();
|
||||
else setStep(stepOrder[currentIdx - 1]);
|
||||
@@ -864,11 +692,6 @@ export default function CompanyProfileForm({
|
||||
error={errors.houseNo?.message}
|
||||
{...register("houseNo")}
|
||||
/>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="etradePhone"
|
||||
label="Phone"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
@@ -917,33 +740,17 @@ export default function CompanyProfileForm({
|
||||
|
||||
{step === "contact" && (
|
||||
<>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
Contact Person
|
||||
</Text>
|
||||
<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") && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
leftSection={<UserCheck size={14} />}
|
||||
onClick={useGmAsContact}
|
||||
>
|
||||
Use General Manager
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
Contact Person
|
||||
</Text>
|
||||
{watch("generalManagerName") && (
|
||||
<LinkCheckboxCard
|
||||
checked={contactSameAsGm}
|
||||
onToggle={toggleContactSameAsGm}
|
||||
title="Same as General Manager"
|
||||
description="Reuse the general manager's name, email and phone. Uncheck to enter different details."
|
||||
/>
|
||||
)}
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Name"
|
||||
@@ -978,12 +785,6 @@ export default function CompanyProfileForm({
|
||||
|
||||
{step === "verify" && (
|
||||
<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">
|
||||
We'll text a one-time code to the contact person's phone to
|
||||
confirm it's reachable. This is required before you continue.
|
||||
@@ -1009,7 +810,10 @@ export default function CompanyProfileForm({
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<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">
|
||||
{maskPhone(contactPhoneE164)}
|
||||
</Text>
|
||||
@@ -1028,15 +832,17 @@ export default function CompanyProfileForm({
|
||||
</Button>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="edr-muted">
|
||||
Enter the 6-digit code we sent to{" "}
|
||||
{maskPhone(contactPhoneE164)}.
|
||||
</Text>
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={otpCode}
|
||||
placeholder="0"
|
||||
styles={{
|
||||
input: {
|
||||
textAlign: "center",
|
||||
},
|
||||
}}
|
||||
onChange={setOtpCode}
|
||||
/>
|
||||
<Group gap="sm">
|
||||
@@ -1056,7 +862,9 @@ export default function CompanyProfileForm({
|
||||
disabled={resendIn > 0 || sendingOtp}
|
||||
leftSection={<RotateCw size={14} />}
|
||||
>
|
||||
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
||||
{resendIn > 0
|
||||
? `Resend in ${resendIn}s`
|
||||
: "Resend code"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -1078,24 +886,18 @@ export default function CompanyProfileForm({
|
||||
|
||||
{step === "poa" && (
|
||||
<>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text size="sm" c="edr-muted">
|
||||
Power of Attorney details are optional. Fill them in if you
|
||||
have them, or skip to continue.
|
||||
</Text>
|
||||
{watch("contactPersonName") && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
leftSection={<UserCheck size={14} />}
|
||||
onClick={useContactAsPoa}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
Use contact person
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="sm" c="edr-muted">
|
||||
Power of Attorney details are optional. Fill them in if you have
|
||||
them, or skip to continue.
|
||||
</Text>
|
||||
{watch("contactPersonName") && (
|
||||
<LinkCheckboxCard
|
||||
checked={poaSameAsContact}
|
||||
onToggle={togglePoaSameAsContact}
|
||||
title="Same as contact person"
|
||||
description="Reuse the contact person's name, email and phone. Uncheck to enter different details."
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
label="PoA Name"
|
||||
placeholder="Authorized Representative Name"
|
||||
@@ -1147,6 +949,8 @@ export default function CompanyProfileForm({
|
||||
<SmartFileInput
|
||||
file={uploadSetting}
|
||||
value={documentFiles}
|
||||
uploadedKeys={uploadedDocumentKeys}
|
||||
containerClassName="lg:grid grid-cols-2 items-stretch"
|
||||
onChange={setDocumentFiles}
|
||||
/>
|
||||
)}
|
||||
@@ -1210,19 +1014,12 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
loading={isPending || saving}
|
||||
rightSection={
|
||||
!isPending &&
|
||||
!saving &&
|
||||
step !== "additional" &&
|
||||
step !== "documents" ? (
|
||||
!isPending && !saving && step !== "additional" ? (
|
||||
<ArrowRight size={16} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{step === "documents" || step === "verify"
|
||||
? "Continue"
|
||||
: step === "additional"
|
||||
? "Submit for review"
|
||||
: "Save & Continue"}
|
||||
{step === "additional" ? "Submit for review" : "Continue"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 ?? "",
|
||||
};
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -1,8 +1,5 @@
|
||||
import React, { useState, useMemo, useRef } from "react";
|
||||
import {
|
||||
IFileUploadSetting,
|
||||
IFileUploadField,
|
||||
} from "@edr/types/freight";
|
||||
import { IFileUploadSetting, IFileUploadField } from "@edr/types/freight";
|
||||
import {
|
||||
UploadCloud,
|
||||
FileText,
|
||||
@@ -24,12 +21,20 @@ export interface SmartFileInputProps {
|
||||
onChange?: (value: Record<string, File | File[] | null>) => void;
|
||||
/** External form errors mapped by fileKey. */
|
||||
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?: boolean;
|
||||
/** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */
|
||||
variant?: "default" | "minimal";
|
||||
/** Optional custom container CSS classes. */
|
||||
className?: string;
|
||||
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
/** Helper to format file sizes in bytes to a human-readable string. */
|
||||
@@ -70,12 +75,16 @@ export function SmartFileInput({
|
||||
value,
|
||||
onChange,
|
||||
errors,
|
||||
uploadedKeys,
|
||||
disabled = false,
|
||||
variant = "default",
|
||||
className,
|
||||
containerClassName,
|
||||
}: SmartFileInputProps) {
|
||||
// Local state to manage files when the component is used in an uncontrolled manner
|
||||
const [internalFiles, setInternalFiles] = useState<Record<string, File[]>>({});
|
||||
const [internalFiles, setInternalFiles] = useState<Record<string, File[]>>(
|
||||
{},
|
||||
);
|
||||
|
||||
// Local validation errors
|
||||
const [localErrors, setLocalErrors] = useState<Record<string, string>>({});
|
||||
@@ -93,10 +102,13 @@ export function SmartFileInput({
|
||||
|
||||
// Create a map of fields for quick lookup
|
||||
const fieldsMap = useMemo(() => {
|
||||
return file.fields.reduce((acc, currentField) => {
|
||||
acc[currentField.fileKey] = currentField;
|
||||
return acc;
|
||||
}, {} as Record<string, IFileUploadField>);
|
||||
return file.fields.reduce(
|
||||
(acc, currentField) => {
|
||||
acc[currentField.fileKey] = currentField;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, IFileUploadField>,
|
||||
);
|
||||
}, [file.fields]);
|
||||
|
||||
// Resolve current files list for a field
|
||||
@@ -110,7 +122,7 @@ export function SmartFileInput({
|
||||
const field = fieldsMap[fieldKey];
|
||||
if (!field) return;
|
||||
|
||||
const newValue = field.isMultiple ? newFiles : (newFiles[0] || null);
|
||||
const newValue = field.isMultiple ? newFiles : newFiles[0] || null;
|
||||
|
||||
if (onChange) {
|
||||
const updatedValues = {
|
||||
@@ -132,7 +144,7 @@ export function SmartFileInput({
|
||||
|
||||
// Clean up extensions (e.g. '.pdf' or 'pdf' -> 'pdf')
|
||||
const allowedExts = field.allowedExtensions.map((ext) =>
|
||||
ext.toLowerCase().replace(/^\./, "")
|
||||
ext.toLowerCase().replace(/^\./, ""),
|
||||
);
|
||||
|
||||
let validIncoming: File[] = [];
|
||||
@@ -140,13 +152,12 @@ export function SmartFileInput({
|
||||
|
||||
for (const fileObj of incomingFiles) {
|
||||
const ext = fileObj.name.split(".").pop()?.toLowerCase() || "";
|
||||
const isExtValid =
|
||||
allowedExts.length === 0 || allowedExts.includes(ext);
|
||||
const isExtValid = allowedExts.length === 0 || allowedExts.includes(ext);
|
||||
const isSizeValid = fileObj.size <= field.maxSizeMb * 1024 * 1024;
|
||||
|
||||
if (!isExtValid) {
|
||||
errorMsg = `Invalid file extension. Allowed: ${field.allowedExtensions.join(
|
||||
", "
|
||||
", ",
|
||||
)}`;
|
||||
break;
|
||||
}
|
||||
@@ -187,7 +198,11 @@ export function SmartFileInput({
|
||||
handleFilesChange(field.fileKey, newFilesList);
|
||||
};
|
||||
|
||||
const handleDrag = (e: React.DragEvent, fieldKey: string, active: boolean) => {
|
||||
const handleDrag = (
|
||||
e: React.DragEvent,
|
||||
fieldKey: string,
|
||||
active: boolean,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
@@ -208,7 +223,7 @@ export function SmartFileInput({
|
||||
|
||||
const handleFileSelect = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
field: IFileUploadField
|
||||
field: IFileUploadField,
|
||||
) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const filesArray = Array.from(e.target.files);
|
||||
@@ -247,178 +262,274 @@ export function SmartFileInput({
|
||||
</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];
|
||||
<div className={cn("flex flex-col gap-6", containerClassName)}>
|
||||
{sortedFields.map((field) => {
|
||||
const currentFiles = getFilesForField(field.fileKey);
|
||||
const maxFiles = field.isMultiple ? Math.max(1, field.maxFiles) : 1;
|
||||
const reachedLimit = currentFiles.length >= maxFiles;
|
||||
const fieldError =
|
||||
errors?.[field.fileKey] || localErrors[field.fileKey];
|
||||
const isDragOver = dragActive[field.fileKey];
|
||||
// Already uploaded server-side and nothing newly picked to replace it.
|
||||
const isUploaded =
|
||||
(uploadedKeys?.includes(field.fileKey) ?? false) &&
|
||||
currentFiles.length === 0;
|
||||
|
||||
// Format accepted files for the HTML input element
|
||||
const acceptString = field.allowedExtensions
|
||||
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
|
||||
.join(",");
|
||||
// Format accepted files for the HTML input element
|
||||
const acceptString = field.allowedExtensions
|
||||
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
|
||||
.join(",");
|
||||
|
||||
return (
|
||||
<div key={field.id || field.fileKey} className="flex flex-col gap-2">
|
||||
{/* Field Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-baseline justify-between gap-1">
|
||||
<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 */}
|
||||
{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"
|
||||
return (
|
||||
<div
|
||||
key={field.id || field.fileKey}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{/* Field Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-baseline justify-between gap-1">
|
||||
<label className="text-sm font-semibold text-foreground flex items-center gap-1.5">
|
||||
<span className="flex items-center gap-1">
|
||||
{field.fileLabel}
|
||||
{field.isRequired && (
|
||||
<span
|
||||
className="text-destructive font-bold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
</span>
|
||||
{isUploaded && (
|
||||
<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">
|
||||
<CheckCircle2 className="h-3 w-3" /> Already uploaded
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<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>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max size: {field.maxSizeMb}MB
|
||||
{field.isMultiple &&
|
||||
` • Files: ${currentFiles.length}/${maxFiles}`}
|
||||
</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",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{isDragOver ? "Drop to replace" : "Document uploaded"}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{isDragOver
|
||||
? "Release to replace the document on file."
|
||||
: "Saved to your application. Drag a new file here or click to replace it."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
type="hidden"
|
||||
name={field.isMultiple ? `${field.fileKey}[]` : field.fileKey}
|
||||
value={fileObj.name}
|
||||
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>
|
||||
))}
|
||||
</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>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>
|
||||
{/* 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
|
||||
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
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user