diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index fddc1cf61..cac19f111 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 939803abf..e57d3b5f8 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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); } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index 0a699fe5e..e5b686d11 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index d94cb5f35..c99ef9d3c 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -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() diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index f38e8c38b..b1e05ab3d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -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() { + {company.status === "pending" && ( + + )} } /> diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 86d14ef7d..fdaa1a6cb 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -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: { diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts index d375ad7a7..8a8124ecd 100644 --- a/apps/edr-freight-web/backoffice/src/services/customers.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts @@ -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 { + return apiClient + .patch(URL_CONSTANTS.COMPANIES.BY_ID(companyId), { status }) + .then((r) => r.data); + }, }; diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index e72178452..fe93865c7 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -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 && ( )} + {awaitingApproval && } 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}