diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index 6908498bb..8cf97e7b1 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -262,9 +262,13 @@ export class OverviewRepository { }; } + // ponytail: a third hardcoded currency is still a shorter diff than rewriting seven raw + // selects. A FOURTH should force these into `GROUP BY payment.currency` returning + // IOverviewCurrencyAmount[] — the shape getRevenueByCurrency below already uses. async getBillingKpis(dirs?: string[]): Promise<{ revenueMtdEtb: number; revenueMtdUsd: number; + revenueMtdDjf: number; pendingPayments: number; successfulPaymentsMtd: number; }> { @@ -279,6 +283,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "revenueMtdUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "revenueMtdDjf", + ) .addSelect(`COUNT(*)::int`, "successfulPaymentsMtd") .where("payment.status = :status", { status: "success" }) .andWhere( @@ -298,6 +306,7 @@ export class OverviewRepository { return { revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0), revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0), + revenueMtdDjf: Number(revenueRow?.revenueMtdDjf ?? 0), pendingPayments, successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0), }; @@ -370,7 +379,7 @@ export class OverviewRepository { days: number, dirs?: string[], offsetDays = 0, - ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { + ): Promise<{ date: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") @@ -386,6 +395,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`, @@ -394,12 +407,13 @@ export class OverviewRepository { .andWhere(scope.sql, scope.params) .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC") - .getRawMany<{ date: string; amountEtb: string; amountUsd: string }>(); + .getRawMany<{ date: string; amountEtb: string; amountUsd: string; amountDjf: string }>(); return rows.map((row) => ({ date: row.date, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -510,7 +524,7 @@ export class OverviewRepository { async getPaymentsByMethod( dirs?: string[], ): Promise< - { method: string; count: number; amountEtb: number; amountUsd: number }[] + { method: string; count: number; amountEtb: number; amountUsd: number; amountDjf: number }[] > { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository @@ -525,6 +539,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF' AND payment.status = 'success'), 0)`, + "amountDjf", + ) .where(scope.sql, scope.params) .groupBy("payment.method") .orderBy("count", "DESC") @@ -533,6 +551,7 @@ export class OverviewRepository { count: string; amountEtb: string; amountUsd: string; + amountDjf: string; }>(); return rows.map((row) => ({ @@ -540,6 +559,7 @@ export class OverviewRepository { count: Number(row.count), amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -580,6 +600,7 @@ export class OverviewRepository { bookingsCreated: number; revenueEtb: number; revenueUsd: number; + revenueDjf: number; tons: number; }> { const bookingScope = directionScopeSql("booking.trade_direction", dirs); @@ -605,13 +626,17 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "revenueUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "revenueDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( windowSql("COALESCE(payment.paid_at, payment.created_at)"), { days, offsetDays }, ) .andWhere(paymentScope.sql, paymentScope.params) - .getRawOne<{ revenueEtb: string; revenueUsd: string }>(), + .getRawOne<{ revenueEtb: string; revenueUsd: string; revenueDjf: string }>(), this.cargoRepository .createQueryBuilder("cargo") .leftJoin(Booking, "booking", "booking.id = cargo.booking_id") @@ -626,6 +651,7 @@ export class OverviewRepository { bookingsCreated, revenueEtb: Number(revenueRow?.revenueEtb ?? 0), revenueUsd: Number(revenueRow?.revenueUsd ?? 0), + revenueDjf: Number(revenueRow?.revenueDjf ?? 0), tons: Number(tonsRow?.tons ?? 0), }; } @@ -634,7 +660,7 @@ export class OverviewRepository { async getRevenueByDirection( days: number, dirs?: string[], - ): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { + ): Promise<{ label: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") @@ -648,6 +674,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, @@ -656,12 +686,13 @@ export class OverviewRepository { .andWhere(scope.sql, scope.params) .andWhere("booking.trade_direction IS NOT NULL") .groupBy("booking.trade_direction") - .getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); + .getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>(); return rows.map((row) => ({ label: row.label, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -669,7 +700,7 @@ export class OverviewRepository { async getRevenueByFreightType( days: number, dirs?: string[], - ): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { + ): Promise<{ label: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") @@ -683,6 +714,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, @@ -691,12 +726,13 @@ export class OverviewRepository { .andWhere(scope.sql, scope.params) .andWhere("booking.freight_type IS NOT NULL") .groupBy("booking.freight_type") - .getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); + .getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>(); return rows.map((row) => ({ label: row.label, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -734,6 +770,7 @@ export class OverviewRepository { freightType: string; amountEtb: number; amountUsd: number; + amountDjf: number; }[] > { const scope = bookingRefScopeSql("payment.ref_id", dirs); @@ -750,6 +787,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, @@ -765,6 +806,7 @@ export class OverviewRepository { freightType: string; amountEtb: string; amountUsd: string; + amountDjf: string; }>(); return rows.map((row) => ({ @@ -772,6 +814,7 @@ export class OverviewRepository { freightType: row.freightType, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx index f1a74ac4e..8106941f7 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx @@ -1,3 +1,4 @@ +import { isPaymentCurrency, type PaymentCurrency } from "@edr/types"; import { Bar, BarChart, @@ -18,7 +19,7 @@ function formatDateLabel(date: string) { return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } -function formatAmount(value: number, currency: "ETB" | "USD") { +function formatAmount(value: number, currency: PaymentCurrency) { return new Intl.NumberFormat("en-US", { style: "currency", currency, @@ -27,7 +28,9 @@ function formatAmount(value: number, currency: "ETB" | "USD") { } export function OverviewPaymentChart({ data }: { data: IOverviewPaymentTrendPoint[] }) { - const hasData = data.some((point) => point.amountEtb > 0 || point.amountUsd > 0); + const hasData = data.some( + (point) => point.amountEtb > 0 || point.amountUsd > 0 || point.amountDjf > 0, + ); return ( @@ -50,10 +53,18 @@ export function OverviewPaymentChart({ data }: { data: IOverviewPaymentTrendPoin formatDateLabel(String(value))} - formatter={(value, name) => [ - formatAmount(Number(value), name === "amountUsd" ? "USD" : "ETB"), - name === "amountUsd" ? "USD" : "ETB", - ]} + formatter={(value, name) => { + // Series keys are amountEtb / amountUsd / amountDjf — the label is the + // suffix, so a new currency series needs no change here. + const currency = String(name).slice("amount".length).toUpperCase(); + return [ + formatAmount( + Number(value), + isPaymentCurrency(currency) ? currency : "ETB", + ), + currency, + ]; + }} /> + diff --git a/apps/edr-freight-web/backoffice/src/components/overview/overview.styles.ts b/apps/edr-freight-web/backoffice/src/components/overview/overview.styles.ts index c927df1ce..57b6c327b 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/overview.styles.ts +++ b/apps/edr-freight-web/backoffice/src/components/overview/overview.styles.ts @@ -7,6 +7,7 @@ export const overviewChartColors = { muted: freightBrand.mutedBg, etb: "#F2A516", usd: "#0369a1", + djf: "#7c3aed", /** Vibrant, well-separated categorical palette for charts (gold-forward). */ pipeline: [ "#F2A516", // gold (brand accent) diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx index e076b1f18..61d67f50f 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx @@ -1,10 +1,11 @@ +import type { PaymentCurrency } from "@edr/types"; import { Banknote, FileSignature, FileText, Package } from "lucide-react"; import { KpiStrip, type KpiItem } from "@/components/page"; import type { IOverviewKpis, IOverviewPeriodTotals } from "@/types/overview"; import { CountUp } from "./CountUp"; -function formatCurrency(amount: number, currency: "ETB" | "USD") { +function formatCurrency(amount: number, currency: PaymentCurrency) { return new Intl.NumberFormat("en-US", { style: "currency", currency, @@ -51,7 +52,11 @@ export function OverviewHeroKpis({ kpis, current, previous, rangeLabel }: Overvi { label: `Revenue (${rangeLabel})`, value: formatCompactCurrency(n, "ETB")} />, - hint: formatCurrency(current.revenueUsd, "USD"), + // Both non-ETB currencies, so DJF revenue is not simply invisible here. + hint: [ + formatCurrency(current.revenueUsd, "USD"), + ...(current.revenueDjf ? [formatCurrency(current.revenueDjf, "DJF")] : []), + ].join(" · "), icon: Banknote, color: "yellow", delta: pctDelta(current.revenueEtb, previous?.revenueEtb ?? 0), diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.test.ts b/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.test.ts index 369b19237..8c92c40d3 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.test.ts +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.test.ts @@ -9,7 +9,7 @@ describe("mergeTrend", () => { { date: "2026-06-01", count: 3 }, { date: "2026-06-02", count: 5 }, ], - [{ date: "2026-06-02", amountEtb: 1000, amountUsd: 0 }], + [{ date: "2026-06-02", amountEtb: 1000, amountUsd: 0, amountDjf: 0 }], ); expect(result).toEqual([ @@ -21,7 +21,7 @@ describe("mergeTrend", () => { it("sorts chronologically regardless of input order", () => { const result = mergeTrend( [{ date: "2026-06-03", count: 1 }], - [{ date: "2026-06-01", amountEtb: 500, amountUsd: 0 }], + [{ date: "2026-06-01", amountEtb: 500, amountUsd: 0, amountDjf: 0 }], ); expect(result.map((p) => p.date)).toEqual(["2026-06-01", "2026-06-03"]); @@ -37,8 +37,8 @@ describe("mergeTrend", () => { [], [ // 7d range: 2026-06-01 + 7 = 2026-06-08 (existing row), 06-02 + 7 = 06-09 (new row) - { date: "2026-06-01", amountEtb: 400, amountUsd: 0 }, - { date: "2026-06-02", amountEtb: 250, amountUsd: 0 }, + { date: "2026-06-01", amountEtb: 400, amountUsd: 0, amountDjf: 0 }, + { date: "2026-06-02", amountEtb: 250, amountUsd: 0, amountDjf: 0 }, ], 7, ); @@ -50,7 +50,7 @@ describe("mergeTrend", () => { }); it("shifts across a month boundary without timezone drift", () => { - const result = mergeTrend([], [], [{ date: "2026-05-28", amountEtb: 100, amountUsd: 0 }], 7); + const result = mergeTrend([], [], [{ date: "2026-05-28", amountEtb: 100, amountUsd: 0, amountDjf: 0 }], 7); expect(result[0].date).toBe("2026-06-04"); }); }); diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx index 52b8bf161..59e0c18f9 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx @@ -1,3 +1,4 @@ +import type { PaymentCurrency } from "@edr/types"; import { Banknote, CreditCard, Wallet } from "lucide-react"; import { Bar, @@ -18,7 +19,7 @@ import { OverviewKpiStrip } from "../OverviewKpiStrip"; import { OverviewPaymentChart } from "../OverviewPaymentChart"; import { overviewChartColors } from "../overview.styles"; -function formatCurrency(amount: number, currency: "ETB" | "USD") { +function formatCurrency(amount: number, currency: PaymentCurrency) { return new Intl.NumberFormat("en-US", { style: "currency", currency, @@ -57,6 +58,11 @@ export function OverviewBillingTabPanel({ data }: OverviewBillingTabPanelProps) value: formatCurrency(data.kpis.revenueMtdUsd, "USD"), icon: Wallet, }, + { + label: "Revenue MTD (DJF)", + value: formatCurrency(data.kpis.revenueMtdDjf, "DJF"), + icon: Wallet, + }, { label: "Pending payments", value: data.kpis.pendingPayments, diff --git a/packages/types/src/freight/overview.ts b/packages/types/src/freight/overview.ts index ede6d6b04..9cb4b5815 100644 --- a/packages/types/src/freight/overview.ts +++ b/packages/types/src/freight/overview.ts @@ -28,6 +28,7 @@ export interface IOverviewCustomerKpis { export interface IOverviewBillingKpis { revenueMtdEtb: number; revenueMtdUsd: number; + revenueMtdDjf: number; pendingPayments: number; successfulPaymentsMtd: number; } @@ -75,6 +76,7 @@ export interface IOverviewPaymentTrendPoint { date: string; amountEtb: number; amountUsd: number; + amountDjf: number; } export interface IOverviewRecentBooking { @@ -105,6 +107,7 @@ export interface IOverviewPeriodTotals { bookingsCreated: number; revenueEtb: number; revenueUsd: number; + revenueDjf: number; tons: number; } @@ -113,6 +116,7 @@ export interface IOverviewRevenueSlice { label: string; amountEtb: number; amountUsd: number; + amountDjf: number; } export interface IOverviewTonsTrendPoint { @@ -126,6 +130,7 @@ export interface IOverviewRevenueFlow { freightType: string; amountEtb: number; amountUsd: number; + amountDjf: number; } /** Booking arrivals for one weekday × 3-hour block. */ @@ -167,6 +172,7 @@ export interface IOverviewPaymentMethodBreakdown { count: number; amountEtb: number; amountUsd: number; + amountDjf: number; } export interface IOverviewCurrencyAmount {