diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts index bc2d95224..15aec5ac6 100644 --- a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -30,7 +30,11 @@ export class CompanyProfileRepository extends BaseRepository { } async generateReference(type: ProfileType): Promise { - const seqName = SEQUENCE_MAP[type]; + // The sequences live in the same schema as the entity (e.g. "freight"), but + // the connection's search_path is "public" — so the sequence MUST be + // schema-qualified or `nextval` fails with "relation does not exist". + const schema = this.repository.metadata.schema ?? "public"; + const seqName = `"${schema}".${SEQUENCE_MAP[type]}`; const result = await this.repository.query( `SELECT nextval('${seqName}') AS next_id`, ); diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 845453200..a34676539 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -149,6 +149,10 @@ export default function OnboardingWizardDialog({ const existingProfiles = company?.company?.companyProfiles ?? []; const companyAlreadyStarted = Boolean(company?.company?.id); + // A draft can exist with zero operational profiles (e.g. an interrupted start). + // Such a draft must re-run role selection so the profiles actually get created + // — otherwise the user is stuck with nothing to upload a license against. + const hasOperationalProfiles = existingProfiles.length > 0; const savedNationality = (company?.company?.nationality as CompanyNationality | null) ?? null; @@ -160,7 +164,11 @@ export default function OnboardingWizardDialog({ // Phases: nationality → role → form. If a draft already exists, resume // straight into the form with nationality + roles pre-selected. const [phase, setPhase] = useState<"nationality" | "role" | "form">( - companyAlreadyStarted ? "form" : "nationality", + companyAlreadyStarted + ? hasOperationalProfiles + ? "form" + : "role" + : "nationality", ); const [nationality, setNationality] = useState( savedNationality, @@ -283,7 +291,9 @@ export default function OnboardingWizardDialog({ resumedRef.current = true; setRoles(existingProfiles.map((p) => p.type)); setNationality(savedNationality); - setPhase("form"); + // Resume into the form only when profiles exist; otherwise send the user to + // role selection so the missing operational profiles get created. + setPhase(hasOperationalProfiles ? "form" : "role"); const idx = FORM_STEPS.indexOf(resumeFormStep); if (idx > furthestIdxRef.current) furthestIdxRef.current = idx; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 41113f6fc..67fd0d9ca 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -1,7 +1,6 @@ import { Alert, Button, - Checkbox, Divider, Group, Loader, @@ -12,12 +11,7 @@ import { } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; -import { - AlertCircle, - ArrowLeft, - ArrowRight, - UserCheck, -} from "lucide-react"; +import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -86,11 +80,11 @@ const onboardingSchema = z.object({ .string() .min(1, "Contact person phone is required") .refine(isValidPhone, "Enter a valid phone number"), - generalManagerName: z.string().min(1, "GM name is required"), - generalManagerEmail: z.string().email("Invalid GM email"), + generalManagerName: z.string().min(1, "Manager name is required"), + generalManagerEmail: z.string().email("Invalid Manager email"), generalManagerPhone: z .string() - .min(1, "GM phone is required") + .min(1, "Manager phone is required") .refine(isValidPhone, "Enter a valid phone number"), poaName: z.string().optional(), poaPhone: z @@ -171,7 +165,10 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { } /** Map one wizard step's form values to the profile-update payload it saves. */ -function stepPayload(step: CompanyStep, d: FormData): Partial { +function stepPayload( + step: CompanyStep, + d: FormData, +): Partial { switch (step) { case "company": return { @@ -262,6 +259,20 @@ function toFormValues(p: ProfileResponse): FormData { }; } +/** A single read-only registration value rendered as a label/value pair. */ +function ReadOnlyField({ label, value }: { label: string; value?: string }) { + return ( + + + {label} + + + {value && value.trim() ? value : "—"} + + + ); +} + export default function CompanyProfileForm({ documentSettingCode, documentFiles: controlledFiles, @@ -389,6 +400,20 @@ export default function CompanyProfileForm({ values: rehydrate ? toFormValues(rehydrate) : undefined, }); + // eTrade carries no email, so the company/contact email fields start blank. + // Seed them from the registering user's account email — but only while empty, + // so a typed or rehydrated value is never overwritten. + useEffect(() => { + if (!user?.email) return; + if (!watch("companyEmail")) { + setValue("companyEmail", user.email, { shouldValidate: true }); + } + if (!watch("contactPersonEmail")) { + setValue("contactPersonEmail", user.email); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.email, rehydrate]); + // The business owner/manager pulled from eTrade — powers "Use owner as // manager" on the General Manager step. Null until a TIN lookup succeeds. const [etradeOwner, setEtradeOwner] = useState<{ @@ -396,11 +421,6 @@ export default function CompanyProfileForm({ phone: string; } | null>(null); - // Mirror the two "copy from previous person" checkboxes so they can be - // re-toggled (re-checking re-pulls the latest values). - const [gmIsContact, setGmIsContact] = useState(false); - const [contactIsPoa, setContactIsPoa] = useState(false); - const handleETradeDataLoaded = (data: CompanyRegistrationData) => { // Company name comes from the eTrade manager/owner name on the license. if (data.managerName) { @@ -457,19 +477,19 @@ export default function CompanyProfileForm({ }); }; - /** Copy the General Manager into the Contact Person fields (toggleable). */ - const toggleGmAsContact = (checked: boolean) => { - setGmIsContact(checked); - if (!checked) return; - setValue("contactPersonName", watch("generalManagerName")); + /** 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")); + setValue("contactPersonPhone", watch("generalManagerPhone"), { + shouldValidate: true, + }); }; - /** Copy the Contact Person into the PoA fields (toggleable, still editable). */ - const toggleContactAsPoa = (checked: boolean) => { - setContactIsPoa(checked); - if (!checked) return; + /** Copy the Contact Person into the PoA fields (still editable). */ + const useContactAsPoa = () => { setValue("poaName", watch("contactPersonName")); setValue("poaEmail", watch("contactPersonEmail")); setValue("poaPhone", watch("contactPersonPhone")); @@ -477,6 +497,25 @@ export default function CompanyProfileForm({ const hasDocuments = Boolean(uploadSetting?.fields?.length); + // Registration + address details come straight from the eTrade lookup and are + // not user-editable — only shown once a TIN lookup (or rehydration) has filled + // them in. We watch the values so the read-only display reflects the latest. + const registration = watch([ + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewalDate", + "renewedFrom", + "renewedTo", + "region", + "zone", + "woreda", + "kebele", + "houseNo", + "etradePhone", + ]); + const hasRegistrationDetails = registration.some((v) => v && v.trim()); + // Single source of truth for step sequence — navigation, labels and the // progress bar all derive from this so adding/removing a step is one edit. const stepOrder: CompanyStep[] = [ @@ -606,51 +645,41 @@ export default function CompanyProfileForm({ /> - <> + {hasRegistrationDetails && ( + <> - - Registration Details - + + + Registration Details + + + from eTrade · read-only + + - - - - - - - - - - @@ -658,47 +687,15 @@ export default function CompanyProfileForm({ Address Information - - - - - - - - - - + + + + + + + )} )} @@ -746,15 +743,22 @@ export default function CompanyProfileForm({ {step === "contact" && ( <> - - Contact Person - - toggleGmAsContact(e.currentTarget.checked)} - /> + + + Contact Person + + {watch("generalManagerName") && ( + + )} + - - Power of Attorney details are optional. Fill them in if you have - them, or skip to continue. - - toggleContactAsPoa(e.currentTarget.checked)} - /> + + + Power of Attorney details are optional. Fill them in if you + have them, or skip to continue. + + {watch("contactPersonName") && ( + + )} + {})} + onChange={onLicenseChange ?? (() => { })} /> )} @@ -902,9 +914,9 @@ export default function CompanyProfileForm({ loading={isPending || saving} rightSection={ !isPending && - !saving && - step !== "additional" && - step !== "documents" ? ( + !saving && + step !== "additional" && + step !== "documents" ? ( ) : undefined }