From 2de3f78fb0eb86a5b02d580f260ab5137af546fa Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 17 Jun 2026 04:35:43 +0000 Subject: [PATCH] Add payments management features including summary and listing, and integrate with the dashboard --- .../src/modules/payment/payment.controller.ts | 13 +- .../src/modules/payment/payment.service.ts | 34 ++ apps/edr-freight-web/backoffice/src/App.tsx | 16 + .../bookings/detail/booking-detail.styles.ts | 1 + .../src/components/layout/route-meta.ts | 7 + .../backoffice/src/constants/URLS.ts | 5 + .../backoffice/src/hooks/usePayments.ts | 23 ++ .../bookings/BookingRequestDetailPage.tsx | 13 +- .../src/pages/payments/PaymentsPage.tsx | 367 ++++++++++++++++++ .../src/services/payments.service.ts | 84 ++++ 10 files changed, 559 insertions(+), 4 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/hooks/usePayments.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/payments.service.ts diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 384876705..14308883d 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -17,7 +17,7 @@ import { } from "@nestjs/swagger"; import { Response } from "express"; import { Public } from "@edr/api-common"; -import { FreightAdmin } from "../../common/booking-guards"; +import { BookingView, FreightAdmin } from "../../common/booking-guards"; import { PaymentService } from "./payment.service"; import { InitiatePaymentDto, @@ -33,9 +33,16 @@ import { export class PaymentController { constructor(private readonly paymentService: PaymentService) { } + @Get("summary") + @BookingView() + @ApiOperation({ summary: "Payment count/amount summary for dashboard cards" }) + getSummary() { + return this.paymentService.getSummary(); + } + @Get("all") - @FreightAdmin() - @ApiOperation({ summary: "Get all payments with filters (staff/admin only)" }) + @BookingView() + @ApiOperation({ summary: "Get all payments with filters (view-only, any staff)" }) @ApiQuery({ name: "search", required: false }) @ApiQuery({ name: "status", required: false }) @ApiQuery({ name: "method", required: false }) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 3f90628f0..b24febf91 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -105,6 +105,40 @@ export class PaymentService { }; } + /** Aggregate counts across ALL payments for the dashboard summary cards. */ + async getSummary() { + const rows = await this.paymentRepo + .createQueryBuilder("payment") + .select("payment.status", "status") + .addSelect("COUNT(*)::int", "count") + .groupBy("payment.status") + .getRawMany<{ status: string; count: number }>(); + + const byStatus: Record = {}; + let total = 0; + for (const row of rows) { + byStatus[row.status] = row.count; + total += row.count; + } + + // Sum of successfully collected amounts. + const paidAgg = await this.paymentRepo + .createQueryBuilder("payment") + .select("COALESCE(SUM(payment.amount), 0)", "sum") + .where("payment.status = :status", { status: "success" }) + .getRawOne<{ sum: string }>(); + + return { + total, + success: byStatus["success"] ?? 0, + processing: + (byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0), + failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0), + refunded: byStatus["refunded"] ?? 0, + paidAmount: Number(paidAgg?.sum ?? 0), + }; + } + async initiatePayment(dto: InitiatePaymentDto): Promise { const booking = await this.datasource .getRepository(Booking) diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 8a1409fbe..a384d5956 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -13,6 +13,7 @@ import { Container, Package, Users, + Wallet, //TrainTrack, } from "lucide-react"; @@ -23,6 +24,7 @@ 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 PaymentsPage from "./pages/payments/PaymentsPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; @@ -72,6 +74,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/booking-requests", icon: , }, + { + label: "Payments", + href: "/dashboard/payments", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, ...demoItems, ], }, @@ -281,6 +289,14 @@ const App = () => { } /> } /> + + + + } + /> } /> } /> = [ subtitle: "Manage your account and signature", }, }, + { + prefix: "/dashboard/payments", + meta: { + title: "Payments", + subtitle: "View booking payment transactions", + }, + }, { prefix: "/dashboard/operations/train-scheduling-v2/", meta: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 99f817b74..c87cf300c 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -124,6 +124,11 @@ export const URL_CONSTANTS = { VERIFY: "/api/otp/verify", }, + PAYMENTS: { + ALL: "/payments/all", + SUMMARY: "/payments/summary", + }, + LOCOMOTIVES: { BASE: "/locomotives", BY_ID: (id: string) => `/locomotives/${id}`, diff --git a/apps/edr-freight-web/backoffice/src/hooks/usePayments.ts b/apps/edr-freight-web/backoffice/src/hooks/usePayments.ts new file mode 100644 index 000000000..e9a09760b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/usePayments.ts @@ -0,0 +1,23 @@ +import { useQuery } from "@tanstack/react-query"; + +import { + paymentsService, + type PaymentListFilter, +} from "@/services/payments.service"; + +export function usePaymentList(filter?: PaymentListFilter, enabled = true) { + return useQuery({ + queryKey: ["payments", "list", filter ?? {}], + queryFn: () => paymentsService.list(filter), + enabled, + }); +} + +export function usePaymentSummary(enabled = true) { + return useQuery({ + queryKey: ["payments", "summary"], + queryFn: () => paymentsService.getSummary(), + staleTime: 30_000, + enabled, + }); +} 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 42bf866b3..73de7ee8f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -35,6 +35,15 @@ import { downloadBookingFile } from "@/services/files.service"; import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings"; import toast from "react-hot-toast"; +// Signature / generated-contract files are surfaced on the contract page, not +// in the booking's Documents list. +const SIGNATURE_FILE_CODES = new Set([ + "signature", + "signature_customer", + "signature_staff", + "contract", +]); + export default function BookingRequestDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -161,7 +170,9 @@ export default function BookingRequestDetailPage() { )} !SIGNATURE_FILE_CODES.has(f.code ?? ""), + )} onDownload={handleDownloadFile} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx new file mode 100644 index 000000000..07e212b76 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx @@ -0,0 +1,367 @@ +import { useMemo, useState } from "react"; +import { + ActionIcon, + Box, + Card, + Container, + Group, + Paper, + Select, + Stack, + Tabs, + Text, + TextInput, +} from "@mantine/core"; +import { + CheckCircle2, + CircleDollarSign, + Loader2, + RotateCcw, + Search, + X, + XCircle, + type LucideIcon, +} from "lucide-react"; + +import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments"; +import type { + PaymentMethod, + PaymentRow, +} from "@/services/payments.service"; +import { cn } from "@/lib/utils"; +import { + Badge, + DataTable, + DataTableFooter, + type ColumnDef, + usePagination, +} from "@edr/ui-common"; + +const STATUS_TABS = [ + { key: "all", label: "All", statuses: undefined as string | undefined }, + { key: "success", label: "Success", statuses: "success" }, + { key: "processing", label: "Processing", statuses: "processing,action-required" }, + { key: "failed", label: "Failed", statuses: "failed,canceled" }, + { key: "refunded", label: "Refunded", statuses: "refunded" }, +] as const; + +type StatusTabKey = (typeof STATUS_TABS)[number]["key"]; + +const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [ + { value: "telebirr", label: "Telebirr" }, + { value: "waafi", label: "Waafi" }, + { value: "cbe-birr", label: "CBE Birr" }, + { value: "ebirr", label: "E-Birr" }, + { value: "card", label: "Card" }, + { value: "dmoney", label: "D-Money" }, + { value: "cac-bank", label: "CAC Bank" }, +]; + +const STATUS_COLORS: Record = { + success: "green", + processing: "yellow", + "action-required": "yellow", + failed: "red", + canceled: "gray", + refunded: "indigo", +}; + +function StatCard({ + icon: Icon, + label, + value, + accent, +}: { + icon: LucideIcon; + label: string; + value: string | number; + accent: string; +}) { + return ( + + + + + + + + {value} + + + {label} + + + + + ); +} + +function formatAmount(amount: number, currency: string): string { + return `${currency} ${Number(amount).toLocaleString(undefined, { + minimumFractionDigits: 2, + })}`; +} + +function formatDate(iso: string | null): string { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? "—" + : d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +const tableHeader = "text-xs font-semibold uppercase tracking-wide text-muted-foreground"; + +export default function PaymentsPage() { + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [query, setQuery] = useState(""); + const [statusTab, setStatusTab] = useState("all"); + const [method, setMethod] = useState(null); + + const statuses = STATUS_TABS.find((t) => t.key === statusTab)?.statuses; + + const filter = useMemo( + () => ({ + search: query.trim() || undefined, + status: statuses, + method: method ?? undefined, + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + }), + [query, statuses, method, pagination.pageIndex, pagination.pageSize], + ); + + const { data, isLoading, isError } = usePaymentList(filter); + const { data: summary, isLoading: summaryLoading } = usePaymentSummary(); + + const rows = data?.items ?? []; + const total = data?.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + + const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0)); + + const columns: ColumnDef[] = [ + { + id: "order", + header: () => Order, + cell: ({ row }) => ( +
+

+ {row.original.merchantOrderId ?? row.original.id.slice(0, 8)} +

+

+ Booking {row.original.bookingId?.slice(0, 8) ?? "—"} +

+
+ ), + }, + { + id: "amount", + header: () => Amount, + cell: ({ row }) => ( + + {formatAmount(row.original.amount, row.original.currency)} + + ), + }, + { + id: "method", + header: () => Method, + cell: ({ row }) => ( + + {METHOD_OPTIONS.find((m) => m.value === row.original.method)?.label ?? + row.original.method} + + ), + }, + { + id: "status", + header: () => Status, + cell: ({ row }) => ( + + {row.original.status.replace(/-/g, " ")} + + ), + }, + { + id: "date", + header: () => Date, + cell: ({ row }) => ( + + {formatDate(row.original.paidAt ?? row.original.createdAt)} + + ), + }, + ]; + + return ( +
+ + + + + + + + + + + + + { + setStatusTab((value as StatusTabKey) ?? "all"); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + > + + {STATUS_TABS.map((t) => ( + + {t.label} + + ))} + + + + + + + } + value={query} + onChange={(e) => { + setQuery(e.target.value); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + rightSection={ + query && ( + setQuery("")} + > + + + ) + } + style={{ flex: 1, minWidth: "200px" }} + radius="lg" + /> +