tr]:last:border-b-0",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ );
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ );
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+};
diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
new file mode 100644
index 000000000..02fe21ffc
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
@@ -0,0 +1,50 @@
+import type { BookingListFilter } from "@/services/bookings.service";
+import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
+import type { RuleEngineResourceSlug } from "@/types/rule-engine";
+
+export const QUERY_KEYS = {
+ USERS: {
+ ROOT: ["users"] as const,
+ ADD: ["users", "add"] as const,
+ },
+
+ FILES: {
+ ROOT: ["file-upload-settings"] as const,
+ list: () => ["file-upload-settings", "list"] as const,
+ byId: (id: string) => ["file-upload-settings", "detail", id] as const,
+ byCode: (code: string) => ["file-upload-settings", "by-code", code] as const,
+ },
+
+ DROPDOWN_SETTINGS: {
+ ROOT: ["dropdown-settings"] as const,
+ list: () => ["dropdown-settings", "list"] as const,
+ byId: (id: string) => ["dropdown-settings", "detail", id] as const,
+ byCode: (code: string) => ["dropdown-settings", "by-code", code] as const,
+ },
+
+ CUSTOMERS: {
+ ROOT: ["customers"] as const,
+ list: () => ["customers", "list"] as const,
+ byId: (id: string) => ["customers", "detail", id] as const,
+ },
+
+ BOOKINGS: {
+ ROOT: ["bookings"] as const,
+ list: (filter?: BookingListFilter) =>
+ ["bookings", "list", filter ?? {}] as const,
+ byId: (id: string) => ["bookings", "detail", id] as const,
+ },
+
+ RULE_ENGINE: {
+ ROOT: ["rule-engine"] as const,
+ list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>
+ ["rule-engine", "list", resource, params ?? {}] as const,
+ detail: (resource: RuleEngineResourceSlug | string, id: string) =>
+ ["rule-engine", "detail", resource, id] as const,
+ chain: ["rule-engine", "approval-rules", "chain"] as const,
+ selectOptions: (
+ resource: RuleEngineResourceSlug | string,
+ params?: Record,
+ ) => ["rule-engine", "select-options", resource, params ?? {}] as const,
+ },
+} as const;
diff --git a/apps/edr-freight-web/backoffice/src/constants/TANSTACK_QUEY_KEY.ts b/apps/edr-freight-web/backoffice/src/constants/TANSTACK_QUEY_KEY.ts
deleted file mode 100644
index fca149d58..000000000
--- a/apps/edr-freight-web/backoffice/src/constants/TANSTACK_QUEY_KEY.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-export const QUERY_KEYS = {
- USERS: "users",
- ADD_USER: "add_user",
- CUSTOMER: "Customers",
- FILES: {
- FILE_UPLOAD_SETTINGS: "file-upload-settings",
- BY_CODE: "by-code"
- },
- DROPDOWN_SETTINGS: {
- ROOT: "dropdown-settings",
- LIST: "list",
- BY_ID: "by-id",
- BY_CODE: "by-code"
- },
- CUSTOMERS: {
- ROOT: "customers",
- LIST: "list",
- BY_ID: "by-id"
- },
- RULE_ENGINE: {
- ROOT: "rule-engine",
- list: (resource: string) => ["rule-engine", resource, "list"] as const,
- chain: ["rule-engine", "approval-rules", "chain"] as const,
- },
-}
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index b7cb4bb8b..91fcd156d 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -77,9 +77,33 @@ export const URL_CONSTANTS = {
BOOKINGS: {
BASE: "/bookings",
- BY_ID: (id: string | number) => `/bookings/${id}`,
- CANCEL: (id: string | number) => `/bookings/${id}/cancel`,
- CONFIRM: (id: string | number) => `/bookings/${id}/confirm`,
+ BY_ID: (id: string) => `/bookings/${id}`,
+ QUEUE: (queue: string) => `/bookings/queues/${queue}`,
+ STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`,
+ STAFF_REQUEST_CHANGES: (id: string) =>
+ `/bookings/${id}/staff/request-changes`,
+ STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`,
+ APPROVE_STEP: (id: string, stepId: string) =>
+ `/bookings/${id}/approval-steps/${stepId}/approve`,
+ REJECT_STEP: (id: string, stepId: string) =>
+ `/bookings/${id}/approval-steps/${stepId}/reject`,
+ CONTRACT_GENERATE: (id: string) => `/bookings/${id}/contract/generate`,
+ CONTRACT_VIEW: (id: string) => `/bookings/${id}/contract/view`,
+ CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`,
+ CONTRACT_SIGN: (id: string) => `/bookings/${id}/contract/sign`,
+ CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
+ SUMMARY: (id: string) => `/bookings/${id}/summary`,
+ CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`,
+ MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`,
+ PAYMENT_PNR: (id: string) => `/bookings/${id}/payment/pnr`,
+ PAYMENT_PROOF: (id: string) => `/bookings/${id}/payment/proof`,
+ PAYMENT_VERIFY: (id: string) => `/bookings/${id}/payment/verify`,
+ PAYMENT_REQUEST_LETTER: (id: string) =>
+ `/bookings/${id}/payment/request-letter`,
+ START_TRANSIT: (id: string) => `/bookings/${id}/operations/start-transit`,
+ COMPLETE: (id: string) => `/bookings/${id}/operations/complete`,
+ CANCEL: (id: string) => `/bookings/${id}/cancel`,
+ CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`,
},
OTP: {
diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts
new file mode 100644
index 000000000..d1bd6f867
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts
@@ -0,0 +1,268 @@
+import type { LucideIcon } from "lucide-react";
+import {
+ Ban,
+ Check,
+ FileSignature,
+ FileText,
+ MessageSquareWarning,
+ Play,
+ ShieldCheck,
+ Truck,
+ Wallet,
+ XCircle,
+} from "lucide-react";
+
+import type {
+ BookingApprovalStep,
+ BookingDetail,
+ BookingStatus,
+} from "@/types/booking";
+
+export type BookingActionId =
+ | "accept"
+ | "requestChanges"
+ | "reject"
+ | "approve"
+ | "rejectApproval"
+ | "generateContract"
+ | "viewContract"
+ | "generatePnr"
+ | "verifyPayment"
+ | "startTransit"
+ | "complete";
+
+export type BookingActionInputKind = "note" | "reason";
+
+export interface BookingActionDef {
+ id: BookingActionId;
+ label: string;
+ shortLabel: string;
+ description: string;
+ confirmTitle: string;
+ confirmDescription: string;
+ variant: "default" | "destructive" | "outline";
+ icon: LucideIcon;
+ input?: BookingActionInputKind;
+ inputLabel?: string;
+ inputPlaceholder?: string;
+ primary?: boolean;
+}
+
+export type BookingActionContext = Pick<
+ BookingDetail,
+ "status" | "paymentCurrency" | "approvalSteps" | "reference"
+>;
+
+export function getNextPendingApprovalStep(
+ steps?: BookingApprovalStep[] | null,
+): BookingApprovalStep | undefined {
+ if (!steps?.length) return undefined;
+ return [...steps]
+ .sort((a, b) => a.stepOrder - b.stepOrder)
+ .find((s) => s.status === "PENDING");
+}
+
+function approvalActions(
+ steps?: BookingApprovalStep[] | null,
+): BookingActionDef[] {
+ const next = getNextPendingApprovalStep(steps);
+ if (!next) return [];
+ return [
+ {
+ id: "approve",
+ label: `Approve (${next.requiredRole})`,
+ shortLabel: "Approve",
+ description: `Complete step ${next.stepOrder} as ${next.requiredRole}`,
+ confirmTitle: `Approve as ${next.requiredRole}?`,
+ confirmDescription:
+ "This records your approval and advances the booking to the next step in the chain.",
+ variant: "default",
+ icon: Check,
+ primary: true,
+ },
+ {
+ id: "rejectApproval",
+ label: "Reject approval",
+ shortLabel: "Reject",
+ description: "Reject at the current approval step",
+ confirmTitle: "Reject at approval step?",
+ confirmDescription:
+ "The booking will be marked rejected. This action cannot be undone from the UI.",
+ variant: "destructive",
+ icon: XCircle,
+ input: "reason",
+ inputLabel: "Rejection reason",
+ inputPlaceholder: "Explain why this booking is rejected…",
+ },
+ ];
+}
+
+const SUBMITTED_ACTIONS: BookingActionDef[] = [
+ {
+ id: "accept",
+ label: "Accept for approval",
+ shortLabel: "Accept",
+ description: "Start the formal approval chain",
+ confirmTitle: "Accept submission?",
+ confirmDescription:
+ "The booking moves to pending approval and approval steps are created from the rule engine.",
+ variant: "default",
+ icon: ShieldCheck,
+ primary: true,
+ },
+ {
+ id: "requestChanges",
+ label: "Request changes",
+ shortLabel: "Changes",
+ description: "Ask the customer to update and resubmit",
+ confirmTitle: "Request changes from customer?",
+ confirmDescription:
+ "The customer will see your note and can edit the booking before resubmitting.",
+ variant: "outline",
+ icon: MessageSquareWarning,
+ input: "note",
+ inputLabel: "Message to customer",
+ inputPlaceholder: "Describe what needs to be corrected or added…",
+ },
+ {
+ id: "reject",
+ label: "Reject booking",
+ shortLabel: "Reject",
+ description: "Reject this submission",
+ confirmTitle: "Reject booking?",
+ confirmDescription:
+ "The booking will be marked rejected and removed from active queues.",
+ variant: "destructive",
+ icon: Ban,
+ input: "reason",
+ inputLabel: "Rejection reason",
+ inputPlaceholder: "Reason for rejection…",
+ },
+];
+
+/** Actions available for the current booking status (detail or list). */
+export function getBookingActions(ctx: BookingActionContext): BookingActionDef[] {
+ const { status, paymentCurrency, approvalSteps } = ctx;
+
+ switch (status) {
+ case "SUBMITTED":
+ return SUBMITTED_ACTIONS;
+ case "PENDING_APPROVAL":
+ case "APPROVED_PENDING_SIGNATURE":
+ return approvalActions(approvalSteps);
+ case "APPROVED":
+ return [
+ {
+ id: "generateContract",
+ label: "Generate contract",
+ shortLabel: "Contract",
+ description: "Create contract document",
+ confirmTitle: "Generate contract?",
+ confirmDescription:
+ "A contract will be generated and the booking moves to contract ready.",
+ variant: "default",
+ icon: FileText,
+ primary: true,
+ },
+ ];
+ case "CONTRACT_READY":
+ case "SIGNED_CUSTOMER":
+ case "FULLY_EXECUTED":
+ return [
+ {
+ id: "viewContract",
+ label:
+ status === "SIGNED_CUSTOMER"
+ ? "View & sign contract (staff)"
+ : status === "CONTRACT_READY"
+ ? "View contract"
+ : "View executed contract",
+ shortLabel: "Contract",
+ description: "Open contract document and signatures",
+ confirmTitle: "",
+ confirmDescription: "",
+ variant: "default",
+ icon: FileSignature,
+ primary: true,
+ },
+ ];
+ case "FULLY_EXECUTED":
+ if (paymentCurrency === "ETB") {
+ return [
+ {
+ id: "generatePnr",
+ label: "Generate PNR",
+ shortLabel: "PNR",
+ description: "Issue PNR for ETB bank payment",
+ confirmTitle: "Generate PNR?",
+ confirmDescription:
+ "A payment reference number will be issued for the customer.",
+ variant: "default",
+ icon: Wallet,
+ primary: true,
+ },
+ ];
+ }
+ return [];
+ case "PAYMENT_VERIFICATION_IN_PROGRESS":
+ return [
+ {
+ id: "verifyPayment",
+ label: "Verify payment",
+ shortLabel: "Verify",
+ description: "Confirm USD payment proof",
+ confirmTitle: "Verify payment?",
+ confirmDescription:
+ "Finance confirms the uploaded proof and marks the booking as paid.",
+ variant: "default",
+ icon: Check,
+ primary: true,
+ },
+ ];
+ case "PAID":
+ case "PNR_GENERATED":
+ return [
+ {
+ id: "startTransit",
+ label: "Start transit",
+ shortLabel: "Transit",
+ description: "Begin rail movement",
+ confirmTitle: "Start transit?",
+ confirmDescription: "The booking will move to in transit status.",
+ variant: "default",
+ icon: Truck,
+ primary: true,
+ },
+ ];
+ case "IN_TRANSIT":
+ return [
+ {
+ id: "complete",
+ label: "Complete booking",
+ shortLabel: "Complete",
+ description: "Mark journey finished",
+ confirmTitle: "Complete booking?",
+ confirmDescription:
+ "Marks the booking as completed. No further staff transitions apply.",
+ variant: "default",
+ icon: Play,
+ primary: true,
+ },
+ ];
+ default:
+ return [];
+ }
+}
+
+export function listRowHasActions(row: {
+ status: BookingStatus;
+ paymentCurrency: string;
+}): boolean {
+ const actions = getBookingActions({
+ status: row.status,
+ paymentCurrency: row.paymentCurrency,
+ reference: "",
+ });
+ if (actions.length > 0) return true;
+ return row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD";
+}
diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts
new file mode 100644
index 000000000..71892a5dc
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts
@@ -0,0 +1,260 @@
+import type { BookingStatus } from "@/types/booking";
+
+export interface StatusStyle {
+ label: string;
+ color: string;
+}
+
+export const BOOKING_STATUS_STYLES: Record = {
+ DRAFT: {
+ label: "Draft",
+ color: "bg-slate-100 text-slate-700 border-slate-300",
+ },
+ SUBMITTED: {
+ label: "Submitted",
+ color: "bg-amber-50 text-amber-700 border-amber-200",
+ },
+ CHANGES_REQUESTED: {
+ label: "Changes Requested",
+ color: "bg-orange-50 text-orange-700 border-orange-200",
+ },
+ PENDING_APPROVAL: {
+ label: "Pending Approval",
+ color: "bg-amber-50 text-amber-700 border-amber-200",
+ },
+ APPROVED_PENDING_SIGNATURE: {
+ label: "Pending Signature",
+ color: "bg-sky-50 text-sky-700 border-sky-200",
+ },
+ APPROVED: {
+ label: "Approved",
+ color: "bg-emerald-50 text-emerald-700 border-emerald-200",
+ },
+ CONTRACT_READY: {
+ label: "Contract Ready",
+ color: "bg-indigo-50 text-indigo-700 border-indigo-200",
+ },
+ SIGNED_CUSTOMER: {
+ label: "Customer Signed",
+ color: "bg-sky-50 text-sky-700 border-sky-200",
+ },
+ FULLY_EXECUTED: {
+ label: "Fully Executed",
+ color: "bg-indigo-50 text-indigo-700 border-indigo-200",
+ },
+ PNR_GENERATED: {
+ label: "PNR Generated",
+ color: "bg-violet-50 text-violet-700 border-violet-200",
+ },
+ PAYMENT_VERIFICATION_IN_PROGRESS: {
+ label: "Payment Verification",
+ color: "bg-amber-50 text-amber-800 border-amber-200",
+ },
+ PAID: {
+ label: "Paid",
+ color: "bg-emerald-50 text-emerald-700 border-emerald-200",
+ },
+ IN_TRANSIT: {
+ label: "In Transit",
+ color: "bg-sky-50 text-sky-700 border-sky-200",
+ },
+ COMPLETED: {
+ label: "Completed",
+ color: "bg-indigo-50 text-indigo-700 border-indigo-200",
+ },
+ REJECTED: {
+ label: "Rejected",
+ color: "bg-red-50 text-red-700 border-red-200",
+ },
+ CANCELLED: {
+ label: "Cancelled",
+ color: "bg-red-50 text-red-700 border-red-200",
+ },
+ PENDING_CONSOLIDATION: {
+ label: "Pending Consolidation",
+ color: "bg-amber-50 text-amber-700 border-amber-200",
+ },
+ CONSOLIDATED: {
+ label: "Consolidated",
+ color: "bg-indigo-50 text-indigo-700 border-indigo-200",
+ },
+};
+
+export interface StatusMeta {
+ title: string;
+ description: string;
+ color: string;
+ stage: number;
+}
+
+export const BOOKING_STATUS_META: Record = {
+ DRAFT: {
+ title: "Draft",
+ description: "Booking is being prepared by the customer.",
+ color: "text-slate-500",
+ stage: 0,
+ },
+ SUBMITTED: {
+ title: "Submitted",
+ description: "Awaiting staff review.",
+ color: "text-amber-600",
+ stage: 0,
+ },
+ CHANGES_REQUESTED: {
+ title: "Changes Requested",
+ description: "Returned to customer for updates.",
+ color: "text-orange-600",
+ stage: 0,
+ },
+ PENDING_APPROVAL: {
+ title: "Pending Approval",
+ description: "Moving through internal approval chain.",
+ color: "text-amber-600",
+ stage: 1,
+ },
+ APPROVED_PENDING_SIGNATURE: {
+ title: "Pending Signature",
+ description: "Awaiting director or CEO signature steps.",
+ color: "text-sky-600",
+ stage: 1,
+ },
+ APPROVED: {
+ title: "Approved",
+ description: "Ready to generate contract.",
+ color: "text-emerald-600",
+ stage: 2,
+ },
+ CONTRACT_READY: {
+ title: "Contract Ready",
+ description: "Contract generated; awaiting customer signature.",
+ color: "text-indigo-600",
+ stage: 2,
+ },
+ SIGNED_CUSTOMER: {
+ title: "Customer Signed",
+ description: "Awaiting contract execution.",
+ color: "text-sky-600",
+ stage: 2,
+ },
+ FULLY_EXECUTED: {
+ title: "Fully Executed",
+ description: "Contract locked; proceed to payment.",
+ color: "text-indigo-600",
+ stage: 3,
+ },
+ PNR_GENERATED: {
+ title: "PNR Generated",
+ description: "ETB payment reference issued.",
+ color: "text-violet-600",
+ stage: 3,
+ },
+ PAYMENT_VERIFICATION_IN_PROGRESS: {
+ title: "Payment Verification",
+ description: "USD payment proof under review.",
+ color: "text-amber-700",
+ stage: 3,
+ },
+ PAID: {
+ title: "Paid",
+ description: "Payment confirmed; ready for operations.",
+ color: "text-emerald-600",
+ stage: 4,
+ },
+ IN_TRANSIT: {
+ title: "In Transit",
+ description: "Shipment is on the railway network.",
+ color: "text-sky-600",
+ stage: 4,
+ },
+ COMPLETED: {
+ title: "Completed",
+ description: "Booking fulfilled.",
+ color: "text-indigo-600",
+ stage: 5,
+ },
+ REJECTED: {
+ title: "Rejected",
+ description: "Booking was rejected.",
+ color: "text-red-600",
+ stage: -1,
+ },
+ CANCELLED: {
+ title: "Cancelled",
+ description: "Booking was cancelled.",
+ color: "text-red-600",
+ stage: -1,
+ },
+ PENDING_CONSOLIDATION: {
+ title: "Pending Consolidation",
+ description: "Waiting for consolidation partner.",
+ color: "text-amber-600",
+ stage: 4,
+ },
+ CONSOLIDATED: {
+ title: "Consolidated",
+ description: "Paired with another booking.",
+ color: "text-indigo-600",
+ stage: 4,
+ },
+};
+
+export const BOOKING_LIST_TABS = [
+ { key: "all", label: "All bookings", status: null },
+ { key: "SUBMITTED", label: "Submitted", status: "SUBMITTED" },
+ { key: "PENDING_APPROVAL", label: "Pending Approval", status: "PENDING_APPROVAL" },
+ {
+ key: "APPROVED_PENDING_SIGNATURE",
+ label: "Pending Signature",
+ status: "APPROVED_PENDING_SIGNATURE",
+ },
+ { key: "SIGNED_CUSTOMER", label: "Customer Signed", status: "SIGNED_CUSTOMER" },
+ {
+ key: "PAYMENT_VERIFICATION_IN_PROGRESS",
+ label: "Payment Verification",
+ status: "PAYMENT_VERIFICATION_IN_PROGRESS",
+ },
+] as const;
+
+export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"];
+
+export const WORKFLOW_STAGES = [
+ { label: "Submission", statuses: ["DRAFT", "SUBMITTED", "CHANGES_REQUESTED"] },
+ {
+ label: "Approval",
+ statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE"],
+ },
+ {
+ label: "Contract",
+ statuses: ["APPROVED", "CONTRACT_READY", "SIGNED_CUSTOMER", "FULLY_EXECUTED"],
+ },
+ {
+ label: "Payment",
+ statuses: [
+ "PNR_GENERATED",
+ "PAYMENT_VERIFICATION_IN_PROGRESS",
+ "PAID",
+ ],
+ },
+ {
+ label: "Operations",
+ statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
+ },
+ { label: "Done", statuses: ["COMPLETED"] },
+] as const;
+
+export function getStatusMeta(status: BookingStatus | string): StatusMeta {
+ return (
+ BOOKING_STATUS_META[status] ?? {
+ title: status,
+ description: "",
+ color: "text-muted-foreground",
+ stage: 0,
+ }
+ );
+}
+
+export function getWorkflowStageIndex(status: BookingStatus | string): number {
+ const meta = getStatusMeta(status);
+ if (meta.stage < 0) return -1;
+ return meta.stage;
+}
diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts
new file mode 100644
index 000000000..6ad2a0cc7
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts
@@ -0,0 +1,35 @@
+import type { BookingDetail, BookingListRow } from "@/types/booking";
+
+function labelFromRef(
+ ref?: { name?: string; label?: string; code?: string; companyName?: string },
+ fallback = "—",
+): string {
+ if (!ref) return fallback;
+ return (
+ ref.companyName ??
+ ref.label ??
+ ref.name ??
+ ref.code ??
+ fallback
+ );
+}
+
+export function toBookingListRow(booking: BookingDetail): BookingListRow {
+ return {
+ id: booking.id,
+ reference: booking.reference,
+ customerLabel: labelFromRef(booking.company, booking.companyId),
+ // customerLabel: labelFromRef(booking.customer, booking.customerId),
+ status: booking.status,
+ scheduledDate: booking.scheduledDate,
+ totalAmount: Number(booking.totalAmount),
+ paymentCurrency: booking.paymentCurrency,
+ paymentStatus: booking.paymentStatus,
+ tradeDirection: booking.tradeDirection,
+ freightType: booking.freightType,
+ originLabel: labelFromRef(booking.originYard),
+ destinationLabel: labelFromRef(booking.destinationYard),
+ priorityScore: booking.priorityScore ?? 0,
+ createdAt: booking.createdAt,
+ };
+}
diff --git a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts
new file mode 100644
index 000000000..241fe5e43
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts
@@ -0,0 +1,178 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import toast from "react-hot-toast";
+
+import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
+import { api } from "@/services/api";
+import {
+ bookingsService,
+ type BookingListFilter,
+} from "@/services/bookings.service";
+import { invalidateBookingDetail } from "@/utils/queryInvalidation";
+
+export function useBookingList(filter?: BookingListFilter, enabled = true) {
+ return useQuery({
+ queryKey: QUERY_KEYS.BOOKINGS.list(filter),
+ queryFn: () => bookingsService.list(filter),
+ enabled,
+ });
+}
+
+export function useBookingDetail(id: string | undefined) {
+ return useQuery({
+ queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? ""),
+ queryFn: () => bookingsService.getById(id!),
+ enabled: Boolean(id),
+ });
+}
+
+export function useBookingMutations(bookingId: string) {
+ const qc = useQueryClient();
+ const onSuccess = (data: { id: string }, message: string) => {
+ toast.success(message);
+ void invalidateBookingDetail(qc, data.id);
+ };
+
+ const staffAccept = useMutation({
+ mutationFn: () => api.bookings.staffAccept.call({ id: bookingId }),
+ onSuccess: (data) => onSuccess(data, "Booking accepted for approval"),
+ onError: () => toast.error("Failed to accept booking"),
+ });
+
+ const requestChanges = useMutation({
+ mutationFn: (note: string) =>
+ api.bookings.requestChanges.call({ id: bookingId, note }),
+ onSuccess: (data) => onSuccess(data, "Changes requested from customer"),
+ onError: () => toast.error("Failed to request changes"),
+ });
+
+ const staffReject = useMutation({
+ mutationFn: (reason: string) =>
+ api.bookings.staffReject.call({ id: bookingId, reason }),
+ onSuccess: (data) => onSuccess(data, "Booking rejected"),
+ onError: () => toast.error("Failed to reject booking"),
+ });
+
+ const approveStep = useMutation({
+ mutationFn: ({
+ stepId,
+ requiredRole,
+ }: {
+ stepId: string;
+ requiredRole: string;
+ }) =>
+ api.bookings.approveStep.call({
+ id: bookingId,
+ stepId,
+ requiredRole,
+ }),
+ onSuccess: (data) => onSuccess(data, "Approval step completed"),
+ onError: () => toast.error("Failed to approve step"),
+ });
+
+ const rejectStep = useMutation({
+ mutationFn: ({
+ stepId,
+ reason,
+ }: {
+ stepId: string;
+ reason: string;
+ }) =>
+ api.bookings.rejectStep.call({
+ id: bookingId,
+ stepId,
+ reason,
+ }),
+ onSuccess: (data) => onSuccess(data, "Booking rejected at approval step"),
+ onError: () => toast.error("Failed to reject step"),
+ });
+
+ const generateContract = useMutation({
+ mutationFn: () => api.bookings.generateContract.call({ id: bookingId }),
+ onSuccess: (data) => onSuccess(data, "Contract generated"),
+ onError: () => toast.error("Failed to generate contract"),
+ });
+
+ const signContract = useMutation({
+ mutationFn: (payload: {
+ role: "CUSTOMER" | "STAFF";
+ signatureImageBase64: string;
+ signerDisplayName: string;
+ consentText?: string;
+ }) => bookingsService.signContract(bookingId, payload),
+ onSuccess: (data) => onSuccess(data, "Contract signed"),
+ onError: () => toast.error("Failed to sign contract"),
+ });
+
+ const generatePnr = useMutation({
+ mutationFn: () => api.bookings.generatePnr.call({ id: bookingId }),
+ onSuccess: (data) => onSuccess(data, "PNR generated"),
+ onError: () => toast.error("Failed to generate PNR"),
+ });
+
+ const submitPaymentProof = useMutation({
+ mutationFn: (file: File) =>
+ bookingsService.submitPaymentProof(bookingId, file),
+ onSuccess: (data) => onSuccess(data, "Payment proof uploaded"),
+ onError: () => toast.error("Failed to upload payment proof"),
+ });
+
+ const verifyPayment = useMutation({
+ mutationFn: () => api.bookings.verifyPayment.call({ id: bookingId }),
+ onSuccess: (data) => onSuccess(data, "Payment verified"),
+ onError: () => toast.error("Failed to verify payment"),
+ });
+
+ const startTransit = useMutation({
+ mutationFn: () => api.bookings.startTransit.call({ id: bookingId }),
+ onSuccess: (data) => onSuccess(data, "Marked in transit"),
+ onError: () => toast.error("Failed to start transit"),
+ });
+
+ const complete = useMutation({
+ mutationFn: () => api.bookings.complete.call({ id: bookingId }),
+ onSuccess: (data) => onSuccess(data, "Booking completed"),
+ onError: () => toast.error("Failed to complete booking"),
+ });
+
+ const cancel = useMutation({
+ mutationFn: (reason: string) =>
+ api.bookings.cancel.call({ id: bookingId, reason }),
+ onSuccess: (data) => onSuccess(data, "Booking cancelled"),
+ onError: () => toast.error("Failed to cancel booking"),
+ });
+
+ const isPending =
+ staffAccept.isPending ||
+ requestChanges.isPending ||
+ staffReject.isPending ||
+ approveStep.isPending ||
+ rejectStep.isPending ||
+ generateContract.isPending ||
+ signContract.isPending ||
+ generatePnr.isPending ||
+ submitPaymentProof.isPending ||
+ verifyPayment.isPending ||
+ startTransit.isPending ||
+ complete.isPending ||
+ cancel.isPending;
+
+ return {
+ staffAccept,
+ requestChanges,
+ staffReject,
+ approveStep,
+ rejectStep,
+ generateContract,
+ signContract,
+ generatePnr,
+ submitPaymentProof,
+ verifyPayment,
+ startTransit,
+ complete,
+ cancel,
+ isPending,
+ downloadContract: () => bookingsService.downloadContract(bookingId),
+ downloadPaymentLetter: () =>
+ bookingsService.downloadPaymentRequestLetter(bookingId),
+ };
+}
diff --git a/apps/edr-freight-web/backoffice/src/hooks/hooks/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/hooks/useBookings.ts
deleted file mode 100644
index 7f353d50a..000000000
--- a/apps/edr-freight-web/backoffice/src/hooks/hooks/useBookings.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-
-import { bookingsService } from "../services/bookings.service";
-
-export const useBookings = () =>
- useQuery({
- queryKey: ["bookings"],
- queryFn: bookingsService.list,
- });
-
-export const useBooking = (id: string) =>
- useQuery({
- queryKey: ["bookings", id],
- queryFn: () => bookingsService.get(id),
- enabled: Boolean(id),
- });
diff --git a/apps/edr-freight-web/backoffice/src/hooks/hooks/useConsignments.ts b/apps/edr-freight-web/backoffice/src/hooks/hooks/useConsignments.ts
deleted file mode 100644
index 593c6be5d..000000000
--- a/apps/edr-freight-web/backoffice/src/hooks/hooks/useConsignments.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-
-import { consignmentsService } from "../services/consignments.service";
-
-export const useConsignments = () =>
- useQuery({
- queryKey: ["consignments"],
- queryFn: consignmentsService.list,
- });
-
-export const useConsignment = (id: string) =>
- useQuery({
- queryKey: ["consignments", id],
- queryFn: () => consignmentsService.get(id),
- enabled: Boolean(id),
- });
diff --git a/apps/edr-freight-web/backoffice/src/hooks/hooks/useCustomers.ts b/apps/edr-freight-web/backoffice/src/hooks/hooks/useCustomers.ts
deleted file mode 100644
index ec26af310..000000000
--- a/apps/edr-freight-web/backoffice/src/hooks/hooks/useCustomers.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-
-import { customersService } from "@/services/customers.service";
-import type {
- CreateCustomerDto,
- UpdateCustomerDto,
-} from "@/types/customers";
-
-const KEY = ["customers"] as const;
-
-export const useCustomers = () =>
- useQuery({
- queryKey: KEY,
- queryFn: customersService.list,
- });
-
-export const useCustomer = (id: string | undefined) =>
- useQuery({
- queryKey: [...KEY, "id", id],
- queryFn: () => customersService.getById(id!),
- enabled: Boolean(id),
- });
-
-export const useCreateCustomer = () => {
- const qc = useQueryClient();
- return useMutation({
- mutationFn: (dto: CreateCustomerDto) => customersService.create(dto),
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
- });
-};
-
-export const useUpdateCustomer = () => {
- const qc = useQueryClient();
- return useMutation({
- mutationFn: ({ id, dto }: { id: string; dto: UpdateCustomerDto }) =>
- customersService.update(id, dto),
- onSuccess: (_data, { id }) => {
- qc.invalidateQueries({ queryKey: KEY });
- qc.invalidateQueries({ queryKey: [...KEY, "id", id] });
- },
- });
-};
-
-export const useDeleteCustomer = () => {
- const qc = useQueryClient();
- return useMutation({
- mutationFn: (id: string) => customersService.remove(id),
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
- });
-};
diff --git a/apps/edr-freight-web/backoffice/src/hooks/hooks/useTracking.ts b/apps/edr-freight-web/backoffice/src/hooks/hooks/useTracking.ts
deleted file mode 100644
index 403dac071..000000000
--- a/apps/edr-freight-web/backoffice/src/hooks/hooks/useTracking.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-
-import { trackingService } from "../services/tracking.service";
-
-export const useTracking = (consignmentId: string) =>
- useQuery({
- queryKey: ["tracking", consignmentId],
- queryFn: () => trackingService.forConsignment(consignmentId),
- enabled: Boolean(consignmentId),
- });
diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
index 1d6360209..a8f2536b9 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
@@ -1,17 +1,18 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
-import { QUERY_KEYS } from "@/constants/TANSTACK_QUEY_KEY";
-import {
- ruleEngineService,
- type RuleEngineListParams,
-} from "@/services/ruleEngine/ruleEngine.service";
+import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
+import { api } from "@/services/api";
+import { ruleEngineService, type RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources";
import type {
- ApproveRatePayload,
RuleEngineRecord,
RuleEngineResourceSlug,
} from "@/types/rule-engine";
+import {
+ invalidateRuleEngineList,
+ patchRuleEngineListRecord,
+} from "@/utils/queryInvalidation";
const CARGO_TYPE_PARENT_PAGE_SIZE = 500;
const CONTAINER_TYPE_OPTIONS_PAGE_SIZE = 500;
@@ -21,17 +22,13 @@ export const useRuleEngineList = (
params: RuleEngineListParams,
) =>
useQuery({
- queryKey: [...QUERY_KEYS.RULE_ENGINE.list(resource), params],
- queryFn: () => ruleEngineService.list(resource, params),
+ queryKey: QUERY_KEYS.RULE_ENGINE.list(resource, params),
+ queryFn: () => ruleEngineService.list(resource, params),
});
export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
useQuery({
- queryKey: [
- ...QUERY_KEYS.RULE_ENGINE.list("cargo-types"),
- "parent-options",
- excludeId ?? "",
- ],
+ queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("cargo-types"),
queryFn: () =>
ruleEngineService.list("cargo-types", {
page: 1,
@@ -53,34 +50,72 @@ export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
},
});
-export const useContainerTypeOptions = (enabled = true) =>
+export function buildContainerTypeSelectOptions(
+ rows: RuleEngineRecord[],
+ includeNone: boolean,
+): { label: string; value: string }[] {
+ const options = rows
+ .filter((row) => row.id)
+ .map((row) => {
+ const label = String(row.label ?? "").trim();
+ const code = String(row.code ?? "").trim();
+ const size = row.sizeFt ? `${String(row.sizeFt)}ft` : "";
+ const parts = [label || code || String(row.id), size].filter(Boolean);
+ return {
+ label: parts.join(" - "),
+ value: String(row.id),
+ };
+ });
+
+ if (!includeNone) return options;
+ return [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...options];
+}
+
+export const useContainerTypeOptions = (
+ includeNone = true,
+ enabled = true,
+) =>
useQuery({
- queryKey: [
- ...QUERY_KEYS.RULE_ENGINE.list("container-types"),
- "select-options",
- ],
+ queryKey: api.ruleEngine.list.queryKey(),
queryFn: () =>
- ruleEngineService.list("container-types", {
- page: 1,
- pageSize: CONTAINER_TYPE_OPTIONS_PAGE_SIZE,
+ api.ruleEngine.list.call({
+ resource: "container-types",
+ params: {
+ page: 1,
+ pageSize: CONTAINER_TYPE_OPTIONS_PAGE_SIZE,
+ },
}),
enabled,
- select: (result) => {
- const noneOption = { label: "None", value: RULE_ENGINE_SELECT_NONE };
- const options = (result.data ?? []).map((row) => {
- const label = String(row.label ?? "").trim();
- const code = String(row.code ?? "").trim();
- const size = row.sizeFt ? `${String(row.sizeFt)}ft` : "";
- const parts = [label || code || String(row.id), size].filter(Boolean);
+ select: (result) =>
+ buildContainerTypeSelectOptions(result.data ?? [], includeNone),
+ });
- return {
- label: parts.join(" - "),
- value: String(row.id),
- };
- });
+const LIVE_RATE_PAGE_SIZE = 500;
- return [noneOption, ...options];
- },
+export const useLiveRateOptions = (enabled = true) =>
+ useQuery({
+ queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("rates", { status: "LIVE" }),
+ queryFn: () =>
+ ruleEngineService.list("rates", {
+ page: 1,
+ pageSize: LIVE_RATE_PAGE_SIZE,
+ status: "LIVE",
+ }),
+ enabled,
+ select: (result) =>
+ (result.data ?? [])
+ .filter((row) => row.id)
+ .map((row) => {
+ const rateType = String(row.rateType ?? "").replace(/_/g, " ");
+ const currency = String(row.currency ?? "");
+ const value = row.rateValue != null ? String(row.rateValue) : "";
+ const unit = row.rateUnit ? String(row.rateUnit).replace(/_/g, " ") : "";
+ const parts = [rateType, currency, value, unit].filter(Boolean);
+ return {
+ label: parts.join(" · "),
+ value: String(row.id),
+ };
+ }),
});
export const useApprovalChain = (enabled: boolean) =>
@@ -92,15 +127,14 @@ export const useApprovalChain = (enabled: boolean) =>
export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
const qc = useQueryClient();
- const invalidate = () =>
- qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.list(resource) });
const create = useMutation({
mutationFn: (payload: Record) =>
- ruleEngineService.create(resource, payload),
- onSuccess: () => {
+ api.ruleEngine.create.call({ resource, payload }),
+ onSuccess: async (created) => {
toast.success("Created successfully");
- invalidate();
+ patchRuleEngineListRecord(qc, resource, created);
+ await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to create record"),
});
@@ -112,19 +146,21 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
}: {
id: string;
payload: Record;
- }) => ruleEngineService.update(resource, id, payload),
- onSuccess: () => {
+ }) => api.ruleEngine.update.call({ resource, id, payload }),
+ onSuccess: async (updated) => {
toast.success("Updated successfully");
- invalidate();
+ patchRuleEngineListRecord(qc, resource, updated);
+ await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to update record"),
});
const remove = useMutation({
- mutationFn: (id: string) => ruleEngineService.remove(resource, id),
- onSuccess: () => {
+ mutationFn: (id: string) =>
+ api.ruleEngine.remove.call({ resource, id }),
+ onSuccess: async () => {
toast.success("Deleted successfully");
- invalidate();
+ await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to delete record"),
});
@@ -134,24 +170,23 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
export const useRateWorkflow = () => {
const qc = useQueryClient();
- const invalidate = () =>
- qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.list("rates") });
const submit = useMutation({
- mutationFn: (id: string) => ruleEngineService.submitRate(id),
- onSuccess: () => {
+ mutationFn: (id: string) => api.ruleEngine.submitRate.call({ id }),
+ onSuccess: async (updated) => {
toast.success("Rate submitted for approval");
- invalidate();
+ patchRuleEngineListRecord(qc, "rates", updated);
+ await invalidateRuleEngineList(qc, "rates");
},
onError: () => toast.error("Failed to submit rate"),
});
const approve = useMutation({
- mutationFn: ({ id, payload }: { id: string; payload: ApproveRatePayload }) =>
- ruleEngineService.approveRate(id, payload),
- onSuccess: () => {
+ mutationFn: (id: string) => api.ruleEngine.approveRate.call({ id }),
+ onSuccess: async (updated) => {
toast.success("Rate approved");
- invalidate();
+ patchRuleEngineListRecord(qc, "rates", updated);
+ await invalidateRuleEngineList(qc, "rates");
},
onError: () => toast.error("Failed to approve rate"),
});
diff --git a/apps/edr-freight-web/backoffice/src/hooks/use-toast.ts b/apps/edr-freight-web/backoffice/src/hooks/use-toast.ts
new file mode 100644
index 000000000..7b15a028d
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/hooks/use-toast.ts
@@ -0,0 +1,24 @@
+import toast from 'react-hot-toast';
+
+interface ToastOptions {
+ title?: string;
+ description?: string;
+ variant?: 'default' | 'destructive';
+ duration?: number;
+}
+
+export function useToast() {
+ const showToast = (options: ToastOptions) => {
+ const { title, description, variant = 'default', duration = 3000 } = options;
+
+ const message = title ? `${title}${description ? ': ' + description : ''}` : description || '';
+
+ if (variant === 'destructive') {
+ toast.error(message, { duration });
+ } else {
+ toast.success(message, { duration });
+ }
+ };
+
+ return { toast: showToast };
+}
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/useBookings.ts
new file mode 100644
index 000000000..81e7bbc83
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/hooks/useBookings.ts
@@ -0,0 +1,5 @@
+export {
+ useBookingList,
+ useBookingDetail,
+ useBookingMutations,
+} from "./bookings/useBookings";
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useDropdownSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useDropdownSettings.ts
index c2b8a4423..256fc1702 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/useDropdownSettings.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/useDropdownSettings.ts
@@ -1,6 +1,6 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { dropdownSettingsService } from "@/services/dropdownSettings.service";
+import { api } from "@/services/api";
import type {
CreateDropdownOptionDto,
CreateDropdownSettingDto,
@@ -8,38 +8,15 @@ import type {
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
-const KEY = ["dropdown-settings"] as const;
-
-/* ------------------------------ Queries ------------------------------ */
-
-export const useDropdownSettings = () =>
- useQuery({
- queryKey: KEY,
- queryFn: dropdownSettingsService.list,
- });
-
-export const useDropdownSetting = (id: string) =>
- useQuery({
- queryKey: [...KEY, "id", id],
- queryFn: () => dropdownSettingsService.getById(id),
- enabled: Boolean(id),
- });
-
-export const useDropdownSettingByCode = (code: string) =>
- useQuery({
- queryKey: [...KEY, "code", code],
- queryFn: () => dropdownSettingsService.getByCode(code),
- enabled: Boolean(code),
- });
-
/* ----------------------------- Mutations ----------------------------- */
export const useCreateDropdownSetting = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: CreateDropdownSettingDto) =>
- dropdownSettingsService.create(dto),
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
+ api.dropdownSettings.create.call(dto),
+ onSuccess: () =>
+ qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }),
});
};
@@ -52,10 +29,12 @@ export const useUpdateDropdownSetting = () => {
}: {
id: string;
dto: UpdateDropdownSettingDto;
- }) => dropdownSettingsService.update(id, dto),
+ }) => api.dropdownSettings.update.call({ id, dto }),
onSuccess: (_data, { id }) => {
- qc.invalidateQueries({ queryKey: KEY });
- qc.invalidateQueries({ queryKey: [...KEY, "id", id] });
+ qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() });
+ qc.invalidateQueries({
+ queryKey: api.dropdownSettings.getById.queryKey({ id }),
+ });
},
});
};
@@ -63,8 +42,9 @@ export const useUpdateDropdownSetting = () => {
export const useDeleteDropdownSetting = () => {
const qc = useQueryClient();
return useMutation({
- mutationFn: (id: string) => dropdownSettingsService.remove(id),
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
+ mutationFn: (id: string) => api.dropdownSettings.remove.call({ id }),
+ onSuccess: () =>
+ qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }),
});
};
@@ -77,10 +57,12 @@ export const useReplaceDropdownOptions = () => {
}: {
settingId: string;
options: CreateDropdownOptionDto[];
- }) => dropdownSettingsService.replaceOptions(settingId, options),
+ }) => api.dropdownSettings.replaceOptions.call({ id: settingId, options }),
onSuccess: (_data, { settingId }) => {
- qc.invalidateQueries({ queryKey: KEY });
- qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] });
+ qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() });
+ qc.invalidateQueries({
+ queryKey: api.dropdownSettings.getById.queryKey({ id: settingId }),
+ });
},
});
};
@@ -94,10 +76,12 @@ export const useAddDropdownOption = () => {
}: {
settingId: string;
dto: CreateDropdownOptionDto;
- }) => dropdownSettingsService.addOption(settingId, dto),
+ }) => api.dropdownSettings.addOption.call({ id: settingId, dto }),
onSuccess: (_data, { settingId }) => {
- qc.invalidateQueries({ queryKey: KEY });
- qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] });
+ qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() });
+ qc.invalidateQueries({
+ queryKey: api.dropdownSettings.getById.queryKey({ id: settingId }),
+ });
},
});
};
@@ -111,8 +95,9 @@ export const useUpdateDropdownOption = () => {
}: {
optionId: string;
dto: UpdateDropdownOptionDto;
- }) => dropdownSettingsService.updateOption(optionId, dto),
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
+ }) => api.dropdownSettings.updateOption.call({ optionId, dto }),
+ onSuccess: () =>
+ qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }),
});
};
@@ -120,7 +105,8 @@ export const useRemoveDropdownOption = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (optionId: string) =>
- dropdownSettingsService.removeOption(optionId),
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
+ api.dropdownSettings.removeOption.call({ optionId }),
+ onSuccess: () =>
+ qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }),
});
};
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useFileUploadSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useFileUploadSettings.ts
index f916915f9..748de4f88 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/useFileUploadSettings.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/useFileUploadSettings.ts
@@ -1,6 +1,6 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { fileUploadSettingsService } from "@/services/fileUploadSettings.service";
+import { api } from "@/services/api";
import type {
CreateFileUploadFieldDto,
CreateFileUploadSettingDto,
@@ -8,38 +8,17 @@ import type {
UpdateFileUploadSettingDto,
} from "@/types/fileUploadSettings";
-const KEY = ["file-upload-settings"] as const;
-
-/* ------------------------------ Queries ------------------------------ */
-
-export const useFileUploadSettings = () =>
- useQuery({
- queryKey: KEY,
- queryFn: fileUploadSettingsService.list,
- });
-
-export const useFileUploadSetting = (id: string) =>
- useQuery({
- queryKey: [...KEY, "id", id],
- queryFn: () => fileUploadSettingsService.getById(id),
- enabled: Boolean(id),
- });
-
-export const useFileUploadSettingByCode = (code: string) =>
- useQuery({
- queryKey: [...KEY, "code", code],
- queryFn: () => fileUploadSettingsService.getByCode(code),
- enabled: Boolean(code),
- });
-
/* ----------------------------- Mutations ----------------------------- */
export const useCreateFileUploadSetting = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (dto: CreateFileUploadSettingDto) =>
- fileUploadSettingsService.create(dto),
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
+ api.fileUploadSettings.create.call(dto),
+ onSuccess: () =>
+ qc.invalidateQueries({
+ queryKey: api.fileUploadSettings.list.queryKey(),
+ }),
});
};
@@ -52,10 +31,14 @@ export const useUpdateFileUploadSetting = () => {
}: {
id: string;
dto: UpdateFileUploadSettingDto;
- }) => fileUploadSettingsService.update(id, dto),
+ }) => api.fileUploadSettings.update.call({ id, dto }),
onSuccess: (_data, { id }) => {
- qc.invalidateQueries({ queryKey: KEY });
- qc.invalidateQueries({ queryKey: [...KEY, "id", id] });
+ qc.invalidateQueries({
+ queryKey: api.fileUploadSettings.list.queryKey(),
+ });
+ qc.invalidateQueries({
+ queryKey: api.fileUploadSettings.getById.queryKey({ id }),
+ });
},
});
};
@@ -63,8 +46,11 @@ export const useUpdateFileUploadSetting = () => {
export const useDeleteFileUploadSetting = () => {
const qc = useQueryClient();
return useMutation({
- mutationFn: (id: string) => fileUploadSettingsService.remove(id),
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
+ mutationFn: (id: string) => api.fileUploadSettings.remove.call({ id }),
+ onSuccess: () =>
+ qc.invalidateQueries({
+ queryKey: api.fileUploadSettings.list.queryKey(),
+ }),
});
};
@@ -77,10 +63,14 @@ export const useReplaceFileUploadFields = () => {
}: {
settingId: string;
fields: CreateFileUploadFieldDto[];
- }) => fileUploadSettingsService.replaceFields(settingId, fields),
+ }) => api.fileUploadSettings.replaceFields.call({ id: settingId, fields }),
onSuccess: (_data, { settingId }) => {
- qc.invalidateQueries({ queryKey: KEY });
- qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] });
+ qc.invalidateQueries({
+ queryKey: api.fileUploadSettings.list.queryKey(),
+ });
+ qc.invalidateQueries({
+ queryKey: api.fileUploadSettings.getById.queryKey({ id: settingId }),
+ });
},
});
};
@@ -94,10 +84,14 @@ export const useAddFileUploadField = () => {
}: {
settingId: string;
dto: CreateFileUploadFieldDto;
- }) => fileUploadSettingsService.addField(settingId, dto),
+ }) => api.fileUploadSettings.addField.call({ settingId, dto }),
onSuccess: (_data, { settingId }) => {
- qc.invalidateQueries({ queryKey: KEY });
- qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] });
+ qc.invalidateQueries({
+ queryKey: api.fileUploadSettings.list.queryKey(),
+ });
+ qc.invalidateQueries({
+ queryKey: api.fileUploadSettings.getById.queryKey({ id: settingId }),
+ });
},
});
};
@@ -111,8 +105,11 @@ export const useUpdateFileUploadField = () => {
}: {
fieldId: string;
dto: UpdateFileUploadFieldDto;
- }) => fileUploadSettingsService.updateField(fieldId, dto),
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
+ }) => api.fileUploadSettings.updateField.call({ fieldId, dto }),
+ onSuccess: () =>
+ qc.invalidateQueries({
+ queryKey: api.fileUploadSettings.list.queryKey(),
+ }),
});
};
@@ -120,7 +117,10 @@ export const useRemoveFileUploadField = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (fieldId: string) =>
- fileUploadSettingsService.removeField(fieldId),
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
+ api.fileUploadSettings.removeField.call({ fieldId }),
+ onSuccess: () =>
+ qc.invalidateQueries({
+ queryKey: api.fileUploadSettings.list.queryKey(),
+ }),
});
};
diff --git a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts
new file mode 100644
index 000000000..9ac8402d8
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts
@@ -0,0 +1,12 @@
+import { QueryClient } from "@tanstack/react-query";
+
+/** Single app-wide React Query client (do not nest additional providers). */
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: 1,
+ refetchOnWindowFocus: false,
+ staleTime: 30_000,
+ },
+ },
+});
diff --git a/apps/edr-freight-web/backoffice/src/main.tsx b/apps/edr-freight-web/backoffice/src/main.tsx
index f2418ba0b..7ab0fcbf8 100644
--- a/apps/edr-freight-web/backoffice/src/main.tsx
+++ b/apps/edr-freight-web/backoffice/src/main.tsx
@@ -9,7 +9,8 @@ import { Toaster } from "react-hot-toast";
import App from "./App";
import { AuthProvider } from "./auth/AuthProvider";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { queryClient } from "./lib/queryClient";
const THEME_STORAGE_KEY = "edr-theme";
@@ -38,8 +39,6 @@ if (!rootElement) {
throw new Error("Root element not found");
}
-const queryClient = new QueryClient();
-
createRoot(rootElement).render(
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx
new file mode 100644
index 000000000..32a7d9750
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx
@@ -0,0 +1,218 @@
+import { useCallback, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ ArrowLeft,
+ Download,
+ FileSignature,
+ Loader2,
+ Printer,
+} from "lucide-react";
+import toast from "react-hot-toast";
+
+import Breadcrumbs from "@/components/ui/Breadcrumbs";
+import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
+import { bookingSurface } from "@/components/bookings/booking-ui.styles";
+import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
+import { invalidateBookingDetail } from "@/utils/queryInvalidation";
+import {
+ bookingsService,
+ type ContractView,
+ type SignContractPayload,
+} from "@/services/bookings.service";
+import { cn } from "@/lib/utils";
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Input,
+ Label,
+} from "@edr/ui-common";
+
+export default function BookingContractPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const qc = useQueryClient();
+ const [signOpen, setSignOpen] = useState(false);
+ const [signerName, setSignerName] = useState("");
+ const [signatureData, setSignatureData] = useState(null);
+
+ const { data, isLoading, isError } = useQuery({
+ queryKey: [...QUERY_KEYS.BOOKINGS.byId(id ?? ""), "contract-view"],
+ queryFn: () => bookingsService.getContractView(id!),
+ enabled: Boolean(id),
+ });
+
+ const signRole: "CUSTOMER" | "STAFF" | null = data?.canSignCustomer
+ ? "CUSTOMER"
+ : data?.canSignStaff
+ ? "STAFF"
+ : null;
+
+ const signMutation = useMutation({
+ mutationFn: (payload: SignContractPayload) =>
+ bookingsService.signContract(id!, payload),
+ onSuccess: async () => {
+ toast.success("Signature recorded");
+ setSignOpen(false);
+ await invalidateBookingDetail(qc, id!);
+ qc.invalidateQueries({
+ queryKey: [...QUERY_KEYS.BOOKINGS.byId(id ?? ""), "contract-view"],
+ });
+ },
+ onError: () => toast.error("Failed to sign contract"),
+ });
+
+ const downloadPdf = useCallback(async () => {
+ if (!id) return;
+ try {
+ const blob = await bookingsService.downloadContractDocument(id);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `contract-${data?.reference ?? id}.pdf`;
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch {
+ toast.error("Contract PDF not available. Ask staff to generate it first.");
+ }
+ }, [id, data?.reference]);
+
+ const handlePrint = () => window.print();
+
+ const openSign = () => {
+ setSignerName("");
+ setSignatureData(null);
+ setSignOpen(true);
+ };
+
+ const confirmSign = () => {
+ if (!signRole || !signatureData || !signerName.trim()) return;
+ signMutation.mutate({
+ role: signRole,
+ signatureImageBase64: signatureData,
+ signerDisplayName: signerName.trim(),
+ consentText: "I agree to the terms of this contract.",
+ });
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (isError || !data) {
+ return (
+
+
Could not load contract.
+
navigate(-1)}>
+ Go back
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
navigate(-1)}>
+
+ Back
+
+
+
+
+ Print
+
+
+
+ Download PDF
+
+ {signRole && (
+
+
+ Sign as {signRole === "CUSTOMER" ? "Customer" : "Staff"}
+
+ )}
+
+
+
+
+
+
+
+
+
+ {signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"}
+
+
+ Sign to execute the contract for {data.reference}.
+
+
+
+
+ Full name
+ setSignerName(e.target.value)}
+ placeholder="As shown on the contract"
+ />
+
+
+
+
+ setSignOpen(false)}>
+ Cancel
+
+
+ {signMutation.isPending ? (
+
+ ) : (
+ "Confirm signature"
+ )}
+
+
+
+
+
+
+ );
+}
+
+/** Render server HTML body content inside our layout wrapper. */
+function extractBodyHtml(fullHtml: string): string {
+ const match = fullHtml.match(/]*>([\s\S]*)<\/body>/i);
+ return match ? match[1] : fullHtml;
+}
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
index 86b4f483e..e88e26e1d 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
@@ -1,640 +1,232 @@
-import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
- AlertCircle,
- AlertTriangle,
Anchor,
ArrowLeft,
ArrowRight,
+ Building2,
Calendar,
- Check,
- CheckCircle2,
Clock,
- FileSignature,
- FileText,
- History,
- Info,
+ Loader2,
MapPin,
Package,
- ShieldCheck,
- Ship,
- StickyNote,
+ FileSignature,
+ RefreshCw,
Train,
Truck,
Weight,
- X,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
-import { cn } from "@/lib/utils";
+import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
+import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
+import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
+import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
+import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
+import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
+import { bookingSurface } from "@/components/bookings/booking-ui.styles";
+import { getStatusMeta } from "@/features/bookings/booking-status.config";
+import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import {
- getBookingRequestById,
- getBookingRequests,
- saveBookingRequestsToStorage,
- updateBookingRequestStatus,
- BOOKING_STATUSES,
- type BookingRequest,
-} from "./booking-requests.mock";
+ useBookingDetail,
+ useBookingMutations,
+} from "@/hooks/bookings/useBookings";
+import type { BookingDetail } from "@/types/booking";
+import { cn } from "@/lib/utils";
import {
Badge,
Button,
- Card,
- CardHeader,
- CardTitle,
- CardDescription,
- CardContent,
Separator,
} from "@edr/ui-common";
-const STATUS_STYLES: Record = {
- DRAFT: {
- label: "Draft",
- color: "bg-slate-100 text-slate-700 border-slate-300",
- },
- RFQ_SUBMITTED: {
- label: "RFQ Submitted",
- color: "bg-amber-50 text-amber-700 border-amber-200",
- },
- QUOTATION_SENT: {
- label: "Quotation Sent",
- color: "bg-sky-50 text-sky-700 border-sky-200",
- },
- QUOTATION_APPROVED: {
- label: "Quotation Approved",
- color: "bg-emerald-50 text-emerald-700 border-emerald-200",
- },
- QUOTATION_REJECTED: {
- label: "Quotation Rejected",
- color: "bg-red-50 text-red-700 border-red-200",
- },
- PENDING_APPROVAL: {
- label: "Pending Approval",
- color: "bg-amber-50 text-amber-700 border-amber-200",
- },
- APPROVED: {
- label: "Approved",
- color: "bg-emerald-50 text-emerald-700 border-emerald-200",
- },
- SIGNED_CUSTOMER: {
- label: "Customer Signed",
- color: "bg-sky-50 text-sky-700 border-sky-200",
- },
- FULLY_EXECUTED: {
- label: "Fully Executed",
- color: "bg-indigo-50 text-indigo-700 border-indigo-200",
- },
- PAID: {
- label: "Paid",
- color: "bg-emerald-50 text-emerald-700 border-emerald-200",
- },
- IN_TRANSIT: {
- label: "In Transit",
- color: "bg-sky-50 text-sky-700 border-sky-200",
- },
- COMPLETED: {
- label: "Completed",
- color: "bg-indigo-50 text-indigo-700 border-indigo-200",
- },
- CANCELLED: {
- label: "Cancelled",
- color: "bg-red-50 text-red-700 border-red-200",
- },
- PENDING_CONSOLIDATION: {
- label: "Pending Consolidation",
- color: "bg-amber-50 text-amber-700 border-amber-200",
- },
- CONSOLIDATED: {
- label: "Consolidated",
- color: "bg-indigo-50 text-indigo-700 border-indigo-200",
- },
-};
-
-const PROGRESS_STAGES = [
- { label: "Request", icon: FileText, statuses: ["DRAFT", "RFQ_SUBMITTED"] },
- {
- label: "Quotation",
- icon: ShieldCheck,
- statuses: ["QUOTATION_SENT", "QUOTATION_APPROVED", "QUOTATION_REJECTED"],
- },
- {
- label: "Approval",
- icon: FileSignature,
- statuses: ["PENDING_APPROVAL", "APPROVED"],
- },
- {
- label: "Execution",
- icon: CheckCircle2,
- statuses: ["SIGNED_CUSTOMER", "FULLY_EXECUTED", "PAID"],
- },
- {
- label: "In Transit",
- icon: Train,
- statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
- },
- { label: "Complete", icon: Check, statuses: ["COMPLETED"] },
-];
-
-const STATUS_CONFIG: Record<
- string,
- { title: string; description: string; color: string; stage: number }
-> = {
- DRAFT: {
- title: "Draft",
- description: "Booking is being prepared.",
- color: "text-slate-500",
- stage: 0,
- },
- RFQ_SUBMITTED: {
- title: "RFQ Submitted",
- description: "Customer has submitted a request for quotation.",
- color: "text-amber-600",
- stage: 0,
- },
- QUOTATION_SENT: {
- title: "Quotation Sent",
- description: "A formal quotation has been sent to the customer.",
- color: "text-sky-600",
- stage: 1,
- },
- QUOTATION_APPROVED: {
- title: "Quotation Approved",
- description: "Customer approved the quotation.",
- color: "text-emerald-600",
- stage: 1,
- },
- QUOTATION_REJECTED: {
- title: "Quotation Rejected",
- description: "Customer rejected the quotation.",
- color: "text-red-600",
- stage: 1,
- },
- PENDING_APPROVAL: {
- title: "Pending Approval",
- description: "Booking requires your approval to proceed.",
- color: "text-amber-600",
- stage: 2,
- },
- APPROVED: {
- title: "Approved",
- description: "Booking has been approved by all parties.",
- color: "text-emerald-600",
- stage: 2,
- },
- SIGNED_CUSTOMER: {
- title: "Customer Signed",
- description: "Customer has signed the contract.",
- color: "text-sky-600",
- stage: 3,
- },
- FULLY_EXECUTED: {
- title: "Fully Executed",
- description: "All parties have signed.",
- color: "text-indigo-600",
- stage: 3,
- },
- PAID: {
- title: "Paid",
- description: "Payment received.",
- color: "text-emerald-600",
- stage: 3,
- },
- IN_TRANSIT: {
- title: "In Transit",
- description: "Cargo is moving through the rail network.",
- color: "text-sky-600",
- stage: 4,
- },
- PENDING_CONSOLIDATION: {
- title: "Pending Consolidation",
- description: "Cargo awaiting consolidation.",
- color: "text-amber-500",
- stage: 4,
- },
- CONSOLIDATED: {
- title: "Consolidated",
- description: "Cargo merged into larger shipment.",
- color: "text-indigo-500",
- stage: 4,
- },
- COMPLETED: {
- title: "Completed",
- description: "Service completed successfully.",
- color: "text-emerald-600",
- stage: 5,
- },
- CANCELLED: {
- title: "Cancelled",
- description: "Booking terminated.",
- color: "text-red-600",
- stage: -1,
- },
-};
-
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
- const [booking, setBooking] = useState(
- id ? getBookingRequestById(id) : undefined,
- );
+ const { data: booking, isLoading, isError, refetch, isFetching } =
+ useBookingDetail(id);
+ const mutations = useBookingMutations(id ?? "");
- if (!booking) {
+ if (isLoading) {
return (
-
-
-
-
- Booking not found
-
- navigate("/dashboard/booking-requests")}
- >
-
- Back to Booking Requests
-
-
+
+
+
+
+ Loading booking…
+
+
);
}
- const statusConfig = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT;
- const currentStage = statusConfig.stage;
-
- const canApprove = ["PENDING_APPROVAL", "RFQ_SUBMITTED"].includes(
- booking.status,
- );
- const canReject = !["COMPLETED", "CANCELLED", "QUOTATION_REJECTED"].includes(
- booking.status,
- );
-
- function handleApprove() {
- if (!booking) return;
- const nextStatus =
- booking.status === "RFQ_SUBMITTED"
- ? ("QUOTATION_SENT" as const)
- : ("APPROVED" as const);
- updateBookingRequestStatus(booking.id, nextStatus);
- setBooking(getBookingRequestById(booking.id));
+ if (isError || !booking) {
+ return (
+
+
+
+
+
+ Booking not found
+
+
+ This request may have been removed or the link is invalid.
+
+
navigate("/dashboard/booking-requests")}
+ >
+
+ Back to booking requests
+
+
+
+
+ );
}
- function handleReject() {
- if (!booking) return;
- updateBookingRequestStatus(booking.id, "CANCELLED");
- setBooking(getBookingRequestById(booking.id));
- }
+ const row = toBookingListRow(booking);
+ const statusMeta = getStatusMeta(booking.status);
+ const amount = Number(booking.totalAmount);
return (
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
{booking.reference}
-
-
+
+
-
-
{booking.customer}
-
-
-
- Requested {booking.scheduledDate}
+
+
+
+ {row.customerLabel}
-
-
-
- {new Date(booking.createdAt).toLocaleDateString()}
+
+
+ Scheduled {booking.scheduledDate}
+
+
+
+ Created{" "}
+ {new Date(booking.createdAt).toLocaleDateString(undefined, {
+ dateStyle: "medium",
+ })}
-
-
-
- {canReject && (
-
-
- Reject
-
- )}
- {canApprove && (
-
-
- {booking.status === "RFQ_SUBMITTED"
- ? "Send Quotation"
- : "Approve"}
-
- )}
+
+
+
+ Total value
+
+
+ {booking.paymentCurrency}{" "}
+ {amount.toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ })}
+
+
+ {booking.paymentStatus}
+
+
+
refetch()}
+ >
+
+ Refresh
+
+
+
-
-
-
-
- Status Lifecycle
-
-
- Track the booking from request to completion
-
-
-
-
-
-
= 0
- ? `${(currentStage / (PROGRESS_STAGES.length - 1)) * 100}%`
- : "0%",
- }}
- />
-
- {PROGRESS_STAGES.map((stage, idx) => {
- const isCompleted = idx < currentStage;
- const isActive = idx === currentStage;
- return (
-
-
- {isCompleted ? (
-
- ) : (
-
- )}
-
-
- {stage.label}
-
-
- );
- })}
-
+
-
-
- {booking.status === "CANCELLED" ? (
-
- ) : (
-
- )}
-
-
-
- {statusConfig.title}
-
-
- {statusConfig.description}
-
-
-
-
-
-
-
-
-
-
-
-
- Route & Service
-
-
-
-
-
}
- />
-
-
-
- {booking.serviceType.replace(/_/g, " ")}
-
-
-
}
- />
-
-
-
- }
- label="Trade Direction"
- value={booking.tradeDirection}
- />
- }
- label="Return"
- value={
- booking.serviceType === "RAIL_AND_FORWARDING"
- ? "With Return"
- : "Without Return"
- }
- />
- {booking.shippingLine && (
- }
- label="Shipping Line"
- value={booking.shippingLine}
- />
- )}
-
-
-
-
- {(booking.firstMilePickupAddress ||
- booking.lastMileDeliveryAddress) && (
-
-
-
-
- Mile Services
-
-
-
- {booking.firstMilePickupAddress && (
-
-
- First Mile
-
-
-
- )}
- {booking.lastMileDeliveryAddress && (
-
-
- Last Mile
-
-
-
- )}
-
-
- )}
-
-
-
-
-
- Cargo Specifications
-
-
-
-
- }
- label="Type"
- value={booking.cargoType}
- />
- }
- label="Total Weight"
- value={`${booking.cargoTotalWeightVgm} Tons`}
- />
- {booking.shippingLine && (
- }
- label="Shipping Line"
- value={booking.shippingLine}
- />
- )}
-
-
-
-
- Hazardous: {booking.isHazardous ? "Yes" : "No"}
-
- {booking.pnrCode && (
-
- PNR: {booking.pnrCode}
-
- )}
-
-
-
+
+
+
+
+
+ {booking.contractSummary && (
+
}
+ title="Contract summary"
+ subtitle="Generated terms"
+ >
+
+ {booking.contractSummary}
+
+
+ )}
-
-
-
-
-
- Contract Info
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {canApprove && (
-
-
-
-
- Approval Required
-
-
- This booking is waiting for your review.
-
-
-
-
-
- {booking.status === "RFQ_SUBMITTED"
- ? "Send Quotation"
- : "Approve Booking"}
-
+
+
+
+ {["CONTRACT_READY", "SIGNED_CUSTOMER", "FULLY_EXECUTED"].includes(
+ booking.status,
+ ) && (
+
+
+ navigate(`/dashboard/booking-requests/${booking.id}/contract`)
+ }
>
-
- Reject
+
+ View & sign contract
-
-
+
+
+ )}
+ {(booking.status === "PENDING_APPROVAL" ||
+ booking.status === "APPROVED_PENDING_SIGNATURE") && (
+
)}
@@ -643,92 +235,221 @@ export default function BookingRequestDetailPage() {
);
}
-function StatusBadge({ status }: { status: string }) {
- const style = STATUS_STYLES[status] ?? {
- label: status,
- color: "bg-muted text-muted-foreground border-border",
- };
+function SectionShell({
+ icon,
+ title,
+ subtitle,
+ children,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ subtitle?: string;
+ children: React.ReactNode;
+}) {
return (
-
- {style.label}
-
+
+
+
+ {icon}
+
+
+
{title}
+ {subtitle && (
+
{subtitle}
+ )}
+
+
+
{children}
+
);
}
-function PriorityBadge({ score }: { score: number }) {
- if (score >= 3) {
- return (
-
- Urgent
-
- );
- }
- if (score === 2) {
- return (
-
- High
-
- );
+function RouteCard({
+ booking,
+ row,
+}: {
+ booking: BookingDetail;
+ row: ReturnType
;
+}) {
+ return (
+ }
+ title="Route & service"
+ subtitle="Corridor and service level"
+ >
+
+
+
+
+
+
+
+
+ {booking.serviceType?.label ??
+ booking.serviceType?.code ??
+ "Rail service"}
+
+
+
+
+
+
+
+
+ {booking.shippingLine && (
+
+ )}
+
+
+ );
+}
+
+function MileCard({ booking }: { booking: BookingDetail }) {
+ if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) {
+ return null;
}
return (
-
- Normal
-
+ }
+ title="Mile services"
+ subtitle="First and last mile"
+ >
+
+ {booking.firstMilePickupAddress && (
+
+ )}
+ {booking.lastMileDeliveryAddress && (
+
+ )}
+
+
+ );
+}
+
+function CargoCard({ booking }: { booking: BookingDetail }) {
+ const containers = booking.bookingContainers ?? [];
+ return (
+ }
+ title="Cargo specifications"
+ subtitle="Freight and containers"
+ >
+
+
+
+
+
+ {containers.length > 0 && (
+ <>
+
+
+
+
+
+ Container type
+ Qty
+ VGM / unit
+
+
+
+ {containers.map((c) => (
+
+
+ {c.containerType?.label ??
+ c.containerType?.code ??
+ c.containerTypeId}
+
+
+ {c.quantity}
+
+
+ {c.vgmPerUnitTons} t
+
+
+ ))}
+
+
+
+ >
+ )}
+
);
}
function RouteEndpoint({
label,
station,
- icon,
}: {
label: string;
station: string;
- icon: React.ReactNode;
}) {
return (
-
-
-
{icon}
+
+
+
-
-
+
+
{label}
-
{station}
+
{station}
);
}
-function InfoItem({
- icon,
+function MetricTile({
label,
value,
+ highlight,
}: {
- icon?: React.ReactNode;
label: string;
- value?: string | number | null;
+ value: string;
+ highlight?: boolean;
}) {
return (
-
- {icon && (
-
- {icon}
-
+
-
- {label}
-
-
{value ?? "—"}
-
+ >
+
+ {label}
+
+
+ {value}
+
);
}
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx
index d59658131..5300dc3ca 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx
@@ -5,24 +5,33 @@ import {
ArrowRight,
Calendar,
Clock,
- Eye,
FileText,
- Filter,
- MoreHorizontal,
+ Inbox,
+ LayoutList,
Package,
+ RefreshCw,
Search,
- ShieldCheck,
- Train,
User,
+ X,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
-import { cn } from "@/lib/utils";
+import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import {
- getBookingRequests,
- BOOKING_STATUSES,
- type BookingRequest,
-} from "./booking-requests.mock";
+ BookingStatusTabs,
+ type BookingStatusTabKey,
+} from "@/components/bookings/BookingStatusTabs";
+import { BookingStatGrid } from "@/components/bookings/BookingStatGrid";
+import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
+import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
+import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
+import { bookingInput, bookingSurface } from "@/components/bookings/booking-ui.styles";
+import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
+import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
+import { useBookingList } from "@/hooks/bookings/useBookings";
+import type { BookingListFilter } from "@/services/bookings.service";
+import type { BookingListRow } from "@/types/booking";
+import { cn } from "@/lib/utils";
import {
DataTable,
DataTableFooter,
@@ -30,184 +39,70 @@ import {
usePagination,
Badge,
Button,
- Card,
- CardHeader,
- CardTitle,
- CardDescription,
- CardContent,
Input,
- DropdownMenu,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- Separator,
} from "@edr/ui-common";
-const STATUS_STYLES: Record
= {
- DRAFT: {
- label: "Draft",
- color: "bg-slate-100 text-slate-700 border-slate-300",
- },
- RFQ_SUBMITTED: {
- label: "RFQ Submitted",
- color: "bg-amber-50 text-amber-700 border-amber-200",
- },
- QUOTATION_SENT: {
- label: "Quotation Sent",
- color: "bg-sky-50 text-sky-700 border-sky-200",
- },
- QUOTATION_APPROVED: {
- label: "Quotation Approved",
- color: "bg-emerald-50 text-emerald-700 border-emerald-200",
- },
- QUOTATION_REJECTED: {
- label: "Quotation Rejected",
- color: "bg-red-50 text-red-700 border-red-200",
- },
- PENDING_APPROVAL: {
- label: "Pending Approval",
- color: "bg-amber-50 text-amber-700 border-amber-200",
- },
- APPROVED: {
- label: "Approved",
- color: "bg-emerald-50 text-emerald-700 border-emerald-200",
- },
- SIGNED_CUSTOMER: {
- label: "Customer Signed",
- color: "bg-sky-50 text-sky-700 border-sky-200",
- },
- FULLY_EXECUTED: {
- label: "Fully Executed",
- color: "bg-indigo-50 text-indigo-700 border-indigo-200",
- },
- PAID: {
- label: "Paid",
- color: "bg-emerald-50 text-emerald-700 border-emerald-200",
- },
- IN_TRANSIT: {
- label: "In Transit",
- color: "bg-sky-50 text-sky-700 border-sky-200",
- },
- COMPLETED: {
- label: "Completed",
- color: "bg-indigo-50 text-indigo-700 border-indigo-200",
- },
- CANCELLED: {
- label: "Cancelled",
- color: "bg-red-50 text-red-700 border-red-200",
- },
- PENDING_CONSOLIDATION: {
- label: "Pending Consolidation",
- color: "bg-amber-50 text-amber-700 border-amber-200",
- },
- CONSOLIDATED: {
- label: "Consolidated",
- color: "bg-indigo-50 text-indigo-700 border-indigo-200",
- },
-};
-
-function StatusBadge({ status }: { status: string }) {
- const style = STATUS_STYLES[status] ?? {
- label: status,
- color: "bg-muted text-muted-foreground border-border",
- };
- return (
-
- {style.label}
-
- );
-}
-
-function PriorityBadge({ score }: { score: number }) {
- if (score >= 3) {
- return (
-
- Urgent
-
- );
- }
- if (score === 2) {
- return (
-
- High
-
- );
- }
- return (
-
- Normal
-
- );
+function getStatusForTab(tab: BookingStatusTabKey): string | undefined {
+ const match = BOOKING_LIST_TABS.find((t) => t.key === tab);
+ return match?.status ?? undefined;
}
export default function BookingRequestsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
- const [statusFilter, setStatusFilter] = useState(null);
+ const [activeTab, setActiveTab] = useState("SUBMITTED");
- const bookingRequests = useMemo(() => getBookingRequests(), []);
-
- const filtered = useMemo(() => {
- const q = query.trim().toLowerCase();
- return bookingRequests.filter((b) => {
- if (
- q &&
- !b.reference.toLowerCase().includes(q) &&
- !b.customer.toLowerCase().includes(q)
- ) {
- return false;
- }
- if (statusFilter && b.status !== statusFilter) {
- return false;
- }
- return true;
- });
- }, [bookingRequests, query, statusFilter]);
-
- const total = filtered.length;
- const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
- const start = pagination.pageIndex * pagination.pageSize;
- const end = Math.min(start + pagination.pageSize, total);
-
- const paginatedData = useMemo(
- () => filtered.slice(start, end),
- [start, end, filtered],
+ const filter: BookingListFilter = useMemo(
+ () => ({
+ page: pagination.pageIndex + 1,
+ pageSize: pagination.pageSize,
+ sortBy: "createdAt",
+ sortOrder: "DESC",
+ ...(getStatusForTab(activeTab) ? { status: getStatusForTab(activeTab) } : {}),
+ }),
+ [pagination.pageIndex, pagination.pageSize, activeTab],
);
- const pendingCount = bookingRequests.filter(
- (b) => b.status === "PENDING_APPROVAL" || b.status === "RFQ_SUBMITTED",
- ).length;
- const activeCount = bookingRequests.filter(
- (b) => !["COMPLETED", "CANCELLED"].includes(b.status),
- ).length;
- const urgentCount = bookingRequests.filter(
- (b) => b.priorityScore >= 3,
- ).length;
+ const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
- const columns: ColumnDef[] = [
+ const rows = useMemo(() => {
+ const items = (data?.items ?? []).map(toBookingListRow);
+ const q = query.trim().toLowerCase();
+ if (!q) return items;
+ return items.filter(
+ (b) =>
+ b.reference.toLowerCase().includes(q) ||
+ b.customerLabel.toLowerCase().includes(q),
+ );
+ }, [data?.items, query]);
+
+ const total = data?.total ?? 0;
+ const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
+ const hasSearch = query.trim().length > 0;
+ const showEmpty = !isLoading && !isError && rows.length === 0;
+
+ const pendingCount = rows.filter(
+ (b) => b.status === "SUBMITTED" || b.status === "PENDING_APPROVAL",
+ ).length;
+ const urgentCount = rows.filter((b) => b.priorityScore >= 1000).length;
+
+ const columns: ColumnDef[] = [
{
id: "booking",
- header: "Booking",
+ header: () => Booking ,
cell: ({ row }) => {
const b = row.original;
return (
-
-
-
+
+
-
-
{b.reference}
-
-
- {b.customer}
+
+
{b.reference}
+
+
+ {b.customerLabel}
@@ -216,264 +111,225 @@ export default function BookingRequestsPage() {
},
{
id: "route",
- header: "Route",
+ header: () =>
Route ,
cell: ({ row }) => {
const b = row.original;
return (
-
-
-
{b.originYard}
-
-
{b.destinationYard}
+
+
+
{b.originLabel}
+
+
{b.destinationLabel}
+
+
+
+ {b.tradeDirection}
+
+
+ {b.freightType}
+
-
- {b.tradeDirection}
-
);
},
},
{
id: "status",
- header: "Status",
- cell: ({ row }) =>
,
+ header: () =>
Status ,
+ cell: ({ row }) =>
,
},
{
- id: "service",
- header: "Service",
- cell: ({ row }) => {
- const b = row.original;
- return (
-
-
- {b.serviceType.replace(/_/g, " ")}
-
-
-
- {b.scheduledDate}
-
-
- );
- },
- },
- {
- id: "cargo",
- header: "Cargo",
- cell: ({ row }) => {
- const b = row.original;
- return (
-
-
- {b.cargoType}
-
-
- {b.cargoTotalWeightVgm}T
-
-
- );
- },
+ id: "scheduled",
+ header: () =>
Scheduled ,
+ cell: ({ row }) => (
+
+
+ {row.original.scheduledDate}
+
+ ),
},
{
id: "priority",
- header: "Priority",
- cell: ({ row }) =>
,
+ header: () =>
Priority ,
+ cell: ({ row }) => (
+
+ ),
},
{
id: "amount",
- header: "Amount",
+ header: () => (
+
Amount
+ ),
cell: ({ row }) => {
const b = row.original;
return (
-
- {b.paymentCurrency} {b.totalAmount.toLocaleString()}
+
+ {b.paymentCurrency}{" "}
+ {b.totalAmount.toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ })}
);
},
},
{
id: "actions",
- size: 40,
- cell: ({ row }) => {
- const b = row.original;
- return (
- e.stopPropagation()}
- >
-
-
-
-
-
-
-
-
- navigate(`/dashboard/booking-requests/${b.id}`)
- }
- >
-
- View Details
-
-
-
- navigate(`/dashboard/booking-requests/${b.id}`)
- }
- >
-
- Review
-
-
-
-
- );
- },
+ size: 140,
+ header: () => (
+
+ Actions
+
+ ),
+ cell: ({ row }) => (
+
+ ),
},
];
return (
-
-
-
+
+
+
-
-
-
- Booking Requests
-
-
- Review, approve, or reject customer booking requests across the
- freight network.
-
+
+
+
+
+
+
+
+
+
+ Booking requests
+
+
+ Track bookings from submission through payment and operations.
+
+
+
+
+ refetch()}
+ >
+
+ Refresh
+
+
+
-
-
-
+
0 ? "rose" : "default",
+ },
+ ]}
+ />
+
+ {
+ setActiveTab(tab);
+ setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
+ }}
+ counts={{
+ [activeTab]: total,
+ }}
+ />
+
+
+
+
+
{
- setQuery(e.target.value);
- setPagination({
- pageIndex: 0,
- pageSize: pagination.pageSize,
- });
- }}
- placeholder="Search reference or customer..."
- className="pl-8!"
+ onChange={(e) => setQuery(e.target.value)}
+ placeholder="Search reference or customer…"
+ className={bookingInput.search}
/>
+ {query && (
+ setQuery("")}
+ aria-label="Clear search"
+ >
+
+
+ )}
+
+
+
+
+ {total} record{total !== 1 ? "s" : ""}
+
-
-
- }
- />
- }
- />
- } />
- } />
-
-
-
-
-
- All Booking Requests
-
- {total} request{total !== 1 ? "s" : ""} found
-
-
-
-
- {statusFilter && (
- setStatusFilter(null)}
- >
- Clear filter
-
- )}
-
-
-
-
- {statusFilter
- ? (STATUS_STYLES[statusFilter]?.label ?? "Filter")
- : "Filter"}
-
-
-
- {BOOKING_STATUSES.map((s) => (
- setStatusFilter(s)}
- >
- {STATUS_STYLES[s]?.label ?? s}
-
- ))}
-
-
-
-
-
-
-
- navigate(`/dashboard/booking-requests/${row.id}`)
- }
- pagination={{
- pageIndex: pagination.pageIndex,
- pageSize: pagination.pageSize,
- pageCount,
- totalCount: total,
- }}
- tableOptions={{
- state: { pagination },
- onPaginationChange: setPagination,
- }}
- containerClassName="border-b shadow-none"
- footer={DataTableFooter}
+ {showEmpty ? (
+ refetch()}
/>
-
-
+ ) : (
+
+
+ navigate(`/dashboard/booking-requests/${row.id}`)
+ }
+ pagination={{
+ pageIndex: pagination.pageIndex,
+ pageSize: pagination.pageSize,
+ pageCount,
+ totalCount: total,
+ }}
+ tableOptions={{
+ state: { pagination },
+ onPaginationChange: setPagination,
+ manualPagination: true,
+ pageCount,
+ }}
+ containerClassName="border-0 shadow-none [&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/40"
+ footer={DataTableFooter}
+ />
+
+ )}
+
);
}
-
-function StatCard({
- label,
- value,
- icon,
-}: {
- label: string;
- value: number;
- icon: React.ReactNode;
-}) {
- return (
-
-
-
-
- {icon}
-
-
-
- );
-}
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts b/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts
index a5d13cbc0..8b738bc5b 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts
@@ -1,148 +1,2 @@
-export interface BookingRequest {
- id: string;
- reference: string;
- customer: string;
- status: (typeof BOOKING_STATUSES)[number];
- scheduledDate: string;
- totalAmount: number;
- paymentStatus: string;
- contractType: string;
- serviceType: string;
- tradeDirection: string;
- originYard: string;
- destinationYard: string;
- cargoType: string;
- cargoTotalWeightVgm: number;
- isHazardous: boolean;
- paymentCurrency: string;
- priorityScore: number;
- firstMilePickupAddress: string | null;
- lastMileDeliveryAddress: string | null;
- shippingLine: string | null;
- pnrCode: string | null;
- createdBy: string;
- createdAt: string;
- updatedAt: string;
-}
-
-export const BOOKING_STATUSES = [
- "DRAFT",
- "RFQ_SUBMITTED",
- "QUOTATION_SENT",
- "QUOTATION_APPROVED",
- "QUOTATION_REJECTED",
- "PENDING_APPROVAL",
- "APPROVED",
- "SIGNED_CUSTOMER",
- "FULLY_EXECUTED",
- "PAID",
- "IN_TRANSIT",
- "COMPLETED",
- "CANCELLED",
- "PENDING_CONSOLIDATION",
- "CONSOLIDATED",
-] as const;
-
-const customers = [
- "Ethio Cargo Logistics",
- "Djibouti Shipping PLC",
- "Horn of Africa Traders",
- "Addis Freight Forwarders",
- "Red Sea Maritime Services",
- "Dire Dawa Imports Ltd",
- "Awash Agro Industry",
- "Mieso Mineral Exports",
-];
-
-const yards = [
- "Addis Ababa Dry Port",
- "Mojo Inland Container Depot",
- "Dire Dawa Freight Station",
- "Djibouti Port Terminal",
- "Adama Logistics Hub",
- "Awash Cargo Center",
-];
-
-const serviceTypes = ["RAIL", "RAIL_AND_FORWARDING"];
-const tradeDirections = ["EXPORT", "IMPORT", "DOMESTIC"];
-const cargoTypes = ["Containerized", "Bulk", "Liquid", "Refrigerated", "Hazardous"];
-const shippingLines = ["MSC", "CMA CGM", "Maersk", "COSCO", "Hapag-Lloyd", null];
-
-function pick
(arr: T[], index: number): T {
- return arr[index % arr.length];
-}
-
-function randDate(daysAgo: number): string {
- const d = new Date(2026, 4, 28 - daysAgo);
- return d.toISOString();
-}
-
-const now = Date.now();
-
-const INITIAL_REQUESTS: BookingRequest[] = Array.from({ length: 25 }, (_, i) => {
- const statusIndex = i % BOOKING_STATUSES.length;
- const status = BOOKING_STATUSES[statusIndex];
- const customer = pick(customers, i);
-
- return {
- id: String(i + 1),
- reference: `EDR-BK-${String(2026001 + i).slice(-6)}`,
- customer,
- status,
- scheduledDate: new Date(2026, 5, 1 + (i % 28)).toISOString().slice(0, 10),
- totalAmount: 1500 + i * 320 + (i % 7) * 100,
- paymentStatus: status === "PAID" || status === "COMPLETED" ? "PAID" : status === "CANCELLED" ? "REFUNDED" : "PENDING",
- contractType: i % 5 === 0 ? "RENEWAL" : "NEW",
- serviceType: pick(serviceTypes, i),
- tradeDirection: pick(tradeDirections, i),
- originYard: pick(yards, i),
- destinationYard: pick(yards, i + 3),
- cargoType: pick(cargoTypes, i),
- cargoTotalWeightVgm: 10 + ((i * 7) % 90),
- isHazardous: i % 7 === 0,
- paymentCurrency: "USD",
- priorityScore: i % 4 === 0 ? 3 : i % 3 === 0 ? 2 : 1,
- firstMilePickupAddress: i % 3 === 0 ? "Bole Industrial Zone, Addis Ababa" : null,
- lastMileDeliveryAddress: i % 4 === 0 ? "Port Boulevard, Djibouti City" : null,
- shippingLine: pick(shippingLines, i),
- pnrCode: i % 6 === 0 ? `PNR-${202600 + i}` : null,
- createdBy: customer,
- createdAt: randDate(30 - i),
- updatedAt: randDate(2),
- };
-});
-
-export function saveBookingRequestsToStorage(data: BookingRequest[]) {
- if (typeof window !== "undefined" && window.localStorage) {
- localStorage.setItem("edr_backoffice_booking_requests", JSON.stringify(data));
- }
-}
-
-export function getBookingRequestById(id: string): BookingRequest | undefined {
- const requests = getBookingRequests();
- return requests.find((r) => r.id === id);
-}
-
-export function updateBookingRequestStatus(id: string, newStatus: (typeof BOOKING_STATUSES)[number]) {
- const requests = getBookingRequests();
- const idx = requests.findIndex((r) => r.id === id);
- if (idx === -1) return;
- requests[idx] = { ...requests[idx], status: newStatus, updatedAt: new Date().toISOString() };
- saveBookingRequestsToStorage(requests);
-}
-
-export function getBookingRequests(): BookingRequest[] {
- if (typeof window === "undefined" || !window.localStorage) {
- return INITIAL_REQUESTS;
- }
- const data = localStorage.getItem("edr_backoffice_booking_requests");
- if (!data) {
- localStorage.setItem("edr_backoffice_booking_requests", JSON.stringify(INITIAL_REQUESTS));
- return INITIAL_REQUESTS;
- }
- try {
- return JSON.parse(data);
- } catch {
- return INITIAL_REQUESTS;
- }
-}
+/** @deprecated Use BookingDetail from @/types/booking — kept for gradual migration */
+export type { BookingListRow as BookingRequest } from "@/types/booking";
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/bookings.mock.ts b/apps/edr-freight-web/backoffice/src/pages/bookings/bookings.mock.ts
new file mode 100644
index 000000000..633c7cb32
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/bookings.mock.ts
@@ -0,0 +1,9 @@
+/** Demo portal mock data — booking requests use the live API instead. */
+export interface Booking {
+ id: number | string;
+ customerId: number | string;
+ reference?: string;
+ status?: string;
+}
+
+export const bookings: Booking[] = [];
diff --git a/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx
index ce2163bad..9cb0e94e3 100644
--- a/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx
@@ -20,12 +20,16 @@ import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { getMinFiles } from "@/types/fileUploadSettings";
-import { useDeleteFileUploadSetting, useFileUploadSettings } from "@/hooks/useFileUploadSettings";
+import { useQuery } from "@tanstack/react-query";
+import { api } from "@/services/api";
+import { useDeleteFileUploadSetting } from "@/hooks/useFileUploadSettings";
export default function FileUploadSettingsPage() {
const [query, setQuery] = useState("");
- const { data, isLoading, isError, error } = useFileUploadSettings();
+ const { data, isLoading, isError, error } = useQuery(
+ api.fileUploadSettings.list.queryOptions(),
+ );
const deleteMutation = useDeleteFileUploadSetting();
const fileUploadSettings = useMemo(
diff --git a/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx
index 8a336cdaf..7edaeb7b1 100644
--- a/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx
@@ -21,10 +21,9 @@ import Breadcrumbs from "@/components/ui/Breadcrumbs";
import EditDropdownSettingDialog from "./EditDropdownSettingDialog";
import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog";
import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog";
-import {
- useDeleteDropdownSetting,
- useDropdownSettings,
-} from "@/hooks/useDropdownSettings";
+import { useQuery } from "@tanstack/react-query";
+import { api } from "@/services/api";
+import { useDeleteDropdownSetting } from "@/hooks/useDropdownSettings";
import type { DropdownSetting } from "@/types/dropdownSettings";
import {
DataTable,
@@ -86,7 +85,9 @@ export default function DropdownSettingsPage() {
return () => cancelAnimationFrame(id);
}, [activeDialog]);
- const { data, isLoading, isError, error } = useDropdownSettings();
+ const { data, isLoading, isError, error } = useQuery(
+ api.dropdownSettings.list.queryOptions(),
+ );
const deleteMutation = useDeleteDropdownSetting();
const dropdownSettings = useMemo(
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
index 7f8f6cfa4..c81f1d6e7 100644
--- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from "react";
+import { useCallback, useMemo, useState } from "react";
import { Navigate, useLocation, useParams } from "react-router-dom";
import type { ColumnDef } from "@tanstack/react-table";
import { Loader2 } from "lucide-react";
@@ -9,7 +9,6 @@ import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordAct
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
import {
- ruleEngineField,
ruleEngineSurface,
ruleEngineTable,
} from "@/components/ruleEngine/ruleEngineStyles";
@@ -26,6 +25,7 @@ import {
useApprovalChain,
useCargoTypeParentOptions,
useContainerTypeOptions,
+ useLiveRateOptions,
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
@@ -41,8 +41,6 @@ import {
DialogDescription,
DialogHeader,
DialogTitle,
- Input,
- Label,
getCoreRowModel,
usePagination,
useReactTable,
@@ -73,9 +71,6 @@ const RuleEngineResourcePage = () => {
const [editing, setEditing] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [chainOpen, setChainOpen] = useState(false);
- const [approveTarget, setApproveTarget] = useState(null);
- const [ceoId, setCeoId] = useState("");
-
const { viewMode, setViewMode } = useRuleEngineViewMode(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
);
@@ -103,33 +98,52 @@ const RuleEngineResourcePage = () => {
);
const editingId = editing?.id ? String(editing.id) : undefined;
+ const usesContainerTypeField = Boolean(
+ config?.formFields.some((f) => f.name === "containerTypeId"),
+ );
+ const usesLiveRateField = Boolean(
+ config?.formFields.some((f) => f.name === "rateId"),
+ );
+
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
- useContainerTypeOptions(config?.slug === "rates");
+ useContainerTypeOptions(
+ config?.slug === "rates",
+ usesContainerTypeField,
+ );
+ const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
+ useLiveRateOptions(usesLiveRateField);
const formFields = useMemo(() => {
if (!config) return [];
- return config.formFields.map((field) =>
- config.slug === "cargo-types" && field.name === "parentGroupId"
- ? {
- ...field,
- options:
- cargoParentOptions ?? [
- { label: "None", value: RULE_ENGINE_SELECT_NONE },
- ],
- }
- : config.slug === "rates" && field.name === "containerTypeId"
- ? {
- ...field,
- options:
- containerTypeOptions ?? [
- { label: "None", value: RULE_ENGINE_SELECT_NONE },
- ],
- }
- : field,
- );
- }, [config, cargoParentOptions, containerTypeOptions]);
+ return config.formFields.map((field) => {
+ if (config.slug === "cargo-types" && field.name === "parentGroupId") {
+ return {
+ ...field,
+ options:
+ cargoParentOptions ?? [
+ { label: "None", value: RULE_ENGINE_SELECT_NONE },
+ ],
+ };
+ }
+ if (field.name === "containerTypeId") {
+ return {
+ ...field,
+ type: "select" as const,
+ options: containerTypeOptions ?? [],
+ };
+ }
+ if (field.name === "rateId") {
+ return {
+ ...field,
+ type: "select" as const,
+ options: liveRateOptions ?? [],
+ };
+ }
+ return field;
+ });
+ }, [config, cargoParentOptions, containerTypeOptions, liveRateOptions]);
const rows = data?.data ?? [];
const meta = data?.meta;
@@ -163,6 +177,13 @@ const RuleEngineResourcePage = () => {
onPaginationChange: setPagination,
});
+ const handleApproveRate = useCallback(
+ (record: RuleEngineRecord) => {
+ approve.mutate(String(record.id));
+ },
+ [approve],
+ );
+
const columns = useMemo((): ColumnDef[] => {
if (!config) return [];
@@ -195,14 +216,14 @@ const RuleEngineResourcePage = () => {
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
}
onSubmitRate={(id) => submit.mutate(id)}
- onApproveRate={setApproveTarget}
+ onApproveRate={handleApproveRate}
/>
),
});
return base;
- }, [config, submit]);
+ }, [config, submit, handleApproveRate]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
@@ -318,7 +339,7 @@ const RuleEngineResourcePage = () => {
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
}
onSubmitRate={(id) => submit.mutate(id)}
- onApproveRate={setApproveTarget}
+ onApproveRate={handleApproveRate}
/>
)}
@@ -337,7 +358,8 @@ const RuleEngineResourcePage = () => {
isSubmitting={create.isPending || update.isPending}
selectOptionsLoading={
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
- (config.slug === "rates" && containerTypeOptionsLoading)
+ (usesContainerTypeField && containerTypeOptionsLoading) ||
+ (usesLiveRateField && liveRateOptionsLoading)
}
onSubmit={handleFormSubmit}
/>
@@ -370,51 +392,6 @@ const RuleEngineResourcePage = () => {
-
!o && setApproveTarget(null)}>
-
-
- Approve rate
- Enter the CEO staff ID to approve this rate.
-
-
-
-
- CEO staff ID
-
- setCeoId(e.target.value)}
- placeholder="UUID"
- className={ruleEngineField.input}
- />
-
-
- setApproveTarget(null)}>
- Cancel
-
- {
- if (!approveTarget) return;
- approve.mutate(
- { id: approveTarget.id, payload: { approvedByCeoId: ceoId.trim() } },
- {
- onSuccess: () => {
- setApproveTarget(null);
- setCeoId("");
- },
- },
- );
- }}
- >
- {approve.isPending ? : "Approve"}
-
-
-
-
-
-
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
index 3fe1b59cb..3ac0286d3 100644
--- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
@@ -3,7 +3,16 @@ import type { RuleEngineResourceSlug } from "@/types/rule-engine";
export type RuleEngineNavCategory = "configuration" | "rules";
-export type ColumnFormat = "text" | "code" | "boolean" | "activeBadge" | "rateStatus" | "date" | "number";
+export type ColumnFormat =
+ | "text"
+ | "code"
+ | "boolean"
+ | "activeBadge"
+ | "rateStatus"
+ | "date"
+ | "number"
+ | "entityLabel"
+ | "rateLabel";
export type FormFieldType = "text" | "number" | "boolean" | "date" | "select" | "textarea";
@@ -187,7 +196,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "score", label: "Score", type: "number", required: true },
- { name: "conditionCurrency", label: "Condition currency", type: "text", placeholder: "USD (optional)" },
+ {
+ name: "conditionCurrency",
+ label: "Condition currency",
+ type: "select",
+ optional: true,
+ options: [{ label: "Any", value: RULE_ENGINE_SELECT_NONE }, ...CURRENCIES],
+ placeholder: "Any currency (optional)",
+ },
{ name: "isActive", label: "Active", type: "boolean" },
],
},
@@ -226,7 +242,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" },
{ id: "triggerCondition", header: "Trigger", accessorKey: "triggerCondition" },
- { id: "rateId", header: "Rate ID", accessorKey: "rateId" },
+ { id: "rateId", header: "Rate", accessorKey: "rate", format: "rateLabel" },
activeColumn,
],
formFields: [
@@ -238,7 +254,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
required: true,
options: SURCHARGE_TRIGGERS,
},
- { name: "rateId", label: "Rate ID", type: "text", required: true, placeholder: "UUID of LIVE rate" },
+ {
+ name: "rateId",
+ label: "Live rate",
+ type: "select",
+ required: true,
+ placeholder: "Select a LIVE rate",
+ },
{ name: "isActive", label: "Active", type: "boolean" },
],
},
@@ -249,14 +271,25 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
subtitle: "VGM limits by container and trade direction",
searchPlaceholder: "Search weight limit rules...",
columns: [
- { id: "containerTypeId", header: "Container", accessorKey: "containerTypeId" },
+ {
+ id: "containerType",
+ header: "Container",
+ accessorKey: "containerType",
+ format: "entityLabel",
+ },
{ id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" },
{ id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" },
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
{ id: "effectiveTo", header: "To", accessorKey: "effectiveTo", format: "date" },
],
formFields: [
- { name: "containerTypeId", label: "Container type ID", type: "text", required: true },
+ {
+ name: "containerTypeId",
+ label: "Container type",
+ type: "select",
+ required: true,
+ placeholder: "Select container type",
+ },
{
name: "tradeDirection",
label: "Trade direction",
@@ -349,7 +382,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "currency", label: "Currency", type: "select", required: true, options: CURRENCIES },
{ name: "rateValue", label: "Rate value", type: "number", required: true },
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
- { name: "proposedByStaffId", label: "Proposed by (staff ID)", type: "text", required: true },
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
{ name: "effectiveTo", label: "Effective to", type: "date" },
],
diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts
new file mode 100644
index 000000000..06595e5ca
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/services/api.ts
@@ -0,0 +1,338 @@
+import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
+import { endpoint } from "@/utils/endpoint";
+import type {
+ CreateFileUploadFieldDto,
+ CreateFileUploadSettingDto,
+ FileUploadField,
+ FileUploadSetting,
+ UpdateFileUploadFieldDto,
+ UpdateFileUploadSettingDto,
+} from "@/types/fileUploadSettings";
+import {
+ CreateDropdownOptionDto,
+ CreateDropdownSettingDto,
+ DropdownOption,
+ DropdownSetting,
+ UpdateDropdownOptionDto,
+ UpdateDropdownSettingDto,
+} from "@/types/dropdownSettings";
+import {
+ RuleEngineListResult,
+ RuleEngineRecord,
+ RuleEngineResourceSlug,
+} from "@/types/rule-engine";
+import { fileUploadSettingsService } from "./fileUploadSettings.service";
+import { dropdownSettingsService } from "./dropdownSettings.service";
+import {
+ ruleEngineService,
+ RuleEngineListParams,
+} from "./ruleEngine/ruleEngine.service";
+import {
+ bookingsService,
+ BookingListFilter,
+ type ApproveStepPayload,
+ type PaginatedBookings,
+ type RejectStepPayload,
+} from "./bookings.service";
+import type { BookingDetail } from "@/types/booking";
+
+export const api = {
+ fileUploadSettings: {
+ list: endpoint(
+ "file-upload-settings",
+ "list",
+ fileUploadSettingsService.list,
+ ),
+
+ getById: endpoint<{ id: string }, FileUploadSetting>(
+ "file-upload-settings",
+ "getById",
+ ({ id }) => fileUploadSettingsService.getById(id),
+ ),
+
+ getByCode: endpoint<{ code: string }, FileUploadSetting>(
+ "file-upload-settings",
+ "getByCode",
+ ({ code }) => fileUploadSettingsService.getByCode(code),
+ ),
+
+ create: endpoint(
+ "file-upload-settings",
+ "create",
+ (payload) => fileUploadSettingsService.create(payload),
+ ),
+
+ update: endpoint<
+ { id: string; dto: UpdateFileUploadSettingDto },
+ FileUploadSetting
+ >("file-upload-settings", "update", ({ id, dto }) =>
+ fileUploadSettingsService.update(id, dto),
+ ),
+
+ remove: endpoint<{ id: string }, void>(
+ "file-upload-settings",
+ "remove",
+ ({ id }) => fileUploadSettingsService.remove(id),
+ ),
+
+ replaceFields: endpoint<
+ { id: string; fields: CreateFileUploadFieldDto[] },
+ FileUploadField[]
+ >("file-upload-settings", "replaceFields", ({ id, fields }) =>
+ fileUploadSettingsService.replaceFields(id, fields),
+ ),
+
+ addField: endpoint<
+ { settingId: string; dto: CreateFileUploadFieldDto },
+ FileUploadField
+ >("file-upload-settings", "addField", ({ settingId, dto }) =>
+ fileUploadSettingsService.addField(settingId, dto),
+ ),
+
+ updateField: endpoint<
+ { fieldId: string; dto: UpdateFileUploadFieldDto },
+ FileUploadField
+ >("file-upload-settings", "updateField", ({ fieldId, dto }) =>
+ fileUploadSettingsService.updateField(fieldId, dto),
+ ),
+
+ removeField: endpoint<{ fieldId: string }, void>(
+ "file-upload-settings",
+ "removeField",
+ ({ fieldId }) => fileUploadSettingsService.removeField(fieldId),
+ ),
+ },
+
+ dropdownSettings: {
+ list: endpoint(
+ "dropdown-settings",
+ "list",
+ dropdownSettingsService.list,
+ ),
+
+ getById: endpoint<{ id: string }, DropdownSetting>(
+ "dropdown-settings",
+ "getById",
+ ({ id }) => dropdownSettingsService.getById(id),
+ ),
+
+ getByCode: endpoint<{ code: string }, DropdownSetting>(
+ "dropdown-settings",
+ "getByCode",
+ ({ code }) => dropdownSettingsService.getByCode(code),
+ ),
+
+ create: endpoint(
+ "dropdown-settings",
+ "create",
+ (payload) => dropdownSettingsService.create(payload),
+ ),
+
+ update: endpoint<
+ { id: string; dto: UpdateDropdownSettingDto },
+ DropdownSetting
+ >("dropdown-settings", "update", ({ id, dto }) =>
+ dropdownSettingsService.update(id, dto),
+ ),
+
+ remove: endpoint<{ id: string }, void>(
+ "dropdown-settings",
+ "remove",
+ ({ id }) => dropdownSettingsService.remove(id),
+ ),
+
+ replaceOptions: endpoint<
+ { id: string; options: CreateDropdownOptionDto[] },
+ DropdownOption[]
+ >("dropdown-settings", "replaceOptions", ({ id, options }) =>
+ dropdownSettingsService.replaceOptions(id, options),
+ ),
+
+ addOption: endpoint<
+ { id: string; dto: CreateDropdownOptionDto },
+ DropdownOption
+ >("dropdown-settings", "addOption", ({ id, dto }) =>
+ dropdownSettingsService.addOption(id, dto),
+ ),
+
+ updateOption: endpoint<
+ { optionId: string; dto: UpdateDropdownOptionDto },
+ DropdownOption
+ >("dropdown-settings", "updateOption", ({ optionId, dto }) =>
+ dropdownSettingsService.updateOption(optionId, dto),
+ ),
+
+ removeOption: endpoint<{ optionId: string }, void>(
+ "dropdown-settings",
+ "removeOption",
+ ({ optionId }) => dropdownSettingsService.removeOption(optionId),
+ ),
+ },
+
+ ruleEngine: {
+ list: endpoint<
+ { resource: RuleEngineResourceSlug; params?: RuleEngineListParams },
+ RuleEngineListResult
+ >(
+ "rule-engine",
+ "list",
+ ({ resource, params }) => ruleEngineService.list(resource, params),
+ ({ resource, params }) => QUERY_KEYS.RULE_ENGINE.list(resource, params),
+ ),
+
+ getById: endpoint<
+ { resource: RuleEngineResourceSlug; id: string },
+ RuleEngineRecord
+ >(
+ "rule-engine",
+ "getById",
+ ({ resource, id }) => ruleEngineService.getById(resource, id),
+ ({ resource, id }) => QUERY_KEYS.RULE_ENGINE.detail(resource, id),
+ ),
+
+ create: endpoint<
+ { resource: RuleEngineResourceSlug; payload: Record },
+ RuleEngineRecord
+ >("rule-engine", "create", ({ resource, payload }) =>
+ ruleEngineService.create(resource, payload),
+ ),
+
+ update: endpoint<
+ {
+ resource: RuleEngineResourceSlug;
+ id: string;
+ payload: Record;
+ },
+ RuleEngineRecord
+ >("rule-engine", "update", ({ resource, id, payload }) =>
+ ruleEngineService.update(resource, id, payload),
+ ),
+
+ remove: endpoint<
+ { resource: RuleEngineResourceSlug; id: string },
+ void
+ >("rule-engine", "remove", ({ resource, id }) =>
+ ruleEngineService.remove(resource, id),
+ ),
+
+ submitRate: endpoint<{ id: string }, RuleEngineRecord>(
+ "rule-engine",
+ "submitRate",
+ ({ id }) => ruleEngineService.submitRate(id),
+ ),
+
+ approveRate: endpoint<{ id: string }, RuleEngineRecord>(
+ "rule-engine",
+ "approveRate",
+ ({ id }) => ruleEngineService.approveRate(id),
+ ),
+
+ getApprovalChain: endpoint(
+ "rule-engine",
+ "getApprovalChain",
+ () => ruleEngineService.getApprovalChain(),
+ () => QUERY_KEYS.RULE_ENGINE.chain,
+ ),
+ },
+
+ bookings: {
+ list: endpoint<{ filter?: BookingListFilter }, PaginatedBookings>(
+ "bookings",
+ "list",
+ ({ filter }) => bookingsService.list(filter),
+ ({ filter }) => QUERY_KEYS.BOOKINGS.list(filter),
+ ),
+
+ getById: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "getById",
+ ({ id }) => bookingsService.getById(id),
+ ({ id }) => QUERY_KEYS.BOOKINGS.byId(id),
+ ),
+
+ remove: endpoint<{ id: string }, void>(
+ "bookings",
+ "remove",
+ ({ id }) => bookingsService.remove(id),
+ ),
+
+ staffAccept: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "staffAccept",
+ ({ id }) => bookingsService.staffAccept(id),
+ ),
+
+ requestChanges: endpoint<{ id: string; note: string }, BookingDetail>(
+ "bookings",
+ "requestChanges",
+ ({ id, note }) => bookingsService.requestChanges(id, note),
+ ),
+
+ staffReject: endpoint<{ id: string; reason: string }, BookingDetail>(
+ "bookings",
+ "staffReject",
+ ({ id, reason }) => bookingsService.staffReject(id, reason),
+ ),
+
+ approveStep: endpoint(
+ "bookings",
+ "approveStep",
+ (payload) => bookingsService.approveStep(payload),
+ ),
+
+ rejectStep: endpoint(
+ "bookings",
+ "rejectStep",
+ (payload) => bookingsService.rejectStep(payload),
+ ),
+
+ generateContract: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "generateContract",
+ ({ id }) => bookingsService.generateContract(id),
+ ),
+
+ getContractView: endpoint<{ id: string }, import("./bookings.service").ContractView>(
+ "bookings",
+ "getContractView",
+ ({ id }) => bookingsService.getContractView(id),
+ ),
+
+ signContract: endpoint<
+ { id: string } & import("./bookings.service").SignContractPayload,
+ BookingDetail
+ >("bookings", "signContract", ({ id, ...payload }) =>
+ bookingsService.signContract(id, payload),
+ ),
+
+ generatePnr: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "generatePnr",
+ ({ id }) => bookingsService.generatePnr(id),
+ ),
+
+ verifyPayment: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "verifyPayment",
+ ({ id }) => bookingsService.verifyPayment(id),
+ ),
+
+ startTransit: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "startTransit",
+ ({ id }) => bookingsService.startTransit(id),
+ ),
+
+ complete: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "complete",
+ ({ id }) => bookingsService.complete(id),
+ ),
+
+ cancel: endpoint<{ id: string; reason: string }, BookingDetail>(
+ "bookings",
+ "cancel",
+ ({ id, reason }) => bookingsService.cancel(id, reason),
+ ),
+ },
+};
diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts
new file mode 100644
index 000000000..4dd64823d
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts
@@ -0,0 +1,173 @@
+import { api as client } from "../auth/http";
+import { unwrap } from "@/utils/endpoint";
+import { URL_CONSTANTS } from "@/constants/URLS";
+import type { BookingDetail } from "@/types/booking";
+
+const B = URL_CONSTANTS.BOOKINGS;
+
+export interface BookingListFilter {
+ status?: string;
+ // customerId?: string;
+ companyId?: string;
+ freightType?: string;
+ tradeDirection?: string;
+ paymentCurrency?: string;
+ page?: number;
+ pageSize?: number;
+ sortBy?: string;
+ sortOrder?: "ASC" | "DESC";
+}
+
+export interface PaginatedBookings {
+ items: BookingDetail[];
+ total: number;
+}
+
+export interface ApproveStepPayload {
+ id: string;
+ stepId: string;
+ requiredRole: string;
+}
+
+export interface RejectStepPayload {
+ id: string;
+ stepId: string;
+ reason: string;
+}
+
+export interface ContractView {
+ bookingId: string;
+ reference: string;
+ status: string;
+ templateKey: string;
+ title: string;
+ html: string;
+ canSignCustomer: boolean;
+ canSignStaff: boolean;
+ hasContractDocument: boolean;
+ signatures: Array<{
+ role: string;
+ signerDisplayName: string;
+ signedAt: string;
+ signatureImageUrl?: string | null;
+ }>;
+}
+
+export interface SignContractPayload {
+ role: "CUSTOMER" | "STAFF";
+ signatureImageBase64: string;
+ signerDisplayName: string;
+ consentText?: string;
+}
+
+async function postBooking(url: string, body?: unknown): Promise {
+ const response = await client.post(url, body ?? {});
+ return unwrap(response.data);
+}
+
+export const bookingsService = {
+ list: async (filter?: BookingListFilter): Promise => {
+ const response = await client.get(B.BASE, {
+ params: filter,
+ });
+ const data = unwrap(response.data);
+ return {
+ items: (data.items ?? []) as BookingDetail[],
+ total: data.total ?? 0,
+ };
+ },
+
+ getById: async (id: string): Promise => {
+ const response = await client.get(B.BY_ID(id));
+ return unwrap(response.data) as BookingDetail;
+ },
+
+ remove: async (id: string): Promise => {
+ await client.delete(B.BY_ID(id));
+ },
+
+ staffAccept: (id: string) => postBooking(B.STAFF_ACCEPT(id)),
+
+ requestChanges: (id: string, note: string) =>
+ postBooking(B.STAFF_REQUEST_CHANGES(id), { note }),
+
+ staffReject: (id: string, reason: string) =>
+ postBooking(B.STAFF_REJECT(id), { reason }),
+
+ approveStep: ({ id, stepId, requiredRole }: ApproveStepPayload) =>
+ postBooking(B.APPROVE_STEP(id, stepId), { requiredRole }),
+
+ rejectStep: ({ id, stepId, reason }: RejectStepPayload) =>
+ postBooking(B.REJECT_STEP(id, stepId), { reason }),
+
+ generateContract: (id: string) =>
+ postBooking(B.CONTRACT_GENERATE(id)),
+
+ getContractView: async (id: string): Promise => {
+ const response = await client.get(B.CONTRACT_VIEW(id));
+ return unwrap(response.data) as ContractView;
+ },
+
+ downloadContract: async (id: string): Promise => {
+ const response = await client.get(B.CONTRACT_DOWNLOAD(id), {
+ responseType: "blob",
+ });
+ return response.data as Blob;
+ },
+
+ downloadContractDocument: async (id: string): Promise => {
+ const response = await client.get(B.CONTRACT_DOCUMENT(id), {
+ responseType: "blob",
+ });
+ return response.data as Blob;
+ },
+
+ signContract: (id: string, payload: SignContractPayload) =>
+ postBooking(B.CONTRACT_SIGN(id), payload),
+
+ getSummary: async (id: string): Promise<{ summary: string }> => {
+ const response = await client.get<{ summary: string }>(B.SUMMARY(id));
+ return unwrap(response.data);
+ },
+
+ customerSign: (id: string, payload: SignContractPayload) =>
+ postBooking(B.CUSTOMER_SIGN(id), {
+ ...payload,
+ role: "CUSTOMER",
+ }),
+
+ marketingApprove: (id: string, payload: SignContractPayload) =>
+ postBooking(B.MARKETING_APPROVE(id), {
+ ...payload,
+ role: "STAFF",
+ }),
+
+ generatePnr: (id: string) => postBooking(B.PAYMENT_PNR(id)),
+
+ submitPaymentProof: async (id: string, file: File): Promise => {
+ const form = new FormData();
+ form.append("file", file);
+ const response = await client.post(B.PAYMENT_PROOF(id), form, {
+ headers: { "Content-Type": "multipart/form-data" },
+ });
+ return unwrap(response.data) as BookingDetail;
+ },
+
+ verifyPayment: (id: string) =>
+ postBooking(B.PAYMENT_VERIFY(id)),
+
+ downloadPaymentRequestLetter: async (id: string): Promise => {
+ const response = await client.get(B.PAYMENT_REQUEST_LETTER(id), {
+ responseType: "blob",
+ });
+ return response.data as Blob;
+ },
+
+ startTransit: (id: string) =>
+ postBooking(B.START_TRANSIT(id)),
+
+ complete: (id: string) => postBooking(B.COMPLETE(id)),
+
+ cancel: (id: string, reason: string) =>
+ postBooking(B.CANCEL(id), { reason }),
+};
diff --git a/apps/edr-freight-web/backoffice/src/services/cargo.servcie.ts b/apps/edr-freight-web/backoffice/src/services/cargoService.ts
similarity index 99%
rename from apps/edr-freight-web/backoffice/src/services/cargo.servcie.ts
rename to apps/edr-freight-web/backoffice/src/services/cargoService.ts
index efd7eecdc..79facab6e 100644
--- a/apps/edr-freight-web/backoffice/src/services/cargo.servcie.ts
+++ b/apps/edr-freight-web/backoffice/src/services/cargoService.ts
@@ -23,4 +23,4 @@ export const cargoService = {
apiClient.post(`/cargoes/${cargoId}/load`, { quantity, weight, volume }),
deliver: (cargoId: string) => apiClient.post(`/cargoes/${cargoId}/deliver`),
unload: (cargoId: string) => apiClient.post(`/cargoes/${cargoId}/unload`),
-};
\ No newline at end of file
+};
diff --git a/apps/edr-freight-web/backoffice/src/services/fileUploadSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/fileUploadSettings.service.ts
index 0313a0561..777100dc8 100644
--- a/apps/edr-freight-web/backoffice/src/services/fileUploadSettings.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/fileUploadSettings.service.ts
@@ -8,10 +8,8 @@ import type {
UpdateFileUploadFieldDto,
UpdateFileUploadSettingDto,
} from "@/types/fileUploadSettings";
-import { URL_CONSTANTS } from "@/constants/URLS";
-import { QUERY_KEYS } from "@/constants/TANSTACK_QUEY_KEY";
import { ApiResponse } from "@/types/apiResponse";
-import { endpoint, unwrap } from "@/utils/endpoint";
+import { unwrap } from "@/utils/endpoint";
const BASE = "/file-upload-settings";
@@ -124,14 +122,3 @@ export const fileUploadSettingsService = {
await client.delete(`${BASE}/fields/${fieldId}`);
},
};
-
-export const getFileUploadSettingByCode = endpoint(
- QUERY_KEYS.FILES.FILE_UPLOAD_SETTINGS,
- QUERY_KEYS.FILES.BY_CODE,
- (code: any) =>
- client
- .get<
- ApiResponse
- >(`${URL_CONSTANTS.FILES.FILE_UPLOAD_SETTINGS_BY_CODE}/${code}`)
- .then((res: any) => res.data.data),
-);
diff --git a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts
index 65d9476e8..6c5c7f47d 100644
--- a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts
@@ -1,7 +1,6 @@
import { api as client } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
- ApproveRatePayload,
RuleEngineListMeta,
RuleEngineListResult,
RuleEngineRecord,
@@ -143,11 +142,8 @@ export const ruleEngineService = {
return normalizeEntity(response.data);
},
- approveRate: async (
- id: string,
- payload: ApproveRatePayload,
- ): Promise => {
- const response = await client.post(URL_CONSTANTS.RULE_ENGINE.RATE_APPROVE(id), payload);
+ approveRate: async (id: string): Promise => {
+ const response = await client.post(URL_CONSTANTS.RULE_ENGINE.RATE_APPROVE(id));
return normalizeEntity(response.data);
},
diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts
new file mode 100644
index 000000000..9cdd4a42e
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/types/booking.ts
@@ -0,0 +1,128 @@
+/** Mirrors API BOOKING_STATUSES from edr-freight-api booking.entity */
+export const BOOKING_STATUSES = [
+ "DRAFT",
+ "SUBMITTED",
+ "CHANGES_REQUESTED",
+ "PENDING_APPROVAL",
+ "APPROVED_PENDING_SIGNATURE",
+ "APPROVED",
+ "CONTRACT_READY",
+ "SIGNED_CUSTOMER",
+ "FULLY_EXECUTED",
+ "PNR_GENERATED",
+ "PAYMENT_VERIFICATION_IN_PROGRESS",
+ "PAID",
+ "IN_TRANSIT",
+ "COMPLETED",
+ "REJECTED",
+ "CANCELLED",
+ "PENDING_CONSOLIDATION",
+ "CONSOLIDATED",
+] as const;
+
+export type BookingStatus = (typeof BOOKING_STATUSES)[number];
+
+export interface BookingNamedRef {
+ id: string;
+ name?: string;
+ code?: string;
+ label?: string;
+ companyName?: string;
+}
+
+export interface BookingContainerLine {
+ id: string;
+ containerTypeId: string;
+ quantity: number;
+ vgmPerUnitTons: number;
+ containerType?: {
+ id: string;
+ code?: string;
+ label?: string;
+ sizeFt?: number;
+ isReefer?: boolean;
+ };
+}
+
+export interface BookingApprovalStep {
+ id: string;
+ stepOrder: number;
+ requiredRole: string;
+ status: "PENDING" | "APPROVED" | "REJECTED" | "SKIPPED";
+ actionedAt?: string | null;
+ remarks?: string | null;
+}
+
+export interface BookingReviewNote {
+ id: string;
+ note: string;
+ type: string;
+ createdAt: string;
+}
+
+export interface BookingFile {
+ id: string;
+ name: string;
+ mimeType?: string;
+ code?: string;
+}
+
+export interface BookingDetail {
+ id: string;
+ reference: string;
+ // customerId: string;
+ companyId: string;
+ status: BookingStatus;
+ scheduledDate: string;
+ totalAmount: number;
+ paymentStatus: string;
+ paymentCurrency: string;
+ contractType: string;
+ freightType: "CONTAINER" | "BULK";
+ tradeDirection: string;
+ cargoTotalWeightVgm: number;
+ isHazardous: boolean;
+ allowConsolidation: boolean;
+ priorityScore: number;
+ pnrCode?: string | null;
+ firstMilePickupAddress?: string | null;
+ lastMileDeliveryAddress?: string | null;
+ equipmentReturn?: string;
+ contractSummary?: string | null;
+ latestChangeRequestNote?: string | null;
+ createdAt: string;
+ updatedAt: string;
+ // customer?: BookingNamedRef & { companyName?: string };
+ company?: BookingNamedRef;
+ originYard?: BookingNamedRef;
+ destinationYard?: BookingNamedRef;
+ serviceType?: BookingNamedRef & { code?: string };
+ cargoType?: BookingNamedRef;
+ shippingLine?: BookingNamedRef;
+ bookingContainers?: BookingContainerLine[];
+ approvalSteps?: BookingApprovalStep[];
+ reviewNotes?: BookingReviewNote[];
+ files?: BookingFile[];
+ cargoModifiers?: Array<{
+ id: string;
+ calculatedAmount: number;
+ triggerValue?: number | null;
+ }>;
+}
+
+export interface BookingListRow {
+ id: string;
+ reference: string;
+ customerLabel: string;
+ status: BookingStatus;
+ scheduledDate: string;
+ totalAmount: number;
+ paymentCurrency: string;
+ paymentStatus: string;
+ tradeDirection: string;
+ freightType: string;
+ originLabel: string;
+ destinationLabel: string;
+ priorityScore: number;
+ createdAt: string;
+}
diff --git a/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts b/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts
index 362b21a63..ceede6873 100644
--- a/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts
+++ b/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts
@@ -23,7 +23,3 @@ export interface RuleEngineListResult {
}
export type RuleEngineRecord = Record & { id: string };
-
-export interface ApproveRatePayload {
- approvedByCeoId: string;
-}
diff --git a/apps/edr-freight-web/backoffice/src/utils/endpoint.ts b/apps/edr-freight-web/backoffice/src/utils/endpoint.ts
index 0ed1848d7..31748c77d 100644
--- a/apps/edr-freight-web/backoffice/src/utils/endpoint.ts
+++ b/apps/edr-freight-web/backoffice/src/utils/endpoint.ts
@@ -44,11 +44,19 @@ export function endpoint(
service: string,
action: string,
execute: (input: TInput) => Promise,
+ queryKeyBuilder?: (input: TInput) => readonly unknown[],
) {
- const buildKey = (input?: TInput): readonly unknown[] =>
- input === undefined
+ const buildKey = (input?: TInput): readonly unknown[] => {
+ if (queryKeyBuilder && input !== undefined) {
+ return queryKeyBuilder(input as TInput);
+ }
+ if (queryKeyBuilder && input === undefined) {
+ return queryKeyBuilder(undefined as TInput);
+ }
+ return input === undefined
? [service, action]
: [service, action, input];
+ };
const call = (input: TInput) => execute(input);
diff --git a/apps/edr-freight-web/backoffice/src/utils/queryInvalidation.ts b/apps/edr-freight-web/backoffice/src/utils/queryInvalidation.ts
new file mode 100644
index 000000000..bead8a8a0
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/utils/queryInvalidation.ts
@@ -0,0 +1,56 @@
+import type { QueryClient } from "@tanstack/react-query";
+
+import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
+import type {
+ RuleEngineListResult,
+ RuleEngineRecord,
+ RuleEngineResourceSlug,
+} from "@/types/rule-engine";
+
+export function invalidateBookings(qc: QueryClient): Promise {
+ return qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT });
+}
+
+export function invalidateBookingDetail(
+ qc: QueryClient,
+ id: string,
+): Promise {
+ return Promise.all([
+ qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.byId(id) }),
+ invalidateBookings(qc),
+ ]).then(() => undefined);
+}
+
+/** Update a single row in all cached list queries for a rule-engine resource. */
+export function patchRuleEngineListRecord(
+ qc: QueryClient,
+ resource: RuleEngineResourceSlug | string,
+ updated: RuleEngineRecord,
+): void {
+ const updatedId = String(updated.id);
+ qc.setQueriesData>(
+ { queryKey: ["rule-engine", "list", resource] },
+ (old) => {
+ if (!old?.data?.length) return old;
+ const index = old.data.findIndex((row) => String(row.id) === updatedId);
+ if (index === -1) return old;
+ const data = old.data.slice();
+ data[index] = { ...data[index], ...updated };
+ return { ...old, data };
+ },
+ );
+}
+
+/** Invalidate and refetch active rule-engine list queries for a resource. */
+export async function invalidateRuleEngineList(
+ qc: QueryClient,
+ resource: RuleEngineResourceSlug | string,
+): Promise {
+ const queryKey = ["rule-engine", "list", resource] as const;
+ await qc.invalidateQueries({ queryKey });
+ await qc.refetchQueries({ queryKey, type: "active" });
+}
+
+export function invalidateRuleEngineRoot(qc: QueryClient): Promise {
+ return qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.ROOT });
+}
diff --git a/apps/edr-freight-web/backoffice/src/utils/result.ts b/apps/edr-freight-web/backoffice/src/utils/result.ts
new file mode 100644
index 000000000..3e9627445
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/utils/result.ts
@@ -0,0 +1,29 @@
+export type Result =
+ | { success: true; data: T }
+ | { success: false; error: E };
+
+export type ApiError = {
+ code: string;
+ message: string;
+ statusCode?: number;
+};
+
+export function extractApiError(err: unknown): ApiError {
+ if (err && typeof err === "object") {
+ const obj = err as Record;
+ const response = obj.response as Record | undefined;
+ if (response) {
+ const statusCode = response.status as number | undefined;
+ const data = response.data as Record | undefined;
+ return {
+ code: (data?.error as string) || (data?.message as string) || "api_error",
+ message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred",
+ statusCode,
+ };
+ }
+ if (obj.message && typeof obj.message === "string") {
+ return { code: "client_error", message: obj.message };
+ }
+ }
+ return { code: "unknown_error", message: "An unexpected error occurred" };
+}
diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx
index d16a70175..147668d91 100644
--- a/apps/edr-freight-web/portal/src/App.tsx
+++ b/apps/edr-freight-web/portal/src/App.tsx
@@ -14,11 +14,13 @@ import {
Home,
Loader2,
User,
+ Settings,
} from "lucide-react";
import useAuth from "./hooks/useAuth";
import ProfilePage from "./pages/ProfilePage";
+import SettingsPage from "./pages/SettingsPage";
import MyPortalPage from "./pages/MyPortalPage";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import SignupPage from "./pages/accounts/SignupPage";
@@ -27,13 +29,12 @@ import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
import LoginPage from "./pages/accounts/LoginPage";
import MyBookings from "./pages/bookings/MyBookings";
+import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import TrackingPage from "./pages/tracking/TrackingPage";
import BillingPage from "./pages/billing/BillingPage";
import { useEffect } from "react";
-import CustomerOnBoarding from "./pages/customers/on_boarding/TransportrOnBoarding";
-import CustomerOnboardingPage from "./pages/customers/on_boarding/CustomerOnboardingPage";
const sidebarItems: SidebarItem[] = [
{ label: "Home", href: "/portal", icon: },
@@ -41,17 +42,20 @@ const sidebarItems: SidebarItem[] = [
{ label: "Tracking", href: "/tracking", icon: },
{ label: "Billing", href: "/billing", icon: },
{ label: "Profile", href: "/profile", icon: },
+ { label: "Settings", href: "/settings", icon: },
];
const App = () => {
const navigate = useNavigate();
const location = useLocation();
- const { user, isPending, logout, customer } = useAuth();
+ const { user, isPending, logout, customer, customerQuery } = useAuth();
+
useEffect(() => {
if (isPending) return;
const isInProtectedRoutes = sidebarItems.find((item) =>
location.pathname.startsWith(item.href),
);
+ console.log({ isInProtectedRoutes, location });
if (!user) {
if (isInProtectedRoutes) return navigate("/login");
return;
@@ -103,9 +107,11 @@ const App = () => {
} />
} />
} />
+ } />
} />
} />
} />
+ } />
} />
diff --git a/apps/edr-freight-web/portal/src/components/bookings/ContractSignaturePad.tsx b/apps/edr-freight-web/portal/src/components/bookings/ContractSignaturePad.tsx
new file mode 100644
index 000000000..aacf47858
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/components/bookings/ContractSignaturePad.tsx
@@ -0,0 +1,105 @@
+import { useEffect, useRef, useState } from "react";
+import { Eraser } from "lucide-react";
+
+import { Button } from "@edr/ui-common";
+import { cn } from "@/lib/utils";
+
+interface ContractSignaturePadProps {
+ onChange: (dataUrl: string | null) => void;
+ className?: string;
+}
+
+export function ContractSignaturePad({
+ onChange,
+ className,
+}: ContractSignaturePadProps) {
+ const canvasRef = useRef(null);
+ const drawing = useRef(false);
+ const [empty, setEmpty] = useState(true);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ const dpr = window.devicePixelRatio || 1;
+ const w = canvas.offsetWidth;
+ const h = canvas.offsetHeight;
+ canvas.width = w * dpr;
+ canvas.height = h * dpr;
+ ctx.scale(dpr, dpr);
+ ctx.strokeStyle = "#111";
+ ctx.lineWidth = 2;
+ ctx.lineCap = "round";
+ }, []);
+
+ const getPos = (e: React.MouseEvent | React.TouchEvent) => {
+ const canvas = canvasRef.current!;
+ const rect = canvas.getBoundingClientRect();
+ if ("touches" in e) {
+ const t = e.touches[0];
+ return { x: t.clientX - rect.left, y: t.clientY - rect.top };
+ }
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
+ };
+
+ const start = (e: React.MouseEvent | React.TouchEvent) => {
+ drawing.current = true;
+ const ctx = canvasRef.current?.getContext("2d");
+ const { x, y } = getPos(e);
+ ctx?.beginPath();
+ ctx?.moveTo(x, y);
+ };
+
+ const move = (e: React.MouseEvent | React.TouchEvent) => {
+ if (!drawing.current) return;
+ const ctx = canvasRef.current?.getContext("2d");
+ const { x, y } = getPos(e);
+ ctx?.lineTo(x, y);
+ ctx?.stroke();
+ setEmpty(false);
+ onChange(canvasRef.current?.toDataURL("image/png") ?? null);
+ };
+
+ const end = () => {
+ drawing.current = false;
+ };
+
+ const clear = () => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ setEmpty(true);
+ onChange(null);
+ };
+
+ return (
+
+
+
+
+
+
Draw your signature above
+
+
+ Clear
+
+
+ {empty && (
+
Signature is required before confirming.
+ )}
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/components/ui/badge.tsx b/apps/edr-freight-web/portal/src/components/ui/badge.tsx
new file mode 100644
index 000000000..0512e9936
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/components/ui/badge.tsx
@@ -0,0 +1,47 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Slot } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
+ outline:
+ "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 [a&]:hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Badge({
+ className,
+ variant = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "span";
+
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/apps/edr-freight-web/portal/src/components/ui/index.ts b/apps/edr-freight-web/portal/src/components/ui/index.ts
new file mode 100644
index 000000000..21dcac6e4
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/components/ui/index.ts
@@ -0,0 +1,8 @@
+export * from './table';
+export * from './badge';
+export * from './button';
+export * from './dialog';
+export * from './input';
+export * from './label';
+export * from './textarea';
+export * from './Breadcrumbs';
diff --git a/apps/edr-freight-web/portal/src/components/ui/table.tsx b/apps/edr-freight-web/portal/src/components/ui/table.tsx
new file mode 100644
index 000000000..128912911
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/components/ui/table.tsx
@@ -0,0 +1,114 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return (
+
+ );
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return (
+
+ );
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return (
+
+ );
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+ return (
+ tr]:last:border-b-0",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ );
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ );
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+};
diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts
index 84db5e2c2..b2dd0f254 100644
--- a/apps/edr-freight-web/portal/src/constants/URLS.ts
+++ b/apps/edr-freight-web/portal/src/constants/URLS.ts
@@ -79,9 +79,20 @@ export const URL_CONSTANTS = {
BY_USER_ID: (id: string) => `/api/customers/user/${id}`,
},
+ COMPANIES_API: {
+ GET_INFO: "/api/companies/getInfo",
+ CREATE: "/api/companies/create",
+ PROFILE: "/api/companies/profile",
+ DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
+ },
+
BOOKINGS: {
BASE: "/bookings",
BY_ID: (id: string | number) => `/bookings/${id}`,
+ CONTRACT_VIEW: (id: string) => `/bookings/${id}/contract/view`,
+ CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`,
+ CONTRACT_SIGN: (id: string) => `/bookings/${id}/contract/sign`,
+ CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
CANCEL: (id: string | number) => `/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/bookings/${id}/confirm`,
},
diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts
index b661ed98a..7f4c02585 100644
--- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts
+++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts
@@ -29,15 +29,13 @@ const useAuth = () => {
const authQuery = useQuery(
api.auth.getMyInfo.queryOptions({
- enabled: !!getCookie("auth-token"),
retry: false,
staleTime: 10 * 60 * 1000,
}),
);
- const customerQuery = useQuery(
- api.customers.getByUserId.queryOptions({
- input: { id: authQuery.data?.id ?? "" },
+ const companyQuery = useQuery(
+ api.companies.getInfo.queryOptions({
enabled: !!authQuery.data?.id,
retry: false,
staleTime: 10 * 60 * 1000,
@@ -48,12 +46,12 @@ const useAuth = () => {
useEffect(() => {
console.log({
user: authQuery.data,
- customer: customerQuery.data,
- isCustomer: !!customerQuery.data,
+ company: companyQuery.data,
+ isCompany: !!companyQuery.data,
isUserPending: authQuery.isPending,
- isCustomerPending: customerQuery.isPending,
+ isCompanyPending: companyQuery.isPending,
});
- }, [authQuery, customerQuery]);
+ }, [authQuery, companyQuery]);
const hasToken = !!getCookie("auth-token");
const isPending = authQuery.isPending && hasToken;
@@ -180,7 +178,8 @@ const useAuth = () => {
return {
isPending,
user: authQuery.data ?? null,
- customer: customerQuery.data ?? null,
+ company: companyQuery.data ?? null,
+ customer: companyQuery.data ?? null,
login,
signup,
setPassword,
@@ -189,7 +188,8 @@ const useAuth = () => {
generateVerificationCode,
logout,
authQuery,
- customerQuery,
+ companyQuery,
+ customerQuery: companyQuery,
};
};
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx
index 440617f3e..6cb111bb7 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx
@@ -1,4 +1,4 @@
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import {
ArrowRight,
@@ -14,6 +14,8 @@ import {
Plus,
Receipt,
Truck,
+ UploadCloud,
+ X,
} from "lucide-react";
import {
@@ -48,6 +50,7 @@ export default function MyPortalPage() {
const outstandingInvoices = myInvoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
);
+ const [dismissed, setDismissed] = useState(false);
const totalOutstanding = outstandingInvoices
.filter((inv) => inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0);
@@ -61,6 +64,34 @@ export default function MyPortalPage() {
return (
+ {/* Documents banner */}
+ {!me.documentsComplete && !dismissed && (
+
+
+
+
Upload your documents
+
+ To enable all account features, please upload your Business
+ License, TIN Certificate, and National ID / Passport.
+
+
+ Upload now
+
+
+
setDismissed(true)}
+ className="shrink-0 rounded-lg p-1 text-amber-400 transition hover:bg-amber-100 hover:text-amber-600"
+ aria-label="Dismiss"
+ >
+
+
+
+ )}
+
{/* Welcome banner */}
diff --git a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx
index e0d364bc1..9c4354723 100644
--- a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx
@@ -1,135 +1,46 @@
-import { useMemo } from "react";
-import {
- User,
- Building2,
- Phone,
- Mail,
- MapPin,
- ShieldCheck,
- Briefcase,
- UserCheck,
- Building,
- Globe,
- Fingerprint,
- FileCheck,
- Settings2,
- ExternalLink,
-} from "lucide-react";
-import useAuth from "@/hooks/useAuth";
-import {
- Card,
- CardHeader,
- CardTitle,
- CardDescription,
- CardContent,
- CardAction,
- Badge,
- Separator,
- SmartFileInput,
- Button,
-} from "@edr/ui-common";
-import type { IFileUploadSetting } from "@edr/types/freight";
-import { cn } from "@/lib/utils";
+import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react";
+import { useQuery } from "@tanstack/react-query";
+import { api } from "@/services/api";
+import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common";
+
+function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) {
+ return (
+
+ {icon &&
{icon}
}
+
+
{label}
+
{value || "—"}
+
+
+ );
+}
export default function ProfilePage() {
- const { user, customer, isPending } = useAuth();
-
- const documentSettings = useMemo
(() => ({
- id: "profile-docs",
- code: "customer_documents",
- label: "Customer Documents",
- entity: "customer",
- createdAt: new Date(),
- updatedAt: new Date(),
- fields: [
- {
- id: "doc-tin",
- settingId: "profile-docs",
- fileKey: "tin_certificate",
- fileLabel: "TIN Certificate",
- isRequired: true,
- isMultiple: false,
- maxFiles: 1,
- allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
- maxSizeMb: 5,
- order: 1,
- createdAt: new Date(),
- updatedAt: new Date(),
- },
- {
- id: "doc-license",
- settingId: "profile-docs",
- fileKey: "business_license",
- fileLabel: "Business/Investment License",
- isRequired: true,
- isMultiple: false,
- maxFiles: 1,
- allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
- maxSizeMb: 5,
- order: 2,
- createdAt: new Date(),
- updatedAt: new Date(),
- },
- {
- id: "doc-reg",
- settingId: "profile-docs",
- fileKey: "registration_certificate",
- fileLabel: "Business Registration Certificate",
- isRequired: true,
- isMultiple: false,
- maxFiles: 1,
- allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
- maxSizeMb: 5,
- order: 3,
- createdAt: new Date(),
- updatedAt: new Date(),
- },
- {
- id: "doc-id",
- settingId: "profile-docs",
- fileKey: "national_id",
- fileLabel: "National ID",
- isRequired: true,
- isMultiple: false,
- maxFiles: 1,
- allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
- maxSizeMb: 5,
- order: 4,
- createdAt: new Date(),
- updatedAt: new Date(),
- },
- {
- id: "doc-poa",
- settingId: "profile-docs",
- fileKey: "power_of_attorney",
- fileLabel: "Power of Attorney",
- isRequired: false,
- isMultiple: false,
- maxFiles: 1,
- allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
- maxSizeMb: 5,
- order: 5,
- createdAt: new Date(),
- updatedAt: new Date(),
- },
- ],
- }), []);
+ const { data: profile, isPending } = useQuery(
+ api.companies.getProfile.queryOptions(),
+ );
if (isPending) {
return (
);
}
- const displayName = user?.name?.en || user?.username || user?.email || "User";
+ if (!profile) {
+ return (
+
+
No company profile found.
+
+ );
+ }
return (
-
-
- {/* Header Section */}
-
+
+
+
+ {/* Header */}
@@ -137,7 +48,7 @@ export default function ProfilePage() {
- {displayName}
+ {profile.companyName}
Verified
@@ -145,182 +56,129 @@ export default function ProfilePage() {
- {customer?.companyName || "No Company Linked"}
+ {profile.companyName}
-
-
-
- Account Settings
-
-
-
-
+
-
- {/* Left Column - Personal & Company Info */}
-
-
- {/* Personal Details Card */}
+
+ {/* Left Column */}
+
+
+ {/* Company Details */}
+
+
+
+
+ Company Details
+
+ Business registration information
+
+
+ } label="Location" value={profile.companyLocation} />
+ } label="Address" value={profile.companyAddress} />
+ } label="TIN Number" value={profile.tinNumber} />
+ } label="FAN Number" value={profile.fanNumber} />
+ } label="Email" value={profile.companyEmail} />
+ } label="Phone" value={profile.companyPhone} />
+
+
+
+ {/* Personal Details (from ExternalProfile) */}
+
+
+
+
+ Profile Details
+
+ Your linked user profile
+
+
+ } label="Profile" value="Primary Contact" />
+
+
+
+
+ {/* Personnel Card */}
-
- Personal Details
+
+ Key Personnel
- Your account contact information
-
-
-
-
-
+ Management and contact persons
-
- } label="Email Address" value={user?.email} />
- } label="Phone Number" value={user?.phoneNumber} />
- } label="Username" value={user?.username} />
+
+
+
+ Contact Person
+
+
+
+
+
+
+
+
+ General Manager
+
+
+
+
+
+
+
- {/* Company Details Card */}
-
-
-
-
- Company Details
-
- Business registration information
-
-
- } label="Location" value={customer?.companyLocation} />
- } label="Address" value={customer?.companyAddress} />
- } label="TIN Number" value={customer?.tinNumber} />
- } label="FAN Number" value={customer?.fanNumber} />
+ {/* Power of Attorney */}
+ {profile.poaName && (
+
+
+
+
+ Power of Attorney
+
+ Authorized representative details
+
+
+
+
+
+
+
+
+ )}
+
+
+ {/* Right Column */}
+
+
+
+
+
+
+ Secure Account
+
+ Your information is protected by enterprise-grade security.
+ Contact support for verified information updates.
+
+
-
- {/* Personnel Card */}
-
-
-
-
- Key Personnel
-
- Management and contact persons
-
-
-
-
- Contact Person
-
-
-
-
-
-
-
-
- General Manager
-
-
-
-
-
-
-
-
-
-
- {/* Power of Attorney Section (Conditional) */}
- {customer?.poaName && (
-
-
-
-
- Power of Attorney
-
- Authorized representative details
-
-
-
-
-
-
-
-
- )}
-
-
- {/* Right Column - Documents */}
-
-
-
-
-
- Documents
-
- Manage required business documents
-
-
-
-
-
-
-
-
-
-
-
- Secure Account
-
- Your information is protected by enterprise-grade security.
- Contact support for verified information updates.
-
-
-
- Contact Support
-
-
-
-
);
}
-
-function InfoItem({
- icon,
- label,
- value,
-}: {
- icon?: React.ReactNode;
- label: string;
- value?: string | null;
-}) {
- return (
-
- {icon && (
-
- {icon}
-
- )}
-
-
- {label}
-
-
- {value || "—"}
-
-
-
- );
-}
diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
new file mode 100644
index 000000000..faec1b5b2
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
@@ -0,0 +1,617 @@
+import { useState, useMemo } from "react";
+import { useSearchParams } from "react-router-dom";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import {
+ Building2,
+ User,
+ Briefcase,
+ UserCheck,
+ FileCheck,
+ Loader2,
+ Save,
+ UploadCloud,
+ CheckCircle2,
+ XCircle,
+} from "lucide-react";
+import { api } from "@/services/api";
+import { companiesService } from "@/services/companies.service";
+import PhoneInput from "@/components/auth/PhoneInput";
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ CardFooter,
+ Button,
+ Input,
+ Field,
+ FieldLabel,
+ FieldError,
+ FieldGroup,
+ SmartFileInput,
+ Badge,
+} from "@edr/ui-common";
+import { cn } from "@/lib/utils";
+
+type SettingsTab =
+ | "company"
+ | "contact"
+ | "gm"
+ | "poa"
+ | "documents";
+
+const settingsSchema = 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"),
+ 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"),
+ 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"),
+ 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"),
+ poaName: z.string().optional(),
+ poaEmail: z.string().optional(),
+ poaPhone: z.string().optional(),
+ poaPhoneCountryCode: z.string().optional(),
+ poaLocation: z.string().optional(),
+ poaAddress: z.string().optional(),
+});
+
+type FormData = z.infer
;
+
+const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
+ { id: "company", label: "Company Profile", icon: },
+ { id: "contact", label: "Contact Person", icon: },
+ { id: "gm", label: "General Manager", icon: },
+ { id: "poa", label: "Power of Attorney", icon: },
+ { id: "documents", label: "Documents", icon: },
+];
+
+function splitPhone(fullPhone?: string | null): { code: string; number: string } {
+ 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 };
+}
+
+export default function SettingsPage() {
+ const queryClient = useQueryClient();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const tab = (searchParams.get("tab") as SettingsTab) || "company";
+ const setTab = (t: SettingsTab) => {
+ setSearchParams((prev) => {
+ const next = new URLSearchParams(prev);
+ next.set("tab", t);
+ return next;
+ }, { replace: true });
+ };
+ const [documentFiles, setDocumentFiles] = useState<
+ Record
+ >({});
+
+ const profileQuery = useQuery(
+ api.companies.getProfile.queryOptions(),
+ );
+
+ const docSettingQuery = useQuery(
+ api.fileUploadSettings.getByCode.queryOptions({
+ input: { code: "customer_documents" },
+ enabled: tab === "documents",
+ }),
+ );
+
+ const profile = profileQuery.data;
+
+ const defaultValues = useMemo((): FormData => {
+ if (!profile) {
+ return {
+ companyName: "",
+ companyEmail: "",
+ companyPhone: "",
+ companyPhoneCountryCode: "+251",
+ companyLocation: "",
+ companyAddress: "",
+ tinNumber: "",
+ fanNumber: "",
+ contactPersonName: "",
+ contactPersonPhone: "",
+ contactPersonPhoneCountryCode: "+251",
+ generalManagerName: "",
+ generalManagerEmail: "",
+ generalManagerPhone: "",
+ generalManagerPhoneCountryCode: "+251",
+ poaName: "",
+ poaEmail: "",
+ poaPhone: "",
+ poaPhoneCountryCode: "+251",
+ poaLocation: "",
+ poaAddress: "",
+ };
+ }
+ const contactPhone = splitPhone(profile.contactPersonPhone);
+ const gmPhone = splitPhone(profile.generalManagerPhone);
+ const poaPhone = splitPhone(profile.poaPhone);
+ return {
+ companyName: profile.companyName,
+ companyEmail: profile.companyEmail ?? "",
+ companyPhone: profile.companyPhone ?? "",
+ companyPhoneCountryCode: splitPhone(profile.companyPhone).code,
+ companyLocation: profile.companyLocation,
+ companyAddress: profile.companyAddress ?? "",
+ tinNumber: profile.tinNumber,
+ fanNumber: profile.fanNumber ?? "",
+ contactPersonName: profile.contactPersonName ?? "",
+ contactPersonPhone: contactPhone.number,
+ contactPersonPhoneCountryCode: contactPhone.code,
+ generalManagerName: profile.generalManagerName ?? "",
+ generalManagerEmail: profile.generalManagerEmail ?? "",
+ generalManagerPhone: gmPhone.number,
+ generalManagerPhoneCountryCode: gmPhone.code,
+ poaName: profile.poaName ?? "",
+ poaEmail: profile.poaEmail ?? "",
+ poaPhone: poaPhone.number,
+ poaPhoneCountryCode: poaPhone.code,
+ poaLocation: profile.poaLocation ?? "",
+ poaAddress: profile.poaAddress ?? "",
+ };
+ }, [profile]);
+
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors, isDirty },
+ } = useForm({
+ resolver: zodResolver(settingsSchema),
+ values: defaultValues,
+ });
+
+ const updateMutation = useMutation({
+ mutationFn: (data: FormData) =>
+ api.companies.updateProfile.call({
+ companyName: data.companyName,
+ companyEmail: data.companyEmail,
+ companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
+ companyLocation: data.companyLocation,
+ companyAddress: data.companyAddress,
+ tin: data.tinNumber,
+ fanNumber: data.fanNumber,
+ contactPersonName: data.contactPersonName,
+ contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
+ generalManagerName: data.generalManagerName,
+ generalManagerEmail: data.generalManagerEmail,
+ generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
+ poaName: data.poaName || undefined,
+ poaPhone:
+ data.poaPhone && data.poaPhoneCountryCode
+ ? `${data.poaPhoneCountryCode}${data.poaPhone}`
+ : undefined,
+ poaEmail: data.poaEmail || undefined,
+ poaLocation: data.poaLocation || undefined,
+ poaAddress: data.poaAddress || undefined,
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: api.companies.getProfile.queryKey(),
+ });
+ },
+ });
+
+ const docUploadMutation = useMutation({
+ mutationFn: (files: Record) =>
+ companiesService.uploadDocuments(profile!.companyId, files),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: api.companies.getProfile.queryKey(),
+ });
+ },
+ });
+
+ const isPending = profileQuery.isPending || updateMutation.isPending || docUploadMutation.isPending;
+
+ if (profileQuery.isPending) {
+ return (
+
+ );
+ }
+
+ if (!profile) {
+ return (
+
+
No company profile found.
+
+ );
+ }
+
+ const onSubmit = (data: FormData) => {
+ updateMutation.mutate(data);
+ };
+
+ return (
+
+
+
+
+ Account Settings
+
+
+ Manage your company profile, personnel, and documents
+
+
+
+ Verified
+
+
+
+ {/* Tab Bar */}
+
+ {TABS.map((t) => (
+ setTab(t.id)}
+ className={cn(
+ "flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-semibold transition-colors",
+ tab === t.id
+ ? "border-primary text-primary"
+ : "border-transparent text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {t.icon}
+ {t.label}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
index fa82a85a2..e86c30132 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
+import { useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
@@ -11,10 +12,10 @@ import {
CheckCircle2,
Loader2,
ChevronLeft,
+ UploadCloud,
} from "lucide-react";
-import type { OnboardingUserType } from "./types";
import type { AuthUser } from "@/types/auth";
-import type { CreateCustomerDto } from "@/types/customers";
+import type { CreateCompanyPayload } from "@/services/companies.service";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
@@ -23,9 +24,11 @@ import {
FieldLabel,
FieldError,
FieldGroup,
+ SmartFileInput,
} from "@edr/ui-common";
+import { api } from "@/services/api";
-type CompanyStep = "company" | "personnel" | "poa";
+type CompanyStep = "company" | "personnel" | "poa" | "documents" | "confirm";
const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
@@ -79,82 +82,76 @@ const stepFields: Record = {
"generalManagerPhoneCountryCode",
],
poa: [],
+ documents: [],
+ confirm: [],
};
-const POA_FIELDS: (keyof FormData)[] = [
- "poaName",
- "poaPhone",
- "poaPhoneCountryCode",
- "poaAddress",
- "poaEmail",
- "poaLocation",
-];
-
-const POA_LABELS: Record = {
- poaName: "PoA name",
- poaPhone: "PoA phone",
- poaPhoneCountryCode: "PoA country code",
- poaAddress: "PoA address",
- poaEmail: "PoA email",
- poaLocation: "PoA location",
-};
-
-function buildPayload(data: FormData, user: AuthUser): CreateCustomerDto {
- const nameParts = (user.name?.en ?? "").split(" ");
+function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
return {
- userId: user.id,
- firstName: nameParts[0] || "",
- lastName: nameParts.slice(-1)[0] || "",
- email: user.email,
- phone: user.phoneNumber,
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
- contactPersonName: data.contactPersonName,
- contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
- tinNumber: data.tinNumber,
+ tin: data.tinNumber,
vatNumber: data.vatNumber,
fanNumber: data.fanNumber,
- generalManagerName: data.generalManagerName,
- generalManagerEmail: data.generalManagerEmail,
- generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
- poaName: data.poaName || undefined,
- poaPhone:
- data.poaPhone && data.poaPhoneCountryCode
- ? `${data.poaPhoneCountryCode}${data.poaPhone}`
- : undefined,
- poaAddress: data.poaAddress || undefined,
- poaEmail: data.poaEmail || undefined,
- poaLocation: data.poaLocation || undefined,
+ attributes: {
+ contactPersonName: data.contactPersonName,
+ contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
+ generalManagerName: data.generalManagerName,
+ generalManagerEmail: data.generalManagerEmail,
+ generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
+ poaName: data.poaName || undefined,
+ poaPhone:
+ data.poaPhone && data.poaPhoneCountryCode
+ ? `${data.poaPhoneCountryCode}${data.poaPhone}`
+ : undefined,
+ poaAddress: data.poaAddress || undefined,
+ poaEmail: data.poaEmail || undefined,
+ poaLocation: data.poaLocation || undefined,
+ },
};
}
export default function CompanyProfileForm({
- userType,
+ documentSettingCode,
+ documentFiles: controlledFiles,
+ onDocumentFilesChange,
user,
onSubmit,
isPending,
onBack,
}: {
- userType: OnboardingUserType;
+ documentSettingCode: string;
+ documentFiles?: Record;
+ onDocumentFilesChange?: (
+ files: Record,
+ ) => void;
user: AuthUser;
- onSubmit: (data: CreateCustomerDto) => void;
+ onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {
- const requirePoA = userType === "freight-forwarder-et";
-
const [step, setStep] = useState("company");
+ const [internalFiles, setInternalFiles] = useState<
+ Record
+ >({});
+ const documentFiles = controlledFiles ?? internalFiles;
+ const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
+
+ const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
+ api.fileUploadSettings.getByCode.queryOptions({
+ input: { code: documentSettingCode },
+ refetchOnMount: false,
+ }),
+ );
const {
register,
handleSubmit,
trigger,
- setError,
- clearErrors,
- getValues,
+ watch,
formState: { errors },
} = useForm({
resolver: zodResolver(onboardingSchema),
@@ -184,26 +181,20 @@ export default function CompanyProfileForm({
},
});
+ const formValues = watch();
+ const hasDocuments = Boolean(uploadSetting?.fields?.length);
+ const totalSteps = 5;
+
const nextStep = async () => {
if (step === "poa") {
- if (requirePoA) {
- clearErrors(POA_FIELDS);
- const values = getValues();
- let hasError = false;
- for (const field of POA_FIELDS) {
- const val = values[field];
- if (!val || val.toString().trim().length === 0) {
- setError(field, {
- message: `${
- POA_LABELS[field].charAt(0).toUpperCase() +
- POA_LABELS[field].slice(1)
- } is required for Freight Forwarders`,
- });
- hasError = true;
- }
- }
- if (hasError) return;
- }
+ setStep("documents");
+ return;
+ }
+ if (step === "documents") {
+ setStep("confirm");
+ return;
+ }
+ if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
@@ -220,6 +211,10 @@ export default function CompanyProfileForm({
setStep("company");
} else if (step === "poa") {
setStep("personnel");
+ } else if (step === "documents") {
+ setStep("poa");
+ } else {
+ setStep("documents");
}
};
@@ -245,24 +240,41 @@ export default function CompanyProfileForm({
}
active={step === "personnel"}
- completed={step === "poa"}
+ completed={
+ step === "poa" || step === "documents" || step === "confirm"
+ }
/>
}
active={step === "poa"}
+ completed={step === "documents" || step === "confirm"}
+ />
+ }
+ active={step === "documents"}
+ completed={step === "confirm"}
+ />
+ }
+ active={step === "confirm"}
completed={false}
/>
- {step === "company" && "Step 1 of 3 — Company Information"}
- {step === "personnel" && "Step 2 of 3 — Personnel Details"}
+ {step === "company" &&
+ `Step 1 of ${totalSteps} — Company Information`}
+ {step === "personnel" &&
+ `Step 2 of ${totalSteps} — Personnel Details`}
{step === "poa" &&
- `Step 3 of 3 — Power of Attorney ${requirePoA ? "(Required)" : "(Optional)"}`}
+ `Step 3 of ${totalSteps} — Power of Attorney (Optional)`}
+ {step === "documents" &&
+ `Step 4 of ${totalSteps} — Upload Documents (Optional)`}
+ {step === "confirm" && `Step 5 of ${totalSteps} — Review & Confirm`}
>
)}
+
+ {step === "documents" && (
+ <>
+ {loadingDocuments ? (
+
+
+
+ ) : !uploadSetting ? (
+
+ No document requirements found for your account type.
+
+ ) : (
+
+
+
+ )}
+ >
+ )}
+
+ {step === "confirm" && (
+
+
+
+ Review your registration
+
+
+ Confirm the company details below before saving.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
- {step === "company" ? "Change Type" : "Back"}
+ {step === "company"
+ ? "Change Type"
+ : step === "confirm"
+ ? "Back to Documents"
+ : "Back"}
-
- {isPending ? (
- <>
-
- Submitting...
- >
- ) : step === "representative" ? (
- "Complete Registration"
- ) : (
- <>
- Next Step
-
- >
+
+ {step === "documents" && (
+
+ Skip for now
+
)}
-
+
+
onSubmit(buildPayload(data, user))) : nextStep}
+ disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
+ >
+ {isPending ? (
+ <>
+
+ Submitting...
+ >
+ ) : step === "documents" ? (
+ "Continue"
+ ) : step === "confirm" ? (
+ "Submit Registration"
+ ) : (
+ <>
+ Next Step
+
+ >
+ )}
+
+
>
);
}
+function ReviewRow({ label, value }: { label: string; value?: string | null }) {
+ return (
+
+
+ {label}
+
+
+ {value?.trim() ? value : "Not provided"}
+
+
+ );
+}
+
function StepIcon({
icon,
active,
diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/CustomerOnboardingPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx
similarity index 57%
rename from apps/edr-freight-web/portal/src/pages/customers/on_boarding/CustomerOnboardingPage.tsx
rename to apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx
index 994dd751e..418b8bcc4 100644
--- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/CustomerOnboardingPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx
@@ -1,6 +1,6 @@
import { useState } from "react";
-import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
+import { useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
@@ -11,11 +11,11 @@ import {
FileText,
CheckCircle2,
Loader2,
+ ChevronLeft,
+ UploadCloud,
} from "lucide-react";
-import useAuth from "@/hooks/useAuth";
-import { api } from "@/services/api";
-import type { CreateCustomerDto } from "@/types/customers";
-import AuthLayout from "@/components/auth/AuthLayout";
+import type { AuthUser } from "@/types/auth";
+import type { CreateCompanyPayload } from "@/services/companies.service";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
@@ -24,14 +24,13 @@ import {
FieldLabel,
FieldError,
FieldGroup,
+ SmartFileInput,
} from "@edr/ui-common";
-import TransporterOnboarding from "./TransportrOnBoarding";
-import DjiboutiForwardingAgentForm from "./DjiboutiFreightForwardingAgent";
-import ImportExportOnBoarding from "./ImportExportOnBoarding";
+import { api } from "@/services/api";
-type OnboardingStep = "company" | "personnel" | "poa";
+type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "confirm";
-const onboardingSchema = z.object({
+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"),
@@ -59,9 +58,9 @@ const onboardingSchema = z.object({
poaLocation: z.string().optional(),
});
-type FormData = z.infer
;
+type FormData = z.infer;
-const stepFields: Record = {
+const stepFields: Record = {
company: [
"companyName",
"companyEmail",
@@ -83,20 +82,79 @@ const stepFields: Record = {
"generalManagerPhoneCountryCode",
],
poa: [],
+ documents: [],
+ confirm: [],
};
-export default function CustomerOnboardingPage() {
- const queryClient = useQueryClient();
- const { user } = useAuth();
- const [step, setStep] = useState("company");
+function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
+ return {
+ companyName: data.companyName,
+ companyEmail: data.companyEmail,
+ companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
+ companyLocation: data.companyLocation,
+ companyAddress: data.companyAddress,
+ tin: data.tinNumber,
+ vatNumber: data.vatNumber,
+ fanNumber: data.fanNumber,
+ attributes: {
+ contactPersonName: data.contactPersonName,
+ contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
+ generalManagerName: data.generalManagerName,
+ generalManagerEmail: data.generalManagerEmail,
+ generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
+ poaName: data.poaName || undefined,
+ poaPhone:
+ data.poaPhone && data.poaPhoneCountryCode
+ ? `${data.poaPhoneCountryCode}${data.poaPhone}`
+ : undefined,
+ poaAddress: data.poaAddress || undefined,
+ poaEmail: data.poaEmail || undefined,
+ poaLocation: data.poaLocation || undefined,
+ },
+ };
+}
+
+export default function ForwarderForm({
+ documentSettingCode,
+ documentFiles: controlledFiles,
+ onDocumentFilesChange,
+ user,
+ onSubmit,
+ isPending,
+ onBack,
+}: {
+ documentSettingCode: string;
+ documentFiles?: Record;
+ onDocumentFilesChange?: (
+ files: Record,
+ ) => void;
+ user: AuthUser;
+ onSubmit: (data: CreateCompanyPayload) => void;
+ isPending: boolean;
+ onBack: () => void;
+}) {
+ const [step, setStep] = useState("company");
+ const [internalFiles, setInternalFiles] = useState<
+ Record
+ >({});
+ const documentFiles = controlledFiles ?? internalFiles;
+ const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
+
+ const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
+ api.fileUploadSettings.getByCode.queryOptions({
+ input: { code: documentSettingCode },
+ refetchOnMount: false,
+ }),
+ );
const {
register,
handleSubmit,
trigger,
+ watch,
formState: { errors },
} = useForm({
- resolver: zodResolver(onboardingSchema),
+ resolver: zodResolver(forwarderSchema),
defaultValues: {
companyName: "",
companyEmail: "",
@@ -123,20 +181,21 @@ export default function CustomerOnboardingPage() {
},
});
- const createCustomerMutation = useMutation({
- mutationFn: (payload: CreateCustomerDto) =>
- api.customers.create.call(payload),
- onSuccess: () => {
- if (user)
- queryClient.invalidateQueries({
- queryKey: api.customers.getByUserId.queryKey({ id: user.id }),
- });
- },
- });
+ const formValues = watch();
+ const hasDocuments = Boolean(uploadSetting?.fields?.length);
+ const totalSteps = 5;
const nextStep = async () => {
if (step === "poa") {
- handleSubmit(onSubmit)();
+ setStep("documents");
+ return;
+ }
+ if (step === "documents") {
+ setStep("confirm");
+ return;
+ }
+ if (step === "confirm") {
+ handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
const fields = stepFields[step];
@@ -145,69 +204,36 @@ export default function CustomerOnboardingPage() {
setStep(step === "company" ? "personnel" : "poa");
};
- const prevStep = () => {
- if (step === "personnel") setStep("company");
- else if (step === "poa") setStep("personnel");
+ const skipDocuments = () => {
+ setStep("confirm");
};
- const onSubmit = async (data: FormData) => {
- const nameParts = (user?.name?.en ?? "").split(" ");
- const payload: CreateCustomerDto = {
- userId: user!.id,
- firstName: nameParts[0] || "",
- lastName: nameParts.slice(-1)[0] || "",
- email: user!.email,
- phone: user!.phoneNumber,
- companyName: data.companyName,
- companyEmail: data.companyEmail,
- companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
- companyLocation: data.companyLocation,
- companyAddress: data.companyAddress,
- contactPersonName: data.contactPersonName,
- contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
- tinNumber: data.tinNumber,
- vatNumber: data.vatNumber,
- fanNumber: data.fanNumber,
- generalManagerName: data.generalManagerName,
- generalManagerEmail: data.generalManagerEmail,
- generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
- poaName: data.poaName || undefined,
- poaPhone:
- data.poaPhone && data.poaPhoneCountryCode
- ? `${data.poaPhoneCountryCode}${data.poaPhone}`
- : undefined,
- poaAddress: data.poaAddress || undefined,
- poaEmail: data.poaEmail || undefined,
- poaLocation: data.poaLocation || undefined,
- };
- createCustomerMutation.mutate(payload);
+ const prevStep = () => {
+ if (step === "company") {
+ onBack();
+ } else if (step === "personnel") {
+ setStep("company");
+ } else if (step === "poa") {
+ setStep("personnel");
+ } else if (step === "documents") {
+ setStep("poa");
+ } else {
+ setStep("documents");
+ }
};
return (
-
+ <>
+
+
+
+ Change account type
+
-
- {/*
*/}
- {/*
*/}
- {/*
}
active={step === "personnel"}
- completed={step === "poa"}
+ completed={step === "poa" || step === "documents" || step === "confirm"}
/>
}
active={step === "poa"}
+ completed={step === "documents" || step === "confirm"}
+ />
+
}
+ active={step === "documents"}
+ completed={step === "confirm"}
+ />
+
}
+ active={step === "confirm"}
completed={false}
/>
- {step === "company" && "Step 1 of 3 — Company Information"}
- {step === "personnel" && "Step 2 of 3 — Personnel Details"}
- {step === "poa" && "Step 3 of 3 — Power of Attorney (Optional)"}
+ {step === "company" &&
+ `Step 1 of ${totalSteps} — Company Information`}
+ {step === "personnel" &&
+ `Step 2 of ${totalSteps} — Personnel Details`}
+ {step === "poa" &&
+ `Step 3 of ${totalSteps} — Power of Attorney (Optional)`}
+ {step === "documents" &&
+ `Step 4 of ${totalSteps} — Upload Documents (Optional)`}
+ {step === "confirm" && `Step 5 of ${totalSteps} — Review & Confirm`}
-
*/}
+
- {/*
+ e.preventDefault()}
+ className="flex flex-col gap-4"
+ >
{step === "company" && (
<>
@@ -332,11 +377,6 @@ export default function CustomerOnboardingPage() {
{step === "personnel" && (
<>
-
- Personal details are pulled from your account. Contact and
- management info is collected below.
-
-
Contact Person
@@ -418,88 +458,212 @@ export default function CustomerOnboardingPage() {
{step === "poa" && (
<>
- Power of Attorney details are optional. Skip if not applicable.
+ Power of Attorney details are optional. Fill them in if you have
+ them, or skip to continue.
-
+
PoA Name
+
-
+
PoA Location
+
-
+
PoA Address
+
>
)}
+
+ {step === "documents" && (
+ <>
+ {loadingDocuments ? (
+
+
+
+ ) : !uploadSetting ? (
+
+ No document requirements found for your account type.
+
+ ) : (
+
+
+
+ )}
+ >
+ )}
+
+ {step === "confirm" && (
+
+
+
+ Review your registration
+
+
+ Confirm the company details below before saving.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
-
+
- Back
+ {step === "company"
+ ? "Change Type"
+ : step === "confirm"
+ ? "Back to Documents"
+ : "Back"}
-
- {createCustomerMutation.isPending ? (
- <>
-
- Submitting...
- >
- ) : step === "poa" ? (
- "Complete Registration"
- ) : (
- <>
- Next Step
-
- >
+
+ {step === "documents" && (
+
+ Skip for now
+
)}
-
+
+
onSubmit(buildPayload(data, user))) : nextStep}
+ disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
+ >
+ {isPending ? (
+ <>
+
+ Submitting...
+ >
+ ) : step === "documents" ? (
+ "Continue"
+ ) : step === "confirm" ? (
+ "Submit Registration"
+ ) : (
+ <>
+ Next Step
+
+ >
+ )}
+
+
- */}
-
+
+ >
+ );
+}
+
+function ReviewRow({ label, value }: { label: string; value?: string | null }) {
+ return (
+
+
+ {label}
+
+
+ {value?.trim() ? value : "Not provided"}
+
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx
index b32d2b192..ec1d724f8 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx
@@ -10,9 +10,11 @@ import {
} from "lucide-react";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
-import type { CreateCustomerDto } from "@/types/customers";
+import { companiesService } from "@/services/companies.service";
+import type { CreateCompanyPayload } from "@/services/companies.service";
import AuthLayout from "@/components/auth/AuthLayout";
import CompanyProfileForm from "./CompanyProfileForm";
+import ForwarderForm from "./ForwarderForm";
import DjiboutiAgentForm from "./DjiboutiAgentForm";
import TransporterForm from "./TransporterForm";
import type { OnboardingUserType } from "./types";
@@ -23,40 +25,37 @@ const USER_TYPE_CARDS: {
description: string;
icon: React.ReactNode;
}[] = [
- {
- id: "importer",
- label: "Importer",
- description: "Import goods into Ethiopia via the railway corridor.",
- icon: ,
- },
- {
- id: "exporter",
- label: "Exporter",
- description: "Export goods from Ethiopia via rail.",
- icon: ,
- },
- {
- id: "freight-forwarder-et",
- label: "Freight Forwarder (Ethiopia)",
- description:
- "Ethiopian freight forwarding company handling client cargo.",
- icon: ,
- },
- {
- id: "freight-forwarder-dj",
- label: "FF Agent (Djibouti)",
- description:
- "Djibouti-based agent coordinating cross-border logistics.",
- icon: ,
- },
- {
- id: "transporter",
- label: "Transporter",
- description:
- "Trucking company providing first/last-mile services.",
- icon: ,
- },
-];
+ {
+ id: "importer",
+ label: "Importer",
+ description: "Import goods into Ethiopia via the railway corridor.",
+ icon: ,
+ },
+ {
+ id: "exporter",
+ label: "Exporter",
+ description: "Export goods from Ethiopia via rail.",
+ icon: ,
+ },
+ {
+ id: "freight-forwarder-et",
+ label: "Freight Forwarder (Ethiopia)",
+ description: "Ethiopian freight forwarding company handling client cargo.",
+ icon: ,
+ },
+ {
+ id: "freight-forwarder-dj",
+ label: "FF Agent (Djibouti)",
+ description: "Djibouti-based agent coordinating cross-border logistics.",
+ icon: ,
+ },
+ {
+ id: "transporter",
+ label: "Transporter",
+ description: "Trucking company providing first/last-mile services.",
+ icon: ,
+ },
+ ];
const USER_TYPE_LEFT_MAP: Record<
OnboardingUserType,
@@ -116,26 +115,54 @@ const PREFLIGHT_LEFT = {
},
};
+const DOCUMENT_SETTING_CODE_MAP: Record = {
+ importer: "company_onboarding_documents_customer",
+ exporter: "company_onboarding_documents_customer",
+ "freight-forwarder-et": "company_onboarding_documents_forwarder",
+ "freight-forwarder-dj": "company_onboarding_documents_forwarder_dj",
+ transporter: "company_onboarding_documents_transporter",
+};
+
export default function OnboardingPage() {
const queryClient = useQueryClient();
const { user } = useAuth();
const [userType, setUserType] = useState(null);
+ const [documentFiles, setDocumentFiles] = useState<
+ Record
+ >({});
- const createCustomerMutation = useMutation({
- mutationFn: (payload: CreateCustomerDto) =>
- api.customers.create.call(payload),
- onSuccess: () => {
- if (user)
- queryClient.invalidateQueries({
- queryKey: api.customers.getByUserId.queryKey({ id: user.id }),
- });
+ const COMPANY_TYPE_MAP: Record = {
+ importer: "customer",
+ exporter: "customer",
+ "freight-forwarder-et": "forwarder",
+ "freight-forwarder-dj": "forwarder",
+ transporter: "transporter",
+ };
+
+ const createCompanyMutation = useMutation({
+ mutationFn: (payload: CreateCompanyPayload) =>
+ api.companies.create.call(payload),
+ onSuccess: async (data) => {
+ const hasFiles = Object.values(documentFiles).some(
+ (f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
+ );
+ if (hasFiles) {
+ await companiesService.uploadDocuments(data.company.id, documentFiles);
+ }
+ await queryClient.invalidateQueries({
+ queryKey: api.companies.getInfo.queryKey(),
+ });
},
});
if (!user) return null;
- const handleSubmit = (payload: CreateCustomerDto) => {
- createCustomerMutation.mutate(payload);
+ const handleSubmit = (payload: CreateCompanyPayload) => {
+ const enriched: CreateCompanyPayload = {
+ ...payload,
+ companyType: COMPANY_TYPE_MAP[userType!],
+ };
+ createCompanyMutation.mutate(enriched);
};
const handleSelectType = (type: OnboardingUserType) => {
@@ -172,9 +199,7 @@ export default function OnboardingPage() {
{card.icon}
-
- {card.label}
-
+
{card.label}
{card.description}
@@ -197,21 +222,21 @@ export default function OnboardingPage() {
features:
userType === "transporter"
? [
- "Vehicle & fleet registration",
- "TIN & FAN verification",
- "First-mile / Last-mile eligibility",
- ]
+ "Vehicle & fleet registration",
+ "TIN & FAN verification",
+ "First-mile / Last-mile eligibility",
+ ]
: userType === "freight-forwarder-dj"
? [
- "Company details",
- "Representative information",
- "Cross-border operations",
- ]
+ "Company details",
+ "Representative information",
+ "Cross-border operations",
+ ]
: [
- "Company registration details",
- "Contact and management personnel",
- "Power of Attorney (optional)",
- ],
+ "Company registration details",
+ "Contact and management personnel",
+ "Power of Attorney (optional)",
+ ],
stats: {
label: "Active Customers",
value: "500+",
@@ -224,24 +249,42 @@ export default function OnboardingPage() {
{userType === "transporter" ? (
) : userType === "freight-forwarder-dj" ? (
+ ) : userType === "freight-forwarder-et" ? (
+
) : (
)}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/TransporterForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/TransporterForm.tsx
index 4f077e1ca..eea5c0051 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/TransporterForm.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/TransporterForm.tsx
@@ -1,14 +1,19 @@
+import { useState } from "react";
import { useForm, Controller } from "react-hook-form";
+import { useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
- Loader2,
+ ArrowRight,
+ ArrowLeft,
ChevronLeft,
Truck,
- Info,
+ CheckCircle2,
+ Loader2,
+ UploadCloud,
} from "lucide-react";
import type { AuthUser } from "@/types/auth";
-import type { CreateCustomerDto } from "@/types/customers";
+import type { CreateCompanyPayload } from "@/services/companies.service";
import {
Button,
Input,
@@ -20,8 +25,10 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
+ SmartFileInput,
} from "@edr/ui-common";
import { cn } from "@/lib/utils";
+import { api } from "@/services/api";
const TRUCK_TYPES = [
"Casoni",
@@ -31,6 +38,8 @@ const TRUCK_TYPES = [
"Others",
] as const;
+type TransporterStep = "vehicle" | "documents" | "confirm";
+
const transporterSchema = z
.object({
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
@@ -45,7 +54,10 @@ const transporterSchema = z
.regex(/^\d{4}$/, "Enter a valid year (e.g. 2023)"),
})
.superRefine((data, ctx) => {
- if (data.truckType === "Casoni" && (!data.plateNumber2 || data.plateNumber2.trim().length === 0)) {
+ if (
+ data.truckType === "Casoni" &&
+ (!data.plateNumber2 || data.plateNumber2.trim().length === 0)
+ ) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["plateNumber2"],
@@ -56,51 +68,63 @@ const transporterSchema = z
type FormData = z.infer;
-function buildPayload(data: FormData, user: AuthUser): CreateCustomerDto {
- const nameParts = (user.name?.en ?? "").split(" ");
+function buildPayload(data: FormData, user: AuthUser): CreateCompanyPayload {
return {
- userId: user.id,
- firstName: nameParts[0] || "",
- lastName: nameParts.slice(-1)[0] || "",
- email: user.email,
- phone: user.phoneNumber,
- companyName: "",
- companyEmail: "",
- companyPhone: "",
+ companyName: user.name?.en ?? "",
+ companyEmail: user.email,
+ companyPhone: user.phoneNumber,
companyLocation: "",
companyAddress: "",
- contactPersonName: "",
- contactPersonPhone: "",
- tinNumber: data.tinNumber,
+ tin: data.tinNumber,
vatNumber: "",
fanNumber: data.fanNumber,
- generalManagerName: "",
- generalManagerEmail: "",
- generalManagerPhone: "",
- notes: JSON.stringify({
+ attributes: {
truckType: data.truckType,
plateNumber: data.plateNumber,
plateNumber2: data.plateNumber2 || null,
vehicleModel: data.vehicleModel,
yearOfManufacturing: data.yearOfManufacturing,
- }),
+ },
};
}
export default function TransporterForm({
+ documentSettingCode,
+ documentFiles: controlledFiles,
+ onDocumentFilesChange,
user,
onSubmit,
isPending,
onBack,
}: {
+ documentSettingCode: string;
+ documentFiles?: Record;
+ onDocumentFilesChange?: (
+ files: Record,
+ ) => void;
user: AuthUser;
- onSubmit: (data: CreateCustomerDto) => void;
+ onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {
+ const [step, setStep] = useState("vehicle");
+ const [internalFiles, setInternalFiles] = useState<
+ Record
+ >({});
+ const documentFiles = controlledFiles ?? internalFiles;
+ const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
+
+ const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
+ api.fileUploadSettings.getByCode.queryOptions({
+ input: { code: documentSettingCode },
+ refetchOnMount: false,
+ }),
+ );
+
const {
register,
handleSubmit,
+ trigger,
watch,
control,
formState: { errors },
@@ -119,187 +143,341 @@ export default function TransporterForm({
const truckType = watch("truckType");
const isCasoni = truckType === "Casoni";
+ const formValues = watch();
+ const hasDocuments = Boolean(uploadSetting?.fields?.length);
+ const totalSteps = 3;
+
+ const nextStep = async () => {
+ if (step === "documents") {
+ setStep("confirm");
+ return;
+ }
+ if (step === "confirm") {
+ handleSubmit((data) => onSubmit(buildPayload(data, user)))();
+ return;
+ }
+ const fields: (keyof FormData)[] = [
+ "tinNumber",
+ "fanNumber",
+ "truckType",
+ "plateNumber",
+ "vehicleModel",
+ "yearOfManufacturing",
+ ];
+ const isValid = await trigger(fields);
+ if (!isValid) return;
+ setStep("documents");
+ };
+
+ const skipDocuments = () => {
+ setStep("confirm");
+ };
+
+ const prevStep = () => {
+ if (step === "vehicle") {
+ onBack();
+ } else if (step === "documents") {
+ setStep("vehicle");
+ } else {
+ setStep("documents");
+ }
+ };
return (
<>
Change account type
-
-
-
-
+
+
+
}
+ active={step === "vehicle"}
+ completed={step !== "vehicle"}
+ />
+
}
+ active={step === "documents"}
+ completed={step === "confirm"}
+ />
+
}
+ active={step === "confirm"}
+ completed={false}
+ />
- Transporter Registration
+ {step === "vehicle" && `Step 1 of ${totalSteps} — Vehicle Information`}
+ {step === "documents" && `Step 2 of ${totalSteps} — Upload Documents (Optional)`}
+ {step === "confirm" && `Step 3 of ${totalSteps} — Review & Confirm`}
onSubmit(buildPayload(data, user)))}
+ onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
- {/* Personal Info (read-only) */}
-
-
-
-
-
- TIN Number (10 digits)
-
-
-
-
-
- FAN Number (16 digits)
-
-
-
-
-
-
-
-
- Vehicle / Truck Information
-
-
-
(
-
- Truck Type
-
-
-
-
-
- {TRUCK_TYPES.map((type) => (
-
- {type}
-
- ))}
-
-
-
-
- )}
- />
-
-
-
-
- Plate Number{isCasoni ? " (Front)" : ""}
-
-
-
-
-
- {isCasoni && (
-
- Plate Number (Trailer)
-
-
-
- )}
-
- {!isCasoni && (
-
- Vehicle Model
-
-
-
- )}
-
-
-
- {isCasoni && (
-
- Vehicle Model
-
-
-
- )}
-
-
- Year of Manufacturing
-
-
-
-
+ )}
-
-
- Change Type
+
+
+ {step === "vehicle"
+ ? "Change Type"
+ : step === "confirm"
+ ? "Back to Documents"
+ : "Back"}
-
- {isPending ? (
- <>
-
- Submitting...
- >
- ) : (
- "Complete Registration"
+
+ {step === "documents" && (
+
+ Skip for now
+
)}
-
+
+
onSubmit(buildPayload(data, user))) : nextStep}
+ disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
+ >
+ {isPending ? (
+ <>
+
+ Submitting...
+ >
+ ) : step === "documents" ? (
+ "Continue"
+ ) : step === "confirm" ? (
+ "Submit Registration"
+ ) : (
+ <>
+ Next Step
+
+ >
+ )}
+
+
>
);
}
+
+function ReviewRow({ label, value }: { label: string; value?: string | null }) {
+ return (
+
+
+ {label}
+
+
+ {value?.trim() ? value : "Not provided"}
+
+
+ );
+}
+
+function StepIcon({
+ icon,
+ active,
+ completed,
+}: {
+ icon: React.ReactNode;
+ active: boolean;
+ completed: boolean;
+}) {
+ return (
+
+ {completed ? : icon}
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx
new file mode 100644
index 000000000..a8742c9b1
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx
@@ -0,0 +1,165 @@
+import { useCallback, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ ArrowLeft,
+ Download,
+ FileSignature,
+ Loader2,
+ Printer,
+} from "lucide-react";
+import toast from "react-hot-toast";
+
+import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
+import {
+ bookingsService,
+ type SignContractPayload,
+} from "@/services/bookings.service";
+import { Button } from "@edr/ui-common";
+
+export default function BookingContractPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const qc = useQueryClient();
+ const [signOpen, setSignOpen] = useState(false);
+ const [signerName, setSignerName] = useState("");
+ const [signatureData, setSignatureData] = useState(null);
+
+ const { data, isLoading, isError, refetch } = useQuery({
+ queryKey: ["booking-contract-view", id],
+ queryFn: () => bookingsService.getContractView(id!),
+ enabled: Boolean(id),
+ });
+
+ const signMutation = useMutation({
+ mutationFn: (payload: SignContractPayload) =>
+ bookingsService.signContract(id!, payload),
+ onSuccess: () => {
+ toast.success("Contract signed successfully");
+ setSignOpen(false);
+ void refetch();
+ qc.invalidateQueries({ queryKey: ["booking", id] });
+ },
+ onError: () => toast.error("Failed to sign contract"),
+ });
+
+ const downloadPdf = useCallback(async () => {
+ if (!id) return;
+ try {
+ const blob = await bookingsService.downloadContractDocument(id);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `contract-${data?.reference ?? id}.pdf`;
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch {
+ toast.error("PDF not ready yet. Contact EDR if this persists.");
+ }
+ }, [id, data?.reference]);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (isError || !data) {
+ return (
+
+
Could not load contract.
+
navigate(-1)}>
+ Go back
+
+
+ );
+ }
+
+ const bodyHtml = extractBodyHtml(data.html);
+
+ return (
+
+
+
+
navigate(`/bookings/${id}`)}>
+
+ Back to booking
+
+
+
window.print()}>
+
+ Print
+
+
+
+ PDF
+
+ {data.canSignCustomer && (
+
setSignOpen(true)}>
+
+ Sign contract
+
+ )}
+
+
+
+
+
+
+ {signOpen && (
+
+
+
Sign contract
+
+ {data.reference} — your signature will be stored securely.
+
+
+
+ Full name
+
+ setSignerName(e.target.value)}
+ />
+
+
+
+ setSignOpen(false)}>
+ Cancel
+
+
+ signMutation.mutate({
+ role: "CUSTOMER",
+ signatureImageBase64: signatureData!,
+ signerDisplayName: signerName.trim(),
+ consentText: "I agree to the terms of this contract.",
+ })
+ }
+ >
+ Confirm signature
+
+
+
+
+ )}
+
+ );
+}
+
+function extractBodyHtml(fullHtml: string): string {
+ const match = fullHtml.match(/]*>([\s\S]*)<\/body>/i);
+ return match ? match[1] : fullHtml;
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx
index 8b153515d..f8ea6c496 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx
@@ -1,4 +1,5 @@
import { useNavigate, useParams } from "react-router-dom";
+import { useQuery } from "@tanstack/react-query";
import {
Calendar,
MapPin,
@@ -22,10 +23,12 @@ import {
CreditCard,
FileSignature,
PackageCheck,
+ LoaderCircle,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
-import { getBookingById } from "./bookings.mock";
+import { api } from "@/services/api";
+import type { Freight } from "@edr/types";
import {
Card,
CardHeader,
@@ -37,45 +40,67 @@ import {
} from "@edr/ui-common";
import { cn } from "@/lib/utils";
-// Grouping the 15 granular statuses into 6 logical progress stages for the UI tracker
const PROGRESS_STAGES = [
- { label: "Request", icon: FileText, statuses: ["DRAFT", "RFQ_SUBMITTED"] },
- { label: "Quotation", icon: ClipboardCheck, statuses: ["QUOTATION_SENT", "QUOTATION_APPROVED", "QUOTATION_REJECTED"] },
- { label: "Approval", icon: ShieldCheck, statuses: ["PENDING_APPROVAL", "APPROVED"] },
- { label: "Execution", icon: FileSignature, statuses: ["SIGNED_CUSTOMER", "FULLY_EXECUTED", "PAID"] },
- { label: "In Transit", icon: Train, statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"] },
- { label: "Complete", icon: PackageCheck, statuses: ["COMPLETED"] },
+ { label: "Request", icon: FileText, statuses: ["DRAFT"] },
+ { label: "Approval", icon: ClipboardCheck, statuses: ["CONFIRMED"] },
+ { label: "In Transit", icon: Train, statuses: ["IN_TRANSIT"] },
+ { label: "Complete", icon: PackageCheck, statuses: ["DELIVERED"] },
];
const STATUS_MAP: Record = {
DRAFT: { title: "Drafting Request", description: "Booking is being prepared and has not been submitted.", color: "text-slate-500", stage: 0 },
- RFQ_SUBMITTED: { title: "RFQ Submitted", description: "Request for Quotation has been sent to the operations team.", color: "text-amber-600", stage: 0 },
- QUOTATION_SENT: { title: "Quotation Received", description: "EDR has sent a formal quotation for your review.", color: "text-sky-600", stage: 1 },
- QUOTATION_APPROVED: { title: "Quotation Approved", description: "You have accepted the quotation terms.", color: "text-emerald-600", stage: 1 },
- QUOTATION_REJECTED: { title: "Quotation Rejected", description: "The quotation was not accepted.", color: "text-red-600", stage: 1 },
- PENDING_APPROVAL: { title: "Internal Approval", description: "Booking is undergoing final administrative review.", color: "text-amber-600", stage: 2 },
- APPROVED: { title: "Booking Approved", description: "Request is fully approved and ready for execution.", color: "text-emerald-600", stage: 2 },
- SIGNED_CUSTOMER: { title: "Customer Signed", description: "Contract has been signed by the customer.", color: "text-sky-600", stage: 3 },
- FULLY_EXECUTED: { title: "Contract Executed", description: "All parties have signed. Operational setup in progress.", color: "text-indigo-600", stage: 3 },
- PAID: { title: "Payment Received", description: "Initial payments confirmed. Cargo ready for dispatch.", color: "text-emerald-600", stage: 3 },
- IN_TRANSIT: { title: "Cargo Moving", description: "Shipment is currently moving through the rail network.", color: "text-sky-600", stage: 4 },
- PENDING_CONSOLIDATION: { title: "Consolidation Node", description: "Cargo is waiting to be consolidated with other shipments.", color: "text-amber-500", stage: 4 },
- CONSOLIDATED: { title: "Load Consolidated", description: "Cargo has been successfully merged into a larger shipment.", color: "text-indigo-500", stage: 4 },
- COMPLETED: { title: "Service Complete", description: "Cargo delivered and service successfully terminated.", color: "text-emerald-600", stage: 5 },
+ CONFIRMED: { title: "Booking Confirmed", description: "Booking has been confirmed and approved.", color: "text-emerald-600", stage: 1 },
+ IN_TRANSIT: { title: "Cargo Moving", description: "Shipment is currently moving through the rail network.", color: "text-sky-600", stage: 2 },
+ DELIVERED: { title: "Service Complete", description: "Cargo delivered and service successfully terminated.", color: "text-emerald-600", stage: 3 },
CANCELLED: { title: "Cancelled", description: "This booking process has been terminated.", color: "text-red-600", stage: -1 },
};
export default function BookingDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
- const booking = id ? getBookingById(id) : undefined;
+
+ const { data: booking, isLoading, isError, error } = useQuery(
+ api.bookings.get.queryOptions({
+ input: { id: id! },
+ enabled: !!id,
+ }),
+ );
+
+ if (isLoading) {
+ return (
+
+
+
+
Loading booking details…
+
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+
+
+
+ Failed to load booking
+
+
+ {error instanceof Error ? error.message : "An unexpected error occurred."}
+
+
+
+ );
+ }
if (!booking) {
return (
Booking not found
@@ -85,16 +110,17 @@ export default function BookingDetailPage() {
);
}
- // Normalize status to upper case for mapping
- const normalizedStatus = (booking.status === "In Transit" ? "IN_TRANSIT" : booking.status === "Pending" ? "RFQ_SUBMITTED" : booking.status.toUpperCase()) as keyof typeof STATUS_MAP;
+ const normalizedStatus = booking.status as keyof typeof STATUS_MAP;
const statusConfig = STATUS_MAP[normalizedStatus] || STATUS_MAP.DRAFT;
const currentStageIndex = statusConfig.stage;
+ const containerCount = booking.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0;
+ const containerType = booking.containers?.[0]?.type ?? null;
+
return (
- {/* Breadcrumbs Restored */}
- {/* Compact Header Card */}
@@ -117,11 +142,9 @@ export default function BookingDetailPage() {
- {booking.customer}
-
- {booking.requestedDate}
+ {booking.scheduledDate ?? booking.createdAt}
@@ -129,7 +152,27 @@ export default function BookingDetailPage() {
- {/* Granular Status Lifecycle */}
+ {(booking.status === "CONFIRMED" || booking.status === "IN_TRANSIT") && (
+
+
+
+
Contract ready
+
+ Review the agreement and apply your digital signature.
+
+
+ navigate(`/bookings/${booking.id}/contract`)}
+ >
+
+ View & sign contract
+
+
+
+ )}
+
@@ -140,7 +183,6 @@ export default function BookingDetailPage() {
- {/* Progress Line */}
- {normalizedStatus !== "CANCELLED" && normalizedStatus !== "COMPLETED" && (
+ {normalizedStatus !== "CANCELLED" && normalizedStatus !== "DELIVERED" && (
Est. Waiting
@@ -200,7 +242,6 @@ export default function BookingDetailPage() {
- {/* Route & Core Service Card */}
@@ -232,14 +273,13 @@ export default function BookingDetailPage() {
- } label="Service" value="Rail & Forwarding" />
- } label="Return" value="With Return" />
- } label="Customs" value="Enabled" />
+ } label="Service" value={booking.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail Only"} />
+ } label="Return" value={booking.equipmentReturn === "WITH_RETURN" ? "With Return" : "Without Return"} />
+ } label="Trade" value={booking.tradeDirection === "IMPORT" ? "Import" : "Export"} />
- {/* Mile Services Card */}
@@ -252,18 +292,19 @@ export default function BookingDetailPage() {
First Mile
-
+
Last Mile
-
Not requested
+
+ {booking.lastMileEnabled && booking.lastMileDeliveryAddress ? booking.lastMileDeliveryAddress : "Not requested"}
+
- {/* Cargo Specifications Card */}
@@ -273,40 +314,44 @@ export default function BookingDetailPage() {
- } label="Category" value={booking.cargoType} />
- } label="Weight" value={`${booking.weightTons} Tons`} />
- } label="Shipping Line" value="MSC" />
+ } label="Freight Type" value={booking.freightType === "BULK" ? "Bulk" : "Break Bulk"} />
+ } label="Weight (VGM)" value={`${booking.cargoTotalWeightVgm} Tons`} />
+ } label="Currency" value={booking.paymentCurrency} />
-
-
-
-
Load Details
-
-
-
-
- Description
- Unit
- Value
-
-
-
-
- Main Equipment
- 20FT Container
- 4 Units
-
-
-
-
-
+ {booking.containers && booking.containers.length > 0 && (
+ <>
+
+
+
Load Details
+
+
+
+
+ Type
+ Quantity
+ VGM (Tons)
+
+
+
+ {booking.containers.map((c, i) => (
+
+ {c.type}
+ {c.qty} Units
+ {c.vgm}t
+
+ ))}
+
+
+
+
+ >
+ )}
- {/* Contract Card */}
@@ -315,40 +360,48 @@ export default function BookingDetailPage() {
-
-
+
+
- Hazardous: No
+ Hazardous: {booking.isHazardous ? "Yes" : "No"}
- Refrigerated: No
+ Refrigerated: {booking.isRefrigerated ? "Yes" : "No"}
- {/* Notes Card */}
Additional Info
-
-
Description
-
"{booking.cargoDescription}"
-
-
-
-
Instructions
-
-
-
- {booking.specialInstructions}
-
-
-
+ {booking.freightSubtype && (
+
+
Cargo Description
+
"{booking.freightSubtype}"
+
+ )}
+ {booking.financialTerms && (
+ <>
+
+
+
Financial Terms
+
+
+
+ {booking.financialTerms}
+
+
+
+ >
+ )}
+ {!booking.freightSubtype && !booking.financialTerms && (
+ No additional information provided.
+ )}
@@ -396,7 +449,7 @@ function InfoItem({
{icon &&
{icon}
}
{label}
-
{value || "—"}
+
{value ?? "—"}
);
@@ -405,20 +458,10 @@ function InfoItem({
function StatusBadge({ status }: { status: string }) {
const statusColors: Record
= {
DRAFT: "bg-slate-50 text-slate-700 border-slate-200",
- RFQ_SUBMITTED: "bg-amber-50 text-amber-700 border-amber-200",
- QUOTATION_SENT: "bg-sky-50 text-sky-700 border-sky-200",
- QUOTATION_APPROVED: "bg-emerald-50 text-emerald-700 border-emerald-200",
- QUOTATION_REJECTED: "bg-red-50 text-red-700 border-red-200",
- PENDING_APPROVAL: "bg-amber-50 text-amber-700 border-amber-200",
- APPROVED: "bg-emerald-50 text-emerald-700 border-emerald-200",
- SIGNED_CUSTOMER: "bg-sky-50 text-sky-700 border-sky-200",
- FULLY_EXECUTED: "bg-indigo-50 text-indigo-700 border-indigo-200",
- PAID: "bg-emerald-50 text-emerald-700 border-emerald-200",
+ CONFIRMED: "bg-emerald-50 text-emerald-700 border-emerald-200",
IN_TRANSIT: "bg-sky-50 text-sky-700 border-sky-200",
- COMPLETED: "bg-indigo-50 text-indigo-700 border-indigo-200",
+ DELIVERED: "bg-indigo-50 text-indigo-700 border-indigo-200",
CANCELLED: "bg-red-50 text-red-700 border-red-200",
- PENDING_CONSOLIDATION: "bg-amber-50 text-amber-700 border-amber-200",
- CONSOLIDATED: "bg-indigo-50 text-indigo-700 border-indigo-200",
};
return (
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx
index 9d8501df0..960d9c0f2 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx
@@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
+import { useQuery } from "@tanstack/react-query";
import {
ArrowRight,
Clock,
@@ -9,13 +10,11 @@ import {
Package,
Plus,
Search,
- Trash2,
Truck,
} from "lucide-react";
-import DeleteBookingDialog from "./DeleteBookingDialog";
-import { getMyBookings } from "@/lib/currentCustomer";
-import { deleteBooking, type Booking, type BookingStatus } from "./bookings.mock";
+import { api } from "@/services/api";
+import type { Freight } from "@edr/types";
import {
DataTable,
DataTableFooter,
@@ -32,32 +31,30 @@ import {
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
- DropdownMenuSeparator,
} from "@edr/ui-common";
export default function MyBookings() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [searchTerm, setSearchTerm] = useState("");
- const [myBookings, setMyBookings] = useState(() => getMyBookings());
- const handleDeleteConfirm = (id: number) => {
- deleteBooking(id);
- setMyBookings(getMyBookings());
- };
+ const { data, isLoading, isError } = useQuery(
+ api.bookings.list.queryOptions(),
+ );
+
+ const bookings = data?.items ?? [];
const filteredData = useMemo(() => {
- return myBookings.filter((b) => {
+ return bookings.filter((b) => {
const term = searchTerm.toLowerCase();
return (
b.reference.toLowerCase().includes(term) ||
b.originStation.toLowerCase().includes(term) ||
b.destinationStation.toLowerCase().includes(term) ||
- b.cargoDescription.toLowerCase().includes(term) ||
b.status.toLowerCase().includes(term)
);
});
- }, [myBookings, searchTerm]);
+ }, [bookings, searchTerm]);
const total = filteredData.length;
const pageCount = Math.ceil(total / pagination.pageSize);
@@ -67,16 +64,16 @@ export default function MyBookings() {
const paginatedData = useMemo(() => filteredData.slice(start, end), [filteredData, start, end]);
const activeCount = useMemo(() => {
- return myBookings.filter(
- (b) => b.status === "Confirmed" || b.status === "In Transit",
+ return bookings.filter(
+ (b) => b.status === "CONFIRMED" || b.status === "IN_TRANSIT",
).length;
- }, [myBookings]);
+ }, [bookings]);
const pendingCount = useMemo(() => {
- return myBookings.filter((b) => b.status === "Pending").length;
- }, [myBookings]);
+ return bookings.filter((b) => b.status === "DRAFT").length;
+ }, [bookings]);
- const columns: ColumnDef[] = [
+ const columns: ColumnDef[] = [
{
accessorKey: "reference",
header: "Reference",
@@ -89,7 +86,7 @@ export default function MyBookings() {
{booking.reference}
-
{booking.requestedDate}
+
{booking.scheduledDate ?? booking.createdAt}
);
@@ -111,22 +108,24 @@ export default function MyBookings() {
header: "Cargo",
cell: ({ row }) => {
const b = row.original;
+ const containerCount = b.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0;
+ const containerType = b.containers?.[0]?.type ?? null;
return (
-
{b.cargoType}
+
{b.freightType === "BULK" ? "Bulk" : "Break Bulk"}
- {b.containerCount > 0 ? `${b.containerCount} × ${b.containerType} · ` : ""}{b.weightTons}t
+ {containerType && containerCount > 0 ? `${containerCount} × ${containerType} · ` : ""}{b.cargoTotalWeightVgm}t
);
},
},
{
- accessorKey: "transportMode",
+ id: "transportMode",
header: "Transport",
cell: ({ row }) => (
- {row.original.transportMode}
+ {row.original.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail"}
),
},
@@ -158,19 +157,6 @@ export default function MyBookings() {
View
-
- handleDeleteConfirm(booking.id)}
- >
- e.preventDefault()}
- variant="destructive"
- >
-
- Delete
-
-
@@ -179,10 +165,11 @@ export default function MyBookings() {
},
];
+ const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
+
return (
- {/* Header Section Card */}
@@ -214,14 +201,13 @@ export default function MyBookings() {
- {/* Stat Cards */}
Total Bookings
- {myBookings.length}
+ {bookings.length}
@@ -259,7 +245,6 @@ export default function MyBookings() {
- {/* Data Table */}
@@ -276,7 +261,7 @@ export default function MyBookings() {
- {total === 0 ? (
+ {total === 0 && dataTableStatus === "success" ? (
No bookings found
@@ -288,8 +273,8 @@ export default function MyBookings() {
navigate(`/bookings/${(row as Booking).id}`)}
+ status={dataTableStatus}
+ onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
@@ -311,20 +296,20 @@ export default function MyBookings() {
);
}
-function StatusBadge({ status }: { status: BookingStatus }) {
- const styles: Record = {
- Pending: "bg-amber-100 text-amber-700",
- Confirmed: "bg-sky-100 text-sky-700",
- "In Transit": "bg-indigo-100 text-indigo-700",
- Delivered: "bg-emerald-100 text-emerald-700",
- Cancelled: "bg-red-100 text-red-700",
+function StatusBadge({ status }: { status: string }) {
+ const styles: Record = {
+ DRAFT: "bg-amber-100 text-amber-700",
+ CONFIRMED: "bg-sky-100 text-sky-700",
+ IN_TRANSIT: "bg-indigo-100 text-indigo-700",
+ DELIVERED: "bg-emerald-100 text-emerald-700",
+ CANCELLED: "bg-red-100 text-red-700",
};
return (
- {status}
+ {status.replace(/_/g, ' ')}
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
index 4a1c53fef..d69258e0d 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import { Button } from "@edr/ui-common";
import { api } from "@/services/api";
+import { Freight } from "@edr/types";
import type { CreateBookingPayload } from "@/services/bookings.service";
import {
BookingFormInputValues,
@@ -120,7 +121,6 @@ export default function NewBookingPage() {
const group = cargoTree.find(
(g) => g.code === "CONTAINER" || /container/i.test(g.name),
);
- console.log(group, cargoTree);
return group?.id ?? "";
};
@@ -132,14 +132,14 @@ export default function NewBookingPage() {
return "";
};
- const cargoTypeId = cargoTree[0].id;
- // data.cargoType === "container"
- // ? findContainerCargoTypeId()
- // : (findCargoTypeId(
- // data.freightType === "bulk"
- // ? data.bulkCommodity
- // : data.breakBulkType,
- // ) ?? "");
+ const cargoTypeId =
+ data.cargoType === "container"
+ ? findContainerCargoTypeId()
+ : (findCargoTypeId(
+ data.freightType === "bulk"
+ ? data.bulkCommodity
+ : data.breakBulkType,
+ ) ?? "");
const cargoFreeText =
data.cargoType === "container"
@@ -168,7 +168,11 @@ export default function NewBookingPage() {
: direction === "domestic"
? "DOMESTIC"
: "IMPORT",
- cargoTypeId,
+ freightType:
+ data.cargoType === "container"
+ ? Freight.FreightType.Container
+ : Freight.FreightType.Bulk,
+ cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
paymentCurrency: "USD",
@@ -181,7 +185,7 @@ export default function NewBookingPage() {
vgmPerUnitTons: Number(c.vgm || 0),
}))
: [],
- ...(customer ? { customerId: customer.id } : {}),
+ ...(customer?.company?.id ? { companyId: customer.company.id } : {}),
...(data.previousContractRef
? { previousContractId: data.previousContractRef }
: {}),
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
index 758cdede0..ee54196e8 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
@@ -130,7 +130,7 @@ export function Step5CargoDetails({
- Container
+ Containerized
Pre-packed containerized cargo (20ft / 40ft).
@@ -145,7 +145,7 @@ export function Step5CargoDetails({
- Bulk
+ General Cargo
Bulk commodities or break-bulk cargo.
diff --git a/apps/edr-freight-web/portal/src/pages/customers/customers.mock.ts b/apps/edr-freight-web/portal/src/pages/customers/customers.mock.ts
index 92ec6ff80..a1a9bf1c2 100644
--- a/apps/edr-freight-web/portal/src/pages/customers/customers.mock.ts
+++ b/apps/edr-freight-web/portal/src/pages/customers/customers.mock.ts
@@ -14,6 +14,7 @@ export interface Customer {
country: string;
address: string;
notes: string;
+ documentsComplete: boolean;
}
const seedCustomers: Customer[] = [
@@ -30,6 +31,7 @@ const seedCustomers: Customer[] = [
country: "Ethiopia",
address: "Bole Road, Sub-City 03, Building 17",
notes: "Top-tier importer. Prefers weekly invoicing.",
+ documentsComplete: false,
},
{
id: 2,
@@ -44,6 +46,7 @@ const seedCustomers: Customer[] = [
country: "Ethiopia",
address: "Industrial Park, Zone B, Warehouse 4",
notes: "Awaiting compliance documents.",
+ documentsComplete: false,
},
{
id: 3,
@@ -58,6 +61,7 @@ const seedCustomers: Customer[] = [
country: "Djibouti",
address: "Port Quarter, Avenue 26, Block 9",
notes: "Account paused since last quarter.",
+ documentsComplete: true,
},
];
@@ -99,6 +103,7 @@ const generated: Customer[] = extras.map((entry, i) => {
country: entry.country,
address: `Block ${i + 1}, Street ${10 + i}, Quarter ${(i % 5) + 1}`,
notes: `Mock customer #${id}.`,
+ documentsComplete: i % 3 === 0,
};
});
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts
index 4300cd56b..baca5cd34 100644
--- a/apps/edr-freight-web/portal/src/services/api.ts
+++ b/apps/edr-freight-web/portal/src/services/api.ts
@@ -15,6 +15,7 @@ import { fileUploadSettingsService } from "./fileUploadSettings.service";
import { dropdownSettingsService } from "./dropdownSettings.service";
import { authService } from "./auth.service";
import { customersService } from "./customers.service";
+import { companiesService } from "./companies.service";
import {
CreateDropdownOptionDto,
CreateDropdownSettingDto,
@@ -28,6 +29,11 @@ import {
Customer,
UpdateCustomerDto,
} from "@/types/customers";
+import type {
+ CompanyInfoResponse,
+ CreateCompanyPayload,
+} from "./companies.service";
+import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type {
AuthUser,
GenerateVerificationCodePayload,
@@ -84,37 +90,29 @@ export const api = {
logout: endpoint("auth", "logout", authService.logout),
},
- customers: {
- list: endpoint(
- "customers",
- "list",
- customersService.list,
+ companies: {
+ getInfo: endpoint(
+ "companies",
+ "getInfo",
+ companiesService.getInfo,
),
- get: endpoint<{ id: string }, Customer>("customers", "get", ({ id }) =>
- customersService.getById(id),
- ),
-
- create: endpoint(
- "customers",
+ create: endpoint(
+ "companies",
"create",
- customersService.create,
+ companiesService.create,
),
- update: endpoint<{ id: string; dto: UpdateCustomerDto }, Customer>(
- "customers",
- "update",
- ({ id, dto }) => customersService.update(id, dto),
+ getProfile: endpoint(
+ "companies",
+ "getProfile",
+ companiesService.getProfile,
),
- remove: endpoint<{ id: string }, void>("customers", "remove", ({ id }) =>
- customersService.remove(id),
- ),
-
- getByUserId: endpoint<{ id: string }, Customer | null>(
- "customers",
- "getByUserId",
- ({ id }) => customersService.getByUserId(id),
+ updateProfile: endpoint(
+ "companies",
+ "updateProfile",
+ companiesService.updateProfile,
),
},
@@ -190,6 +188,12 @@ export const api = {
({ code }) => fileUploadSettingsService.getByCode(code),
),
+ getByEntity: endpoint<{ entity: string }, FileUploadSetting[]>(
+ "file-upload-settings",
+ "getByEntity",
+ ({ entity }) => fileUploadSettingsService.getByEntity(entity),
+ ),
+
create: endpoint(
"file-upload-settings",
"create",
diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts
index dd4c9f0dd..2dc1ba235 100644
--- a/apps/edr-freight-web/portal/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts
@@ -1,27 +1,75 @@
import type { Freight, PaginatedResponse } from "@edr/types";
+import { URL_CONSTANTS } from "@/constants/URLS";
import { client } from "../utils/api";
+const B = URL_CONSTANTS.BOOKINGS;
+
export type CreateBookingPayload = Freight.CreateBookingDto;
+export interface ContractView {
+ bookingId: string;
+ reference: string;
+ status: string;
+ templateKey: string;
+ title: string;
+ html: string;
+ canSignCustomer: boolean;
+ canSignStaff: boolean;
+ hasContractDocument: boolean;
+ signatures: Array<{
+ role: string;
+ signerDisplayName: string;
+ signedAt: string;
+ signatureImageUrl?: string | null;
+ }>;
+}
+
+export interface SignContractPayload {
+ role: "CUSTOMER" | "STAFF";
+ signatureImageBase64: string;
+ signerDisplayName: string;
+ consentText?: string;
+}
+
export const bookingsService = {
list: async (): Promise> => {
- const { data } = await client.get("/bookings");
+ const { data } = await client.get("/api/bookings");
return data.data;
},
get: async (id: string): Promise => {
- const { data } = await client.get(`/bookings/${id}`);
+ const { data } = await client.get(`/api/bookings/${id}`);
return data.data;
},
create: async (payload: CreateBookingPayload): Promise => {
const { data } = await client.post("/api/bookings", payload);
- return data.data;
+ return data.data.booking;
},
getReferenceData: async (): Promise => {
const { data } = await client.get("/api/bookings/reference-data");
return data.data;
},
remove: async (id: string): Promise => {
- await client.delete(`/bookings/${id}`);
+ await client.delete(`/api/bookings/${id}`);
+ },
+
+ getContractView: async (id: string): Promise => {
+ const { data } = await client.get(B.CONTRACT_VIEW(id));
+ return data.data ?? data;
+ },
+
+ downloadContractDocument: async (id: string): Promise => {
+ const { data } = await client.get(B.CONTRACT_DOCUMENT(id), {
+ responseType: "blob",
+ });
+ return data;
+ },
+
+ signContract: async (
+ id: string,
+ payload: SignContractPayload,
+ ): Promise => {
+ const { data } = await client.post(B.CONTRACT_SIGN(id), payload);
+ return data.data ?? data;
},
};
diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts
new file mode 100644
index 000000000..359881a9b
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/services/companies.service.ts
@@ -0,0 +1,117 @@
+import { client } from "@/utils/api";
+import { unwrap } from "@/utils/endpoint";
+import { URL_CONSTANTS } from "@/constants/URLS";
+import type { ApiResponse } from "@/types/apiResponse";
+import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
+import { isAxiosError } from "axios";
+
+export interface ExternalProfileResponse {
+ id: string;
+ userId: string;
+ companyId: string;
+ firstName: string;
+ lastName: string;
+ email: string;
+ phone: string | null;
+ nationalId: string | null;
+ jobTitle: string | null;
+ isPrimaryContact: boolean;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface CompanyResponse {
+ id: string;
+ name: string;
+ type: string;
+ status: string;
+ tin: string;
+ vatNumber: string | null;
+ businessLicense: string | null;
+ fanNumber: string | null;
+ country: string;
+ address: string | null;
+ phone: string | null;
+ email: string | null;
+ website: string | null;
+ attributes: Record | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface CompanyInfoResponse {
+ profile: ExternalProfileResponse;
+ company: CompanyResponse;
+}
+
+export interface CreateCompanyPayload {
+ companyType?: string;
+ companyName: string;
+ companyEmail?: string;
+ companyPhone?: string;
+ companyLocation?: string;
+ companyAddress?: string;
+ tin?: string;
+ vatNumber?: string;
+ fanNumber?: string;
+ jobTitle?: string;
+ isPrimaryContact?: boolean;
+ attributes?: Record;
+}
+
+export const companiesService = {
+ getInfo: async (): Promise => {
+ try {
+ const response = await client.get>(
+ URL_CONSTANTS.COMPANIES_API.GET_INFO,
+ );
+ return unwrap(response.data);
+ } catch (e) {
+ if (isAxiosError(e) && e.response?.status === 404) {
+ return null;
+ }
+ throw e;
+ }
+ },
+
+ create: async (payload: CreateCompanyPayload): Promise => {
+ const response = await client.post>(
+ URL_CONSTANTS.COMPANIES_API.CREATE,
+ payload,
+ );
+ return unwrap(response.data);
+ },
+
+ getProfile: async (): Promise => {
+ const response = await client.get>(
+ URL_CONSTANTS.COMPANIES_API.PROFILE,
+ );
+ return unwrap(response.data);
+ },
+
+ updateProfile: async (payload: UpdateProfilePayload): Promise => {
+ const response = await client.patch>(
+ URL_CONSTANTS.COMPANIES_API.PROFILE,
+ payload,
+ );
+ return unwrap(response.data);
+ },
+
+ uploadDocuments: async (
+ companyId: string,
+ files: Record,
+ ): Promise => {
+ const formData = new FormData();
+ for (const [fieldName, fileOrFiles] of Object.entries(files)) {
+ if (!fileOrFiles) continue;
+ if (Array.isArray(fileOrFiles)) {
+ for (const f of fileOrFiles) {
+ formData.append(fieldName, f);
+ }
+ } else {
+ formData.append(fieldName, fileOrFiles);
+ }
+ }
+ await client.post(URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), formData);
+ },
+};
diff --git a/apps/edr-freight-web/portal/src/services/customers.service.ts b/apps/edr-freight-web/portal/src/services/customers.service.ts
deleted file mode 100644
index 3d809709d..000000000
--- a/apps/edr-freight-web/portal/src/services/customers.service.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { client } from "@/utils/api";
-import { unwrap } from "@/utils/endpoint";
-import { URL_CONSTANTS } from "@/constants/URLS";
-import type { ApiResponse } from "@/types/apiResponse";
-import type {
- CreateCustomerDto,
- Customer,
- UpdateCustomerDto,
-} from "@/types/customers";
-import { isAxiosError } from "axios";
-
-const BASE = URL_CONSTANTS.CUSTOMERS_API.BASE;
-
-export const customersService = {
- list: async (): Promise => {
- const response = await client.get>(BASE);
- return unwrap(response.data);
- },
-
- getById: async (id: string): Promise => {
- const response = await client.get>(
- URL_CONSTANTS.CUSTOMERS_API.BY_ID(id),
- );
- return unwrap(response.data);
- },
-
- getByUserId: async (userId: string): Promise => {
- try {
- const response = await client.get>(
- URL_CONSTANTS.CUSTOMERS_API.BY_USER_ID(userId),
- );
- return unwrap(response.data);
- } catch (e) {
- if (isAxiosError(e) && e.response?.status === 404) {
- return null;
- }
- throw e;
- }
- },
-
- create: async (payload: CreateCustomerDto): Promise => {
- const response = await client.post>(BASE, payload);
- return unwrap(response.data);
- },
-
- update: async (id: string, payload: UpdateCustomerDto): Promise => {
- const response = await client.patch>(
- URL_CONSTANTS.CUSTOMERS_API.BY_ID(id),
- payload,
- );
- return unwrap(response.data);
- },
-
- remove: async (id: string): Promise => {
- await client.delete(URL_CONSTANTS.CUSTOMERS_API.BY_ID(id));
- },
-};
diff --git a/apps/edr-freight-web/portal/src/services/fileUploadSettings.service.ts b/apps/edr-freight-web/portal/src/services/fileUploadSettings.service.ts
index d4632980e..7d855c5ff 100644
--- a/apps/edr-freight-web/portal/src/services/fileUploadSettings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/fileUploadSettings.service.ts
@@ -43,6 +43,14 @@ export const fileUploadSettingsService = {
return unwrap(response.data);
},
+ // GET /file-upload-settings/by-entity/:entity
+ getByEntity: async (entity: string): Promise => {
+ const response = await client.get>(
+ `${BASE}/by-entity/${encodeURIComponent(entity)}`,
+ );
+ return unwrap(response.data);
+ },
+
// GET /file-upload-settings/by-code/:code
getByCode: async (code: string): Promise => {
const response = await client.get>(
diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts
new file mode 100644
index 000000000..cab34ee23
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/types/profile.ts
@@ -0,0 +1,43 @@
+export interface ProfileResponse {
+ companyId: string;
+ companyName: string;
+ companyEmail: string | null;
+ companyPhone: string | null;
+ companyLocation: string;
+ companyAddress: string | null;
+ tinNumber: string;
+ vatNumber: string | null;
+ fanNumber: string | null;
+ contactPersonName: string | null;
+ contactPersonPhone: string | null;
+ generalManagerName: string | null;
+ generalManagerEmail: string | null;
+ generalManagerPhone: string | null;
+ poaName: string | null;
+ poaPhone: string | null;
+ poaEmail: string | null;
+ poaLocation: string | null;
+ poaAddress: string | null;
+ profileId: string;
+}
+
+export interface UpdateProfilePayload {
+ companyName?: string;
+ companyEmail?: string;
+ companyPhone?: string;
+ companyLocation?: string;
+ companyAddress?: string;
+ tin?: string;
+ vatNumber?: string;
+ fanNumber?: string;
+ contactPersonName?: string;
+ contactPersonPhone?: string;
+ generalManagerName?: string;
+ generalManagerEmail?: string;
+ generalManagerPhone?: string;
+ poaName?: string;
+ poaPhone?: string;
+ poaEmail?: string;
+ poaLocation?: string;
+ poaAddress?: string;
+}
diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts
index 2dce17419..c6a627de2 100644
--- a/packages/types/src/freight/index.ts
+++ b/packages/types/src/freight/index.ts
@@ -27,6 +27,11 @@ export enum CalculationMethod {
PERCENTAGE = 'PERCENTAGE',
}
+export enum FreightType {
+ Container = 'CONTAINER',
+ Bulk = 'BULK',
+}
+
export enum BookingStatus {
Draft = "DRAFT",
Confirmed = "CONFIRMED",
@@ -176,7 +181,7 @@ export interface IBooking extends BaseEntity {
destinationStation: string;
cargoTotalWeightVgm: number;
- freightType: "BULK" | "BREAK_BULK";
+ freightType: FreightType;
freightSubtype?: string | null;
isHazardous: boolean;
@@ -283,6 +288,7 @@ export interface CreateBookingContainerDto {
export interface CreateBookingDto {
reference?: string;
customerId?: string;
+ companyId?: string;
trainId?: string;
scheduledDate: string;
contractType: "NEW" | "RENEWAL";
@@ -294,7 +300,8 @@ export interface CreateBookingDto {
originYardId: string;
destinationYardId: string;
tradeDirection: "IMPORT" | "EXPORT" | "DOMESTIC";
- cargoTypeId: string;
+ freightType: FreightType;
+ cargoTypeId?: string;
cargoFreeText?: string;
shippingLineId?: string;
cargoTotalWeightVgm: number;
@@ -304,6 +311,6 @@ export interface CreateBookingDto {
startDate?: string;
endDate?: string;
financialTerms?: string;
- containers: CreateBookingContainerDto[];
+ containers?: CreateBookingContainerDto[];
allowConsolidation?: boolean;
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6cd75b663..8d486a27d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -48,6 +48,9 @@ importers:
'@edr/types':
specifier: workspace:*
version: link:../../packages/types
+ '@nestjs/axios':
+ specifier: ^4.0.1
+ version: 4.0.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.1)(rxjs@7.8.2)
'@nestjs/common':
specifier: ^11.0.0
version: 11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -85,7 +88,7 @@ importers:
specifier: ^2.0.1
version: 2.0.1
axios:
- specifier: ^1.7.7
+ specifier: ^1.16.1
version: 1.16.1
class-transformer:
specifier: ^0.5.1
@@ -96,12 +99,18 @@ importers:
dotenv:
specifier: ^17.4.2
version: 17.4.2
+ handlebars:
+ specifier: ^4.7.9
+ version: 4.7.9
minio:
specifier: 7.1.3
version: 7.1.3
pg:
specifier: ^8.13.0
version: 8.21.0
+ puppeteer:
+ specifier: ^24.2.0
+ version: 24.43.1(typescript@5.9.3)
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@@ -2199,6 +2208,11 @@ packages:
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
+ '@puppeteer/browsers@2.13.2':
+ resolution: {integrity: sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -3629,6 +3643,9 @@ packages:
'@tokenizer/token@0.3.0':
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
+ '@tootallnate/quickjs-emscripten@0.23.0':
+ resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
+
'@tria-plc/api-common@0.1.4':
resolution: {integrity: sha512-lm9esp5PDxUyggqxYXbx2CQhZKwtcAaAcGFuuUURw3u5DlzGUNwMOFkQkDLM/fbgN+33+s1RKC7UynnG9NZFww==, tarball: https://npm.pkg.github.com/download/@tria-plc/api-common/0.1.4/de160e4bb61a882efd9c877b4614028f1ffd7cb8}
peerDependencies:
@@ -3910,6 +3927,9 @@ packages:
'@types/yargs@17.0.35':
resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==}
+ '@types/yauzl@2.10.3':
+ resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
+
'@typescript-eslint/eslint-plugin@8.59.4':
resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4665,6 +4685,10 @@ packages:
resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==}
engines: {node: '>=0.10.0'}
+ ast-types@0.13.4:
+ resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
+ engines: {node: '>=4'}
+
ast-types@0.16.1:
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
engines: {node: '>=4'}
@@ -4705,6 +4729,14 @@ packages:
axios@1.16.1:
resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==}
+ b4a@1.8.1:
+ resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==}
+ peerDependencies:
+ react-native-b4a: '*'
+ peerDependenciesMeta:
+ react-native-b4a:
+ optional: true
+
babel-jest@29.7.0:
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -4741,6 +4773,47 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
+ bare-events@2.9.1:
+ resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==}
+ peerDependencies:
+ bare-abort-controller: '*'
+ peerDependenciesMeta:
+ bare-abort-controller:
+ optional: true
+
+ bare-fs@4.7.2:
+ resolution: {integrity: sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==}
+ engines: {bare: '>=1.16.0'}
+ peerDependencies:
+ bare-buffer: '*'
+ peerDependenciesMeta:
+ bare-buffer:
+ optional: true
+
+ bare-os@3.9.1:
+ resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==}
+ engines: {bare: '>=1.14.0'}
+
+ bare-path@3.0.1:
+ resolution: {integrity: sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==}
+
+ bare-stream@2.13.1:
+ resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==}
+ peerDependencies:
+ bare-abort-controller: '*'
+ bare-buffer: '*'
+ bare-events: '*'
+ peerDependenciesMeta:
+ bare-abort-controller:
+ optional: true
+ bare-buffer:
+ optional: true
+ bare-events:
+ optional: true
+
+ bare-url@2.4.4:
+ resolution: {integrity: sha512-zbQJi2YQUe3SrX19TItQ8DoPj9E1i5rrdE9iHV4PhUif1GodNRSe85lavVGbmU7P4M8579EQi4akGFuhCATWaQ==}
+
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
@@ -4761,6 +4834,10 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ basic-ftp@5.3.1:
+ resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==}
+ engines: {node: '>=10.0.0'}
+
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
@@ -4964,6 +5041,11 @@ packages:
resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==}
engines: {node: '>=6.0'}
+ chromium-bidi@14.0.0:
+ resolution: {integrity: sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==}
+ peerDependencies:
+ devtools-protocol: '*'
+
ci-info@3.9.0:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
engines: {node: '>=8'}
@@ -5324,6 +5406,10 @@ packages:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
+ data-uri-to-buffer@6.0.2:
+ resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
+ engines: {node: '>= 14'}
+
data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@@ -5465,6 +5551,10 @@ packages:
resolution: {integrity: sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==}
engines: {node: '>=0.10.0'}
+ degenerator@5.0.1:
+ resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==}
+ engines: {node: '>= 14'}
+
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@@ -5494,6 +5584,9 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+ devtools-protocol@0.0.1608973:
+ resolution: {integrity: sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==}
+
dezalgo@1.0.4:
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
@@ -5727,6 +5820,11 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
+ escodegen@2.1.0:
+ resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==}
+ engines: {node: '>=6.0'}
+ hasBin: true
+
eslint-config-prettier@9.1.2:
resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==}
hasBin: true
@@ -5859,6 +5957,9 @@ packages:
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+ events-universal@1.0.1:
+ resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
+
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
@@ -5925,6 +6026,11 @@ packages:
resolution: {integrity: sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==}
engines: {node: '>=0.10.0'}
+ extract-zip@2.0.1:
+ resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
+ engines: {node: '>= 10.17.0'}
+ hasBin: true
+
falsey@0.3.2:
resolution: {integrity: sha512-lxEuefF5MBIVDmE6XeqCdM4BWk1+vYmGZtkbKZ/VFcg6uBBw6fXNEbWmxCjDdQlFc9hy450nkiWwM3VAW6G1qg==}
engines: {node: '>=0.10.0'}
@@ -5940,6 +6046,9 @@ packages:
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
engines: {node: '>=6.0.0'}
+ fast-fifo@1.3.2:
+ resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
+
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@@ -5978,6 +6087,9 @@ packages:
fb-watchman@2.0.2:
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
+ fd-slicer@1.1.0:
+ resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
+
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -6229,6 +6341,10 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
+ get-stream@5.2.0:
+ resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
+ engines: {node: '>=8'}
+
get-stream@6.0.1:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'}
@@ -6245,6 +6361,10 @@ packages:
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
engines: {node: '>= 0.4'}
+ get-uri@6.0.5:
+ resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==}
+ engines: {node: '>= 14'}
+
get-value@2.0.6:
resolution: {integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==}
engines: {node: '>=0.10.0'}
@@ -7465,6 +7585,10 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+ lru-cache@7.18.3:
+ resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
+ engines: {node: '>=12'}
+
lucide-react@0.513.0:
resolution: {integrity: sha512-CJZKq2g8Y8yN4Aq002GahSXbG2JpFv9kXwyiOAMvUBv7pxeOFHUWKB0mO7MiY4ZVFCV4aNjv2BJFq/z3DgKPQg==}
peerDependencies:
@@ -7641,6 +7765,9 @@ packages:
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
engines: {node: '>= 8'}
+ mitt@3.0.1:
+ resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
+
mixin-deep@1.3.2:
resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==}
engines: {node: '>=0.10.0'}
@@ -7738,6 +7865,10 @@ packages:
'@nestjs/common': '>=9.0.0'
'@nestjs/core': '>=9.0.0'
+ netmask@2.1.1:
+ resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==}
+ engines: {node: '>= 0.4.0'}
+
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
@@ -7959,6 +8090,14 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
+ pac-proxy-agent@7.2.0:
+ resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==}
+ engines: {node: '>= 14'}
+
+ pac-resolver@7.0.1:
+ resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==}
+ engines: {node: '>= 14'}
+
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@@ -8077,6 +8216,9 @@ packages:
resolution: {integrity: sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==}
engines: {node: '>=14.16'}
+ pend@1.2.0:
+ resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
+
perfect-freehand@1.2.3:
resolution: {integrity: sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==}
@@ -8267,6 +8409,10 @@ packages:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
+ progress@2.0.3:
+ resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
+ engines: {node: '>=0.4.0'}
+
promise-breaker@6.0.0:
resolution: {integrity: sha512-BthzO9yTPswGf7etOBiHCVuugs2N01/Q/94dIPls48z2zCmrnDptUUZzfIb+41xq0MnYZ/BzmOd6ikDR4ibNZA==}
@@ -8317,9 +8463,16 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
+ proxy-agent@6.5.0:
+ resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==}
+ engines: {node: '>= 14'}
+
proxy-compare@3.0.1:
resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==}
+ proxy-from-env@1.1.0:
+ resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
@@ -8327,6 +8480,9 @@ packages:
proxy-memoize@3.0.1:
resolution: {integrity: sha512-VDdG/VYtOgdGkWJx7y0o7p+zArSf2383Isci8C+BP3YXgMYDoPd3cCBjw0JdWb6YBb9sFiOPbAADDVTPJnh+9g==}
+ pump@3.0.4:
+ resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
+
punycode@1.4.1:
resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==}
@@ -8334,6 +8490,15 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ puppeteer-core@24.43.1:
+ resolution: {integrity: sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==}
+ engines: {node: '>=18'}
+
+ puppeteer@24.43.1:
+ resolution: {integrity: sha512-/FSOViCrqRdb1HDocpsM9Z1giA71gTQPUt3SpHGVRALKAy/rJr1fLFYZW9F23qPxqVxTHQnbh/5B5opJST3kAw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
@@ -8984,6 +9149,10 @@ packages:
resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==}
engines: {node: '>=18'}
+ smart-buffer@4.2.0:
+ resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
+ engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
+
smob@1.6.2:
resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==}
engines: {node: '>=20.0.0'}
@@ -9008,6 +9177,14 @@ packages:
resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==}
engines: {node: '>=10.0.0'}
+ socks-proxy-agent@8.0.5:
+ resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==}
+ engines: {node: '>= 14'}
+
+ socks@2.8.9:
+ resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
+ engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
+
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
@@ -9105,6 +9282,9 @@ packages:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
+ streamx@2.26.0:
+ resolution: {integrity: sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A==}
+
strict-event-emitter@0.5.1:
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
@@ -9291,15 +9471,24 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
+ tar-fs@3.1.2:
+ resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==}
+
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
+ tar-stream@3.2.0:
+ resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==}
+
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+ teex@1.0.1:
+ resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
+
terser-webpack-plugin@5.6.0:
resolution: {integrity: sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==}
engines: {node: '>= 10.13.0'}
@@ -9358,6 +9547,9 @@ packages:
resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==}
engines: {node: '>=8'}
+ text-decoder@1.2.7:
+ resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
+
text-extensions@2.4.0:
resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==}
engines: {node: '>=8'}
@@ -9636,6 +9828,9 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
+ typed-query-selector@2.12.2:
+ resolution: {integrity: sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==}
+
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
@@ -9995,6 +10190,9 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
+ webdriver-bidi-protocol@0.4.1:
+ resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==}
+
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -10204,6 +10402,9 @@ packages:
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
+ yauzl@2.10.0:
+ resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
+
year@0.2.1:
resolution: {integrity: sha512-9GnJUZ0QM4OgXuOzsKNzTJ5EOkums1Xc+3YQXp+Q+UxFjf7zLucp9dQ8QMIft0Szs1E1hUiXFim1OYfEKFq97w==}
engines: {node: '>=0.8'}
@@ -11972,6 +12173,21 @@ snapshots:
'@popperjs/core@2.11.8': {}
+ '@puppeteer/browsers@2.13.2':
+ dependencies:
+ debug: 4.4.3
+ extract-zip: 2.0.1
+ progress: 2.0.3
+ proxy-agent: 6.5.0
+ semver: 7.8.1
+ tar-fs: 3.1.2
+ yargs: 17.7.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - react-native-b4a
+ - supports-color
+
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@@ -13483,6 +13699,8 @@ snapshots:
'@tokenizer/token@0.3.0': {}
+ '@tootallnate/quickjs-emscripten@0.23.0': {}
+
'@tria-plc/api-common@0.1.4(kw56ayyn7pbkaoqn2kt3fjycd4)':
dependencies:
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.1)(rxjs@7.8.2)
@@ -13932,6 +14150,11 @@ snapshots:
dependencies:
'@types/yargs-parser': 21.0.3
+ '@types/yauzl@2.10.3':
+ dependencies:
+ '@types/node': 20.19.41
+ optional: true
+
'@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -15149,6 +15372,10 @@ snapshots:
assign-symbols@1.0.0: {}
+ ast-types@0.13.4:
+ dependencies:
+ tslib: 2.8.1
+
ast-types@0.16.1:
dependencies:
tslib: 2.8.1
@@ -15190,6 +15417,8 @@ snapshots:
- debug
- supports-color
+ b4a@1.8.1: {}
+
babel-jest@29.7.0(@babel/core@7.29.0):
dependencies:
'@babel/core': 7.29.0
@@ -15255,6 +15484,38 @@ snapshots:
balanced-match@4.0.4: {}
+ bare-events@2.9.1: {}
+
+ bare-fs@4.7.2:
+ dependencies:
+ bare-events: 2.9.1
+ bare-path: 3.0.1
+ bare-stream: 2.13.1(bare-events@2.9.1)
+ bare-url: 2.4.4
+ fast-fifo: 1.3.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+
+ bare-os@3.9.1: {}
+
+ bare-path@3.0.1:
+ dependencies:
+ bare-os: 3.9.1
+
+ bare-stream@2.13.1(bare-events@2.9.1):
+ dependencies:
+ streamx: 2.26.0
+ teex: 1.0.1
+ optionalDependencies:
+ bare-events: 2.9.1
+ transitivePeerDependencies:
+ - react-native-b4a
+
+ bare-url@2.4.4:
+ dependencies:
+ bare-path: 3.0.1
+
base64-arraybuffer@1.0.2: {}
base64-js@0.0.8: {}
@@ -15273,6 +15534,8 @@ snapshots:
baseline-browser-mapping@2.10.32: {}
+ basic-ftp@5.3.1: {}
+
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
@@ -15520,6 +15783,12 @@ snapshots:
chrome-trace-event@1.0.4: {}
+ chromium-bidi@14.0.0(devtools-protocol@0.0.1608973):
+ dependencies:
+ devtools-protocol: 0.0.1608973
+ mitt: 3.0.1
+ zod: 3.25.76
+
ci-info@3.9.0: {}
cjs-module-lexer@1.4.3: {}
@@ -15868,6 +16137,8 @@ snapshots:
data-uri-to-buffer@4.0.1: {}
+ data-uri-to-buffer@6.0.2: {}
+
data-urls@5.0.0:
dependencies:
whatwg-mimetype: 4.0.0
@@ -15986,6 +16257,12 @@ snapshots:
is-descriptor: 1.0.4
isobject: 3.0.1
+ degenerator@5.0.1:
+ dependencies:
+ ast-types: 0.13.4
+ escodegen: 2.1.0
+ esprima: 4.0.1
+
delayed-stream@1.0.0: {}
delegates@1.0.0:
@@ -16003,6 +16280,8 @@ snapshots:
detect-node-es@1.1.0: {}
+ devtools-protocol@0.0.1608973: {}
+
dezalgo@1.0.4:
dependencies:
asap: 2.0.6
@@ -16311,6 +16590,14 @@ snapshots:
escape-string-regexp@4.0.0: {}
+ escodegen@2.1.0:
+ dependencies:
+ esprima: 4.0.1
+ estraverse: 5.3.0
+ esutils: 2.0.3
+ optionalDependencies:
+ source-map: 0.6.1
+
eslint-config-prettier@9.1.2(eslint@8.57.1):
dependencies:
eslint: 8.57.1
@@ -16487,6 +16774,12 @@ snapshots:
eventemitter3@5.0.4: {}
+ events-universal@1.0.1:
+ dependencies:
+ bare-events: 2.9.1
+ transitivePeerDependencies:
+ - bare-abort-controller
+
events@3.3.0: {}
eventsource-parser@3.0.8: {}
@@ -16630,6 +16923,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ extract-zip@2.0.1:
+ dependencies:
+ debug: 4.4.3
+ get-stream: 5.2.0
+ yauzl: 2.10.0
+ optionalDependencies:
+ '@types/yauzl': 2.10.3
+ transitivePeerDependencies:
+ - supports-color
+
falsey@0.3.2:
dependencies:
kind-of: 5.1.0
@@ -16643,6 +16946,8 @@ snapshots:
fast-equals@5.4.0: {}
+ fast-fifo@1.3.2: {}
+
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -16687,6 +16992,10 @@ snapshots:
dependencies:
bser: 2.1.1
+ fd-slicer@1.1.0:
+ dependencies:
+ pend: 1.2.0
+
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -16963,6 +17272,10 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
+ get-stream@5.2.0:
+ dependencies:
+ pump: 3.0.4
+
get-stream@6.0.1: {}
get-stream@8.0.1: {}
@@ -16978,6 +17291,14 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
+ get-uri@6.0.5:
+ dependencies:
+ basic-ftp: 5.3.1
+ data-uri-to-buffer: 6.0.2
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
get-value@2.0.6: {}
git-raw-commits@4.0.0:
@@ -18371,6 +18692,8 @@ snapshots:
dependencies:
yallist: 3.1.1
+ lru-cache@7.18.3: {}
+
lucide-react@0.513.0(react@19.2.6):
dependencies:
react: 19.2.6
@@ -18537,6 +18860,8 @@ snapshots:
yallist: 4.0.0
optional: true
+ mitt@3.0.1: {}
+
mixin-deep@1.3.2:
dependencies:
for-in: 1.0.2
@@ -18651,6 +18976,8 @@ snapshots:
reflect-metadata: 0.1.14
rxjs: 7.8.2
+ netmask@2.1.1: {}
+
next-themes@0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
react: 19.2.6
@@ -18899,6 +19226,24 @@ snapshots:
p-try@2.2.0: {}
+ pac-proxy-agent@7.2.0:
+ dependencies:
+ '@tootallnate/quickjs-emscripten': 0.23.0
+ agent-base: 7.1.4
+ debug: 4.4.3
+ get-uri: 6.0.5
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ pac-resolver: 7.0.1
+ socks-proxy-agent: 8.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ pac-resolver@7.0.1:
+ dependencies:
+ degenerator: 5.0.1
+ netmask: 2.1.1
+
package-json-from-dist@1.0.1: {}
pako@0.2.9: {}
@@ -18997,6 +19342,8 @@ snapshots:
peek-readable@5.4.2: {}
+ pend@1.2.0: {}
+
perfect-freehand@1.2.3: {}
performance-now@2.1.0:
@@ -19142,6 +19489,8 @@ snapshots:
process@0.11.10: {}
+ progress@2.0.3: {}
+
promise-breaker@6.0.0: {}
prompts@2.4.2:
@@ -19229,18 +19578,72 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
+ proxy-agent@6.5.0:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ lru-cache: 7.18.3
+ pac-proxy-agent: 7.2.0
+ proxy-from-env: 1.1.0
+ socks-proxy-agent: 8.0.5
+ transitivePeerDependencies:
+ - supports-color
+
proxy-compare@3.0.1: {}
+ proxy-from-env@1.1.0: {}
+
proxy-from-env@2.1.0: {}
proxy-memoize@3.0.1:
dependencies:
proxy-compare: 3.0.1
+ pump@3.0.4:
+ dependencies:
+ end-of-stream: 1.4.5
+ once: 1.4.0
+
punycode@1.4.1: {}
punycode@2.3.1: {}
+ puppeteer-core@24.43.1:
+ dependencies:
+ '@puppeteer/browsers': 2.13.2
+ chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
+ debug: 4.4.3
+ devtools-protocol: 0.0.1608973
+ typed-query-selector: 2.12.2
+ webdriver-bidi-protocol: 0.4.1
+ ws: 8.20.1
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - bufferutil
+ - react-native-b4a
+ - supports-color
+ - utf-8-validate
+
+ puppeteer@24.43.1(typescript@5.9.3):
+ dependencies:
+ '@puppeteer/browsers': 2.13.2
+ chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
+ cosmiconfig: 9.0.1(typescript@5.9.3)
+ devtools-protocol: 0.0.1608973
+ puppeteer-core: 24.43.1
+ typed-query-selector: 2.12.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - bufferutil
+ - react-native-b4a
+ - supports-color
+ - typescript
+ - utf-8-validate
+
pure-rand@6.1.0: {}
qrcode@1.5.4:
@@ -20074,6 +20477,8 @@ snapshots:
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.1.0
+ smart-buffer@4.2.0: {}
+
smob@1.6.2: {}
snapdragon-node@2.1.1:
@@ -20117,6 +20522,19 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ socks-proxy-agent@8.0.5:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ socks: 2.8.9
+ transitivePeerDependencies:
+ - supports-color
+
+ socks@2.8.9:
+ dependencies:
+ ip-address: 10.2.0
+ smart-buffer: 4.2.0
+
sonner@2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
react: 19.2.6
@@ -20195,6 +20613,15 @@ snapshots:
streamsearch@1.1.0: {}
+ streamx@2.26.0:
+ dependencies:
+ events-universal: 1.0.1
+ fast-fifo: 1.3.2
+ text-decoder: 1.2.7
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+
strict-event-emitter@0.5.1: {}
strict-uri-encode@2.0.0: {}
@@ -20422,6 +20849,18 @@ snapshots:
tapable@2.3.3: {}
+ tar-fs@3.1.2:
+ dependencies:
+ pump: 3.0.4
+ tar-stream: 3.2.0
+ optionalDependencies:
+ bare-fs: 4.7.2
+ bare-path: 3.0.1
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - react-native-b4a
+
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
@@ -20430,6 +20869,17 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
+ tar-stream@3.2.0:
+ dependencies:
+ b4a: 1.8.1
+ bare-fs: 4.7.2
+ fast-fifo: 1.3.2
+ streamx: 2.26.0
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - react-native-b4a
+
tar@6.2.1:
dependencies:
chownr: 2.0.0
@@ -20440,6 +20890,13 @@ snapshots:
yallist: 4.0.0
optional: true
+ teex@1.0.1:
+ dependencies:
+ streamx: 2.26.0
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+
terser-webpack-plugin@5.6.0(webpack@5.106.0):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@@ -20477,6 +20934,12 @@ snapshots:
glob: 7.2.3
minimatch: 3.1.5
+ text-decoder@1.2.7:
+ dependencies:
+ b4a: 1.8.1
+ transitivePeerDependencies:
+ - react-native-b4a
+
text-extensions@2.4.0: {}
text-segmentation@1.0.3:
@@ -20766,6 +21229,8 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
+ typed-query-selector@2.12.2: {}
+
typedarray@0.0.6: {}
typeof-article@0.1.1:
@@ -21100,6 +21565,8 @@ snapshots:
web-streams-polyfill@3.3.3: {}
+ webdriver-bidi-protocol@0.4.1: {}
+
webidl-conversions@3.0.1: {}
webidl-conversions@7.0.0: {}
@@ -21354,6 +21821,11 @@ snapshots:
y18n: 5.0.8
yargs-parser: 22.0.0
+ yauzl@2.10.0:
+ dependencies:
+ buffer-crc32: 0.2.13
+ fd-slicer: 1.1.0
+
year@0.2.1: {}
yn@3.1.1: {}