mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 08:32:54 +00:00
enhance company approval workflow and TIN validation; add pending approval handling in booking and onboarding forms
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user