From 243803fc2942892a848dcceeb2ae794e2f011171 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 2 Jun 2026 10:09:05 +0300 Subject: [PATCH 01/20] feat(bookings): Integrate booking list and detail pages with API --- .../src/pages/bookings/BookingDetailPage.tsx | 222 ++++++++++-------- .../portal/src/pages/bookings/MyBookings.tsx | 91 +++---- .../portal/src/services/bookings.service.ts | 8 +- 3 files changed, 164 insertions(+), 157 deletions(-) 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..6a4f13227 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,6 @@ export default function BookingDetailPage() { - {/* Granular Status Lifecycle */} @@ -140,7 +162,6 @@ export default function BookingDetailPage() {
- {/* Progress Line */}
- {normalizedStatus !== "CANCELLED" && normalizedStatus !== "COMPLETED" && ( + {normalizedStatus !== "CANCELLED" && normalizedStatus !== "DELIVERED" && (

Est. Waiting

@@ -200,7 +221,6 @@ export default function BookingDetailPage() {
- {/* Route & Core Service Card */} @@ -232,14 +252,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 +271,19 @@ export default function BookingDetailPage() {

First Mile

- +

Last Mile

-

Not requested

+

+ {booking.lastMileEnabled && booking.lastMileDeliveryAddress ? booking.lastMileDeliveryAddress : "Not requested"} +

- {/* Cargo Specifications Card */} @@ -273,40 +293,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

-
- - - - - - - - - - - - - - - -
DescriptionUnitValue
Main Equipment20FT Container4 Units
-
-
+ {booking.containers && booking.containers.length > 0 && ( + <> + +
+

Load Details

+
+ + + + + + + + + + {booking.containers.map((c, i) => ( + + + + + + ))} + +
TypeQuantityVGM (Tons)
{c.type}{c.qty} Units{c.vgm}t
+
+
+ + )}
- {/* Contract Card */} @@ -315,40 +339,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 +428,7 @@ function InfoItem({ {icon &&
{icon}
}

{label}

-

{value || "—"}

+

{value ?? "—"}

); @@ -405,20 +437,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/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index dd4c9f0dd..6ac93a742 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -6,22 +6,22 @@ export type CreateBookingPayload = Freight.CreateBookingDto; 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}`); }, }; From 169e49faae1a461fd06582c92c706c9508ca7b40 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 2 Jun 2026 14:04:23 +0300 Subject: [PATCH 02/20] fixes --- apps/edr-freight-web/portal/src/App.tsx | 5 ++--- .../pages/bookings/new-booking-form/step5-cargo-details.tsx | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index d16a70175..1187422f8 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -32,8 +32,6 @@ 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: }, @@ -46,12 +44,13 @@ const sidebarItems: SidebarItem[] = [ 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; 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.

From 34bf4ade48c8d39bc739c6c95578338fac0c444d Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 2 Jun 2026 14:43:18 +0300 Subject: [PATCH 03/20] refactor(api): Introduce unified API layer for freight backoffice --- .../backoffice/src/hooks/hooks/useBookings.ts | 16 -- .../src/hooks/hooks/useConsignments.ts | 16 -- .../src/hooks/hooks/useCustomers.ts | 50 ---- .../backoffice/src/hooks/hooks/useTracking.ts | 10 - .../src/hooks/rule-engine/useRuleEngine.ts | 70 +++--- .../src/hooks/useDropdownSettings.ts | 72 +++--- .../src/hooks/useFileUploadSettings.ts | 86 +++---- .../documents/FileUploadSettingsPage.tsx | 8 +- .../DropdownSettingsPage.tsx | 11 +- .../backoffice/src/services/api.ts | 223 ++++++++++++++++++ .../services/fileUploadSettings.service.ts | 15 +- .../backoffice/src/utils/result.ts | 29 +++ 12 files changed, 376 insertions(+), 230 deletions(-) delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/hooks/useBookings.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/hooks/useConsignments.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/hooks/useCustomers.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/hooks/useTracking.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/api.ts create mode 100644 apps/edr-freight-web/backoffice/src/utils/result.ts 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 6db9b446f..d56da7465 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,11 +1,8 @@ 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 { api } from "@/services/api"; +import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service"; import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources"; import type { ApproveRatePayload, @@ -15,26 +12,29 @@ import type { const CARGO_TYPE_PARENT_PAGE_SIZE = 500; +const listKey = (resource: RuleEngineResourceSlug) => + ["rule-engine", resource] as const; + export const useRuleEngineList = ( resource: RuleEngineResourceSlug, params: RuleEngineListParams, ) => - useQuery({ - queryKey: [...QUERY_KEYS.RULE_ENGINE.list(resource), params], - queryFn: () => ruleEngineService.list(resource, params), - }); + useQuery( + api.ruleEngine.list.queryOptions({ + input: { resource, params }, + }), + ); export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) => useQuery({ - queryKey: [ - ...QUERY_KEYS.RULE_ENGINE.list("cargo-types"), - "parent-options", - excludeId ?? "", - ], + queryKey: api.ruleEngine.list.queryKey({ + resource: "cargo-types", + params: { page: 1, pageSize: CARGO_TYPE_PARENT_PAGE_SIZE }, + }), queryFn: () => - ruleEngineService.list("cargo-types", { - page: 1, - pageSize: CARGO_TYPE_PARENT_PAGE_SIZE, + api.ruleEngine.list.call({ + resource: "cargo-types", + params: { page: 1, pageSize: CARGO_TYPE_PARENT_PAGE_SIZE }, }), enabled, select: (result) => { @@ -45,7 +45,9 @@ export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) => const name = String(row.cargoTypeName ?? "").trim(); const code = String(row.code ?? "").trim(); const label = - name && code ? `${name} (${code})` : name || code || String(row.id); + name && code + ? `${name} (${code})` + : name || code || String(row.id); return { label, value: String(row.id) }; }); return [noneOption, ...parents]; @@ -53,20 +55,20 @@ export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) => }); export const useApprovalChain = (enabled: boolean) => - useQuery({ - queryKey: QUERY_KEYS.RULE_ENGINE.chain, - queryFn: () => ruleEngineService.getApprovalChain(), - enabled, - }); + useQuery( + api.ruleEngine.getApprovalChain.queryOptions({ + enabled, + }), + ); export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => { const qc = useQueryClient(); const invalidate = () => - qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.list(resource) }); + qc.invalidateQueries({ queryKey: listKey(resource) }); const create = useMutation({ mutationFn: (payload: Record) => - ruleEngineService.create(resource, payload), + api.ruleEngine.create.call({ resource, payload }), onSuccess: () => { toast.success("Created successfully"); invalidate(); @@ -81,7 +83,7 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => { }: { id: string; payload: Record; - }) => ruleEngineService.update(resource, id, payload), + }) => api.ruleEngine.update.call({ resource, id, payload }), onSuccess: () => { toast.success("Updated successfully"); invalidate(); @@ -90,7 +92,8 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => { }); const remove = useMutation({ - mutationFn: (id: string) => ruleEngineService.remove(resource, id), + mutationFn: (id: string) => + api.ruleEngine.remove.call({ resource, id }), onSuccess: () => { toast.success("Deleted successfully"); invalidate(); @@ -104,10 +107,10 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => { export const useRateWorkflow = () => { const qc = useQueryClient(); const invalidate = () => - qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.list("rates") }); + qc.invalidateQueries({ queryKey: listKey("rates") }); const submit = useMutation({ - mutationFn: (id: string) => ruleEngineService.submitRate(id), + mutationFn: (id: string) => api.ruleEngine.submitRate.call({ id }), onSuccess: () => { toast.success("Rate submitted for approval"); invalidate(); @@ -116,8 +119,13 @@ export const useRateWorkflow = () => { }); const approve = useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: ApproveRatePayload }) => - ruleEngineService.approveRate(id, payload), + mutationFn: ({ + id, + payload, + }: { + id: string; + payload: ApproveRatePayload; + }) => api.ruleEngine.approveRate.call({ id, payload }), onSuccess: () => { toast.success("Rate approved"); invalidate(); 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/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/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts new file mode 100644 index 000000000..f45b3e29e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -0,0 +1,223 @@ +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 { + ApproveRatePayload, + RuleEngineListResult, + RuleEngineRecord, + RuleEngineResourceSlug, +} from "@/types/rule-engine"; +import { fileUploadSettingsService } from "./fileUploadSettings.service"; +import { dropdownSettingsService } from "./dropdownSettings.service"; +import { + ruleEngineService, + RuleEngineListParams, +} from "./ruleEngine/ruleEngine.service"; + +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), + ), + + getById: endpoint< + { resource: RuleEngineResourceSlug; id: string }, + RuleEngineRecord + >("rule-engine", "getById", ({ resource, id }) => + ruleEngineService.getById(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; payload: ApproveRatePayload }, + RuleEngineRecord + >("rule-engine", "approveRate", ({ id, payload }) => + ruleEngineService.approveRate(id, payload), + ), + + getApprovalChain: endpoint( + "rule-engine", + "getApprovalChain", + () => ruleEngineService.getApprovalChain(), + ), + }, +}; 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/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" }; +} From dab72217e19980f82b29e5f9e84d35f6462589a2 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 2 Jun 2026 16:04:47 +0300 Subject: [PATCH 04/20] feat(bookings): Integrate booking list and detail pages with backend API --- .../backoffice/src/hooks/useBookings.ts | 48 +++++++++++++++++ .../bookings/BookingRequestDetailPage.tsx | 38 +++++++------- .../pages/bookings/BookingRequestsPage.tsx | 12 ++++- .../pages/bookings/booking-requests.mock.ts | 32 ++++++++++++ .../backoffice/src/services/api.ts | 28 ++++++++++ .../src/services/bookings.service.ts | 51 +++++++++++++++++++ 6 files changed, 189 insertions(+), 20 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/hooks/useBookings.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/bookings.service.ts 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..5ee2e5749 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useBookings.ts @@ -0,0 +1,48 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { api } from "@/services/api"; +import type { BookingListFilter } from "@/services/bookings.service"; + +export const useBookingList = (filter?: BookingListFilter) => { + const input = { filter }; + return { + queryKey: api.bookings.list.queryKey(input), + queryFn: () => api.bookings.list.call(input), + }; +}; + +export const useBooking = (id: string) => ({ + queryKey: api.bookings.getById.queryKey({ id }), + queryFn: () => api.bookings.getById.call({ id }), + enabled: Boolean(id), +}); + +export const useUpdateBookingStatus = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + id, + action, + reason, + }: { + id: string; + action: string; + reason?: string; + }) => api.bookings.updateStatus.call({ id, action, reason }), + onSuccess: (_data, { id }) => { + qc.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + qc.invalidateQueries({ + queryKey: api.bookings.getById.queryKey({ id }), + }); + }, + }); +}; + +export const useDeleteBooking = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.bookings.remove.call({ id }), + onSuccess: () => + qc.invalidateQueries({ queryKey: api.bookings.list.queryKey() }), + }); +}; 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..3bb75b6bf 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { AlertCircle, AlertTriangle, @@ -27,14 +27,9 @@ import { import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { cn } from "@/lib/utils"; -import { - getBookingRequestById, - getBookingRequests, - saveBookingRequestsToStorage, - updateBookingRequestStatus, - BOOKING_STATUSES, - type BookingRequest, -} from "./booking-requests.mock"; +import { api } from "@/services/api"; +import { useUpdateBookingStatus } from "@/hooks/useBookings"; +import { mapBookingToRequest, BOOKING_STATUSES } from "./booking-requests.mock"; import { Badge, Button, @@ -233,9 +228,16 @@ const STATUS_CONFIG: Record< export default function BookingRequestDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const [booking, setBooking] = useState( - id ? getBookingRequestById(id) : undefined, + + const { data: bookingData } = useQuery( + api.bookings.getById.queryOptions({ + input: { id: id ?? "" }, + enabled: Boolean(id), + }), ); + const updateStatus = useUpdateBookingStatus(); + + const booking = bookingData ? mapBookingToRequest(bookingData) : undefined; if (!booking) { return ( @@ -270,20 +272,20 @@ export default function BookingRequestDetailPage() { booking.status, ); + const isPending = updateStatus.isPending; + function handleApprove() { if (!booking) return; - const nextStatus = + const action = booking.status === "RFQ_SUBMITTED" - ? ("QUOTATION_SENT" as const) - : ("APPROVED" as const); - updateBookingRequestStatus(booking.id, nextStatus); - setBooking(getBookingRequestById(booking.id)); + ? "SEND_QUOTATION" + : "APPROVE"; + updateStatus.mutate({ id: booking.id, action }); } function handleReject() { if (!booking) return; - updateBookingRequestStatus(booking.id, "CANCELLED"); - setBooking(getBookingRequestById(booking.id)); + updateStatus.mutate({ id: booking.id, action: "CANCEL", reason: "Cancelled by backoffice" }); } return ( 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..643257bbd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { AlertCircle, ArrowRight, @@ -18,10 +19,11 @@ import { import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { cn } from "@/lib/utils"; +import { api } from "@/services/api"; import { - getBookingRequests, BOOKING_STATUSES, type BookingRequest, + mapBookingToRequest, } from "./booking-requests.mock"; import { DataTable, @@ -153,7 +155,13 @@ export default function BookingRequestsPage() { const [query, setQuery] = useState(""); const [statusFilter, setStatusFilter] = useState(null); - const bookingRequests = useMemo(() => getBookingRequests(), []); + const { data: bookingData } = useQuery( + api.bookings.list.queryOptions({ input: { filter: { page: pagination.pageIndex + 1, pageSize: pagination.pageSize } } }), + ); + const bookingRequests = useMemo( + () => (bookingData?.items ?? []).map(mapBookingToRequest), + [bookingData], + ); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); 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..9adb8eb33 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,3 +1,5 @@ +import type { Freight } from "@edr/types"; + export interface BookingRequest { id: string; reference: string; @@ -131,6 +133,36 @@ export function updateBookingRequestStatus(id: string, newStatus: (typeof BOOKIN saveBookingRequestsToStorage(requests); } +export function mapBookingToRequest(booking: Freight.IBooking): BookingRequest { + return { + id: booking.id, + reference: booking.reference, + customer: booking.customerId, + status: booking.status as BookingRequest["status"], + scheduledDate: booking.scheduledDate, + totalAmount: booking.totalAmount, + paymentStatus: booking.paymentStatus, + contractType: booking.contractType, + serviceType: + booking.serviceType === "RAIL_ONLY" ? "RAIL" : (booking.serviceType as string), + tradeDirection: booking.tradeDirection as string, + originYard: booking.originStation, + destinationYard: booking.destinationStation, + cargoType: booking.freightType ?? booking.freightSubtype ?? "", + cargoTotalWeightVgm: booking.cargoTotalWeightVgm, + isHazardous: booking.isHazardous, + paymentCurrency: booking.paymentCurrency, + priorityScore: booking.priorityScore, + firstMilePickupAddress: booking.firstMilePickupAddress ?? null, + lastMileDeliveryAddress: booking.lastMileDeliveryAddress ?? null, + shippingLine: null, + pnrCode: null, + createdBy: booking.customerId, + createdAt: booking.createdAt, + updatedAt: booking.updatedAt, + }; +} + export function getBookingRequests(): BookingRequest[] { if (typeof window === "undefined" || !window.localStorage) { return INITIAL_REQUESTS; diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index f45b3e29e..f0d585859 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -27,6 +27,8 @@ import { ruleEngineService, RuleEngineListParams, } from "./ruleEngine/ruleEngine.service"; +import { bookingsService, BookingListFilter } from "./bookings.service"; +import type { Freight, PaginatedResponse } from "@edr/types"; export const api = { fileUploadSettings: { @@ -220,4 +222,30 @@ export const api = { () => ruleEngineService.getApprovalChain(), ), }, + + bookings: { + list: endpoint< + { filter?: BookingListFilter }, + PaginatedResponse + >("bookings", "list", ({ filter }) => bookingsService.list(filter)), + + getById: endpoint<{ id: string }, Freight.IBooking>( + "bookings", + "getById", + ({ id }) => bookingsService.getById(id), + ), + + updateStatus: endpoint< + { id: string; action: string; reason?: string }, + Freight.IBooking + >("bookings", "updateStatus", ({ id, action, reason }) => + bookingsService.updateStatus(id, { action, reason }), + ), + + remove: endpoint<{ id: string }, void>( + "bookings", + "remove", + ({ id }) => bookingsService.remove(id), + ), + }, }; 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..e823b2146 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -0,0 +1,51 @@ +import type { Freight, PaginatedResponse } from "@edr/types"; + +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; +import { URL_CONSTANTS } from "@/constants/URLS"; + +const BASE = URL_CONSTANTS.BOOKINGS.BASE; + +export interface BookingListFilter { + status?: string; + customerId?: string; + search?: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: "ASC" | "DESC"; +} + +export const bookingsService = { + list: async ( + filter?: BookingListFilter, + ): Promise> => { + const response = await client.get>( + BASE, + { params: filter }, + ); + return unwrap(response.data); + }, + + getById: async (id: string): Promise => { + const response = await client.get( + URL_CONSTANTS.BOOKINGS.BY_ID(id), + ); + return unwrap(response.data); + }, + + updateStatus: async ( + id: string, + payload: { action: string; reason?: string }, + ): Promise => { + const response = await client.patch( + `${URL_CONSTANTS.BOOKINGS.BY_ID(id)}/status`, + payload, + ); + return unwrap(response.data); + }, + + remove: async (id: string): Promise => { + await client.delete(URL_CONSTANTS.BOOKINGS.BY_ID(id)); + }, +}; From 9e4e44ee6a672262018850c39a5926fbd6be8d89 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Wed, 3 Jun 2026 11:52:05 +0300 Subject: [PATCH 05/20] feat(file-upload-settings): Add API endpoint and logic to retrieve settings by entity --- .../file-upload-settings.controller.ts | 6 ++++++ .../file-upload-settings.repository.ts | 9 +++++++++ .../file-upload-settings/file-upload-settings.service.ts | 4 ++++ .../file-upload-settings.repository.interface.ts | 1 + 4 files changed, 20 insertions(+) diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts index 3613d9444..ecdecffc3 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts @@ -42,6 +42,12 @@ export class FileUploadSettingsController { return this.service.getByCode(code); } + @Get("by-entity/:entity") + @ApiOperation({ summary: "Get all file upload settings for an entity type (customer, booking, etc.)" }) + getByEntity(@Param("entity") entity: string) { + return this.service.getByEntity(entity); + } + @Post() @ApiOperation({ summary: "Create a new file upload setting" }) create(@Body() dto: CreateFileUploadSettingDto) { diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.repository.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.repository.ts index 558a7bb74..c14b30052 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.repository.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.repository.ts @@ -21,6 +21,15 @@ export class FileUploadSettingsRepository super(repository); } + /** Look up all settings for a given entity (e.g. "customer", "booking"). */ + findByEntity(entity: string): Promise { + return this.repository.find({ + where: { entity }, + order: { label: "ASC" }, + relations: { fields: true }, + }); + } + /** Look up a setting by its stable code. */ findByCode(code: string): Promise { return this.repository.findOne({ diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts index 8ae5395d7..947bb5ffb 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts @@ -33,6 +33,10 @@ export class FileUploadSettingsService { return setting; } + getByEntity(entity: string): Promise { + return this.repository.findByEntity(entity); + } + async getByCode(code: string): Promise { const setting = await this.repository.findByCode(code); if (!setting) throw new NotFoundException(`Setting "${code}" not found`); diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/interfaces/file-upload-settings.repository.interface.ts b/apps/edr-freight-api/src/modules/file-upload-settings/interfaces/file-upload-settings.repository.interface.ts index 7e833de02..a0aa01d06 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/interfaces/file-upload-settings.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/interfaces/file-upload-settings.repository.interface.ts @@ -13,6 +13,7 @@ export interface IFileUploadSettingsRepository { findAll(): Promise; findById(id: string): Promise; findByCode(code: string): Promise; + findByEntity(entity: string): Promise; create(data: Partial): Promise; update( From c2145b48f549a25a1e963b04050c2e1e0c398baf Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Wed, 3 Jun 2026 15:31:29 +0300 Subject: [PATCH 06/20] fix --- .../src/hooks/rule-engine/useRuleEngine.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 8430c8f1c..6342d66fb 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 @@ -9,6 +9,7 @@ import type { RuleEngineRecord, RuleEngineResourceSlug, } from "@/types/rule-engine"; +import { QUERY_KEYS } from "@/constants/TANSTACK_QUEY_KEY"; const CARGO_TYPE_PARENT_PAGE_SIZE = 500; const CONTAINER_TYPE_OPTIONS_PAGE_SIZE = 500; @@ -46,9 +47,7 @@ export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) => const name = String(row.cargoTypeName ?? "").trim(); const code = String(row.code ?? "").trim(); const label = - name && code - ? `${name} (${code})` - : name || code || String(row.id); + name && code ? `${name} (${code})` : name || code || String(row.id); return { label, value: String(row.id) }; }); return [noneOption, ...parents]; @@ -62,9 +61,12 @@ export const useContainerTypeOptions = (enabled = true) => "select-options", ], 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) => { @@ -123,8 +125,7 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => { }); const remove = useMutation({ - mutationFn: (id: string) => - api.ruleEngine.remove.call({ resource, id }), + mutationFn: (id: string) => api.ruleEngine.remove.call({ resource, id }), onSuccess: () => { toast.success("Deleted successfully"); invalidate(); @@ -137,8 +138,7 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => { export const useRateWorkflow = () => { const qc = useQueryClient(); - const invalidate = () => - qc.invalidateQueries({ queryKey: listKey("rates") }); + const invalidate = () => qc.invalidateQueries({ queryKey: listKey("rates") }); const submit = useMutation({ mutationFn: (id: string) => api.ruleEngine.submitRate.call({ id }), From 4ed0e22f5561ae0bd924171f988ef4998ea4bf67 Mon Sep 17 00:00:00 2001 From: marshal Date: Wed, 3 Jun 2026 15:45:38 +0300 Subject: [PATCH 07/20] refactor(bookings): replace RFQ/quotation flow with submit, staff review, approval routing, and payment stubs --- .../1749200000000-BookingFlowRefactor.ts | 50 +++ .../bookings/booking-contract.service.ts | 106 +++++ .../bookings/booking-payment.service.ts | 130 ++++++ .../bookings/booking-pricing.service.ts | 248 +++++++++++ .../modules/bookings/booking-status.util.ts | 10 + .../bookings/booking-transition.service.ts | 291 +++++++++++++ .../modules/bookings/bookings.controller.ts | 394 ++++++++++++------ .../src/modules/bookings/bookings.module.ts | 13 +- .../modules/bookings/bookings.repository.ts | 115 ++++- .../src/modules/bookings/bookings.service.ts | 223 ++-------- .../dto/generate-price-response.dto.ts | 32 ++ .../bookings/dto/request-changes.dto.ts | 74 ++++ .../modules/bookings/dto/update-status.dto.ts | 41 -- .../entities/booking-review-note.entity.ts | 26 ++ .../bookings/entities/booking.entity.ts | 45 +- .../bookings/payments-webhook.controller.ts | 17 + 16 files changed, 1448 insertions(+), 367 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-status.util.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts delete mode 100644 apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/payments-webhook.controller.ts diff --git a/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts b/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts new file mode 100644 index 000000000..162672727 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class BookingFlowRefactor1749200000000 implements MigrationInterface { + name = 'BookingFlowRefactor1749200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.booking_review_note ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + author_id UUID, + note TEXT NOT NULL, + type VARCHAR(30) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_booking_review_note_booking_id + ON freight.booking_review_note(booking_id); + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS marketing_approved_by_id UUID, + ADD COLUMN IF NOT EXISTS marketing_approved_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS contract_summary TEXT, + ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ; + `); + + await queryRunner.query(` + UPDATE freight.bookings SET status = 'SUBMITTED' + WHERE status IN ('RFQ_SUBMITTED', 'QUOTATION_SENT', 'QUOTATION_APPROVED'); + UPDATE freight.bookings SET status = 'REJECTED' + WHERE status = 'QUOTATION_REJECTED'; + UPDATE freight.bookings SET status = 'CANCELLED' + WHERE status = 'CANCELLED'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS locked_at, + DROP COLUMN IF EXISTS contract_summary, + DROP COLUMN IF EXISTS marketing_approved_at, + DROP COLUMN IF EXISTS marketing_approved_by_id; + `); + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_review_note;`); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts new file mode 100644 index 000000000..65c317eaf --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -0,0 +1,106 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Readable } from 'stream'; + +import { FilesService } from '../files/files.service'; +import { BookingsRepository } from './bookings.repository'; +import { Booking } from './entities/booking.entity'; +import { assertBookingStatus } from './booking-status.util'; + +@Injectable() +export class BookingContractService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly filesService: FilesService, + ) {} + + buildContractSummary(booking: Booking): string { + const direction = + booking.tradeDirection === 'IMPORT' + ? 'Import' + : booking.tradeDirection === 'EXPORT' + ? 'Export' + : booking.tradeDirection; + + const cargo = booking.cargoType; + const isBulk = cargo?.requiresDirectorApproval; + + let cargoLabel: string; + if (isBulk) { + cargoLabel = `Bulk (${booking.cargoFreeText || cargo?.cargoTypeName || 'Commodity'})`; + } else { + const lines = + booking.bookingContainers?.map((bc) => { + const label = bc.containerType?.label ?? bc.containerType?.code ?? 'Container'; + return `${bc.quantity}× ${label}`; + }) ?? []; + cargoLabel = + lines.length > 0 + ? `Container (${lines.join(', ')})` + : `Container (${cargo?.cargoTypeName ?? 'Standard'})`; + } + + return `Operation: ${direction} | Cargo Type: ${cargoLabel}`; + } + + async getSummary(bookingId: string): Promise<{ summary: string }> { + const booking = await this.requireBooking(bookingId); + const summary = booking.contractSummary ?? this.buildContractSummary(booking); + return { summary }; + } + + async generateContract(bookingId: string): Promise { + const booking = await this.requireBooking(bookingId); + assertBookingStatus(booking, ['APPROVED']); + + const summary = this.buildContractSummary(booking); + const body = [ + 'FREIGHT CONTRACT (STUB)', + `Reference: ${booking.reference}`, + summary, + `Total: ${booking.totalAmount} ${booking.paymentCurrency}`, + `Trade: ${booking.tradeDirection}`, + ].join('\n'); + + const buffer = Buffer.from(body, 'utf-8'); + const file: Express.Multer.File = { + fieldname: 'contract', + originalname: `contract-${booking.reference}.txt`, + encoding: '7bit', + mimetype: 'text/plain', + size: buffer.length, + buffer, + stream: Readable.from(buffer), + destination: '', + filename: '', + path: '', + }; + + await this.filesService.upload({ + resourceId: bookingId, + resource: 'bookings', + code: 'contract', + file, + }); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'CONTRACT_READY', + contractSummary: summary, + } as never); + return updated!; + } + + async streamContract(bookingId: string) { + const record = await this.filesService.findByCode( + bookingId, + 'bookings', + 'contract', + ); + return this.filesService.streamById(record.id); + } + + private async requireBooking(id: string): Promise { + const booking = await this.bookingsRepository.findByIdWithFiles(id); + if (!booking) throw new NotFoundException(`Booking ${id} not found`); + return booking; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts new file mode 100644 index 000000000..80476ead2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts @@ -0,0 +1,130 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { FilesService } from '../files/files.service'; +import { BookingsRepository } from './bookings.repository'; +import { Booking } from './entities/booking.entity'; +import { assertBookingStatus } from './booking-status.util'; + +const PROOF_MAX_BYTES = 5 * 1024 * 1024; +const PROOF_MIMES = ['application/pdf', 'image/jpeg', 'image/png']; + +@Injectable() +export class BookingPaymentService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly filesService: FilesService, + ) {} + + async generatePnr(bookingId: string): Promise { + const booking = await this.requireBooking(bookingId); + assertBookingStatus(booking, ['FULLY_EXECUTED']); + + if (booking.paymentCurrency !== 'ETB') { + throw new BadRequestException('PNR generation is only for ETB payers'); + } + + const year = new Date().getFullYear(); + const pnrCode = `PNR-${year}-${Math.random().toString(36).slice(2, 10).toUpperCase()}`; + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'PNR_GENERATED', + pnrCode, + paymentStatus: 'PNR_GENERATED', + } as never); + return updated!; + } + + async submitPaymentProof( + bookingId: string, + file: Express.Multer.File, + ): Promise { + const booking = await this.requireBooking(bookingId); + assertBookingStatus(booking, ['FULLY_EXECUTED']); + + if (booking.paymentCurrency !== 'USD') { + throw new BadRequestException('Payment proof upload is only for USD payers'); + } + + this.validateProofFile(file); + + await this.filesService.upload({ + resourceId: bookingId, + resource: 'bookings', + code: 'payment_proof', + file, + }); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'PAYMENT_VERIFICATION_IN_PROGRESS', + paymentStatus: 'VERIFICATION_IN_PROGRESS', + } as never); + return updated!; + } + + async verifyPayment(bookingId: string): Promise { + const booking = await this.requireBooking(bookingId); + assertBookingStatus(booking, ['PAYMENT_VERIFICATION_IN_PROGRESS']); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'PAID', + paymentStatus: 'PAID', + } as never); + return updated!; + } + + async handleBankCallback(pnrCode: string): Promise { + const booking = await this.bookingsRepository.findByPnrCode(pnrCode); + if (!booking) { + throw new NotFoundException(`No booking found for PNR ${pnrCode}`); + } + + if (booking.status !== 'PNR_GENERATED') { + throw new BadRequestException( + `Booking ${booking.reference} is not awaiting bank payment (status: ${booking.status})`, + ); + } + + const updated = await this.bookingsRepository.update(booking.id, { + status: 'PAID', + paymentStatus: 'PAID', + } as never); + return updated!; + } + + async getPaymentRequestLetter( + bookingId: string, + ): Promise<{ buffer: Buffer; filename: string }> { + const booking = await this.requireBooking(bookingId); + const body = [ + 'PAYMENT REQUEST LETTER (STUB)', + `Reference: ${booking.reference}`, + `Amount: ${booking.totalAmount} ${booking.paymentCurrency}`, + 'Pay at your bank and upload stamped proof.', + ].join('\n'); + return { + buffer: Buffer.from(body, 'utf-8'), + filename: `payment-request-${booking.reference}.txt`, + }; + } + + private validateProofFile(file: Express.Multer.File): void { + if (!file?.buffer?.length) { + throw new BadRequestException('Payment proof file is required'); + } + if (file.size > PROOF_MAX_BYTES) { + throw new BadRequestException('Payment proof must be 5MB or less'); + } + if (!PROOF_MIMES.includes(file.mimetype)) { + throw new BadRequestException('Payment proof must be PDF, JPG, or PNG'); + } + } + + private async requireBooking(id: string): Promise { + const booking = await this.bookingsRepository.findById(id); + if (!booking) throw new NotFoundException(`Booking ${id} not found`); + return booking; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts new file mode 100644 index 000000000..621fb58e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -0,0 +1,248 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; + +import { ContainerTypesService } from '../rule-engine/services/container-types.service'; +import { + IRatesRepository, + RATES_REPOSITORY, +} from '../rule-engine/interfaces/rates.repository.interface'; +import { + IServiceTypesRepository, + SERVICE_TYPES_REPOSITORY, +} from '../rule-engine/interfaces/service-types.repository.interface'; +import { Rate } from '../rule-engine/entities/rate.entity'; +import { + AppliedCargoModifier, + BookingEvaluationInput, + RuleEngineService, +} from '../rule-engine/rule-engine.service'; +import { BookingsRepository } from './bookings.repository'; +import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; +import { Booking } from './entities/booking.entity'; +import { assertBookingStatus } from './booking-status.util'; + +@Injectable() +export class BookingPricingService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly ruleEngineService: RuleEngineService, + private readonly containerTypesService: ContainerTypesService, + @Inject(RATES_REPOSITORY) + private readonly ratesRepo: IRatesRepository, + @Inject(SERVICE_TYPES_REPOSITORY) + private readonly serviceTypesRepo: IServiceTypesRepository, + ) {} + + async generatePrice(bookingId: string): Promise { + const booking = await this.requireBooking(bookingId); + assertBookingStatus(booking, ['DRAFT']); + + const evalInput = await this.buildEvalInputForBooking(booking); + const ruleResult = await this.ruleEngineService.evaluate(evalInput); + this.ruleEngineService.assertNoHardBlocks(ruleResult); + + const lineItems: PriceLineItemDto[] = []; + let total = 0; + + const baseLines = await this.computeBaseRailLines(booking, evalInput); + for (const line of baseLines) { + lineItems.push(line); + total += line.amount; + } + + for (const mod of ruleResult.appliedModifiers) { + const item: PriceLineItemDto = { + code: mod.surchargeTypeCode, + description: `Surcharge: ${mod.surchargeTypeCode}`, + amount: mod.calculatedAmount, + currency: mod.currency, + }; + lineItems.push(item); + total += mod.calculatedAmount; + } + + await this.persistPriceRun(bookingId, ruleResult.appliedModifiers, total); + + await this.bookingsRepository.update(bookingId, { + totalAmount: total, + priorityScore: ruleResult.priorityScore, + } as never); + + return { + bookingId, + totalAmount: total, + currency: booking.paymentCurrency, + lineItems, + warnings: ruleResult.warnings, + }; + } + + async buildEvalInputForBooking(booking: Booking): Promise { + const containers = await Promise.all( + (booking.bookingContainers ?? []).map(async (bc) => { + const ct = await this.containerTypesService.findById(bc.containerTypeId); + const vgm = Number(bc.vgmPerUnitTons); + const qty = bc.quantity; + return { + containerTypeId: bc.containerTypeId, + quantity: qty, + vgmPerUnitTons: vgm, + totalVgmTons: qty * vgm, + isReefer: ct.isReefer, + }; + }), + ); + return { + cargoTypeId: booking.cargoTypeId, + serviceTypeId: booking.serviceTypeId, + paymentCurrency: booking.paymentCurrency, + tradeDirection: booking.tradeDirection, + isHazardous: booking.isHazardous, + allowConsolidation: booking.allowConsolidation, + shippingLineId: booking.shippingLineId, + containers, + }; + } + + private async requireBooking(id: string): Promise { + const booking = await this.bookingsRepository.findByIdWithFiles(id); + if (!booking) throw new NotFoundException(`Booking ${id} not found`); + return booking; + } + + /** Recompute priority on submit (USD + service tier). */ + async computeSubmitPriorityScore(booking: Booking): Promise { + const evalInput = await this.buildEvalInputForBooking(booking); + const ruleResult = await this.ruleEngineService.evaluate(evalInput); + let score = ruleResult.priorityScore; + + const serviceType = await this.serviceTypesRepo.findById(booking.serviceTypeId); + if (booking.paymentCurrency === 'USD' && serviceType) { + const code = (serviceType.code ?? '').toUpperCase(); + const hasForwarding = + serviceType.includesFirstMile || + serviceType.includesLastMile || + code.includes('FORWARD') || + code.includes('Y'); + const railOnly = code.includes('RAIL') && !hasForwarding; + + if (hasForwarding) score += 1000; + else if (railOnly || code.includes('X')) score += 500; + } + + return score; + } + + private async computeBaseRailLines( + booking: Booking, + evalInput: BookingEvaluationInput, + ): Promise { + const liveRates = await this.ratesRepo.findLiveRates(); + const currency = booking.paymentCurrency; + const isBulk = booking.cargoType?.requiresDirectorApproval ?? false; + + const rateType = + booking.tradeDirection === 'IMPORT' + ? isBulk + ? 'BULK_IMPORT' + : 'CONTAINER_IMPORT' + : booking.tradeDirection === 'EXPORT' + ? isBulk + ? 'BULK_EXPORT' + : 'CONTAINER_EXPORT' + : 'INTERCITY_CONTAINER'; + + const lines: PriceLineItemDto[] = []; + const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); + + for (const container of evalInput.containers) { + const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency); + if (!rate) continue; + + const amount = this.amountForRate(rate, container.quantity, wagonCount); + lines.push({ + code: rateType, + description: `Base rail (${rateType})`, + amount, + currency: rate.currency, + }); + } + + if (lines.length === 0) { + const fallback = liveRates.find( + (r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE', + ); + if (fallback) { + const amount = this.amountForRate(fallback, 1, wagonCount); + lines.push({ + code: rateType, + description: `Base rail (${rateType})`, + amount, + currency: fallback.currency, + }); + } + } + + return lines; + } + + private pickRate( + rates: Rate[], + rateType: string, + containerTypeId: string, + currency: string, + ): Rate | undefined { + return ( + rates.find( + (r) => + r.rateType === rateType && + r.currency === currency && + r.containerTypeId === containerTypeId, + ) ?? + rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId) + ); + } + + private amountForRate(rate: Rate, quantity: number, wagonCount: number): number { + const value = Number(rate.rateValue); + switch (rate.rateUnit) { + case 'PER_CONTAINER': + return value * quantity; + case 'PER_WAGON': + return value * wagonCount; + case 'PER_TON': + return value * quantity; + case 'FLAT': + return value; + default: + return value * quantity; + } + } + + private async persistPriceRun( + bookingId: string, + modifiers: AppliedCargoModifier[], + _total: number, + ): Promise { + await this.bookingsRepository.clearPricingArtifacts(bookingId); + const snapshots = await this.ruleEngineService.snapshotLiveRates(bookingId); + + const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id])); + const rows = modifiers + .map((m) => { + const snapshotId = snapshotByRateId.get(m.rateId); + if (!snapshotId) return null; + return { + bookingId, + surchargeTypeId: m.surchargeTypeId, + triggerValue: m.triggerValue, + calculatedAmount: m.calculatedAmount, + rateSnapshotId: snapshotId, + }; + }) + .filter((r): r is NonNullable => r !== null); + + if (rows.length > 0) { + await this.bookingsRepository.createCargoModifiers(rows); + } + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts new file mode 100644 index 000000000..fe9152149 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts @@ -0,0 +1,10 @@ +import { ConflictException } from '@nestjs/common'; +import { Booking } from './entities/booking.entity'; + +export function assertBookingStatus(booking: Booking, allowed: string[]): void { + if (!allowed.includes(booking.status)) { + throw new ConflictException( + `Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts new file mode 100644 index 000000000..d40d6590b --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -0,0 +1,291 @@ +import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common'; + +import { RuleEngineService } from '../rule-engine/rule-engine.service'; +import { BookingContractService } from './booking-contract.service'; +import { BookingPricingService } from './booking-pricing.service'; +import { BookingsRepository } from './bookings.repository'; +import { assertBookingStatus } from './booking-status.util'; +import { Booking } from './entities/booking.entity'; +import { BookingsService } from './bookings.service'; + +@Injectable() +export class BookingTransitionService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly ruleEngineService: RuleEngineService, + private readonly pricingService: BookingPricingService, + private readonly contractService: BookingContractService, + @Inject(forwardRef(() => BookingsService)) + private readonly bookingsService: BookingsService, + ) {} + + async submit(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']); + + if (Number(booking.totalAmount) <= 0) { + throw new BadRequestException( + 'Generate a price before submitting (POST /bookings/:id/generate-price)', + ); + } + + const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + await this.ruleEngineService.snapshotLiveRates(bookingId); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'SUBMITTED', + priorityScore, + } as never); + return this.bookingsService.findById(updated!.id); + } + + async requestChanges( + bookingId: string, + note: string, + actorId?: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['SUBMITTED']); + + await this.bookingsRepository.createReviewNote( + bookingId, + note, + 'CHANGES_REQUESTED', + actorId, + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'CHANGES_REQUESTED', + } as never); + return this.bookingsService.findById(updated!.id); + } + + async acceptIntake(bookingId: string, actorId?: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['SUBMITTED']); + + await this.ruleEngineService.instantiateApprovalSteps( + bookingId, + booking.cargoTypeId, + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'PENDING_APPROVAL', + approvedByStaffId: actorId ?? booking.approvedByStaffId, + approvedByStaffAt: actorId ? new Date() : booking.approvedByStaffAt, + } as never); + return this.bookingsService.findById(updated!.id); + } + + async staffReject( + bookingId: string, + reason: string, + actorId?: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']); + + await this.bookingsRepository.createReviewNote( + bookingId, + reason, + 'REJECTION', + actorId, + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'REJECTED', + } as never); + return this.bookingsService.findById(updated!.id); + } + + async approveStep( + bookingId: string, + stepId: string, + actorId: string, + requiredRole: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, [ + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', + ]); + + const step = await this.bookingsRepository.findApprovalStepById( + bookingId, + stepId, + ); + if (!step || step.status !== 'PENDING') { + throw new BadRequestException('Approval step not found or already actioned'); + } + + const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId); + if (!next || next.id !== step.id) { + throw new BadRequestException( + 'Approval steps must be completed in order', + ); + } + + if (step.requiredRole !== requiredRole) { + throw new BadRequestException( + `Step requires role ${step.requiredRole}, not ${requiredRole}`, + ); + } + + const blocksRole = step.approvalRule?.blocksRole; + if (blocksRole && blocksRole === requiredRole) { + throw new BadRequestException(`Role ${requiredRole} is blocked for this step`); + } + + await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); + + const updates: Record = {}; + const now = new Date(); + + if (requiredRole === 'LINE_STAFF') { + updates.status = 'APPROVED_PENDING_SIGNATURE'; + updates.approvedByStaffId = actorId; + updates.approvedByStaffAt = now; + } else if (requiredRole === 'DIRECTOR') { + updates.signedByDirectorId = actorId; + updates.signedByDirectorAt = now; + } else if (requiredRole === 'CEO') { + updates.signedByCeoId = actorId; + updates.signedByCeoAt = now; + } + + const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId); + if (allDone) { + updates.status = 'APPROVED'; + } + + if (Object.keys(updates).length > 0) { + await this.bookingsRepository.update(bookingId, updates as never); + } + + return this.bookingsService.findById(bookingId); + } + + async rejectStep( + bookingId: string, + stepId: string, + actorId: string, + reason: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); + + const step = await this.bookingsRepository.findApprovalStepById( + bookingId, + stepId, + ); + if (!step) throw new BadRequestException('Approval step not found'); + + await this.bookingsRepository.completeApprovalStep( + step.id, + actorId, + 'REJECTED', + reason, + ); + + await this.bookingsRepository.createReviewNote( + bookingId, + reason, + 'REJECTION', + actorId, + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'REJECTED', + } as never); + return this.bookingsService.findById(updated!.id); + } + + async customerSign(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['CONTRACT_READY']); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'SIGNED_CUSTOMER', + customerSignedAt: new Date(), + } as never); + return this.bookingsService.findById(updated!.id); + } + + async marketingApprove( + bookingId: string, + actorId?: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['SIGNED_CUSTOMER']); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'FULLY_EXECUTED', + fullyExecutedAt: new Date(), + marketingApprovedById: actorId ?? null, + marketingApprovedAt: new Date(), + lockedAt: new Date(), + } as never); + return this.bookingsService.findById(updated!.id); + } + + async startTransit(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['PAID']); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'IN_TRANSIT', + } as never); + return this.bookingsService.findById(updated!.id); + } + + async complete(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['IN_TRANSIT']); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'COMPLETED', + endDate: new Date(), + } as never); + return this.bookingsService.findById(updated!.id); + } + + async cancel(bookingId: string, reason: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, [ + 'DRAFT', + 'SUBMITTED', + 'CHANGES_REQUESTED', + 'PENDING_APPROVAL', + 'CONTRACT_READY', + ]); + + await this.bookingsRepository.createReviewNote( + bookingId, + reason, + 'REJECTION', + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'CANCELLED', + } as never); + return this.bookingsService.findById(updated!.id); + } + + async enrichBookingResponse(booking: Booking): Promise { + const note = await this.bookingsRepository.findLatestReviewNote( + booking.id, + 'CHANGES_REQUESTED', + ); + const summary = + booking.contractSummary ?? + this.contractService.buildContractSummary(booking); + return { + ...booking, + latestChangeRequestNote: note?.note ?? null, + contractSummary: summary, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 41a1a09c4..c8572dd76 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -3,6 +3,7 @@ import { Controller, Delete, Get, + Header, HttpCode, Param, ParseUUIDPipe, @@ -10,10 +11,12 @@ import { Post, Query, Request, + Res, + StreamableFile, UploadedFiles, UseInterceptors, -} from "@nestjs/common"; -import { AnyFilesInterceptor } from "@nestjs/platform-express"; +} from '@nestjs/common'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, @@ -21,181 +24,332 @@ import { ApiOkResponse, ApiOperation, ApiTags, -} from "@nestjs/swagger"; +} from '@nestjs/swagger'; +import type { Response } from 'express'; -import { BookingReferenceDataService } from "./booking-reference-data.service"; -import { BookingsService } from "./bookings.service"; -import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto"; -import { CreateBookingDto } from "./dto/create-booking.dto"; -import { FilterBookingDto } from "./dto/filter-booking.dto"; -import { UpdateBookingDto } from "./dto/update-booking.dto"; -import { UpdateStatusDto } from "./dto/update-status.dto"; +import { BookingContractService } from './booking-contract.service'; +import { BookingPaymentService } from './booking-payment.service'; +import { BookingPricingService } from './booking-pricing.service'; +import { BookingTransitionService } from './booking-transition.service'; +import { BookingReferenceDataService } from './booking-reference-data.service'; +import { BookingsService } from './bookings.service'; +import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; +import { CreateBookingDto } from './dto/create-booking.dto'; +import { FilterBookingDto } from './dto/filter-booking.dto'; +import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; +import { + ApproveStepDto, + CancelBookingDto, + RejectStepDto, + MarketingApproveDto, + RequestChangesDto, + StaffAcceptDto, + StaffRejectDto, +} from './dto/request-changes.dto'; +import { UpdateBookingDto } from './dto/update-booking.dto'; -@ApiTags("bookings") -@Controller("bookings") +@ApiTags('bookings') +@Controller('bookings') @ApiBearerAuth() export class BookingsController { constructor( private readonly bookingsService: BookingsService, private readonly bookingReferenceDataService: BookingReferenceDataService, + private readonly pricingService: BookingPricingService, + private readonly transitionService: BookingTransitionService, + private readonly contractService: BookingContractService, + private readonly paymentService: BookingPaymentService, ) {} - // ── 1. Create booking (multipart/form-data) ────────────────────────── @Post() @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes("multipart/form-data") - @ApiOperation({ - summary: "Create a new freight booking", - description: - "Accepts all booking fields as form fields + dynamic file keys (e.g. passport, license). " + - "Auto-enables consolidation when container quantity does not fill a whole wagon; attempts partner match or PENDING_CONSOLIDATION.", - }) - @ApiBody({ - description: - "Booking form data. Attach files with any field name (e.g. passport, tin_certificate). " + - "Each uploaded file is saved as a row in the files table (resource=bookings).", - type: CreateBookingDto, - }) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Create a new freight booking (DRAFT)' }) + @ApiBody({ type: CreateBookingDto }) create( @Body() dto: CreateBookingDto, @UploadedFiles() files: Express.Multer.File[], - @Request() req: any, + @Request() req: { user?: { id?: string; sub?: string } }, ) { - console.log( - "[BookingsController] Files received:", - files?.length, - files?.map((f) => ({ - fieldname: f.fieldname, - originalname: f.originalname, - size: f.size, - mimetype: f.mimetype, - })), - ); - const userId: string | undefined = req.user?.id ?? req.user?.sub; + const userId = req.user?.id ?? req.user?.sub; return this.bookingsService.create(dto, files ?? [], userId); } - // ── 2. Update draft booking (multipart/form-data) ───────────────────── - @Patch(":id") + @Patch(':id') @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes("multipart/form-data") + @ApiConsumes('multipart/form-data') @ApiOperation({ - summary: "Update a draft booking", - description: - "Only DRAFT bookings can be updated. New files are merged into existing documents.", + summary: 'Update booking', + description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.', }) @ApiBody({ type: UpdateBookingDto }) update( - @Param("id", ParseUUIDPipe) id: string, + @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateBookingDto, @UploadedFiles() files: Express.Multer.File[], ) { return this.bookingsService.update(id, dto, files ?? []); } - // ── 3. List bookings (paginated + filtered) ─────────────────────────── @Get() - @ApiOperation({ - summary: "List freight bookings (paginated)", - description: - "Filter by status, customerId, contractType, serviceTypeId, cargoTypeId, tradeDirection, " + - "paymentCurrency, allowConsolidation, consolidationPaired. " + - "Sort by createdAt or priorityScore.", - }) + @ApiOperation({ summary: 'List freight bookings (paginated)' }) findAll(@Query() filter: FilterBookingDto) { return this.bookingsService.findAll(filter); } - // ── Booking form catalog (must be before :id) ───────────────────────── - @Get("reference-data") + @Get('queues/:queue') @ApiOperation({ - summary: "Booking form catalog", - description: - "Returns yards, container types (grouped by size), service types, shipping lines, " + - "and hierarchical cargo types for the booking UI in a single payload.", + summary: 'List bookings for a dashboard queue', + description: 'Queues: intake, approval, signatures, marketing, finance', }) + findQueue( + @Param('queue') queue: string, + @Query() filter: FilterBookingDto, + @Query('excludeBulk') excludeBulk?: string, + ) { + return this.bookingsService.findQueue(queue, filter, { + excludeBulk: excludeBulk === 'true', + }); + } + + @Get('reference-data') + @ApiOperation({ summary: 'Booking form catalog' }) @ApiOkResponse({ type: BookingReferenceDataDto }) getReferenceData(): Promise { return this.bookingReferenceDataService.getReferenceData(); } - // ── 5. Lookup by reference (must be before :id to avoid conflict) ───── - @Get("by-reference/:reference") - @ApiOperation({ - summary: "Get a freight booking by reference number", - description: "Lookup booking by its human-readable reference string.", - }) - findByReference(@Param("reference") reference: string) { - return this.bookingsService.findByReference(reference); + @Get('by-reference/:reference') + @ApiOperation({ summary: 'Get booking by reference' }) + async findByReference(@Param('reference') reference: string) { + const booking = await this.bookingsService.findByReference(reference); + return this.transitionService.enrichBookingResponse(booking); } - // ── 4. Get single booking by ID ─────────────────────────────────────── - @Get(":id") - @ApiOperation({ summary: "Get a freight booking by ID" }) - findOne(@Param("id", ParseUUIDPipe) id: string) { - return this.bookingsService.findById(id); + @Get(':id') + @ApiOperation({ summary: 'Get booking by ID' }) + async findOne(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.bookingsService.findById(id); + return this.transitionService.enrichBookingResponse(booking); } - // ── 6. Soft-delete (DRAFT only) ─────────────────────────────────────── - @Delete(":id") + @Delete(':id') @HttpCode(204) - @ApiOperation({ - summary: "Soft-delete a freight booking", - description: "Only DRAFT bookings can be deleted.", - }) - remove(@Param("id", ParseUUIDPipe) id: string) { + @ApiOperation({ summary: 'Soft-delete DRAFT booking' }) + remove(@Param('id', ParseUUIDPipe) id: string) { return this.bookingsService.remove(id); } - // ── 7. Unified status transition ────────────────────────────────────── - @Patch(":id/status") - @ApiOperation({ - summary: "Transition booking status", - description: - "Unified endpoint for all status transitions. Actions: " + - "SUBMIT, APPROVE_STAFF, APPROVE_DIRECTOR, APPROVE_CEO, REJECT, CANCEL, ACTIVATE, EXPIRE. " + - "Approval routing: Standard → LINE_STAFF → DIRECTOR → SIGNED. " + - "Bulk/high-volume → DIRECTOR → CEO → SIGNED.", - }) - updateStatus( - @Param("id", ParseUUIDPipe) id: string, - @Body() dto: UpdateStatusDto, - ) { - return this.bookingsService.updateStatus(id, dto); + @Post(':id/generate-price') + @ApiOperation({ summary: 'Generate price preview (DRAFT only)' }) + @ApiOkResponse({ type: GeneratePriceResponseDto }) + generatePrice(@Param('id', ParseUUIDPipe) id: string) { + return this.pricingService.generatePrice(id); } - // ── 8. Request or auto-pair consolidation ───────────────────────────── - @Post(":id/consolidation") - @ApiOperation({ - summary: "Request freight consolidation", - description: - "Searches for a partner whose container quantity complements yours to fill whole wagon(s) " + - "(same route, same container type). Pairs on match or sets PENDING_CONSOLIDATION with a status message.", - }) - requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { + @Post(':id/submit') + @ApiOperation({ summary: 'Customer submit booking' }) + async submit(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.transitionService.submit(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/staff/request-changes') + @ApiOperation({ summary: 'Staff return booking for customer updates' }) + async requestChanges( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RequestChangesDto, + ) { + const booking = await this.transitionService.requestChanges( + id, + dto.note, + dto.actorId, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/staff/accept') + @ApiOperation({ summary: 'Staff accept intake → start approval chain' }) + async acceptIntake( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: StaffAcceptDto, + ) { + const booking = await this.transitionService.acceptIntake(id, dto.actorId); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/staff/reject') + @ApiOperation({ summary: 'Staff final reject' }) + async staffReject( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: StaffRejectDto, + ) { + const booking = await this.transitionService.staffReject( + id, + dto.reason, + dto.actorId, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/approval-steps/:stepId/approve') + @ApiOperation({ summary: 'Approve one approval step in sequence' }) + async approveStep( + @Param('id', ParseUUIDPipe) id: string, + @Param('stepId', ParseUUIDPipe) stepId: string, + @Body() dto: ApproveStepDto, + ) { + const booking = await this.transitionService.approveStep( + id, + stepId, + dto.actorId, + dto.requiredRole, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/approval-steps/:stepId/reject') + @ApiOperation({ summary: 'Reject at approval step' }) + async rejectStep( + @Param('id', ParseUUIDPipe) id: string, + @Param('stepId', ParseUUIDPipe) stepId: string, + @Body() dto: RejectStepDto, + ) { + const booking = await this.transitionService.rejectStep( + id, + stepId, + dto.actorId, + dto.reason, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/contract/generate') + @ApiOperation({ summary: 'Generate contract document' }) + async generateContract(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.contractService.generateContract(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Get(':id/contract') + @ApiOperation({ summary: 'Download contract file' }) + async downloadContract( + @Param('id', ParseUUIDPipe) id: string, + @Res({ passthrough: true }) res: Response, + ) { + const { stream, record } = await this.contractService.streamContract(id); + res.set({ + 'Content-Type': record.mimeType ?? 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${record.name}"`, + }); + return new StreamableFile(stream); + } + + @Get(':id/summary') + @ApiOperation({ summary: 'Contract summary string for dashboard' }) + getSummary(@Param('id', ParseUUIDPipe) id: string) { + return this.contractService.getSummary(id); + } + + @Post(':id/customer/sign') + @ApiOperation({ summary: 'Customer digital signature' }) + async customerSign(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.transitionService.customerSign(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/marketing/approve') + @ApiOperation({ summary: 'Marketing verify and fully execute' }) + async marketingApprove( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: MarketingApproveDto, + ) { + const booking = await this.transitionService.marketingApprove( + id, + dto.actorId, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/payment/pnr') + @ApiOperation({ summary: 'Generate PNR code (ETB)' }) + async generatePnr(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.paymentService.generatePnr(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/payment/proof') + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Upload USD payment proof' }) + async submitPaymentProof( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + const file = files?.[0]; + const booking = await this.paymentService.submitPaymentProof(id, file); + return this.transitionService.enrichBookingResponse(booking); + } + + @Get(':id/payment/request-letter') + @ApiOperation({ summary: 'Download payment request letter (USD stub)' }) + @Header('Content-Type', 'text/plain') + async paymentRequestLetter( + @Param('id', ParseUUIDPipe) id: string, + @Res({ passthrough: true }) res: Response, + ) { + const { buffer, filename } = + await this.paymentService.getPaymentRequestLetter(id); + res.set('Content-Disposition', `attachment; filename="${filename}"`); + return new StreamableFile(buffer); + } + + @Post(':id/payment/verify') + @ApiOperation({ summary: 'Finance verify USD payment' }) + async verifyPayment(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.paymentService.verifyPayment(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/operations/start-transit') + @ApiOperation({ summary: 'Mark in transit' }) + async startTransit(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.transitionService.startTransit(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/operations/complete') + @ApiOperation({ summary: 'Mark completed' }) + async complete(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.transitionService.complete(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/cancel') + @ApiOperation({ summary: 'Cancel booking' }) + async cancel( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CancelBookingDto, + ) { + const booking = await this.transitionService.cancel(id, dto.reason); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/consolidation') + @ApiOperation({ summary: 'Request freight consolidation' }) + requestConsolidation(@Param('id', ParseUUIDPipe) id: string) { return this.bookingsService.requestConsolidation(id); } - // ── 9. Remove consolidation pairing ─────────────────────────────────── - @Delete(":id/consolidation") - @ApiOperation({ - summary: "Remove consolidation pairing", - description: - "Unpairs both bookings and returns them to PENDING_CONSOLIDATION status.", - }) - removeConsolidation(@Param("id", ParseUUIDPipe) id: string) { + @Delete(':id/consolidation') + @ApiOperation({ summary: 'Remove consolidation pairing' }) + removeConsolidation(@Param('id', ParseUUIDPipe) id: string) { return this.bookingsService.removeConsolidation(id); } - // ── 10. Get consolidation details ───────────────────────────────────── - @Get(":id/consolidation") - @ApiOperation({ - summary: "Get consolidation details", - description: - "Returns partner booking details and split billing information.", - }) - getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) { + @Get(':id/consolidation') + @ApiOperation({ summary: 'Get consolidation details' }) + getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) { return this.bookingsService.getConsolidationDetails(id); } - } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 9785ec270..f3b3a9360 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -5,15 +5,21 @@ import { CustomersModule } from '../customers/customers.module'; import { FilesModule } from '../files/files.module'; import { MinioModule } from '../minio/minio.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; +import { BookingContractService } from './booking-contract.service'; +import { BookingPaymentService } from './booking-payment.service'; +import { BookingPricingService } from './booking-pricing.service'; import { BookingReferenceDataService } from './booking-reference-data.service'; +import { BookingTransitionService } from './booking-transition.service'; import { BookingsController } from './bookings.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; import { BookingsService } from './bookings.service'; +import { PaymentsWebhookController } from './payments-webhook.controller'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingContainer } from './entities/booking-container.entity'; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; +import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; @Module({ @@ -24,18 +30,23 @@ import { Booking } from './entities/booking.entity'; BookingCargoModifier, BookingApprovalStep, BookingRateSnapshot, + BookingReviewNote, ]), FilesModule, MinioModule, CustomersModule, RuleEngineModule, ], - controllers: [BookingsController], + controllers: [BookingsController, PaymentsWebhookController], providers: [ BookingsService, BookingsRepository, ConsolidationService, BookingReferenceDataService, + BookingPricingService, + BookingTransitionService, + BookingContractService, + BookingPaymentService, ], exports: [BookingsService], }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index f329c942f..3a6155f3b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1,13 +1,14 @@ import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, Repository } from 'typeorm'; +import { DataSource, FindOptionsWhere, Repository } from 'typeorm'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingContainer } from './entities/booking-container.entity'; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; +import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; import { FileRecord } from '../files/entities/file.entity'; import { ContainerWeightResult } from '../rule-engine/rule-engine.service'; @@ -66,6 +67,7 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.approvalSteps', 'steps') .leftJoinAndSelect('booking.rateSnapshots', 'snapshots') .leftJoinAndSelect('booking.cargoModifiers', 'modifiers') + .leftJoinAndSelect('booking.reviewNotes', 'reviewNotes') .where('booking.id = :id', { id }) .leftJoinAndMapMany( 'booking.files', @@ -216,15 +218,35 @@ export class BookingsRepository extends BaseRepository { await this.dataSource.getRepository(BookingContainer).delete({ bookingId }); } - /** Get pending approval step for a role. */ + /** Lowest-order pending approval step (sequential enforcement). */ + async findNextPendingApprovalStep( + bookingId: string, + ): Promise { + return this.dataSource.getRepository(BookingApprovalStep).findOne({ + where: { bookingId, status: 'PENDING' }, + order: { stepOrder: 'ASC' }, + relations: ['approvalRule'], + }); + } + + async findApprovalStepById( + bookingId: string, + stepId: string, + ): Promise { + return this.dataSource.getRepository(BookingApprovalStep).findOne({ + where: { bookingId, id: stepId }, + relations: ['approvalRule'], + }); + } + + /** Get pending approval step for a role (must match next in sequence). */ async findPendingApprovalStep( bookingId: string, requiredRole: string, ): Promise { - return this.dataSource.getRepository(BookingApprovalStep).findOne({ - where: { bookingId, requiredRole, status: 'PENDING' }, - order: { stepOrder: 'ASC' }, - }); + const next = await this.findNextPendingApprovalStep(bookingId); + if (!next || next.requiredRole !== requiredRole) return null; + return next; } /** Mark an approval step complete. */ @@ -277,4 +299,85 @@ export class BookingsRepository extends BaseRepository { where: { bookingId, rateId }, }); } + + async createReviewNote( + bookingId: string, + note: string, + type: ReviewNoteType, + authorId?: string, + ): Promise { + const repo = this.dataSource.getRepository(BookingReviewNote); + return repo.save( + repo.create({ bookingId, note, type, authorId: authorId ?? null }), + ); + } + + async findLatestReviewNote( + bookingId: string, + type?: ReviewNoteType, + ): Promise { + const repo = this.dataSource.getRepository(BookingReviewNote); + return repo.findOne({ + where: type ? { bookingId, type } : { bookingId }, + order: { createdAt: 'DESC' }, + }); + } + + async clearPricingArtifacts(bookingId: string): Promise { + await this.dataSource.getRepository(BookingCargoModifier).delete({ bookingId }); + await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId }); + } + + async findByPnrCode(pnrCode: string): Promise { + return this.repository.findOne({ where: { pnrCode } }); + } + + /** Queue listing with optional bulk exclusion for LINE_STAFF. */ + async findQueue(options: { + status: string | string[]; + page?: number; + pageSize?: number; + excludeBulk?: boolean; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + }): Promise<{ items: Booking[]; total: number }> { + const page = options.page ?? 1; + const pageSize = options.pageSize ?? 20; + const statuses = Array.isArray(options.status) ? options.status : [options.status]; + + const qb = this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.customer', 'customer') + .leftJoinAndSelect('booking.cargoType', 'cargo') + .leftJoinAndSelect('booking.serviceType', 'serviceType') + .where('booking.status IN (:...statuses)', { statuses }); + + if (options.excludeBulk) { + qb.andWhere('cargo.requires_director_approval = false'); + } + + const sortField = + options.sortBy === 'priorityScore' ? 'booking.priority_score' : 'booking.created_at'; + qb.orderBy(sortField, options.sortOrder ?? 'DESC'); + + const [items, total] = await qb + .skip((page - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + + return { items, total }; + } + + async findAndCountFiltered(where: FindOptionsWhere, options: { + skip: number; + take: number; + order: Record; + }): Promise<[Booking[], number]> { + return this.repository.findAndCount({ + where, + skip: options.skip, + take: options.take, + order: options.order, + }); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 3aaa12a70..1039e60c1 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -19,7 +19,7 @@ import { ConsolidationService } from './consolidation.service'; import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto'; import { FilterBookingDto } from './dto/filter-booking.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; -import { UpdateStatusDto } from './dto/update-status.dto'; +import { CUSTOMER_EDITABLE_STATUSES } from './entities/booking.entity'; import { Booking } from './entities/booking.entity'; import { FileRecord } from '../files/entities/file.entity'; @@ -242,8 +242,10 @@ export class BookingsService { files: Express.Multer.File[], ): Promise<{ booking: Booking; warnings: string[] }> { const existing = await this.findById(id); - if (existing.status !== 'DRAFT') { - throw new BadRequestException('Only DRAFT bookings can be updated'); + if (!CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) { + throw new BadRequestException( + 'Only DRAFT or CHANGES_REQUESTED bookings can be updated', + ); } const warnings: string[] = []; @@ -390,201 +392,32 @@ export class BookingsService { await this.bookingsRepository.softDelete(id); } - /** Unified status transition handler. */ - async updateStatus(id: string, dto: UpdateStatusDto): Promise { - const booking = await this.findById(id); - const { action, actorId, reason, requiredRole } = dto; + async findQueue( + queue: string, + filter: FilterBookingDto, + options?: { excludeBulk?: boolean }, + ): Promise<{ items: Booking[]; total: number }> { + const statusMap: Record = { + intake: 'SUBMITTED', + approval: 'PENDING_APPROVAL', + signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'], + marketing: 'SIGNED_CUSTOMER', + finance: 'PAYMENT_VERIFICATION_IN_PROGRESS', + }; - switch (action) { - case 'SUBMIT': - return this.handleSubmit(booking); - case 'SEND_QUOTATION': - return this.handleSendQuotation(booking); - case 'APPROVE_QUOTATION': - return this.handleApproveQuotation(booking); - case 'REJECT_QUOTATION': - return this.handleRejectQuotation(booking, reason); - case 'APPROVE_STEP': - return this.handleApproveStep(booking, actorId, requiredRole); - case 'APPROVE': - return this.handleFullyApproved(booking); - case 'CUSTOMER_SIGN': - return this.handleCustomerSign(booking); - case 'MARK_FULLY_EXECUTED': - return this.handleFullyExecuted(booking); - case 'MARK_PAID': - return this.handleMarkPaid(booking); - case 'START_TRANSIT': - return this.handleStartTransit(booking); - case 'COMPLETE': - return this.handleComplete(booking); - case 'REJECT': - return this.handleReject(booking, actorId, reason); - case 'CANCEL': - return this.handleCancel(booking, reason); - default: - throw new BadRequestException(`Unknown action: ${action}`); - } - } - - /** SUBMIT: DRAFT → RFQ_SUBMITTED → PENDING_APPROVAL with approval steps and rate snapshots. */ - private async handleSubmit(booking: Booking): Promise { - this.assertStatus(booking, ['DRAFT']); - - await this.bookingsRepository.update(booking.id, { status: 'RFQ_SUBMITTED' } as never); - await this.ruleEngineService.snapshotLiveRates(booking.id); - await this.ruleEngineService.instantiateApprovalSteps(booking.id, booking.cargoTypeId); - - const updated = await this.bookingsRepository.update(booking.id, { - status: 'PENDING_APPROVAL', - } as never); - return updated!; - } - - private async handleSendQuotation(booking: Booking): Promise { - this.assertStatus(booking, ['RFQ_SUBMITTED']); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'QUOTATION_SENT', - } as never); - return updated!; - } - - private async handleApproveQuotation(booking: Booking): Promise { - this.assertStatus(booking, ['QUOTATION_SENT']); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'QUOTATION_APPROVED', - } as never); - return updated!; - } - - private async handleRejectQuotation(booking: Booking, reason?: string): Promise { - this.assertStatus(booking, ['QUOTATION_SENT']); - if (!reason) throw new BadRequestException('reason is required for REJECT_QUOTATION'); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'QUOTATION_REJECTED', - } as never); - return updated!; - } - - private async handleApproveStep( - booking: Booking, - actorId?: string, - requiredRole?: string, - ): Promise { - this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']); - if (!actorId || !requiredRole) { - throw new BadRequestException('actorId and requiredRole are required for APPROVE_STEP'); + const status = statusMap[queue]; + if (!status) { + throw new BadRequestException(`Unknown queue: ${queue}`); } - const step = await this.bookingsRepository.findPendingApprovalStep( - booking.id, - requiredRole, - ); - if (!step) { - throw new BadRequestException(`No pending approval step for role ${requiredRole}`); - } - - await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); - - const allDone = await this.bookingsRepository.allApprovalStepsComplete(booking.id); - if (allDone) { - const updated = await this.bookingsRepository.update(booking.id, { - status: 'APPROVED', - } as never); - return updated!; - } - - return this.findById(booking.id); - } - - private async handleFullyApproved(booking: Booking): Promise { - this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'APPROVED', - } as never); - return updated!; - } - - private async handleCustomerSign(booking: Booking): Promise { - this.assertStatus(booking, ['APPROVED']); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'SIGNED_CUSTOMER', - customerSignedAt: new Date(), - } as never); - return updated!; - } - - private async handleFullyExecuted(booking: Booking): Promise { - this.assertStatus(booking, ['SIGNED_CUSTOMER']); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'FULLY_EXECUTED', - fullyExecutedAt: new Date(), - } as never); - return updated!; - } - - private async handleMarkPaid(booking: Booking): Promise { - this.assertStatus(booking, ['FULLY_EXECUTED', 'APPROVED', 'SIGNED_CUSTOMER']); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'PAID', - paymentStatus: 'PAID', - } as never); - return updated!; - } - - private async handleStartTransit(booking: Booking): Promise { - this.assertStatus(booking, ['PAID']); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'IN_TRANSIT', - } as never); - return updated!; - } - - private async handleComplete(booking: Booking): Promise { - this.assertStatus(booking, ['IN_TRANSIT']); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'COMPLETED', - endDate: new Date(), - } as never); - return updated!; - } - - private async handleReject( - booking: Booking, - actorId?: string, - reason?: string, - ): Promise { - this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']); - if (!actorId || !reason) { - throw new BadRequestException('actorId and reason are required for REJECT'); - } - const updated = await this.bookingsRepository.update(booking.id, { - status: 'CANCELLED', - } as never); - return updated!; - } - - private async handleCancel(booking: Booking, reason?: string): Promise { - this.assertStatus(booking, [ - 'DRAFT', - 'RFQ_SUBMITTED', - 'QUOTATION_SENT', - 'QUOTATION_APPROVED', - 'PENDING_APPROVAL', - ]); - if (!reason) throw new BadRequestException('reason is required for CANCEL'); - const updated = await this.bookingsRepository.update(booking.id, { - status: 'CANCELLED', - } as never); - return updated!; - } - - private assertStatus(booking: Booking, allowed: string[]): void { - if (!allowed.includes(booking.status)) { - throw new ConflictException( - `Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`, - ); - } + return this.bookingsRepository.findQueue({ + status, + page: filter.page, + pageSize: filter.pageSize, + excludeBulk: options?.excludeBulk ?? queue === 'approval', + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + }); } async requestConsolidation(id: string): Promise<{ diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts new file mode 100644 index 000000000..3474bec74 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PriceLineItemDto { + @ApiProperty() + code!: string; + + @ApiProperty() + description!: string; + + @ApiProperty() + amount!: number; + + @ApiProperty() + currency!: string; +} + +export class GeneratePriceResponseDto { + @ApiProperty() + bookingId!: string; + + @ApiProperty() + totalAmount!: number; + + @ApiProperty() + currency!: string; + + @ApiProperty({ type: [PriceLineItemDto] }) + lineItems!: PriceLineItemDto[]; + + @ApiProperty({ type: [String] }) + warnings!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts new file mode 100644 index 000000000..8e77b51fc --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -0,0 +1,74 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator'; + +export class RequestChangesDto { + @ApiProperty({ description: 'Staff note explaining what the customer must fix' }) + @IsString() + @MinLength(1) + note!: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + actorId?: string; +} + +export class StaffAcceptDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + actorId?: string; +} + +export class MarketingApproveDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + actorId?: string; +} + +export class StaffRejectDto { + @ApiProperty() + @IsString() + @MinLength(1) + reason!: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + actorId?: string; +} + +export class ApproveStepDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + actorId!: string; + + @ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' }) + @IsString() + requiredRole!: string; +} + +export class RejectStepDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + actorId!: string; + + @ApiProperty() + @IsString() + @MinLength(1) + reason!: string; +} + +export class CancelBookingDto { + @ApiProperty() + @IsString() + @MinLength(1) + reason!: string; +} + +export class BankCallbackDto { + @ApiProperty() + @IsString() + pnrCode!: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts deleted file mode 100644 index a2d6c192d..000000000 --- a/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; - -const STATUS_ACTIONS = [ - 'SUBMIT', - 'SEND_QUOTATION', - 'APPROVE_QUOTATION', - 'REJECT_QUOTATION', - 'APPROVE_STEP', - 'APPROVE', - 'CUSTOMER_SIGN', - 'MARK_FULLY_EXECUTED', - 'MARK_PAID', - 'START_TRANSIT', - 'COMPLETE', - 'REJECT', - 'CANCEL', -] as const; - -export { STATUS_ACTIONS }; - -export class UpdateStatusDto { - @ApiProperty({ enum: STATUS_ACTIONS }) - @IsIn([...STATUS_ACTIONS]) - action!: string; - - @ApiPropertyOptional({ format: 'uuid', description: 'Staff/director/CEO actor' }) - @IsOptional() - @IsUUID() - actorId?: string; - - @ApiPropertyOptional({ description: 'Required role for APPROVE_STEP (LINE_STAFF, DIRECTOR, CEO)' }) - @IsOptional() - @IsString() - requiredRole?: string; - - @ApiPropertyOptional({ description: 'Required for REJECT, REJECT_QUOTATION, CANCEL' }) - @IsOptional() - @IsString() - reason?: string; -} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts new file mode 100644 index 000000000..af39a469c --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from './booking.entity'; + +export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION'] as const; +export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number]; + +@Entity({ schema: 'freight', name: 'booking_review_note' }) +@Index(['bookingId']) +export class BookingReviewNote extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, (b) => b.reviewNotes, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'author_id', type: 'uuid', nullable: true }) + authorId?: string | null; + + @Column({ name: 'note', type: 'text' }) + note!: string; + + @Column({ name: 'type', type: 'varchar', length: 30 }) + type!: ReviewNoteType; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 7875babbf..6a4140006 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -11,25 +11,47 @@ import { BookingApprovalStep } from './booking-approval-step.entity'; import { BookingCargoModifier } from './booking-cargo-modifier.entity'; import { BookingContainer } from './booking-container.entity'; import { BookingRateSnapshot } from './booking-rate-snapshot.entity'; +import { BookingReviewNote } from './booking-review-note.entity'; export const BOOKING_STATUSES = [ 'DRAFT', - 'RFQ_SUBMITTED', - 'QUOTATION_SENT', - 'QUOTATION_APPROVED', - 'QUOTATION_REJECTED', + '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 const PAYMENT_STATUSES = [ + 'PENDING', + 'PNR_GENERATED', + 'VERIFICATION_IN_PROGRESS', + 'PAID', + 'FAILED', +] as const; + +export type PaymentStatus = (typeof PAYMENT_STATUSES)[number]; + +/** Statuses where the customer may edit booking fields. */ +export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [ + 'DRAFT', + 'CHANGES_REQUESTED', +]; + @Entity({ schema: 'freight', name: 'bookings' }) export class Booking extends BaseEntity { @Column({ name: 'reference', type: 'varchar', length: 64, unique: true }) @@ -169,6 +191,18 @@ export class Booking extends BaseEntity { @Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true }) fullyExecutedAt?: Date | null; + @Column({ name: 'marketing_approved_by_id', type: 'uuid', nullable: true }) + marketingApprovedById?: string | null; + + @Column({ name: 'marketing_approved_at', type: 'timestamptz', nullable: true }) + marketingApprovedAt?: Date | null; + + @Column({ name: 'contract_summary', type: 'text', nullable: true }) + contractSummary?: string | null; + + @Column({ name: 'locked_at', type: 'timestamptz', nullable: true }) + lockedAt?: Date | null; + @Column({ name: 'priority_score', type: 'int', default: 0 }) priorityScore!: number; @@ -194,6 +228,9 @@ export class Booking extends BaseEntity { @OneToMany(() => BookingRateSnapshot, (s) => s.booking) rateSnapshots?: BookingRateSnapshot[]; + @OneToMany(() => BookingReviewNote, (n) => n.booking) + reviewNotes?: BookingReviewNote[]; + @OneToMany(() => FileRecord, (file) => file.resourceId, { createForeignKeyConstraints: false, }) diff --git a/apps/edr-freight-api/src/modules/bookings/payments-webhook.controller.ts b/apps/edr-freight-api/src/modules/bookings/payments-webhook.controller.ts new file mode 100644 index 000000000..d0f60f8df --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/payments-webhook.controller.ts @@ -0,0 +1,17 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { BookingPaymentService } from './booking-payment.service'; +import { BankCallbackDto } from './dto/request-changes.dto'; + +@ApiTags('payments') +@Controller('webhooks/payments') +export class PaymentsWebhookController { + constructor(private readonly paymentService: BookingPaymentService) {} + + @Post('bank') + @ApiOperation({ summary: 'Bank payment callback (stub)' }) + bankCallback(@Body() dto: BankCallbackDto) { + return this.paymentService.handleBankCallback(dto.pnrCode); + } +} From dc9244d66ec713fab47cc9f0998e4a3bb4cd7aa8 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Wed, 3 Jun 2026 16:01:07 +0300 Subject: [PATCH 08/20] feat(core): Introduce company and external profile management with dedicated API, services, and data schema --- apps/edr-freight-api/src/app.module.ts | 2 + .../1749200000000-CreateCompaniesModule.ts | 115 +++++++++++++ .../modules/companies/companies.controller.ts | 158 ++++++++++++++++++ .../src/modules/companies/companies.module.ts | 18 ++ .../modules/companies/companies.repository.ts | 35 ++++ .../modules/companies/companies.service.ts | 156 +++++++++++++++++ .../dto/company-info-response.dto.ts | 14 ++ .../dto/create-company-with-profile.dto.ts | 58 +++++++ .../companies/dto/create-company.dto.ts | 59 +++++++ .../dto/create-external-profile.dto.ts | 44 +++++ .../companies/dto/create-ff-client.dto.ts | 24 +++ .../companies/dto/response-company.dto.ts | 42 +++++ .../dto/response-external-profile.dto.ts | 31 ++++ .../companies/dto/response-ff-client.dto.ts | 23 +++ .../companies/dto/update-company.dto.ts | 4 + .../dto/update-external-profile.dto.ts | 4 + .../companies/dto/update-ff-client.dto.ts | 4 + .../companies/entities/company.entity.ts | 64 +++++++ .../entities/external-profile.entity.ts | 39 +++++ .../companies/entities/ff-client.entity.ts | 37 ++++ .../companies/external-profile.repository.ts | 30 ++++ .../modules/companies/ff-client.repository.ts | 32 ++++ 22 files changed, 993 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1749200000000-CreateCompaniesModule.ts create mode 100644 apps/edr-freight-api/src/modules/companies/companies.controller.ts create mode 100644 apps/edr-freight-api/src/modules/companies/companies.module.ts create mode 100644 apps/edr-freight-api/src/modules/companies/companies.repository.ts create mode 100644 apps/edr-freight-api/src/modules/companies/companies.service.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/update-company.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/update-external-profile.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/entities/company.entity.ts create mode 100644 apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts create mode 100644 apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts create mode 100644 apps/edr-freight-api/src/modules/companies/external-profile.repository.ts create mode 100644 apps/edr-freight-api/src/modules/companies/ff-client.repository.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 0fcc7a546..a4ecf321e 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -14,6 +14,7 @@ import { FilesModule } from "./modules/files/files.module"; import { ConsignmentsModule } from "./modules/consignments/consignments.module"; import { TrainsModule } from "./modules/trains/trains.module"; import { CustomersModule } from "./modules/customers/customers.module"; +import { CompaniesModule } from "./modules/companies/companies.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; @@ -59,6 +60,7 @@ import { DemoUsersSeeder } from "./seed/demo-users.seeder"; ConsignmentsModule, TrainsModule, CustomersModule, + CompaniesModule, TrackingModule, BillingModule, NotificationsModule, diff --git a/apps/edr-freight-api/src/migrations/1749200000000-CreateCompaniesModule.ts b/apps/edr-freight-api/src/migrations/1749200000000-CreateCompaniesModule.ts new file mode 100644 index 000000000..c02c9fb5b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749200000000-CreateCompaniesModule.ts @@ -0,0 +1,115 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex, TableUnique } from 'typeorm'; + +export class CreateCompaniesModule1749200000000 implements MigrationInterface { + name = 'CreateCompaniesModule1749200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'companies', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'name', type: 'varchar', length: '200' }, + { name: 'type', type: 'varchar', length: '32' }, + { name: 'status', type: 'varchar', length: '32', default: "'pending'" }, + { name: 'tin', type: 'varchar', length: '10', isUnique: true }, + { name: 'vat_number', type: 'varchar', length: '50', isNullable: true }, + { name: 'business_license', type: 'varchar', length: '100', isNullable: true }, + { name: 'fan_number', type: 'varchar', length: '16', isNullable: true }, + { name: 'country', type: 'varchar', length: '32', default: "'Ethiopia'" }, + { name: 'address', type: 'text', isNullable: true }, + { name: 'phone', type: 'varchar', length: '20', isNullable: true }, + { name: 'email', type: 'varchar', length: '150', isNullable: true }, + { name: 'website', type: 'varchar', length: '200', isNullable: true }, + { name: 'attributes', type: 'jsonb', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'external_profiles', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'user_id', type: 'uuid' }, + { name: 'company_id', type: 'uuid' }, + { name: 'first_name', type: 'varchar', length: '100' }, + { name: 'last_name', type: 'varchar', length: '100' }, + { name: 'email', type: 'varchar', length: '150', isUnique: true }, + { name: 'phone', type: 'varchar', length: '20', isNullable: true }, + { name: 'national_id', type: 'varchar', length: '50', isNullable: true }, + { name: 'job_title', type: 'varchar', length: '100', isNullable: true }, + { name: 'is_primary_contact', type: 'boolean', default: false }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['company_id'], + referencedTableName: 'companies', + referencedSchema: 'freight', + referencedColumnNames: ['id'], + }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'ff_clients', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'forwarder_company_id', type: 'uuid' }, + { name: 'client_company_id', type: 'uuid' }, + { name: 'relationship_type', type: 'varchar', length: '32', default: "'managed_account'" }, + { name: 'can_book_on_behalf', type: 'boolean', default: true }, + { name: 'can_view_documents', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['forwarder_company_id'], + referencedTableName: 'companies', + referencedSchema: 'freight', + referencedColumnNames: ['id'], + }, + { + columnNames: ['client_company_id'], + referencedTableName: 'companies', + referencedSchema: 'freight', + referencedColumnNames: ['id'], + }, + ], + }), + true, + ); + + await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['tin'] })); + await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['type'] })); + await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['user_id'] })); + await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['company_id'] })); + await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['forwarder_company_id'] })); + await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['client_company_id'] })); + + await queryRunner.createUniqueConstraint('freight.ff_clients', new TableUnique({ + columnNames: ['forwarder_company_id', 'client_company_id'], + })); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.ff_clients'); + await queryRunner.dropTable('freight.external_profiles'); + await queryRunner.dropTable('freight.companies'); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts new file mode 100644 index 000000000..5c646279e --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -0,0 +1,158 @@ +import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import { CompaniesService } from './companies.service'; +import { CreateCompanyDto } from './dto/create-company.dto'; +import { UpdateCompanyDto } from './dto/update-company.dto'; +import { CreateExternalProfileDto } from './dto/create-external-profile.dto'; +import { CreateFFClientDto } from './dto/create-ff-client.dto'; +import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto'; +import { ResponseCompanyDto } from './dto/response-company.dto'; +import { ResponseExternalProfileDto } from './dto/response-external-profile.dto'; +import { ResponseFFClientDto } from './dto/response-ff-client.dto'; +import { CompanyInfoResponseDto } from './dto/company-info-response.dto'; + +interface CurrentIamUser { + id: string; + name?: { en: string; am: string }; + email?: string; + phoneNumber?: string; +} + +@ApiTags('Companies') +@Controller('companies') +export class CompaniesController { + constructor(private readonly companiesService: CompaniesService) {} + + @Get('getInfo') + @ApiOperation({ summary: 'Get company info for the current user' }) + async getInfo(@CurrentUser() user: CurrentIamUser): Promise { + const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); + return new CompanyInfoResponseDto(profile, company); + } + + @Post('create') + @ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' }) + async createWithProfile( + @CurrentUser() user: CurrentIamUser, + @Body() dto: CreateCompanyWithProfileDto, + ): Promise { + const nameParts = (user.name?.en ?? '').split(' '); + const { profile, company } = await this.companiesService.createCompanyWithProfile( + { + userId: user.id, + firstName: nameParts[0] || '', + lastName: nameParts.slice(-1)[0] || '', + email: user.email ?? '', + phone: user.phoneNumber ?? '', + }, + dto, + ); + return new CompanyInfoResponseDto(profile, company); + } + + @Post() + @ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' }) + async create(@Body() dto: CreateCompanyDto): Promise { + const company = await this.companiesService.createCompany(dto); + return new ResponseCompanyDto(company); + } + + @Get() + @ApiOperation({ summary: 'List all companies' }) + async findAll(): Promise { + const companies = await this.companiesService.findAllCompanies(); + return companies.map((c) => new ResponseCompanyDto(c)); + } + + @Get('type/:type') + @ApiOperation({ summary: 'Find companies by type' }) + async findByType(@Param('type') type: string): Promise { + const companies = await this.companiesService.findAllCompanies(); + return companies.filter((c) => c.type === type).map((c) => new ResponseCompanyDto(c)); + } + + @Get('search') + @ApiOperation({ summary: 'Search companies by name' }) + async search(@Query('name') name: string): Promise { + const companies = await this.companiesService.findAllCompanies(); + return companies + .filter((c) => c.name.toLowerCase().includes(name.toLowerCase())) + .map((c) => new ResponseCompanyDto(c)); + } + + @Get(':id') + @ApiOperation({ summary: 'Get company by ID' }) + async findById(@Param('id', ParseUUIDPipe) id: string): Promise { + const company = await this.companiesService.findCompanyById(id); + return new ResponseCompanyDto(company); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a company' }) + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateCompanyDto, + ): Promise { + const company = await this.companiesService.updateCompany(id, dto); + return new ResponseCompanyDto(company); + } + + @Delete(':id') + @ApiOperation({ summary: 'Soft-delete a company' }) + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@Param('id', ParseUUIDPipe) id: string): Promise { + await this.companiesService.deleteCompany(id); + } + + @Post(':companyId/profiles') + @ApiOperation({ summary: 'Add a profile (employee) to a company' }) + async createProfile( + @Param('companyId', ParseUUIDPipe) companyId: string, + @Body() dto: CreateExternalProfileDto, + ): Promise { + const profile = await this.companiesService.createProfile({ ...dto, companyId }); + return new ResponseExternalProfileDto(profile); + } + + @Get(':companyId/profiles') + @ApiOperation({ summary: 'List profiles for a company' }) + async listProfiles( + @Param('companyId', ParseUUIDPipe) companyId: string, + ): Promise { + const profiles = await this.companiesService.findProfilesByCompany(companyId); + return profiles.map((p) => new ResponseExternalProfileDto(p)); + } + + @Get('profile/user/:userId') + @ApiOperation({ summary: 'Get profile by IAM user ID' }) + async findProfileByUser( + @Param('userId', ParseUUIDPipe) userId: string, + ): Promise { + const profile = await this.companiesService.findProfileByUserId(userId); + return new ResponseExternalProfileDto(profile); + } + + @Post('ff-clients') + @ApiOperation({ summary: 'Link a forwarder to a client company' }) + async createFFClient(@Body() dto: CreateFFClientDto): Promise { + const client = await this.companiesService.createFFClient(dto); + return new ResponseFFClientDto(client); + } + + @Get(':forwarderCompanyId/clients') + @ApiOperation({ summary: 'List clients of a forwarder' }) + async listFFClients( + @Param('forwarderCompanyId', ParseUUIDPipe) forwarderCompanyId: string, + ): Promise { + const clients = await this.companiesService.findForwarderClients(forwarderCompanyId); + return clients.map((c) => new ResponseFFClientDto(c)); + } + + @Delete('ff-clients/:id') + @ApiOperation({ summary: 'Remove a forwarder-client relationship' }) + @HttpCode(HttpStatus.NO_CONTENT) + async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise { + await this.companiesService.deleteFFClient(id); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts new file mode 100644 index 000000000..8fac2f901 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CompaniesController } from './companies.controller'; +import { CompaniesService } from './companies.service'; +import { CompaniesRepository } from './companies.repository'; +import { ExternalProfileRepository } from './external-profile.repository'; +import { FFClientRepository } from './ff-client.repository'; +import { Company } from './entities/company.entity'; +import { ExternalProfile } from './entities/external-profile.entity'; +import { FFClient } from './entities/ff-client.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient])], + controllers: [CompaniesController], + providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository], + exports: [CompaniesService], +}) +export class CompaniesModule {} diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts new file mode 100644 index 000000000..1156823f7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Company } from './entities/company.entity'; + +@Injectable() +export class CompaniesRepository extends BaseRepository { + constructor( + @InjectRepository(Company) + repo: Repository, + ) { + super(repo); + } + + async findByTin(tin: string): Promise { + return this.repository.findOne({ where: { tin } as any }); + } + + async findByType(type: string): Promise { + return this.repository.find({ where: { type } as any, order: { name: 'ASC' } }); + } + + async findByName(name: string): Promise { + return this.repository + .createQueryBuilder('company') + .where('company.name ILIKE :name', { name: `%${name}%` }) + .getMany(); + } + + async existsByTin(tin: string): Promise { + const count = await this.repository.count({ where: { tin } as any }); + return count > 0; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts new file mode 100644 index 000000000..03c104798 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -0,0 +1,156 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { CompaniesRepository } from './companies.repository'; +import { ExternalProfileRepository } from './external-profile.repository'; +import { FFClientRepository } from './ff-client.repository'; +import { CreateCompanyDto } from './dto/create-company.dto'; +import { UpdateCompanyDto } from './dto/update-company.dto'; +import { CreateExternalProfileDto } from './dto/create-external-profile.dto'; +import { CreateFFClientDto } from './dto/create-ff-client.dto'; +import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto'; +import { Company } from './entities/company.entity'; +import { ExternalProfile } from './entities/external-profile.entity'; +import { FFClient } from './entities/ff-client.entity'; + +export interface UserIdentity { + userId: string; + firstName: string; + lastName: string; + email: string; + phone: string; +} + +@Injectable() +export class CompaniesService { + constructor( + private readonly companiesRepo: CompaniesRepository, + private readonly profilesRepo: ExternalProfileRepository, + private readonly ffClientsRepo: FFClientRepository, + ) {} + + async createCompany(dto: CreateCompanyDto): Promise { + const exists = await this.companiesRepo.existsByTin(dto.tin); + if (exists) { + throw new ConflictException(`Company with TIN ${dto.tin} already exists`); + } + return this.companiesRepo.create(dto); + } + + async createCompanyWithProfile(identity: UserIdentity, dto: CreateCompanyWithProfileDto): Promise<{ company: Company; profile: ExternalProfile }> { + if (dto.tin) { + const exists = await this.companiesRepo.existsByTin(dto.tin); + if (exists) { + throw new ConflictException(`Company with TIN ${dto.tin} already exists`); + } + } + + const existingProfile = await this.profilesRepo.findByEmail(identity.email); + if (existingProfile) { + throw new ConflictException(`Profile with email ${identity.email} already exists`); + } + + const company = await this.companiesRepo.create({ + name: dto.companyName, + type: dto.companyType, + tin: dto.tin ?? '', + vatNumber: dto.vatNumber ?? null, + businessLicense: dto.fanNumber ?? null, + fanNumber: dto.fanNumber ?? null, + country: dto.companyLocation ?? 'Ethiopia', + address: dto.companyAddress ?? null, + phone: dto.companyPhone ?? null, + email: dto.companyEmail ?? null, + attributes: dto.attributes ?? null, + }); + + const profile = await this.profilesRepo.create({ + userId: identity.userId, + companyId: company.id, + firstName: identity.firstName, + lastName: identity.lastName, + email: identity.email, + phone: identity.phone, + jobTitle: dto.jobTitle ?? null, + isPrimaryContact: dto.isPrimaryContact ?? true, + }); + + return { company, profile }; + } + + async findAllCompanies(): Promise { + return this.companiesRepo.findAll({ order: { name: 'ASC' as any } }); + } + + async findCompanyById(id: string): Promise { + const company = await this.companiesRepo.findById(id); + if (!company) throw new NotFoundException(`Company ${id} not found`); + return company; + } + + async getCompanyInfoByUserId(userId: string): Promise<{ profile: ExternalProfile; company: Company }> { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); + + const company = profile.company; + if (!company) throw new NotFoundException(`Company for profile ${profile.id} not found`); + + return { profile, company }; + } + + async updateCompany(id: string, dto: UpdateCompanyDto): Promise { + await this.findCompanyById(id); + const updated = await this.companiesRepo.update(id, dto); + if (!updated) throw new NotFoundException(`Company ${id} not found`); + return updated; + } + + async deleteCompany(id: string): Promise { + await this.findCompanyById(id); + await this.companiesRepo.softDelete(id); + } + + async createProfile(dto: CreateExternalProfileDto): Promise { + await this.findCompanyById(dto.companyId); + + const existing = await this.profilesRepo.findByEmail(dto.email); + if (existing) { + throw new ConflictException(`Profile with email ${dto.email} already exists`); + } + + return this.profilesRepo.create(dto); + } + + async findProfileByUserId(userId: string): Promise { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); + return profile; + } + + async findProfilesByCompany(companyId: string): Promise { + return this.profilesRepo.findByCompanyId(companyId); + } + + async createFFClient(dto: CreateFFClientDto): Promise { + await this.findCompanyById(dto.forwarderCompanyId); + await this.findCompanyById(dto.clientCompanyId); + + const existing = await this.ffClientsRepo.findRelationship( + dto.forwarderCompanyId, + dto.clientCompanyId, + ); + if (existing) { + throw new ConflictException('This forwarder-client relationship already exists'); + } + + return this.ffClientsRepo.create(dto); + } + + async findForwarderClients(forwarderCompanyId: string): Promise { + return this.ffClientsRepo.findByForwarder(forwarderCompanyId); + } + + async deleteFFClient(id: string): Promise { + const client = await this.ffClientsRepo.findById(id); + if (!client) throw new NotFoundException(`FFClient ${id} not found`); + await this.ffClientsRepo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts new file mode 100644 index 000000000..f6ffb8296 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -0,0 +1,14 @@ +import { Company } from '../entities/company.entity'; +import { ExternalProfile } from '../entities/external-profile.entity'; +import { ResponseCompanyDto } from './response-company.dto'; +import { ResponseExternalProfileDto } from './response-external-profile.dto'; + +export class CompanyInfoResponseDto { + profile: ResponseExternalProfileDto; + company: ResponseCompanyDto; + + constructor(profile: ExternalProfile, company: Company) { + this.profile = new ResponseExternalProfileDto(profile); + this.company = new ResponseCompanyDto(company); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts new file mode 100644 index 000000000..4287676d6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -0,0 +1,58 @@ +import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum } from 'class-validator'; +import { CompanyType } from '../entities/company.entity'; + +export class CreateCompanyWithProfileDto { + @IsEnum(CompanyType) + companyType!: CompanyType; + + @IsString() + @IsNotEmpty() + @MaxLength(200) + companyName!: string; + + @IsOptional() + @IsEmail() + @MaxLength(150) + companyEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + companyPhone?: string; + + @IsOptional() + @IsString() + @MaxLength(32) + companyLocation?: string; + + @IsOptional() + @IsString() + companyAddress?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + tin?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + vatNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(16) + fanNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + jobTitle?: string; + + @IsOptional() + @IsBoolean() + isPrimaryContact?: boolean; + + @IsOptional() + attributes?: Record; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts new file mode 100644 index 000000000..b22334697 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -0,0 +1,59 @@ +import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator'; +import { CompanyType, CompanyStatus } from '../entities/company.entity'; + +export class CreateCompanyDto { + @IsString() + @IsNotEmpty() + @MaxLength(200) + name!: string; + + @IsEnum(CompanyType) + type!: CompanyType; + + @IsOptional() + @IsEnum(CompanyStatus) + status?: CompanyStatus; + + @IsString() + @IsNotEmpty() + @Length(10, 10) + @Matches(/^\d+$/, { message: 'TIN must contain only digits' }) + tin!: string; + + @IsOptional() + @IsString() + @MaxLength(50) + vatNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + businessLicense?: string; + + @IsOptional() + @IsString() + @MaxLength(32) + country?: string; + + @IsOptional() + @IsString() + address?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsEmail() + @MaxLength(150) + email?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + website?: string; + + @IsOptional() + attributes?: Record; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts new file mode 100644 index 000000000..c694a50e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts @@ -0,0 +1,44 @@ +import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator'; + +export class CreateExternalProfileDto { + @IsUUID() + @IsNotEmpty() + userId!: string; + + @IsUUID() + @IsNotEmpty() + companyId!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + firstName!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + lastName!: string; + + @IsEmail() + @IsNotEmpty() + email!: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + nationalId?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + jobTitle?: string; + + @IsOptional() + @IsBoolean() + isPrimaryContact?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts new file mode 100644 index 000000000..46375d7ea --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts @@ -0,0 +1,24 @@ +import { IsUUID, IsNotEmpty, IsOptional, IsBoolean, IsEnum } from 'class-validator'; +import { FFClientRelationship } from '../entities/ff-client.entity'; + +export class CreateFFClientDto { + @IsUUID() + @IsNotEmpty() + forwarderCompanyId!: string; + + @IsUUID() + @IsNotEmpty() + clientCompanyId!: string; + + @IsOptional() + @IsEnum(FFClientRelationship) + relationshipType?: FFClientRelationship; + + @IsOptional() + @IsBoolean() + canBookOnBehalf?: boolean; + + @IsOptional() + @IsBoolean() + canViewDocuments?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts new file mode 100644 index 000000000..1879e25d6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -0,0 +1,42 @@ +import { Company, CompanyType, CompanyStatus } from '../entities/company.entity'; +import { ResponseExternalProfileDto } from './response-external-profile.dto'; + +export class ResponseCompanyDto { + id: string; + name: string; + type: CompanyType; + status: CompanyStatus; + 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; + profiles?: ResponseExternalProfileDto[]; + createdAt: Date; + updatedAt: Date; + + constructor(company: Company) { + this.id = company.id; + this.name = company.name; + this.type = company.type; + this.status = company.status; + this.tin = company.tin; + this.vatNumber = company.vatNumber; + this.businessLicense = company.businessLicense; + this.fanNumber = company.fanNumber; + this.country = company.country; + this.address = company.address; + this.phone = company.phone; + this.email = company.email; + this.website = company.website; + this.attributes = company.attributes; + this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p)); + this.createdAt = company.createdAt; + this.updatedAt = company.updatedAt; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts new file mode 100644 index 000000000..a33585845 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts @@ -0,0 +1,31 @@ +import { ExternalProfile } from '../entities/external-profile.entity'; + +export class ResponseExternalProfileDto { + id: string; + userId: string; + companyId: string; + firstName: string; + lastName: string; + email: string; + phone?: string | null; + nationalId?: string | null; + jobTitle?: string | null; + isPrimaryContact: boolean; + createdAt: Date; + updatedAt: Date; + + constructor(profile: ExternalProfile) { + this.id = profile.id; + this.userId = profile.userId; + this.companyId = profile.companyId; + this.firstName = profile.firstName; + this.lastName = profile.lastName; + this.email = profile.email; + this.phone = profile.phone; + this.nationalId = profile.nationalId; + this.jobTitle = profile.jobTitle; + this.isPrimaryContact = profile.isPrimaryContact; + this.createdAt = profile.createdAt; + this.updatedAt = profile.updatedAt; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts new file mode 100644 index 000000000..44a48069b --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts @@ -0,0 +1,23 @@ +import { FFClient, FFClientRelationship } from '../entities/ff-client.entity'; + +export class ResponseFFClientDto { + id: string; + forwarderCompanyId: string; + clientCompanyId: string; + relationshipType: FFClientRelationship; + canBookOnBehalf: boolean; + canViewDocuments: boolean; + createdAt: Date; + updatedAt: Date; + + constructor(client: FFClient) { + this.id = client.id; + this.forwarderCompanyId = client.forwarderCompanyId; + this.clientCompanyId = client.clientCompanyId; + this.relationshipType = client.relationshipType; + this.canBookOnBehalf = client.canBookOnBehalf; + this.canViewDocuments = client.canViewDocuments; + this.createdAt = client.createdAt; + this.updatedAt = client.updatedAt; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-company.dto.ts new file mode 100644 index 000000000..71c3c0739 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/update-company.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateCompanyDto } from './create-company.dto'; + +export class UpdateCompanyDto extends PartialType(CreateCompanyDto) {} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-external-profile.dto.ts new file mode 100644 index 000000000..3546e5c10 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/update-external-profile.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateExternalProfileDto } from './create-external-profile.dto'; + +export class UpdateExternalProfileDto extends PartialType(CreateExternalProfileDto) {} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts new file mode 100644 index 000000000..a7ace689d --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateFFClientDto } from './create-ff-client.dto'; + +export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts new file mode 100644 index 000000000..ad7407df1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -0,0 +1,64 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; +import { ExternalProfile } from './external-profile.entity'; + +export enum CompanyType { + Customer = 'customer', + Forwarder = 'forwarder', + Transporter = 'transporter', + Broker = 'broker', +} + +export enum CompanyStatus { + Active = 'active', + Pending = 'pending', + Suspended = 'suspended', + Blacklisted = 'blacklisted', +} + +@Entity({ schema: 'freight', name: 'companies' }) +@Index(['tin']) +@Index(['type']) +export class Company extends BaseEntity { + @Column({ name: 'name', type: 'varchar', length: 200 }) + name!: string; + + @Column({ name: 'type', type: 'varchar', length: 32, enum: CompanyType }) + type!: CompanyType; + + @Column({ name: 'status', type: 'varchar', length: 32, default: CompanyStatus.Pending }) + status!: CompanyStatus; + + @Column({ name: 'tin', type: 'varchar', length: 10, unique: true }) + tin!: string; + + @Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true }) + vatNumber?: string | null; + + @Column({ name: 'business_license', type: 'varchar', length: 100, nullable: true }) + businessLicense?: string | null; + + @Column({ name: 'fan_number', type: 'varchar', length: 16, nullable: true }) + fanNumber?: string | null; + + @Column({ name: 'country', type: 'varchar', length: 32, default: 'Ethiopia' }) + country!: string; + + @Column({ name: 'address', type: 'text', nullable: true }) + address?: string | null; + + @Column({ name: 'phone', type: 'varchar', length: 20, nullable: true }) + phone?: string | null; + + @Column({ name: 'email', type: 'varchar', length: 150, nullable: true }) + email?: string | null; + + @Column({ name: 'website', type: 'varchar', length: 200, nullable: true }) + website?: string | null; + + @Column({ name: 'attributes', type: 'jsonb', nullable: true }) + attributes?: Record | null; + + @OneToMany(() => ExternalProfile, (profile) => profile.company) + profiles?: ExternalProfile[]; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts new file mode 100644 index 000000000..91a014f10 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Company } from './company.entity'; + +@Entity({ schema: 'freight', name: 'external_profiles' }) +@Index(['userId']) +@Index(['companyId']) +export class ExternalProfile extends BaseEntity { + @Column({ name: 'user_id', type: 'uuid' }) + userId!: string; + + @Column({ name: 'company_id', type: 'uuid' }) + companyId!: string; + + @ManyToOne(() => Company, (company) => company.profiles) + @JoinColumn({ name: 'company_id' }) + company!: Company; + + @Column({ name: 'first_name', type: 'varchar', length: 100 }) + firstName!: string; + + @Column({ name: 'last_name', type: 'varchar', length: 100 }) + lastName!: string; + + @Column({ name: 'email', type: 'varchar', length: 150, unique: true }) + email!: string; + + @Column({ name: 'phone', type: 'varchar', length: 20, nullable: true }) + phone?: string | null; + + @Column({ name: 'national_id', type: 'varchar', length: 50, nullable: true }) + nationalId?: string | null; + + @Column({ name: 'job_title', type: 'varchar', length: 100, nullable: true }) + jobTitle?: string | null; + + @Column({ name: 'is_primary_contact', type: 'boolean', default: false }) + isPrimaryContact!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts new file mode 100644 index 000000000..136dea277 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts @@ -0,0 +1,37 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, ManyToOne, JoinColumn, Unique } from 'typeorm'; +import { Company } from './company.entity'; + +export enum FFClientRelationship { + ManagedAccount = 'managed_account', + SubAgent = 'sub_agent', +} + +@Entity({ schema: 'freight', name: 'ff_clients' }) +@Unique(['forwarderCompanyId', 'clientCompanyId']) +@Index(['forwarderCompanyId']) +@Index(['clientCompanyId']) +export class FFClient extends BaseEntity { + @Column({ name: 'forwarder_company_id', type: 'uuid' }) + forwarderCompanyId!: string; + + @ManyToOne(() => Company) + @JoinColumn({ name: 'forwarder_company_id' }) + forwarderCompany!: Company; + + @Column({ name: 'client_company_id', type: 'uuid' }) + clientCompanyId!: string; + + @ManyToOne(() => Company) + @JoinColumn({ name: 'client_company_id' }) + clientCompany!: Company; + + @Column({ name: 'relationship_type', type: 'varchar', length: 32, default: FFClientRelationship.ManagedAccount }) + relationshipType!: FFClientRelationship; + + @Column({ name: 'can_book_on_behalf', type: 'boolean', default: true }) + canBookOnBehalf!: boolean; + + @Column({ name: 'can_view_documents', type: 'boolean', default: true }) + canViewDocuments!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts new file mode 100644 index 000000000..581dfd72b --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { ExternalProfile } from './entities/external-profile.entity'; + +@Injectable() +export class ExternalProfileRepository extends BaseRepository { + constructor( + @InjectRepository(ExternalProfile) + repo: Repository, + ) { + super(repo); + } + + async findByUserId(userId: string): Promise { + return this.repository.findOne({ + where: { userId } as any, + relations: ['company'], + }); + } + + async findByCompanyId(companyId: string): Promise { + return this.repository.find({ where: { companyId } as any }); + } + + async findByEmail(email: string): Promise { + return this.repository.findOne({ where: { email } as any }); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts b/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts new file mode 100644 index 000000000..b94cedec6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { FFClient } from './entities/ff-client.entity'; + +@Injectable() +export class FFClientRepository extends BaseRepository { + constructor( + @InjectRepository(FFClient) + repo: Repository, + ) { + super(repo); + } + + async findByForwarder(forwarderCompanyId: string): Promise { + return this.repository.find({ where: { forwarderCompanyId } as any }); + } + + async findByClient(clientCompanyId: string): Promise { + return this.repository.find({ where: { clientCompanyId } as any }); + } + + async findRelationship( + forwarderCompanyId: string, + clientCompanyId: string, + ): Promise { + return this.repository.findOne({ + where: { forwarderCompanyId, clientCompanyId } as any, + }); + } +} From da10d067f809c6859fbdb7af4338b475c261fb60 Mon Sep 17 00:00:00 2001 From: Tria Date: Wed, 3 Jun 2026 16:17:36 +0300 Subject: [PATCH 09/20] multiple notification strategies --- apps/edr-freight-api/package.json | 5 ++-- .../notifications/notifications.module.ts | 9 +++++-- .../notifications/notifications.service.ts | 27 ++++++++++++++++--- .../strategies/notification.email.strategy.ts | 12 +++++++++ .../strategies/notification.sms.strategy.ts | 25 +++++++++++++++++ .../strategies/notification.strategy.ts | 6 +++++ pnpm-lock.yaml | 5 +++- 7 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/notifications/strategies/notification.email.strategy.ts create mode 100644 apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts create mode 100644 apps/edr-freight-api/src/modules/notifications/strategies/notification.strategy.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 5114e20da..41c570010 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -15,6 +15,7 @@ "dependencies": { "@edr/api-common": "workspace:*", "@edr/types": "workspace:*", + "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.0", "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.0", @@ -27,7 +28,7 @@ "@tria-plc/iamapi-common": "^0.1.6", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", - "axios": "^1.7.7", + "axios": "^1.16.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^17.4.2", @@ -76,4 +77,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts index 39fe1b5c7..2ff2f9727 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts @@ -1,9 +1,14 @@ import { Module } from "@nestjs/common"; import { NotificationsService } from "./notifications.service"; +import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; +import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; +import { HttpModule } from "@nestjs/axios"; @Module({ - providers: [NotificationsService], + imports: [HttpModule], + controllers: [], + providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService], exports: [NotificationsService], }) -export class NotificationsModule {} +export class NotificationsModule { } diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.service.ts b/apps/edr-freight-api/src/modules/notifications/notifications.service.ts index c17250264..35e8ff07d 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.service.ts @@ -1,14 +1,35 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; +import { NotificationStrategy } from "./strategies/notification.strategy"; +import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; + + +type StrategyMethod = "sms" | "email" @Injectable() export class NotificationsService { private readonly logger = new Logger(NotificationsService.name); + private readonly strategies: Map + constructor(private readonly email: EmailNotificationStrategy, private readonly sms: SmsNotificationStrategy) { + this.strategies = new Map([ + ["sms", this.sms as NotificationStrategy], + ["email", this.email as NotificationStrategy] + ]) + } /** * Dispatch a notification to an operator or customer. * TODO: wire to email/SMS provider (SendGrid, SMS API, etc.) via a mailer service. */ - async send(recipient: string, subject: string, body: string): Promise { - this.logger.log(`[notify] ${recipient} :: ${subject} :: ${body}`); + + async directSend(method: StrategyMethod, recipient: string, message: string) { + const strategy = this.strategies.get(method); + if (!strategy) { + throw new NotFoundException(); + } + const sent = await strategy.send(recipient, message) + this.logger.log(`is sent - ${sent}`) } + + } diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.email.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.email.strategy.ts new file mode 100644 index 000000000..57873639b --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.email.strategy.ts @@ -0,0 +1,12 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { NotificationStrategy } from "./notification.strategy"; + +@Injectable() +export class EmailNotificationStrategy implements NotificationStrategy { + private readonly logger = new Logger(EmailNotificationStrategy.name); + constructor() { } + async send(recipient: string, message: string): Promise { + this.logger.log(`${recipient}, ${message}`) + return false; + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts new file mode 100644 index 000000000..2f8916845 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts @@ -0,0 +1,25 @@ +import { Injectable} from "@nestjs/common"; +import { NotificationStrategy } from "./notification.strategy"; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from "@nestjs/config"; +import { firstValueFrom } from 'rxjs'; + +@Injectable() +export class SmsNotificationStrategy implements NotificationStrategy { + constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { } + async send(recipient: string, message: string) { + const url = this.configService.get("OZIKING_SMS_URL") + const body = { + to: recipient, + text: message + } + const response = await firstValueFrom( + this.httpService.post( + url, + body, + ), + ); + + return response.status === 201; + } +} diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.strategy.ts new file mode 100644 index 000000000..2bc53120c --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.strategy.ts @@ -0,0 +1,6 @@ +import { Injectable } from "@nestjs/common"; + +@Injectable() +export abstract class NotificationStrategy { + abstract send(recipient: string, message: string): Promise +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9b737e64..8c735c36c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,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) @@ -81,7 +84,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 From e0473b2ffd836d1bb2bceb9fb41e8a7fbd521ad3 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Wed, 3 Jun 2026 16:17:58 +0300 Subject: [PATCH 10/20] fix: add fan_number to companyies --- .../1749300000000-AddFanNumberToCompanies.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts diff --git a/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts b/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts new file mode 100644 index 000000000..647e4fb5b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddFanNumberToCompanies1749300000000 implements MigrationInterface { + name = 'AddFanNumberToCompanies1749300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN fan_number varchar(16) NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN fan_number; + `); + } +} From f9357ed1b21877d9eacedc714fe4ce49d77a50a2 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Wed, 3 Jun 2026 16:19:04 +0300 Subject: [PATCH 11/20] feat(api): Introduce dedicated company management API and service --- .../portal/src/constants/URLS.ts | 5 + .../portal/src/hooks/useAuth.ts | 19 +- .../src/pages/accounts/CompanyProfileForm.tsx | 192 +++++++++--------- .../src/pages/accounts/DjiboutiAgentForm.tsx | 24 +-- .../src/pages/accounts/OnboardingPage.tsx | 139 +++++++------ .../src/pages/accounts/TransporterForm.tsx | 29 +-- .../portal/src/services/api.ts | 25 +++ .../portal/src/services/companies.service.ts | 83 ++++++++ .../services/fileUploadSettings.service.ts | 8 + 9 files changed, 324 insertions(+), 200 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/services/companies.service.ts diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 84db5e2c2..a9ce8fc00 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -79,6 +79,11 @@ export const URL_CONSTANTS = { BY_USER_ID: (id: string) => `/api/customers/user/${id}`, }, + COMPANIES_API: { + GET_INFO: "/api/companies/getInfo", + CREATE: "/api/companies/create", + }, + BOOKINGS: { BASE: "/bookings", BY_ID: (id: string | number) => `/bookings/${id}`, diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index b661ed98a..8d58d3ada 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -35,9 +35,8 @@ const useAuth = () => { }), ); - 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 +47,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 +179,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 +189,8 @@ const useAuth = () => { generateVerificationCode, logout, authQuery, - customerQuery, + companyQuery, + customerQuery: companyQuery, }; }; 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..eb8d5a343 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,12 @@ 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 type { FileUploadSetting } from "@/types/fileUploadSettings"; import PhoneInput from "@/components/auth/PhoneInput"; import { Button, @@ -23,9 +26,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"; const onboardingSchema = z.object({ companyName: z.string().min(1, "Company name is required"), @@ -79,55 +84,34 @@ const stepFields: Record = { "generalManagerPhoneCountryCode", ], poa: [], + documents: [], }; -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, + }, }; } @@ -140,21 +124,26 @@ export default function CompanyProfileForm({ }: { userType: OnboardingUserType; 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 [documentFiles, setDocumentFiles] = useState< + Record + >({}); + + const { data: uploadSettings = [], isLoading: loadingDocuments } = useQuery( + api.fileUploadSettings.getByEntity.queryOptions({ + input: { entity: "customer" }, + refetchOnMount: false, + }), + ); const { register, handleSubmit, trigger, - setError, - clearErrors, - getValues, formState: { errors }, } = useForm({ resolver: zodResolver(onboardingSchema), @@ -184,26 +173,18 @@ export default function CompanyProfileForm({ }, }); + const hasDocuments = uploadSettings.length > 0; + 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; + if (hasDocuments) { + setStep("documents"); + } else { + handleSubmit((data) => onSubmit(buildPayload(data, user)))(); } + return; + } + if (step === "documents") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } @@ -220,6 +201,8 @@ export default function CompanyProfileForm({ setStep("company"); } else if (step === "poa") { setStep("personnel"); + } else { + setStep("poa"); } }; @@ -250,14 +233,21 @@ export default function CompanyProfileForm({ } active={step === "poa"} - completed={false} + completed={hasDocuments ? step === "documents" : step === "personnel"} /> + {hasDocuments && ( + } + active={step === "documents"} + 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 ${requirePoA ? "(Required)" : "(Optional)"}`} + {step === "company" && `Step 1 of ${hasDocuments ? 4 : 3} — Company Information`} + {step === "personnel" && `Step 2 of ${hasDocuments ? 4 : 3} — Personnel Details`} + {step === "poa" && `Step 3 of ${hasDocuments ? 4 : 3} — Power of Attorney (Optional)`} + {step === "documents" && "Step 4 of 4 — Upload Documents (Optional)"}

@@ -449,16 +439,12 @@ export default function CompanyProfileForm({ {step === "poa" && ( <>

- {requirePoA - ? "Power of Attorney details are required for Freight Forwarder registration." - : "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 - {requirePoA && *} - + PoA Name - - PoA Email - {requirePoA && *} - + PoA Email @@ -493,10 +476,7 @@ export default function CompanyProfileForm({
- - PoA Location - {requirePoA && *} - + PoA Location - - PoA Address - {requirePoA && *} - + PoA Address )} + + {step === "documents" && ( + <> +

+ Upload required documents for your registration. You can skip + this step and upload later from your account settings. +

+ + {loadingDocuments ? ( +
+ +
+ ) : uploadSettings.length === 0 ? ( +

+ No document requirements found for your account type. +

+ ) : ( +
+ {uploadSettings.map((setting) => ( + + ))} +
+ )} + + )}
@@ -534,7 +541,7 @@ export default function CompanyProfileForm({ Submitting... - ) : step === "poa" ? ( + ) : step === "documents" ? ( "Complete Registration" ) : ( <> @@ -560,13 +567,12 @@ function StepIcon({ }) { return (
{completed ? : icon}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx index a2f08a012..9422dd3e2 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx @@ -12,7 +12,7 @@ import { ChevronLeft, } from "lucide-react"; 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, @@ -45,27 +45,21 @@ const stepLabels: Record = { representative: "Step 2 of 2 — Representative Details", }; -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.repName, - contactPersonPhone: `${data.repPhoneCountryCode}${data.repPhone}`, - tinNumber: "", + tin: "", vatNumber: "", fanNumber: "", - generalManagerName: "", - generalManagerEmail: "", - generalManagerPhone: "", + attributes: { + repName: data.repName, + repEmail: data.repEmail, + repPhone: `${data.repPhoneCountryCode}${data.repPhone}`, + }, }; } @@ -76,7 +70,7 @@ export default function DjiboutiAgentForm({ onBack, }: { user: AuthUser; - onSubmit: (data: CreateCustomerDto) => void; + onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; }) { 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..a53b37ee5 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowDownToLine, ArrowUpFromLine, @@ -10,7 +10,7 @@ import { } from "lucide-react"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; -import type { CreateCustomerDto } from "@/types/customers"; +import type { CreateCompanyPayload } from "@/services/companies.service"; import AuthLayout from "@/components/auth/AuthLayout"; import CompanyProfileForm from "./CompanyProfileForm"; import DjiboutiAgentForm from "./DjiboutiAgentForm"; @@ -23,40 +23,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, @@ -121,21 +118,39 @@ export default function OnboardingPage() { const { user } = useAuth(); const [userType, setUserType] = useState(null); - const createCustomerMutation = useMutation({ - mutationFn: (payload: CreateCustomerDto) => - api.customers.create.call(payload), + useQuery( + api.fileUploadSettings.getByEntity.queryOptions({ + input: { entity: "customer" }, + refetchOnMount: false, + }), + ); + + 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: () => { - if (user) - queryClient.invalidateQueries({ - queryKey: api.customers.getByUserId.queryKey({ id: user.id }), - }); + 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 +187,7 @@ export default function OnboardingPage() { {card.icon}
-

- {card.label} -

+

{card.label}

{card.description}

@@ -197,21 +210,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+", @@ -226,14 +239,14 @@ export default function OnboardingPage() { ) : userType === "freight-forwarder-dj" ? ( ) : ( @@ -241,7 +254,7 @@ export default function OnboardingPage() { userType={userType} user={user} onSubmit={handleSubmit} - isPending={createCustomerMutation.isPending} + isPending={createCompanyMutation.isPending} onBack={handleBack} /> )} 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..7f6a302cf 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/TransporterForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/TransporterForm.tsx @@ -8,7 +8,7 @@ import { Info, } 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, @@ -56,34 +56,23 @@ 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, - }), + }, }; } @@ -94,7 +83,7 @@ export default function TransporterForm({ onBack, }: { user: AuthUser; - onSubmit: (data: CreateCustomerDto) => void; + onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; }) { diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 4300cd56b..bb5fcdcc7 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,10 @@ import { Customer, UpdateCustomerDto, } from "@/types/customers"; +import type { + CompanyInfoResponse, + CreateCompanyPayload, +} from "./companies.service"; import type { AuthUser, GenerateVerificationCodePayload, @@ -118,6 +123,20 @@ export const api = { ), }, + companies: { + getInfo: endpoint( + "companies", + "getInfo", + companiesService.getInfo, + ), + + create: endpoint( + "companies", + "create", + companiesService.create, + ), + }, + bookings: { list: endpoint>( "bookings", @@ -190,6 +209,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/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts new file mode 100644 index 000000000..4a68b5aa2 --- /dev/null +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -0,0 +1,83 @@ +import { client } from "@/utils/api"; +import { unwrap } from "@/utils/endpoint"; +import { URL_CONSTANTS } from "@/constants/URLS"; +import type { ApiResponse } from "@/types/apiResponse"; +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); + }, +}; 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>( From 125ee18308506561b1c12291386cc575a03a2f24 Mon Sep 17 00:00:00 2001 From: marshal Date: Thu, 4 Jun 2026 15:16:27 +0300 Subject: [PATCH 12/20] implement booking flow --- apps/edr-freight-api/nest-cli.json | 5 +- apps/edr-freight-api/package.json | 2 + .../src/common/resolve-auth-user-id.ts | 12 + .../src/contracts/contract-pdf.service.ts | 57 + .../contract-pricing-schedule.builder.ts | 70 ++ .../contracts/contract-renderer.service.ts | 51 + .../contracts/contract-template.registry.ts | 98 ++ .../contracts/contract-template.resolver.ts | 43 + .../contracts/contract-view-model.builder.ts | 118 +++ .../templates/IMP_CON_ETB_TRANSPORT_ONLY.hbs | 68 ++ .../templates/_partials/article5_pricing.hbs | 47 + .../templates/_partials/signatures_block.hbs | 30 + .../contracts/templates/_partials/styles.hbs | 22 + .../src/contracts/templates/generic.hbs | 42 + .../1749300000000-AddBookingFreightType.ts | 71 ++ .../1749400000000-AddContractSignatures.ts | 45 + .../bookings/booking-contract.service.ts | 210 +++- .../modules/bookings/booking-freight.util.ts | 43 + .../bookings/booking-pricing.service.ts | 99 +- .../bookings/booking-transition.service.ts | 25 +- .../modules/bookings/bookings.controller.ts | 120 ++- .../src/modules/bookings/bookings.module.ts | 12 + .../modules/bookings/bookings.repository.ts | 41 +- .../src/modules/bookings/bookings.service.ts | 147 ++- .../modules/bookings/dto/contract-view.dto.ts | 50 + .../bookings/dto/create-booking.dto.ts | 31 +- .../bookings/dto/filter-booking.dto.ts | 12 +- .../bookings/dto/request-changes.dto.ts | 36 +- .../modules/bookings/dto/sign-contract.dto.ts | 23 + .../bookings/dto/update-booking.dto.ts | 11 +- .../validators/booking-freight.validator.ts | 60 ++ .../booking-contract-signature.entity.ts | 44 + .../bookings/entities/booking.entity.ts | 19 +- .../src/modules/files/files.repository.ts | 8 + .../src/modules/files/files.service.ts | 7 + .../controllers/rates.controller.ts | 26 +- .../rule-engine/dto/create-rate.dto.ts | 10 - .../rule-engine/rule-engine.service.ts | 42 +- .../rule-engine/services/rates.service.ts | 11 +- .../services/surcharge-types.service.ts | 1 + apps/edr-freight-web/backoffice/src/App.tsx | 32 +- .../components/bookings/ApprovalStepsCard.tsx | 116 +++ .../bookings/BookingActionsMenu.tsx | 233 +++++ .../bookings/BookingActionsToolbar.tsx | 168 +++ .../bookings/BookingConfirmDialog.tsx | 134 +++ .../bookings/BookingPricingSummary.tsx | 86 ++ .../bookings/BookingPriorityBadge.tsx | 21 + .../components/bookings/BookingStatGrid.tsx | 56 + .../bookings/BookingStatusBadge.tsx | 21 + .../components/bookings/BookingStatusTabs.tsx | 91 ++ .../components/bookings/BookingTableEmpty.tsx | 44 + .../bookings/BookingWorkflowStepper.tsx | 124 +++ .../bookings/ContractSignaturePad.tsx | 107 ++ .../components/bookings/booking-ui.styles.ts | 32 + .../bookings/useBookingActionDialog.ts | 132 +++ .../ruleEngine/ruleEngineFormat.tsx | 40 + .../backoffice/src/constants/QUERY_KEYS.ts | 50 + .../src/constants/TANSTACK_QUEY_KEY.ts | 25 - .../backoffice/src/constants/URLS.ts | 30 +- .../bookings/booking-actions.config.ts | 268 +++++ .../bookings/booking-status.config.ts | 260 +++++ .../features/bookings/mapBookingListRow.ts | 34 + .../src/hooks/bookings/useBookings.ts | 178 ++++ .../src/hooks/rule-engine/useRuleEngine.ts | 152 +-- .../backoffice/src/hooks/useBookings.ts | 53 +- .../backoffice/src/lib/queryClient.ts | 12 + apps/edr-freight-web/backoffice/src/main.tsx | 5 +- .../pages/bookings/BookingContractPage.tsx | 218 ++++ .../bookings/BookingRequestDetailPage.tsx | 985 +++++++----------- .../pages/bookings/BookingRequestsPage.tsx | 624 +++++------ .../pages/bookings/booking-requests.mock.ts | 182 +--- .../src/pages/bookings/bookings.mock.ts | 9 + .../ruleEngine/RuleEngineResourcePage.tsx | 131 +-- .../src/pages/ruleEngine/config/resources.ts | 46 +- .../backoffice/src/services/api.ts | 135 ++- .../src/services/bookings.service.ts | 179 +++- .../services/ruleEngine/ruleEngine.service.ts | 8 +- .../backoffice/src/types/booking.ts | 126 +++ .../backoffice/src/types/rule-engine/index.ts | 4 - .../backoffice/src/utils/endpoint.ts | 12 +- .../backoffice/src/utils/queryInvalidation.ts | 56 + apps/edr-freight-web/portal/src/App.tsx | 2 + .../bookings/ContractSignaturePad.tsx | 105 ++ .../portal/src/constants/URLS.ts | 4 + .../pages/bookings/BookingContractPage.tsx | 165 +++ .../src/pages/bookings/BookingDetailPage.tsx | 21 + .../portal/src/services/bookings.service.ts | 48 + packages/types/src/freight/index.ts | 12 +- pnpm-lock.yaml | 469 +++++++++ 89 files changed, 6190 insertions(+), 1724 deletions(-) create mode 100644 apps/edr-freight-api/src/common/resolve-auth-user-id.ts create mode 100644 apps/edr-freight-api/src/contracts/contract-pdf.service.ts create mode 100644 apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts create mode 100644 apps/edr-freight-api/src/contracts/contract-renderer.service.ts create mode 100644 apps/edr-freight-api/src/contracts/contract-template.registry.ts create mode 100644 apps/edr-freight-api/src/contracts/contract-template.resolver.ts create mode 100644 apps/edr-freight-api/src/contracts/contract-view-model.builder.ts create mode 100644 apps/edr-freight-api/src/contracts/templates/IMP_CON_ETB_TRANSPORT_ONLY.hbs create mode 100644 apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs create mode 100644 apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs create mode 100644 apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs create mode 100644 apps/edr-freight-api/src/contracts/templates/generic.hbs create mode 100644 apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts create mode 100644 apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingPriorityBadge.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingStatGrid.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingTableEmpty.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/BookingWorkflowStepper.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/ContractSignaturePad.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts create mode 100644 apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts delete mode 100644 apps/edr-freight-web/backoffice/src/constants/TANSTACK_QUEY_KEY.ts create mode 100644 apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts create mode 100644 apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts create mode 100644 apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts create mode 100644 apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts create mode 100644 apps/edr-freight-web/backoffice/src/lib/queryClient.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/bookings/bookings.mock.ts create mode 100644 apps/edr-freight-web/backoffice/src/types/booking.ts create mode 100644 apps/edr-freight-web/backoffice/src/utils/queryInvalidation.ts create mode 100644 apps/edr-freight-web/portal/src/components/bookings/ContractSignaturePad.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx diff --git a/apps/edr-freight-api/nest-cli.json b/apps/edr-freight-api/nest-cli.json index 6c524a8a1..f4a3b488d 100644 --- a/apps/edr-freight-api/nest-cli.json +++ b/apps/edr-freight-api/nest-cli.json @@ -4,7 +4,10 @@ "sourceRoot": "src", "compilerOptions": { "deleteOutDir": true, - "assets": [{ "include": "migrations/**/*", "outDir": "dist" }], + "assets": [ + { "include": "migrations/**/*", "outDir": "dist" }, + { "include": "contracts/templates/**/*", "watchAssets": true } + ], "watchAssets": true } } diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 5114e20da..7e85ffe21 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -31,7 +31,9 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^17.4.2", + "handlebars": "^4.7.9", "minio": "7.1.3", + "puppeteer": "^24.2.0", "pg": "^8.13.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", diff --git a/apps/edr-freight-api/src/common/resolve-auth-user-id.ts b/apps/edr-freight-api/src/common/resolve-auth-user-id.ts new file mode 100644 index 000000000..cab29b671 --- /dev/null +++ b/apps/edr-freight-api/src/common/resolve-auth-user-id.ts @@ -0,0 +1,12 @@ +import { UnauthorizedException } from '@nestjs/common'; + +export type AuthUserPayload = { id?: string; sub?: string } | null | undefined; + +/** Resolve IAM user id from JWT payload attached by JwtGuard. */ +export function resolveAuthUserId(user: AuthUserPayload): string { + const id = user?.id ?? user?.sub; + if (!id) { + throw new UnauthorizedException('Authentication required'); + } + return id; +} diff --git a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts new file mode 100644 index 000000000..399559fb2 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts @@ -0,0 +1,57 @@ +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class ContractPdfService { + private readonly logger = new Logger(ContractPdfService.name); + + async htmlToPdfBuffer(html: string): Promise { + try { + const puppeteer = await import('puppeteer'); + const browser = await puppeteer.default.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + try { + const page = await browser.newPage(); + await page.setContent(html, { waitUntil: 'load' }); + const pdf = await page.pdf({ + format: 'A4', + printBackground: true, + margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' }, + }); + return Buffer.from(pdf); + } finally { + await browser.close(); + } + } catch (err) { + this.logger.warn( + `Puppeteer PDF failed, falling back to minimal PDF stub: ${err}`, + ); + return this.fallbackPdfBuffer(html); + } + } + + /** Minimal valid PDF when Chromium is unavailable. */ + private fallbackPdfBuffer(html: string): Buffer { + const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').slice(0, 2000); + const escaped = text.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); + const stream = `BT /F1 10 Tf 50 750 Td (${escaped}) Tj ET`; + const len = stream.length; + const pdf = `%PDF-1.4 +1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj +2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj +3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>endobj +4 0 obj<< /Length ${len} >>stream +${stream} +endstream endobj +5 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>endobj +xref +0 6 +0000000000 65535 f +trailer<< /Size 6 /Root 1 0 R >> +startxref +0 +%%EOF`; + return Buffer.from(pdf, 'utf-8'); + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts new file mode 100644 index 000000000..f9dc0b7aa --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; + +import { BookingPricingService } from '../modules/bookings/booking-pricing.service'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { PriceLineItemDto } from '../modules/bookings/dto/generate-price-response.dto'; + +export interface PricingScheduleRow { + label: string; + description: string; + amount: number; + currency: string; +} + +export interface PricingSchedule { + lineItems: PricingScheduleRow[]; + surcharges: PricingScheduleRow[]; + totalAmount: number; + currency: string; + equipmentReturn?: string; + originLabel: string; + destinationLabel: string; + containerLines: Array<{ + label: string; + quantity: number; + vgmPerUnitTons: number; + }>; +} + +@Injectable() +export class ContractPricingScheduleBuilder { + constructor(private readonly pricingService: BookingPricingService) {} + + async build(booking: Booking): Promise { + const { lineItems, totalAmount, currency } = + await this.pricingService.computeContractLineItems(booking); + + const isSurcharge = (l: PriceLineItemDto) => + l.code.includes('SURCHARGE') || l.description.toLowerCase().includes('surcharge'); + + const baseLines = lineItems.filter((l) => !isSurcharge(l)); + const surchargeLines = lineItems.filter(isSurcharge); + + return { + lineItems: baseLines.map((l) => ({ + label: l.code, + description: l.description, + amount: l.amount, + currency: l.currency, + })), + surcharges: surchargeLines.map((l) => ({ + label: l.code, + description: l.description, + amount: l.amount, + currency: l.currency, + })), + totalAmount, + currency, + equipmentReturn: booking.equipmentReturn ?? undefined, + originLabel: booking.originYard?.label ?? booking.originYard?.code ?? '—', + destinationLabel: + booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—', + containerLines: (booking.bookingContainers ?? []).map((c) => ({ + label: + c.containerType?.label ?? c.containerType?.code ?? c.containerTypeId, + quantity: c.quantity, + vgmPerUnitTons: Number(c.vgmPerUnitTons), + })), + }; + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts new file mode 100644 index 000000000..dd539df25 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts @@ -0,0 +1,51 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; +import Handlebars from 'handlebars'; + +import { ContractViewModel } from './contract-view-model.builder'; + +@Injectable() +export class ContractRendererService implements OnModuleInit { + private readonly templatesDir = path.join(__dirname, 'templates'); + private readonly compiled = new Map(); + + onModuleInit(): void { + Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b); + + const partialsDir = path.join(this.templatesDir, '_partials'); + if (fs.existsSync(partialsDir)) { + for (const file of fs.readdirSync(partialsDir)) { + if (!file.endsWith('.hbs')) continue; + const name = file.replace(/\.hbs$/, ''); + const content = fs.readFileSync(path.join(partialsDir, file), 'utf-8'); + Handlebars.registerPartial(name, content); + } + } + } + + render(view: ContractViewModel): string { + const fileName = + view.template.templateFile ?? 'generic.hbs'; + const template = this.getCompiled(fileName); + return template({ + ...view, + paymentArticle: view.pricing.currency === 'ETB' ? 'ETB' : 'USD', + }); + } + + private getCompiled(fileName: string): Handlebars.TemplateDelegate { + const cached = this.compiled.get(fileName); + if (cached) return cached; + + const filePath = path.join(this.templatesDir, fileName); + const fallbackPath = path.join(this.templatesDir, 'generic.hbs'); + const source = fs.existsSync(filePath) + ? fs.readFileSync(filePath, 'utf-8') + : fs.readFileSync(fallbackPath, 'utf-8'); + + const compiled = Handlebars.compile(source); + this.compiled.set(fileName, compiled); + return compiled; + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-template.registry.ts b/apps/edr-freight-api/src/contracts/contract-template.registry.ts new file mode 100644 index 000000000..aacf6a0a3 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-template.registry.ts @@ -0,0 +1,98 @@ +export interface ContractTemplateMeta { + key: string; + title: string; + directionLabel: string; + freightLabel: string; + currency: string; + serviceScope: 'TRANSPORT_ONLY' | 'FORWARDING'; + /** Optional dedicated .hbs file; otherwise uses generic.hbs */ + templateFile?: string; + whereas: string; + article1Objective: string; +} + +const DIRECTION_LABELS: Record = { + IMP: 'Import', + EXP: 'Export', + DOM: 'Domestic', +}; + +const FREIGHT_LABELS: Record = { + CON: 'Container', + BULK: 'Bulk', +}; + +function buildMeta( + dir: string, + freight: string, + currency: string, + service: 'TRANSPORT_ONLY' | 'FORWARDING', + templateFile?: string, +): ContractTemplateMeta { + const key = `${dir}_${freight}_${currency}_${service}`; + const dirLabel = DIRECTION_LABELS[dir] ?? dir; + const freightLabel = FREIGHT_LABELS[freight] ?? freight; + const serviceLabel = + service === 'FORWARDING' ? 'Rail and Forwarding' : 'Transport Only'; + + const corridor = + dir === 'IMP' + ? 'from SGTD railway freight station at Djibouti to Ethiopian dry ports and return of empty containers as applicable' + : dir === 'EXP' + ? 'from Ethiopian dry ports to SGTD and related export corridors' + : 'between designated Ethiopian rail terminals'; + + return { + key, + title: `${dirLabel} ${freightLabel} Transport Service by Railway (${serviceLabel})`, + directionLabel: dirLabel, + freightLabel, + currency, + serviceScope: service, + templateFile, + whereas: `The Client has requested transportation of ${freightLabel.toLowerCase()} cargo ${corridor} using the Addis Ababa–Djibouti Railway line. The Service Provider has agreed to provide services per this contract.`, + article1Objective: `To provide railway transportation services for ${freightLabel.toLowerCase()} cargo on the agreed corridor (${serviceLabel}).`, + }; +} + +const DIRECTIONS = ['IMP', 'EXP', 'DOM'] as const; +const FREIGHTS = ['CON', 'BULK'] as const; +const CURRENCIES = ['ETB', 'USD'] as const; +const SERVICES = ['TRANSPORT_ONLY', 'FORWARDING'] as const; + +/** Full template matrix (24 keys). */ +export const CONTRACT_TEMPLATE_REGISTRY: Record = + {}; + +for (const dir of DIRECTIONS) { + for (const freight of FREIGHTS) { + for (const currency of CURRENCIES) { + for (const service of SERVICES) { + const dedicated = + dir === 'IMP' && + freight === 'CON' && + currency === 'ETB' && + service === 'TRANSPORT_ONLY' + ? 'IMP_CON_ETB_TRANSPORT_ONLY.hbs' + : undefined; + const meta = buildMeta(dir, freight, currency, service, dedicated); + CONTRACT_TEMPLATE_REGISTRY[meta.key] = meta; + } + } + } +} + +export function getTemplateMeta(key: string): ContractTemplateMeta { + return ( + CONTRACT_TEMPLATE_REGISTRY[key] ?? { + key, + title: 'Freight Contract Agreement', + directionLabel: 'Freight', + freightLabel: 'Cargo', + currency: 'USD', + serviceScope: 'TRANSPORT_ONLY', + whereas: 'The parties agree to railway freight services as described in the schedule below.', + article1Objective: 'To provide railway transportation services per the agreed schedule.', + } + ); +} diff --git a/apps/edr-freight-api/src/contracts/contract-template.resolver.ts b/apps/edr-freight-api/src/contracts/contract-template.resolver.ts new file mode 100644 index 000000000..daa48a4e7 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-template.resolver.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; + +@Injectable() +export class ContractTemplateResolver { + resolve(booking: Booking): string { + const dir = + booking.tradeDirection === 'IMPORT' + ? 'IMP' + : booking.tradeDirection === 'EXPORT' + ? 'EXP' + : 'DOM'; + + let freight = booking.freightType === 'BULK' ? 'BULK' : 'CON'; + const cargoCode = (booking.cargoType as CargoType | undefined)?.code ?? ''; + if (cargoCode.startsWith('BREAK_BULK')) { + freight = 'BULK'; + } + + const currency = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD'; + const service = this.resolveServiceScope(booking.serviceType); + + return `${dir}_${freight}_${currency}_${service}`; + } + + private resolveServiceScope( + serviceType?: ServiceType | null, + ): 'TRANSPORT_ONLY' | 'FORWARDING' { + if (!serviceType) return 'TRANSPORT_ONLY'; + const code = (serviceType.code ?? '').toUpperCase(); + if ( + serviceType.includesFirstMile || + serviceType.includesLastMile || + code.includes('FORWARD') + ) { + return 'FORWARDING'; + } + return 'TRANSPORT_ONLY'; + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts new file mode 100644 index 000000000..36c0328c8 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -0,0 +1,118 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { BookingsRepository } from '../modules/bookings/bookings.repository'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { + BookingContractSignature, + ContractSignerRole, +} from '../modules/bookings/entities/booking-contract-signature.entity'; +import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder'; +import { ContractTemplateResolver } from './contract-template.resolver'; +import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry'; + +export interface ContractSignatureView { + role: ContractSignerRole; + signerDisplayName: string; + signedAt: string; + signatureImageUrl?: string | null; +} + +export interface ContractViewModel { + bookingId: string; + reference: string; + status: string; + templateKey: string; + template: ContractTemplateMeta; + contractDate: string; + contractYear: number; + client: { + companyName: string; + companyAddress: string; + companyLocation: string; + phone: string; + email: string; + tinNumber: string; + }; + pricing: PricingSchedule; + signatures: ContractSignatureView[]; + canSignCustomer: boolean; + canSignStaff: boolean; + hasContractDocument: boolean; + hasCustomerSignature: boolean; + hasStaffSignature: boolean; +} + +@Injectable() +export class ContractViewModelBuilder { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly templateResolver: ContractTemplateResolver, + private readonly pricingBuilder: ContractPricingScheduleBuilder, + ) {} + + async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> { + const booking = await this.bookingsRepository.findByIdWithFiles(bookingId); + if (!booking) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + + const templateKey = + booking.contractTemplateKey ?? this.templateResolver.resolve(booking); + const template = getTemplateMeta(templateKey); + const pricing = await this.pricingBuilder.build(booking); + const signatures = await this.loadSignatures(bookingId); + + const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); + const hasStaff = signatures.some((s) => s.role === 'STAFF'); + const hasContractFile = Boolean( + booking.files?.some((f) => f.code === 'contract'), + ); + + const view: ContractViewModel = { + bookingId: booking.id, + reference: booking.reference, + status: booking.status, + templateKey, + template, + contractDate: new Date().toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + }), + contractYear: new Date().getFullYear(), + client: { + companyName: booking.customer?.companyName ?? 'Client', + companyAddress: booking.customer?.companyAddress ?? '—', + companyLocation: booking.customer?.companyLocation ?? '—', + phone: booking.customer?.companyPhone ?? booking.customer?.phone ?? '—', + email: booking.customer?.companyEmail ?? booking.customer?.email ?? '—', + tinNumber: booking.customer?.tinNumber ?? '—', + }, + pricing, + signatures, + canSignCustomer: + booking.status === 'CONTRACT_READY' && !hasCustomer, + canSignStaff: + booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff, + hasContractDocument: hasContractFile, + hasCustomerSignature: hasCustomer, + hasStaffSignature: hasStaff, + }; + + return { booking, view }; + } + + private async loadSignatures(bookingId: string): Promise { + const rows = await this.bookingsRepository.findContractSignatures(bookingId); + return rows.map((s) => this.toSignatureView(s)); + } + + toSignatureView(row: BookingContractSignature): ContractSignatureView { + return { + role: row.signerRole, + signerDisplayName: row.signerDisplayName, + signedAt: row.signedAt.toISOString(), + signatureImageUrl: row.signatureFile?.url ?? null, + }; + } +} diff --git a/apps/edr-freight-api/src/contracts/templates/IMP_CON_ETB_TRANSPORT_ONLY.hbs b/apps/edr-freight-api/src/contracts/templates/IMP_CON_ETB_TRANSPORT_ONLY.hbs new file mode 100644 index 000000000..90b6dba93 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/IMP_CON_ETB_TRANSPORT_ONLY.hbs @@ -0,0 +1,68 @@ + + + + + Import Container Transport — {{reference}} + {{> styles}} + + +
+

Contract Agreement

+

Import Container Transport Service by Railway

+

Contract Ref No: {{reference}}

+

Year: {{contractYear}}

+
+ +

This Contract Agreement is made on {{contractDate}}.

+

Between Ethio-Djibouti Standard Gauge Railway Share Company (EDR), Addis Ababa (“Service Provider”), and {{client.companyName}} at {{client.companyAddress}}, {{client.companyLocation}} (“Client”). Phone {{client.phone}} / {{client.email}}. TIN {{client.tinNumber}}.

+ +

Whereas

+

{{template.whereas}}

+

Now therefore, the parties agree as follows:

+ +
+

Article 1: Objective and Scope of Services

+

Objective: To provide railway transportation for 40ft and/or 20ft full containers from SGTD to Dire Dawa, Modjo dry port and/or Galaan Multipurpose port (GMP), and empty container return from those terminals to SGTD.

+

Scope: (1) Railway transport service; (2) Cargo handling at Galaan Multipurpose port (GMP) where applicable.

+
+ +
+

Article 2: Obligations of the Client (summary)

+
    +
  1. Provide shipment instructions to EDR for container movements on the agreed corridor.
  2. +
  3. Meet minimum container supply per terminal (Modjo, Dire Dawa, GMP) as per EDR operational rules.
  4. +
  5. Submit required documents to Djibouti Nagad station at least 24 hours before loading.
  6. +
  7. Pay 100% transportation fees in advance per train set in {{paymentArticle}}.
  8. +
  9. Notify EDR 48 hours in advance for hazardous or valuable cargo.
  10. +
+
+ +
+

Article 3: Obligations of the Service Provider (summary)

+
    +
  1. Assign voyage per operational schedule and notify train schedule 48 hours in advance.
  2. +
  3. Provide safe transportation and deliver within agreed timelines when documents are complete.
  4. +
  5. Return empty containers from Dire Dawa, Modjo and GMP to SGTD within seven (7) calendar days of receipt.
  6. +
  7. Maintain cargo liability insurance per wagon.
  8. +
+
+ + {{> article5_pricing}} + +
+

Article 4: Force Majeure

+

Neither party is liable for delays due to force majeure interpreted under the Ethiopian Civil Code.

+
+ +
+

Article 6: Contract Documents

+
    +
  1. Amendments (if any)
  2. +
  3. This Contract Agreement
  4. +
  5. Final Minutes of Negotiation (if any)
  6. +
+
+ + {{> signatures_block}} + + diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs new file mode 100644 index 000000000..d56c8b8a6 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs @@ -0,0 +1,47 @@ +

Article 5: Contract Price and Terms of Payment

+
+

Contract Price

+

Corridor: {{pricing.originLabel}} → {{pricing.destinationLabel}}

+ {{#if pricing.equipmentReturn}} +

Equipment return: {{pricing.equipmentReturn}}

+ {{/if}} + {{#if pricing.containerLines.length}} + + + + + + {{#each pricing.containerLines}} + + {{/each}} + +
Container typeQuantityVGM / unit (t)
{{label}}{{quantity}}{{vgmPerUnitTons}}
+ {{/if}} + + + + + + {{#each pricing.lineItems}} + + + + + + {{/each}} + {{#each pricing.surcharges}} + + + + + + {{/each}} + + + + + +
ItemDescriptionAmount
{{label}}{{description}}{{currency}} {{amount}}
{{label}}{{description}}{{currency}} {{amount}}
Total contract value{{pricing.currency}} {{pricing.totalAmount}}
+

Terms of payment

+

All payments shall be made in accordance with EDR policy in {{paymentArticle}}, unless otherwise agreed in writing.

+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs new file mode 100644 index 000000000..ed5b7048d --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs @@ -0,0 +1,30 @@ +
+
+

Service Provider (EDR)

+ {{#if hasStaffSignature}} + {{#each signatures}} + {{#if (eq role "STAFF")}} + {{#if signatureImageUrl}}Staff signature{{/if}} +

{{signerDisplayName}}

+

Signed: {{signedAt}}

+ {{/if}} + {{/each}} + {{else}} +

Authorized representative (pending)

+ {{/if}} +
+
+

Client — {{client.companyName}}

+ {{#if hasCustomerSignature}} + {{#each signatures}} + {{#if (eq role "CUSTOMER")}} + {{#if signatureImageUrl}}Customer signature{{/if}} +

{{signerDisplayName}}

+

Signed: {{signedAt}}

+ {{/if}} + {{/each}} + {{else}} +

Client representative (pending)

+ {{/if}} +
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs new file mode 100644 index 000000000..601768185 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs @@ -0,0 +1,22 @@ + diff --git a/apps/edr-freight-api/src/contracts/templates/generic.hbs b/apps/edr-freight-api/src/contracts/templates/generic.hbs new file mode 100644 index 000000000..23a35cb88 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/generic.hbs @@ -0,0 +1,42 @@ + + + + + {{template.title}} — {{reference}} + {{> styles}} + + +
+

Contract Agreement

+

{{template.title}}

+

Contract Ref No: {{reference}}

+

Year: {{contractYear}}

+
+ +

This Contract Agreement is made on {{contractDate}}.

+

Between Ethio-Djibouti Standard Gauge Railway Share Company (EDR) (“Service Provider”) and {{client.companyName}} (“Client”) at {{client.companyAddress}}, {{client.companyLocation}}. Phone: {{client.phone}}. Email: {{client.email}}. TIN: {{client.tinNumber}}.

+ +

Whereas

+

{{template.whereas}}

+

Now therefore, the parties agree as follows:

+ +
+

Article 1: Objective and Scope

+

{{template.article1Objective}}

+
+ + {{> article5_pricing}} + +
+

Article 4: Force Majeure

+

Neither party shall be liable for delays caused by force majeure beyond reasonable control, interpreted per the Ethiopian Civil Code.

+
+ +
+

Article 6: Contract Documents

+

This agreement, amendments (if any), and negotiated minutes constitute the contract.

+
+ + {{> signatures_block}} + + diff --git a/apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts b/apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts new file mode 100644 index 000000000..795d93fc3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts @@ -0,0 +1,71 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookingFreightType1749300000000 implements MigrationInterface { + name = 'AddBookingFreightType1749300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS freight_type VARCHAR(20); + `); + + await queryRunner.query(` + UPDATE freight.bookings b + SET freight_type = 'CONTAINER' + WHERE EXISTS ( + SELECT 1 FROM freight.booking_container bc WHERE bc.booking_id = b.id + ); + `); + + await queryRunner.query(` + UPDATE freight.bookings b + SET freight_type = 'BULK' + WHERE freight_type IS NULL + AND b.cargo_type_id IS NOT NULL + AND EXISTS ( + SELECT 1 FROM freight.cargo_types ct + WHERE ct.id = b.cargo_type_id AND ct.requires_director_approval = true + ); + `); + + await queryRunner.query(` + UPDATE freight.bookings + SET freight_type = 'CONTAINER' + WHERE freight_type IS NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN cargo_type_id DROP NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN freight_type SET NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD CONSTRAINT chk_bookings_freight_type + CHECK (freight_type IN ('CONTAINER', 'BULK')); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS chk_bookings_freight_type; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings DROP COLUMN IF EXISTS freight_type; + `); + await queryRunner.query(` + UPDATE freight.bookings SET cargo_type_id = ( + SELECT id FROM freight.cargo_types LIMIT 1 + ) WHERE cargo_type_id IS NULL; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN cargo_type_id SET NOT NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts b/apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts new file mode 100644 index 000000000..8126b91ca --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddContractSignatures1749400000000 implements MigrationInterface { + name = 'AddContractSignatures1749400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS contract_template_key VARCHAR(80), + ADD COLUMN IF NOT EXISTS contract_generated_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS pricing_breakdown JSONB; + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.booking_contract_signatures ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + signer_role VARCHAR(20) NOT NULL, + signer_user_id UUID, + signer_display_name VARCHAR(200) NOT NULL, + signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + signature_file_id UUID REFERENCES freight.files(id) ON DELETE SET NULL, + consent_text TEXT, + ip_address VARCHAR(64), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uq_booking_contract_signatures_role + UNIQUE (booking_id, signer_role) + ); + CREATE INDEX IF NOT EXISTS idx_booking_contract_signatures_booking_id + ON freight.booking_contract_signatures(booking_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_contract_signatures;`); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS pricing_breakdown, + DROP COLUMN IF EXISTS contract_generated_at, + DROP COLUMN IF EXISTS contract_template_key; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts index 65c317eaf..4eadff0fc 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -1,16 +1,34 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { Readable } from 'stream'; +import { ContractPdfService } from '../../contracts/contract-pdf.service'; +import { ContractRendererService } from '../../contracts/contract-renderer.service'; +import { getTemplateMeta } from '../../contracts/contract-template.registry'; +import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; +import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; +import { MinioService } from '../minio/minio.service'; import { FilesService } from '../files/files.service'; import { BookingsRepository } from './bookings.repository'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; +import { ContractViewDto } from './dto/contract-view.dto'; +import { SignContractDto } from './dto/sign-contract.dto'; +import { ContractSignerRole } from './entities/booking-contract-signature.entity'; @Injectable() export class BookingContractService { constructor( private readonly bookingsRepository: BookingsRepository, private readonly filesService: FilesService, + private readonly minioService: MinioService, + private readonly templateResolver: ContractTemplateResolver, + private readonly viewModelBuilder: ContractViewModelBuilder, + private readonly renderer: ContractRendererService, + private readonly pdfService: ContractPdfService, ) {} buildContractSummary(booking: Booking): string { @@ -22,7 +40,7 @@ export class BookingContractService { : booking.tradeDirection; const cargo = booking.cargoType; - const isBulk = cargo?.requiresDirectorApproval; + const isBulk = booking.freightType === 'BULK'; let cargoLabel: string; if (isBulk) { @@ -36,7 +54,7 @@ export class BookingContractService { cargoLabel = lines.length > 0 ? `Container (${lines.join(', ')})` - : `Container (${cargo?.cargoTypeName ?? 'Standard'})`; + : 'Container (Standard)'; } return `Operation: ${direction} | Cargo Type: ${cargoLabel}`; @@ -48,25 +66,117 @@ export class BookingContractService { return { summary }; } + async getContractView(bookingId: string): Promise { + const { view } = await this.viewModelBuilder.build(bookingId); + await this.enrichSignatureUrls(view.signatures); + const html = this.renderer.render(view); + return { + bookingId: view.bookingId, + reference: view.reference, + status: view.status, + templateKey: view.templateKey, + title: view.template.title, + html, + canSignCustomer: view.canSignCustomer, + canSignStaff: view.canSignStaff, + hasContractDocument: view.hasContractDocument, + signatures: view.signatures, + pricingSchedule: view.pricing as unknown as Record, + }; + } + async generateContract(bookingId: string): Promise { const booking = await this.requireBooking(bookingId); assertBookingStatus(booking, ['APPROVED']); - const summary = this.buildContractSummary(booking); - const body = [ - 'FREIGHT CONTRACT (STUB)', - `Reference: ${booking.reference}`, - summary, - `Total: ${booking.totalAmount} ${booking.paymentCurrency}`, - `Trade: ${booking.tradeDirection}`, - ].join('\n'); + const templateKey = this.templateResolver.resolve(booking); + const { view } = await this.viewModelBuilder.build(bookingId); + view.templateKey = templateKey; + view.template = getTemplateMeta(templateKey); + + const html = this.renderer.render(view); + const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html); + const summary = this.buildContractSummary(booking); - const buffer = Buffer.from(body, 'utf-8'); const file: Express.Multer.File = { fieldname: 'contract', - originalname: `contract-${booking.reference}.txt`, + originalname: `contract-${booking.reference}.pdf`, encoding: '7bit', - mimetype: 'text/plain', + mimetype: 'application/pdf', + size: pdfBuffer.length, + buffer: pdfBuffer, + stream: Readable.from(pdfBuffer), + destination: '', + filename: '', + path: '', + }; + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'contract', + file, + }); + + const now = new Date(); + const updated = await this.bookingsRepository.update(bookingId, { + status: 'CONTRACT_READY', + contractSummary: summary, + contractTemplateKey: templateKey, + contractGeneratedAt: now, + } as never); + return updated!; + } + + async streamContract(bookingId: string) { + try { + const record = await this.filesService.findByCode( + bookingId, + 'bookings', + 'contract', + ); + return this.filesService.streamById(record.id); + } catch { + throw new NotFoundException( + 'Contract document not found. Generate the contract first.', + ); + } + } + + async signContract( + bookingId: string, + dto: SignContractDto, + options: { signerUserId?: string; ipAddress?: string }, + ): Promise { + const booking = await this.requireBooking(bookingId); + const role = dto.role as ContractSignerRole; + + if (role === 'CUSTOMER') { + assertBookingStatus(booking, ['CONTRACT_READY']); + const existing = await this.bookingsRepository.findContractSignature( + bookingId, + 'CUSTOMER', + ); + if (existing) { + throw new BadRequestException('Customer has already signed this contract'); + } + } else { + assertBookingStatus(booking, ['SIGNED_CUSTOMER']); + const existing = await this.bookingsRepository.findContractSignature( + bookingId, + 'STAFF', + ); + if (existing) { + throw new BadRequestException('Staff has already signed this contract'); + } + } + + const buffer = this.decodeSignatureImage(dto.signatureImageBase64); + const sigFile: Express.Multer.File = { + fieldname: `signature_${role.toLowerCase()}`, + originalname: `signature-${role.toLowerCase()}-${booking.reference}.png`, + encoding: '7bit', + mimetype: 'image/png', size: buffer.length, buffer, stream: Readable.from(buffer), @@ -75,27 +185,71 @@ export class BookingContractService { path: '', }; - await this.filesService.upload({ + const fileRecord = await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', - code: 'contract', - file, + code: role === 'CUSTOMER' ? 'signature_customer' : 'signature_staff', + file: sigFile, }); - const updated = await this.bookingsRepository.update(bookingId, { - status: 'CONTRACT_READY', - contractSummary: summary, - } as never); + const now = new Date(); + await this.bookingsRepository.saveContractSignature({ + bookingId, + signerRole: role, + signerUserId: options.signerUserId ?? null, + signerDisplayName: dto.signerDisplayName, + signedAt: now, + signatureFileId: fileRecord.id, + consentText: dto.consentText ?? null, + ipAddress: options.ipAddress ?? null, + }); + + const updates: Record = {}; + + if (role === 'CUSTOMER') { + updates.status = 'SIGNED_CUSTOMER'; + updates.customerSignedAt = now; + } else { + updates.status = 'FULLY_EXECUTED'; + updates.fullyExecutedAt = now; + updates.marketingApprovedAt = now; + updates.marketingApprovedById = options.signerUserId ?? null; + updates.lockedAt = now; + } + + const updated = await this.bookingsRepository.update(bookingId, updates as never); return updated!; } - async streamContract(bookingId: string) { - const record = await this.filesService.findByCode( - bookingId, - 'bookings', - 'contract', - ); - return this.filesService.streamById(record.id); + async getSignatures(bookingId: string) { + const rows = await this.bookingsRepository.findContractSignatures(bookingId); + const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r)); + await this.enrichSignatureUrls(views); + return { signatures: views }; + } + + private async enrichSignatureUrls( + signatures: Array<{ signatureImageUrl?: string | null }>, + ): Promise { + for (const sig of signatures) { + if (!sig.signatureImageUrl) continue; + try { + const objectName = this.extractObjectName(sig.signatureImageUrl); + sig.signatureImageUrl = await this.minioService.getSignedUrl(objectName, 3600); + } catch { + /* keep original url */ + } + } + } + + private extractObjectName(url: string): string { + const parts = url.split('/'); + return parts.slice(4).join('/'); + } + + private decodeSignatureImage(base64: string): Buffer { + const raw = base64.includes(',') ? base64.split(',')[1]! : base64; + return Buffer.from(raw, 'base64'); } private async requireBooking(id: string): Promise { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts new file mode 100644 index 000000000..cf4561c7b --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts @@ -0,0 +1,43 @@ +import { BadRequestException } from '@nestjs/common'; + +import { FREIGHT_TYPES, FreightType } from './entities/booking.entity'; +import { BookingFreightShapeInput } from './dto/validators/booking-freight.validator'; + +/** Normalize and validate booking freight shape (used on create and after update merge). */ +export function assertFreightShape(input: BookingFreightShapeInput): void { + if (!input.freightType || !FREIGHT_TYPES.includes(input.freightType as FreightType)) { + throw new BadRequestException( + `freightType is required and must be one of: ${FREIGHT_TYPES.join(', ')}`, + ); + } + + const containers = input.containers ?? []; + const hasContainers = containers.length > 0; + const hasCargoType = Boolean(input.cargoTypeId); + + if (input.freightType === 'BULK') { + if (hasContainers) { + throw new BadRequestException( + 'BULK freight cannot include container lines; use cargoTypeId only', + ); + } + if (!hasCargoType) { + throw new BadRequestException('cargoTypeId is required for BULK freight'); + } + return; + } + + if (hasCargoType) { + throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight'); + } + if (!hasContainers) { + throw new BadRequestException( + 'CONTAINER freight requires at least one container line with containerTypeId', + ); + } + for (const line of containers) { + if (!line.containerTypeId) { + throw new BadRequestException('Each container line must include containerTypeId'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 621fb58e9..4e47456e0 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -1,14 +1,8 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; -import { - IRatesRepository, - RATES_REPOSITORY, -} from '../rule-engine/interfaces/rates.repository.interface'; -import { - IServiceTypesRepository, - SERVICE_TYPES_REPOSITORY, -} from '../rule-engine/interfaces/service-types.repository.interface'; +import { RatesService } from '../rule-engine/services/rates.service'; +import { ServiceTypesService } from '../rule-engine/services/service-types.service'; import { Rate } from '../rule-engine/entities/rate.entity'; import { AppliedCargoModifier, @@ -26,10 +20,8 @@ export class BookingPricingService { private readonly bookingsRepository: BookingsRepository, private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, - @Inject(RATES_REPOSITORY) - private readonly ratesRepo: IRatesRepository, - @Inject(SERVICE_TYPES_REPOSITORY) - private readonly serviceTypesRepo: IServiceTypesRepository, + private readonly ratesService: RatesService, + private readonly serviceTypesService: ServiceTypesService, ) {} async generatePrice(bookingId: string): Promise { @@ -37,6 +29,7 @@ export class BookingPricingService { assertBookingStatus(booking, ['DRAFT']); const evalInput = await this.buildEvalInputForBooking(booking); + console.log('evalInput----', evalInput); const ruleResult = await this.ruleEngineService.evaluate(evalInput); this.ruleEngineService.assertNoHardBlocks(ruleResult); @@ -65,6 +58,12 @@ export class BookingPricingService { await this.bookingsRepository.update(bookingId, { totalAmount: total, priorityScore: ruleResult.priorityScore, + pricingBreakdown: { + lineItems, + totalAmount: total, + currency: booking.paymentCurrency, + generatedAt: new Date().toISOString(), + }, } as never); return { @@ -92,7 +91,8 @@ export class BookingPricingService { }), ); return { - cargoTypeId: booking.cargoTypeId, + freightType: booking.freightType as 'CONTAINER' | 'BULK', + cargoTypeId: booking.cargoTypeId ?? null, serviceTypeId: booking.serviceTypeId, paymentCurrency: booking.paymentCurrency, tradeDirection: booking.tradeDirection, @@ -109,13 +109,71 @@ export class BookingPricingService { return booking; } + /** Line items for contract schedule (uses stored breakdown or recomputes). */ + async computeContractLineItems(booking: Booking): Promise<{ + lineItems: PriceLineItemDto[]; + totalAmount: number; + currency: string; + }> { + const stored = booking.pricingBreakdown as { + lineItems?: PriceLineItemDto[]; + totalAmount?: number; + currency?: string; + } | null; + + if (stored?.lineItems?.length) { + return { + lineItems: stored.lineItems, + totalAmount: Number(stored.totalAmount ?? booking.totalAmount), + currency: stored.currency ?? booking.paymentCurrency, + }; + } + + const evalInput = await this.buildEvalInputForBooking(booking); + const ruleResult = await this.ruleEngineService.evaluate(evalInput); + const lineItems: PriceLineItemDto[] = []; + let total = 0; + + const baseLines = await this.computeBaseRailLines(booking, evalInput); + for (const line of baseLines) { + lineItems.push(line); + total += line.amount; + } + + for (const mod of ruleResult.appliedModifiers) { + lineItems.push({ + code: mod.surchargeTypeCode, + description: `Surcharge: ${mod.surchargeTypeCode}`, + amount: mod.calculatedAmount, + currency: mod.currency, + }); + total += mod.calculatedAmount; + } + + if (lineItems.length === 0) { + total = Number(booking.totalAmount); + lineItems.push({ + code: 'TOTAL', + description: 'Contract total', + amount: total, + currency: booking.paymentCurrency, + }); + } + + return { + lineItems, + totalAmount: total || Number(booking.totalAmount), + currency: booking.paymentCurrency, + }; + } + /** Recompute priority on submit (USD + service tier). */ async computeSubmitPriorityScore(booking: Booking): Promise { const evalInput = await this.buildEvalInputForBooking(booking); const ruleResult = await this.ruleEngineService.evaluate(evalInput); let score = ruleResult.priorityScore; - const serviceType = await this.serviceTypesRepo.findById(booking.serviceTypeId); + const serviceType = await this.serviceTypesService.findById(booking.serviceTypeId); if (booking.paymentCurrency === 'USD' && serviceType) { const code = (serviceType.code ?? '').toUpperCase(); const hasForwarding = @@ -136,10 +194,10 @@ export class BookingPricingService { booking: Booking, evalInput: BookingEvaluationInput, ): Promise { - const liveRates = await this.ratesRepo.findLiveRates(); + const liveRates = await this.ratesService.findLiveRates(); const currency = booking.paymentCurrency; - const isBulk = booking.cargoType?.requiresDirectorApproval ?? false; - + const isBulk = booking.freightType === 'BULK'; +console.log('liveRates----', liveRates); const rateType = booking.tradeDirection === 'IMPORT' ? isBulk @@ -151,11 +209,16 @@ export class BookingPricingService { : 'CONTAINER_EXPORT' : 'INTERCITY_CONTAINER'; + + console.log('rateType----', rateType); + const lines: PriceLineItemDto[] = []; const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); for (const container of evalInput.containers) { + console.log('container----', container); const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency); + console.log('rate----', rate); if (!rate) continue; const amount = this.amountForRate(rate, container.quantity, wagonCount); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index d40d6590b..ad76fa7a3 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -42,7 +42,7 @@ export class BookingTransitionService { async requestChanges( bookingId: string, note: string, - actorId?: string, + actorId: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ['SUBMITTED']); @@ -60,19 +60,19 @@ export class BookingTransitionService { return this.bookingsService.findById(updated!.id); } - async acceptIntake(bookingId: string, actorId?: string): Promise { + async acceptIntake(bookingId: string, actorId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ['SUBMITTED']); - await this.ruleEngineService.instantiateApprovalSteps( - bookingId, - booking.cargoTypeId, - ); + await this.ruleEngineService.instantiateApprovalSteps(bookingId, { + freightType: booking.freightType as 'CONTAINER' | 'BULK', + cargoTypeId: booking.cargoTypeId, + }); const updated = await this.bookingsRepository.update(bookingId, { status: 'PENDING_APPROVAL', - approvedByStaffId: actorId ?? booking.approvedByStaffId, - approvedByStaffAt: actorId ? new Date() : booking.approvedByStaffAt, + approvedByStaffId: actorId, + approvedByStaffAt: new Date(), } as never); return this.bookingsService.findById(updated!.id); } @@ -80,7 +80,7 @@ export class BookingTransitionService { async staffReject( bookingId: string, reason: string, - actorId?: string, + actorId: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']); @@ -211,17 +211,14 @@ export class BookingTransitionService { return this.bookingsService.findById(updated!.id); } - async marketingApprove( - bookingId: string, - actorId?: string, - ): Promise { + async marketingApprove(bookingId: string, actorId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ['SIGNED_CUSTOMER']); const updated = await this.bookingsRepository.update(bookingId, { status: 'FULLY_EXECUTED', fullyExecutedAt: new Date(), - marketingApprovedById: actorId ?? null, + marketingApprovedById: actorId, marketingApprovedAt: new Date(), lockedAt: new Date(), } as never); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index c8572dd76..03c5cfde4 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -14,8 +14,11 @@ import { Res, StreamableFile, UploadedFiles, + UseGuards, UseInterceptors, } from '@nestjs/common'; +import { CurrentUser } from '@edr/api-common'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, @@ -41,12 +44,16 @@ import { ApproveStepDto, CancelBookingDto, RejectStepDto, - MarketingApproveDto, RequestChangesDto, - StaffAcceptDto, StaffRejectDto, } from './dto/request-changes.dto'; +import { ContractViewDto } from './dto/contract-view.dto'; +import { SignContractDto } from './dto/sign-contract.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; +import { + type AuthUserPayload, + resolveAuthUserId, +} from '../../common/resolve-auth-user-id'; @ApiTags('bookings') @Controller('bookings') @@ -155,96 +162,146 @@ export class BookingsController { } @Post(':id/staff/request-changes') + @UseGuards(JwtGuard) @ApiOperation({ summary: 'Staff return booking for customer updates' }) async requestChanges( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RequestChangesDto, + @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.requestChanges( id, dto.note, - dto.actorId, + resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/staff/accept') + @UseGuards(JwtGuard) @ApiOperation({ summary: 'Staff accept intake → start approval chain' }) async acceptIntake( @Param('id', ParseUUIDPipe) id: string, - @Body() dto: StaffAcceptDto, + @CurrentUser() user: AuthUserPayload, ) { - const booking = await this.transitionService.acceptIntake(id, dto.actorId); + const booking = await this.transitionService.acceptIntake( + id, + resolveAuthUserId(user), + ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/staff/reject') + @UseGuards(JwtGuard) @ApiOperation({ summary: 'Staff final reject' }) async staffReject( @Param('id', ParseUUIDPipe) id: string, @Body() dto: StaffRejectDto, + @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.staffReject( id, dto.reason, - dto.actorId, + resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/approval-steps/:stepId/approve') + @UseGuards(JwtGuard) @ApiOperation({ summary: 'Approve one approval step in sequence' }) async approveStep( @Param('id', ParseUUIDPipe) id: string, @Param('stepId', ParseUUIDPipe) stepId: string, @Body() dto: ApproveStepDto, + @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.approveStep( id, stepId, - dto.actorId, + resolveAuthUserId(user), dto.requiredRole, ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/approval-steps/:stepId/reject') + @UseGuards(JwtGuard) @ApiOperation({ summary: 'Reject at approval step' }) async rejectStep( @Param('id', ParseUUIDPipe) id: string, @Param('stepId', ParseUUIDPipe) stepId: string, @Body() dto: RejectStepDto, + @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.rejectStep( id, stepId, - dto.actorId, + resolveAuthUserId(user), dto.reason, ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/contract/generate') - @ApiOperation({ summary: 'Generate contract document' }) + @UseGuards(JwtGuard) + @ApiOperation({ summary: 'Generate contract PDF from template' }) async generateContract(@Param('id', ParseUUIDPipe) id: string) { const booking = await this.contractService.generateContract(id); return this.transitionService.enrichBookingResponse(booking); } - @Get(':id/contract') - @ApiOperation({ summary: 'Download contract file' }) - async downloadContract( + @Get(':id/contract/view') + @ApiOkResponse({ type: ContractViewDto }) + @ApiOperation({ summary: 'Contract HTML view for portal and backoffice' }) + getContractView(@Param('id', ParseUUIDPipe) id: string) { + return this.contractService.getContractView(id); + } + + @Get(':id/contract/document') + @ApiOperation({ summary: 'Download contract PDF' }) + async downloadContractDocument( @Param('id', ParseUUIDPipe) id: string, @Res({ passthrough: true }) res: Response, ) { const { stream, record } = await this.contractService.streamContract(id); res.set({ - 'Content-Type': record.mimeType ?? 'application/octet-stream', + 'Content-Type': record.mimeType ?? 'application/pdf', 'Content-Disposition': `attachment; filename="${record.name}"`, }); return new StreamableFile(stream); } + @Get(':id/contract') + @ApiOperation({ summary: 'Download contract file (alias)' }) + async downloadContract( + @Param('id', ParseUUIDPipe) id: string, + @Res({ passthrough: true }) res: Response, + ) { + return this.downloadContractDocument(id, res); + } + + @Post(':id/contract/sign') + @ApiOperation({ summary: 'Apply digital signature (customer or staff)' }) + async signContract( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SignContractDto, + @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, + ) { + const userId = req.user?.id ?? req.user?.sub; + const booking = await this.contractService.signContract(id, dto, { + signerUserId: userId, + ipAddress: req.ip, + }); + return this.transitionService.enrichBookingResponse(booking); + } + + @Get(':id/contract/signatures') + @ApiOperation({ summary: 'List contract signatures' }) + getContractSignatures(@Param('id', ParseUUIDPipe) id: string) { + return this.contractService.getSignatures(id); + } + @Get(':id/summary') @ApiOperation({ summary: 'Contract summary string for dashboard' }) getSummary(@Param('id', ParseUUIDPipe) id: string) { @@ -252,22 +309,41 @@ export class BookingsController { } @Post(':id/customer/sign') - @ApiOperation({ summary: 'Customer digital signature' }) - async customerSign(@Param('id', ParseUUIDPipe) id: string) { - const booking = await this.transitionService.customerSign(id); + @ApiOperation({ + summary: 'Customer digital signature (deprecated — use POST contract/sign)', + }) + async customerSign( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SignContractDto, + @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, + ) { + const payload: SignContractDto = { ...dto, role: 'CUSTOMER' }; + const booking = await this.contractService.signContract(id, payload, { + signerUserId: req.user?.id ?? req.user?.sub, + ipAddress: req.ip, + }); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/marketing/approve') - @ApiOperation({ summary: 'Marketing verify and fully execute' }) + @UseGuards(JwtGuard) + @ApiOperation({ + summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)', + }) async marketingApprove( @Param('id', ParseUUIDPipe) id: string, - @Body() dto: MarketingApproveDto, + @Body() dto: SignContractDto, + @CurrentUser() user: AuthUserPayload, + @Request() req: { ip?: string }, ) { - const booking = await this.transitionService.marketingApprove( - id, - dto.actorId, - ); + const payload: SignContractDto = { + ...dto, + role: 'STAFF', + }; + const booking = await this.contractService.signContract(id, payload, { + signerUserId: resolveAuthUserId(user), + ipAddress: req.ip, + }); return this.transitionService.enrichBookingResponse(booking); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index f3b3a9360..1f96176ba 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -19,8 +19,14 @@ import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingContainer } from './entities/booking-container.entity'; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; +import { BookingContractSignature } from './entities/booking-contract-signature.entity'; import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; +import { ContractPdfService } from '../../contracts/contract-pdf.service'; +import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder'; +import { ContractRendererService } from '../../contracts/contract-renderer.service'; +import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; +import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; @Module({ imports: [ @@ -31,6 +37,7 @@ import { Booking } from './entities/booking.entity'; BookingApprovalStep, BookingRateSnapshot, BookingReviewNote, + BookingContractSignature, ]), FilesModule, MinioModule, @@ -47,6 +54,11 @@ import { Booking } from './entities/booking.entity'; BookingTransitionService, BookingContractService, BookingPaymentService, + ContractTemplateResolver, + ContractViewModelBuilder, + ContractPricingScheduleBuilder, + ContractRendererService, + ContractPdfService, ], exports: [BookingsService], }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 3a6155f3b..04c8a2fb7 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -10,6 +10,10 @@ import { BookingContainer } from './entities/booking-container.entity'; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; +import { + BookingContractSignature, + ContractSignerRole, +} from './entities/booking-contract-signature.entity'; import { FileRecord } from '../files/entities/file.entity'; import { ContainerWeightResult } from '../rule-engine/rule-engine.service'; @@ -353,7 +357,7 @@ export class BookingsRepository extends BaseRepository { .where('booking.status IN (:...statuses)', { statuses }); if (options.excludeBulk) { - qb.andWhere('cargo.requires_director_approval = false'); + qb.andWhere("booking.freight_type = 'CONTAINER'"); } const sortField = @@ -380,4 +384,39 @@ export class BookingsRepository extends BaseRepository { order: options.order, }); } + + findContractSignatures(bookingId: string): Promise { + return this.dataSource.getRepository(BookingContractSignature).find({ + where: { bookingId }, + relations: ['signatureFile'], + order: { signedAt: 'ASC' }, + }); + } + + findContractSignature( + bookingId: string, + role: ContractSignerRole, + ): Promise { + return this.dataSource.getRepository(BookingContractSignature).findOne({ + where: { bookingId, signerRole: role }, + relations: ['signatureFile'], + }); + } + + async saveContractSignature( + data: Partial, + ): Promise { + const repo = this.dataSource.getRepository(BookingContractSignature); + const existing = await repo.findOne({ + where: { + bookingId: data.bookingId!, + signerRole: data.signerRole!, + }, + }); + if (existing) { + Object.assign(existing, data); + return repo.save(existing); + } + return repo.save(repo.create(data)); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 1039e60c1..f4351a1e6 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -16,10 +16,11 @@ import { } from '../rule-engine/rule-engine.service'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; +import { assertFreightShape } from './booking-freight.util'; import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto'; import { FilterBookingDto } from './dto/filter-booking.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; -import { CUSTOMER_EDITABLE_STATUSES } from './entities/booking.entity'; +import { CUSTOMER_EDITABLE_STATUSES, FreightType } from './entities/booking.entity'; import { Booking } from './entities/booking.entity'; import { FileRecord } from '../files/entities/file.entity'; @@ -42,22 +43,23 @@ export class BookingsService { return `BK-${year}-${String(count + 1).padStart(6, '0')}`; } - /** Build evaluation input from DTO containers. */ - private async buildEvalInput( - dto: Pick< - CreateBookingDto, - | 'cargoTypeId' - | 'serviceTypeId' - | 'paymentCurrency' - | 'tradeDirection' - | 'isHazardous' - | 'allowConsolidation' - | 'shippingLineId' - | 'containers' - >, - ): Promise { + /** Build evaluation input from booking freight shape. */ + private async buildEvalInput(dto: { + freightType: FreightType; + cargoTypeId?: string | null; + serviceTypeId: string; + paymentCurrency: string; + tradeDirection: string; + isHazardous?: boolean; + allowConsolidation?: boolean; + shippingLineId?: string | null; + containers: CreateBookingContainerDto[]; + }): Promise { + const containerLines = + dto.freightType === 'CONTAINER' ? dto.containers : []; + const containers = await Promise.all( - dto.containers.map(async (c) => { + containerLines.map(async (c) => { const ct = await this.containerTypesService.findById(c.containerTypeId); const totalVgmTons = c.quantity * c.vgmPerUnitTons; return { @@ -69,13 +71,16 @@ export class BookingsService { }; }), ); + return { - cargoTypeId: dto.cargoTypeId, + freightType: dto.freightType, + cargoTypeId: dto.cargoTypeId ?? null, serviceTypeId: dto.serviceTypeId, paymentCurrency: dto.paymentCurrency, tradeDirection: dto.tradeDirection, isHazardous: dto.isHazardous ?? false, - allowConsolidation: dto.allowConsolidation, + allowConsolidation: + dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false, shippingLineId: dto.shippingLineId, containers, }; @@ -161,12 +166,29 @@ export class BookingsService { } const reference = dto.reference || (await this.generateReference()); - const allowConsolidation = await this.resolveConsolidation( - dto.containers, - dto.allowConsolidation, - ); + const containers = dto.containers ?? []; + assertFreightShape({ + freightType: dto.freightType, + cargoTypeId: dto.cargoTypeId, + containers, + }); - const evalInput = await this.buildEvalInput({ ...dto, allowConsolidation }); + const allowConsolidation = + dto.freightType === 'CONTAINER' + ? await this.resolveConsolidation(containers, dto.allowConsolidation) + : false; + + const evalInput = await this.buildEvalInput({ + freightType: dto.freightType as FreightType, + cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null, + serviceTypeId: dto.serviceTypeId, + paymentCurrency: dto.paymentCurrency, + tradeDirection: dto.tradeDirection, + isHazardous: dto.isHazardous, + allowConsolidation, + shippingLineId: dto.shippingLineId, + containers, + }); const ruleResult = await this.ruleEngineService.evaluate(evalInput); this.ruleEngineService.assertNoHardBlocks(ruleResult); @@ -185,7 +207,8 @@ export class BookingsService { originYardId: dto.originYardId, destinationYardId: dto.destinationYardId, tradeDirection: dto.tradeDirection, - cargoTypeId: dto.cargoTypeId, + freightType: dto.freightType, + cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null, cargoFreeText: dto.cargoFreeText, shippingLineId: dto.shippingLineId, cargoTotalWeightVgm: dto.cargoTotalWeightVgm, @@ -203,18 +226,19 @@ export class BookingsService { paymentStatus: 'PENDING', }); - await this.bookingsRepository.createContainers( - booking.id, - dto.containers.map((c, i) => ({ - containerTypeId: c.containerTypeId, - quantity: c.quantity, - vgmPerUnitTons: c.vgmPerUnitTons, - weightResult: ruleResult.containerWeightResults[i], - })), - ); - - const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); - warnings.push(`Estimated wagons required: ${wagonCount}`); + if (dto.freightType === 'CONTAINER') { + await this.bookingsRepository.createContainers( + booking.id, + containers.map((c, i) => ({ + containerTypeId: c.containerTypeId, + quantity: c.quantity, + vgmPerUnitTons: c.vgmPerUnitTons, + weightResult: ruleResult.containerWeightResults[i], + })), + ); + const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); + warnings.push(`Estimated wagons required: ${wagonCount}`); + } if (files.length > 0) { try { @@ -249,19 +273,44 @@ export class BookingsService { } const warnings: string[] = []; - const containers = dto.containers ?? existing.bookingContainers?.map((bc) => ({ - containerTypeId: bc.containerTypeId, - quantity: bc.quantity, - vgmPerUnitTons: Number(bc.vgmPerUnitTons), - })) ?? []; + const freightType = (dto.freightType ?? existing.freightType) as FreightType; + let containers = + dto.containers ?? + existing.bookingContainers?.map((bc) => ({ + containerTypeId: bc.containerTypeId, + quantity: bc.quantity, + vgmPerUnitTons: Number(bc.vgmPerUnitTons), + })) ?? + []; - const allowConsolidation = await this.resolveConsolidation( - containers, - dto.allowConsolidation ?? existing.allowConsolidation, - ); + let cargoTypeId = + dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; + + if (freightType === 'BULK') { + containers = []; + if (dto.containers !== undefined) { + await this.bookingsRepository.deleteContainers(id); + } + } else { + cargoTypeId = null; + if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== null) { + throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight'); + } + } + + assertFreightShape({ freightType, cargoTypeId, containers }); + + const allowConsolidation = + freightType === 'CONTAINER' + ? await this.resolveConsolidation( + containers, + dto.allowConsolidation ?? existing.allowConsolidation, + ) + : false; const evalInput = await this.buildEvalInput({ - cargoTypeId: dto.cargoTypeId ?? existing.cargoTypeId, + freightType, + cargoTypeId, serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId, paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, tradeDirection: dto.tradeDirection ?? existing.tradeDirection, @@ -277,6 +326,8 @@ export class BookingsService { const updates: Record = { ...dto, + freightType, + cargoTypeId: freightType === 'BULK' ? cargoTypeId : null, allowConsolidation, priorityScore: ruleResult.priorityScore, }; @@ -287,7 +338,7 @@ export class BookingsService { await this.bookingsRepository.update(id, updates); - if (dto.containers) { + if (freightType === 'CONTAINER' && dto.containers) { await this.bookingsRepository.deleteContainers(id); await this.bookingsRepository.createContainers( id, @@ -328,6 +379,7 @@ export class BookingsService { if (filter.contractType) where.contractType = filter.contractType; if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId; if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId; + if (filter.freightType) where.freightType = filter.freightType; if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection; if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency; if (filter.allowConsolidation !== undefined) { @@ -347,6 +399,7 @@ export class BookingsService { skip: (page - 1) * pageSize, take: pageSize, order: { [sortField]: sortDir }, + relations: ['customer', 'originYard', 'destinationYard', 'serviceType'], }); return { items, total }; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts new file mode 100644 index 000000000..4af9e535d --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts @@ -0,0 +1,50 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ContractSignatureDto { + @ApiProperty({ enum: ['CUSTOMER', 'STAFF'] }) + role!: string; + + @ApiProperty() + signerDisplayName!: string; + + @ApiProperty() + signedAt!: string; + + @ApiPropertyOptional() + signatureImageUrl?: string | null; +} + +export class ContractViewDto { + @ApiProperty() + bookingId!: string; + + @ApiProperty() + reference!: string; + + @ApiProperty() + status!: string; + + @ApiProperty() + templateKey!: string; + + @ApiProperty() + title!: string; + + @ApiProperty({ description: 'Full HTML document for in-browser display' }) + html!: string; + + @ApiProperty() + canSignCustomer!: boolean; + + @ApiProperty() + canSignStaff!: boolean; + + @ApiProperty() + hasContractDocument!: boolean; + + @ApiProperty({ type: [ContractSignatureDto] }) + signatures!: ContractSignatureDto[]; + + @ApiPropertyOptional() + pricingSchedule?: Record; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 0d61752f4..30cfd9fab 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { + ArrayMinSize, IsArray, IsBoolean, IsDateString, @@ -11,9 +12,12 @@ import { IsString, IsUUID, Min, + Validate, + ValidateIf, ValidateNested, } from 'class-validator'; -import { BOOKING_STATUSES } from '../entities/booking.entity'; +import { BOOKING_STATUSES, FREIGHT_TYPES } from '../entities/booking.entity'; +import { BookingFreightShapeConstraint } from './validators/booking-freight.validator'; const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const; const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN', 'NA'] as const; @@ -24,6 +28,7 @@ export { BOOKING_STATUSES, CONTRACT_TYPES, EQUIPMENT_RETURNS, + FREIGHT_TYPES, TRADE_DIRECTIONS, PAYMENT_CURRENCIES, }; @@ -47,6 +52,9 @@ export class CreateBookingContainerDto { } export class CreateBookingDto { + /** Class-level freight shape check (not a request field). */ + @Validate(BookingFreightShapeConstraint) + freightShapeValidation?: boolean; @ApiPropertyOptional({ description: 'Unique booking reference (auto-generated if omitted)' }) @IsOptional() @IsString() @@ -107,9 +115,17 @@ export class CreateBookingDto { @IsIn([...TRADE_DIRECTIONS]) tradeDirection!: string; - @ApiProperty({ format: 'uuid', description: 'FK to cargo_types.id' }) + @ApiProperty({ enum: FREIGHT_TYPES, description: 'CONTAINER or BULK (mutually exclusive cargo shape)' }) + @IsIn([...FREIGHT_TYPES]) + freightType!: string; + + @ApiPropertyOptional({ + format: 'uuid', + description: 'Required for BULK; must be omitted for CONTAINER', + }) + @ValidateIf((o) => o.freightType === 'BULK') @IsUUID() - cargoTypeId!: string; + cargoTypeId?: string; @ApiPropertyOptional({ maxLength: 200 }) @IsOptional() @@ -157,11 +173,16 @@ export class CreateBookingDto { @IsString() financialTerms?: string; - @ApiProperty({ type: [CreateBookingContainerDto] }) + @ApiPropertyOptional({ + type: [CreateBookingContainerDto], + description: 'Required for CONTAINER (min 1 line); must be empty for BULK', + }) + @ValidateIf((o) => o.freightType === 'CONTAINER') @IsArray() + @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => CreateBookingContainerDto) - containers!: CreateBookingContainerDto[]; + containers?: CreateBookingContainerDto[]; @ApiPropertyOptional({ default: false }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index 912064905..cdf4a6b18 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -1,7 +1,12 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsIn, IsOptional, IsUUID } from 'class-validator'; -import { BOOKING_STATUSES, PAYMENT_CURRENCIES, TRADE_DIRECTIONS } from './create-booking.dto'; +import { + BOOKING_STATUSES, + FREIGHT_TYPES, + PAYMENT_CURRENCIES, + TRADE_DIRECTIONS, +} from './create-booking.dto'; export class FilterBookingDto { @ApiPropertyOptional({ enum: BOOKING_STATUSES }) @@ -28,6 +33,11 @@ export class FilterBookingDto { @IsUUID() cargoTypeId?: string; + @ApiPropertyOptional({ enum: FREIGHT_TYPES }) + @IsOptional() + @IsIn([...FREIGHT_TYPES]) + freightType?: string; + @ApiPropertyOptional({ enum: TRADE_DIRECTIONS }) @IsOptional() @IsIn([...TRADE_DIRECTIONS]) diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index 8e77b51fc..6f716388c 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -1,30 +1,11 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MinLength } from 'class-validator'; export class RequestChangesDto { @ApiProperty({ description: 'Staff note explaining what the customer must fix' }) @IsString() @MinLength(1) note!: string; - - @ApiPropertyOptional({ format: 'uuid' }) - @IsOptional() - @IsUUID() - actorId?: string; -} - -export class StaffAcceptDto { - @ApiPropertyOptional({ format: 'uuid' }) - @IsOptional() - @IsUUID() - actorId?: string; -} - -export class MarketingApproveDto { - @ApiPropertyOptional({ format: 'uuid' }) - @IsOptional() - @IsUUID() - actorId?: string; } export class StaffRejectDto { @@ -32,28 +13,15 @@ export class StaffRejectDto { @IsString() @MinLength(1) reason!: string; - - @ApiPropertyOptional({ format: 'uuid' }) - @IsOptional() - @IsUUID() - actorId?: string; } export class ApproveStepDto { - @ApiProperty({ format: 'uuid' }) - @IsUUID() - actorId!: string; - @ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' }) @IsString() requiredRole!: string; } export class RejectStepDto { - @ApiProperty({ format: 'uuid' }) - @IsUUID() - actorId!: string; - @ApiProperty() @IsString() @MinLength(1) diff --git a/apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts new file mode 100644 index 000000000..0b176ebd5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString, MinLength } from 'class-validator'; + +export class SignContractDto { + @ApiProperty({ enum: ['CUSTOMER', 'STAFF'] }) + @IsIn(['CUSTOMER', 'STAFF']) + role!: 'CUSTOMER' | 'STAFF'; + + @ApiProperty({ description: 'PNG signature image as base64 (with or without data URL prefix)' }) + @IsString() + @MinLength(20) + signatureImageBase64!: string; + + @ApiProperty() + @IsString() + @MinLength(1) + signerDisplayName!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + consentText?: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts index 2b97debc6..328e71180 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts @@ -1,5 +1,10 @@ -import { PartialType } from "@nestjs/mapped-types"; +import { PartialType } from '@nestjs/mapped-types'; +import { Validate } from 'class-validator'; -import { CreateBookingDto } from "./create-booking.dto"; +import { CreateBookingDto } from './create-booking.dto'; +import { BookingFreightShapeConstraint } from './validators/booking-freight.validator'; -export class UpdateBookingDto extends PartialType(CreateBookingDto) {} +export class UpdateBookingDto extends PartialType(CreateBookingDto) { + @Validate(BookingFreightShapeConstraint) + freightShapeValidation?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts b/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts new file mode 100644 index 000000000..1365158b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts @@ -0,0 +1,60 @@ +import { + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +import { FREIGHT_TYPES, FreightType } from '../../entities/booking.entity'; + +export interface BookingFreightShapeInput { + freightType?: string; + cargoTypeId?: string | null; + containers?: Array<{ containerTypeId?: string }> | null; +} + +@ValidatorConstraint({ name: 'BookingFreightShape', async: false }) +export class BookingFreightShapeConstraint implements ValidatorConstraintInterface { + validate(_value: unknown, args: ValidationArguments): boolean { + const dto = args.object as BookingFreightShapeInput; + if (!dto.freightType || !FREIGHT_TYPES.includes(dto.freightType as FreightType)) { + return true; + } + + const containers = dto.containers ?? []; + const hasContainers = containers.length > 0; + const hasCargoType = + dto.cargoTypeId !== undefined && + dto.cargoTypeId !== null && + String(dto.cargoTypeId).trim() !== ''; + + if (dto.freightType === 'BULK') { + if (hasContainers) return false; + if (!hasCargoType) return false; + return true; + } + + if (dto.freightType === 'CONTAINER') { + if (hasCargoType) return false; + if (!hasContainers) return false; + return containers.every( + (c) => + c.containerTypeId !== undefined && + c.containerTypeId !== null && + String(c.containerTypeId).trim() !== '', + ); + } + + return true; + } + + defaultMessage(args: ValidationArguments): string { + const dto = args.object as BookingFreightShapeInput; + if (dto.freightType === 'BULK') { + return 'BULK freight requires cargoTypeId and must not include container lines'; + } + if (dto.freightType === 'CONTAINER') { + return 'CONTAINER freight requires at least one container line with containerTypeId and must not include cargoTypeId'; + } + return 'Invalid freight type shape'; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts new file mode 100644 index 000000000..6370c97c2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts @@ -0,0 +1,44 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm'; +import { FileRecord } from '../../files/entities/file.entity'; +import { Booking } from './booking.entity'; + +export const CONTRACT_SIGNER_ROLES = ['CUSTOMER', 'STAFF'] as const; +export type ContractSignerRole = (typeof CONTRACT_SIGNER_ROLES)[number]; + +@Entity({ schema: 'freight', name: 'booking_contract_signatures' }) +@Unique(['bookingId', 'signerRole']) +@Index(['bookingId']) +export class BookingContractSignature extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'signer_role', type: 'varchar', length: 20 }) + signerRole!: ContractSignerRole; + + @Column({ name: 'signer_user_id', type: 'uuid', nullable: true }) + signerUserId?: string | null; + + @Column({ name: 'signer_display_name', type: 'varchar', length: 200 }) + signerDisplayName!: string; + + @Column({ name: 'signed_at', type: 'timestamptz' }) + signedAt!: Date; + + @Column({ name: 'signature_file_id', type: 'uuid', nullable: true }) + signatureFileId?: string | null; + + @ManyToOne(() => FileRecord, { nullable: true }) + @JoinColumn({ name: 'signature_file_id' }) + signatureFile?: FileRecord | null; + + @Column({ name: 'consent_text', type: 'text', nullable: true }) + consentText?: string | null; + + @Column({ name: 'ip_address', type: 'varchar', length: 64, nullable: true }) + ipAddress?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 6a4140006..eaec84e82 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -46,6 +46,9 @@ export const PAYMENT_STATUSES = [ export type PaymentStatus = (typeof PAYMENT_STATUSES)[number]; +export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const; +export type FreightType = (typeof FREIGHT_TYPES)[number]; + /** Statuses where the customer may edit booking fields. */ export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [ 'DRAFT', @@ -126,8 +129,11 @@ export class Booking extends BaseEntity { @Column({ name: 'trade_direction', type: 'varchar', length: 10 }) tradeDirection!: string; - @Column({ name: 'cargo_type_id', type: 'uuid' }) - cargoTypeId!: string; + @Column({ name: 'freight_type', type: 'varchar', length: 20 }) + freightType!: string; + + @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true }) + cargoTypeId?: string | null; @ManyToOne(() => CargoType) @JoinColumn({ name: 'cargo_type_id' }) @@ -200,6 +206,15 @@ export class Booking extends BaseEntity { @Column({ name: 'contract_summary', type: 'text', nullable: true }) contractSummary?: string | null; + @Column({ name: 'contract_template_key', type: 'varchar', length: 80, nullable: true }) + contractTemplateKey?: string | null; + + @Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true }) + contractGeneratedAt?: Date | null; + + @Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true }) + pricingBreakdown?: Record | null; + @Column({ name: 'locked_at', type: 'timestamptz', nullable: true }) lockedAt?: Date | null; diff --git a/apps/edr-freight-api/src/modules/files/files.repository.ts b/apps/edr-freight-api/src/modules/files/files.repository.ts index 2ec2b94a2..ea4bfd19e 100644 --- a/apps/edr-freight-api/src/modules/files/files.repository.ts +++ b/apps/edr-freight-api/src/modules/files/files.repository.ts @@ -25,4 +25,12 @@ export class FilesRepository extends BaseRepository { ): Promise { return this.repository.findOne({ where: { resourceId, resource, code } }); } + + async deleteByCode( + resourceId: string, + resource: string, + code: string, + ): Promise { + await this.repository.delete({ resourceId, resource, code }); + } } diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index e08fce72b..1f0076986 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -35,6 +35,13 @@ export class FilesService { }); } + /** Replace existing file row for the same resource + code (e.g. contract PDF). */ + async upsertByCode(input: CreateFileInput): Promise { + const { resourceId, resource, code } = input; + await this.filesRepository.deleteByCode(resourceId, resource, code); + return this.upload(input); + } + async uploadMany( resourceId: string, resource: string, diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts index 02408e53d..0ff7ef543 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts @@ -1,9 +1,15 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, - Param, ParseUUIDPipe, Patch, Post, Query, + Param, ParseUUIDPipe, Patch, Post, Query, UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { ApproveRateDto, CreateRateDto } from '../dto/create-rate.dto'; +import { CurrentUser } from '@edr/api-common'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { CreateRateDto } from '../dto/create-rate.dto'; +import { + type AuthUserPayload, + resolveAuthUserId, +} from '../../../common/resolve-auth-user-id'; import { UpdateRateDto } from '../dto/update-rate.dto'; import { RatesService } from '../services/rates.service'; @@ -38,9 +44,13 @@ export class RatesController { } @Post() + @UseGuards(JwtGuard) @ApiOperation({ summary: 'Create a rate (DRAFT)' }) - create(@Body() dto: CreateRateDto) { - return this.service.create(dto); + create( + @Body() dto: CreateRateDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.service.create(dto, resolveAuthUserId(user)); } @Patch(':id') @@ -56,9 +66,13 @@ export class RatesController { } @Post(':id/approve') + @UseGuards(JwtGuard) @ApiOperation({ summary: 'CEO approves a rate' }) - approve(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveRateDto) { - return this.service.approve(id, dto); + approve( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.service.approve(id, resolveAuthUserId(user)); } @Delete(':id') diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index fe9084395..2f73780d9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -35,10 +35,6 @@ export class CreateRateDto { @IsIn([...RATE_UNITS]) rateUnit!: string; - @ApiProperty({ description: 'ID of the staff member (Director) proposing this rate' }) - @IsUUID() - proposedByStaffId!: string; - @ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' }) @IsDateString() effectiveFrom!: string; @@ -49,12 +45,6 @@ export class CreateRateDto { effectiveTo?: string; } -export class ApproveRateDto { - @ApiProperty({ description: 'ID of the CEO approving this rate' }) - @IsUUID() - approvedByCeoId!: string; -} - export class SubmitRateForApprovalDto { @ApiPropertyOptional({ description: 'Optional note for the approval request', maxLength: 500 }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 5fc27ecbd..452bee90b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -47,7 +47,8 @@ export interface BookingContainerEvalInput { } export interface BookingEvaluationInput { - cargoTypeId: string; + cargoTypeId?: string | null; + freightType?: 'CONTAINER' | 'BULK'; serviceTypeId: string; paymentCurrency: string; tradeDirection: string; @@ -115,13 +116,19 @@ export class RuleEngineService { let priorityScore = 0; let requiresDirectorApproval = false; - const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId); - if (!cargoType) { - hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`); - } else if (cargoType.requiresDirectorApproval) { + if (input.freightType === 'BULK') { requiresDirectorApproval = true; } + if (input.cargoTypeId) { + const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId); + if (!cargoType) { + hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`); + } else if (cargoType.requiresDirectorApproval) { + requiresDirectorApproval = true; + } + } + for (const container of input.containers) { const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( container.containerTypeId, @@ -232,16 +239,29 @@ export class RuleEngineService { } /** - * Instantiate booking_approval_step rows from approval_rules for a cargo type. + * Instantiate booking_approval_step rows from approval_rules by freight type. */ - async instantiateApprovalSteps(bookingId: string, cargoTypeId: string): Promise { - const cargoType = await this.cargoTypesRepo.findById(cargoTypeId); - if (!cargoType) { - throw new BadRequestException(`Cargo type ${cargoTypeId} not found`); + async instantiateApprovalSteps( + bookingId: string, + options: { + freightType: 'CONTAINER' | 'BULK'; + cargoTypeId?: string | null; + }, + ): Promise { + let requiresDirectorApproval = options.freightType === 'BULK'; + + if (options.cargoTypeId) { + const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId); + if (!cargoType) { + throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`); + } + if (cargoType.requiresDirectorApproval) { + requiresDirectorApproval = true; + } } const chain = await this.approvalRulesRepo.findChainForCargo( - cargoType.requiresDirectorApproval, + requiresDirectorApproval, ); const stepRepo = this.dataSource.getRepository(BookingApprovalStep); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 656802e3f..0202ef44a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; -import { ApproveRateDto, CreateRateDto } from '../dto/create-rate.dto'; +import { CreateRateDto } from '../dto/create-rate.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; import { Rate } from '../entities/rate.entity'; import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; @@ -46,7 +46,7 @@ export class RatesService { } /** Create a rate in DRAFT status. */ - async create(dto: CreateRateDto): Promise { + async create(dto: CreateRateDto, proposedByStaffId: string): Promise { return this.repository.create({ rateType: dto.rateType as Rate['rateType'], containerTypeId: dto.containerTypeId, @@ -55,7 +55,7 @@ export class RatesService { rateValue: dto.rateValue, rateUnit: dto.rateUnit as Rate['rateUnit'], status: 'DRAFT', - proposedByStaffId: dto.proposedByStaffId, + proposedByStaffId, effectiveFrom: new Date(dto.effectiveFrom), effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined, }); @@ -74,7 +74,6 @@ export class RatesService { if (dto.currency) updates.currency = dto.currency; if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue; if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit']; - if (dto.proposedByStaffId) updates.proposedByStaffId = dto.proposedByStaffId; if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom); if (dto.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo); const updated = await this.repository.update(id, updates); @@ -93,14 +92,14 @@ export class RatesService { } /** CEO approves a rate — moves to LIVE. */ - async approve(id: string, dto: ApproveRateDto): Promise { + async approve(id: string, approverUserId: string): Promise { const rate = await this.findById(id); if (rate.status !== 'PENDING_APPROVAL') { throw new BadRequestException('Only PENDING_APPROVAL rates can be approved'); } const updated = await this.repository.update(id, { status: 'LIVE', - approvedByCeoId: dto.approvedByCeoId, + approvedByCeoId: approverUserId, approvedAt: new Date(), }); return updated!; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts index 98e5b1642..387e26ba9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts @@ -28,6 +28,7 @@ export class SurchargeTypesService { const [data, total] = await this.repository.findAndCount({ where, + relations: { rate: true }, order: { label: 'ASC' }, skip: (page - 1) * pageSize, take: pageSize, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 7cc6a9935..884fde00b 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,5 +1,4 @@ import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Boxes, FileText, @@ -14,6 +13,7 @@ import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@ import LoadingScreen from "./components/LoadingScreen"; import { useAuth } from "./auth/useAuth"; import LoginPage from "./pages/auth/LoginPage"; +import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; @@ -31,16 +31,6 @@ import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirec import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: 1, - refetchOnWindowFocus: false, - staleTime: 5 * 60 * 1000, - }, - }, -}); - const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { title: "Main menu", @@ -188,18 +178,15 @@ const App = () => { if (!user) { return ( - - - } /> - } /> - - + + } /> + } /> + ); } return ( - - + } /> } /> @@ -208,6 +195,10 @@ const App = () => { } /> } /> + } + /> } /> } /> @@ -251,8 +242,7 @@ const App = () => { } /> - - + ); }; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx new file mode 100644 index 000000000..64cca7db3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx @@ -0,0 +1,116 @@ +import { useMemo } from "react"; +import { ShieldCheck } from "lucide-react"; + +import type { BookingApprovalStep, BookingDetail } from "@/types/booking"; +import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config"; +import { Badge } from "@edr/ui-common"; +import { cn } from "@/lib/utils"; + +interface ApprovalStepsCardProps { + booking: BookingDetail; +} + +/** Read-only approval chain visualization; actions live in BookingActionsToolbar. */ +export function ApprovalStepsCard({ booking }: ApprovalStepsCardProps) { + const steps = useMemo( + () => + [...(booking.approvalSteps ?? [])].sort( + (a, b) => a.stepOrder - b.stepOrder, + ), + [booking.approvalSteps], + ); + + const nextPending = getNextPendingApprovalStep(steps); + + return ( +
+
+
+ +
+
+

+ Approval chain +

+

+ Next:{" "} + {nextPending + ? `${nextPending.requiredRole} · step ${nextPending.stepOrder}` + : steps.length + ? "All steps complete" + : "Accept submission to begin"} +

+
+
+ +
+ {steps.length === 0 ? ( +

+ Use Accept for approval{" "} + in staff actions to instantiate steps. +

+ ) : ( +
    + {steps.map((step) => ( + + ))} +
+ )} +
+
+ ); +} + +function StepRow({ + step, + isNext, +}: { + step: BookingApprovalStep; + isNext: boolean; +}) { + const statusStyles = + step.status === "APPROVED" + ? "bg-emerald-500/15 text-emerald-800 dark:text-emerald-300" + : step.status === "REJECTED" + ? "bg-red-500/15 text-red-800 dark:text-red-300" + : isNext + ? "bg-amber-500/15 text-amber-800 dark:text-amber-300" + : "bg-muted text-muted-foreground"; + + return ( +
  • +
    + + {step.stepOrder} + +
    +

    + {step.requiredRole} +

    + {step.remarks && ( +

    + {step.remarks} +

    + )} +
    +
    + + {step.status} + +
  • + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx new file mode 100644 index 000000000..29b4e9b19 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -0,0 +1,233 @@ +import { useNavigate } from "react-router-dom"; +import { + ChevronRight, + ExternalLink, + Loader2, + MoreHorizontal, + Upload, +} from "lucide-react"; + +import { BookingConfirmDialog } from "./BookingConfirmDialog"; +import { useBookingActionDialog } from "./useBookingActionDialog"; +import { + listRowHasActions, + type BookingActionContext, +} from "@/features/bookings/booking-actions.config"; +import type { BookingListRow } from "@/types/booking"; +import { cn } from "@/lib/utils"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@edr/ui-common"; + +interface BookingActionsMenuProps { + row: BookingListRow; + /** Compact table cell vs. larger detail toolbar */ + variant?: "table" | "toolbar"; + className?: string; +} + +export function BookingActionsMenu({ + row, + variant = "table", + className, +}: BookingActionsMenuProps) { + const navigate = useNavigate(); + const context: BookingActionContext = { + status: row.status, + paymentCurrency: row.paymentCurrency, + reference: row.reference, + }; + + const flow = useBookingActionDialog(row.id, context); + const { actions, pendingAction, mutations } = flow; + + const goToContract = () => + navigate(`/dashboard/booking-requests/${row.id}/contract`); + + const showUsdPaymentHint = + row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD"; + const hasMenu = listRowHasActions(row) || showUsdPaymentHint; + + const primary = actions.find((a) => a.primary) ?? actions[0]; + + if (!hasMenu && variant === "table") { + return ( + + ); + } + + return ( + <> +
    e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {variant === "table" && primary && ( + + )} + + {variant === "toolbar" && actions.length > 0 ? ( +
    + {actions.map((action) => { + const Icon = action.icon; + return ( + + ); + })} +
    + ) : ( + + + + + + + {row.reference} + + + {actions.map((action) => { + const Icon = action.icon; + return ( + + action.id === "viewContract" + ? goToContract() + : flow.openAction(action) + } + > + + {action.label} + + ); + })} + {showUsdPaymentHint && ( + + navigate(`/dashboard/booking-requests/${row.id}`) + } + > + + Upload payment proof… + + )} + {(actions.length > 0 || showUsdPaymentHint) && ( + + )} + + navigate(`/dashboard/booking-requests/${row.id}`) + } + > + + Open full details + + + + )} +
    + + + + Loading approval steps… +

    + ) : pendingAction?.id === "approve" && + !flow.mergedContext.approvalSteps?.length ? ( +

    + No pending approval step found. Accept the submission on the detail + page first. +

    + ) : null + } + /> + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx new file mode 100644 index 000000000..8c0b123d5 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx @@ -0,0 +1,168 @@ +import { useRef } from "react"; +import { Download, Upload, Zap } from "lucide-react"; + +import type { BookingDetail } from "@/types/booking"; +import { BookingActionsMenu } from "./BookingActionsMenu"; +import { bookingSurface } from "./booking-ui.styles"; +import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; +import type { useBookingMutations } from "@/hooks/bookings/useBookings"; +import { Button } from "@edr/ui-common"; + +type Mutations = ReturnType; + +interface BookingActionsToolbarProps { + booking: BookingDetail; + mutations: Mutations; +} + +/** Detail-page actions: primary toolbar + payment uploads + downloads. */ +export function BookingActionsToolbar({ + booking, + mutations, +}: BookingActionsToolbarProps) { + const fileRef = useRef(null); + const row = toBookingListRow(booking); + const { status, paymentCurrency } = booking; + const pending = mutations.isPending; + + const downloadBlob = async (fn: () => Promise, filename: string) => { + const blob = await fn(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + }; + + if ( + status === "REJECTED" || + status === "CANCELLED" || + status === "COMPLETED" + ) { + return null; + } + + if (status === "CHANGES_REQUESTED") { + return ( + + {booking.latestChangeRequestNote && ( +

    + {booking.latestChangeRequestNote} +

    + )} +
    + ); + } + + if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) { + return ( + + ); + } + + return ( +
    + + + + + {status === "FULLY_EXECUTED" && paymentCurrency === "USD" && ( + + { + const file = e.target.files?.[0]; + if (file) mutations.submitPaymentProof.mutate(file); + }} + /> +
    + + +
    +
    + )} + + {status === "CONTRACT_READY" && ( + + + + )} +
    + ); +} + +function PanelShell({ + title, + description, + children, + muted, +}: { + title: string; + description: string; + children: React.ReactNode; + muted?: boolean; +}) { + return ( +
    +
    +
    + +
    +
    +

    {title}

    +

    {description}

    +
    +
    +
    {children}
    +
    + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx new file mode 100644 index 000000000..183995a65 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx @@ -0,0 +1,134 @@ +import { Loader2 } from "lucide-react"; + +import type { BookingActionDef } from "@/features/bookings/booking-actions.config"; +import { cn } from "@/lib/utils"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Textarea, +} from "@edr/ui-common"; + +interface BookingConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + action: BookingActionDef | null; + reference?: string; + inputValue: string; + onInputChange: (value: string) => void; + onConfirm: () => void; + isPending: boolean; + confirmDisabled?: boolean; + extra?: React.ReactNode; +} + +export function BookingConfirmDialog({ + open, + onOpenChange, + action, + reference, + inputValue, + onInputChange, + onConfirm, + isPending, + confirmDisabled = false, + extra, +}: BookingConfirmDialogProps) { + if (!action || !action.confirmTitle) return null; + + const Icon = action.icon; + const needsInput = Boolean(action.input); + const inputMissing = needsInput && !inputValue.trim(); + const isDestructive = action.variant === "destructive"; + + return ( + + +
    + +
    +
    + +
    +
    + + {action.confirmTitle} + + {reference && ( +

    + {reference} +

    + )} +
    +
    + + {action.confirmDescription} + +
    +
    + +
    + {needsInput && ( +
    + +