Add payments management features including summary and listing, and integrate with the dashboard

This commit is contained in:
Marshal
2026-06-17 04:35:43 +00:00
parent 648f4f2ee4
commit 2de3f78fb0
10 changed files with 559 additions and 4 deletions

View File

@@ -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 })

View File

@@ -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<string, number> = {};
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<InitiateResponseDto> {
const booking = await this.datasource
.getRepository(Booking)

View File

@@ -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: <FileText />,
},
{
label: "Payments",
href: "/dashboard/payments",
icon: <Wallet />,
permission: FREIGHT_PERMS.bookings.view,
},
...demoItems,
],
},
@@ -281,6 +289,14 @@ const App = () => {
<Route path="profile" element={<MyProfilePage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route
path="payments"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<PaymentsPage />
</RequirePermission>
}
/>
<Route path="booking-requests/new" element={<NewBookingPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route

View File

@@ -122,6 +122,7 @@ export interface BookingFileView {
id: string;
name: string;
mimeType?: string;
code?: string;
}
export interface BookingDetailView {

View File

@@ -43,6 +43,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Manage your account and signature",
},
},
{
prefix: "/dashboard/payments",
meta: {
title: "Payments",
subtitle: "View booking payment transactions",
},
},
{
prefix: "/dashboard/operations/train-scheduling-v2/",
meta: {

View File

@@ -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}`,

View File

@@ -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,
});
}

View File

@@ -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() {
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
<BookingDocumentsCard
files={booking.files ?? []}
files={(booking.files ?? []).filter(
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
)}
onDownload={handleDownloadFile}
/>
</Stack>

View File

@@ -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<string, string> = {
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 (
<Paper
p="md"
radius="lg"
style={{
flex: "1 1 180px",
minWidth: 160,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Group gap="sm" wrap="nowrap" align="center">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: 11,
background: `var(--mantine-color-${accent}-1)`,
color: `var(--mantine-color-${accent}-7)`,
flexShrink: 0,
}}
>
<Icon size={20} strokeWidth={2} />
</Box>
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
<Text fw={800} size="24px" lh={1.05} style={{ color: "#0f172a" }} truncate>
{value}
</Text>
<Text size="xs" fw={600} c="dimmed" truncate>
{label}
</Text>
</Stack>
</Group>
</Paper>
);
}
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<StatusTabKey>("all");
const [method, setMethod] = useState<string | null>(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<PaymentRow>[] = [
{
id: "order",
header: () => <span className={tableHeader}>Order</span>,
cell: ({ row }) => (
<div className="min-w-0 py-1">
<p className="truncate font-medium text-foreground">
{row.original.merchantOrderId ?? row.original.id.slice(0, 8)}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
Booking {row.original.bookingId?.slice(0, 8) ?? "—"}
</p>
</div>
),
},
{
id: "amount",
header: () => <span className={tableHeader}>Amount</span>,
cell: ({ row }) => (
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
{formatAmount(row.original.amount, row.original.currency)}
</span>
),
},
{
id: "method",
header: () => <span className={tableHeader}>Method</span>,
cell: ({ row }) => (
<Badge variant="light" radius="sm">
{METHOD_OPTIONS.find((m) => m.value === row.original.method)?.label ??
row.original.method}
</Badge>
),
},
{
id: "status",
header: () => <span className={tableHeader}>Status</span>,
cell: ({ row }) => (
<Badge
color={STATUS_COLORS[row.original.status] ?? "gray"}
variant="light"
radius="sm"
tt="capitalize"
>
{row.original.status.replace(/-/g, " ")}
</Badge>
),
},
{
id: "date",
header: () => <span className={tableHeader}>Date</span>,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatDate(row.original.paidAt ?? row.original.createdAt)}
</span>
),
},
];
return (
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
<Container size="xxl" py="xl">
<Breadcrumbs items={[{ label: "Operations" }, { label: "Payments" }]} />
<Stack gap="lg" mt="md">
<Group grow gap="md" align="stretch" wrap="wrap">
<StatCard
icon={CircleDollarSign}
label="Total collected"
value={
summaryLoading
? "—"
: `ETB ${Number(summary?.paidAmount ?? 0).toLocaleString()}`
}
accent="teal"
/>
<StatCard
icon={CheckCircle2}
label="Successful"
value={val(summary?.success)}
accent="green"
/>
<StatCard
icon={Loader2}
label="Processing"
value={val(summary?.processing)}
accent="yellow"
/>
<StatCard
icon={XCircle}
label="Failed"
value={val(summary?.failed)}
accent="red"
/>
<StatCard
icon={RotateCcw}
label="Refunded"
value={val(summary?.refunded)}
accent="indigo"
/>
</Group>
<Tabs
value={statusTab}
onChange={(value) => {
setStatusTab((value as StatusTabKey) ?? "all");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
>
<Tabs.List>
{STATUS_TABS.map((t) => (
<Tabs.Tab key={t.key} value={t.key}>
{t.label}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
<Card
p="md"
radius="lg"
withBorder
style={{ background: "white", border: "1px solid var(--mantine-color-gray-2)" }}
>
<Stack gap="md">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search order, booking, or transaction…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Select
placeholder="All methods"
clearable
data={METHOD_OPTIONS}
value={method}
onChange={(value) => {
setMethod(value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
radius="lg"
style={{ minWidth: 180 }}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<div style={{ overflowX: "auto" }}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName={cn(
"border-0 shadow-none",
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
"[&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
)}
footer={DataTableFooter}
/>
</div>
</Stack>
</Card>
</Stack>
</Container>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
const P = URL_CONSTANTS.PAYMENTS;
export type PaymentStatus =
| "action-required"
| "processing"
| "success"
| "failed"
| "canceled"
| "refunded";
export type PaymentMethod =
| "telebirr"
| "cbe-birr"
| "ebirr"
| "waafi"
| "card"
| "dmoney"
| "cac-bank";
export interface PaymentRow {
id: string;
bookingId: string;
amount: number;
currency: string;
method: PaymentMethod;
status: PaymentStatus;
merchantOrderId: string | null;
paidAt: string | null;
createdAt: string;
}
export interface PaymentListFilter {
search?: string;
status?: string;
method?: string;
page?: number;
pageSize?: number;
}
export interface PaginatedPayments {
items: PaymentRow[];
total: number;
page: number;
pageSize: number;
}
export interface PaymentSummary {
total: number;
success: number;
processing: number;
failed: number;
refunded: number;
paidAmount: number;
}
export const paymentsService = {
list: async (filter?: PaymentListFilter): Promise<PaginatedPayments> => {
const params: Record<string, string | number | undefined> = {};
if (filter) {
if (filter.search) params.search = filter.search;
if (filter.status) params.status = filter.status;
if (filter.method) params.method = filter.method;
if (filter.page != null) params.page = filter.page;
if (filter.pageSize != null) params.pageSize = filter.pageSize;
}
const response = await client.get<PaginatedPayments>(P.ALL, { params });
const data = unwrap(response.data) as PaginatedPayments;
return {
items: data.items ?? [],
total: data.total ?? 0,
page: data.page ?? 1,
pageSize: data.pageSize ?? 10,
};
},
getSummary: async (): Promise<PaymentSummary> => {
const response = await client.get<PaymentSummary>(P.SUMMARY);
return unwrap(response.data) as PaymentSummary;
},
};