fix: onboarding

This commit is contained in:
Nathnael
2026-06-24 12:18:31 +00:00
parent 9b48955eaf
commit 66d3ae8093
3 changed files with 149 additions and 123 deletions

View File

@@ -30,7 +30,11 @@ export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
}
async generateReference(type: ProfileType): Promise<string> {
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`,
);

View File

@@ -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<CompanyNationality | null>(
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

View File

@@ -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<UpdateProfilePayload> {
function stepPayload(
step: CompanyStep,
d: FormData,
): Partial<UpdateProfilePayload> {
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 (
<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({
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({
/>
</SimpleGrid>
<>
{hasRegistrationDetails && (
<>
<Divider my="sm" />
<Text fw={600} size="sm" c="edr-text">
Registration Details
</Text>
<Group gap="xs" align="center">
<Text fw={600} size="sm" c="edr-text">
Registration Details
</Text>
<Text size="xs" c="dimmed">
from eTrade · read-only
</Text>
</Group>
<SimpleGrid cols={2} spacing="md">
<TextInput
<ReadOnlyField
label="License Number"
placeholder="01/23/01/19786/2006"
error={errors.licenceNumber?.message}
{...register("licenceNumber")}
value={watch("licenceNumber")}
/>
<TextInput
<ReadOnlyField
label="Status"
placeholder="Not renewed for 2 years"
error={errors.statusDescription?.message}
{...register("statusDescription")}
value={watch("statusDescription")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
<ReadOnlyField
label="Date Registered"
placeholder="12/17/2013"
error={errors.dateRegistered?.message}
{...register("dateRegistered")}
value={watch("dateRegistered")}
/>
<TextInput
<ReadOnlyField
label="Renewal Date"
placeholder="3/17/2016"
error={errors.renewalDate?.message}
{...register("renewalDate")}
value={watch("renewalDate")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
<ReadOnlyField
label="Renewed From"
placeholder="3/17/2016"
error={errors.renewedFrom?.message}
{...register("renewedFrom")}
value={watch("renewedFrom")}
/>
<TextInput
<ReadOnlyField
label="Renewed To"
placeholder="7/7/2016"
error={errors.renewedTo?.message}
{...register("renewedTo")}
value={watch("renewedTo")}
/>
</SimpleGrid>
@@ -658,47 +687,15 @@ export default function CompanyProfileForm({
Address Information
</Text>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Region"
placeholder="Tigray"
error={errors.region?.message}
{...register("region")}
/>
<TextInput
label="Zone"
placeholder="EASTERN TIGRAY"
error={errors.zone?.message}
{...register("zone")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Woreda"
placeholder="EROB"
error={errors.woreda?.message}
{...register("woreda")}
/>
<TextInput
label="Kebele"
placeholder="ARAS"
error={errors.kebele?.message}
{...register("kebele")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="House No"
placeholder="House Number"
error={errors.houseNo?.message}
{...register("houseNo")}
/>
<ControlledPhoneField
control={control}
name="etradePhone"
label="Phone"
/>
<ReadOnlyField label="Region" value={watch("region")} />
<ReadOnlyField label="Zone" value={watch("zone")} />
<ReadOnlyField label="Woreda" value={watch("woreda")} />
<ReadOnlyField label="Kebele" value={watch("kebele")} />
<ReadOnlyField label="House No" value={watch("houseNo")} />
<ReadOnlyField label="Phone" value={watch("etradePhone")} />
</SimpleGrid>
</>
)}
</>
)}
@@ -746,15 +743,22 @@ export default function CompanyProfileForm({
{step === "contact" && (
<>
<Text fw={600} size="sm" c="edr-text">
Contact Person
</Text>
<Checkbox
color="edr-green"
label="Use General Manager as contact person"
checked={gmIsContact}
onChange={(e) => toggleGmAsContact(e.currentTarget.checked)}
/>
<Group justify="space-between" align="center">
<Text fw={600} size="sm" c="edr-text">
Contact Person
</Text>
{watch("generalManagerName") && (
<Button
variant="light"
color="edr-green"
size="xs"
leftSection={<UserCheck size={14} />}
onClick={useGmAsContact}
>
Use General Manager
</Button>
)}
</Group>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Name"
@@ -789,16 +793,24 @@ export default function CompanyProfileForm({
{step === "poa" && (
<>
<Text size="sm" c="edr-muted">
Power of Attorney details are optional. Fill them in if you have
them, or skip to continue.
</Text>
<Checkbox
color="edr-green"
label="Use contact person as Power of Attorney"
checked={contactIsPoa}
onChange={(e) => toggleContactAsPoa(e.currentTarget.checked)}
/>
<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>
<TextInput
label="PoA Name"
placeholder="Authorized Representative Name"
@@ -860,7 +872,7 @@ export default function CompanyProfileForm({
<RoleLicenseStep
profiles={roleProfiles ?? []}
value={licenseFiles ?? {}}
onChange={onLicenseChange ?? (() => {})}
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" ? (
<ArrowRight size={16} />
) : undefined
}