mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
fix: onboarding
This commit is contained in:
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user