mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix: merge issues
This commit is contained in:
@@ -2,7 +2,6 @@ import { AppLayout, type SidebarItem } from "@/components/AppLayout";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
CalendarCheck,
|
||||
Clock,
|
||||
Home,
|
||||
Layers,
|
||||
Loader2,
|
||||
@@ -112,13 +111,11 @@ function isOnboardingAllowedPath(pathname: string): boolean {
|
||||
* as users who haven't completed onboarding.
|
||||
*/
|
||||
function OnboardingGate() {
|
||||
const { company, onboardingCompleted, companyStatus } = useAuth();
|
||||
const { company, onboardingCompleted } = 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 }] =
|
||||
@@ -148,9 +145,7 @@ function OnboardingGate() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{needsOnboarding && (
|
||||
<OnboardingResumeBanner onResume={openWizard} />
|
||||
)}
|
||||
{needsOnboarding && <OnboardingResumeBanner onResume={openWizard} />}
|
||||
{!needsOnboarding && <AccountReviewBanner />}
|
||||
<Outlet />
|
||||
<OnboardingWizardDialog
|
||||
@@ -275,7 +270,10 @@ const App = () => {
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
<Route path="/billing" element={<BillingPage />} />
|
||||
{/* Profile was merged into Settings — keep old links working. */}
|
||||
<Route path="/profile" element={<Navigate to="/settings" replace />} />
|
||||
<Route
|
||||
path="/profile"
|
||||
element={<Navigate to="/settings" replace />}
|
||||
/>
|
||||
<Route path="/signature" element={<MySignaturePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -20,7 +20,6 @@ export default function MyPortalPage() {
|
||||
null,
|
||||
);
|
||||
const {
|
||||
customer,
|
||||
companyProfiles,
|
||||
bookingsQuery,
|
||||
dashboardQuery,
|
||||
@@ -105,9 +104,7 @@ export default function MyPortalPage() {
|
||||
<FreightVolumeSection
|
||||
totalTonnes={dashboard?.freightVolume.totalTonnes ?? 0}
|
||||
totalValue={dashboard?.freightVolume.totalValue ?? 0}
|
||||
currency={
|
||||
(dashboard?.freightVolume.currency ?? "ETB") as Currency
|
||||
}
|
||||
currency={(dashboard?.freightVolume.currency ?? "ETB") as Currency}
|
||||
ytdChangePct={dashboard?.freightVolume.ytdChangePct ?? 0}
|
||||
volumePoints={volumePoints}
|
||||
maxVolume={maxVolume}
|
||||
|
||||
@@ -63,8 +63,7 @@ const maskPhone = (p: string) =>
|
||||
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
companyFirstName: z.string().min(1, "First name is required"),
|
||||
companyLastName: z.string().min(1, "Last name is required"),
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
companyEmail: z.string().email("Invalid email address"),
|
||||
companyPhone: z
|
||||
.string()
|
||||
@@ -74,10 +73,7 @@ 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")
|
||||
.regex(/^00\d{8}$/, "TIN must be 10 digits starting with 00"),
|
||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||
vatNumber: z
|
||||
.string()
|
||||
.min(1, "VAT number is required")
|
||||
@@ -97,12 +93,7 @@ const onboardingSchema = z.object({
|
||||
kebele: z.string().min(1, "Kebele is required"),
|
||||
houseNo: z.string().min(1, "House number is required"),
|
||||
etradePhone: z.string().optional(),
|
||||
contactPersonFirstName: z
|
||||
.string()
|
||||
.min(1, "Contact person first name is required"),
|
||||
contactPersonLastName: z
|
||||
.string()
|
||||
.min(1, "Contact person last name is required"),
|
||||
contactPersonName: z.string().min(1, "Contact person name is required"),
|
||||
contactPersonPosition: z.string().optional(),
|
||||
contactPersonEmail: z
|
||||
.string()
|
||||
@@ -119,8 +110,7 @@ const onboardingSchema = z.object({
|
||||
.string()
|
||||
.min(1, "Manager phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
poaFirstName: z.string().optional(),
|
||||
poaLastName: z.string().optional(),
|
||||
poaName: z.string().optional(),
|
||||
poaPhone: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -134,8 +124,7 @@ type FormData = z.infer<typeof onboardingSchema>;
|
||||
|
||||
const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||
company: [
|
||||
"companyFirstName",
|
||||
"companyLastName",
|
||||
"companyName",
|
||||
"companyEmail",
|
||||
"companyPhone",
|
||||
"companyLocation",
|
||||
@@ -157,14 +146,12 @@ const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||
"etradePhone",
|
||||
],
|
||||
personnel: [
|
||||
"generalManagerFirstName",
|
||||
"generalManagerLastName",
|
||||
"generalManagerName",
|
||||
"generalManagerEmail",
|
||||
"generalManagerPhone",
|
||||
],
|
||||
contact: [
|
||||
"contactPersonFirstName",
|
||||
"contactPersonLastName",
|
||||
"contactPersonName",
|
||||
"contactPersonPosition",
|
||||
"contactPersonEmail",
|
||||
"contactPersonPhone",
|
||||
@@ -175,23 +162,9 @@ 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: joinName(data.companyFirstName, data.companyLastName),
|
||||
companyName: data.companyName,
|
||||
companyEmail: data.companyEmail,
|
||||
companyPhone: data.companyPhone,
|
||||
companyLocation: data.companyLocation,
|
||||
@@ -200,20 +173,14 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
||||
vatNumber: data.vatNumber,
|
||||
fanNumber: data.fanNumber,
|
||||
attributes: {
|
||||
contactPersonName: joinName(
|
||||
data.contactPersonFirstName,
|
||||
data.contactPersonLastName,
|
||||
),
|
||||
contactPersonName: data.contactPersonName,
|
||||
contactPersonPosition: data.contactPersonPosition || undefined,
|
||||
contactPersonEmail: data.contactPersonEmail || undefined,
|
||||
contactPersonPhone: data.contactPersonPhone,
|
||||
generalManagerName: joinName(
|
||||
data.generalManagerFirstName,
|
||||
data.generalManagerLastName,
|
||||
),
|
||||
generalManagerName: data.generalManagerName,
|
||||
generalManagerEmail: data.generalManagerEmail,
|
||||
generalManagerPhone: data.generalManagerPhone,
|
||||
poaName: joinName(data.poaFirstName, data.poaLastName) || undefined,
|
||||
poaName: data.poaName || undefined,
|
||||
poaPhone: data.poaPhone || undefined,
|
||||
poaAddress: data.poaAddress || undefined,
|
||||
poaEmail: data.poaEmail || undefined,
|
||||
@@ -230,7 +197,7 @@ function stepPayload(
|
||||
switch (step) {
|
||||
case "company":
|
||||
return {
|
||||
companyName: joinName(d.companyFirstName, d.companyLastName),
|
||||
companyName: d.companyName,
|
||||
companyEmail: d.companyEmail,
|
||||
companyPhone: d.companyPhone,
|
||||
companyLocation: d.companyLocation,
|
||||
@@ -253,26 +220,20 @@ function stepPayload(
|
||||
};
|
||||
case "personnel":
|
||||
return {
|
||||
generalManagerName: joinName(
|
||||
d.generalManagerFirstName,
|
||||
d.generalManagerLastName,
|
||||
),
|
||||
generalManagerName: d.generalManagerName,
|
||||
generalManagerEmail: d.generalManagerEmail,
|
||||
generalManagerPhone: d.generalManagerPhone,
|
||||
};
|
||||
case "contact":
|
||||
return {
|
||||
contactPersonName: joinName(
|
||||
d.contactPersonFirstName,
|
||||
d.contactPersonLastName,
|
||||
),
|
||||
contactPersonName: d.contactPersonName,
|
||||
contactPersonPosition: d.contactPersonPosition || undefined,
|
||||
contactPersonEmail: d.contactPersonEmail || undefined,
|
||||
contactPersonPhone: d.contactPersonPhone,
|
||||
};
|
||||
case "poa":
|
||||
return {
|
||||
poaName: joinName(d.poaFirstName, d.poaLastName) || undefined,
|
||||
poaName: d.poaName || undefined,
|
||||
poaPhone: d.poaPhone || undefined,
|
||||
poaEmail: d.poaEmail || undefined,
|
||||
poaLocation: d.poaLocation || undefined,
|
||||
@@ -287,13 +248,8 @@ function stepPayload(
|
||||
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 {
|
||||
companyFirstName: companyN.first,
|
||||
companyLastName: companyN.last,
|
||||
companyName: p.companyName ?? "",
|
||||
companyEmail: p.companyEmail ?? "",
|
||||
companyPhone: p.companyPhone ?? "",
|
||||
companyLocation: p.companyLocation ?? "",
|
||||
@@ -313,17 +269,14 @@ function toFormValues(p: ProfileResponse): FormData {
|
||||
kebele: p.kebele ?? "",
|
||||
houseNo: p.houseNo ?? "",
|
||||
etradePhone: p.etradePhone ?? "",
|
||||
contactPersonFirstName: contactN.first,
|
||||
contactPersonLastName: contactN.last,
|
||||
contactPersonName: p.contactPersonName ?? "",
|
||||
contactPersonPosition: p.contactPersonPosition ?? "",
|
||||
contactPersonEmail: p.contactPersonEmail ?? "",
|
||||
contactPersonPhone: p.contactPersonPhone ?? "",
|
||||
generalManagerFirstName: gmN.first,
|
||||
generalManagerLastName: gmN.last,
|
||||
generalManagerName: p.generalManagerName ?? "",
|
||||
generalManagerEmail: p.generalManagerEmail ?? "",
|
||||
generalManagerPhone: p.generalManagerPhone ?? "",
|
||||
poaFirstName: poaN.first,
|
||||
poaLastName: poaN.last,
|
||||
poaName: p.poaName ?? "",
|
||||
poaPhone: p.poaPhone ?? "",
|
||||
poaAddress: p.poaAddress ?? "",
|
||||
poaEmail: p.poaEmail ?? "",
|
||||
@@ -438,8 +391,7 @@ export default function CompanyProfileForm({
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(onboardingSchema),
|
||||
defaultValues: {
|
||||
companyFirstName: "",
|
||||
companyLastName: "",
|
||||
companyName: "",
|
||||
companyEmail: "",
|
||||
companyPhone: "",
|
||||
companyLocation: "",
|
||||
@@ -459,17 +411,14 @@ export default function CompanyProfileForm({
|
||||
kebele: "",
|
||||
houseNo: "",
|
||||
etradePhone: "",
|
||||
contactPersonFirstName: "",
|
||||
contactPersonLastName: "",
|
||||
contactPersonName: "",
|
||||
contactPersonPosition: "",
|
||||
contactPersonEmail: "",
|
||||
contactPersonPhone: "",
|
||||
generalManagerFirstName: "",
|
||||
generalManagerLastName: "",
|
||||
generalManagerName: "",
|
||||
generalManagerEmail: "",
|
||||
generalManagerPhone: "",
|
||||
poaFirstName: "",
|
||||
poaLastName: "",
|
||||
poaName: "",
|
||||
poaPhone: "",
|
||||
poaAddress: "",
|
||||
poaEmail: "",
|
||||
@@ -510,19 +459,16 @@ export default function CompanyProfileForm({
|
||||
}, [region, zone, woreda, kebele, houseNo]);
|
||||
|
||||
// The business owner/manager pulled from eTrade — powers "Use owner as
|
||||
// manager" on the General Manager step.
|
||||
// manager" on the General Manager step. Null until a TIN lookup succeeds.
|
||||
const [etradeOwner, setEtradeOwner] = useState<{
|
||||
name: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleETradeDataLoaded = (data: CompanyRegistrationData) => {
|
||||
// Company name comes from the eTrade manager/owner name on the license.
|
||||
if (data.managerName) {
|
||||
const { first, last } = splitName(data.managerName);
|
||||
setValue("companyFirstName", first, { shouldValidate: true });
|
||||
setValue("companyLastName", last, { shouldValidate: true });
|
||||
setValue("companyName", data.managerName, { shouldValidate: true });
|
||||
}
|
||||
setValue("licenceNumber", data.licenceNumber);
|
||||
setValue("statusDescription", data.statusDescription);
|
||||
@@ -554,20 +500,13 @@ export default function CompanyProfileForm({
|
||||
phone: toEthiopianE164(
|
||||
data.managerPhone || data.regularPhone || data.mobilePhone,
|
||||
),
|
||||
email: data.managerEmail || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
/** Fill the General Manager from the eTrade business owner. */
|
||||
const toggleOwnerAsGm = (checked: boolean) => {
|
||||
setOwnerIsGm(checked);
|
||||
if (!checked || !etradeOwner) return;
|
||||
const { first, last } = splitName(etradeOwner.name);
|
||||
setValue("generalManagerFirstName", first, { shouldValidate: true });
|
||||
setValue("generalManagerLastName", last, { shouldValidate: true });
|
||||
setValue("generalManagerEmail", etradeOwner.email ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
const useOwnerAsManager = () => {
|
||||
if (!etradeOwner) return;
|
||||
setValue("generalManagerName", etradeOwner.name);
|
||||
setValue("generalManagerPhone", etradeOwner.phone ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
@@ -797,23 +736,15 @@ 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 <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
|
||||
placeholder="Global"
|
||||
error={errors.companyFirstName?.message}
|
||||
{...register("companyFirstName")}
|
||||
/>
|
||||
<TextInput
|
||||
label={<>Last Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
|
||||
placeholder="Logistics Ltd"
|
||||
error={errors.companyLastName?.message}
|
||||
{...register("companyLastName")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label={<>Company Email <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
|
||||
label="Company Email"
|
||||
type="email"
|
||||
placeholder="ops@company.com"
|
||||
error={errors.companyEmail?.message}
|
||||
@@ -827,21 +758,21 @@ export default function CompanyProfileForm({
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<TextInput
|
||||
label={<>Location <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
|
||||
label="Location"
|
||||
placeholder="Addis Ababa, Ethiopia"
|
||||
error={errors.companyLocation?.message}
|
||||
{...register("companyLocation")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label={<>VAT Number <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
|
||||
label="VAT Number"
|
||||
placeholder="VAT-12345"
|
||||
maxLength={10}
|
||||
error={errors.vatNumber?.message}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
<TextInput
|
||||
label={<>FAN Number (16 digits) <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
|
||||
label="FAN Number (16 digits)"
|
||||
placeholder="1234567890123456"
|
||||
maxLength={16}
|
||||
error={errors.fanNumber?.message}
|
||||
@@ -944,33 +875,31 @@ export default function CompanyProfileForm({
|
||||
|
||||
{step === "personnel" && (
|
||||
<>
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
General Manager
|
||||
</Text>
|
||||
<Checkbox
|
||||
color="edr-green"
|
||||
label="Use eTrade business owner as General Manager"
|
||||
checked={ownerIsGm}
|
||||
disabled={!etradeOwner}
|
||||
onChange={(e) => toggleOwnerAsGm(e.currentTarget.checked)}
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
General Manager
|
||||
</Text>
|
||||
{etradeOwner && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
leftSection={<UserCheck size={14} />}
|
||||
onClick={useOwnerAsManager}
|
||||
>
|
||||
Use owner as manager
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.generalManagerName?.message}
|
||||
{...register("generalManagerName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label={<>First Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
|
||||
placeholder="Abebe"
|
||||
error={errors.generalManagerFirstName?.message}
|
||||
{...register("generalManagerFirstName")}
|
||||
/>
|
||||
<TextInput
|
||||
label={<>Last Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
|
||||
placeholder="Bikila"
|
||||
error={errors.generalManagerLastName?.message}
|
||||
{...register("generalManagerLastName")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label={<>Email <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="gm@company.com"
|
||||
error={errors.generalManagerEmail?.message}
|
||||
@@ -1017,25 +946,19 @@ export default function CompanyProfileForm({
|
||||
</Group>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label={<>First Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
|
||||
placeholder="Jane"
|
||||
error={errors.contactPersonFirstName?.message}
|
||||
{...register("contactPersonFirstName")}
|
||||
label="Name"
|
||||
placeholder="Jane Smith"
|
||||
error={errors.contactPersonName?.message}
|
||||
{...register("contactPersonName")}
|
||||
/>
|
||||
<TextInput
|
||||
label={<>Last Name <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
|
||||
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"
|
||||
@@ -1043,8 +966,6 @@ export default function CompanyProfileForm({
|
||||
error={errors.contactPersonEmail?.message}
|
||||
{...register("contactPersonEmail")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="contactPersonPhone"
|
||||
|
||||
@@ -33,8 +33,7 @@ import {
|
||||
|
||||
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
|
||||
import { PayNowButton } from "./payments/PayNowButton";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||
import { NewBookingButton } from "@/components/NewBookingButton";
|
||||
import {
|
||||
BookingTypeBadge,
|
||||
CargoModeCell,
|
||||
@@ -64,7 +63,11 @@ import {
|
||||
// ── Status filter options (grouped by lifecycle) ──────────────────────────────
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ key: "all", label: "All bookings", statuses: undefined as string | undefined },
|
||||
{
|
||||
key: "all",
|
||||
label: "All bookings",
|
||||
statuses: undefined as string | undefined,
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
label: "In progress",
|
||||
@@ -80,12 +83,19 @@ const STATUS_FILTERS = [
|
||||
},
|
||||
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" },
|
||||
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
|
||||
{ key: "closed", label: "Cancelled / rejected", statuses: "CANCELLED,REJECTED" },
|
||||
{
|
||||
key: "closed",
|
||||
label: "Cancelled / rejected",
|
||||
statuses: "CANCELLED,REJECTED",
|
||||
},
|
||||
] as const;
|
||||
|
||||
type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"];
|
||||
|
||||
const SELECT_DATA = STATUS_FILTERS.map((f) => ({ value: f.key, label: f.label }));
|
||||
const SELECT_DATA = STATUS_FILTERS.map((f) => ({
|
||||
value: f.key,
|
||||
label: f.label,
|
||||
}));
|
||||
|
||||
// ── Summary stat cards (clickable lifecycle filters) ──────────────────────────
|
||||
|
||||
@@ -96,42 +106,42 @@ const STAT_CARDS: Array<{
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
}> = [
|
||||
{
|
||||
key: "all",
|
||||
label: "All bookings",
|
||||
icon: LayoutList,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A8A5F",
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
label: "In progress",
|
||||
icon: Package,
|
||||
iconBg: "#FDF3E0",
|
||||
iconColor: "#C77F09",
|
||||
},
|
||||
{
|
||||
key: "payment",
|
||||
label: "Awaiting payment",
|
||||
icon: Wallet,
|
||||
iconBg: "#FEF6E6",
|
||||
iconColor: "#F2A516",
|
||||
},
|
||||
{
|
||||
key: "draft",
|
||||
label: "Drafts",
|
||||
icon: FileEdit,
|
||||
iconBg: "#F1F4F7",
|
||||
iconColor: "#475569",
|
||||
},
|
||||
{
|
||||
key: "done",
|
||||
label: "Completed",
|
||||
icon: CheckCircle2,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A8A5F",
|
||||
},
|
||||
];
|
||||
{
|
||||
key: "all",
|
||||
label: "All bookings",
|
||||
icon: LayoutList,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A8A5F",
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
label: "In progress",
|
||||
icon: Package,
|
||||
iconBg: "#FDF3E0",
|
||||
iconColor: "#C77F09",
|
||||
},
|
||||
{
|
||||
key: "payment",
|
||||
label: "Awaiting payment",
|
||||
icon: Wallet,
|
||||
iconBg: "#FEF6E6",
|
||||
iconColor: "#F2A516",
|
||||
},
|
||||
{
|
||||
key: "draft",
|
||||
label: "Drafts",
|
||||
icon: FileEdit,
|
||||
iconBg: "#F1F4F7",
|
||||
iconColor: "#475569",
|
||||
},
|
||||
{
|
||||
key: "done",
|
||||
label: "Completed",
|
||||
icon: CheckCircle2,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A8A5F",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Status badge (reuses the shared portal status config) ─────────────────────
|
||||
|
||||
@@ -139,8 +149,12 @@ function StatusBadge({ status }: { status: string }) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
|
||||
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
|
||||
const text = cfg ? `var(--mantine-color-${cfg.badgeText}-7, #475569)` : "#475569";
|
||||
const dot = cfg ? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)` : "#94A3B8";
|
||||
const text = cfg
|
||||
? `var(--mantine-color-${cfg.badgeText}-7, #475569)`
|
||||
: "#475569";
|
||||
const dot = cfg
|
||||
? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)`
|
||||
: "#94A3B8";
|
||||
return (
|
||||
<Group
|
||||
gap={6}
|
||||
@@ -191,7 +205,10 @@ function PrimaryAction({
|
||||
fw={700}
|
||||
fz={13}
|
||||
rightSection={<ArrowRight size={14} />}
|
||||
style={{ backgroundColor: "var(--mantine-color-edr-ink-0)", color: "#fff" }}
|
||||
style={{
|
||||
backgroundColor: "var(--mantine-color-edr-ink-0)",
|
||||
color: "#fff",
|
||||
}}
|
||||
onClick={go}
|
||||
>
|
||||
Continue
|
||||
@@ -220,7 +237,14 @@ function PrimaryAction({
|
||||
return <PayNowButton booking={booking} />;
|
||||
}
|
||||
return (
|
||||
<Button size="xs" radius="md" variant="default" fw={600} fz={13} onClick={go}>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="default"
|
||||
fw={600}
|
||||
fz={13}
|
||||
onClick={go}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
);
|
||||
@@ -232,7 +256,11 @@ function ColHeader({ label }: { label: string }) {
|
||||
fz={11}
|
||||
fw={700}
|
||||
c="edr-muted"
|
||||
style={{ letterSpacing: "0.6px", textTransform: "uppercase", whiteSpace: "nowrap" }}
|
||||
style={{
|
||||
letterSpacing: "0.6px",
|
||||
textTransform: "uppercase",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
@@ -247,22 +275,19 @@ function fmtDate(iso?: string | null): string {
|
||||
return Number.isNaN(d.getTime())
|
||||
? ""
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
// Lightweight count query for a single lifecycle filter (reads only `total`).
|
||||
function useStatusCount(
|
||||
statuses: string | undefined,
|
||||
companyProfileId?: string,
|
||||
): number | undefined {
|
||||
function useStatusCount(statuses: string | undefined): number | undefined {
|
||||
const { data } = useQuery(
|
||||
api.bookings.list.queryOptions({
|
||||
input: { statuses, companyProfileId, page: 1, pageSize: 1 },
|
||||
input: { statuses, page: 1, pageSize: 1 },
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
);
|
||||
@@ -338,21 +363,10 @@ export default function MyBookings() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
const [freightFilter, setFreightFilter] = useState<string | null>(null);
|
||||
const [serviceFilter, setServiceFilter] = useState<string | null>(null);
|
||||
const [createdFrom, setCreatedFrom] = useState<string>("");
|
||||
const [createdTo, setCreatedTo] = useState<string>("");
|
||||
|
||||
// Operational-service options (importer / exporter / freight forwarder) for
|
||||
// the per-page filter. Empty for non-customer companies.
|
||||
const { company } = useAuth();
|
||||
const companyProfiles = company?.company?.companyProfiles ?? [];
|
||||
const serviceOptions = companyProfiles.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${PROFILE_TYPE_LABELS[p.type] ?? p.type} · ${p.reference}`,
|
||||
}));
|
||||
const [trackingBooking, setTrackingBooking] = useState<Freight.IBooking | null>(
|
||||
null,
|
||||
);
|
||||
const [trackingBooking, setTrackingBooking] =
|
||||
useState<Freight.IBooking | null>(null);
|
||||
|
||||
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
|
||||
|
||||
@@ -365,15 +379,10 @@ export default function MyBookings() {
|
||||
};
|
||||
|
||||
const hasExtraFilters =
|
||||
!!typeFilter ||
|
||||
!!freightFilter ||
|
||||
!!serviceFilter ||
|
||||
!!createdFrom ||
|
||||
!!createdTo;
|
||||
!!typeFilter || !!freightFilter || !!createdFrom || !!createdTo;
|
||||
const clearExtraFilters = () => {
|
||||
setTypeFilter(null);
|
||||
setFreightFilter(null);
|
||||
setServiceFilter(null);
|
||||
setCreatedFrom("");
|
||||
setCreatedTo("");
|
||||
resetPage();
|
||||
@@ -384,7 +393,6 @@ export default function MyBookings() {
|
||||
statuses,
|
||||
bookingType: typeFilter ?? undefined,
|
||||
freightType: freightFilter ?? undefined,
|
||||
companyProfileId: serviceFilter ?? undefined,
|
||||
createdFrom: createdFrom || undefined,
|
||||
// include the whole selected end day
|
||||
createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined,
|
||||
@@ -395,7 +403,6 @@ export default function MyBookings() {
|
||||
statuses,
|
||||
typeFilter,
|
||||
freightFilter,
|
||||
serviceFilter,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
pagination.pageIndex,
|
||||
@@ -407,33 +414,25 @@ export default function MyBookings() {
|
||||
api.bookings.list.queryOptions({ input: filter }),
|
||||
);
|
||||
|
||||
// Per-card lifecycle counts (one cheap query each, total-only). Scoped to the
|
||||
// selected service so the cards match the filtered table.
|
||||
const svc = serviceFilter ?? undefined;
|
||||
const allCount = useStatusCount(undefined, svc);
|
||||
// Per-card lifecycle counts (one cheap query each, total-only).
|
||||
const allCount = useStatusCount(undefined);
|
||||
const activeCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
|
||||
svc,
|
||||
);
|
||||
const paymentCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
|
||||
svc,
|
||||
);
|
||||
const draftCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
|
||||
svc,
|
||||
);
|
||||
const doneCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
|
||||
svc,
|
||||
);
|
||||
const transitCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "transit")!.statuses,
|
||||
svc,
|
||||
);
|
||||
const closedCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "closed")!.statuses,
|
||||
svc,
|
||||
);
|
||||
const cardCounts: Record<StatusFilterKey, number | undefined> = {
|
||||
all: allCount,
|
||||
@@ -461,8 +460,7 @@ export default function MyBookings() {
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
const showEmpty =
|
||||
!isLoading && !isError && rows.length === 0;
|
||||
const showEmpty = !isLoading && !isError && rows.length === 0;
|
||||
|
||||
const columns: ColumnDef<Freight.IBooking>[] = [
|
||||
{
|
||||
@@ -472,7 +470,8 @@ export default function MyBookings() {
|
||||
header: () => <ColHeader label="Booking" />,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const cargoLabel = b.freightType === "BULK" ? "Bulk cargo" : "Container";
|
||||
const cargoLabel =
|
||||
b.freightType === "BULK" ? "Bulk cargo" : "Container";
|
||||
return (
|
||||
<Group gap={12} wrap="nowrap" align="center">
|
||||
<Box
|
||||
@@ -487,7 +486,11 @@ export default function MyBookings() {
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Package size={18} color="var(--mantine-color-edr-green-7)" strokeWidth={2} />
|
||||
<Package
|
||||
size={18}
|
||||
color="var(--mantine-color-edr-green-7)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={700} c="edr-text" truncate>
|
||||
@@ -593,7 +596,12 @@ export default function MyBookings() {
|
||||
const booking = row.original;
|
||||
const trackable = TRACKABLE_STATUSES.has(booking.status);
|
||||
return (
|
||||
<Group justify="flex-end" gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
<Group
|
||||
justify="flex-end"
|
||||
gap={8}
|
||||
wrap="nowrap"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{trackable && (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -611,7 +619,12 @@ export default function MyBookings() {
|
||||
<PrimaryAction booking={booking} onNavigate={navigate} />
|
||||
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="transparent" size={30} radius="md" aria-label="More options">
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size={30}
|
||||
radius="md"
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreVertical size={16} color="#9AA8B5" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
@@ -642,7 +655,12 @@ export default function MyBookings() {
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
|
||||
<Box>
|
||||
<Group gap={10} align="center">
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
<Title
|
||||
order={1}
|
||||
fw={800}
|
||||
fz={26}
|
||||
style={{ letterSpacing: "-0.01em" }}
|
||||
>
|
||||
Bookings
|
||||
</Title>
|
||||
</Group>
|
||||
@@ -674,7 +692,9 @@ export default function MyBookings() {
|
||||
px={20}
|
||||
py={14}
|
||||
wrap="wrap"
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-edr-border-0)",
|
||||
}}
|
||||
>
|
||||
<Group gap={10} wrap="wrap" style={{ flex: 1, minWidth: 260 }}>
|
||||
<TextInput
|
||||
@@ -700,7 +720,9 @@ export default function MyBookings() {
|
||||
<Select
|
||||
data={SELECT_DATA}
|
||||
value={statusFilter}
|
||||
onChange={(value) => selectFilter((value as StatusFilterKey) ?? "all")}
|
||||
onChange={(value) =>
|
||||
selectFilter((value as StatusFilterKey) ?? "all")
|
||||
}
|
||||
allowDeselect={false}
|
||||
radius="md"
|
||||
checkIconPosition="right"
|
||||
@@ -742,22 +764,6 @@ export default function MyBookings() {
|
||||
style={{ width: 150 }}
|
||||
aria-label="Filter by cargo type"
|
||||
/>
|
||||
{serviceOptions.length > 1 && (
|
||||
<Select
|
||||
placeholder="All services"
|
||||
data={serviceOptions}
|
||||
value={serviceFilter}
|
||||
onChange={(v) => {
|
||||
setServiceFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 200 }}
|
||||
aria-label="Filter by service"
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
type="date"
|
||||
value={createdFrom}
|
||||
@@ -802,11 +808,19 @@ export default function MyBookings() {
|
||||
|
||||
{showEmpty ? (
|
||||
<Stack align="center" gap={4} px="lg" py={64} ta="center">
|
||||
<ThemeIcon size={56} radius="lg" color="edr-green" variant="light" mb="xs">
|
||||
<ThemeIcon
|
||||
size={56}
|
||||
radius="lg"
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
mb="xs"
|
||||
>
|
||||
<Package size={28} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{query ? "No bookings match your search" : "No bookings here yet"}
|
||||
{query
|
||||
? "No bookings match your search"
|
||||
: "No bookings here yet"}
|
||||
</Text>
|
||||
<Text size="xs" c="edr-muted" maw={320}>
|
||||
{query
|
||||
@@ -814,7 +828,11 @@ export default function MyBookings() {
|
||||
: "Create your first booking to get started."}
|
||||
</Text>
|
||||
{!query && (
|
||||
<NewBookingButton label="Create first booking" size="sm" mt="md" />
|
||||
<NewBookingButton
|
||||
label="Create first booking"
|
||||
size="sm"
|
||||
mt="md"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
@@ -822,7 +840,9 @@ export default function MyBookings() {
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={dataTableStatus}
|
||||
onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)}
|
||||
onRowClick={(row) =>
|
||||
navigate(`/bookings/${(row as Freight.IBooking).id}`)
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
@@ -848,7 +868,8 @@ export default function MyBookings() {
|
||||
bookingId={trackingBooking?.id ?? ""}
|
||||
bookingReference={trackingBooking?.reference ?? ""}
|
||||
originLabel={
|
||||
trackingBooking?.originYard?.label ?? trackingBooking?.originYard?.code
|
||||
trackingBooking?.originYard?.label ??
|
||||
trackingBooking?.originYard?.code
|
||||
}
|
||||
destinationLabel={
|
||||
trackingBooking?.destinationYard?.label ??
|
||||
|
||||
Reference in New Issue
Block a user