diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 64f91da72..94ad11f1b 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,4 +1,5 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Navigate, Outlet, @@ -10,6 +11,7 @@ import { } from "react-router-dom"; import { FreightDashboardLayout, type SidebarItem } from "@/components/layout"; +import { api } from "@/services/api"; import { useAuth } from "./auth/useAuth"; import LoadingScreen from "./components/LoadingScreen"; import LoginPage from "./pages/auth/LoginPage"; @@ -139,8 +141,18 @@ const DashboardShell = () => { const demoItems: SidebarItem[] = []; + const { data: reportCatalog } = useQuery(api.reports.catalog.queryOptions()); + const reportItems: SidebarItem[] = useMemo( + () => + (reportCatalog ?? []).map((report) => ({ + label: report.title, + href: `/dashboard/reports/${report.key}`, + })), + [reportCatalog], + ); + const sidebarSections = filterSidebarByPermission( - buildSidebarSections(demoItems), + buildSidebarSections(demoItems, reportItems), user, ); const displayName = user?.name?.en || user?.username || user?.email || "User"; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 95608b6db..4548dc134 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -51,7 +51,10 @@ import { getCategorySidebarChildren } from "@/pages/ruleEngine/config/resources" * a user's first reachable route without importing the route tree (App.tsx * imports RequirePermission, which imports landing — that would cycle). */ -export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ +export const buildSidebarSections = ( + demoItems: SidebarItem[], + reportItems: SidebarItem[] = [], +): SidebarSection[] => [ { title: "Main menu", items: [ @@ -66,6 +69,9 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] href: "/dashboard/reports", icon: , permission: FREIGHT_PERMS.reports.view, + // Populated from the live GET /reports catalog (already permission- + // filtered server-side) — no report key is ever hand-listed here. + ...(reportItems.length ? { children: reportItems } : {}), }, { label: "Customers", diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportFilters.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportFilters.tsx new file mode 100644 index 000000000..9412d4db8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportFilters.tsx @@ -0,0 +1,115 @@ +import { Group, MultiSelect, Select, TextInput } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import { Search } from "lucide-react"; + +import type { ReportFilterDef } from "@/types/reports"; + +export interface ReportFilterValues { + [param: string]: string | undefined; +} + +interface ReportFiltersProps { + filters: ReportFilterDef[]; + values: ReportFilterValues; + onChange: (values: ReportFilterValues) => void; +} + +const toDate = (value: string | undefined): Date | null => (value ? new Date(value) : null); +const fromDate = (value: string | null): string | undefined => value ?? undefined; + +/** Renders one widget per report-declared filter and reports raw param values back up. */ +export function ReportFilters({ filters, values, onChange }: ReportFiltersProps) { + if (!filters.length) return null; + + const set = (patch: ReportFilterValues) => onChange({ ...values, ...patch }); + + return ( + + {filters.map((filter) => { + switch (filter.type) { + case "daterange": + return ( + + set({ [`${filter.key}From`]: fromDate(d) })} + radius="md" + size="sm" + clearable + w={150} + /> + set({ [`${filter.key}To`]: fromDate(d) })} + radius="md" + size="sm" + clearable + w={150} + /> + + ); + case "date": + return ( + set({ [filter.key]: fromDate(d) })} + radius="md" + size="sm" + clearable + w={150} + /> + ); + case "select": + return ( + setParam("granularity", v)} - allowDeselect={false} - /> - ) : null} - {config.filters.includes("yards") ? ( - ({ - value: y.id, - label: y.label, - }))} - value={params.get("yardIds")?.split(",").filter(Boolean) ?? []} - onChange={(v) => setParam("yardIds", v.length ? v.join(",") : null)} - placeholder="All yards" - /> - ) : null} - {config.filters.includes("direction") ? ( - setParam("freightType", v)} - placeholder="All" - /> - ) : null} - {config.filters.includes("statuses") && config.statusOptions ? ( - setParam("statuses", v.length ? v.join(",") : null)} - placeholder="Default (active)" - /> - ) : null} - - - - - ({ - label: k.label, - value: k.value.toLocaleString(), - hint: k.unit, - }))} - /> - - - - void reportQuery.refetch(), - } - : undefined - } - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount, - totalCount: rows.length, - }} - tableOptions={{ - manualPagination: false, - state: { pagination }, - onPaginationChange: setPagination, - autoResetPageIndex: false, - }} - footer={({ table, pagination: p }) => ( - - )} /> + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx b/apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx index f8f6f161d..172afca82 100644 --- a/apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx @@ -1,159 +1,64 @@ -import { - ActionIcon, - Badge, - Card, - Group, - SimpleGrid, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { Search, Star } from "lucide-react"; -import { useMemo, useState } from "react"; +import { Alert, Card, SimpleGrid, Skeleton, Stack, Text, Title } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; import { PageContainer, PageHeader } from "@/components/page"; -import { - REPORT_CONFIGS, - REPORT_DOMAINS, - type ReportConfig, -} from "./reportConfigs"; +import { api } from "@/services/api"; +import type { ReportCatalogEntry } from "@/types/reports"; -const FAVORITES_KEY = "reports.favorites"; - -const loadFavorites = (): string[] => { - try { - return JSON.parse(localStorage.getItem(FAVORITES_KEY) ?? "[]"); - } catch { - return []; - } -}; - -function ReportCard({ - config, - favorite, - onToggleFavorite, -}: { - config: ReportConfig; - favorite: boolean; - onToggleFavorite: () => void; -}) { - const navigate = useNavigate(); - return ( - navigate(`/dashboard/reports/${config.key}`)} - > - -
- - {config.title} - - - {config.description} - -
- { - e.stopPropagation(); - onToggleFavorite(); - }} - > - - -
- - {config.domain} - -
- ); -} +const GROUP_ORDER: ReportCatalogEntry["group"][] = ["Commercial", "Operations", "Finance"]; export default function ReportsHubPage() { - const [search, setSearch] = useState(""); - const [favorites, setFavorites] = useState(loadFavorites); + const navigate = useNavigate(); + const { data: catalog, isLoading, isError } = useQuery(api.reports.catalog.queryOptions()); - const toggleFavorite = (key: string) => { - setFavorites((prev) => { - const next = prev.includes(key) - ? prev.filter((k) => k !== key) - : [...prev, key]; - localStorage.setItem(FAVORITES_KEY, JSON.stringify(next)); - return next; - }); - }; - - const visible = useMemo(() => { - const q = search.trim().toLowerCase(); - if (!q) return REPORT_CONFIGS; - return REPORT_CONFIGS.filter( - (c) => - c.title.toLowerCase().includes(q) || - c.description.toLowerCase().includes(q), - ); - }, [search]); - - const pinned = visible.filter((c) => favorites.includes(c.key)); - - const renderGrid = (configs: ReportConfig[]) => ( - - {configs.map((c) => ( - toggleFavorite(c.key)} - /> - ))} - - ); + const groups = GROUP_ORDER.map((group) => ({ + group, + reports: (catalog ?? []).filter((r) => r.group === group), + })).filter((g) => g.reports.length); return ( - } - placeholder="Search reports…" - value={search} - onChange={(e) => setSearch(e.currentTarget.value)} - /> - } - /> + - {pinned.length ? ( - - Favorites - {renderGrid(pinned)} + {isError ? Failed to load the report catalog. : null} + + {isLoading ? ( + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + ) : null} + + {!isLoading && !isError && !groups.length ? ( + You don't have access to any reports yet. + ) : null} + + {groups.map(({ group, reports }) => ( + + {group} + + {reports.map((report) => ( + navigate(`/dashboard/reports/${report.key}`)} + > + + {report.title} + + + {report.description} + + + ))} + - ) : null} - - {REPORT_DOMAINS.map((domain) => { - const configs = visible.filter((c) => c.domain === domain); - if (!configs.length) return null; - return ( - - {domain} - {renderGrid(configs)} - - ); - })} - - {visible.length === 0 ? ( - - No reports match “{search}” - - ) : null} + ))} ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts b/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts deleted file mode 100644 index ced916fbb..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts +++ /dev/null @@ -1,445 +0,0 @@ -import { BookingStatus } from "@edr/types"; - -export type ReportDomain = "Commercial" | "Operations" | "Finance" | "Data"; - -export type ReportColumnUnit = "ETB" | "t" | "%" | "min"; - -export interface ReportColumn { - key: string; - label: string; - /** Numeric unit — formats the cell (thousands separators, suffix). */ - unit?: ReportColumnUnit; - numeric?: boolean; -} - -export interface ReportChart { - type: "area" | "line" | "bar"; - xKey: string; - series: { key: string; label: string }[]; - /** Chart only the first N rows (rows arrive sorted by the backend). */ - topN?: number; -} - -export type ReportFilterKey = - | "granularity" - | "yards" - | "direction" - | "freightType" - | "statuses"; - -export interface ReportConfig { - key: string; - title: string; - description: string; - domain: ReportDomain; - filters: ReportFilterKey[]; - /** Options for the `statuses` filter, when enabled. */ - statusOptions?: string[]; - chart?: ReportChart; - columns: ReportColumn[]; -} - -// Full enum from @edr/types; Set dedupes the deprecated AwaitingPayment alias. -const BOOKING_STATUSES = [...new Set(Object.values(BookingStatus))]; - -// Full list mirroring CONTRACT_STATUSES in contract.entity.ts (no shared enum -// in @edr/types yet). -const CONTRACT_STATUSES = [ - "DRAFT", - "SUBMITTED", - "PRICE_CHANGED_PENDING_CONFIRM", - "CHANGES_REQUESTED", - "PENDING_APPROVAL", - "APPROVED", - "APPROVED_PENDING_SIGNATURE", - "CONTRACT_READY", - "SIGNED_CUSTOMER", - "FULLY_EXECUTED", - "CONTRACT_ACTIVE", - "AWAITING_CLEARANCE_DOCUMENTS", - "CLEARANCE_UNDER_REVIEW", - "CLEARANCE_READY_FOR_BOOKING", - "ACTIVE_SHIPMENT_IN_PROGRESS", - "SUSPENDED", - "CONTRACT_CLOSED", - "EXPIRED", - "REJECTED", - "CANCELLED", - "RENEWAL_DRAFT", - "RENEWAL_SUBMITTED", - "RENEWAL_PENDING_APPROVAL", - "AMENDMENTS_PROPOSED", - "ARCHIVED", -]; - -const INVOICE_STATUSES = [ - "ISSUED", - "PENDING", - "PAYMENT_PROCESSING", - "PARTIALLY_PAID", - "PAID", - "OVERDUE", - "REFUNDED", -]; - -export const REPORT_CONFIGS: ReportConfig[] = [ - { - key: "bookings-trend", - title: "Bookings Trend", - description: "Booking volume, tonnage and revenue over time", - domain: "Commercial", - filters: ["granularity", "yards", "direction", "freightType", "statuses"], - statusOptions: BOOKING_STATUSES, - chart: { - type: "area", - xKey: "period", - series: [{ key: "revenue", label: "Revenue (ETB)" }], - }, - columns: [ - { key: "period", label: "Period" }, - { key: "bookings", label: "Bookings", numeric: true }, - { key: "tons", label: "Tonnage", unit: "t" }, - { key: "revenue", label: "Revenue", unit: "ETB" }, - ], - }, - { - key: "revenue-by-customer", - title: "Revenue by Customer", - description: "Ranked customers by booking revenue", - domain: "Commercial", - filters: ["yards", "direction", "freightType", "statuses"], - statusOptions: BOOKING_STATUSES, - chart: { - type: "bar", - xKey: "customer", - series: [{ key: "revenue", label: "Revenue (ETB)" }], - topN: 10, - }, - columns: [ - { key: "customer", label: "Customer" }, - { key: "bookings", label: "Bookings", numeric: true }, - { key: "tons", label: "Tonnage", unit: "t" }, - { key: "revenue", label: "Revenue", unit: "ETB" }, - ], - }, - { - key: "revenue-by-lane", - title: "Revenue by Lane", - description: "Origin → destination lanes by tonnage and revenue", - domain: "Commercial", - filters: ["direction", "freightType", "statuses"], - statusOptions: BOOKING_STATUSES, - chart: { - type: "bar", - xKey: "origin+destination", - series: [{ key: "revenue", label: "Revenue (ETB)" }], - topN: 10, - }, - columns: [ - { key: "origin", label: "Origin" }, - { key: "destination", label: "Destination" }, - { key: "bookings", label: "Bookings", numeric: true }, - { key: "tons", label: "Tonnage", unit: "t" }, - { key: "revenue", label: "Revenue", unit: "ETB" }, - ], - }, - { - key: "contract-utilization", - title: "Contract Utilization", - description: "Committed scope caps vs booked tonnage per contract", - domain: "Commercial", - filters: ["direction", "statuses"], - statusOptions: CONTRACT_STATUSES, - columns: [ - { key: "reference", label: "Contract" }, - { key: "customer", label: "Customer" }, - { key: "status", label: "Status" }, - { key: "kind", label: "Kind" }, - { key: "valid_from", label: "Valid from" }, - { key: "valid_until", label: "Valid until" }, - { key: "committed", label: "Committed", unit: "t" }, - { key: "booked_tons", label: "Booked", unit: "t" }, - { key: "bookings", label: "Bookings", numeric: true }, - { key: "utilization_pct", label: "Utilization", unit: "%" }, - ], - }, - { - key: "train-on-time", - title: "Train On-Time Performance", - description: "Departure punctuality and delays by lane (60-min grace)", - domain: "Operations", - filters: ["yards", "direction"], - chart: { - type: "bar", - xKey: "origin+destination", - series: [{ key: "on_time_pct", label: "On-time %" }], - topN: 15, - }, - columns: [ - { key: "origin", label: "Origin" }, - { key: "destination", label: "Destination" }, - { key: "trips", label: "Trips", numeric: true }, - { key: "departed", label: "Departed", numeric: true }, - { key: "avg_dep_delay_min", label: "Avg dep. delay", unit: "min" }, - { key: "avg_arr_delay_min", label: "Avg arr. delay", unit: "min" }, - { key: "on_time_pct", label: "On-time", unit: "%" }, - ], - }, - { - key: "schedule-fill-rate", - title: "Schedule Fill Rate", - description: "Booked tonnage vs wagon capacity per train schedule", - domain: "Operations", - filters: ["yards", "direction"], - chart: { - type: "line", - xKey: "departure", - series: [{ key: "fill_pct", label: "Fill %" }], - }, - columns: [ - { key: "train_number", label: "Train" }, - { key: "departure", label: "Departure" }, - { key: "origin", label: "Origin" }, - { key: "destination", label: "Destination" }, - { key: "direction", label: "Direction" }, - { key: "status", label: "Status" }, - { key: "wagon_count", label: "Wagons", numeric: true }, - { key: "capacity_tons", label: "Capacity", unit: "t" }, - { key: "booked_tons", label: "Booked", unit: "t" }, - { key: "fill_pct", label: "Fill", unit: "%" }, - ], - }, - { - key: "trips-per-route", - title: "Trips per Route", - description: "Completed trips and tonnage hauled per lane", - domain: "Operations", - filters: ["yards", "direction"], - chart: { - type: "bar", - xKey: "origin+destination", - series: [{ key: "trips", label: "Trips" }], - topN: 15, - }, - columns: [ - { key: "origin", label: "Origin" }, - { key: "destination", label: "Destination" }, - { key: "direction", label: "Direction" }, - { key: "trips", label: "Trips", numeric: true }, - { key: "tons_hauled", label: "Tonnage hauled", unit: "t" }, - { key: "avg_tons_per_trip", label: "Avg per trip", unit: "t" }, - ], - }, - { - key: "invoiced-vs-collected", - title: "Invoiced vs Collected", - description: "Billing issued vs payments received over time", - domain: "Finance", - filters: ["granularity", "direction"], - chart: { - type: "line", - xKey: "period", - series: [ - { key: "invoiced", label: "Invoiced (ETB)" }, - { key: "collected", label: "Collected (ETB)" }, - ], - }, - columns: [ - { key: "period", label: "Period" }, - { key: "invoices", label: "Invoices", numeric: true }, - { key: "invoiced", label: "Invoiced", unit: "ETB" }, - { key: "collected", label: "Collected", unit: "ETB" }, - { key: "outstanding", label: "Outstanding", unit: "ETB" }, - ], - }, - { - key: "aging-receivables", - title: "Aging Receivables", - description: "Outstanding invoice balances by age bucket per customer", - domain: "Finance", - filters: ["direction", "statuses"], - statusOptions: INVOICE_STATUSES, - chart: { - type: "bar", - xKey: "customer", - series: [{ key: "outstanding", label: "Outstanding (ETB)" }], - topN: 10, - }, - columns: [ - { key: "customer", label: "Customer" }, - { key: "invoices", label: "Invoices", numeric: true }, - { key: "outstanding", label: "Outstanding", unit: "ETB" }, - { key: "current", label: "Current", unit: "ETB" }, - { key: "overdue_0_30", label: "0–30d", unit: "ETB" }, - { key: "overdue_31_60", label: "31–60d", unit: "ETB" }, - { key: "overdue_61_90", label: "61–90d", unit: "ETB" }, - { key: "overdue_90_plus", label: "90d+", unit: "ETB" }, - ], - }, - { - key: "revenue-by-payment-method", - title: "Revenue by Payment Method", - description: "Successful payments broken down by method", - domain: "Finance", - filters: ["direction"], - chart: { - type: "bar", - xKey: "method", - series: [{ key: "amount", label: "Amount (ETB)" }], - }, - columns: [ - { key: "method", label: "Method" }, - { key: "payments", label: "Payments", numeric: true }, - { key: "amount", label: "Amount", unit: "ETB" }, - ], - }, - // --- Record-level list exports (Data domain) — filtered or full dumps --- - { - key: "bookings-list", - title: "Bookings Export", - description: "Booking records with customer, lane, cargo, amounts", - domain: "Data", - filters: ["yards", "direction", "freightType", "statuses"], - statusOptions: BOOKING_STATUSES, - columns: [ - { key: "reference", label: "Reference" }, - { key: "created", label: "Created" }, - { key: "customer", label: "Customer" }, - { key: "status", label: "Status" }, - { key: "freight_type", label: "Freight" }, - { key: "direction", label: "Direction" }, - { key: "origin", label: "Origin" }, - { key: "destination", label: "Destination" }, - { key: "cargo", label: "Cargo" }, - { key: "tons", label: "Tonnage", unit: "t" }, - { key: "amount", label: "Amount", unit: "ETB" }, - { key: "payment_status", label: "Payment" }, - { key: "scheduling_status", label: "Scheduling" }, - ], - }, - { - key: "contracts-list", - title: "Contracts Export", - description: "Contract records with validity, status, customer", - domain: "Data", - filters: ["direction", "statuses"], - statusOptions: CONTRACT_STATUSES, - columns: [ - { key: "reference", label: "Reference" }, - { key: "customer", label: "Customer" }, - { key: "kind", label: "Kind" }, - { key: "status", label: "Status" }, - { key: "direction", label: "Direction" }, - { key: "freight_type", label: "Freight" }, - { key: "valid_from", label: "Valid from" }, - { key: "valid_until", label: "Valid until" }, - { key: "created", label: "Created" }, - ], - }, - { - key: "schedules-list", - title: "Train Schedules Export", - description: "Schedule records with planned vs actual times", - domain: "Data", - filters: ["yards", "direction", "statuses"], - statusOptions: ["DRAFT", "SCHEDULED", "DISPATCHED", "ARRIVED", "CANCELLED"], - columns: [ - { key: "train_number", label: "Train" }, - { key: "reference", label: "Reference" }, - { key: "direction", label: "Direction" }, - { key: "status", label: "Status" }, - { key: "origin", label: "Origin" }, - { key: "destination", label: "Destination" }, - { key: "scheduled_departure", label: "Sched. departure" }, - { key: "actual_departure", label: "Actual departure" }, - { key: "scheduled_arrival", label: "Sched. arrival" }, - { key: "actual_arrival", label: "Actual arrival" }, - { key: "max_wagons", label: "Max wagons", numeric: true }, - { key: "wagon_count", label: "Wagons", numeric: true }, - ], - }, - { - key: "fleet-wagons", - title: "Wagons Export", - description: "Wagon fleet with type, capacity, status, location", - domain: "Data", - filters: ["yards", "statuses"], - statusOptions: ["AVAILABLE", "ASSIGNED", "MAINTENANCE"], - columns: [ - { key: "wagon_number", label: "Wagon" }, - { key: "type", label: "Type" }, - { key: "capacity_tons", label: "Capacity", unit: "t" }, - { key: "status", label: "Status" }, - { key: "current_yard", label: "Current yard" }, - ], - }, - { - key: "fleet-locomotives", - title: "Locomotives Export", - description: "Locomotive fleet with type, pull capacity, status", - domain: "Data", - filters: ["yards", "statuses"], - statusOptions: ["AVAILABLE", "OUT_OF_SERVICE"], - columns: [ - { key: "code", label: "Code" }, - { key: "name", label: "Name" }, - { key: "locomotive_type", label: "Type" }, - { key: "max_pull_tons", label: "Max pull", unit: "t" }, - { key: "status", label: "Status" }, - { key: "current_yard", label: "Current yard" }, - ], - }, - { - key: "customers-list", - title: "Customers Export", - description: "Company records with type, status, TIN", - domain: "Data", - filters: ["statuses"], - statusOptions: ["pending", "active"], - columns: [ - { key: "name", label: "Name" }, - { key: "type", label: "Type" }, - { key: "kind", label: "Kind" }, - { key: "status", label: "Status" }, - { key: "tin", label: "TIN" }, - { key: "approved", label: "Approved" }, - { key: "created", label: "Created" }, - ], - }, - { - key: "payments-list", - title: "Payments Export", - description: "Payment transactions with method, status, references", - domain: "Data", - filters: ["direction", "statuses"], - statusOptions: [ - "action-required", - "processing", - "success", - "failed", - "canceled", - "refunded", - ], - columns: [ - { key: "created", label: "Created" }, - { key: "method", label: "Method" }, - { key: "status", label: "Status" }, - { key: "currency", label: "Currency" }, - { key: "amount", label: "Amount", unit: "ETB" }, - { key: "transaction_id", label: "Transaction" }, - { key: "merchant_order_id", label: "Merchant order" }, - { key: "paid", label: "Paid" }, - ], - }, -]; - -export const REPORT_CONFIG_BY_KEY = new Map( - REPORT_CONFIGS.map((c) => [c.key, c]), -); - -export const REPORT_DOMAINS: ReportDomain[] = [ - "Commercial", - "Operations", - "Finance", - "Data", -]; diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 2a1963f8d..8f8068a45 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -174,7 +174,7 @@ import { } from "./locomotives.service"; import { overviewService } from "./overview.service"; import { reportsService } from "./reports.service"; -import type { ReportQueryInput, ReportResult } from "@/types/reports"; +import type { ReportCatalogEntry, ReportRunParams, ReportRunResult } from "@/types/reports"; import { paymentsService, type PaginatedPayments, @@ -3030,7 +3030,10 @@ export const api = { }, reports: { - run: endpoint( + catalog: endpoint("reports", "catalog", () => + reportsService.catalog(), + ), + run: endpoint( "reports", "run", (input) => reportsService.run(input), diff --git a/apps/edr-freight-web/backoffice/src/services/reports.service.ts b/apps/edr-freight-web/backoffice/src/services/reports.service.ts index 6f6995614..f04bc8c8f 100644 --- a/apps/edr-freight-web/backoffice/src/services/reports.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/reports.service.ts @@ -1,14 +1,31 @@ import { api as client } from "../auth/http"; import { unwrap } from "@/utils/endpoint"; import { URL_CONSTANTS } from "@/constants/URLS"; -import type { ReportQueryInput, ReportResult } from "@/types/reports"; +import type { ReportCatalogEntry, ReportRunParams, ReportRunResult } from "@/types/reports"; + +const R = URL_CONSTANTS.REPORTS; export const reportsService = { - run: async ({ key, ...params }: ReportQueryInput): Promise => { - const response = await client.get( - URL_CONSTANTS.REPORTS.RUN(key), - { params }, - ); + catalog: async (): Promise => { + const response = await client.get(R.CATALOG); return unwrap(response.data); }, + + run: async ({ key, ...params }: ReportRunParams): Promise => { + const response = await client.get(R.RUN(key), { params }); + return unwrap(response.data); + }, + + /** Streams the export file as a blob — caller triggers the browser save. */ + download: async ( + key: string, + format: "xlsx" | "pdf", + params: Omit, + ): Promise => { + const response = await client.get(R.EXPORT(key), { + params: { ...params, format }, + responseType: "blob", + }); + return response.data as Blob; + }, }; diff --git a/apps/edr-freight-web/backoffice/src/types/reports.ts b/apps/edr-freight-web/backoffice/src/types/reports.ts index 72326788c..854b1a801 100644 --- a/apps/edr-freight-web/backoffice/src/types/reports.ts +++ b/apps/edr-freight-web/backoffice/src/types/reports.ts @@ -1,27 +1,77 @@ +export type ReportColumnType = + | "string" + | "number" + | "money" + | "tons" + | "percent" + | "date"; + +export interface ReportColumn { + key: string; + label: string; + type: ReportColumnType; + sortable?: boolean; +} + +export type ReportFilterType = "daterange" | "date" | "select" | "multiselect" | "text"; + +export interface ReportFilterOption { + value: string; + label: string; +} + +export interface ReportFilterDef { + key: string; + label: string; + type: ReportFilterType; + options?: ReportFilterOption[]; +} + +export interface ReportIdKey { + key: string; + label: string; +} + export interface ReportKpi { label: string; value: number; unit?: string; } -export type ReportRow = Record; - -export interface ReportResult { - kpis: ReportKpi[]; - rows: ReportRow[]; -} - -/** Query params for GET /reports/:key. List filters are comma-separated. */ -export interface ReportQueryInput { +/** Mirrors the backend's ReportCatalogEntry — one entry per GET /reports item. */ +export interface ReportCatalogEntry { key: string; - dateFrom?: string; - dateTo?: string; - granularity?: "day" | "week" | "month"; - companyIds?: string; - routeIds?: string; - yardIds?: string; - cargoTypeIds?: string; - statuses?: string; - direction?: string; - freightType?: string; + title: string; + description: string; + group: "Commercial" | "Operations" | "Finance"; + idKey?: ReportIdKey; + filters: ReportFilterDef[]; + columns: ReportColumn[]; + defaultSort?: { key: string; dir: "ASC" | "DESC" }; + hasSummary: boolean; } + +export interface ReportPageMeta { + page: number; + pageSize: number; + total: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; +} + +export interface ReportRunResult { + columns: ReportColumn[]; + items: Record[]; + meta: ReportPageMeta; + kpis: ReportKpi[]; +} + +/** Query params for GET /reports/:key — page/sort plus whatever filters the report declares. */ +export type ReportRunParams = Record & { + key: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: "ASC" | "DESC"; +};