void }) {
);
}
+/** Shown after onboarding while the company awaits backoffice approval. */
+function PendingApprovalBanner() {
+ return (
+
+
+
+ Your company is awaiting EDR approval. You can browse, but creating
+ bookings is disabled until your company is approved.
+
+
+ );
+}
+
/** Keeps authenticated users off the login/signup pages. */
function RedirectIfAuthed() {
const { isPending, isAuthenticated } = useAuth();
diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx
index 5fcfadaa1..41dc33886 100644
--- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx
+++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx
@@ -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 ? :
}
diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts
index 0f4f4e6a1..ec54a9a30 100644
--- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts
+++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts
@@ -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,
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 29055a2e8..69aabae53 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
@@ -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;
const stepFields: Record = {
company: [
- "companyName",
+ "companyFirstName",
+ "companyLastName",
"companyEmail",
"companyPhone",
"companyLocation",
@@ -136,12 +148,14 @@ const stepFields: Record = {
"etradePhone",
],
personnel: [
- "generalManagerName",
+ "generalManagerFirstName",
+ "generalManagerLastName",
"generalManagerEmail",
"generalManagerPhone",
],
contact: [
- "contactPersonName",
+ "contactPersonFirstName",
+ "contactPersonLastName",
"contactPersonPosition",
"contactPersonEmail",
"contactPersonPhone",
@@ -151,9 +165,23 @@ const stepFields: Record = {
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({
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({
-
+
+
+
+
)}
-
+
+
+
+
+
+
+
-
-
+
+
toggleContactAsPoa(e.currentTarget.checked)}
/>
-
+
+
+
+
+ }
+ radius="md"
+ style={{ maxWidth: "500px" }}
+ mb="lg"
+ >
+
+ Awaiting Approval
+
+
+ Your company is awaiting EDR approval. Creating bookings is disabled
+ until your company has been approved.
+
+
+
+
+ );
+ }
+
const persistAndPriceMutation = useMutation({
mutationFn: async ({
payload,
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
index eae62a92a..7a21ef3d0 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
@@ -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={}
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}