mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-01 18:13:27 +00:00
fix(overview): stop non-ETB/USD revenue vanishing from the dashboards
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.
This commit is contained in:
@@ -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<{
|
async getBillingKpis(dirs?: string[]): Promise<{
|
||||||
revenueMtdEtb: number;
|
revenueMtdEtb: number;
|
||||||
revenueMtdUsd: number;
|
revenueMtdUsd: number;
|
||||||
|
revenueMtdDjf: number;
|
||||||
pendingPayments: number;
|
pendingPayments: number;
|
||||||
successfulPaymentsMtd: number;
|
successfulPaymentsMtd: number;
|
||||||
}> {
|
}> {
|
||||||
@@ -279,6 +283,10 @@ export class OverviewRepository {
|
|||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||||
"revenueMtdUsd",
|
"revenueMtdUsd",
|
||||||
)
|
)
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
|
||||||
|
"revenueMtdDjf",
|
||||||
|
)
|
||||||
.addSelect(`COUNT(*)::int`, "successfulPaymentsMtd")
|
.addSelect(`COUNT(*)::int`, "successfulPaymentsMtd")
|
||||||
.where("payment.status = :status", { status: "success" })
|
.where("payment.status = :status", { status: "success" })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
@@ -298,6 +306,7 @@ export class OverviewRepository {
|
|||||||
return {
|
return {
|
||||||
revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0),
|
revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0),
|
||||||
revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0),
|
revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0),
|
||||||
|
revenueMtdDjf: Number(revenueRow?.revenueMtdDjf ?? 0),
|
||||||
pendingPayments,
|
pendingPayments,
|
||||||
successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0),
|
successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0),
|
||||||
};
|
};
|
||||||
@@ -370,7 +379,7 @@ export class OverviewRepository {
|
|||||||
days: number,
|
days: number,
|
||||||
dirs?: string[],
|
dirs?: string[],
|
||||||
offsetDays = 0,
|
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 scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||||
const rows = await this.paymentRepository
|
const rows = await this.paymentRepository
|
||||||
.createQueryBuilder("payment")
|
.createQueryBuilder("payment")
|
||||||
@@ -386,6 +395,10 @@ export class OverviewRepository {
|
|||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||||
"amountUsd",
|
"amountUsd",
|
||||||
)
|
)
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
|
||||||
|
"amountDjf",
|
||||||
|
)
|
||||||
.where("payment.status = :status", { status: "success" })
|
.where("payment.status = :status", { status: "success" })
|
||||||
.andWhere(
|
.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`,
|
`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)
|
.andWhere(scope.sql, scope.params)
|
||||||
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
|
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
|
||||||
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC")
|
.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) => ({
|
return rows.map((row) => ({
|
||||||
date: row.date,
|
date: row.date,
|
||||||
amountEtb: Number(row.amountEtb),
|
amountEtb: Number(row.amountEtb),
|
||||||
amountUsd: Number(row.amountUsd),
|
amountUsd: Number(row.amountUsd),
|
||||||
|
amountDjf: Number(row.amountDjf),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,7 +524,7 @@ export class OverviewRepository {
|
|||||||
async getPaymentsByMethod(
|
async getPaymentsByMethod(
|
||||||
dirs?: string[],
|
dirs?: string[],
|
||||||
): Promise<
|
): 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 scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||||
const rows = await this.paymentRepository
|
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)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
|
||||||
"amountUsd",
|
"amountUsd",
|
||||||
)
|
)
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF' AND payment.status = 'success'), 0)`,
|
||||||
|
"amountDjf",
|
||||||
|
)
|
||||||
.where(scope.sql, scope.params)
|
.where(scope.sql, scope.params)
|
||||||
.groupBy("payment.method")
|
.groupBy("payment.method")
|
||||||
.orderBy("count", "DESC")
|
.orderBy("count", "DESC")
|
||||||
@@ -533,6 +551,7 @@ export class OverviewRepository {
|
|||||||
count: string;
|
count: string;
|
||||||
amountEtb: string;
|
amountEtb: string;
|
||||||
amountUsd: string;
|
amountUsd: string;
|
||||||
|
amountDjf: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -540,6 +559,7 @@ export class OverviewRepository {
|
|||||||
count: Number(row.count),
|
count: Number(row.count),
|
||||||
amountEtb: Number(row.amountEtb),
|
amountEtb: Number(row.amountEtb),
|
||||||
amountUsd: Number(row.amountUsd),
|
amountUsd: Number(row.amountUsd),
|
||||||
|
amountDjf: Number(row.amountDjf),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,6 +600,7 @@ export class OverviewRepository {
|
|||||||
bookingsCreated: number;
|
bookingsCreated: number;
|
||||||
revenueEtb: number;
|
revenueEtb: number;
|
||||||
revenueUsd: number;
|
revenueUsd: number;
|
||||||
|
revenueDjf: number;
|
||||||
tons: number;
|
tons: number;
|
||||||
}> {
|
}> {
|
||||||
const bookingScope = directionScopeSql("booking.trade_direction", dirs);
|
const bookingScope = directionScopeSql("booking.trade_direction", dirs);
|
||||||
@@ -605,13 +626,17 @@ export class OverviewRepository {
|
|||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||||
"revenueUsd",
|
"revenueUsd",
|
||||||
)
|
)
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
|
||||||
|
"revenueDjf",
|
||||||
|
)
|
||||||
.where("payment.status = :status", { status: "success" })
|
.where("payment.status = :status", { status: "success" })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
windowSql("COALESCE(payment.paid_at, payment.created_at)"),
|
windowSql("COALESCE(payment.paid_at, payment.created_at)"),
|
||||||
{ days, offsetDays },
|
{ days, offsetDays },
|
||||||
)
|
)
|
||||||
.andWhere(paymentScope.sql, paymentScope.params)
|
.andWhere(paymentScope.sql, paymentScope.params)
|
||||||
.getRawOne<{ revenueEtb: string; revenueUsd: string }>(),
|
.getRawOne<{ revenueEtb: string; revenueUsd: string; revenueDjf: string }>(),
|
||||||
this.cargoRepository
|
this.cargoRepository
|
||||||
.createQueryBuilder("cargo")
|
.createQueryBuilder("cargo")
|
||||||
.leftJoin(Booking, "booking", "booking.id = cargo.booking_id")
|
.leftJoin(Booking, "booking", "booking.id = cargo.booking_id")
|
||||||
@@ -626,6 +651,7 @@ export class OverviewRepository {
|
|||||||
bookingsCreated,
|
bookingsCreated,
|
||||||
revenueEtb: Number(revenueRow?.revenueEtb ?? 0),
|
revenueEtb: Number(revenueRow?.revenueEtb ?? 0),
|
||||||
revenueUsd: Number(revenueRow?.revenueUsd ?? 0),
|
revenueUsd: Number(revenueRow?.revenueUsd ?? 0),
|
||||||
|
revenueDjf: Number(revenueRow?.revenueDjf ?? 0),
|
||||||
tons: Number(tonsRow?.tons ?? 0),
|
tons: Number(tonsRow?.tons ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -634,7 +660,7 @@ export class OverviewRepository {
|
|||||||
async getRevenueByDirection(
|
async getRevenueByDirection(
|
||||||
days: number,
|
days: number,
|
||||||
dirs?: string[],
|
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 scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||||
const rows = await this.paymentRepository
|
const rows = await this.paymentRepository
|
||||||
.createQueryBuilder("payment")
|
.createQueryBuilder("payment")
|
||||||
@@ -648,6 +674,10 @@ export class OverviewRepository {
|
|||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||||
"amountUsd",
|
"amountUsd",
|
||||||
)
|
)
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
|
||||||
|
"amountDjf",
|
||||||
|
)
|
||||||
.where("payment.status = :status", { status: "success" })
|
.where("payment.status = :status", { status: "success" })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
`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(scope.sql, scope.params)
|
||||||
.andWhere("booking.trade_direction IS NOT NULL")
|
.andWhere("booking.trade_direction IS NOT NULL")
|
||||||
.groupBy("booking.trade_direction")
|
.groupBy("booking.trade_direction")
|
||||||
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>();
|
.getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
label: row.label,
|
label: row.label,
|
||||||
amountEtb: Number(row.amountEtb),
|
amountEtb: Number(row.amountEtb),
|
||||||
amountUsd: Number(row.amountUsd),
|
amountUsd: Number(row.amountUsd),
|
||||||
|
amountDjf: Number(row.amountDjf),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,7 +700,7 @@ export class OverviewRepository {
|
|||||||
async getRevenueByFreightType(
|
async getRevenueByFreightType(
|
||||||
days: number,
|
days: number,
|
||||||
dirs?: string[],
|
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 scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||||
const rows = await this.paymentRepository
|
const rows = await this.paymentRepository
|
||||||
.createQueryBuilder("payment")
|
.createQueryBuilder("payment")
|
||||||
@@ -683,6 +714,10 @@ export class OverviewRepository {
|
|||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||||
"amountUsd",
|
"amountUsd",
|
||||||
)
|
)
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
|
||||||
|
"amountDjf",
|
||||||
|
)
|
||||||
.where("payment.status = :status", { status: "success" })
|
.where("payment.status = :status", { status: "success" })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
`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(scope.sql, scope.params)
|
||||||
.andWhere("booking.freight_type IS NOT NULL")
|
.andWhere("booking.freight_type IS NOT NULL")
|
||||||
.groupBy("booking.freight_type")
|
.groupBy("booking.freight_type")
|
||||||
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>();
|
.getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
label: row.label,
|
label: row.label,
|
||||||
amountEtb: Number(row.amountEtb),
|
amountEtb: Number(row.amountEtb),
|
||||||
amountUsd: Number(row.amountUsd),
|
amountUsd: Number(row.amountUsd),
|
||||||
|
amountDjf: Number(row.amountDjf),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -734,6 +770,7 @@ export class OverviewRepository {
|
|||||||
freightType: string;
|
freightType: string;
|
||||||
amountEtb: number;
|
amountEtb: number;
|
||||||
amountUsd: number;
|
amountUsd: number;
|
||||||
|
amountDjf: number;
|
||||||
}[]
|
}[]
|
||||||
> {
|
> {
|
||||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||||
@@ -750,6 +787,10 @@ export class OverviewRepository {
|
|||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||||
"amountUsd",
|
"amountUsd",
|
||||||
)
|
)
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
|
||||||
|
"amountDjf",
|
||||||
|
)
|
||||||
.where("payment.status = :status", { status: "success" })
|
.where("payment.status = :status", { status: "success" })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||||
@@ -765,6 +806,7 @@ export class OverviewRepository {
|
|||||||
freightType: string;
|
freightType: string;
|
||||||
amountEtb: string;
|
amountEtb: string;
|
||||||
amountUsd: string;
|
amountUsd: string;
|
||||||
|
amountDjf: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -772,6 +814,7 @@ export class OverviewRepository {
|
|||||||
freightType: row.freightType,
|
freightType: row.freightType,
|
||||||
amountEtb: Number(row.amountEtb),
|
amountEtb: Number(row.amountEtb),
|
||||||
amountUsd: Number(row.amountUsd),
|
amountUsd: Number(row.amountUsd),
|
||||||
|
amountDjf: Number(row.amountDjf),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isPaymentCurrency, type PaymentCurrency } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -18,7 +19,7 @@ function formatDateLabel(date: string) {
|
|||||||
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
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", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency,
|
currency,
|
||||||
@@ -27,7 +28,9 @@ function formatAmount(value: number, currency: "ETB" | "USD") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function OverviewPaymentChart({ data }: { data: IOverviewPaymentTrendPoint[] }) {
|
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 (
|
return (
|
||||||
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
|
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
|
||||||
@@ -50,10 +53,18 @@ export function OverviewPaymentChart({ data }: { data: IOverviewPaymentTrendPoin
|
|||||||
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
labelFormatter={(value) => formatDateLabel(String(value))}
|
labelFormatter={(value) => formatDateLabel(String(value))}
|
||||||
formatter={(value, name) => [
|
formatter={(value, name) => {
|
||||||
formatAmount(Number(value), name === "amountUsd" ? "USD" : "ETB"),
|
// Series keys are amountEtb / amountUsd / amountDjf — the label is the
|
||||||
name === "amountUsd" ? "USD" : "ETB",
|
// 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,
|
||||||
|
];
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Legend />
|
<Legend />
|
||||||
<Bar
|
<Bar
|
||||||
@@ -68,6 +79,13 @@ export function OverviewPaymentChart({ data }: { data: IOverviewPaymentTrendPoin
|
|||||||
name="USD"
|
name="USD"
|
||||||
stackId="payments"
|
stackId="payments"
|
||||||
fill={overviewChartColors.usd}
|
fill={overviewChartColors.usd}
|
||||||
|
radius={[0, 0, 0, 0]}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="amountDjf"
|
||||||
|
name="DJF"
|
||||||
|
stackId="payments"
|
||||||
|
fill={overviewChartColors.djf}
|
||||||
radius={[4, 4, 0, 0]}
|
radius={[4, 4, 0, 0]}
|
||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const overviewChartColors = {
|
|||||||
muted: freightBrand.mutedBg,
|
muted: freightBrand.mutedBg,
|
||||||
etb: "#F2A516",
|
etb: "#F2A516",
|
||||||
usd: "#0369a1",
|
usd: "#0369a1",
|
||||||
|
djf: "#7c3aed",
|
||||||
/** Vibrant, well-separated categorical palette for charts (gold-forward). */
|
/** Vibrant, well-separated categorical palette for charts (gold-forward). */
|
||||||
pipeline: [
|
pipeline: [
|
||||||
"#F2A516", // gold (brand accent)
|
"#F2A516", // gold (brand accent)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import type { PaymentCurrency } from "@edr/types";
|
||||||
import { Banknote, FileSignature, FileText, Package } from "lucide-react";
|
import { Banknote, FileSignature, FileText, Package } from "lucide-react";
|
||||||
|
|
||||||
import { KpiStrip, type KpiItem } from "@/components/page";
|
import { KpiStrip, type KpiItem } from "@/components/page";
|
||||||
import type { IOverviewKpis, IOverviewPeriodTotals } from "@/types/overview";
|
import type { IOverviewKpis, IOverviewPeriodTotals } from "@/types/overview";
|
||||||
import { CountUp } from "./CountUp";
|
import { CountUp } from "./CountUp";
|
||||||
|
|
||||||
function formatCurrency(amount: number, currency: "ETB" | "USD") {
|
function formatCurrency(amount: number, currency: PaymentCurrency) {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency,
|
currency,
|
||||||
@@ -51,7 +52,11 @@ export function OverviewHeroKpis({ kpis, current, previous, rangeLabel }: Overvi
|
|||||||
{
|
{
|
||||||
label: `Revenue (${rangeLabel})`,
|
label: `Revenue (${rangeLabel})`,
|
||||||
value: <CountUp value={current.revenueEtb} format={(n) => formatCompactCurrency(n, "ETB")} />,
|
value: <CountUp value={current.revenueEtb} format={(n) => 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,
|
icon: Banknote,
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
delta: pctDelta(current.revenueEtb, previous?.revenueEtb ?? 0),
|
delta: pctDelta(current.revenueEtb, previous?.revenueEtb ?? 0),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ describe("mergeTrend", () => {
|
|||||||
{ date: "2026-06-01", count: 3 },
|
{ date: "2026-06-01", count: 3 },
|
||||||
{ date: "2026-06-02", count: 5 },
|
{ 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([
|
expect(result).toEqual([
|
||||||
@@ -21,7 +21,7 @@ describe("mergeTrend", () => {
|
|||||||
it("sorts chronologically regardless of input order", () => {
|
it("sorts chronologically regardless of input order", () => {
|
||||||
const result = mergeTrend(
|
const result = mergeTrend(
|
||||||
[{ date: "2026-06-03", count: 1 }],
|
[{ 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"]);
|
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)
|
// 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-01", amountEtb: 400, amountUsd: 0, amountDjf: 0 },
|
||||||
{ date: "2026-06-02", amountEtb: 250, amountUsd: 0 },
|
{ date: "2026-06-02", amountEtb: 250, amountUsd: 0, amountDjf: 0 },
|
||||||
],
|
],
|
||||||
7,
|
7,
|
||||||
);
|
);
|
||||||
@@ -50,7 +50,7 @@ describe("mergeTrend", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shifts across a month boundary without timezone drift", () => {
|
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");
|
expect(result[0].date).toBe("2026-06-04");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { PaymentCurrency } from "@edr/types";
|
||||||
import { Banknote, CreditCard, Wallet } from "lucide-react";
|
import { Banknote, CreditCard, Wallet } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
@@ -18,7 +19,7 @@ import { OverviewKpiStrip } from "../OverviewKpiStrip";
|
|||||||
import { OverviewPaymentChart } from "../OverviewPaymentChart";
|
import { OverviewPaymentChart } from "../OverviewPaymentChart";
|
||||||
import { overviewChartColors } from "../overview.styles";
|
import { overviewChartColors } from "../overview.styles";
|
||||||
|
|
||||||
function formatCurrency(amount: number, currency: "ETB" | "USD") {
|
function formatCurrency(amount: number, currency: PaymentCurrency) {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency,
|
currency,
|
||||||
@@ -57,6 +58,11 @@ export function OverviewBillingTabPanel({ data }: OverviewBillingTabPanelProps)
|
|||||||
value: formatCurrency(data.kpis.revenueMtdUsd, "USD"),
|
value: formatCurrency(data.kpis.revenueMtdUsd, "USD"),
|
||||||
icon: Wallet,
|
icon: Wallet,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Revenue MTD (DJF)",
|
||||||
|
value: formatCurrency(data.kpis.revenueMtdDjf, "DJF"),
|
||||||
|
icon: Wallet,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Pending payments",
|
label: "Pending payments",
|
||||||
value: data.kpis.pendingPayments,
|
value: data.kpis.pendingPayments,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface IOverviewCustomerKpis {
|
|||||||
export interface IOverviewBillingKpis {
|
export interface IOverviewBillingKpis {
|
||||||
revenueMtdEtb: number;
|
revenueMtdEtb: number;
|
||||||
revenueMtdUsd: number;
|
revenueMtdUsd: number;
|
||||||
|
revenueMtdDjf: number;
|
||||||
pendingPayments: number;
|
pendingPayments: number;
|
||||||
successfulPaymentsMtd: number;
|
successfulPaymentsMtd: number;
|
||||||
}
|
}
|
||||||
@@ -75,6 +76,7 @@ export interface IOverviewPaymentTrendPoint {
|
|||||||
date: string;
|
date: string;
|
||||||
amountEtb: number;
|
amountEtb: number;
|
||||||
amountUsd: number;
|
amountUsd: number;
|
||||||
|
amountDjf: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IOverviewRecentBooking {
|
export interface IOverviewRecentBooking {
|
||||||
@@ -105,6 +107,7 @@ export interface IOverviewPeriodTotals {
|
|||||||
bookingsCreated: number;
|
bookingsCreated: number;
|
||||||
revenueEtb: number;
|
revenueEtb: number;
|
||||||
revenueUsd: number;
|
revenueUsd: number;
|
||||||
|
revenueDjf: number;
|
||||||
tons: number;
|
tons: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +116,7 @@ export interface IOverviewRevenueSlice {
|
|||||||
label: string;
|
label: string;
|
||||||
amountEtb: number;
|
amountEtb: number;
|
||||||
amountUsd: number;
|
amountUsd: number;
|
||||||
|
amountDjf: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IOverviewTonsTrendPoint {
|
export interface IOverviewTonsTrendPoint {
|
||||||
@@ -126,6 +130,7 @@ export interface IOverviewRevenueFlow {
|
|||||||
freightType: string;
|
freightType: string;
|
||||||
amountEtb: number;
|
amountEtb: number;
|
||||||
amountUsd: number;
|
amountUsd: number;
|
||||||
|
amountDjf: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Booking arrivals for one weekday × 3-hour block. */
|
/** Booking arrivals for one weekday × 3-hour block. */
|
||||||
@@ -167,6 +172,7 @@ export interface IOverviewPaymentMethodBreakdown {
|
|||||||
count: number;
|
count: number;
|
||||||
amountEtb: number;
|
amountEtb: number;
|
||||||
amountUsd: number;
|
amountUsd: number;
|
||||||
|
amountDjf: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IOverviewCurrencyAmount {
|
export interface IOverviewCurrencyAmount {
|
||||||
|
|||||||
Reference in New Issue
Block a user