enhance company approval workflow and TIN validation; add pending approval handling in booking and onboarding forms

This commit is contained in:
Marshal
2026-06-23 08:37:15 +00:00
parent 1d77f377f3
commit 6fd1b68389
13 changed files with 276 additions and 59 deletions

View File

@@ -11,6 +11,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service'; // import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service'; import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity'; import { ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util'; import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
@@ -287,6 +288,12 @@ export class BookingsService {
); );
} }
const { company } = await this.companiesService.getCompanyInfoByUserId(userId); const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
// A customer can only book once their company has been approved.
if (company.status !== CompanyStatus.Active) {
throw new ForbiddenException(
"Your company is awaiting approval — you can't create bookings yet.",
);
}
companyId = company.id; companyId = company.id;
} }

View File

@@ -844,8 +844,9 @@ export class CompaniesService {
onboardingCompleted: true, onboardingCompleted: true,
onboardingStep: "done", onboardingStep: "done",
}); });
// Awaiting backoffice approval — stays Pending until an admin activates it.
await this.companiesRepo.update(companyId, { await this.companiesRepo.update(companyId, {
status: CompanyStatus.Active, status: CompanyStatus.Pending,
}); });
return this.getCompanyInfoByUserId(userId); return this.getCompanyInfoByUserId(userId);
} }

View File

@@ -18,7 +18,9 @@ export class CreateCompanyDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@Length(10, 10) @Length(10, 10)
@Matches(/^\d+$/, { message: 'TIN must contain only digits' }) @Matches(/^00\d{8}$/, {
message: 'TIN must be 10 digits starting with 00',
})
tin!: string; tin!: string;
@IsOptional() @IsOptional()

View File

@@ -35,7 +35,9 @@ export class UpdateProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@Length(10, 10) @Length(10, 10)
@Matches(/^\d+$/, { message: 'TIN must contain only digits' }) @Matches(/^00\d{8}$/, {
message: 'TIN must be 10 digits starting with 00',
})
tin?: string; tin?: string;
@IsOptional() @IsOptional()

View File

@@ -22,7 +22,7 @@ import {
LayoutGrid, LayoutGrid,
Package, Package,
} from "lucide-react"; } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { useMemo } from "react"; import { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
@@ -84,6 +84,9 @@ export default function CustomerDetailPage() {
enabled: Boolean(id), enabled: Boolean(id),
}), }),
); );
const approveMutation = useMutation(
api.customers.setCompanyStatus.mutationOptions(),
);
const bookingsQuery = useQuery( const bookingsQuery = useQuery(
api.customers.bookings.queryOptions({ api.customers.bookings.queryOptions({
input: { id: id ?? "" }, input: { id: id ?? "" },
@@ -398,6 +401,21 @@ export default function CustomerDetailPage() {
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<CompanyTypeBadge type={company.type} /> <CompanyTypeBadge type={company.type} />
<CompanyStatusBadge status={company.status} /> <CompanyStatusBadge status={company.status} />
{company.status === "pending" && (
<Button
size="xs"
color="green"
loading={approveMutation.isPending}
onClick={() =>
approveMutation.mutate({
companyId: company.id,
status: "active",
})
}
>
Approve
</Button>
)}
</Group> </Group>
} }
/> />

View File

@@ -1947,6 +1947,18 @@ export const api = {
QUERY_KEYS.CUSTOMERS.ROOT, QUERY_KEYS.CUSTOMERS.ROOT,
], ],
), ),
setCompanyStatus: endpoint<{ companyId: string; status: string }, unknown>(
"customers",
"setCompanyStatus",
({ companyId, status }) =>
customersService.setCompanyStatus(companyId, status),
undefined,
(input) => [
QUERY_KEYS.CUSTOMERS.byId(input.companyId),
QUERY_KEYS.CUSTOMERS.ROOT,
],
),
}, },
overview: { overview: {

View File

@@ -87,4 +87,11 @@ export const customersService = {
) )
.then((r) => r.data); .then((r) => r.data);
}, },
/** Approve / change a company's status (e.g. pending → active). */
setCompanyStatus(companyId: string, status: string): Promise<unknown> {
return apiClient
.patch(URL_CONSTANTS.COMPANIES.BY_ID(companyId), { status })
.then((r) => r.data);
},
}; };

View File

@@ -1,6 +1,7 @@
import { AppLayout, type SidebarItem } from "@/components/AppLayout"; import { AppLayout, type SidebarItem } from "@/components/AppLayout";
import { import {
CalendarCheck, CalendarCheck,
Clock,
Home, Home,
Layers, Layers,
Loader2, Loader2,
@@ -109,11 +110,13 @@ function isOnboardingAllowedPath(pathname: string): boolean {
* as users who haven't completed onboarding. * as users who haven't completed onboarding.
*/ */
function OnboardingGate() { function OnboardingGate() {
const { company, onboardingCompleted } = useAuth(); const { company, onboardingCompleted, companyStatus } = useAuth();
const location = useLocation(); const location = useLocation();
const needsOnboarding = !company || !onboardingCompleted; const needsOnboarding = !company || !onboardingCompleted;
const allowedHere = isOnboardingAllowedPath(location.pathname); const allowedHere = isOnboardingAllowedPath(location.pathname);
// Onboarding done but not yet approved by an admin → awaiting-approval state.
const awaitingApproval = !needsOnboarding && companyStatus === "pending";
// Open by default while onboarding is pending (covers the login case). // Open by default while onboarding is pending (covers the login case).
const [wizardOpen, { open: openWizard, close: closeWizard }] = const [wizardOpen, { open: openWizard, close: closeWizard }] =
@@ -146,6 +149,7 @@ function OnboardingGate() {
{needsOnboarding && !wizardOpen && ( {needsOnboarding && !wizardOpen && (
<OnboardingResumeBanner onResume={openWizard} /> <OnboardingResumeBanner onResume={openWizard} />
)} )}
{awaitingApproval && <PendingApprovalBanner />}
<Outlet /> <Outlet />
<OnboardingWizardDialog <OnboardingWizardDialog
opened={needsOnboarding && wizardOpen} opened={needsOnboarding && wizardOpen}
@@ -177,6 +181,19 @@ function OnboardingResumeBanner({ onResume }: { onResume: () => void }) {
); );
} }
/** Shown after onboarding while the company awaits backoffice approval. */
function PendingApprovalBanner() {
return (
<div className="flex flex-wrap items-center gap-2 border-b border-amber-300/50 bg-amber-50 px-6 py-3">
<Clock size={16} className="text-amber-700" />
<span className="text-sm font-medium text-amber-800">
Your company is awaiting EDR approval. You can browse, but creating
bookings is disabled until your company is approved.
</span>
</div>
);
}
/** Keeps authenticated users off the login/signup pages. */ /** Keeps authenticated users off the login/signup pages. */
function RedirectIfAuthed() { function RedirectIfAuthed() {
const { isPending, isAuthenticated } = useAuth(); const { isPending, isAuthenticated } = useAuth();

View File

@@ -33,7 +33,7 @@ export default function ETradeInfo({
const hasData = mutation.data; const hasData = mutation.data;
const handleFetch = async () => { const handleFetch = async () => {
if (!tin || tin.length !== 10) return; if (!tin || tin.length !== 10 || !tin.startsWith("00")) return;
const result = await mutation.mutateAsync(tin); const result = await mutation.mutateAsync(tin);
if (result) { if (result) {
onDataLoaded(result); onDataLoaded(result);
@@ -60,7 +60,7 @@ export default function ETradeInfo({
variant="filled" variant="filled"
color="edr-green" color="edr-green"
onClick={handleFetch} onClick={handleFetch}
disabled={!tin || tin.length !== 10 || isLoading} disabled={!tin || tin.length !== 10 || !tin.startsWith("00") || isLoading}
leftSection={ leftSection={
isLoading ? <Loader size={16} /> : <Download size={16} /> isLoading ? <Loader size={16} /> : <Download size={16} />
} }

View File

@@ -157,6 +157,9 @@ const useAuth = () => {
const activeCompanyProfileId = const activeCompanyProfileId =
companyInfo?.profile?.activeCompanyProfileId ?? null; companyInfo?.profile?.activeCompanyProfileId ?? null;
const companyType = companyInfo?.company?.type ?? null; const companyType = companyInfo?.company?.type ?? null;
const companyStatus = companyInfo?.company?.status ?? null;
// A company can create bookings only once an admin has approved it (active).
const isCompanyApproved = companyStatus === "active";
const onboardingCompleted = const onboardingCompleted =
companyInfo?.profile?.onboardingCompleted ?? false; companyInfo?.profile?.onboardingCompleted ?? false;
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null; const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
@@ -230,6 +233,8 @@ const useAuth = () => {
activeProfileType, activeProfileType,
activeCompanyProfileId, activeCompanyProfileId,
companyType, companyType,
companyStatus,
isCompanyApproved,
onboardingCompleted, onboardingCompleted,
onboardingStep, onboardingStep,
switchMode, switchMode,

View File

@@ -55,7 +55,8 @@ type CompanyStep =
| "additional"; | "additional";
const onboardingSchema = z.object({ const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"), companyFirstName: z.string().min(1, "First name is required"),
companyLastName: z.string().min(1, "Last name is required"),
companyEmail: z.string().email("Invalid email address"), companyEmail: z.string().email("Invalid email address"),
companyPhone: z companyPhone: z
.string() .string()
@@ -65,7 +66,10 @@ const onboardingSchema = z.object({
// Derived from the eTrade address parts (kebele/woreda/zone/region); no // Derived from the eTrade address parts (kebele/woreda/zone/region); no
// standalone input — the granular fields live in the registration section. // standalone input — the granular fields live in the registration section.
companyAddress: z.string().optional(), companyAddress: z.string().optional(),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), tinNumber: z
.string()
.length(10, "TIN must be exactly 10 digits")
.regex(/^00\d{8}$/, "TIN must be 10 digits starting with 00"),
vatNumber: z vatNumber: z
.string() .string()
.min(1, "VAT number is required") .min(1, "VAT number is required")
@@ -83,7 +87,12 @@ const onboardingSchema = z.object({
kebele: z.string().optional(), kebele: z.string().optional(),
houseNo: z.string().optional(), houseNo: z.string().optional(),
etradePhone: z.string().optional(), etradePhone: z.string().optional(),
contactPersonName: z.string().min(1, "Contact person name is required"), contactPersonFirstName: z
.string()
.min(1, "Contact person first name is required"),
contactPersonLastName: z
.string()
.min(1, "Contact person last name is required"),
contactPersonPosition: z.string().optional(), contactPersonPosition: z.string().optional(),
contactPersonEmail: z contactPersonEmail: z
.string() .string()
@@ -94,13 +103,15 @@ const onboardingSchema = z.object({
.string() .string()
.min(1, "Contact person phone is required") .min(1, "Contact person phone is required")
.refine(isValidPhone, "Enter a valid phone number"), .refine(isValidPhone, "Enter a valid phone number"),
generalManagerName: z.string().min(1, "GM name is required"), generalManagerFirstName: z.string().min(1, "GM first name is required"),
generalManagerLastName: z.string().min(1, "GM last name is required"),
generalManagerEmail: z.string().email("Invalid GM email"), generalManagerEmail: z.string().email("Invalid GM email"),
generalManagerPhone: z generalManagerPhone: z
.string() .string()
.min(1, "GM phone is required") .min(1, "GM phone is required")
.refine(isValidPhone, "Enter a valid phone number"), .refine(isValidPhone, "Enter a valid phone number"),
poaName: z.string().optional(), poaFirstName: z.string().optional(),
poaLastName: z.string().optional(),
poaPhone: z poaPhone: z
.string() .string()
.optional() .optional()
@@ -114,7 +125,8 @@ type FormData = z.infer<typeof onboardingSchema>;
const stepFields: Record<CompanyStep, (keyof FormData)[]> = { const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
company: [ company: [
"companyName", "companyFirstName",
"companyLastName",
"companyEmail", "companyEmail",
"companyPhone", "companyPhone",
"companyLocation", "companyLocation",
@@ -136,12 +148,14 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"etradePhone", "etradePhone",
], ],
personnel: [ personnel: [
"generalManagerName", "generalManagerFirstName",
"generalManagerLastName",
"generalManagerEmail", "generalManagerEmail",
"generalManagerPhone", "generalManagerPhone",
], ],
contact: [ contact: [
"contactPersonName", "contactPersonFirstName",
"contactPersonLastName",
"contactPersonPosition", "contactPersonPosition",
"contactPersonEmail", "contactPersonEmail",
"contactPersonPhone", "contactPersonPhone",
@@ -151,9 +165,23 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
additional: [], additional: [],
}; };
/** Join first + last into the single name the API stores. */
function joinName(first?: string, last?: string): string {
return [first?.trim(), last?.trim()].filter(Boolean).join(" ");
}
/** Split a stored single name into first (first token) + last (the rest). */
function splitName(full?: string | null): { first: string; last: string } {
const trimmed = (full ?? "").trim();
if (!trimmed) return { first: "", last: "" };
const idx = trimmed.indexOf(" ");
if (idx === -1) return { first: trimmed, last: "" };
return { first: trimmed.slice(0, idx), last: trimmed.slice(idx + 1).trim() };
}
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
return { return {
companyName: data.companyName, companyName: joinName(data.companyFirstName, data.companyLastName),
companyEmail: data.companyEmail, companyEmail: data.companyEmail,
companyPhone: data.companyPhone, companyPhone: data.companyPhone,
companyLocation: data.companyLocation, companyLocation: data.companyLocation,
@@ -162,14 +190,20 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
vatNumber: data.vatNumber, vatNumber: data.vatNumber,
fanNumber: data.fanNumber, fanNumber: data.fanNumber,
attributes: { attributes: {
contactPersonName: data.contactPersonName, contactPersonName: joinName(
data.contactPersonFirstName,
data.contactPersonLastName,
),
contactPersonPosition: data.contactPersonPosition || undefined, contactPersonPosition: data.contactPersonPosition || undefined,
contactPersonEmail: data.contactPersonEmail || undefined, contactPersonEmail: data.contactPersonEmail || undefined,
contactPersonPhone: data.contactPersonPhone, contactPersonPhone: data.contactPersonPhone,
generalManagerName: data.generalManagerName, generalManagerName: joinName(
data.generalManagerFirstName,
data.generalManagerLastName,
),
generalManagerEmail: data.generalManagerEmail, generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: data.generalManagerPhone, generalManagerPhone: data.generalManagerPhone,
poaName: data.poaName || undefined, poaName: joinName(data.poaFirstName, data.poaLastName) || undefined,
poaPhone: data.poaPhone || undefined, poaPhone: data.poaPhone || undefined,
poaAddress: data.poaAddress || undefined, poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined, poaEmail: data.poaEmail || undefined,
@@ -183,7 +217,7 @@ function stepPayload(step: CompanyStep, d: FormData): Partial<UpdateProfilePaylo
switch (step) { switch (step) {
case "company": case "company":
return { return {
companyName: d.companyName, companyName: joinName(d.companyFirstName, d.companyLastName),
companyEmail: d.companyEmail, companyEmail: d.companyEmail,
companyPhone: d.companyPhone, companyPhone: d.companyPhone,
companyLocation: d.companyLocation, companyLocation: d.companyLocation,
@@ -206,20 +240,26 @@ function stepPayload(step: CompanyStep, d: FormData): Partial<UpdateProfilePaylo
}; };
case "personnel": case "personnel":
return { return {
generalManagerName: d.generalManagerName, generalManagerName: joinName(
d.generalManagerFirstName,
d.generalManagerLastName,
),
generalManagerEmail: d.generalManagerEmail, generalManagerEmail: d.generalManagerEmail,
generalManagerPhone: d.generalManagerPhone, generalManagerPhone: d.generalManagerPhone,
}; };
case "contact": case "contact":
return { return {
contactPersonName: d.contactPersonName, contactPersonName: joinName(
d.contactPersonFirstName,
d.contactPersonLastName,
),
contactPersonPosition: d.contactPersonPosition || undefined, contactPersonPosition: d.contactPersonPosition || undefined,
contactPersonEmail: d.contactPersonEmail || undefined, contactPersonEmail: d.contactPersonEmail || undefined,
contactPersonPhone: d.contactPersonPhone, contactPersonPhone: d.contactPersonPhone,
}; };
case "poa": case "poa":
return { return {
poaName: d.poaName || undefined, poaName: joinName(d.poaFirstName, d.poaLastName) || undefined,
poaPhone: d.poaPhone || undefined, poaPhone: d.poaPhone || undefined,
poaEmail: d.poaEmail || undefined, poaEmail: d.poaEmail || undefined,
poaLocation: d.poaLocation || undefined, poaLocation: d.poaLocation || undefined,
@@ -234,8 +274,13 @@ function stepPayload(step: CompanyStep, d: FormData): Partial<UpdateProfilePaylo
function toFormValues(p: ProfileResponse): FormData { function toFormValues(p: ProfileResponse): FormData {
// The draft placeholder TIN ("D…") shouldn't show as a real value. // The draft placeholder TIN ("D…") shouldn't show as a real value.
const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : ""; const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : "";
const companyN = splitName(p.companyName);
const contactN = splitName(p.contactPersonName);
const gmN = splitName(p.generalManagerName);
const poaN = splitName(p.poaName);
return { return {
companyName: p.companyName ?? "", companyFirstName: companyN.first,
companyLastName: companyN.last,
companyEmail: p.companyEmail ?? "", companyEmail: p.companyEmail ?? "",
companyPhone: p.companyPhone ?? "", companyPhone: p.companyPhone ?? "",
companyLocation: p.companyLocation ?? "", companyLocation: p.companyLocation ?? "",
@@ -255,14 +300,17 @@ function toFormValues(p: ProfileResponse): FormData {
kebele: p.kebele ?? "", kebele: p.kebele ?? "",
houseNo: p.houseNo ?? "", houseNo: p.houseNo ?? "",
etradePhone: p.etradePhone ?? "", etradePhone: p.etradePhone ?? "",
contactPersonName: p.contactPersonName ?? "", contactPersonFirstName: contactN.first,
contactPersonLastName: contactN.last,
contactPersonPosition: p.contactPersonPosition ?? "", contactPersonPosition: p.contactPersonPosition ?? "",
contactPersonEmail: p.contactPersonEmail ?? "", contactPersonEmail: p.contactPersonEmail ?? "",
contactPersonPhone: p.contactPersonPhone ?? "", contactPersonPhone: p.contactPersonPhone ?? "",
generalManagerName: p.generalManagerName ?? "", generalManagerFirstName: gmN.first,
generalManagerLastName: gmN.last,
generalManagerEmail: p.generalManagerEmail ?? "", generalManagerEmail: p.generalManagerEmail ?? "",
generalManagerPhone: p.generalManagerPhone ?? "", generalManagerPhone: p.generalManagerPhone ?? "",
poaName: p.poaName ?? "", poaFirstName: poaN.first,
poaLastName: poaN.last,
poaPhone: p.poaPhone ?? "", poaPhone: p.poaPhone ?? "",
poaAddress: p.poaAddress ?? "", poaAddress: p.poaAddress ?? "",
poaEmail: p.poaEmail ?? "", poaEmail: p.poaEmail ?? "",
@@ -360,7 +408,8 @@ export default function CompanyProfileForm({
} = useForm<FormData>({ } = useForm<FormData>({
resolver: zodResolver(onboardingSchema), resolver: zodResolver(onboardingSchema),
defaultValues: { defaultValues: {
companyName: "", companyFirstName: "",
companyLastName: "",
companyEmail: "", companyEmail: "",
companyPhone: "", companyPhone: "",
companyLocation: "", companyLocation: "",
@@ -380,14 +429,17 @@ export default function CompanyProfileForm({
kebele: "", kebele: "",
houseNo: "", houseNo: "",
etradePhone: "", etradePhone: "",
contactPersonName: "", contactPersonFirstName: "",
contactPersonLastName: "",
contactPersonPosition: "", contactPersonPosition: "",
contactPersonEmail: "", contactPersonEmail: "",
contactPersonPhone: "", contactPersonPhone: "",
generalManagerName: "", generalManagerFirstName: "",
generalManagerLastName: "",
generalManagerEmail: "", generalManagerEmail: "",
generalManagerPhone: "", generalManagerPhone: "",
poaName: "", poaFirstName: "",
poaLastName: "",
poaPhone: "", poaPhone: "",
poaAddress: "", poaAddress: "",
poaEmail: "", poaEmail: "",
@@ -412,7 +464,9 @@ export default function CompanyProfileForm({
const handleETradeDataLoaded = (data: CompanyRegistrationData) => { const handleETradeDataLoaded = (data: CompanyRegistrationData) => {
// Company name comes from the eTrade manager/owner name on the license. // Company name comes from the eTrade manager/owner name on the license.
if (data.managerName) { if (data.managerName) {
setValue("companyName", data.managerName, { shouldValidate: true }); const { first, last } = splitName(data.managerName);
setValue("companyFirstName", first, { shouldValidate: true });
setValue("companyLastName", last, { shouldValidate: true });
} }
setValue("licenceNumber", data.licenceNumber); setValue("licenceNumber", data.licenceNumber);
setValue("statusDescription", data.statusDescription); setValue("statusDescription", data.statusDescription);
@@ -459,7 +513,9 @@ export default function CompanyProfileForm({
/** Fill the General Manager from the eTrade business owner. */ /** Fill the General Manager from the eTrade business owner. */
const useOwnerAsManager = () => { const useOwnerAsManager = () => {
if (!etradeOwner) return; if (!etradeOwner) return;
setValue("generalManagerName", etradeOwner.name); const { first, last } = splitName(etradeOwner.name);
setValue("generalManagerFirstName", first, { shouldValidate: true });
setValue("generalManagerLastName", last, { shouldValidate: true });
setValue("generalManagerPhone", etradeOwner.phone ?? "", { setValue("generalManagerPhone", etradeOwner.phone ?? "", {
shouldValidate: true, shouldValidate: true,
}); });
@@ -469,7 +525,8 @@ export default function CompanyProfileForm({
const toggleGmAsContact = (checked: boolean) => { const toggleGmAsContact = (checked: boolean) => {
setGmIsContact(checked); setGmIsContact(checked);
if (!checked) return; if (!checked) return;
setValue("contactPersonName", watch("generalManagerName")); setValue("contactPersonFirstName", watch("generalManagerFirstName"));
setValue("contactPersonLastName", watch("generalManagerLastName"));
setValue("contactPersonEmail", watch("generalManagerEmail")); setValue("contactPersonEmail", watch("generalManagerEmail"));
setValue("contactPersonPhone", watch("generalManagerPhone")); setValue("contactPersonPhone", watch("generalManagerPhone"));
}; };
@@ -478,7 +535,8 @@ export default function CompanyProfileForm({
const toggleContactAsPoa = (checked: boolean) => { const toggleContactAsPoa = (checked: boolean) => {
setContactIsPoa(checked); setContactIsPoa(checked);
if (!checked) return; if (!checked) return;
setValue("poaName", watch("contactPersonName")); setValue("poaFirstName", watch("contactPersonFirstName"));
setValue("poaLastName", watch("contactPersonLastName"));
setValue("poaEmail", watch("contactPersonEmail")); setValue("poaEmail", watch("contactPersonEmail"));
setValue("poaPhone", watch("contactPersonPhone")); setValue("poaPhone", watch("contactPersonPhone"));
}; };
@@ -642,12 +700,20 @@ export default function CompanyProfileForm({
<Divider my="sm" /> <Divider my="sm" />
<TextInput <SimpleGrid cols={2} spacing="md">
label="Company Name" <TextInput
placeholder="Global Logistics Ltd" label="First Name"
error={errors.companyName?.message} placeholder="Global"
{...register("companyName")} error={errors.companyFirstName?.message}
/> {...register("companyFirstName")}
/>
<TextInput
label="Last Name"
placeholder="Logistics Ltd"
error={errors.companyLastName?.message}
{...register("companyLastName")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="Company Email" label="Company Email"
@@ -800,12 +866,20 @@ export default function CompanyProfileForm({
</Button> </Button>
)} )}
</Group> </Group>
<TextInput <SimpleGrid cols={2} spacing="md">
label="Name" <TextInput
placeholder="Abebe Bikila" label="First Name"
error={errors.generalManagerName?.message} placeholder="Abebe"
{...register("generalManagerName")} error={errors.generalManagerFirstName?.message}
/> {...register("generalManagerFirstName")}
/>
<TextInput
label="Last Name"
placeholder="Bikila"
error={errors.generalManagerLastName?.message}
{...register("generalManagerLastName")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="Email" label="Email"
@@ -837,19 +911,25 @@ export default function CompanyProfileForm({
/> />
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="Name" label="First Name"
placeholder="Jane Smith" placeholder="Jane"
error={errors.contactPersonName?.message} error={errors.contactPersonFirstName?.message}
{...register("contactPersonName")} {...register("contactPersonFirstName")}
/> />
<TextInput
label="Last Name"
placeholder="Smith"
error={errors.contactPersonLastName?.message}
{...register("contactPersonLastName")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="Position (Optional)" label="Position (Optional)"
placeholder="Operations Lead" placeholder="Operations Lead"
error={errors.contactPersonPosition?.message} error={errors.contactPersonPosition?.message}
{...register("contactPersonPosition")} {...register("contactPersonPosition")}
/> />
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="Email (Optional)" label="Email (Optional)"
type="email" type="email"
@@ -857,6 +937,8 @@ export default function CompanyProfileForm({
error={errors.contactPersonEmail?.message} error={errors.contactPersonEmail?.message}
{...register("contactPersonEmail")} {...register("contactPersonEmail")}
/> />
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<ControlledPhoneField <ControlledPhoneField
control={control} control={control}
name="contactPersonPhone" name="contactPersonPhone"
@@ -879,12 +961,20 @@ export default function CompanyProfileForm({
checked={contactIsPoa} checked={contactIsPoa}
onChange={(e) => toggleContactAsPoa(e.currentTarget.checked)} onChange={(e) => toggleContactAsPoa(e.currentTarget.checked)}
/> />
<TextInput <SimpleGrid cols={2} spacing="md">
label="PoA Name" <TextInput
placeholder="Authorized Representative Name" label="PoA First Name"
error={errors.poaName?.message} placeholder="First name"
{...register("poaName")} error={errors.poaFirstName?.message}
/> {...register("poaFirstName")}
/>
<TextInput
label="PoA Last Name"
placeholder="Last name"
error={errors.poaLastName?.message}
{...register("poaLastName")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="md">
<TextInput <TextInput
label="PoA Email" label="PoA Email"

View File

@@ -97,6 +97,40 @@ export default function NewBookingPage() {
); );
} }
if (!auth.isPending && auth.companyStatus === "pending") {
return (
<Box
style={{
padding: "28px",
minHeight: "calc(100dvh - var(--app-shell-header-height, 56px))",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
}}
>
<Alert
color="orange"
icon={<AlertCircle size={20} />}
radius="md"
style={{ maxWidth: "500px" }}
mb="lg"
>
<Text size="lg" fw={600} mb="md">
Awaiting Approval
</Text>
<Text size="sm" mb="md">
Your company is awaiting EDR approval. Creating bookings is disabled
until your company has been approved.
</Text>
<Button color="orange" onClick={() => navigate("/bookings")} mt="md">
Back to Bookings
</Button>
</Alert>
</Box>
);
}
const persistAndPriceMutation = useMutation({ const persistAndPriceMutation = useMutation({
mutationFn: async ({ mutationFn: async ({
payload, payload,

View File

@@ -66,6 +66,21 @@ export function Step5CargoDetails({
} }
}, [parentId]); }, [parentId]);
// For containerised cargo, the total weight is derived from the containers
// (Σ qty × vgm) rather than typed by hand — keep cargoWeight in sync.
useEffect(() => {
if (cargoType !== "container") return;
const total = (containers ?? []).reduce(
(sum, c) => sum + (Number(c?.qty) || 0) * (Number(c?.vgm) || 0),
0,
);
form.setValue("cargoWeight", total ? String(total) : "", {
shouldValidate: true,
});
// form is stable; re-run when the containers or cargo type change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [containers, cargoType]);
const selectedCommodity = useMemo(() => { const selectedCommodity = useMemo(() => {
if (!referenceData?.cargo_type || !parentId || !childId) return null; if (!referenceData?.cargo_type || !parentId || !childId) return null;
const group = referenceData.cargo_type.find((g) => g.id === parentId); const group = referenceData.cargo_type.find((g) => g.id === parentId);
@@ -207,6 +222,13 @@ export function Step5CargoDetails({
placeholder={isPerItem ? "0" : "0.00"} placeholder={isPerItem ? "0" : "0.00"}
leftSection={<Weight className="h-4 w-4" />} leftSection={<Weight className="h-4 w-4" />}
error={fieldState.error?.message} error={fieldState.error?.message}
// Container total is auto-summed from the containers below.
readOnly={cargoType === "container"}
description={
cargoType === "container"
? "Auto-calculated from the containers below."
: undefined
}
radius={10} radius={10}
styles={fieldStyles} styles={fieldStyles}
min={0} min={0}