Merge pull request #243 from Tria-plc/freight_feature/profile

Freight feature/profile
This commit is contained in:
marshal
2026-06-22 15:49:25 +03:00
committed by GitHub
135 changed files with 8034 additions and 1618 deletions

View File

@@ -1,10 +1,3 @@
// Accept either a host-only URL or one that already ends with `/api`.
// The HTTP client appends `/api` itself, so we normalize here to avoid
// accidental `/api/api/...` requests from env values.
const rawApiBaseUrl =
(import.meta.env.VITE_API_URL as string | undefined) ?? "http://localhost:3001";
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
export const API_BASE_URL = rawApiBaseUrl
.trim()
.replace(/\/+$/, "")
.replace(/\/api$/, "");
export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -29,6 +29,7 @@
"react-dom": "19.2.6",
"react-hook-form": "^7.76.0",
"react-hot-toast": "^2.6.0",
"react-phone-number-input": "^3.4.17",
"react-router-dom": "^6.27.0",
"recharts": "^3.8.1",
"tailwind-merge": "^3.6.0",

View File

@@ -2,12 +2,14 @@ import { AppLayout, type SidebarItem } from "@/components/AppLayout";
import {
CalendarCheck,
Home,
Layers,
Loader2,
MapPin,
Receipt,
Settings,
User,
Sparkles,
} from "lucide-react";
import { useDisclosure } from "@mantine/hooks";
import { useEffect, useRef } from "react";
import {
Navigate,
@@ -19,13 +21,12 @@ import {
} from "react-router-dom";
import useAuth from "./hooks/useAuth";
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import MyPortalPage from "./pages/MyPortalPage";
import ProfilePage from "./pages/ProfilePage";
import MySignaturePage from "./pages/MySignaturePage";
import SettingsPage from "./pages/SettingsPage";
import LoginPage from "./pages/accounts/LoginPage";
import OnboardingPage from "./pages/accounts/OnboardingPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
import SignupPage from "./pages/accounts/SignupPage";
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
@@ -35,6 +36,8 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import EditBookingPage from "./pages/bookings/EditBookingPage";
import MyBookings from "./pages/bookings/MyBookings";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import ContractsList from "./pages/contracts/ContractsList";
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
@@ -74,9 +77,8 @@ function RequireAuth() {
}
/**
* Sends authenticated users without a company to onboarding.
* Only redirects on a confirmed "no company" response — never on a
* transient query error.
* Waits for the company query so downstream routes can rely on it being
* resolved. Onboarding is enforced by OnboardingGate, not here.
*/
function RequireCompany() {
const { customerQuery } = useAuth();
@@ -85,13 +87,94 @@ function RequireCompany() {
return <Outlet />;
}
/** Keeps already-onboarded users out of the onboarding flow. */
function RequireNoCompany() {
const { customerQuery } = useAuth();
/**
* Routes an un-onboarded user may still visit. The wizard auto-opens but is
* dismissable, so they can browse these freely; any other route forces the
* wizard back open and bounces them home.
*/
const ONBOARDING_ALLOWED_PATHS = ["/portal", "/signature"];
if (customerQuery.isPending) return <FullScreenSpinner />;
if (customerQuery.data) return <Navigate to="/portal" replace />;
return <Outlet />;
function isOnboardingAllowedPath(pathname: string): boolean {
const path = pathname.toLowerCase();
return ONBOARDING_ALLOWED_PATHS.some(
(p) => path === p || path.startsWith(p + "/"),
);
}
/**
* Enforces first-run onboarding. The home (dashboard) and signature pages stay
* reachable while onboarding is incomplete; the wizard auto-opens on login but
* can be dismissed to use those pages. Visiting any other page bounces back to
* home and re-opens the wizard. New users (no company yet) are treated the same
* as users who haven't completed onboarding.
*/
function OnboardingGate() {
const { company, onboardingCompleted } = useAuth();
const location = useLocation();
const needsOnboarding = !company || !onboardingCompleted;
const allowedHere = isOnboardingAllowedPath(location.pathname);
// Open by default while onboarding is pending (covers the login case).
const [wizardOpen, { open: openWizard, close: closeWizard }] =
useDisclosure(false);
// Re-evaluate on every navigation: force the wizard open on blocked routes,
// and auto-open on first arrival while onboarding is pending.
useEffect(() => {
if (needsOnboarding && !allowedHere) {
openWizard();
}
}, [needsOnboarding, allowedHere, location.pathname, openWizard]);
// Auto-open once when onboarding becomes/loads as pending (login).
const autoOpenedRef = useRef(false);
useEffect(() => {
if (needsOnboarding && !autoOpenedRef.current) {
autoOpenedRef.current = true;
openWizard();
}
if (!needsOnboarding) autoOpenedRef.current = false;
}, [needsOnboarding, openWizard]);
if (needsOnboarding && !allowedHere) {
return <Navigate to="/portal" replace />;
}
return (
<>
{needsOnboarding && !wizardOpen && (
<OnboardingResumeBanner onResume={openWizard} />
)}
<Outlet />
<OnboardingWizardDialog
opened={needsOnboarding && wizardOpen}
onClose={closeWizard}
/>
</>
);
}
/** Slim sticky prompt shown on allowed pages after the wizard is dismissed. */
function OnboardingResumeBanner({ onResume }: { onResume: () => void }) {
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#0EA371]/20 bg-[#ECF6F1] px-6 py-3">
<div className="flex items-center gap-2">
<Sparkles size={16} className="text-[#0A6F4D]" />
<span className="text-sm font-medium text-[#0A6F4D]">
Finish setting up your company to unlock bookings, tracking and
billing.
</span>
</div>
<button
type="button"
onClick={onResume}
className="rounded-lg bg-[#0EA371] px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
Continue onboarding
</button>
</div>
);
}
/** Keeps authenticated users off the login/signup pages. */
@@ -119,6 +202,11 @@ const sidebarItems: SidebarItem[] = [
href: "/bookings",
icon: <CalendarCheck size={18} />,
},
{
label: "General Contracts",
href: "/contracts",
icon: <Layers size={18} />,
},
{
label: "Tracking",
href: "/tracking",
@@ -129,12 +217,6 @@ const sidebarItems: SidebarItem[] = [
href: "/billing",
icon: <Receipt size={18} />,
},
{
section: "Account",
label: "Profile",
href: "/profile",
icon: <User size={18} />,
},
{
section: "Account",
label: "Settings",
@@ -146,7 +228,14 @@ const sidebarItems: SidebarItem[] = [
const App = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, company } = useAuth();
const {
user,
company,
activeProfileType,
companyType,
switchMode,
createProfileAndSwitch,
} = useAuth();
const displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email;
@@ -176,10 +265,6 @@ const App = () => {
<Route path="/set-password" element={<SetPasswordPage />} />
<Route element={<RequireAuth />}>
<Route element={<RequireNoCompany />}>
<Route path="/onboarding" element={<OnboardingPage />} />
</Route>
<Route element={<RequireCompany />}>
<Route
element={
@@ -192,8 +277,12 @@ const App = () => {
userName={displayName}
userEmail={userEmail}
companyProfiles={companyProfiles}
companyType={companyType}
activeProfileType={activeProfileType}
onSwitchMode={switchMode}
onCreateProfile={createProfileAndSwitch}
>
<Outlet />
<OnboardingGate />
</AppLayout>
}
>
@@ -206,9 +295,12 @@ const App = () => {
path="/bookings/:id/contract"
element={<BookingContractPage />}
/>
<Route path="/contracts" element={<ContractsList />} />
<Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/profile" element={<ProfilePage />} />
{/* Profile was merged into Settings — keep old links working. */}
<Route path="/profile" element={<Navigate to="/settings" replace />} />
<Route path="/signature" element={<MySignaturePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>

View File

@@ -2,9 +2,12 @@ import {
AppShell,
Avatar,
Box,
Button,
Divider,
FileInput,
Group,
Menu,
Modal,
NavLink,
ScrollArea,
Stack,
@@ -16,7 +19,9 @@ import {
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
ArrowLeftRight,
Bell,
Check,
ChevronDown,
FileSignature,
LogOut,
@@ -26,10 +31,17 @@ import {
Search,
Settings,
Sun,
Upload,
User,
X,
} from "lucide-react";
import { type CSSProperties, Fragment, type ReactNode } from "react";
import {
type CSSProperties,
Fragment,
type ReactNode,
useState,
} from "react";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
export interface SidebarItem {
label: string;
@@ -49,16 +61,32 @@ export interface AppLayoutProps {
userEmail?: string;
/** Operational profiles for the company — surfaced as reference chips in the account menu. */
companyProfiles?: { type: string; reference: string; status?: string }[];
/** Company type (e.g. "customer", "forwarder") — gates the importer/exporter switch. */
companyType?: string | null;
/** The active operational mode (importer/exporter/...). */
activeProfileType?: string | null;
/** Switch to an existing profile of the given type. */
onSwitchMode?: (type: ServiceType) => Promise<SwitchResult> | void;
/** Create the profile of the given type (with business license) then switch. */
onCreateProfile?: (
type: ServiceType,
licenseFiles: File[],
) => Promise<SwitchResult> | void;
children: ReactNode;
}
const PROFILE_TYPE_LABELS: Record<string, string> = {
importer: "Importer",
exporter: "Exporter",
freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter",
};
/** Service profiles a customer company can operate under and switch between. */
type ServiceType = "importer" | "exporter" | "freight_forwarder";
/** Services a customer company can select in the header. */
const CUSTOMER_SERVICES: ServiceType[] = [
"importer",
"exporter",
"freight_forwarder",
];
type SwitchResult =
| { success: true; data?: unknown }
| { success: false; error?: { message?: string } };
function getInitials(name: string): string {
return name
@@ -117,6 +145,10 @@ export function AppLayout({
userName = "User",
userEmail,
companyProfiles = [],
companyType,
activeProfileType,
onSwitchMode,
onCreateProfile,
children,
}: AppLayoutProps) {
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
@@ -142,6 +174,62 @@ export function AppLayout({
const initials = getInitials(userName);
const activePage = getActivePage(sidebarItems, activePath);
// ── Service selection (customer companies only) ──
// A customer can operate as importer, exporter and/or freight forwarder,
// and switch between whichever service profiles their company has.
const isCustomer = companyType === "customer";
const canSwitch =
isCustomer &&
CUSTOMER_SERVICES.includes(activeProfileType as ServiceType);
const profileExists = (type: ServiceType) =>
companyProfiles.some((p) => p.type === type);
const [switching, setSwitching] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [createTarget, setCreateTarget] = useState<ServiceType>("importer");
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
const [createError, setCreateError] = useState<string | null>(null);
const handleSelectService = async (type: ServiceType) => {
if (type === activeProfileType) return;
if (profileExists(type)) {
setSwitching(true);
try {
await onSwitchMode?.(type);
} finally {
setSwitching(false);
}
} else {
// No profile yet — collect a business license, then create + switch.
setCreateTarget(type);
setLicenseFiles([]);
setCreateError(null);
setCreateOpen(true);
}
};
const handleCreateConfirm = async () => {
if (licenseFiles.length === 0) {
setCreateError("Please upload at least one business license file.");
return;
}
setSwitching(true);
setCreateError(null);
try {
const res = await onCreateProfile?.(createTarget, licenseFiles);
if (res && !res.success) {
setCreateError(res.error?.message ?? "Failed to create profile");
return;
}
setCreateOpen(false);
} finally {
setSwitching(false);
}
};
const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m;
const isItemActive = (item: SidebarItem) =>
activePath === item.href.toLowerCase() ||
activePath.startsWith(item.href.toLowerCase() + "/");
@@ -212,8 +300,66 @@ export function AppLayout({
</Text>
</Group>
{/* Right: search + bell + avatar */}
{/* Right: switch + search + bell + avatar */}
<Group gap={10} wrap="nowrap" align="center">
{/* Service selector (customer companies only) */}
{canSwitch && (
<Menu
width={220}
position="bottom-end"
withinPortal
shadow="md"
offset={8}
radius="md"
>
<Menu.Target>
<Button
loading={switching}
variant="light"
color="edr-green"
radius={999}
size="sm"
leftSection={<ArrowLeftRight size={15} strokeWidth={1.8} />}
rightSection={<ChevronDown size={14} strokeWidth={1.8} />}
styles={{ root: { height: 36 } }}
visibleFrom="xs"
>
{serviceLabel(activeProfileType as ServiceType)}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Select service</Menu.Label>
{CUSTOMER_SERVICES.map((type) => {
const isActive = type === activeProfileType;
const exists = profileExists(type);
return (
<Menu.Item
key={type}
onClick={() => handleSelectService(type)}
leftSection={
isActive ? (
<Check size={15} strokeWidth={2} />
) : exists ? (
<ArrowLeftRight size={15} strokeWidth={1.8} />
) : (
<Plus size={15} strokeWidth={1.8} />
)
}
disabled={isActive}
>
{serviceLabel(type)}
{!exists && (
<Text span size="xs" c="dimmed" ml={6}>
(set up)
</Text>
)}
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
)}
{/* Search pill */}
<Group
gap={8}
@@ -322,25 +468,39 @@ export function AppLayout({
<Divider />
<Box px="sm" py="xs">
<Stack gap={6}>
{companyProfiles.map((p) => (
<Group
key={p.reference}
justify="space-between"
gap="sm"
wrap="nowrap"
>
<Text
size="xs"
fw={600}
style={{ color: textColor }}
{companyProfiles.map((p) => {
const isActive = p.type === activeProfileType;
return (
<Group
key={p.reference}
justify="space-between"
gap="sm"
wrap="nowrap"
>
{PROFILE_TYPE_LABELS[p.type] ?? p.type}
</Text>
<Text size="xs" ff="monospace" c="dimmed">
{p.reference}
</Text>
</Group>
))}
<Group gap={6} wrap="nowrap">
{isActive && (
<Check
size={13}
color={primaryDarkColor}
strokeWidth={2.5}
/>
)}
<Text
size="xs"
fw={isActive ? 700 : 600}
style={{
color: isActive ? primaryDarkColor : textColor,
}}
>
{PROFILE_TYPE_LABELS[p.type] ?? p.type}
</Text>
</Group>
<Text size="xs" ff="monospace" c="dimmed">
{p.reference}
</Text>
</Group>
);
})}
</Stack>
</Box>
</>
@@ -686,6 +846,51 @@ export function AppLayout({
>
{children}
</AppShell.Main>
{/* Create-profile modal — opens when switching to a mode the company
doesn't have a profile for yet. */}
<Modal
opened={createOpen}
onClose={() => (switching ? undefined : setCreateOpen(false))}
title={`Set up your ${serviceLabel(createTarget)} profile`}
centered
radius="lg"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
You don't have a {serviceLabel(createTarget).toLowerCase()} profile
yet. Add your business license to create one and switch to{" "}
{serviceLabel(createTarget).toLowerCase()}.
</Text>
<FileInput
label="Business license"
multiple
clearable
accept="application/pdf,image/png,image/jpeg"
leftSection={<Upload size={16} />}
placeholder="Select license file(s)"
value={licenseFiles}
onChange={(files) => setLicenseFiles(files ?? [])}
error={createError ?? undefined}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setCreateOpen(false)}
disabled={switching}
>
Cancel
</Button>
<Button
color="edr-green"
onClick={handleCreateConfirm}
loading={switching}
>
Create &amp; switch
</Button>
</Group>
</Stack>
</Modal>
</AppShell>
);
}

View File

@@ -0,0 +1,55 @@
import { Badge, Tooltip } from "@mantine/core";
import { ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import useAuth from "@/hooks/useAuth";
import { modeDataDescription, modeDataLabel } from "@/constants/profileMode";
interface ModeIndicatorProps {
/** Mantine size token for the badge. */
size?: "sm" | "md" | "lg";
}
/**
* Small pill showing which operational mode's data is currently on screen
* (Import / Export). The data itself is scoped server-side by the active
* profile; this just makes the scope visible. Switching is done via the header
* button — this is read-only.
*
* Renders nothing for non-customer companies or when no import/export mode is
* active, so it never interferes with forwarders or not-yet-onboarded users.
*/
export function ModeIndicator({ size = "md" }: ModeIndicatorProps) {
const { companyType, activeProfileType } = useAuth();
if (companyType !== "customer") return null;
const label = modeDataLabel(activeProfileType);
if (!label) return null;
const isImport = activeProfileType === "importer";
return (
<Tooltip label={modeDataDescription(activeProfileType)} withArrow>
<Badge
size={size}
radius="sm"
variant="light"
color={isImport ? "edr-green" : "blue"}
leftSection={
isImport ? (
<ArrowDownToLine size={13} />
) : (
<ArrowUpFromLine size={13} />
)
}
styles={{
root: { textTransform: "none", letterSpacing: 0, fontWeight: 600 },
}}
>
Viewing: {label}
</Badge>
</Tooltip>
);
}
export default ModeIndicator;

View File

@@ -0,0 +1,137 @@
import { Input } from "@mantine/core";
import { forwardRef } from "react";
import {
Controller,
type Control,
type FieldValues,
type Path,
} from "react-hook-form";
import RPNInput, { isValidPhoneNumber } from "react-phone-number-input";
import "react-phone-number-input/style.css";
import "./phone-field.css";
/** Re-exported for zod `.refine()` checks on phone fields. */
export const isValidPhone = (value?: string | null): boolean =>
!!value && isValidPhoneNumber(value);
/**
* Normalize a raw (often eTrade) phone string to Ethiopian E.164 (+251…).
* eTrade returns local numbers like "0912345678" / "0355235416"; the phone
* input needs +251… to parse, so we drop a leading 0 and prepend +251. Numbers
* already in +… form, or that can't be coerced, are returned trimmed/as-is.
*/
export const toEthiopianE164 = (raw?: string | null): string => {
if (!raw) return "";
const trimmed = raw.trim();
if (trimmed.startsWith("+")) return trimmed.replace(/[^\d+]/g, "");
// Keep digits only, drop a single leading zero (national trunk prefix).
const digits = trimmed.replace(/\D/g, "").replace(/^0/, "");
if (!digits) return "";
// Already includes the 251 country code.
if (digits.startsWith("251")) return `+${digits}`;
return `+251${digits}`;
};
/**
* The text input rendered inside react-phone-number-input, styled to match the
* portal's Mantine fields (44px height, 10px radius, edr border). Must forward
* the ref and accept native input props for the library to drive it.
*/
const StyledInput = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
function StyledInput(props, ref) {
return <input {...props} ref={ref} className="edr-phone-input" />;
},
);
export interface PhoneFieldProps {
label?: string;
value?: string;
onChange: (value: string | undefined) => void;
onBlur?: () => void;
error?: string;
required?: boolean;
disabled?: boolean;
placeholder?: string;
}
/**
* Professional phone input: searchable country selector (all countries, default
* Ethiopia), live formatting, emits a single E.164 value (e.g. +251912345678).
* Visually aligned with the portal's Mantine form fields.
*/
export function PhoneField({
label,
value,
onChange,
onBlur,
error,
required,
disabled,
placeholder = "912 345 678",
}: PhoneFieldProps) {
return (
<Input.Wrapper
label={label}
required={required}
error={error}
styles={{
label: { fontWeight: 600, fontSize: 13, color: "#10202F", marginBottom: 6 },
}}
>
<div className={`edr-phone-wrapper${error ? " edr-phone-wrapper--error" : ""}`}>
<RPNInput
international
defaultCountry="ET"
countryCallingCodeEditable={false}
addInternationalOption
value={value}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
placeholder={placeholder}
inputComponent={StyledInput}
/>
</div>
</Input.Wrapper>
);
}
interface ControlledPhoneFieldProps<T extends FieldValues> {
control: Control<T>;
name: Path<T>;
label?: string;
required?: boolean;
disabled?: boolean;
placeholder?: string;
}
/** RHF Controller wrapper so forms drop in one line. */
export function ControlledPhoneField<T extends FieldValues>({
control,
name,
label,
required,
disabled,
placeholder,
}: ControlledPhoneFieldProps<T>) {
return (
<Controller
control={control}
name={name}
render={({ field, fieldState }) => (
<PhoneField
label={label}
required={required}
disabled={disabled}
placeholder={placeholder}
value={(field.value as string) ?? ""}
onChange={(v) => field.onChange(v ?? "")}
onBlur={field.onBlur}
error={fieldState.error?.message}
/>
)}
/>
);
}
export default PhoneField;

View File

@@ -1,47 +0,0 @@
import { Group, Stack, Text, TextInput, type TextInputProps } from "@mantine/core";
type InputPassthrough = Partial<TextInputProps>;
interface PhoneInputProps {
disabled?: boolean;
countryCode?: InputPassthrough;
phone?: InputPassthrough;
countryCodeError?: { message?: string };
phoneError?: { message?: string };
label?: string;
}
export default function PhoneInput({
disabled,
countryCode: countryCodeProps,
phone: phoneProps,
countryCodeError,
phoneError,
label = "Phone Number",
}: PhoneInputProps) {
const errorMsg = countryCodeError?.message ?? phoneError?.message;
return (
<Stack gap={6}>
<Text size="sm" fw={500} c="edr-text">{label}</Text>
<Group gap={8} wrap="nowrap" align="flex-start">
<TextInput
w={80}
disabled={disabled}
error={Boolean(countryCodeError)}
styles={{ input: { textAlign: "center" } }}
{...countryCodeProps}
/>
<TextInput
style={{ flex: 1 }}
placeholder="912345678"
disabled={disabled}
error={Boolean(phoneError)}
{...phoneProps}
/>
</Group>
{errorMsg && (
<Text size="xs" c="red.6">{errorMsg}</Text>
)}
</Stack>
);
}

View File

@@ -0,0 +1,107 @@
import {
Alert,
Button,
Group,
Loader,
Stack,
Text,
TextInput,
} from "@mantine/core";
import type { UseFormRegisterReturn } from "react-hook-form";
import { AlertCircle, CheckCircle2, Download } from "lucide-react";
import { useETradeData } from "@/hooks/useETradeData";
import type { CompanyRegistrationData } from "@edr/types";
interface ETradeInfoProps {
/** Current TIN value (drives button enablement). */
tin: string;
/** RHF registration for the TIN input — this is the form's primary TIN field. */
register: UseFormRegisterReturn;
/** Validation error for the TIN field, if any. */
error?: string;
onDataLoaded: (data: CompanyRegistrationData) => void;
}
export default function ETradeInfo({
tin,
register,
error,
onDataLoaded,
}: ETradeInfoProps) {
const mutation = useETradeData();
const isLoading = mutation.isPending;
const hasData = mutation.data;
const handleFetch = async () => {
if (!tin || tin.length !== 10) return;
const result = await mutation.mutateAsync(tin);
if (result) {
onDataLoaded(result);
}
};
const errorMessage =
mutation.isError && mutation.error
? (mutation.error as any).message ||
"Failed to fetch company information. Please try again."
: null;
return (
<Stack gap="md">
<Group align="flex-start" grow>
<TextInput
label="TIN Number (10 digits)"
placeholder="1234567890"
maxLength={10}
error={error}
{...register}
/>
<Button
variant="filled"
color="edr-green"
onClick={handleFetch}
disabled={!tin || tin.length !== 10 || isLoading}
leftSection={
isLoading ? <Loader size={16} /> : <Download size={16} />
}
mt="24px"
>
{isLoading ? "Getting..." : "Get Data"}
</Button>
</Group>
{errorMessage && (
<Alert
icon={<AlertCircle size={16} />}
color="red"
title="Failed to fetch data"
>
{errorMessage} You can still fill in the details manually below.
</Alert>
)}
{hasData && (
<Alert
icon={<CheckCircle2 size={16} />}
color="green"
title="Company information loaded"
>
<Stack gap={0}>
<Text size="sm">
<strong>License:</strong> {hasData.licenceNumber}
</Text>
<Text size="sm">
<strong>Status:</strong> {hasData.statusDescription}
</Text>
{hasData.region && (
<Text size="sm">
<strong>Location:</strong> {hasData.kebele}, {hasData.woreda},{" "}
{hasData.zone}, {hasData.region}
</Text>
)}
</Stack>
</Alert>
)}
</Stack>
);
}

View File

@@ -0,0 +1,341 @@
import { Modal, ScrollArea, Stack, Text } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import type {
CompanyNationality,
CreateCompanyPayload,
ProfileTypeValue,
} from "@/services/companies.service";
import { companiesService } from "@/services/companies.service";
import type { UpdateProfilePayload } from "@/types/profile";
import { extractApiError } from "@/utils/result";
import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm";
import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep";
import NationalitySelect from "@/pages/settings/NationalitySelect";
import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect";
/** Form steps shared by CompanyProfileForm and ForwarderForm. */
type FormStep =
| "company"
| "personnel"
| "contact"
| "poa"
| "documents"
| "additional";
const FORM_STEPS: FormStep[] = [
"company",
"personnel",
"contact",
"poa",
"documents",
"additional",
];
interface OnboardingWizardDialogProps {
opened: boolean;
/** Dismiss the dialog (user clicked the close icon). */
onClose: () => void;
}
/**
* The company type for the onboarding selection. Importer / Exporter / Freight
* Forwarder are all services a single "customer" company can hold (in any
* combination), each with its own business license — so the company is always
* registered as a "customer".
*/
function companyTypeForRoles(_roles: string[]): string {
return "customer";
}
/** Document upload setting code per company nationality. */
function documentSettingCode(nationality: CompanyNationality): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
/**
* First-run onboarding wizard with a "draft-first" flow: picking the role(s)
* immediately creates a draft company + profile on the backend, so every
* subsequent step saves its data incrementally (PATCH /profile, /onboarding-step)
* against existing rows. The final step uploads documents and marks onboarding
* complete. Dismissable — the gate keeps it reachable until finished.
*/
export default function OnboardingWizardDialog({
opened,
onClose,
}: OnboardingWizardDialogProps) {
const queryClient = useQueryClient();
const { user, company, onboardingStep } = useAuth();
const existingProfiles = company?.company?.companyProfiles ?? [];
const companyAlreadyStarted = Boolean(company?.company?.id);
const savedNationality =
(company?.company?.nationality as CompanyNationality | null) ?? null;
// Resume position from the backend-persisted step.
const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep)
? (onboardingStep as FormStep)
: "company";
// Phases: nationality → role → form. If a draft already exists, resume
// straight into the form with nationality + roles pre-selected.
const [phase, setPhase] = useState<"nationality" | "role" | "form">(
companyAlreadyStarted ? "form" : "nationality",
);
const [nationality, setNationality] = useState<CompanyNationality | null>(
savedNationality,
);
const [roles, setRoles] = useState<string[]>(
existingProfiles.map((p) => p.type),
);
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
// Newly-selected business-license files per company_profile id.
const [licenseFiles, setLicenseFiles] = useState<Record<string, File[]>>({});
const [startError, setStartError] = useState<string | null>(null);
// Saved profile data, for rehydrating the form fields after a refresh.
const profileQuery = useQuery(
api.companies.getProfile.queryOptions({
enabled: companyAlreadyStarted,
retry: false,
refetchOnWindowFocus: false,
}),
);
const refreshInfo = useCallback(
() =>
queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
}),
[queryClient],
);
// Begin onboarding: create the draft company + profile + role(s) + nationality.
const startMutation = useMutation({
mutationFn: (vars: {
companyType: string;
roles: ProfileTypeValue[];
nationality?: CompanyNationality;
}) => api.companies.startOnboarding.call(vars),
onSuccess: async () => {
await refreshInfo();
setPhase("form");
},
onError: (err) => setStartError(extractApiError(err).message),
});
// Finalize: upload per-role license files + company documents, then complete.
const finishMutation = useMutation({
mutationFn: async () => {
const companyId = company?.company?.id;
// Per-role business licenses (file model, resource=company_profiles).
for (const [profileId, files] of Object.entries(licenseFiles)) {
if (files.length > 0) {
await companiesService.uploadProfileLicense(profileId, files);
}
}
// Nationality-based company documents (resource=companies).
const hasDocs = Object.values(documentFiles).some(
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
);
if (companyId && hasDocs) {
await companiesService.uploadDocuments(companyId, documentFiles);
}
return api.companies.completeOnboarding.call();
},
onSuccess: refreshInfo,
onError: (err) => setStartError(extractApiError(err).message),
});
// Persist the resume step to the backend, but only ever move FORWARD — going
// Back must never downgrade the furthest step the user reached, so reopening
// always lands on the furthest step.
const furthestIdxRef = useRef(FORM_STEPS.indexOf(resumeFormStep));
const persistStep = useCallback((step: string) => {
const idx = FORM_STEPS.indexOf(step as FormStep);
if (idx < 0 || idx <= furthestIdxRef.current) return;
furthestIdxRef.current = idx;
api.companies.setOnboardingStep.call({ step }).catch(() => {});
}, []);
// The company query may resolve AFTER this dialog mounts (it's kept mounted by
// the gate), so the phase/roles/nationality initial state can be stale — a
// draft that already exists would otherwise leave us stuck on the first
// (nationality) phase. Once a draft loads, jump straight into the form with
// the persisted roles/nationality. Runs once per resumed draft.
const resumedRef = useRef(false);
useEffect(() => {
if (!companyAlreadyStarted || resumedRef.current) return;
resumedRef.current = true;
setRoles(existingProfiles.map((p) => p.type));
setNationality(savedNationality);
setPhase("form");
const idx = FORM_STEPS.indexOf(resumeFormStep);
if (idx > furthestIdxRef.current) furthestIdxRef.current = idx;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [companyAlreadyStarted, resumeFormStep]);
const handleNationalityContinue = useCallback(() => {
if (nationality) setPhase("role");
}, [nationality]);
const handleRolesContinue = useCallback(() => {
setStartError(null);
startMutation.mutate({
companyType: companyTypeForRoles(roles),
roles: roles as ProfileTypeValue[],
nationality: nationality ?? undefined,
});
}, [roles, nationality, startMutation]);
// Note: no "back to role selection" — once the draft is created the role(s)
// are fixed; the form's first-step Back is a no-op so progress never resets.
const handleBackToRoles = useCallback(() => {}, []);
// Save the current step's fields to the draft (PATCH /profile). Returns the
// server error message on failure so the form can show it (e.g. duplicate TIN).
const saveStep = useCallback(
async (
data: Partial<UpdateProfilePayload>,
): Promise<{ ok: true } | { ok: false; error: string }> => {
try {
await api.companies.updateProfile.call(data as UpdateProfilePayload);
return { ok: true };
} catch (err) {
return { ok: false, error: extractApiError(err).message };
}
},
[],
);
// Final confirm step → finalize onboarding (no company create; it already
// exists as a draft that's been filled in step-by-step).
const handleSubmit = useCallback(
(_payload: CreateCompanyPayload) => {
finishMutation.mutate();
},
[finishMutation],
);
if (!user) return null;
// Any non-empty combination of importer/exporter/freight-forwarder is valid.
const rolesValid = roles.length > 0;
// Documents depend on nationality; fall back to the saved one (resume) then ethiopian.
const effectiveNationality: CompanyNationality =
nationality ?? savedNationality ?? "ethiopian";
// Per-role license cards for the final step (from the created profiles).
const roleProfiles: RoleLicenseProfile[] = existingProfiles.map((p) => ({
id: p.id,
type: p.type,
reference: p.reference,
existingFiles: p.licenseFiles ?? [],
}));
const titleHint =
phase === "nationality"
? "Where is your company registered?"
: phase === "role"
? "Tell us what your company does to get started."
: "Set up your company profile to finish.";
const formProps = {
documentSettingCode: documentSettingCode(effectiveNationality),
documentFiles,
onDocumentFilesChange: setDocumentFiles,
user,
onSubmit: handleSubmit,
isPending: finishMutation.isPending,
onBack: handleBackToRoles,
hideFirstStepBack: true,
initialStep: resumeFormStep,
resyncOpen: opened,
onStepChange: persistStep,
onSaveStep: saveStep,
rehydrate: profileQuery.data ?? null,
roleProfiles,
licenseFiles,
onLicenseChange: setLicenseFiles,
};
return (
<Modal
opened={opened}
onClose={onClose}
withCloseButton
closeOnClickOutside={false}
closeOnEscape
size={1040}
radius="lg"
padding="xl"
centered
keepMounted
scrollAreaComponent={ScrollArea.Autosize}
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
title={
<Stack gap={2}>
<Text fz={20} fw={800} c="edr-text" className="tracking-tight">
Complete your onboarding
</Text>
<Text size="sm" c="edr-muted">
{titleHint}
</Text>
</Stack>
}
>
{phase === "nationality" ? (
<Stack gap="lg">
<NationalitySelect value={nationality} onChange={setNationality} />
<RoleContinueBar
disabled={!nationality}
onClick={handleNationalityContinue}
/>
</Stack>
) : phase === "role" ? (
<Stack gap="lg">
<OnboardingRoleSelect value={roles} onChange={setRoles} />
{startError && (
<Text size="sm" c="red">
{startError}
</Text>
)}
<RoleContinueBar
disabled={!rolesValid}
loading={startMutation.isPending}
onClick={handleRolesContinue}
/>
</Stack>
) : (
<CompanyProfileForm {...formProps} />
)}
</Modal>
);
}
function RoleContinueBar({
disabled,
loading,
onClick,
}: {
disabled: boolean;
loading?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled || loading}
onClick={onClick}
className="ml-auto rounded-lg bg-[var(--mantine-color-edr-green-6)] px-5 py-2.5 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Setting up…" : "Continue"}
</button>
);
}

View File

@@ -0,0 +1,129 @@
import {
Anchor,
Badge,
Card,
FileInput,
Group,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { FileText, Paperclip, Upload } from "lucide-react";
import type { LicenseFile } from "@/services/companies.service";
const ROLE_LABELS: Record<string, string> = {
importer: "Importer",
exporter: "Exporter",
freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter",
};
export interface RoleLicenseProfile {
id: string;
type: string;
reference: string;
/** License files already uploaded for this profile (rehydration). */
existingFiles: LicenseFile[];
}
interface RoleLicenseStepProps {
/** One card per operational role/profile. */
profiles: RoleLicenseProfile[];
/** Newly-selected files per profile id (not yet uploaded). */
value: Record<string, File[]>;
onChange: (value: Record<string, File[]>) => void;
}
/**
* Final onboarding step: collect a business license (one or more files) for
* each operational role the company holds. Each role gets its own multi-file
* input; already-uploaded files are listed for context.
*/
export default function RoleLicenseStep({
profiles,
value,
onChange,
}: RoleLicenseStepProps) {
const setFiles = (profileId: string, files: File[]) => {
onChange({ ...value, [profileId]: files });
};
return (
<Stack gap="md">
<Text size="sm" c="edr-muted">
Upload the business license for each of your operational profiles. You
can attach more than one document per profile.
</Text>
{profiles.map((profile) => {
const label = ROLE_LABELS[profile.type] ?? profile.type;
const selected = value[profile.id] ?? [];
const hasAny = selected.length > 0 || profile.existingFiles.length > 0;
return (
<Card key={profile.id} padding="lg" withBorder>
<Group justify="space-between" mb="sm" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon
size={40}
radius="md"
variant="light"
color="edr-green"
>
<FileText size={20} />
</ThemeIcon>
<div>
<Text fw={700} c="edr-text" fz={15}>
{label} Business License
</Text>
<Text size="xs" c="edr-muted" ff="monospace">
{profile.reference}
</Text>
</div>
</Group>
{hasAny && (
<Badge color="edr-green" variant="light">
Provided
</Badge>
)}
</Group>
{profile.existingFiles.length > 0 && (
<Stack gap={4} mb="sm">
{profile.existingFiles.map((f) => (
<Group key={f.url} gap={6} wrap="nowrap">
<Paperclip size={13} className="text-edr-muted" />
<Anchor
href={f.url}
target="_blank"
rel="noopener noreferrer"
size="xs"
>
{f.name}
</Anchor>
</Group>
))}
</Stack>
)}
<FileInput
multiple
clearable
accept="application/pdf,image/png,image/jpeg"
leftSection={<Upload size={16} />}
placeholder={
profile.existingFiles.length > 0
? "Upload more / replace files"
: "Select license file(s)"
}
value={selected}
onChange={(files) => setFiles(profile.id, files ?? [])}
/>
</Card>
);
})}
</Stack>
);
}

View File

@@ -0,0 +1,82 @@
/* Align react-phone-number-input with the portal's Mantine field styling:
44px height, 10px radius, edr border, brand-green focus ring. */
.edr-phone-wrapper .PhoneInput {
display: flex;
align-items: stretch;
gap: 8px;
}
/* Country selector — a compact pill matching the input height/radius. */
.edr-phone-wrapper .PhoneInputCountry {
margin: 0;
padding: 0 10px;
height: 44px;
border: 1px solid #e6ecf2;
border-radius: 10px;
background: #fff;
display: flex;
align-items: center;
gap: 6px;
transition:
border-color 120ms ease,
box-shadow 120ms ease;
}
.edr-phone-wrapper .PhoneInputCountryIcon {
width: 22px;
height: 16px;
box-shadow: none;
}
.edr-phone-wrapper .PhoneInputCountrySelectArrow {
color: #6b7c8e;
opacity: 0.8;
}
/* The number input itself. */
.edr-phone-input {
flex: 1;
min-width: 0;
height: 44px;
padding: 0 12px;
border: 1px solid #e6ecf2;
border-radius: 10px;
font-size: 14px;
color: #10202f;
background: #fff;
outline: none;
transition:
border-color 120ms ease,
box-shadow 120ms ease;
}
.edr-phone-input::placeholder {
color: #9aa8b5;
}
.edr-phone-input:focus {
border-color: #0ea371;
box-shadow: 0 0 0 3px rgba(14, 163, 113, 0.15);
}
.edr-phone-wrapper .PhoneInputCountry:focus-within {
border-color: #0ea371;
box-shadow: 0 0 0 3px rgba(14, 163, 113, 0.15);
}
.edr-phone-input:disabled,
.edr-phone-wrapper .PhoneInputCountrySelect:disabled + .PhoneInputCountryIcon {
opacity: 0.6;
cursor: not-allowed;
}
/* Error state mirrors Mantine's invalid styling. */
.edr-phone-wrapper--error .edr-phone-input,
.edr-phone-wrapper--error .PhoneInputCountry {
border-color: #e03131;
}
.edr-phone-wrapper--error .edr-phone-input:focus {
box-shadow: 0 0 0 3px rgba(224, 49, 49, 0.12);
}

View File

@@ -84,8 +84,16 @@ export const URL_CONSTANTS = {
CREATE: "/api/companies/create",
PROFILE: "/api/companies/profile",
COMPANY_PROFILES: "/api/companies/company-profiles",
COMPANY_PROFILE: "/api/companies/company-profile",
ACTIVE_MODE: "/api/companies/active-mode",
ONBOARDING_START: "/api/companies/onboarding/start",
ONBOARDING_STEP: "/api/companies/onboarding-step",
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
DASHBOARD: "/api/companies/dashboard",
FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info",
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
PROFILE_LICENSE: (profileId: string) =>
`/api/companies/company-profiles/${profileId}/license`,
},
BOOKINGS: {

View File

@@ -0,0 +1,30 @@
/**
* Operational-mode (importer/exporter/…) labels and helpers, shared by the app
* header and the per-page mode indicator so there is a single source of truth.
*/
export const PROFILE_TYPE_LABELS: Record<string, string> = {
importer: "Importer",
exporter: "Exporter",
freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter",
};
/** The data-scope label shown to the user (importer ⇒ "Import", exporter ⇒ "Export"). */
export function modeDataLabel(
activeProfileType?: string | null,
): string | null {
if (activeProfileType === "importer") return "Import";
if (activeProfileType === "exporter") return "Export";
return null;
}
/** Short helper sentence describing what the active mode scopes. */
export function modeDataDescription(
activeProfileType?: string | null,
): string {
const label = modeDataLabel(activeProfileType);
if (!label) return "";
return `Showing your ${label.toLowerCase()} data — switch in the header.`;
}

View File

@@ -1,4 +1,6 @@
import { api } from "@/services/api";
import type { ProfileTypeValue } from "@/services/companies.service";
import { companiesService } from "@/services/companies.service";
import type {
LoginPayload,
LoginResponse,
@@ -149,6 +151,57 @@ const useAuth = () => {
}
};
// Active-mode (importer/exporter) state, sourced from the persisted profile.
const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null;
const activeProfileType = companyInfo?.profile?.activeProfileType ?? null;
const activeCompanyProfileId =
companyInfo?.profile?.activeCompanyProfileId ?? null;
const companyType = companyInfo?.company?.type ?? null;
const onboardingCompleted =
companyInfo?.profile?.onboardingCompleted ?? false;
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
/** Refetch everything scoped to the active operational profile. */
const invalidateScopedData = async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
}),
queryClient.invalidateQueries({
queryKey: api.companies.getDashboard.queryKey(),
}),
queryClient.invalidateQueries({ queryKey: ["bookings"] }),
]);
};
const switchMode = async (
type: ProfileTypeValue,
): Promise<Result<void>> => {
try {
await api.companies.setActiveMode.call({ type });
await invalidateScopedData();
return { success: true, data: undefined };
} catch (err) {
return { success: false, error: extractApiError(err) };
}
};
const createProfileAndSwitch = async (
type: ProfileTypeValue,
licenseFiles: File[],
): Promise<Result<void>> => {
try {
const created = await api.companies.createCompanyProfile.call({ type });
if (licenseFiles.length > 0) {
await companiesService.uploadProfileLicense(created.id, licenseFiles);
}
await invalidateScopedData();
return { success: true, data: undefined };
} catch (err) {
return { success: false, error: extractApiError(err) };
}
};
const logout = async () => {
try {
await api.auth.logout.call();
@@ -174,6 +227,13 @@ const useAuth = () => {
user: isAuthenticated ? (authQuery.data ?? null) : null,
company: isAuthenticated ? (companyQuery.data ?? null) : null,
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
activeProfileType,
activeCompanyProfileId,
companyType,
onboardingCompleted,
onboardingStep,
switchMode,
createProfileAndSwitch,
login,
signup,
setPassword,

View File

@@ -0,0 +1,16 @@
import { useMutation } from "@tanstack/react-query";
import { companiesService } from "@/services/companies.service";
import { extractApiError } from "@/utils/result";
import type { CompanyRegistrationData } from "@edr/types";
export function useETradeData() {
return useMutation({
mutationFn: async (tin: string): Promise<CompanyRegistrationData> => {
return companiesService.fetchETradeInfo({ tin });
},
onError: (error) => {
const { message } = extractApiError(error);
console.error("eTrade fetch error:", message);
},
});
}

View File

@@ -2,6 +2,7 @@ import { Box, Group, Text } from "@mantine/core";
import { ArrowRight, Truck } from "lucide-react";
import { memo } from "react";
import { Link } from "react-router-dom";
import { ModeIndicator } from "@/components/ModeIndicator";
import { cv } from "../constants";
interface HelloSectionProps {
@@ -19,9 +20,12 @@ export const HelloSection = memo(function HelloSection({
<Text size="sm" c="edr-muted">
{greeting}
</Text>
<Text fz={26} fw={800} mt={2} c="edr-text" className="tracking-tight">
{companyName} 👋
</Text>
<Group gap={12} align="center" mt={2} wrap="wrap">
<Text fz={26} fw={800} c="edr-text" className="tracking-tight">
{companyName} 👋
</Text>
<ModeIndicator />
</Group>
</Box>
<Link to="/bookings/new">

View File

@@ -1,410 +0,0 @@
import { api } from "@/services/api";
import {
Badge,
Box,
Button,
Card,
Center,
Container,
Divider,
Grid,
Group,
Loader,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
BadgeCheck,
Briefcase,
Building,
Building2,
FileCheck,
Globe,
Mail,
MapPin,
Phone,
Plus,
ShieldCheck,
User,
UserCheck,
} from "lucide-react";
import { Link } from "react-router-dom";
import { rolesForCompanyType } from "./settings/companyRoles";
function InfoItem({
icon,
label,
value,
}: {
icon?: React.ReactNode;
label: string;
value?: string | null;
}) {
return (
<Group gap="sm" align="flex-start" wrap="nowrap">
{icon && (
<ThemeIcon variant="light" color="edr-green" size="md" radius="md">
{icon}
</ThemeIcon>
)}
<Stack gap={2}>
<Text size="xs" fw={700} tt="uppercase" c="edr-muted">
{label}
</Text>
<Text size="sm" fw={600} c="edr-text">
{value || "—"}
</Text>
</Stack>
</Group>
);
}
function CardHeading({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) {
return (
<Stack gap={2} mb="md">
<Group gap="sm">
{icon}
<Title order={4} size="h5">
{title}
</Title>
</Group>
<Text size="sm" c="edr-muted">
{description}
</Text>
</Stack>
);
}
function PersonnelGroup({
color,
title,
children,
}: {
color: string;
title: string;
children: React.ReactNode;
}) {
return (
<Stack gap="sm">
<Group gap="xs">
<Box w={4} h={16} bg={color} style={{ borderRadius: 2 }} />
<Text size="sm" fw={700} tt="uppercase" c="edr-text">
{title}
</Text>
</Group>
<Stack gap="sm" pl="lg">
{children}
</Stack>
</Stack>
);
}
export default function ProfilePage() {
const { data: profile, isPending } = useQuery(
api.companies.getProfile.queryOptions(),
);
if (isPending) {
return (
<Center h="100%">
<Loader color="edr-green" size="lg" />
</Center>
);
}
if (!profile) {
return (
<Center h="100%">
<Text c="edr-muted">No company profile found.</Text>
</Center>
);
}
// Registered operational profiles keyed by type, plus the roles this company
// type may hold (importer/exporter for a customer). Mirrors CompanyRolesCard.
const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p]));
const roleOptions = rolesForCompanyType(profile.companyType);
const activeOptions = roleOptions.filter((o) => refByType.has(o.type));
return (
<Container size="xl" px="lg" py="xl">
{/* Header */}
<Group gap="lg" align="center" mb="lg">
<ThemeIcon variant="light" color="edr-green" size={88} radius="lg">
<User size={44} />
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center">
<Title order={1} size="h2">
{profile.companyName}
</Title>
<Badge color="edr-green" variant="light">
Verified
</Badge>
</Group>
{activeOptions.length > 0 ? (
<Group gap="xs">
{activeOptions.map((opt) => (
<Badge
key={opt.type}
variant="light"
color="edr-green"
size="lg"
radius="sm"
>
{opt.label} · {refByType.get(opt.type)!.reference}
</Badge>
))}
</Group>
) : (
<Group gap={6} c="edr-muted">
<Building size={16} />
<Text c="edr-muted" fw={500}>
{profile.companyType}
</Text>
</Group>
)}
</Stack>
</Group>
<Divider mb="lg" />
<Grid gap="lg">
{/* Left Column */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
{/* Company Details */}
<Card>
<CardHeading
icon={
<Building2
size={20}
color="var(--mantine-color-edr-green-6)"
/>
}
title="Company Details"
description="Business registration information"
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoItem
icon={<Globe size={16} />}
label="Location"
value={profile.companyLocation}
/>
<InfoItem
icon={<MapPin size={16} />}
label="Address"
value={profile.companyAddress}
/>
<InfoItem
icon={<FileCheck size={16} />}
label="TIN Number"
value={profile.tinNumber}
/>
<InfoItem
icon={<ShieldCheck size={16} />}
label="FAN Number"
value={profile.fanNumber}
/>
<InfoItem
icon={<Mail size={16} />}
label="Email"
value={profile.companyEmail}
/>
<InfoItem
icon={<Phone size={16} />}
label="Phone"
value={profile.companyPhone}
/>
</SimpleGrid>
</Card>
{/* Key Personnel */}
<Card>
<CardHeading
icon={
<Briefcase
size={20}
color="var(--mantine-color-edr-green-6)"
/>
}
title="Key Personnel"
description="Management and contact persons"
/>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
<PersonnelGroup color="edr-green" title="Contact Person">
<InfoItem label="Name" value={profile.contactPersonName} />
<InfoItem label="Phone" value={profile.contactPersonPhone} />
</PersonnelGroup>
<PersonnelGroup color="edr-accent" title="General Manager">
<InfoItem label="Name" value={profile.generalManagerName} />
<InfoItem label="Email" value={profile.generalManagerEmail} />
<InfoItem label="Phone" value={profile.generalManagerPhone} />
</PersonnelGroup>
</SimpleGrid>
</Card>
{/* Power of Attorney */}
{profile.poaName && (
<Card style={{ borderStyle: "dashed" }}>
<CardHeading
icon={
<UserCheck
size={20}
color="var(--mantine-color-edr-accent-6)"
/>
}
title="Power of Attorney"
description="Authorized representative details"
/>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<InfoItem label="PoA Name" value={profile.poaName} />
<InfoItem label="PoA Email" value={profile.poaEmail} />
<InfoItem label="PoA Phone" value={profile.poaPhone} />
<InfoItem label="PoA Location" value={profile.poaLocation} />
</SimpleGrid>
</Card>
)}
</Stack>
</Grid.Col>
{/* Right Column */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
{/* Operating Roles */}
<Card>
<CardHeading
icon={
<BadgeCheck
size={20}
color="var(--mantine-color-edr-green-6)"
/>
}
title="Operating Roles"
description="Your registered freight roles and reference numbers"
/>
{roleOptions.length === 0 ? (
<Text size="sm" c="edr-muted">
Role management for this company type is coming soon.
</Text>
) : (
<Stack gap="md">
{roleOptions.map((opt) => {
const active = refByType.get(opt.type);
return (
<Group
key={opt.type}
justify="space-between"
wrap="nowrap"
align="center"
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon
variant="light"
color="edr-green"
size="lg"
radius="md"
>
{opt.icon}
</ThemeIcon>
<Stack gap={2}>
<Text size="sm" fw={600} c="edr-text">
{opt.label}
</Text>
<Text
size="xs"
c="edr-muted"
ff={active ? "monospace" : undefined}
>
{active ? active.reference : "Not registered"}
</Text>
</Stack>
</Group>
{active ? (
<Badge
variant="light"
color={
active.status === "active"
? "edr-green"
: "edr-accent"
}
tt="capitalize"
>
{active.status}
</Badge>
) : (
<Button
component={Link}
to="/settings?tab=company"
size="xs"
variant="light"
color="edr-green"
leftSection={<Plus size={14} />}
>
Add {opt.label}
</Button>
)}
</Group>
);
})}
</Stack>
)}
</Card>
{/* Secure Account */}
<Card
padding="xl"
style={{
background: "var(--mantine-color-edr-ink-6)",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
top: 16,
right: 16,
opacity: 0.1,
}}
>
<ShieldCheck size={128} color="white" />
</Box>
<Stack gap="md" style={{ position: "relative", zIndex: 1 }}>
<Title order={3} size="h4" c="white">
Secure Account
</Title>
<Text size="sm" c="gray.4">
Your information is protected by enterprise-grade security.
Contact support for verified information updates.
</Text>
<Button
component={Link}
to="/settings"
variant="white"
color="dark"
mt="xs"
w="fit-content"
>
Edit Settings
</Button>
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
</Container>
);
}

View File

@@ -1,27 +1,34 @@
import { api } from "@/services/api";
import type { ProfileResponse } from "@/types/profile";
import {
Alert,
Badge,
Box,
Card,
Center,
Container,
Group,
Loader,
Stack,
Tabs,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
BadgeCheck,
Briefcase,
Building2,
FileCheck,
Globe,
ShieldCheck,
User,
UserCheck,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useCallback, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { rolesForCompanyType } from "./settings/companyRoles";
import TabCompanyProfile from "./settings/TabCompanyProfile";
import TabContactPerson from "./settings/TabContactPerson";
import TabDocuments from "./settings/TabDocuments";
@@ -30,35 +37,139 @@ import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents";
function tabIncomplete(tabId: SettingsTab, profile?: ProfileResponse | null): boolean {
if (!profile) return false;
/** A section is "incomplete" when its required fields aren't filled in yet. */
function tabIncomplete(
tabId: SettingsTab,
profile: ProfileResponse,
): boolean {
switch (tabId) {
case "company":
return !profile.companyEmail || !profile.companyPhone || !profile.companyAddress || !profile.fanNumber;
return (
!profile.companyEmail ||
!profile.companyPhone ||
!profile.companyAddress ||
!profile.fanNumber
);
case "contact":
return !profile.contactPersonName || !profile.contactPersonPhone;
case "gm":
return !profile.generalManagerName || !profile.generalManagerEmail || !profile.generalManagerPhone;
return (
!profile.generalManagerName ||
!profile.generalManagerEmail ||
!profile.generalManagerPhone
);
case "poa":
return false;
case "documents":
return false;
}
}
const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: "company", label: "Company Profile", icon: <Building2 size={16} /> },
{ id: "company", label: "Company", icon: <Building2 size={16} /> },
{ id: "contact", label: "Contact Person", icon: <User size={16} /> },
{ id: "gm", label: "General Manager", icon: <Briefcase size={16} /> },
{ id: "poa", label: "Power of Attorney", icon: <UserCheck size={16} /> },
{ id: "documents", label: "Documents", icon: <FileCheck size={16} /> },
];
/**
* Polished identity banner shown above the editor tabs — company name, its
* registered operating roles, location and verification status at a glance.
*/
function ProfileHeader({ profile }: { profile: ProfileResponse }) {
const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p]));
const roleOptions = rolesForCompanyType(profile.companyType);
const activeRoles = roleOptions.filter((o) => refByType.has(o.type));
return (
<Card
padding="xl"
radius="lg"
style={{
background:
"linear-gradient(135deg, var(--mantine-color-edr-ink-6) 0%, var(--mantine-color-edr-ink-8, var(--mantine-color-edr-ink-6)) 100%)",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{ position: "absolute", top: -24, right: -16, opacity: 0.08 }}
>
<Building2 size={180} color="white" />
</Box>
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
style={{ position: "relative", zIndex: 1 }}
>
<Group gap="lg" align="center" wrap="nowrap">
<ThemeIcon variant="white" color="edr-green" size={72} radius="lg">
<Building2 size={36} />
</ThemeIcon>
<Stack gap={8}>
<Group gap="sm" align="center">
<Title order={1} size="h2" c="white">
{profile.companyName}
</Title>
<Badge
color="edr-green"
variant="filled"
leftSection={<BadgeCheck size={13} />}
>
Verified
</Badge>
</Group>
{activeRoles.length > 0 ? (
<Group gap="xs">
{activeRoles.map((opt) => (
<Badge
key={opt.type}
variant="white"
color="edr-ink"
radius="sm"
size="lg"
>
{opt.label} · {refByType.get(opt.type)!.reference}
</Badge>
))}
</Group>
) : (
<Text c="gray.4" fw={500} tt="capitalize">
{profile.companyType.replace(/_/g, " ")}
</Text>
)}
<Group gap="lg" mt={4}>
{profile.companyLocation && (
<Group gap={6} c="gray.4">
<Globe size={15} />
<Text size="sm">{profile.companyLocation}</Text>
</Group>
)}
{profile.tinNumber && (
<Group gap={6} c="gray.4">
<ShieldCheck size={15} />
<Text size="sm" ff="monospace">
TIN {profile.tinNumber}
</Text>
</Group>
)}
</Group>
</Stack>
</Group>
</Group>
</Card>
);
}
export default function SettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const tab = (searchParams.get("tab") as SettingsTab) || "company";
const setTab = useCallback(
(t: SettingsTab) => {
setSearchParams(
@@ -79,9 +190,10 @@ export default function SettingsPage() {
refetchOnWindowFocus: false,
}),
);
const profile = profileQuery.data;
// Keep the cached company info in sync whenever the profile changes, so the
// header (and the rest of the app) reflect edits immediately.
useEffect(() => {
if (profileQuery.dataUpdatedAt > 0) {
queryClient.invalidateQueries({
@@ -90,34 +202,6 @@ export default function SettingsPage() {
}
}, [profileQuery.dataUpdatedAt, queryClient]);
const [isOnboarding, setIsOnboarding] = useState<boolean | null>(null);
useEffect(() => {
if (profileQuery.isFetched && isOnboarding === null) {
setIsOnboarding(!profileQuery.data);
}
}, [profileQuery.isFetched, profileQuery.data, isOnboarding]);
const handleOnboardingSuccess = useCallback(() => {
setTab("contact");
}, [setTab]);
const handleContactContinue = useCallback(() => {
setTab("gm");
}, [setTab]);
const handleGMContinue = useCallback(() => {
setTab("poa");
}, [setTab]);
const handlePOAContinue = useCallback(() => {
setTab("documents");
}, [setTab]);
const handleDocumentsContinue = useCallback(() => {
navigate("/portal");
}, [navigate]);
if (profileQuery.isPending) {
return (
<Center h="100%">
@@ -126,126 +210,82 @@ export default function SettingsPage() {
);
}
const onboarding = isOnboarding === true;
const renderProfileContent = (children: React.ReactNode) => {
if (onboarding && tab !== "company" && !profile) {
return (
<Center h={200}>
<Loader color="edr-green" />
</Center>
);
}
if (!profile) {
return (
<Card padding="xl">
if (!profile) {
return (
<Container size="xl" px="lg" py="xl">
<Card padding="xl" radius="lg">
<Center>
<Alert
icon={<AlertCircle size={24} />}
color="gray"
variant="light"
>
Please complete the company profile first.
</Alert>
<Group gap="sm" c="edr-muted">
<AlertCircle size={20} />
<Text>No company profile found.</Text>
</Group>
</Center>
</Card>
);
}
return children;
};
</Container>
);
}
return (
<Container size="xl" px="lg">
<Group justify="space-between" mb="xl">
<Container size="xl" px="lg" py="xl">
<Stack gap="xl">
<ProfileHeader profile={profile} />
<div>
<Title order={1} size="h2">
{onboarding ? "Complete Your Profile" : "Account Settings"}
<Title order={2} size="h3">
Account Settings
</Title>
<Text c="edr-muted" size="sm" mt={4}>
{onboarding
? "Set up your company profile, personnel, and documents to get started"
: "Manage your company profile, personnel, and documents"}
Manage your company profile, personnel, and documents.
</Text>
</div>
</Group>
<Tabs
value={tab}
onChange={(value) => {
if (!value) return;
// if (onboarding) return;
setTab(value as SettingsTab);
}}
>
<Tabs.List mb="md">
{TABS.map((t) => (
<Tabs.Tab
key={t.id}
value={t.id}
leftSection={t.icon}
disabled={!onboarding && !profile && t.id !== "company"}
rightSection={
!onboarding && profile && tabIncomplete(t.id, profile) ? (
<AlertCircle size={14} color="red" />
) : undefined
}
>
{t.label}
</Tabs.Tab>
))}
</Tabs.List>
<Tabs
value={tab}
onChange={(value) => value && setTab(value as SettingsTab)}
variant="pills"
radius="md"
>
<Tabs.List mb="lg">
{TABS.map((t) => (
<Tabs.Tab
key={t.id}
value={t.id}
leftSection={t.icon}
rightSection={
tabIncomplete(t.id, profile) ? (
<Box
w={7}
h={7}
style={{
borderRadius: "50%",
background: "var(--mantine-color-red-6)",
}}
/>
) : undefined
}
>
{t.label}
</Tabs.Tab>
))}
</Tabs.List>
<Tabs.Panel value="company">
{!profile ? (
<TabCompanyProfile
mode="create"
onCreateSuccess={handleOnboardingSuccess}
/>
) : (
<Tabs.Panel value="company">
<TabCompanyProfile mode="edit" profile={profile} />
)}
</Tabs.Panel>
<Tabs.Panel value="contact">
{renderProfileContent(
<TabContactPerson
profile={profile!}
mode={onboarding ? "onboarding" : "edit"}
onContinue={handleContactContinue}
/>,
)}
</Tabs.Panel>
<Tabs.Panel value="gm">
{renderProfileContent(
<TabGeneralManager
profile={profile!}
mode={onboarding ? "onboarding" : "edit"}
onContinue={handleGMContinue}
/>,
)}
</Tabs.Panel>
<Tabs.Panel value="poa">
{renderProfileContent(
<TabPowerOfAttorney
profile={profile!}
mode={onboarding ? "onboarding" : "edit"}
onContinue={handlePOAContinue}
/>,
)}
</Tabs.Panel>
<Tabs.Panel value="documents">
{renderProfileContent(
<TabDocuments
profile={profile!}
mode={onboarding ? "onboarding" : "edit"}
onContinue={handleDocumentsContinue}
/>,
)}
</Tabs.Panel>
</Tabs>
</Tabs.Panel>
<Tabs.Panel value="contact">
<TabContactPerson profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="gm">
<TabGeneralManager profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="poa">
<TabPowerOfAttorney profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="documents">
<TabDocuments profile={profile} mode="edit" />
</Tabs.Panel>
</Tabs>
</Stack>
</Container>
);
}

View File

@@ -16,7 +16,7 @@ import { z } from "zod";
import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
import PhoneInput from "@/components/auth/PhoneInput";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { SmartFileInput } from "@edr/ui-common";
import { api } from "@/services/api";
@@ -25,21 +25,25 @@ type DjiboutiStep = "company" | "representative" | "documents" | "confirm";
const djiboutiSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"),
companyPhoneCountryCode: z.string().min(1, "Country code is required"),
companyPhone: z
.string()
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z.string().min(1, "Location / Country is required"),
companyAddress: z.string().min(1, "Address is required"),
repName: z.string().min(1, "Representative name is required"),
repEmail: z.string().email("Invalid representative email"),
repPhone: z.string().min(1, "Representative phone is required"),
repPhoneCountryCode: z.string().min(1, "Country code is required"),
repPhone: z
.string()
.min(1, "Representative phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
});
type FormData = z.infer<typeof djiboutiSchema>;
const stepFields: Record<DjiboutiStep, (keyof FormData)[]> = {
company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress"],
representative: ["repName", "repEmail", "repPhone", "repPhoneCountryCode"],
company: ["companyName", "companyEmail", "companyPhone", "companyLocation", "companyAddress"],
representative: ["repName", "repEmail", "repPhone"],
documents: [],
confirm: [],
};
@@ -48,7 +52,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
return {
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
companyPhone: data.companyPhone,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
tin: "",
@@ -57,7 +61,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
attributes: {
repName: data.repName,
repEmail: data.repEmail,
repPhone: `${data.repPhoneCountryCode}${data.repPhone}`,
repPhone: data.repPhone,
},
};
}
@@ -88,11 +92,11 @@ export default function DjiboutiAgentForm({
api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }),
);
const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
const { register, control, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(djiboutiSchema),
defaultValues: {
companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+253",
companyLocation: "", companyAddress: "", repName: "", repEmail: "", repPhone: "", repPhoneCountryCode: "+253",
companyName: "", companyEmail: "", companyPhone: "",
companyLocation: "", companyAddress: "", repName: "", repEmail: "", repPhone: "",
},
});
@@ -195,12 +199,11 @@ export default function DjiboutiAgentForm({
error={errors.companyEmail?.message}
{...register("companyEmail")}
/>
<PhoneInput
countryCode={{ ...register("companyPhoneCountryCode") }}
phone={{ ...register("companyPhone"), placeholder: "12345678" }}
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
<ControlledPhoneField
control={control}
name="companyPhone"
label="Company Phone"
required
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
@@ -239,12 +242,11 @@ export default function DjiboutiAgentForm({
error={errors.repEmail?.message}
{...register("repEmail")}
/>
<PhoneInput
countryCode={{ ...register("repPhoneCountryCode") }}
phone={{ ...register("repPhone"), placeholder: "12345678" }}
countryCodeError={errors.repPhoneCountryCode}
phoneError={errors.repPhone}
<ControlledPhoneField
control={control}
name="repPhone"
label="Representative Phone"
required
/>
</SimpleGrid>
</>
@@ -280,7 +282,7 @@ export default function DjiboutiAgentForm({
<ReviewRow label="Address" value={formValues.companyAddress} />
<ReviewRow label="Rep. name" value={formValues.repName} />
<ReviewRow label="Rep. email" value={formValues.repEmail} />
<ReviewRow label="Rep. phone" value={`${formValues.repPhoneCountryCode}${formValues.repPhone}`} />
<ReviewRow label="Rep. phone" value={formValues.repPhone} />
</SimpleGrid>
</Box>
)}

View File

@@ -1,7 +1,8 @@
import { Box, Button, Divider, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
import { Alert, Box, Button, Divider, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
ArrowLeft,
ArrowRight,
Building2,
@@ -11,38 +12,50 @@ import {
UploadCloud,
User,
} from "lucide-react";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
import PhoneInput from "@/components/auth/PhoneInput";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { SmartFileInput } from "@edr/ui-common";
import { api } from "@/services/api";
import RoleLicenseStep, {
type RoleLicenseProfile,
} from "@/components/onboarding/RoleLicenseStep";
type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "confirm";
type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "additional";
const forwarderSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"),
companyPhoneCountryCode: z.string().min(1, "Country code is required"),
companyPhone: z
.string()
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
vatNumber: z.string().min(1, "VAT number is required").length(10, "VAT number must be exactly 10 digits"),
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
contactPersonName: z.string().min(1, "Contact person name is required"),
contactPersonPhone: z.string().min(1, "Contact person phone is required"),
contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"),
contactPersonPhone: z
.string()
.min(1, "Contact person phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
generalManagerName: z.string().min(1, "GM name is required"),
generalManagerEmail: z.string().email("Invalid GM email"),
generalManagerPhone: z.string().min(1, "GM phone is required"),
generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"),
generalManagerPhone: z
.string()
.min(1, "GM phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
poaName: z.string().optional(),
poaPhone: z.string().optional(),
poaPhoneCountryCode: z.string().optional(),
poaPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaAddress: z.string().optional(),
poaEmail: z.string().optional(),
poaLocation: z.string().optional(),
@@ -51,18 +64,18 @@ const forwarderSchema = z.object({
type FormData = z.infer<typeof forwarderSchema>;
const stepFields: Record<ForwarderStep, (keyof FormData)[]> = {
company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"],
personnel: ["contactPersonName", "contactPersonPhone", "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", "generalManagerPhoneCountryCode"],
company: ["companyName", "companyEmail", "companyPhone", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"],
personnel: ["contactPersonName", "contactPersonPhone", "generalManagerName", "generalManagerEmail", "generalManagerPhone"],
poa: [],
documents: [],
confirm: [],
additional: [],
};
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
return {
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
companyPhone: data.companyPhone,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
tin: data.tinNumber,
@@ -70,12 +83,12 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
fanNumber: data.fanNumber,
attributes: {
contactPersonName: data.contactPersonName,
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
contactPersonPhone: data.contactPersonPhone,
generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
generalManagerPhone: data.generalManagerPhone,
poaName: data.poaName || undefined,
poaPhone: data.poaPhone && data.poaPhoneCountryCode ? `${data.poaPhoneCountryCode}${data.poaPhone}` : undefined,
poaPhone: data.poaPhone || undefined,
poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined,
@@ -83,6 +96,66 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
};
}
/** Map one wizard step's form values to the profile-update payload it saves. */
function stepPayload(step: ForwarderStep, d: FormData): Partial<UpdateProfilePayload> {
switch (step) {
case "company":
return {
companyName: d.companyName,
companyEmail: d.companyEmail,
companyPhone: d.companyPhone,
companyLocation: d.companyLocation,
companyAddress: d.companyAddress,
tin: d.tinNumber,
vatNumber: d.vatNumber,
fanNumber: d.fanNumber,
};
case "personnel":
return {
contactPersonName: d.contactPersonName,
contactPersonPhone: d.contactPersonPhone,
generalManagerName: d.generalManagerName,
generalManagerEmail: d.generalManagerEmail,
generalManagerPhone: d.generalManagerPhone,
};
case "poa":
return {
poaName: d.poaName || undefined,
poaPhone: d.poaPhone || undefined,
poaEmail: d.poaEmail || undefined,
poaLocation: d.poaLocation || undefined,
poaAddress: d.poaAddress || undefined,
};
default:
return {};
}
}
/** Seed the form from previously-saved profile data. */
function toFormValues(p: ProfileResponse): FormData {
const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : "";
return {
companyName: p.companyName ?? "",
companyEmail: p.companyEmail ?? "",
companyPhone: p.companyPhone ?? "",
companyLocation: p.companyLocation ?? "",
companyAddress: p.companyAddress ?? "",
tinNumber: tin,
vatNumber: p.vatNumber ?? "",
fanNumber: p.fanNumber ?? "",
contactPersonName: p.contactPersonName ?? "",
contactPersonPhone: p.contactPersonPhone ?? "",
generalManagerName: p.generalManagerName ?? "",
generalManagerEmail: p.generalManagerEmail ?? "",
generalManagerPhone: p.generalManagerPhone ?? "",
poaName: p.poaName ?? "",
poaPhone: p.poaPhone ?? "",
poaAddress: p.poaAddress ?? "",
poaEmail: p.poaEmail ?? "",
poaLocation: p.poaLocation ?? "",
};
}
export default function ForwarderForm({
documentSettingCode,
documentFiles: controlledFiles,
@@ -91,6 +164,15 @@ export default function ForwarderForm({
onSubmit,
isPending,
onBack,
initialStep,
resyncOpen,
hideFirstStepBack,
onStepChange,
onSaveStep,
rehydrate,
roleProfiles,
licenseFiles,
onLicenseChange,
}: {
documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>;
@@ -99,8 +181,46 @@ export default function ForwarderForm({
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
/** Step to resume at (defaults to "company"). */
initialStep?: ForwarderStep;
/** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */
resyncOpen?: boolean;
/** Hide the Back button on the first step (onboarding can't go back to role pick). */
hideFirstStepBack?: boolean;
/** Reports the active step so the parent can persist resume progress. */
onStepChange?: (step: ForwarderStep) => void;
/** Persist the current step's data before advancing; returns an error to show. */
onSaveStep?: (
data: Partial<UpdateProfilePayload>,
) => Promise<{ ok: true } | { ok: false; error: string }>;
/** Saved profile to seed the form with (rehydration after refresh). */
rehydrate?: ProfileResponse | null;
/** Operational profiles for the final per-role license step. */
roleProfiles?: RoleLicenseProfile[];
/** Newly-selected license files per profile id. */
licenseFiles?: Record<string, File[]>;
onLicenseChange?: (value: Record<string, File[]>) => void;
}) {
const [step, setStep] = useState<ForwarderStep>("company");
const [step, setStep] = useState<ForwarderStep>(initialStep ?? "company");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// Report each step change up so the wizard can persist it for resume.
useEffect(() => {
onStepChange?.(step);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
// On reopen, jump to the furthest step reached so progress never resets.
const wasOpen = useRef(resyncOpen);
useEffect(() => {
if (resyncOpen && !wasOpen.current && initialStep) {
setStep(initialStep);
setSaveError(null);
}
wasOpen.current = resyncOpen;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resyncOpen]);
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
const documentFiles = controlledFiles ?? internalFiles;
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
@@ -109,33 +229,68 @@ export default function ForwarderForm({
api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }),
);
const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
const { register, control, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(forwarderSchema),
defaultValues: {
companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+251",
companyName: "", companyEmail: "", companyPhone: "",
companyLocation: "", companyAddress: "", tinNumber: "", vatNumber: "", fanNumber: "",
contactPersonName: "", contactPersonPhone: "", contactPersonPhoneCountryCode: "+251",
generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", generalManagerPhoneCountryCode: "+251",
poaName: "", poaPhone: "", poaPhoneCountryCode: "+251", poaAddress: "", poaEmail: "", poaLocation: "",
contactPersonName: "", contactPersonPhone: "",
generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "",
poaName: "", poaPhone: "", poaAddress: "", poaEmail: "", poaLocation: "",
},
// Rehydrate from previously-saved data (RHF re-syncs when `values` change).
values: rehydrate ? toFormValues(rehydrate) : undefined,
});
const formValues = watch();
const hasDocuments = Boolean(uploadSetting?.fields?.length);
const totalSteps = 5;
const nextStep = async () => {
if (step === "poa") { setStep("documents"); return; }
if (step === "documents") { setStep("confirm"); return; }
if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; }
/** Validate + persist the current step, returning whether we may advance. */
const saveCurrentStep = async (): Promise<boolean> => {
setSaveError(null);
const isValid = await trigger(stepFields[step]);
if (!isValid) return;
setStep(step === "company" ? "personnel" : "poa");
if (!isValid) return false;
if (!onSaveStep) return true;
setSaving(true);
try {
const res = await onSaveStep(stepPayload(step, watch()));
if (!res.ok) {
setSaveError(res.error);
return false;
}
return true;
} finally {
setSaving(false);
}
};
const skipDocuments = () => setStep("confirm");
// Every role needs at least one license file (existing or newly selected).
const licenseComplete = (roleProfiles ?? []).every(
(p) =>
(licenseFiles?.[p.id]?.length ?? 0) > 0 || p.existingFiles.length > 0,
);
const nextStep = async () => {
if (step === "additional") {
if (!licenseComplete) {
setSaveError(
"Please upload a business license for each of your operational profiles.",
);
return;
}
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
if (step === "documents") { setStep("additional"); return; }
const ok = await saveCurrentStep();
if (!ok) return;
setStep(step === "company" ? "personnel" : step === "personnel" ? "poa" : "documents");
};
const skipDocuments = () => setStep("additional");
const prevStep = () => {
setSaveError(null);
if (step === "company") onBack();
else if (step === "personnel") setStep("company");
else if (step === "poa") setStep("personnel");
@@ -143,23 +298,25 @@ export default function ForwarderForm({
else setStep("documents");
};
const showBack = !(hideFirstStepBack && step === "company");
const STEPS: { key: ForwarderStep; icon: React.ReactNode }[] = [
{ key: "company", icon: <Building2 size={18} /> },
{ key: "personnel", icon: <User size={18} /> },
{ key: "poa", icon: <FileText size={18} /> },
{ key: "documents", icon: <UploadCloud size={18} /> },
{ key: "confirm", icon: <CheckCircle2 size={18} /> },
{ key: "additional", icon: <CheckCircle2 size={18} /> },
];
const STEP_LABELS: Record<ForwarderStep, string> = {
company: `Step 1 of ${totalSteps} — Company Information`,
personnel: `Step 2 of ${totalSteps} — Personnel Details`,
poa: `Step 3 of ${totalSteps} — Power of Attorney (Optional)`,
documents: `Step 4 of ${totalSteps} — Upload Documents (Optional)`,
confirm: `Step 5 of ${totalSteps}Review & Confirm`,
documents: `Step 4 of ${totalSteps} — Upload Documents`,
additional: `Step 5 of ${totalSteps}Business License`,
};
const stepOrder: ForwarderStep[] = ["company", "personnel", "poa", "documents", "confirm"];
const stepOrder: ForwarderStep[] = ["company", "personnel", "poa", "documents", "additional"];
const currentIdx = stepOrder.indexOf(step);
return (
@@ -222,12 +379,11 @@ export default function ForwarderForm({
error={errors.companyEmail?.message}
{...register("companyEmail")}
/>
<PhoneInput
countryCode={{ ...register("companyPhoneCountryCode") }}
phone={{ ...register("companyPhone"), placeholder: "912345678" }}
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
<ControlledPhoneField
control={control}
name="companyPhone"
label="Company Phone"
required
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
@@ -280,12 +436,11 @@ export default function ForwarderForm({
error={errors.contactPersonName?.message}
{...register("contactPersonName")}
/>
<PhoneInput
countryCode={{ ...register("contactPersonPhoneCountryCode") }}
phone={{ ...register("contactPersonPhone"), placeholder: "912345678" }}
countryCodeError={errors.contactPersonPhoneCountryCode}
phoneError={errors.contactPersonPhone}
<ControlledPhoneField
control={control}
name="contactPersonPhone"
label="Phone"
required
/>
</SimpleGrid>
@@ -306,12 +461,11 @@ export default function ForwarderForm({
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
<PhoneInput
countryCode={{ ...register("generalManagerPhoneCountryCode") }}
phone={{ ...register("generalManagerPhone"), placeholder: "912345678" }}
countryCodeError={errors.generalManagerPhoneCountryCode}
phoneError={errors.generalManagerPhone}
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone"
required
/>
</SimpleGrid>
</>
@@ -336,11 +490,9 @@ export default function ForwarderForm({
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
<PhoneInput
countryCode={{ ...register("poaPhoneCountryCode") }}
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
countryCodeError={errors.poaPhoneCountryCode}
phoneError={errors.poaPhone}
<ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone"
/>
</SimpleGrid>
@@ -377,52 +529,47 @@ export default function ForwarderForm({
</>
)}
{step === "confirm" && (
<Box p={16} className="rounded-2xl border border-edr-border bg-edr-card">
<Text fw={600} c="edr-text">Review your registration</Text>
<Text size="sm" c="edr-muted" mt={4} mb="md">
Confirm the company details below before saving.
</Text>
<SimpleGrid cols={2} spacing="sm">
<ReviewRow label="Company name" value={formValues.companyName} />
<ReviewRow label="Company email" value={formValues.companyEmail} />
<ReviewRow label="Company phone" value={formValues.companyPhone} />
<ReviewRow label="Location" value={formValues.companyLocation} />
<ReviewRow label="Address" value={formValues.companyAddress} />
<ReviewRow label="TIN" value={formValues.tinNumber} />
<ReviewRow label="VAT" value={formValues.vatNumber} />
<ReviewRow label="FAN" value={formValues.fanNumber} />
<ReviewRow label="Contact person" value={formValues.contactPersonName} />
<ReviewRow label="Contact phone" value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`} />
<ReviewRow label="General manager" value={formValues.generalManagerName} />
<ReviewRow label="GM email" value={formValues.generalManagerEmail} />
<ReviewRow label="GM phone" value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`} />
<ReviewRow label="PoA name" value={formValues.poaName || undefined} />
<ReviewRow label="PoA phone" value={formValues.poaPhone && formValues.poaPhoneCountryCode ? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}` : undefined} />
<ReviewRow label="PoA email" value={formValues.poaEmail || undefined} />
<ReviewRow label="PoA location" value={formValues.poaLocation || undefined} />
</SimpleGrid>
</Box>
{step === "additional" && (
<RoleLicenseStep
profiles={roleProfiles ?? []}
value={licenseFiles ?? {}}
onChange={onLicenseChange ?? (() => {})}
/>
)}
{saveError && (
<Alert
color="red"
variant="light"
icon={<AlertCircle size={18} />}
title={step === "additional" ? "Business license required" : "Couldn't save this step"}
>
{saveError}
</Alert>
)}
<Group justify="space-between" pt="xs">
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
{step === "company" ? "Change Type" : step === "confirm" ? "Back to Documents" : "Back"}
</Button>
{showBack ? (
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
{step === "additional" ? "Back to Documents" : "Back"}
</Button>
) : (
<span />
)}
<Group gap="sm">
{step === "documents" && (
<Button variant="default" onClick={skipDocuments} disabled={isPending}>
<Button variant="default" onClick={skipDocuments} disabled={isPending || saving}>
Skip for now
</Button>
)}
<Button
color="edr-green"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
loading={isPending}
rightSection={!isPending && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
onClick={nextStep}
disabled={isPending || saving || (step === "documents" && !hasDocuments && loadingDocuments)}
loading={isPending || saving}
rightSection={!isPending && !saving && step !== "additional" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
>
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Next Step"}
{step === "documents" ? "Continue" : step === "additional" ? "Finish onboarding" : "Save & Continue"}
</Button>
</Group>
</Group>
@@ -431,16 +578,3 @@ export default function ForwarderForm({
</>
);
}
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<Box p={12} className="rounded-xl bg-edr-bg">
<Text size="xs" fw={600} c="edr-muted" className="uppercase tracking-wide">
{label}
</Text>
<Text size="sm" fw={500} c={value?.trim() ? "edr-text" : "edr-muted"} mt={4}>
{value?.trim() ? value : "Not provided"}
</Text>
</Box>
);
}

View File

@@ -1,9 +1,12 @@
import { type FormEvent, useState } from "react";
import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import RPNInput from "react-phone-number-input";
import "react-phone-number-input/style.css";
import useAuth from "@/hooks/useAuth";
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
import "@/components/phone-field.css";
const EDR_LOGO = "/assets/edr-logo.png";
@@ -25,7 +28,6 @@ export default function LoginPage() {
const { login } = useAuth();
const [method, setMethod] = useState<LoginMethod>("email");
const [identifier, setIdentifier] = useState("");
const [countryCode] = useState("+251");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -38,11 +40,9 @@ export default function LoginPage() {
setError(null);
setLoading(true);
try {
const loginId =
method === "email"
? identifier
: `${countryCode}${identifier.startsWith("0") ? identifier.slice(1) : identifier}`;
const result = await login({ email: loginId, password });
// In phone mode the identifier is already a canonical E.164 string
// (e.g. +251912345678) from the phone field; email mode passes through.
const result = await login({ email: identifier, password });
if (result.success) {
const from = (location.state as { from?: { pathname: string } } | null)?.from
?.pathname;
@@ -79,7 +79,10 @@ export default function LoginPage() {
<div className="relative">
<select
value={method}
onChange={(event) => setMethod(event.target.value as LoginMethod)}
onChange={(event) => {
setMethod(event.target.value as LoginMethod);
setIdentifier("");
}}
disabled={loading}
className={`${fieldClass} appearance-none pr-10`}
>
@@ -97,13 +100,29 @@ export default function LoginPage() {
<label className="text-sm font-medium text-gray-800">
{currentMethod.label} <span className="text-red-500">*</span>
</label>
<input
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
placeholder={currentMethod.placeholder}
disabled={loading}
className={fieldClass}
/>
{method === "phone" ? (
<div className="edr-phone-wrapper">
<RPNInput
international
defaultCountry="ET"
countryCallingCodeEditable={false}
addInternationalOption
placeholder="912 345 678"
disabled={loading}
value={identifier || undefined}
onChange={(v) => setIdentifier(v ?? "")}
/>
</div>
) : (
<input
type="email"
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
placeholder={currentMethod.placeholder}
disabled={loading}
className={fieldClass}
/>
)}
</div>
<div className="space-y-1.5">

View File

@@ -1,14 +1,18 @@
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react";
import { useForm } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import RPNInput from "react-phone-number-input";
import "react-phone-number-input/style.css";
import { userType } from "@/enums/userType";
import useAuth from "@/hooks/useAuth";
import type { SignupPayload } from "@/types/auth";
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
import { isValidPhone } from "@/components/PhoneField";
import "@/components/phone-field.css";
const EDR_LOGO = "/assets/edr-logo.png";
@@ -20,22 +24,13 @@ const passwordRequirements = [
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
] as const;
const ETHIOPIA_COUNTRY_CODE = "+251";
const isValidEthiopianMobile = (value: string) => {
const digits = value.replace(/\D/g, "");
const normalized = digits.startsWith("0") ? digits.slice(1) : digits;
return /^9\d{8}$/.test(normalized);
};
const userSchema = z
.object({
email: z.string().email("Invalid email address"),
countryCode: z.literal(ETHIOPIA_COUNTRY_CODE),
phone: z
.string()
.min(1, "Phone number is required")
.refine(isValidEthiopianMobile, "Enter a valid mobile number (e.g. 0912345678)"),
.refine(isValidPhone, "Enter a valid phone number"),
userType: z.string(),
firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
@@ -70,12 +65,12 @@ export default function SignupPage() {
register,
handleSubmit,
watch,
control,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(userSchema),
defaultValues: {
email: "",
countryCode: ETHIOPIA_COUNTRY_CODE,
phone: "",
userType: userType.individual,
firstName: { en: "", am: "" },
@@ -89,12 +84,11 @@ export default function SignupPage() {
setError(null);
setLoading(true);
try {
const digits = data.phone.replace(/\D/g, "");
const normalizedPhone = digits.startsWith("0") ? digits.slice(1) : digits;
const payload: SignupPayload = {
email: data.email,
username: data.email,
phoneNumber: `${data.countryCode}${normalizedPhone}`,
// Already a canonical E.164 string from the phone field (e.g. +251912345678).
phoneNumber: data.phone,
userType: data.userType,
name: {
en: `${data.firstName.en} ${data.lastName.en}`,
@@ -183,31 +177,30 @@ export default function SignupPage() {
<label htmlFor="signup-phone" className="text-sm font-medium text-gray-800">
Phone <span className="text-red-500">*</span>
</label>
<input type="hidden" {...register("countryCode")} />
<div
className={`flex overflow-hidden rounded-xl border bg-white shadow-sm transition-all duration-200 hover:border-gray-300 focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/10 ${
errors.phone ? "border-red-300 focus-within:border-red-400 focus-within:ring-red-100" : "border-gray-200/90"
}`}
>
<span className="flex h-11 shrink-0 items-center border-r border-gray-200/90 bg-gray-50 px-3 text-sm font-medium text-gray-600">
{ETHIOPIA_COUNTRY_CODE}
</span>
<input
id="signup-phone"
type="tel"
inputMode="numeric"
autoComplete="tel-national"
placeholder="0912345678"
maxLength={10}
disabled={loading}
className="h-11 min-w-0 flex-1 border-0 bg-transparent px-4 text-sm text-gray-900 outline-none placeholder:text-gray-400"
{...register("phone", {
onChange: (event) => {
event.target.value = event.target.value.replace(/\D/g, "").slice(0, 10);
},
})}
/>
</div>
<Controller
control={control}
name="phone"
render={({ field }) => (
<div
className={`edr-phone-wrapper${
errors.phone ? " edr-phone-wrapper--error" : ""
}`}
>
<RPNInput
international
defaultCountry="ET"
countryCallingCodeEditable={false}
addInternationalOption
id="signup-phone"
placeholder="912 345 678"
disabled={loading}
value={field.value || undefined}
onChange={(v) => field.onChange(v ?? "")}
onBlur={field.onBlur}
/>
</div>
)}
/>
{errorText(errors.phone?.message)}
</div>

View File

@@ -9,8 +9,10 @@ import { paymentsService, type PaymentMethod } from "@/services/payments.service
import type { Freight } from "@edr/types";
import { ActivityCard } from "./components/ActivityCard";
import { ContainersCard } from "./components/ContainersCard";
import { ContractCard } from "./components/ContractCard";
import { DocRow, IconSquare } from "./components/Documents";
import { KeyFactsStrip } from "./components/KeyFactsStrip";
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
import {
CancelledBanner,
@@ -48,8 +50,15 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
});
const pricing = booking.pricingBreakdown;
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
// enters batch selection. A one-time booking can only pay once it's been
// SELECTED_FOR_BATCH (assigned a slot with a pay window).
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
const canPay =
status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
booking.paymentStatus !== "PAID" &&
(isGeneralContract
? status === "FULLY_EXECUTED"
: status === "SELECTED_FOR_BATCH");
const showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED";
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
@@ -113,6 +122,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
{showPairedNotice && <ConsolidationPairedNotice />}
<KeyFactsStrip booking={booking} />
<ContractCard booking={booking} navigate={navigate} />
<BodyGrid
@@ -120,6 +131,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
<>
<ShipmentDetailsCard booking={booking} />
<ContainersCard booking={booking} />
{booking.files && booking.files.length > 0 && (
<SectionCard>
<Group justify="space-between" align="center" mb="md">

View File

@@ -0,0 +1,90 @@
import { Box, Group, Table, Text } from "@mantine/core";
import { Boxes } from "lucide-react";
import type { Freight } from "@edr/types";
import { CardTitle, SectionCard } from "./layout";
/**
* Per-container-type breakdown for container bookings (count, type, VGM).
* Renders nothing for bulk bookings, which have no container lines.
*/
export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
const containers = booking.containers ?? [];
if (booking.freightType === "BULK" || containers.length === 0) return null;
const totalUnits = containers.reduce((sum, c) => sum + Number(c.qty || 0), 0);
const totalVgm = containers.reduce(
(sum, c) => sum + Number(c.vgm || 0) * Number(c.qty || 0),
0,
);
return (
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<Group gap={8} align="center">
<Boxes size={18} color="#0A6F4D" />
<CardTitle>Containers</CardTitle>
</Group>
<Text fz="12.5px" fw={600} c="#9AA8B5">
{totalUnits} unit{totalUnits !== 1 ? "s" : ""}
</Text>
</Group>
<Table verticalSpacing="sm" horizontalSpacing={0}>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ color: "#9AA8B5", fontSize: 11.5 }}>Type</Table.Th>
<Table.Th style={{ color: "#9AA8B5", fontSize: 11.5 }}>Qty</Table.Th>
<Table.Th style={{ color: "#9AA8B5", fontSize: 11.5 }}>VGM / unit</Table.Th>
<Table.Th style={{ color: "#9AA8B5", fontSize: 11.5, textAlign: "right" }}>
Total VGM
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((c, i) => {
const lineVgm = Number(c.vgm || 0) * Number(c.qty || 0);
return (
<Table.Tr key={`${c.type}-${i}`}>
<Table.Td>
<Text fz={14} fw={700} c="#10202F">
{c.type}
</Text>
</Table.Td>
<Table.Td>
<Text fz={14} c="#10202F">
{c.qty}
</Text>
</Table.Td>
<Table.Td>
<Text fz={14} c="#475569">
{c.vgm ? `${c.vgm} t` : "—"}
</Text>
</Table.Td>
<Table.Td>
<Text fz={14} fw={700} c="#10202F" ta="right">
{lineVgm ? `${lineVgm.toLocaleString()} t` : "—"}
</Text>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
<Box
mt="sm"
pt="sm"
style={{ borderTop: "1px solid #F2F5F8", display: "flex", justifyContent: "space-between" }}
>
<Text fz={13} fw={600} c="#475569">
Total weight (VGM)
</Text>
<Text fz={14} fw={800} c="#0A6F4D">
{totalVgm.toLocaleString()} t
</Text>
</Box>
</SectionCard>
);
}

View File

@@ -0,0 +1,107 @@
import { Box, Group, SimpleGrid, Text } from "@mantine/core";
import {
CalendarClock,
CreditCard,
MapPin,
Package,
Tag,
Train,
} from "lucide-react";
import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
import { fmtDate, yardLabel } from "../utils";
import { SectionCard } from "./layout";
type BookingLike = Freight.IBooking & {
bookingType?: string;
paymentStatus?: string;
trainScheduleId?: string | null;
};
function Fact({
icon,
label,
value,
}: {
icon: ReactNode;
label: string;
value: ReactNode;
}) {
return (
<Group gap={10} wrap="nowrap" align="flex-start">
<Box
style={{
width: 34,
height: 34,
borderRadius: 9,
flexShrink: 0,
background: "#F1F6FA",
color: "#0A6F4D",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{icon}
</Box>
<Box miw={0}>
<Text fz="11.5px" fw={600} c="#9AA8B5">
{label}
</Text>
<Text mt={2} fz="14px" fw={700} c="#10202F" truncate>
{value}
</Text>
</Box>
</Group>
);
}
/**
* Compact at-a-glance facts strip at the top of the booking detail page — gives
* a fast scan of the key attributes before the deeper cards below.
*/
export function KeyFactsStrip({ booking }: { booking: BookingLike }) {
const isContract = booking.bookingType === "GENERAL_CONTRACT";
const freight = booking.freightType === "BULK" ? "Bulk" : "Container";
const payment = booking.paymentStatus
? booking.paymentStatus
.replace(/_/g, " ")
.toLowerCase()
.replace(/^\w/, (c) => c.toUpperCase())
: "—";
return (
<SectionCard p="md">
<SimpleGrid cols={{ base: 1, xs: 2, md: 3, xl: 6 }} spacing="lg">
<Fact
icon={<Tag size={17} />}
label="Type"
value={isContract ? "General Contract" : "One-Time"}
/>
<Fact icon={<Package size={17} />} label="Cargo" value={freight} />
<Fact
icon={<MapPin size={17} />}
label="Route"
value={`${yardLabel(booking.originYard)}${yardLabel(booking.destinationYard)}`}
/>
<Fact icon={<CreditCard size={17} />} label="Payment" value={payment} />
<Fact
icon={<Train size={17} />}
label="Train"
value={booking.trainScheduleId ? "Assigned" : "Not assigned"}
/>
<Fact
icon={<CalendarClock size={17} />}
label={isContract ? "Ordering until" : "Scheduled"}
value={
isContract
? fmtDate(booking.expiresAt ?? null)
: fmtDate(booking.scheduledDate)
}
/>
</SimpleGrid>
</SectionCard>
);
}

View File

@@ -34,6 +34,13 @@ import {
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
import { ModeIndicator } from "@/components/ModeIndicator";
import {
BookingTypeBadge,
CargoModeCell,
PaymentBadge,
SchedulingCell,
} from "./booking-display";
// Bookings that have left (or are leaving) the yard can be tracked live.
const TRACKABLE_STATUSES = new Set([
@@ -173,6 +180,9 @@ function PrimaryAction({
}) {
const { status, id } = booking;
const go = () => onNavigate(`/bookings/${id}`);
// A general contract is payable as soon as it's FULLY_EXECUTED (signed); a
// one-time booking only after it's SELECTED_FOR_BATCH.
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
if (status === "DRAFT") {
return (
<Button
@@ -203,7 +213,10 @@ function PrimaryAction({
</Button>
);
}
if (status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID") {
const payableStatus = isGeneralContract
? "FULLY_EXECUTED"
: "SELECTED_FOR_BATCH";
if (status === payableStatus && booking.paymentStatus !== "PAID") {
return <PayNowButton booking={booking} />;
}
return (
@@ -320,24 +333,54 @@ export default function MyBookings() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>("all");
const [query, setQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [freightFilter, setFreightFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<string>("");
const [createdTo, setCreatedTo] = useState<string>("");
const [trackingBooking, setTrackingBooking] = useState<Freight.IBooking | null>(
null,
);
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const selectFilter = (key: StatusFilterKey) => {
setStatusFilter(key);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
resetPage();
};
const hasExtraFilters =
!!typeFilter || !!freightFilter || !!createdFrom || !!createdTo;
const clearExtraFilters = () => {
setTypeFilter(null);
setFreightFilter(null);
setCreatedFrom("");
setCreatedTo("");
resetPage();
};
const filter: BookingListFilter = useMemo(
() => ({
statuses,
bookingType: typeFilter ?? undefined,
freightType: freightFilter ?? undefined,
createdFrom: createdFrom || undefined,
// include the whole selected end day
createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
}),
[statuses, pagination.pageIndex, pagination.pageSize],
[
statuses,
typeFilter,
freightFilter,
createdFrom,
createdTo,
pagination.pageIndex,
pagination.pageSize,
],
);
const { data, isLoading, isError } = useQuery(
@@ -358,14 +401,20 @@ export default function MyBookings() {
const doneCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
);
const transitCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "transit")!.statuses,
);
const closedCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "closed")!.statuses,
);
const cardCounts: Record<StatusFilterKey, number | undefined> = {
all: allCount,
active: activeCount,
payment: paymentCount,
draft: draftCount,
done: doneCount,
transit: undefined,
closed: undefined,
transit: transitCount,
closed: closedCount,
};
const allItems = data?.items ?? [];
@@ -424,6 +473,20 @@ export default function MyBookings() {
);
},
},
{
id: "type",
size: 150,
meta: hMeta,
header: () => <ColHeader label="Type" />,
cell: ({ row }) => <BookingTypeBadge booking={row.original} />,
},
{
id: "cargo",
size: 168,
meta: hMeta,
header: () => <ColHeader label="Cargo" />,
cell: ({ row }) => <CargoModeCell booking={row.original} />,
},
{
id: "route",
size: 196,
@@ -448,6 +511,20 @@ export default function MyBookings() {
);
},
},
{
id: "payment",
size: 130,
meta: hMeta,
header: () => <ColHeader label="Payment" />,
cell: ({ row }) => <PaymentBadge status={row.original.paymentStatus} />,
},
{
id: "scheduling",
size: 140,
meta: hMeta,
header: () => <ColHeader label="Train" />,
cell: ({ row }) => <SchedulingCell booking={row.original} />,
},
{
id: "status",
size: 190,
@@ -536,9 +613,12 @@ export default function MyBookings() {
{/* ── Page header ─────────────────────────────────────────────── */}
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
<Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
Bookings
</Title>
<Group gap={10} align="center">
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
Bookings
</Title>
<ModeIndicator />
</Group>
<Text size="sm" c="edr-muted" mt={4}>
Track every cargo booking from draft to delivery.
</Text>
@@ -606,9 +686,79 @@ export default function MyBookings() {
radius="md"
checkIconPosition="right"
comboboxProps={{ withinPortal: true }}
style={{ width: 200 }}
style={{ width: 190 }}
aria-label="Filter by status"
/>
<Select
placeholder="Any type"
data={[
{ value: "ONE_TIME", label: "One-time" },
{ value: "GENERAL_CONTRACT", label: "General contract" },
]}
value={typeFilter}
onChange={(v) => {
setTypeFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 170 }}
aria-label="Filter by booking type"
/>
<Select
placeholder="Any cargo"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freightFilter}
onChange={(v) => {
setFreightFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 150 }}
aria-label="Filter by cargo type"
/>
<TextInput
type="date"
value={createdFrom}
onChange={(e) => {
setCreatedFrom(e.currentTarget.value);
resetPage();
}}
radius="md"
style={{ width: 150 }}
aria-label="Created from"
placeholder="From"
/>
<TextInput
type="date"
value={createdTo}
onChange={(e) => {
setCreatedTo(e.currentTarget.value);
resetPage();
}}
radius="md"
style={{ width: 150 }}
aria-label="Created to"
placeholder="To"
/>
{hasExtraFilters && (
<Button
variant="subtle"
color="gray"
radius="md"
size="sm"
leftSection={<X size={14} />}
onClick={clearExtraFilters}
>
Clear
</Button>
)}
</Group>
<Text fz={12} c="edr-muted">
{total} booking{total !== 1 ? "s" : ""}

View File

@@ -1,4 +1,5 @@
import { api } from "@/services/api";
import { Freight } from "@edr/types";
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
import type {
CreateBookingPayload,
@@ -175,6 +176,30 @@ export default function NewBookingPage() {
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const bookingType = form.watch("bookingType");
const isGeneralContract = bookingType === "general_contract";
// General contracts have no shipment date at creation — the Schedule step
// (id 5) is skipped; the date is chosen per order against the contract later.
const visibleSteps = useMemo(
() => STEPS.filter((s) => !(isGeneralContract && s.id === 5)),
[isGeneralContract],
);
const visibleStepIds = useMemo<number[]>(
() => visibleSteps.map((s) => s.id),
[visibleSteps],
);
const currentStepIndex = visibleStepIds.indexOf(step);
const isLastStep = currentStepIndex === visibleStepIds.length - 1;
const isFirstStep = currentStepIndex <= 0;
const goToStep = (delta: number) => {
const idx = visibleStepIds.indexOf(step);
const nextIdx = Math.min(
visibleStepIds.length - 1,
Math.max(0, idx + delta),
);
setStep(visibleStepIds[nextIdx]);
};
const direction = useMemo(() => {
const origin = referenceData?.yard.find((y) => y.id === originYard);
@@ -210,7 +235,7 @@ export default function NewBookingPage() {
return;
}
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
goToStep(1);
}
function buildApiPayload(data: BookingFormValues): CreateBookingPayload {
@@ -259,10 +284,20 @@ export default function NewBookingPage() {
(s) => s.id === data.serviceTypeId,
)!;
const isContract = data.bookingType === "general_contract";
return {
scheduledDate: data.scheduledDate
? new Date(data.scheduledDate).toISOString()
: new Date().toISOString(),
bookingType: isContract
? Freight.BookingType.GeneralContract
: Freight.BookingType.OneTime,
// General contracts omit the shipment date — chosen per order later.
...(isContract
? {}
: {
scheduledDate: data.scheduledDate
? new Date(data.scheduledDate).toISOString()
: new Date().toISOString(),
}),
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: data.serviceTypeId,
@@ -408,7 +443,7 @@ export default function NewBookingPage() {
>
<Box flex={1} p="24px">
<Box mb="lg">
<StepIndicator step={step} />
<StepIndicator step={step} steps={visibleSteps} />
</Box>
{persistAndPriceMutation.isError && (
@@ -494,13 +529,13 @@ export default function NewBookingPage() {
variant="default"
radius="md"
leftSection={<ChevronLeft size={16} />}
onClick={() => setStep((s) => Math.max(1, s - 1))}
disabled={step === 1}
onClick={() => goToStep(-1)}
disabled={isFirstStep}
>
Back
</Button>
{step < STEPS.length ? (
{!isLastStep ? (
<Button
type="button"
color="edr-green"

View File

@@ -0,0 +1,109 @@
import { Badge, Group, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
/**
* Shared presentation helpers for booking-like rows (one-time bookings AND
* general contracts). Kept in one place so the bookings list, contracts list,
* and detail page render type/freight/mode/payment consistently.
*/
type BookingLike = Freight.IBooking & {
bookingType?: string;
freightType?: string;
tradeDirection?: string;
paymentStatus?: string;
};
/** One-Time vs General Contract. */
export function BookingTypeBadge({ booking }: { booking: BookingLike }) {
const isContract = booking.bookingType === "GENERAL_CONTRACT";
return (
<Badge
variant="light"
radius="sm"
color={isContract ? "violet" : "gray"}
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
>
{isContract ? "General Contract" : "One-Time"}
</Badge>
);
}
/** Containerised vs Bulk, plus the trade direction (Import/Export/Domestic). */
export function CargoModeCell({ booking }: { booking: BookingLike }) {
const freight =
booking.freightType === "BULK" ? "Bulk" : "Container";
const dir = booking.tradeDirection
? booking.tradeDirection.charAt(0) + booking.tradeDirection.slice(1).toLowerCase()
: null;
return (
<Group gap={6} wrap="nowrap">
<Badge
variant="light"
radius="sm"
color={booking.freightType === "BULK" ? "orange" : "teal"}
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
>
{freight}
</Badge>
{dir && (
<Text fz={12} c="edr-muted">
{dir}
</Text>
)}
</Group>
);
}
const PAYMENT_COLORS: Record<string, string> = {
PAID: "green",
PENDING: "gray",
PNR_GENERATED: "blue",
VERIFICATION_IN_PROGRESS: "yellow",
FAILED: "red",
};
const PAYMENT_LABELS: Record<string, string> = {
PAID: "Paid",
PENDING: "Pending",
PNR_GENERATED: "PNR generated",
VERIFICATION_IN_PROGRESS: "Verifying",
FAILED: "Failed",
};
/** Payment status pill. */
export function PaymentBadge({ status }: { status?: string | null }) {
if (!status) return <Text fz={13} c="dimmed"></Text>;
return (
<Badge
variant="light"
radius="sm"
color={PAYMENT_COLORS[status] ?? "gray"}
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
>
{PAYMENT_LABELS[status] ?? status.replace(/_/g, " ")}
</Badge>
);
}
/** Whether a booking is assigned to a train yet (scheduling progress). */
export function SchedulingCell({ booking }: { booking: BookingLike & { trainScheduleId?: string | null; schedulingStatus?: string } }) {
const assigned = !!booking.trainScheduleId;
const label = assigned
? "Assigned"
: booking.schedulingStatus === "HOLDING"
? "Holding"
: booking.schedulingStatus === "ELIGIBLE"
? "Eligible"
: "Not scheduled";
return (
<Badge
variant="dot"
radius="sm"
color={assigned ? "green" : "gray"}
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
>
{label}
</Badge>
);
}

View File

@@ -8,10 +8,18 @@ const BORDER = "var(--mantine-color-edr-border-0)";
const MUTED = "var(--mantine-color-edr-muted-0)";
const INK = "var(--mantine-color-edr-text-0)";
export function StepIndicator({ step }: { step: number }) {
type StepItem = (typeof STEPS)[number];
export function StepIndicator({
step,
steps = STEPS as readonly StepItem[],
}: {
step: number;
steps?: readonly StepItem[];
}) {
return (
<div className="flex items-start">
{STEPS.map((item, index) => {
{steps.map((item, index) => {
const done = step > item.id;
const active = step === item.id;
return (
@@ -67,7 +75,7 @@ export function StepIndicator({ step }: { step: number }) {
{item.short}
</span>
</div>
{index < STEPS.length - 1 && (
{index < steps.length - 1 && (
<div
style={{
flex: 1,

View File

@@ -81,8 +81,13 @@ export const PAYMENT_CURRENCY_OPTIONS: Array<{
},
];
export const BOOKING_TYPES = ["one_time", "general_contract"] as const;
export type BookingTypeOption = (typeof BOOKING_TYPES)[number];
export const bookingFormSchema = z
.object({
// One-time booking vs. a general contract (umbrella, drawn down by orders).
bookingType: z.enum(BOOKING_TYPES).default("one_time"),
contractType: z.enum(["new", "renewal"], "Select a contract type."),
previousContractRef: z.string(),
serviceTypeId: z.string("Select a service type."),
@@ -115,7 +120,9 @@ export const bookingFormSchema = z
shippingLine: z.string(),
// Day-level pool: the customer selects only a DAY. The batch engine assigns
// the specific train later, so no trainScheduleId is collected here.
scheduledDate: z.string().min(1, "Select a shipment date."),
// Optional in the base schema — required for one-time bookings via the
// superRefine below; general contracts pick the date per order instead.
scheduledDate: z.string().default(""),
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(),
cargoTypePath: z.array(z.string()).default([]),
@@ -188,6 +195,14 @@ export const bookingFormSchema = z
{ message: "Add at least one container.", path: ["containers"] },
)
.superRefine((data, ctx) => {
// One-time bookings must pick a shipment date; general contracts must not.
if (data.bookingType !== "general_contract" && !data.scheduledDate.trim()) {
ctx.addIssue({
code: "custom",
path: ["scheduledDate"],
message: "Select a shipment date.",
});
}
if (data.cargoType === "bulk") {
if (!data.cargoTypePath[0]) {
ctx.addIssue({
@@ -222,6 +237,7 @@ export type BookingFormValues = z.infer<typeof bookingFormSchema>;
export type BookingFormInputValues = z.input<typeof bookingFormSchema>;
export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
bookingType: "one_time",
previousContractRef: "",
serviceTypeId: "",
@@ -252,7 +268,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
};
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
1: ["contractType", "previousContractRef"],
1: ["bookingType", "contractType", "previousContractRef"],
2: [
"serviceTypeId",
"paymentCurrency",

View File

@@ -1,7 +1,7 @@
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
import { useQuery } from "@tanstack/react-query";
import { FileText, RefreshCw } from "lucide-react";
import { CalendarClock, FileText, Layers, RefreshCw } from "lucide-react";
import { useMemo, useState } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { BookingFormInputValues, type BookingFormValues } from "./schema";
@@ -14,7 +14,7 @@ import {
StepHeader,
} from "./shared";
import { FileSignature } from "lucide-react";
import { Stack } from "@mantine/core";
import { Divider, Stack, Text } from "@mantine/core";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -128,7 +128,7 @@ export function Step1ContractType({
(sl) => sl.id === booking.shippingLineId,
);
if (shippingLine) {
form.setValue("shippingLine", shippingLine.name);
form.setValue("shippingLine", shippingLine.id);
}
}
@@ -187,10 +187,46 @@ export function Step1ContractType({
<StepCard>
<StepHeader
icon={<FileSignature size={22} />}
title="Contract Type"
description="Start a new contract or renew an existing one to reuse its details."
title="Booking Type"
description="Choose a one-time shipment or a general contract you can draw down from over time."
/>
<Controller
name="bookingType"
control={form.control}
render={({ field }) => (
<div className="grid gap-4 md:grid-cols-2">
<OptionCard
selected={field.value !== "general_contract"}
icon={<CalendarClock className="h-5 w-5" />}
iconBg="#ECF6F1"
iconColor="#0A6F4D"
title="One-Time Booking"
description="A single shipment with a chosen ship date — the standard flow."
onClick={() => field.onChange("one_time")}
/>
<OptionCard
selected={field.value === "general_contract"}
icon={<Layers className="h-5 w-5" />}
iconBg="#F1ECFB"
iconColor="#6A40B8"
title="General Contract"
description="Reserve a total quantity once, then place multiple orders against it until it runs out."
onClick={() => field.onChange("general_contract")}
/>
</div>
)}
/>
<Divider my={24} />
<Text fw={700} fz={15} mb={4} style={{ color: "#10202F" }}>
Contract Type
</Text>
<Text fz={13} c="edr-muted" mb={16}>
Start a fresh contract or renew an existing one to reuse its details.
</Text>
<Controller
name="contractType"
control={form.control}

View File

@@ -35,7 +35,7 @@ export function Step4Route({
const shippingLineOptions = useMemo(() => {
if (!referenceData?.shipping_line) return [];
// The form keys shipping line by name, so options are keyed by name too.
// The form keys shipping line by ID (required by API as UUID).
// Dedupe by name: if the reference data has two lines sharing a name, a
// duplicate option would crash Mantine's Select ("Duplicate options...").
const seen = new Set<string>();
@@ -43,7 +43,7 @@ export function Step4Route({
for (const sl of referenceData.shipping_line) {
if (!sl.name || seen.has(sl.name)) continue;
seen.add(sl.name);
options.push({ value: sl.name, label: sl.name });
options.push({ value: sl.id, label: sl.name });
}
return options;
}, [referenceData]);

View File

@@ -72,6 +72,11 @@ export function Step5CargoDetails({
return group?.children?.find((c) => c.id === childId) ?? null;
}, [referenceData, parentId, childId]);
// Unit of measure for bulk/break-bulk cargo: PER_ITEM → "Items", else "Tons".
// Drives the weight/quantity label so customers enter the right unit.
const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM";
const bulkUnitLabel = isPerItem ? "Items" : "Tons";
const freightTypeGroups = useMemo(() => {
if (!referenceData?.cargo_type) return [];
return referenceData.cargo_type.filter(
@@ -194,14 +199,18 @@ export function Step5CargoDetails({
{...field}
id="cargoWeight"
type="number"
label="Total Cargo Weight (Tons) *"
placeholder="0.00"
label={
cargoType === "bulk"
? `Total Cargo Quantity (${bulkUnitLabel}) *`
: "Total Cargo Weight (Tons) *"
}
placeholder={isPerItem ? "0" : "0.00"}
leftSection={<Weight className="h-4 w-4" />}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
min={0}
step={0.01}
step={isPerItem ? 1 : 0.01}
/>
)}
/>

View File

@@ -151,6 +151,9 @@ export function Step8Review({
const serviceType = referenceData?.service.find(
(s) => s.id === values.serviceTypeId,
);
const shippingLine = referenceData?.shipping_line.find(
(sl) => sl.id === values.shippingLine,
);
const containerSummary =
values.cargoType === "container" && values.containers.length > 0
@@ -274,7 +277,7 @@ export function Step8Review({
value={`${originYardName}${destinationYardName}`}
/>
<DetailRow label="Trade direction" value={directionLabel} />
<DetailRow label="Shipping line" value={values.shippingLine || "—"} />
<DetailRow label="Shipping line" value={shippingLine?.name || "—"} />
<DetailRow
label="Modifiers"
value={

View File

@@ -0,0 +1,293 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Card,
Center,
Group,
Loader,
Paper,
Progress,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import {
ArrowLeft,
CalendarClock,
Layers,
MapPin,
PackagePlus,
Ship,
} from "lucide-react";
import { api } from "@/services/api";
import { ModeIndicator } from "@/components/ModeIndicator";
import { PayNowButton } from "../bookings/payments/PayNowButton";
import {
BORDER,
ContractStatusBadge,
formatQuantity,
GREEN,
INK,
MetaItem,
MUTED,
} from "./contract-ui";
import { PlaceOrderDialog } from "./PlaceOrderDialog";
export default function ContractDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [orderOpen, setOrderOpen] = useState(false);
const {
data: contract,
isLoading,
isError,
} = useQuery(api.bookings.get.queryOptions({ input: { id: id! }, enabled: !!id }));
const { data: pool } = useQuery({
...api.bookingOrders.pool.queryOptions({
input: { contractBookingId: id! },
}),
enabled: !!id && contract?.status !== "DRAFT",
});
const { data: orders } = useQuery({
...api.bookingOrders.listByContract.queryOptions({
input: { contractBookingId: id! },
}),
enabled: !!id && contract?.status !== "DRAFT",
});
if (isLoading) {
return (
<Center mih={400} p="xl">
<Stack align="center" gap="md">
<Loader color="edr-green" />
<Text size="sm" c="dimmed">
Loading contract
</Text>
</Stack>
</Center>
);
}
if (isError || !contract) {
return (
<Box p="xl">
<Paper withBorder radius="lg" p="xl" style={{ borderColor: BORDER }}>
<Text fw={700} mb="xs">
Contract not found
</Text>
<Button variant="default" onClick={() => navigate("/contracts")}>
Back to contracts
</Button>
</Paper>
</Box>
);
}
const isContainer = contract.freightType === "CONTAINER";
const isActive = contract.status === "CONTRACT_ACTIVE";
const awaitingPayment = contract.status === "FULLY_EXECUTED";
const poolLines = pool ?? [];
return (
<Box style={{ padding: "28px 32px 40px" }}>
<Stack gap="lg">
{/* Header */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="center" wrap="nowrap">
<Button
variant="subtle"
color="gray"
radius="md"
px={8}
onClick={() => navigate("/contracts")}
>
<ArrowLeft size={18} />
</Button>
<Group gap={12} wrap="nowrap" align="center">
<ThemeIcon size={46} radius="md" variant="light" color="violet">
<Layers size={22} />
</ThemeIcon>
<div>
<Group gap={10} align="center">
<Title order={2} fw={800} fz={22} style={{ color: INK }}>
{contract.reference}
</Title>
<ContractStatusBadge status={contract.status} />
<ModeIndicator size="sm" />
</Group>
<Text size="sm" c="dimmed" mt={2}>
General contract · {isContainer ? "Containerised" : "Bulk"}
</Text>
</div>
</Group>
</Group>
<Group gap="sm">
{awaitingPayment && <PayNowButton booking={contract} label="Pay & activate" size="sm" />}
{isActive && (
<Button
color="edr-green"
radius="md"
leftSection={<PackagePlus size={16} />}
onClick={() => setOrderOpen(true)}
>
Place order
</Button>
)}
</Group>
</Group>
{/* Summary */}
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Group gap={48} wrap="wrap">
<MetaItem
label="Route"
icon={<MapPin size={15} color={MUTED} />}
value={`${contract.originYard?.label ?? "—"}${contract.destinationYard?.label ?? "—"}`}
/>
<MetaItem
label="Ordering until"
icon={<CalendarClock size={15} color={MUTED} />}
value={
contract.expiresAt
? new Date(contract.expiresAt).toLocaleDateString()
: "Not active yet"
}
/>
<MetaItem
label="Trade direction"
icon={<Ship size={15} color={MUTED} />}
value={contract.tradeDirection ?? "—"}
/>
</Group>
</Paper>
{/* Drawdown pool */}
{contract.status !== "DRAFT" && (
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Text fw={700} fz={16} mb={4} style={{ color: INK }}>
Contracted quantity
</Text>
<Text fz={13} c="dimmed" mb="lg">
How much of this contract has been ordered versus what remains.
</Text>
<Stack gap="lg">
{poolLines.length === 0 && (
<Text fz={13} c="dimmed">
No quantity pool available.
</Text>
)}
{poolLines.map((line, i) => {
const pct =
line.contractedQuantity > 0
? Math.min(
100,
(line.orderedQuantity / line.contractedQuantity) * 100,
)
: 0;
const label = isContainer
? (line.containerTypeName ?? "Containers")
: line.unitOfMeasure === "PER_ITEM"
? "Items"
: "Tons";
return (
<div key={line.containerTypeId ?? `bulk-${i}`}>
<Group justify="space-between" mb={6}>
<Text fz={14} fw={600} style={{ color: INK }}>
{label}
</Text>
<Text fz={13} c="dimmed">
<Text span fw={700} style={{ color: GREEN }}>
{formatQuantity(
line.remainingQuantity,
line.unitOfMeasure,
isContainer,
)}
</Text>{" "}
remaining of{" "}
{formatQuantity(
line.contractedQuantity,
line.unitOfMeasure,
isContainer,
)}
</Text>
</Group>
<Progress
value={pct}
color="edr-green"
size="md"
radius="xl"
/>
</div>
);
})}
</Stack>
</Card>
)}
{/* Orders */}
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Text fw={700} fz={16} mb="md" style={{ color: INK }}>
Orders ({orders?.length ?? 0})
</Text>
{!orders || orders.length === 0 ? (
<Text fz={13} c="dimmed">
{isActive
? "No orders yet. Use “Place order” to draw down from this contract."
: "Orders can be placed once the contract is active (paid)."}
</Text>
) : (
<Stack gap={0}>
{orders.map((order, idx) => (
<Box
key={order.id}
py="sm"
style={{
borderTop: idx === 0 ? undefined : `1px solid ${BORDER}`,
}}
>
<Group justify="space-between" wrap="nowrap">
<div>
<Text fz={14} fw={700} style={{ color: INK }}>
{order.reference}
</Text>
<Text fz={12} c="dimmed">
Ship {new Date(order.scheduledDate).toLocaleDateString()}
{" · "}
{order.lines
.map(
(l) =>
`${Number.isInteger(l.quantity) ? l.quantity : l.quantity.toFixed(2)}${
l.containerTypeName ? ` ${l.containerTypeName}` : ""
}`,
)
.join(", ")}
</Text>
</div>
<ContractStatusBadge status={order.status} />
</Group>
</Box>
))}
</Stack>
)}
</Card>
</Stack>
<PlaceOrderDialog
opened={orderOpen}
onClose={() => setOrderOpen(false)}
contract={contract}
pool={poolLines}
onPlaced={() => setOrderOpen(false)}
/>
</Box>
);
}

View File

@@ -0,0 +1,340 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Card,
Group,
Paper,
Select,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { Layers, Plus, Search, X } from "lucide-react";
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
} from "@edr/ui-common";
import { ModeIndicator } from "@/components/ModeIndicator";
import { CargoModeCell, PaymentBadge } from "../bookings/booking-display";
import { ContractStatusBadge, GREEN, INK, MUTED } from "./contract-ui";
export default function ContractsList() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [freightFilter, setFreightFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<string>("");
const [createdTo, setCreatedTo] = useState<string>("");
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const hasExtraFilters = !!freightFilter || !!createdFrom || !!createdTo;
const clearExtraFilters = () => {
setFreightFilter(null);
setCreatedFrom("");
setCreatedTo("");
resetPage();
};
const filter: BookingListFilter = useMemo(
() => ({
bookingType: "GENERAL_CONTRACT",
freightType: freightFilter ?? undefined,
createdFrom: createdFrom || undefined,
createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy: "createdAt",
sortOrder: "DESC",
}),
[
freightFilter,
createdFrom,
createdTo,
pagination.pageIndex,
pagination.pageSize,
],
);
const { data, isLoading, isError } = useQuery(
api.bookings.list.queryOptions({ input: filter }),
);
const rows = useMemo(() => {
const items = data?.items ?? [];
if (!query.trim()) return items;
const q = query.toLowerCase();
return items.filter(
(b) =>
b.reference?.toLowerCase().includes(q) ||
b.originYard?.label?.toLowerCase().includes(q) ||
b.destinationYard?.label?.toLowerCase().includes(q),
);
}, [data, query]);
const activeCount = useMemo(
() =>
(data?.items ?? []).filter((b) => b.status === "CONTRACT_ACTIVE").length,
[data],
);
const columns: ColumnDef<Freight.IBooking>[] = [
{
id: "reference",
header: () => <ColHeader label="Contract" />,
cell: ({ row }) => {
const b = row.original;
return (
<Group gap={12} wrap="nowrap" align="center">
<ThemeIcon
size={38}
radius="md"
variant="light"
color="violet"
style={{ flexShrink: 0 }}
>
<Layers size={18} />
</ThemeIcon>
<div>
<Text fz={14} fw={700} style={{ color: INK }}>
{b.reference}
</Text>
<Text fz={12} c="dimmed">
{b.freightType === "CONTAINER" ? "Containerised" : "Bulk"}
</Text>
</div>
</Group>
);
},
},
{
id: "cargo",
header: () => <ColHeader label="Cargo" />,
cell: ({ row }) => <CargoModeCell booking={row.original} />,
},
{
id: "route",
header: () => <ColHeader label="Route" />,
cell: ({ row }) => {
const b = row.original;
return (
<Text fz={13} style={{ color: INK }}>
{b.originYard?.label ?? "—"}{" "}
<Text span c="dimmed">
</Text>{" "}
{b.destinationYard?.label ?? "—"}
</Text>
);
},
},
{
id: "payment",
header: () => <ColHeader label="Payment" />,
cell: ({ row }) => <PaymentBadge status={row.original.paymentStatus} />,
},
{
id: "expires",
header: () => <ColHeader label="Ordering Until" />,
cell: ({ row }) => {
const exp = row.original.expiresAt;
return (
<Text fz={13} c={exp ? undefined : "dimmed"} style={{ color: exp ? INK : undefined }}>
{exp ? new Date(exp).toLocaleDateString() : "—"}
</Text>
);
},
},
{
id: "status",
header: () => <ColHeader label="Status" />,
cell: ({ row }) => <ContractStatusBadge status={row.original.status} />,
},
];
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
const total = data?.meta?.total ?? (data?.items?.length ?? 0);
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
return (
<Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg">
{/* Header */}
<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" }}>
General Contracts
</Title>
<ModeIndicator />
</Group>
<Text size="sm" c="edr-muted" mt={4}>
Reserve a quantity once, then place orders against it until the
contract runs out or its window closes.
</Text>
</Box>
<Button
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
onClick={() => navigate("/bookings/new")}
>
New Contract
</Button>
</Group>
{/* Summary */}
<SimpleStat
label="Active contracts"
value={activeCount}
hint="accepting orders"
/>
{/* Search + filters */}
<Group gap={10} wrap="wrap" align="center">
<TextInput
placeholder="Search by reference or route…"
leftSection={<Search size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
radius="md"
styles={{ input: { height: 44 } }}
style={{ flex: 1, minWidth: 220, maxWidth: 360 }}
/>
<Select
placeholder="Any cargo"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freightFilter}
onChange={(v) => {
setFreightFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 160 }}
styles={{ input: { height: 44 } }}
aria-label="Filter by cargo type"
/>
<TextInput
type="date"
value={createdFrom}
onChange={(e) => {
setCreatedFrom(e.currentTarget.value);
resetPage();
}}
radius="md"
style={{ width: 160 }}
styles={{ input: { height: 44 } }}
aria-label="Created from"
/>
<TextInput
type="date"
value={createdTo}
onChange={(e) => {
setCreatedTo(e.currentTarget.value);
resetPage();
}}
radius="md"
style={{ width: 160 }}
styles={{ input: { height: 44 } }}
aria-label="Created to"
/>
{hasExtraFilters && (
<Button
variant="subtle"
color="gray"
radius="md"
leftSection={<X size={14} />}
onClick={clearExtraFilters}
>
Clear
</Button>
)}
</Group>
{/* Table */}
<Card p={0} style={{ overflow: "hidden" }}>
<DataTable
columns={columns}
data={rows}
status={dataTableStatus}
onRowClick={(row) =>
navigate(`/contracts/${(row as Freight.IBooking).id}`)
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
footer={DataTableFooter}
emptyMessage="No general contracts yet. Create one from New Booking → General Contract."
/>
</Card>
</Stack>
</Box>
);
}
function ColHeader({ label }: { label: string }) {
return (
<Text fz={12} fw={700} c="dimmed" style={{ letterSpacing: 0.3 }}>
{label}
</Text>
);
}
function SimpleStat({
label,
value,
hint,
}: {
label: string;
value: number | string;
hint?: string;
}) {
return (
<Paper
withBorder
radius="lg"
p="md"
maw={260}
style={{ borderColor: "#E6ECF2" }}
>
<Text fz={12} fw={600} c="dimmed">
{label}
</Text>
<Group gap={8} align="baseline" mt={2}>
<Text fz={28} fw={800} style={{ color: GREEN }}>
{value}
</Text>
{hint && (
<Text fz={12} style={{ color: MUTED }}>
{hint}
</Text>
)}
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,254 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Button,
Group,
Modal,
NumberInput,
Select,
Stack,
Text,
} from "@mantine/core";
import { AlertCircle, CalendarDays, PackagePlus } from "lucide-react";
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
import { formatQuantity, GREEN, INK } from "./contract-ui";
interface PlaceOrderDialogProps {
opened: boolean;
onClose: () => void;
contract: Freight.IBooking;
pool: Freight.ContractQuantityLine[];
onPlaced: () => void;
}
/**
* Place a drawdown order against an ACTIVE general contract. The customer picks
* a shipment day (constrained to days with a departure on the contract's route)
* and a quantity per pool line, validated against the remaining quantity.
*/
export function PlaceOrderDialog({
opened,
onClose,
contract,
pool,
onPlaced,
}: PlaceOrderDialogProps) {
const queryClient = useQueryClient();
const isContainer = contract.freightType === "CONTAINER";
const [scheduledDate, setScheduledDate] = useState<string | null>(null);
const [quantities, setQuantities] = useState<Record<string, number | "">>({});
const { data: availableDays, isLoading: daysLoading } = useQuery({
...api.bookings.getAvailableDays.queryOptions({
input: {
originYardId: contract.originYard?.id,
destinationYardId: contract.destinationYard?.id,
},
}),
enabled: opened && !!contract.originYard?.id && !!contract.destinationYard?.id,
});
const dayOptions = useMemo(
() =>
(availableDays ?? []).map((d) => ({
value: d,
label: new Date(d).toLocaleDateString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
}),
})),
[availableDays],
);
const lineKey = (line: Freight.ContractQuantityLine) =>
line.containerTypeId ?? "__bulk__";
const createMutation = useMutation({
...api.bookingOrders.create.mutationOptions(),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: api.bookingOrders.listByContract.queryKey({
contractBookingId: contract.id,
}),
});
queryClient.invalidateQueries({
queryKey: api.bookingOrders.pool.queryKey({
contractBookingId: contract.id,
}),
});
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: contract.id }),
});
reset();
onPlaced();
},
});
function reset() {
setScheduledDate(null);
setQuantities({});
}
function handleClose() {
if (createMutation.isPending) return;
reset();
onClose();
}
function handleSubmit() {
if (!scheduledDate) return;
const lines: Freight.CreateBookingOrderLineDto[] = pool
.map((line) => {
const raw = quantities[lineKey(line)];
const qty = typeof raw === "number" ? raw : 0;
return {
containerTypeId: isContainer ? line.containerTypeId : null,
quantity: qty,
};
})
.filter((l) => l.quantity > 0);
if (lines.length === 0) return;
createMutation.mutate({
contractBookingId: contract.id,
scheduledDate: new Date(scheduledDate).toISOString(),
lines,
});
}
const orderableLines = pool.filter((l) => l.remainingQuantity > 0);
const hasQuantity = pool.some((l) => {
const raw = quantities[lineKey(l)];
return typeof raw === "number" && raw > 0;
});
const canSubmit = !!scheduledDate && hasQuantity && !createMutation.isPending;
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Group gap={8}>
<PackagePlus size={18} color={GREEN} />
<Text fw={700} style={{ color: INK }}>
Place an order
</Text>
</Group>
}
radius="lg"
centered
size="md"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Draw down from contract <strong>{contract.reference}</strong>. Route,
cargo and service are inherited just pick a shipment date and
quantity.
</Text>
<Select
label="Shipment date"
placeholder={daysLoading ? "Loading available days…" : "Select a day"}
data={dayOptions}
value={scheduledDate}
onChange={setScheduledDate}
disabled={daysLoading}
radius="md"
leftSection={<CalendarDays size={16} />}
nothingFoundMessage="No departures on this route"
searchable
comboboxProps={{ withinPortal: true }}
styles={{ input: { height: 44 } }}
/>
<Stack gap="sm">
<Text fz={13} fw={600} style={{ color: INK }}>
Quantity
</Text>
{orderableLines.length === 0 && (
<Alert color="gray" radius="md" icon={<AlertCircle size={16} />}>
This contract is fully drawn down no quantity remains.
</Alert>
)}
{orderableLines.map((line) => {
const key = lineKey(line);
const label = isContainer
? (line.containerTypeName ?? "Containers")
: line.unitOfMeasure === "PER_ITEM"
? "Items"
: "Tons";
return (
<Group key={key} justify="space-between" wrap="nowrap" gap="md">
<div style={{ flex: 1 }}>
<Text fz={14} fw={600} style={{ color: INK }}>
{label}
</Text>
<Text fz={12} c="dimmed">
{formatQuantity(
line.remainingQuantity,
line.unitOfMeasure,
isContainer,
)}{" "}
remaining
</Text>
</div>
<NumberInput
value={quantities[key] ?? ""}
onChange={(v) =>
setQuantities((prev) => ({
...prev,
[key]: v === "" ? "" : Number(v),
}))
}
min={0}
max={line.remainingQuantity}
step={isContainer || line.unitOfMeasure === "PER_ITEM" ? 1 : 0.5}
clampBehavior="strict"
radius="md"
w={130}
placeholder="0"
/>
</Group>
);
})}
</Stack>
{createMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
{createMutation.error instanceof Error
? createMutation.error.message
: "Failed to place the order. Please try again."}
</Alert>
)}
<Group justify="flex-end" gap="sm" mt="xs">
<Button
variant="default"
radius="md"
onClick={handleClose}
disabled={createMutation.isPending}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<PackagePlus size={16} />}
onClick={handleSubmit}
disabled={!canSubmit}
loading={createMutation.isPending}
>
Place order
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,91 @@
import { Badge, Group, Text } from "@mantine/core";
import type { ReactNode } from "react";
// Brand palette (mirrors the booking form's shared constants).
export const INK = "#10202F";
export const MUTED = "#6B7C8E";
export const GREEN = "#0EA371";
export const GREEN_DARK = "#0A6F4D";
export const BORDER = "#E6ECF2";
/** Visual config for a general-contract status. */
export const CONTRACT_STATUS_CONFIG: Record<
string,
{ label: string; color: string; bg: string }
> = {
DRAFT: { label: "Draft", color: "#6B7C8E", bg: "#EEF2F6" },
SUBMITTED: { label: "Submitted", color: "#2E5B96", bg: "#EAF1FB" },
PENDING_APPROVAL: { label: "Pending Approval", color: "#9A6700", bg: "#FFF6E5" },
APPROVED_PENDING_SIGNATURE: { label: "Awaiting Signature", color: "#9A6700", bg: "#FFF6E5" },
CONTRACT_READY: { label: "Ready to Sign", color: "#2E5B96", bg: "#EAF1FB" },
SIGNED_CUSTOMER: { label: "Signed", color: "#2E5B96", bg: "#EAF1FB" },
FULLY_EXECUTED: { label: "Awaiting Payment", color: "#9A6700", bg: "#FFF6E5" },
CONTRACT_ACTIVE: { label: "Active", color: "#0A6F4D", bg: "#E7F6EE" },
CONTRACT_CLOSED: { label: "Closed", color: "#6B7C8E", bg: "#EEF2F6" },
EXPIRED: { label: "Expired", color: "#B42318", bg: "#FEECEB" },
CANCELLED: { label: "Cancelled", color: "#B42318", bg: "#FEECEB" },
REJECTED: { label: "Rejected", color: "#B42318", bg: "#FEECEB" },
};
export function ContractStatusBadge({ status }: { status: string }) {
const cfg =
CONTRACT_STATUS_CONFIG[status] ?? {
label: status,
color: MUTED,
bg: "#EEF2F6",
};
return (
<Badge
variant="light"
radius="sm"
styles={{
root: {
backgroundColor: cfg.bg,
color: cfg.color,
fontWeight: 600,
textTransform: "none",
letterSpacing: 0,
},
}}
>
{cfg.label}
</Badge>
);
}
/** A labelled value used across the contract detail summary cards. */
export function MetaItem({
label,
value,
icon,
}: {
label: string;
value: ReactNode;
icon?: ReactNode;
}) {
return (
<div>
<Text fz={12} fw={600} c="dimmed" mb={4} style={{ letterSpacing: 0.2 }}>
{label}
</Text>
<Group gap={6} wrap="nowrap" align="center">
{icon}
<Text fz={14} fw={600} style={{ color: INK }}>
{value}
</Text>
</Group>
</div>
);
}
/** Format a contracted/remaining quantity with its unit. */
export function formatQuantity(
qty: number,
unit?: string | null,
isContainerLine?: boolean,
): string {
const rounded = Number.isInteger(qty) ? qty : Number(qty.toFixed(2));
if (isContainerLine) return `${rounded} containers`;
if (unit === "PER_ITEM") return `${rounded} items`;
return `${rounded} tons`;
}

View File

@@ -11,7 +11,7 @@ import {
CheckCircle2,
} from "lucide-react";
import PhoneInput from "@/components/auth/PhoneInput";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import {
Button,
@@ -37,9 +37,8 @@ const schema = z.object({
phoneNumber: z
.string()
.min(1, "Phone number is required"),
phoneCountryCode: z.string().min(1),
.min(1, "Phone number is required")
.refine(isValidPhone, "Enter a valid phone number"),
// COMPANY
companyName: z
@@ -52,9 +51,8 @@ const schema = z.object({
companyPhone: z
.string()
.min(1, "Company phone is required"),
companyPhoneCountryCode: z.string().min(1),
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z
.string()
@@ -75,9 +73,8 @@ const schema = z.object({
representativePhone: z
.string()
.min(1, "Representative phone is required"),
representativePhoneCountryCode: z.string().min(1),
.min(1, "Representative phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
});
type FormData = z.infer<typeof schema>;
@@ -91,14 +88,12 @@ const stepFields: Record<
"lastName",
"email",
"phoneNumber",
"phoneCountryCode",
],
company: [
"companyName",
"companyEmail",
"companyPhone",
"companyPhoneCountryCode",
"companyLocation",
"companyAddress",
],
@@ -107,7 +102,6 @@ const stepFields: Record<
"representativeName",
"representativeEmail",
"representativePhone",
"representativePhoneCountryCode",
],
};
@@ -117,6 +111,7 @@ export default function DjiboutiForwardingAgentForm() {
const {
register,
control,
handleSubmit,
trigger,
formState: { errors, isSubmitting },
@@ -124,10 +119,9 @@ export default function DjiboutiForwardingAgentForm() {
resolver: zodResolver(schema),
defaultValues: {
phoneCountryCode: "+253",
companyPhoneCountryCode: "+253",
representativePhoneCountryCode:
"+253",
phoneNumber: "",
companyPhone: "",
representativePhone: "",
},
});
@@ -280,23 +274,11 @@ export default function DjiboutiForwardingAgentForm() {
/>
</Field>
<PhoneInput
<ControlledPhoneField
control={control}
name="phoneNumber"
label="Phone Number"
countryCode={{
...register(
"phoneCountryCode"
),
}}
phone={{
...register(
"phoneNumber"
),
placeholder: "77123456",
}}
countryCodeError={
errors.phoneCountryCode
}
phoneError={errors.phoneNumber}
required
/>
</div>
</>
@@ -347,25 +329,11 @@ export default function DjiboutiForwardingAgentForm() {
/>
</Field>
<PhoneInput
<ControlledPhoneField
control={control}
name="companyPhone"
label="Company Phone"
countryCode={{
...register(
"companyPhoneCountryCode"
),
}}
phone={{
...register(
"companyPhone"
),
placeholder: "77123456",
}}
countryCodeError={
errors.companyPhoneCountryCode
}
phoneError={
errors.companyPhone
}
required
/>
</div>
@@ -472,25 +440,11 @@ export default function DjiboutiForwardingAgentForm() {
/>
</Field>
<PhoneInput
<ControlledPhoneField
control={control}
name="representativePhone"
label="Representative Phone"
countryCode={{
...register(
"representativePhoneCountryCode"
),
}}
phone={{
...register(
"representativePhone"
),
placeholder: "77123456",
}}
countryCodeError={
errors.representativePhoneCountryCode
}
phoneError={
errors.representativePhone
}
required
/>
</div>
</>

View File

@@ -12,7 +12,7 @@ import {
CheckCircle2,
} from "lucide-react";
import PhoneInput from "@/components/auth/PhoneInput";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import {
Button,
@@ -34,14 +34,18 @@ const onboardingSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
phoneNumber: z.string().min(1, "Phone number is required"),
phoneCountryCode: z.string().min(1),
phoneNumber: z
.string()
.min(1, "Phone number is required")
.refine(isValidPhone, "Enter a valid phone number"),
// COMPANY
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"),
companyPhoneCountryCode: z.string().min(1),
companyPhone: z
.string()
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"),
@@ -63,9 +67,8 @@ const onboardingSchema = z.object({
contactPersonPhone: z
.string()
.min(1, "Contact person phone is required"),
contactPersonPhoneCountryCode: z.string().min(1),
.min(1, "Contact person phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
// GENERAL MANAGER
generalManagerName: z
@@ -78,14 +81,15 @@ const onboardingSchema = z.object({
generalManagerPhone: z
.string()
.min(1, "General manager phone is required"),
generalManagerPhoneCountryCode: z.string().min(1),
.min(1, "General manager phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
// OPTIONAL POA
poaName: z.string().optional(),
poaPhone: z.string().optional(),
poaPhoneCountryCode: z.string().optional(),
poaPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaAddress: z.string().optional(),
poaEmail: z.string().optional(),
poaLocation: z.string().optional(),
@@ -102,14 +106,12 @@ const stepFields: Record<
"lastName",
"email",
"phoneNumber",
"phoneCountryCode",
],
company: [
"companyName",
"companyEmail",
"companyPhone",
"companyPhoneCountryCode",
"companyLocation",
"companyAddress",
"tinNumber",
@@ -120,11 +122,9 @@ const stepFields: Record<
personnel: [
"contactPersonName",
"contactPersonPhone",
"contactPersonPhoneCountryCode",
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
"generalManagerPhoneCountryCode",
],
poa: [],
@@ -136,6 +136,7 @@ export default function ImportExportOnBoarding() {
const {
register,
control,
handleSubmit,
trigger,
formState: { errors, isSubmitting },
@@ -143,11 +144,11 @@ export default function ImportExportOnBoarding() {
resolver: zodResolver(onboardingSchema),
defaultValues: {
phoneCountryCode: "+251",
companyPhoneCountryCode: "+251",
contactPersonPhoneCountryCode: "+251",
generalManagerPhoneCountryCode: "+251",
poaPhoneCountryCode: "+251",
phoneNumber: "",
companyPhone: "",
contactPersonPhone: "",
generalManagerPhone: "",
poaPhone: "",
},
});
@@ -303,23 +304,11 @@ export default function ImportExportOnBoarding() {
/>
</Field>
<PhoneInput
<ControlledPhoneField
control={control}
name="phoneNumber"
label="Phone Number"
countryCode={{
...register(
"phoneCountryCode"
),
}}
phone={{
...register(
"phoneNumber"
),
placeholder: "912345678",
}}
countryCodeError={
errors.phoneCountryCode
}
phoneError={errors.phoneNumber}
required
/>
</div>
</>
@@ -370,25 +359,11 @@ export default function ImportExportOnBoarding() {
/>
</Field>
<PhoneInput
<ControlledPhoneField
control={control}
name="companyPhone"
label="Company Phone"
countryCode={{
...register(
"companyPhoneCountryCode"
),
}}
phone={{
...register(
"companyPhone"
),
placeholder: "912345678",
}}
countryCodeError={
errors.companyPhoneCountryCode
}
phoneError={
errors.companyPhone
}
required
/>
</div>
@@ -535,25 +510,11 @@ export default function ImportExportOnBoarding() {
/>
</Field>
<PhoneInput
<ControlledPhoneField
control={control}
name="contactPersonPhone"
label="Contact Person Phone"
countryCode={{
...register(
"contactPersonPhoneCountryCode"
),
}}
phone={{
...register(
"contactPersonPhone"
),
placeholder: "912345678",
}}
countryCodeError={
errors.contactPersonPhoneCountryCode
}
phoneError={
errors.contactPersonPhone
}
required
/>
</div>
</div>
@@ -614,25 +575,11 @@ export default function ImportExportOnBoarding() {
/>
</Field>
<PhoneInput
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="General Manager Phone"
countryCode={{
...register(
"generalManagerPhoneCountryCode"
),
}}
phone={{
...register(
"generalManagerPhone"
),
placeholder: "912345678",
}}
countryCodeError={
errors.generalManagerPhoneCountryCode
}
phoneError={
errors.generalManagerPhone
}
required
/>
</div>
</div>
@@ -669,17 +616,10 @@ export default function ImportExportOnBoarding() {
/>
</Field>
<PhoneInput
<ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone"
countryCode={{
...register(
"poaPhoneCountryCode"
),
}}
phone={{
...register("poaPhone"),
placeholder: "912345678",
}}
/>
</div>

View File

@@ -11,7 +11,7 @@ import {
CheckCircle2,
} from "lucide-react";
import PhoneInput from "@/components/auth/PhoneInput";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import {
Button,
@@ -29,8 +29,10 @@ const schema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phoneNumber: z.string().min(1),
phoneCountryCode: z.string().min(1),
phoneNumber: z
.string()
.min(1)
.refine(isValidPhone, "Enter a valid phone number"),
// TRANSPORT
fanNumber: z.string().min(1),
@@ -60,7 +62,6 @@ const stepFields: Record<Step, (keyof FormData)[]> = {
"lastName",
"email",
"phoneNumber",
"phoneCountryCode",
],
transport: [
"fanNumber",
@@ -78,6 +79,7 @@ export default function TransporterOnboarding() {
const {
register,
control,
handleSubmit,
trigger,
watch,
@@ -85,7 +87,7 @@ export default function TransporterOnboarding() {
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
phoneCountryCode: "+251",
phoneNumber: "",
},
});
@@ -161,12 +163,11 @@ export default function TransporterOnboarding() {
<FieldError errors={[errors.email]} />
</Field>
<PhoneInput
<ControlledPhoneField
control={control}
name="phoneNumber"
label="Phone Number"
countryCode={{ ...register("phoneCountryCode") }}
phone={{ ...register("phoneNumber") }}
countryCodeError={errors.phoneCountryCode}
phoneError={errors.phoneNumber}
required
/>
</div>
</>

View File

@@ -68,11 +68,11 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
<Card padding="lg">
<Group gap="sm" mb="xs">
<Building2 size={20} />
<Title order={3}>Business Profile</Title>
<Title order={3}>Operating Roles</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
{profile.companyType === "customer"
? "Select the role(s) your company operates as — importer, exporter, or both."
? "Select the service(s) your company operates as — importer, exporter and/or freight forwarder."
: "Your company's operational role."}
</Text>

View File

@@ -0,0 +1,50 @@
import { Card, Group, SimpleGrid, Text, Title } from "@mantine/core";
import { Globe2, MapPin } from "lucide-react";
import type { CompanyNationality } from "@/services/companies.service";
import RoleCard from "./RoleCard";
interface NationalitySelectProps {
value: CompanyNationality | null;
onChange: (next: CompanyNationality) => void;
}
/**
* First step of onboarding: is this an Ethiopian or a Foreign company? The
* choice determines which documents are requested later (TIN / Commercial
* License / National ID for Ethiopian, Passport / Investment License for
* Foreign).
*/
export default function NationalitySelect({
value,
onChange,
}: NationalitySelectProps) {
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Globe2 size={20} />
<Title order={3}>Where is your company registered?</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
This determines the documents we'll ask you to provide.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<RoleCard
label="Ethiopian Company"
description="Registered in Ethiopia. You'll provide a TIN certificate, commercial license and national ID."
icon={<MapPin size={22} />}
selected={value === "ethiopian"}
onClick={() => onChange("ethiopian")}
/>
<RoleCard
label="Foreign Company"
description="Registered abroad. You'll provide a passport and investment license."
icon={<Globe2 size={22} />}
selected={value === "foreign"}
onClick={() => onChange("foreign")}
/>
</SimpleGrid>
</Card>
);
}

View File

@@ -1,39 +1,34 @@
import { Card, Divider, Group, SimpleGrid, Text, Title } from "@mantine/core";
import { Card, Group, SimpleGrid, Text, Title } from "@mantine/core";
import { Building2 } from "lucide-react";
import RoleCard from "./RoleCard";
import { CUSTOMER_ROLES, FREIGHT_FORWARDER } from "./companyRoles";
import { CUSTOMER_ROLES } from "./companyRoles";
interface OnboardingRoleSelectProps {
/** Currently selected profile types (e.g. ["importer"], ["importer","exporter"], ["freight_forwarder"]). */
/** Currently selected profile types (e.g. ["importer"], ["importer","exporter","freight_forwarder"]). */
value: string[];
onChange: (next: string[]) => void;
}
/**
* First (and only) thing shown in the Company Profile tab during onboarding.
* Importer / Exporter sit side by side and can both be picked; Freight
* Forwarder is a separate, mutually-exclusive choice below them. A valid
* selection reveals the company-profile fields.
* Importer / Exporter / Freight Forwarder are independent services that can be
* picked in any combination — each becomes its own profile (with its own
* business license) under the same company. A valid selection reveals the
* company-profile fields.
*/
export default function OnboardingRoleSelect({
value,
onChange,
}: OnboardingRoleSelectProps) {
const selected = new Set(value);
const isForwarder = selected.has(FREIGHT_FORWARDER.type);
// Toggling a customer role drops any forwarder selection (mutually exclusive).
const toggleCustomerRole = (type: string) => {
const next = new Set(value.filter((t) => t !== FREIGHT_FORWARDER.type));
const toggleRole = (type: string) => {
const next = new Set(value);
if (next.has(type)) next.delete(type);
else next.add(type);
onChange([...next]);
};
const toggleForwarder = () => {
onChange(isForwarder ? [] : [FREIGHT_FORWARDER.type]);
};
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
@@ -41,7 +36,8 @@ export default function OnboardingRoleSelect({
<Title order={3}>What does your company do?</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Pick Importer, Exporter, or both or register as a Freight Forwarder.
Pick any combination of Importer, Exporter and Freight Forwarder each
is set up with its own business license.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
@@ -52,26 +48,10 @@ export default function OnboardingRoleSelect({
description={role.description}
icon={role.icon}
selected={selected.has(role.type)}
onClick={() => toggleCustomerRole(role.type)}
onClick={() => toggleRole(role.type)}
/>
))}
</SimpleGrid>
<Divider
label="or"
labelPosition="center"
my="lg"
c="edr-muted"
styles={{ label: { textTransform: "uppercase", fontSize: 11 } }}
/>
<RoleCard
label={FREIGHT_FORWARDER.label}
description={FREIGHT_FORWARDER.description}
icon={FREIGHT_FORWARDER.icon}
selected={isForwarder}
onClick={toggleForwarder}
/>
</Card>
);
}

View File

@@ -15,7 +15,7 @@ import {
Grid,
} from "@mantine/core";
import { api } from "@/services/api";
import PhoneInput from "@/components/auth/PhoneInput";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import type { ProfileResponse } from "@/types/profile";
import type {
CreateCompanyPayload,
@@ -27,8 +27,10 @@ import OnboardingRoleSelect from "./OnboardingRoleSelect";
export const COMPANY_PROFILE_SCHEMA = z.object({
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"),
companyPhoneCountryCode: z.string().min(1, "Country code is required"),
companyPhone: z
.string()
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"),
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
@@ -37,13 +39,6 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
export function splitPhone(fullPhone?: string | null) {
if (!fullPhone) return { code: "+251", number: "" };
const match = fullPhone.match(/^(\+\d{1,3})(.*)$/);
if (match) return { code: match[1], number: match[2] };
return { code: "+251", number: fullPhone };
}
interface TabCompanyProfileProps {
profile?: ProfileResponse;
mode?: "edit" | "create";
@@ -61,12 +56,10 @@ export default function TabCompanyProfile({
const defaultValues = useMemo((): CompanyProfileFormData => {
if (profile) {
const phone = splitPhone(profile.companyPhone);
return {
companyName: profile.companyName,
companyEmail: profile.companyEmail ?? "",
companyPhone: phone.number,
companyPhoneCountryCode: phone.code,
companyPhone: profile.companyPhone ?? "",
companyLocation: profile.companyLocation,
companyAddress: profile.companyAddress ?? "",
tinNumber: profile.tinNumber,
@@ -77,7 +70,6 @@ export default function TabCompanyProfile({
companyName: "",
companyEmail: "",
companyPhone: "",
companyPhoneCountryCode: "+251",
companyLocation: "",
companyAddress: "",
tinNumber: "",
@@ -87,6 +79,7 @@ export default function TabCompanyProfile({
const {
register,
control,
handleSubmit,
reset,
formState: { errors, isDirty },
@@ -100,7 +93,7 @@ export default function TabCompanyProfile({
const base = {
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
companyPhone: data.companyPhone,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
tin: data.tinNumber,
@@ -185,15 +178,11 @@ export default function TabCompanyProfile({
/>
</Grid.Col>
<Grid.Col span={6}>
<PhoneInput
countryCode={{ ...register("companyPhoneCountryCode") }}
phone={{
...register("companyPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
<ControlledPhoneField
control={control}
name="companyPhone"
label="Company Phone"
required
/>
</Grid.Col>
</Grid>

View File

@@ -14,24 +14,19 @@ import {
Button,
} from "@mantine/core";
import { api } from "@/services/api";
import PhoneInput from "@/components/auth/PhoneInput";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import type { ProfileResponse } from "@/types/profile";
const schema = z.object({
contactPersonName: z.string().min(1, "Contact person name is required"),
contactPersonPhone: z.string().min(1, "Contact person phone is required"),
contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"),
contactPersonPhone: z
.string()
.min(1, "Contact person phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
});
type FormData = z.infer<typeof schema>;
function splitPhone(fullPhone?: string | null) {
if (!fullPhone) return { code: "+251", number: "" };
const match = fullPhone.match(/^(\+\d{1,3})(.*)$/);
if (match) return { code: match[1], number: match[2] };
return { code: "+251", number: fullPhone };
}
interface TabContactPersonProps {
profile: ProfileResponse;
mode?: "edit" | "onboarding";
@@ -42,16 +37,15 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }:
const queryClient = useQueryClient();
const defaultValues = useMemo((): FormData => {
const phone = splitPhone(profile.contactPersonPhone);
return {
contactPersonName: profile.contactPersonName ?? "",
contactPersonPhone: phone.number,
contactPersonPhoneCountryCode: phone.code,
contactPersonPhone: profile.contactPersonPhone ?? "",
};
}, [profile]);
const {
register,
control,
handleSubmit,
reset,
formState: { errors, isDirty },
@@ -64,7 +58,7 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }:
mutationFn: (data: FormData) =>
api.companies.updateProfile.call({
contactPersonName: data.contactPersonName,
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
contactPersonPhone: data.contactPersonPhone,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
@@ -93,12 +87,11 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }:
{...register("contactPersonName")}
/>
<PhoneInput
countryCode={{ ...register("contactPersonPhoneCountryCode") }}
phone={{ ...register("contactPersonPhone"), placeholder: "912345678" }}
countryCodeError={errors.contactPersonPhoneCountryCode}
phoneError={errors.contactPersonPhone}
<ControlledPhoneField
control={control}
name="contactPersonPhone"
label="Phone Number"
required
/>
</Stack>

View File

@@ -15,25 +15,20 @@ import {
Grid,
} from "@mantine/core";
import { api } from "@/services/api";
import PhoneInput from "@/components/auth/PhoneInput";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import type { ProfileResponse } from "@/types/profile";
const schema = z.object({
generalManagerName: z.string().min(1, "GM name is required"),
generalManagerEmail: z.string().email("Invalid GM email"),
generalManagerPhone: z.string().min(1, "GM phone is required"),
generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"),
generalManagerPhone: z
.string()
.min(1, "GM phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
});
type FormData = z.infer<typeof schema>;
function splitPhone(fullPhone?: string | null) {
if (!fullPhone) return { code: "+251", number: "" };
const match = fullPhone.match(/^(\+\d{1,3})(.*)$/);
if (match) return { code: match[1], number: match[2] };
return { code: "+251", number: fullPhone };
}
interface TabGeneralManagerProps {
profile: ProfileResponse;
mode?: "edit" | "onboarding";
@@ -44,17 +39,16 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
const queryClient = useQueryClient();
const defaultValues = useMemo((): FormData => {
const phone = splitPhone(profile.generalManagerPhone);
return {
generalManagerName: profile.generalManagerName ?? "",
generalManagerEmail: profile.generalManagerEmail ?? "",
generalManagerPhone: phone.number,
generalManagerPhoneCountryCode: phone.code,
generalManagerPhone: profile.generalManagerPhone ?? "",
};
}, [profile]);
const {
register,
control,
handleSubmit,
reset,
formState: { errors, isDirty },
@@ -68,7 +62,7 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
api.companies.updateProfile.call({
generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
generalManagerPhone: data.generalManagerPhone,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
@@ -108,12 +102,11 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
/>
</Grid.Col>
<Grid.Col span={6}>
<PhoneInput
countryCode={{ ...register("generalManagerPhoneCountryCode") }}
phone={{ ...register("generalManagerPhone"), placeholder: "912345678" }}
countryCodeError={errors.generalManagerPhoneCountryCode}
phoneError={errors.generalManagerPhone}
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone Number"
required
/>
</Grid.Col>
</Grid>

View File

@@ -15,27 +15,22 @@ import {
Grid,
} from "@mantine/core";
import { api } from "@/services/api";
import PhoneInput from "@/components/auth/PhoneInput";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import type { ProfileResponse } from "@/types/profile";
const schema = z.object({
poaName: z.string().optional(),
poaEmail: z.string().optional(),
poaPhone: z.string().optional(),
poaPhoneCountryCode: z.string().optional(),
poaPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaLocation: z.string().optional(),
poaAddress: z.string().optional(),
});
type FormData = z.infer<typeof schema>;
function splitPhone(fullPhone?: string | null) {
if (!fullPhone) return { code: "+251", number: "" };
const match = fullPhone.match(/^(\+\d{1,3})(.*)$/);
if (match) return { code: match[1], number: match[2] };
return { code: "+251", number: fullPhone };
}
interface TabPowerOfAttorneyProps {
profile: ProfileResponse;
mode?: "edit" | "onboarding";
@@ -50,12 +45,10 @@ export default function TabPowerOfAttorney({
const queryClient = useQueryClient();
const defaultValues = useMemo((): FormData => {
const phone = splitPhone(profile.poaPhone);
return {
poaName: profile.poaName ?? "",
poaEmail: profile.poaEmail ?? "",
poaPhone: phone.number,
poaPhoneCountryCode: profile.poaPhone ? phone.code : "+251",
poaPhone: profile.poaPhone ?? "",
poaLocation: profile.poaLocation ?? "",
poaAddress: profile.poaAddress ?? "",
};
@@ -63,6 +56,7 @@ export default function TabPowerOfAttorney({
const {
register,
control,
handleSubmit,
reset,
formState: { errors, isDirty },
@@ -75,10 +69,7 @@ export default function TabPowerOfAttorney({
mutationFn: (data: FormData) =>
api.companies.updateProfile.call({
poaName: data.poaName || undefined,
poaPhone:
data.poaPhone && data.poaPhoneCountryCode
? `${data.poaPhoneCountryCode}${data.poaPhone}`
: undefined,
poaPhone: data.poaPhone || undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined,
poaAddress: data.poaAddress || undefined,
@@ -124,12 +115,10 @@ export default function TabPowerOfAttorney({
/>
</Grid.Col>
<Grid.Col span={6}>
<PhoneInput
countryCode={{ ...register("poaPhoneCountryCode") }}
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
<ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone"
countryCodeError={errors.poaPhoneCountryCode}
phoneError={errors.poaPhone}
/>
</Grid.Col>
</Grid>

View File

@@ -28,8 +28,12 @@ export const FREIGHT_FORWARDER: RoleMeta = {
icon: <Building2 size={22} />,
};
/** Importer / Exporter — the two roles a "customer" company can hold. */
export const CUSTOMER_ROLES: RoleMeta[] = [IMPORTER, EXPORTER];
/**
* Importer / Exporter / Freight Forwarder — the services a "customer" company
* can hold. A single company may register for any combination, each getting its
* own business license.
*/
export const CUSTOMER_ROLES: RoleMeta[] = [IMPORTER, EXPORTER, FREIGHT_FORWARDER];
// dj_freight_forwarder and transporter are intentionally not exposed yet.
export function rolesForCompanyType(companyType: string): RoleMeta[] {

View File

@@ -2,11 +2,9 @@ import type { Freight, PaginatedResponse } from "@edr/types";
import { endpoint } from "@/utils/endpoint";
import type {
CreateFileUploadFieldDto,
CreateFileUploadSettingDto,
FileUploadField,
FileUploadSetting,
UpdateFileUploadFieldDto,
UpdateFileUploadSettingDto,
} from "@/types/fileUploadSettings";
import {
bookingsService,
@@ -15,6 +13,10 @@ import {
GeneratePriceResponse,
SubmitBookingResponse,
} from "./bookings.service";
import {
bookingOrdersService,
CreateBookingOrderPayload,
} from "./booking-orders.service";
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
import {
paymentsService,
@@ -38,9 +40,11 @@ import {
} from "@/types/dropdownSettings";
import type {
CompanyInfoResponse,
CompanyNationality,
CompanyProfileResponse,
CreateCompanyPayload,
DashboardSummary,
ProfileTypeValue,
} from "./companies.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type {
@@ -135,6 +139,38 @@ export const api = {
"addCompanyProfiles",
companiesService.addCompanyProfiles,
),
createCompanyProfile: endpoint<
{ type: ProfileTypeValue; businessLicense?: string },
CompanyProfileResponse
>("companies", "createCompanyProfile", companiesService.createCompanyProfile),
startOnboarding: endpoint<
{
companyType: string;
roles: ProfileTypeValue[];
nationality?: CompanyNationality;
},
CompanyInfoResponse
>("companies", "startOnboarding", companiesService.startOnboarding),
setActiveMode: endpoint<{ type: ProfileTypeValue }, CompanyInfoResponse>(
"companies",
"setActiveMode",
companiesService.setActiveMode,
),
setOnboardingStep: endpoint<{ step: string }, void>(
"companies",
"setOnboardingStep",
companiesService.setOnboardingStep,
),
completeOnboarding: endpoint<void, CompanyInfoResponse>(
"companies",
"completeOnboarding",
companiesService.completeOnboarding,
),
},
bookings: {
@@ -241,6 +277,28 @@ export const api = {
),
},
bookingOrders: {
listByContract: endpoint<
{ contractBookingId: string },
Freight.IBookingOrder[]
>("booking-orders", "listByContract", ({ contractBookingId }) =>
bookingOrdersService.listByContract(contractBookingId),
),
pool: endpoint<
{ contractBookingId: string },
Freight.ContractQuantityLine[]
>("booking-orders", "pool", ({ contractBookingId }) =>
bookingOrdersService.pool(contractBookingId),
),
create: endpoint<CreateBookingOrderPayload, Freight.IBookingOrder>(
"booking-orders",
"create",
(payload) => bookingOrdersService.create(payload),
),
},
payments: {
initiate: endpoint<InitiatePaymentPayload, InitiateResponse>(
"payments",

View File

@@ -0,0 +1,34 @@
import type { Freight } from "@edr/types";
import { client } from "../utils/api";
export type CreateBookingOrderPayload = Freight.CreateBookingOrderDto;
export const bookingOrdersService = {
/** Orders placed against a general contract. */
listByContract: async (
contractBookingId: string,
): Promise<Freight.IBookingOrder[]> => {
const { data } = await client.get("/api/booking-orders", {
params: { contractBookingId },
});
return data.data ?? data;
},
/** Contracted / ordered / remaining quantities for a general contract. */
pool: async (
contractBookingId: string,
): Promise<Freight.ContractQuantityLine[]> => {
const { data } = await client.get(
`/api/booking-orders/contract/${contractBookingId}/pool`,
);
return data.data ?? data;
},
/** Place a drawdown order against a contract. */
create: async (
payload: CreateBookingOrderPayload,
): Promise<Freight.IBookingOrder> => {
const { data } = await client.post("/api/booking-orders", payload);
return data.data ?? data;
},
};

View File

@@ -69,6 +69,15 @@ export interface BookingListFilter {
status?: string;
/** Comma-separated statuses (overrides `status` when set). */
statuses?: string;
/** ONE_TIME or GENERAL_CONTRACT. */
bookingType?: string;
/** CONTAINER or BULK. */
freightType?: string;
/** IMPORT / EXPORT / DOMESTIC. */
tradeDirection?: string;
/** Created-date range (ISO). */
createdFrom?: string;
createdTo?: string;
page?: number;
pageSize?: number;
sortBy?: string;

View File

@@ -5,6 +5,22 @@ import type { ApiResponse } from "@/types/apiResponse";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import { isAxiosError } from "axios";
export type ProfileTypeValue =
| "importer"
| "exporter"
| "freight_forwarder"
| "dj_freight_forwarder"
| "transporter";
export type CompanyNationality = "ethiopian" | "foreign";
export interface LicenseFile {
name: string;
url: string;
size: number;
mimeType?: string;
}
export interface ExternalProfileResponse {
id: string;
userId: string;
@@ -16,6 +32,12 @@ export interface ExternalProfileResponse {
nationalId: string | null;
jobTitle: string | null;
isPrimaryContact: boolean;
/** The active operational mode (importer/exporter/forwarder). */
activeProfileType: ProfileTypeValue | null;
/** Id of the company_profile matching activeProfileType (server-resolved). */
activeCompanyProfileId: string | null;
onboardingStep: string | null;
onboardingCompleted: boolean;
createdAt: string;
updatedAt: string;
}
@@ -25,6 +47,7 @@ export interface CompanyResponse {
name: string;
type: string;
status: string;
nationality: CompanyNationality | null;
tin: string;
vatNumber: string | null;
businessLicense: string | null;
@@ -45,7 +68,10 @@ export interface CompanyProfileResponse {
type: string;
reference: string;
status: string;
/** @deprecated Superseded by licenseFiles (file model). */
businessLicense: string | null;
/** Business-license documents uploaded for this profile. */
licenseFiles: LicenseFile[];
attributes: Record<string, any> | null;
createdAt: string;
updatedAt: string;
@@ -63,6 +89,7 @@ export interface CompanyProfileInput {
export interface CreateCompanyPayload {
companyType?: string;
nationality?: CompanyNationality;
companyName: string;
companyEmail?: string;
companyPhone?: string;
@@ -152,6 +179,53 @@ export const companiesService = {
return unwrap(response.data);
},
/** Create a single operational profile and make it the active mode. */
createCompanyProfile: async (payload: {
type: ProfileTypeValue;
businessLicense?: string;
}): Promise<CompanyProfileResponse> => {
const response = await client.post<ApiResponse<CompanyProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILE,
payload,
);
return unwrap(response.data);
},
/** Begin onboarding — create the draft company + profile + role(s) up front. */
startOnboarding: async (payload: {
companyType: string;
roles: ProfileTypeValue[];
nationality?: CompanyNationality;
}): Promise<CompanyInfoResponse> => {
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.ONBOARDING_START,
payload,
);
return unwrap(response.data);
},
/** Switch the active operational mode (target profile must already exist). */
setActiveMode: async (payload: {
type: ProfileTypeValue;
}): Promise<CompanyInfoResponse> => {
const response = await client.patch<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.ACTIVE_MODE,
payload,
);
return unwrap(response.data);
},
setOnboardingStep: async (payload: { step: string }): Promise<void> => {
await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload);
},
completeOnboarding: async (): Promise<CompanyInfoResponse> => {
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.ONBOARDING_COMPLETE,
);
return unwrap(response.data);
},
uploadDocuments: async (
companyId: string,
files: Record<string, File | File[] | null>,
@@ -169,4 +243,36 @@ export const companiesService = {
}
await client.post(URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), formData);
},
/** Upload business-license document(s) for a company profile (multi-file). */
uploadProfileLicense: async (
profileId: string,
files: File[],
code = "business_license",
): Promise<LicenseFile[]> => {
const formData = new FormData();
for (const f of files) formData.append(code, f);
const response = await client.post<ApiResponse<LicenseFile[]>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId),
formData,
);
return unwrap(response.data);
},
/** List business-license document(s) already uploaded for a company profile. */
getProfileLicense: async (profileId: string): Promise<LicenseFile[]> => {
const response = await client.get<ApiResponse<LicenseFile[]>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId),
);
return unwrap(response.data);
},
/** Fetch company registration data from eTrade by TIN. */
fetchETradeInfo: async (payload: { tin: string }): Promise<any> => {
const response = await client.post<ApiResponse<any>>(
URL_CONSTANTS.COMPANIES_API.FETCH_ETRADE_INFO,
payload,
);
return unwrap(response.data);
},
};

View File

@@ -4,6 +4,7 @@ export interface ProfileResponse {
companyId: string;
companyName: string;
companyType: string;
nationality: string | null;
companyProfiles: CompanyProfileResponse[];
companyEmail: string | null;
companyPhone: string | null;
@@ -12,7 +13,21 @@ export interface ProfileResponse {
tinNumber: string;
vatNumber: string | null;
fanNumber: string | null;
licenceNumber?: string | null;
statusDescription?: string | null;
dateRegistered?: string | null;
renewedFrom?: string | null;
renewalDate?: string | null;
renewedTo?: string | null;
region?: string | null;
zone?: string | null;
woreda?: string | null;
kebele?: string | null;
houseNo?: string | null;
etradePhone?: string | null;
contactPersonName: string | null;
contactPersonPosition: string | null;
contactPersonEmail: string | null;
contactPersonPhone: string | null;
generalManagerName: string | null;
generalManagerEmail: string | null;
@@ -26,6 +41,7 @@ export interface ProfileResponse {
}
export interface UpdateProfilePayload {
nationality?: "ethiopian" | "foreign";
companyName?: string;
companyEmail?: string;
companyPhone?: string;
@@ -34,7 +50,21 @@ export interface UpdateProfilePayload {
tin?: string;
vatNumber?: string;
fanNumber?: string;
licenceNumber?: string;
statusDescription?: string;
dateRegistered?: string;
renewedFrom?: string;
renewalDate?: string;
renewedTo?: string;
region?: string;
zone?: string;
woreda?: string;
kebele?: string;
houseNo?: string;
etradePhone?: string;
contactPersonName?: string;
contactPersonPosition?: string;
contactPersonEmail?: string;
contactPersonPhone?: string;
generalManagerName?: string;
generalManagerEmail?: string;