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 { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
@@ -287,6 +288,12 @@ export class BookingsService {
);
}
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;
}

View File

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

View File

@@ -18,7 +18,9 @@ export class CreateCompanyDto {
@IsString()
@IsNotEmpty()
@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;
@IsOptional()

View File

@@ -35,7 +35,9 @@ export class UpdateProfileDto {
@IsOptional()
@IsString()
@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;
@IsOptional()

View File

@@ -22,7 +22,7 @@ import {
LayoutGrid,
Package,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
@@ -84,6 +84,9 @@ export default function CustomerDetailPage() {
enabled: Boolean(id),
}),
);
const approveMutation = useMutation(
api.customers.setCompanyStatus.mutationOptions(),
);
const bookingsQuery = useQuery(
api.customers.bookings.queryOptions({
input: { id: id ?? "" },
@@ -398,6 +401,21 @@ export default function CustomerDetailPage() {
<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>
}
/>

View File

@@ -1947,6 +1947,18 @@ export const api = {
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: {

View File

@@ -87,4 +87,11 @@ export const customersService = {
)
.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 {
CalendarCheck,
Clock,
Home,
Layers,
Loader2,
@@ -109,11 +110,13 @@ function isOnboardingAllowedPath(pathname: string): boolean {
* as users who haven't completed onboarding.
*/
function OnboardingGate() {
const { company, onboardingCompleted } = useAuth();
const { company, onboardingCompleted, companyStatus } = useAuth();
const location = useLocation();
const needsOnboarding = !company || !onboardingCompleted;
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).
const [wizardOpen, { open: openWizard, close: closeWizard }] =
@@ -146,6 +149,7 @@ function OnboardingGate() {
{needsOnboarding && !wizardOpen && (
<OnboardingResumeBanner onResume={openWizard} />
)}
{awaitingApproval && <PendingApprovalBanner />}
<Outlet />
<OnboardingWizardDialog
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. */
function RedirectIfAuthed() {
const { isPending, isAuthenticated } = useAuth();

View File

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

View File

@@ -157,6 +157,9 @@ const useAuth = () => {
const activeCompanyProfileId =
companyInfo?.profile?.activeCompanyProfileId ?? 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 =
companyInfo?.profile?.onboardingCompleted ?? false;
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
@@ -230,6 +233,8 @@ const useAuth = () => {
activeProfileType,
activeCompanyProfileId,
companyType,
companyStatus,
isCompanyApproved,
onboardingCompleted,
onboardingStep,
switchMode,

View File

@@ -55,7 +55,8 @@ type CompanyStep =
| "additional";
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"),
companyPhone: z
.string()
@@ -65,7 +66,10 @@ const onboardingSchema = z.object({
// 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"),
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
.string()
.min(1, "VAT number is required")
@@ -83,7 +87,12 @@ const onboardingSchema = z.object({
kebele: z.string().optional(),
houseNo: 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(),
contactPersonEmail: z
.string()
@@ -94,13 +103,15 @@ 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"),
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"),
generalManagerPhone: z
.string()
.min(1, "GM phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
poaName: z.string().optional(),
poaFirstName: z.string().optional(),
poaLastName: z.string().optional(),
poaPhone: z
.string()
.optional()
@@ -114,7 +125,8 @@ type FormData = z.infer<typeof onboardingSchema>;
const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
company: [
"companyName",
"companyFirstName",
"companyLastName",
"companyEmail",
"companyPhone",
"companyLocation",
@@ -136,12 +148,14 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"etradePhone",
],
personnel: [
"generalManagerName",
"generalManagerFirstName",
"generalManagerLastName",
"generalManagerEmail",
"generalManagerPhone",
],
contact: [
"contactPersonName",
"contactPersonFirstName",
"contactPersonLastName",
"contactPersonPosition",
"contactPersonEmail",
"contactPersonPhone",
@@ -151,9 +165,23 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
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 {
return {
companyName: data.companyName,
companyName: joinName(data.companyFirstName, data.companyLastName),
companyEmail: data.companyEmail,
companyPhone: data.companyPhone,
companyLocation: data.companyLocation,
@@ -162,14 +190,20 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
vatNumber: data.vatNumber,
fanNumber: data.fanNumber,
attributes: {
contactPersonName: data.contactPersonName,
contactPersonName: joinName(
data.contactPersonFirstName,
data.contactPersonLastName,
),
contactPersonPosition: data.contactPersonPosition || undefined,
contactPersonEmail: data.contactPersonEmail || undefined,
contactPersonPhone: data.contactPersonPhone,
generalManagerName: data.generalManagerName,
generalManagerName: joinName(
data.generalManagerFirstName,
data.generalManagerLastName,
),
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: data.generalManagerPhone,
poaName: data.poaName || undefined,
poaName: joinName(data.poaFirstName, data.poaLastName) || undefined,
poaPhone: data.poaPhone || undefined,
poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined,
@@ -183,7 +217,7 @@ function stepPayload(step: CompanyStep, d: FormData): Partial<UpdateProfilePaylo
switch (step) {
case "company":
return {
companyName: d.companyName,
companyName: joinName(d.companyFirstName, d.companyLastName),
companyEmail: d.companyEmail,
companyPhone: d.companyPhone,
companyLocation: d.companyLocation,
@@ -206,20 +240,26 @@ function stepPayload(step: CompanyStep, d: FormData): Partial<UpdateProfilePaylo
};
case "personnel":
return {
generalManagerName: d.generalManagerName,
generalManagerName: joinName(
d.generalManagerFirstName,
d.generalManagerLastName,
),
generalManagerEmail: d.generalManagerEmail,
generalManagerPhone: d.generalManagerPhone,
};
case "contact":
return {
contactPersonName: d.contactPersonName,
contactPersonName: joinName(
d.contactPersonFirstName,
d.contactPersonLastName,
),
contactPersonPosition: d.contactPersonPosition || undefined,
contactPersonEmail: d.contactPersonEmail || undefined,
contactPersonPhone: d.contactPersonPhone,
};
case "poa":
return {
poaName: d.poaName || undefined,
poaName: joinName(d.poaFirstName, d.poaLastName) || undefined,
poaPhone: d.poaPhone || undefined,
poaEmail: d.poaEmail || undefined,
poaLocation: d.poaLocation || undefined,
@@ -234,8 +274,13 @@ function stepPayload(step: CompanyStep, d: FormData): Partial<UpdateProfilePaylo
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 : "";
const companyN = splitName(p.companyName);
const contactN = splitName(p.contactPersonName);
const gmN = splitName(p.generalManagerName);
const poaN = splitName(p.poaName);
return {
companyName: p.companyName ?? "",
companyFirstName: companyN.first,
companyLastName: companyN.last,
companyEmail: p.companyEmail ?? "",
companyPhone: p.companyPhone ?? "",
companyLocation: p.companyLocation ?? "",
@@ -255,14 +300,17 @@ function toFormValues(p: ProfileResponse): FormData {
kebele: p.kebele ?? "",
houseNo: p.houseNo ?? "",
etradePhone: p.etradePhone ?? "",
contactPersonName: p.contactPersonName ?? "",
contactPersonFirstName: contactN.first,
contactPersonLastName: contactN.last,
contactPersonPosition: p.contactPersonPosition ?? "",
contactPersonEmail: p.contactPersonEmail ?? "",
contactPersonPhone: p.contactPersonPhone ?? "",
generalManagerName: p.generalManagerName ?? "",
generalManagerFirstName: gmN.first,
generalManagerLastName: gmN.last,
generalManagerEmail: p.generalManagerEmail ?? "",
generalManagerPhone: p.generalManagerPhone ?? "",
poaName: p.poaName ?? "",
poaFirstName: poaN.first,
poaLastName: poaN.last,
poaPhone: p.poaPhone ?? "",
poaAddress: p.poaAddress ?? "",
poaEmail: p.poaEmail ?? "",
@@ -360,7 +408,8 @@ export default function CompanyProfileForm({
} = useForm<FormData>({
resolver: zodResolver(onboardingSchema),
defaultValues: {
companyName: "",
companyFirstName: "",
companyLastName: "",
companyEmail: "",
companyPhone: "",
companyLocation: "",
@@ -380,14 +429,17 @@ export default function CompanyProfileForm({
kebele: "",
houseNo: "",
etradePhone: "",
contactPersonName: "",
contactPersonFirstName: "",
contactPersonLastName: "",
contactPersonPosition: "",
contactPersonEmail: "",
contactPersonPhone: "",
generalManagerName: "",
generalManagerFirstName: "",
generalManagerLastName: "",
generalManagerEmail: "",
generalManagerPhone: "",
poaName: "",
poaFirstName: "",
poaLastName: "",
poaPhone: "",
poaAddress: "",
poaEmail: "",
@@ -412,7 +464,9 @@ export default function CompanyProfileForm({
const handleETradeDataLoaded = (data: CompanyRegistrationData) => {
// Company name comes from the eTrade manager/owner name on the license.
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("statusDescription", data.statusDescription);
@@ -459,7 +513,9 @@ export default function CompanyProfileForm({
/** Fill the General Manager from the eTrade business owner. */
const useOwnerAsManager = () => {
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 ?? "", {
shouldValidate: true,
});
@@ -469,7 +525,8 @@ export default function CompanyProfileForm({
const toggleGmAsContact = (checked: boolean) => {
setGmIsContact(checked);
if (!checked) return;
setValue("contactPersonName", watch("generalManagerName"));
setValue("contactPersonFirstName", watch("generalManagerFirstName"));
setValue("contactPersonLastName", watch("generalManagerLastName"));
setValue("contactPersonEmail", watch("generalManagerEmail"));
setValue("contactPersonPhone", watch("generalManagerPhone"));
};
@@ -478,7 +535,8 @@ export default function CompanyProfileForm({
const toggleContactAsPoa = (checked: boolean) => {
setContactIsPoa(checked);
if (!checked) return;
setValue("poaName", watch("contactPersonName"));
setValue("poaFirstName", watch("contactPersonFirstName"));
setValue("poaLastName", watch("contactPersonLastName"));
setValue("poaEmail", watch("contactPersonEmail"));
setValue("poaPhone", watch("contactPersonPhone"));
};
@@ -642,12 +700,20 @@ export default function CompanyProfileForm({
<Divider my="sm" />
<TextInput
label="Company Name"
placeholder="Global Logistics Ltd"
error={errors.companyName?.message}
{...register("companyName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="First Name"
placeholder="Global"
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">
<TextInput
label="Company Email"
@@ -800,12 +866,20 @@ export default function CompanyProfileForm({
</Button>
)}
</Group>
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="First Name"
placeholder="Abebe"
error={errors.generalManagerFirstName?.message}
{...register("generalManagerFirstName")}
/>
<TextInput
label="Last Name"
placeholder="Bikila"
error={errors.generalManagerLastName?.message}
{...register("generalManagerLastName")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Email"
@@ -837,19 +911,25 @@ export default function CompanyProfileForm({
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Name"
placeholder="Jane Smith"
error={errors.contactPersonName?.message}
{...register("contactPersonName")}
label="First Name"
placeholder="Jane"
error={errors.contactPersonFirstName?.message}
{...register("contactPersonFirstName")}
/>
<TextInput
label="Last Name"
placeholder="Smith"
error={errors.contactPersonLastName?.message}
{...register("contactPersonLastName")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Position (Optional)"
placeholder="Operations Lead"
error={errors.contactPersonPosition?.message}
{...register("contactPersonPosition")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Email (Optional)"
type="email"
@@ -857,6 +937,8 @@ export default function CompanyProfileForm({
error={errors.contactPersonEmail?.message}
{...register("contactPersonEmail")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<ControlledPhoneField
control={control}
name="contactPersonPhone"
@@ -879,12 +961,20 @@ export default function CompanyProfileForm({
checked={contactIsPoa}
onChange={(e) => toggleContactAsPoa(e.currentTarget.checked)}
/>
<TextInput
label="PoA Name"
placeholder="Authorized Representative Name"
error={errors.poaName?.message}
{...register("poaName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA First Name"
placeholder="First name"
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">
<TextInput
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({
mutationFn: async ({
payload,

View File

@@ -66,6 +66,21 @@ export function Step5CargoDetails({
}
}, [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(() => {
if (!referenceData?.cargo_type || !parentId || !childId) return null;
const group = referenceData.cargo_type.find((g) => g.id === parentId);
@@ -207,6 +222,13 @@ export function Step5CargoDetails({
placeholder={isPerItem ? "0" : "0.00"}
leftSection={<Weight className="h-4 w-4" />}
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}
styles={fieldStyles}
min={0}