mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +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 { 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user