From 82991812618d28d24920505007336919c0d79d1e Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 29 Aug 2026 08:13:19 +0000 Subject: [PATCH] fix(overview): stop non-ETB/USD revenue vanishing from the dashboards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven selects in overview.repository sum payments with a literal currency filter, one column for ETB and one for USD: COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0) Anything else is summed nowhere. A DJF payment would not appear as a separate figure — it would simply be absent from revenue MTD, the payment trend, the per-method breakdown and the direction and freight-type splits, with no error and nothing to notice. Adds the third column at each site, the matching response fields, and the tiles and stacked bar that render them. The payment chart's tooltip now derives the label from the series key instead of a ternary on "amountUsd", so it does not need touching again. ponytail: a third hardcoded currency is still the shorter diff. A fourth should force these seven into a GROUP BY payment.currency returning IOverviewCurrencyAmount[] — the shape getRevenueByCurrency already uses a few lines below. Note the ordering dependency: the DJF enum label must exist before these run. payments.currency is enum-typed, so `= 'DJF'` against a database where the migration has not been applied is a runtime error, not an empty result. All three query shapes were EXPLAIN-checked against the dev database. --- .../modules/overview/overview.repository.ts | 59 ++++++++++++++++--- .../overview/OverviewPaymentChart.tsx | 30 ++++++++-- .../components/overview/overview.styles.ts | 1 + .../overview/summary/OverviewHeroKpis.tsx | 9 ++- .../overview/summary/mergeTrend.test.ts | 10 ++-- .../overview/tabs/OverviewBillingTabPanel.tsx | 8 ++- packages/types/src/freight/overview.ts | 6 ++ 7 files changed, 101 insertions(+), 22 deletions(-) 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 {