mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
style: overview revamp
This commit is contained in:
@@ -21,6 +21,7 @@ export class OverviewContractKpisDto {
|
||||
export class OverviewOperationsKpisDto {
|
||||
@ApiProperty() trainsActive!: number;
|
||||
@ApiProperty() wagonsAvailable!: number;
|
||||
@ApiProperty() wagonsTotal!: number;
|
||||
@ApiProperty() containersInTransit!: number;
|
||||
@ApiProperty() cargoesLoaded!: number;
|
||||
@ApiProperty() schedulesUpcoming!: number;
|
||||
@@ -108,6 +109,41 @@ export class OverviewRecentContractDto {
|
||||
@ApiProperty() createdAt!: string;
|
||||
}
|
||||
|
||||
export class OverviewPeriodTotalsDto {
|
||||
@ApiProperty() bookingsCreated!: number;
|
||||
@ApiProperty() revenueEtb!: number;
|
||||
@ApiProperty() revenueUsd!: number;
|
||||
@ApiProperty() tons!: number;
|
||||
}
|
||||
|
||||
export class OverviewRevenueSliceDto {
|
||||
@ApiProperty() label!: string;
|
||||
@ApiProperty() amountEtb!: number;
|
||||
@ApiProperty() amountUsd!: number;
|
||||
}
|
||||
|
||||
export class OverviewTonsTrendPointDto {
|
||||
@ApiProperty({ example: '2026-06-01' }) date!: string;
|
||||
@ApiProperty() tons!: number;
|
||||
}
|
||||
|
||||
export class OverviewRevenueFlowDto {
|
||||
@ApiProperty() direction!: string;
|
||||
@ApiProperty() freightType!: string;
|
||||
@ApiProperty() amountEtb!: number;
|
||||
@ApiProperty() amountUsd!: number;
|
||||
}
|
||||
|
||||
export class OverviewHeatmapCellDto {
|
||||
@ApiProperty({ description: 'ISO weekday, 1 = Monday … 7 = Sunday' })
|
||||
dow!: number;
|
||||
|
||||
@ApiProperty({ description: '3-hour block, 0 = 00–03 … 7 = 21–24' })
|
||||
block!: number;
|
||||
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class OverviewResponseDto {
|
||||
@ApiProperty({ type: OverviewKpisDto })
|
||||
kpis!: OverviewKpisDto;
|
||||
@@ -124,8 +160,29 @@ export class OverviewResponseDto {
|
||||
@ApiProperty({ type: [OverviewPaymentTrendPointDto] })
|
||||
paymentTrend!: OverviewPaymentTrendPointDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewRecentBookingDto] })
|
||||
recentBookings!: OverviewRecentBookingDto[];
|
||||
@ApiProperty({ type: OverviewPeriodTotalsDto })
|
||||
current!: OverviewPeriodTotalsDto;
|
||||
|
||||
@ApiProperty({ type: OverviewPeriodTotalsDto })
|
||||
previous!: OverviewPeriodTotalsDto;
|
||||
|
||||
@ApiProperty({ type: [OverviewRevenueSliceDto] })
|
||||
revenueByDirection!: OverviewRevenueSliceDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewRevenueSliceDto] })
|
||||
revenueByFreightType!: OverviewRevenueSliceDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewPaymentTrendPointDto] })
|
||||
previousPaymentTrend!: OverviewPaymentTrendPointDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewTonsTrendPointDto] })
|
||||
tonsTrend!: OverviewTonsTrendPointDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewRevenueFlowDto] })
|
||||
revenueFlows!: OverviewRevenueFlowDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewHeatmapCellDto] })
|
||||
bookingHeatmap!: OverviewHeatmapCellDto[];
|
||||
|
||||
@ApiProperty() generatedAt!: string;
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ export class OverviewRepository {
|
||||
async getOperationsKpis(): Promise<{
|
||||
trainsActive: number;
|
||||
wagonsAvailable: number;
|
||||
wagonsTotal: number;
|
||||
containersInTransit: number;
|
||||
cargoesLoaded: number;
|
||||
schedulesUpcoming: number;
|
||||
@@ -163,6 +164,7 @@ export class OverviewRepository {
|
||||
const [
|
||||
trainsActive,
|
||||
wagonsAvailable,
|
||||
wagonsTotal,
|
||||
containersInTransit,
|
||||
cargoesLoaded,
|
||||
schedulesUpcoming,
|
||||
@@ -185,6 +187,10 @@ export class OverviewRepository {
|
||||
status: Freight.WagonStatus.Available,
|
||||
})
|
||||
.getCount(),
|
||||
this.wagonRepository
|
||||
.createQueryBuilder("wagon")
|
||||
.where("wagon.deleted_at IS NULL")
|
||||
.getCount(),
|
||||
this.containerRepository
|
||||
.createQueryBuilder("container")
|
||||
.where("container.deleted_at IS NULL")
|
||||
@@ -218,6 +224,7 @@ export class OverviewRepository {
|
||||
return {
|
||||
trainsActive,
|
||||
wagonsAvailable,
|
||||
wagonsTotal,
|
||||
containersInTransit,
|
||||
cargoesLoaded,
|
||||
schedulesUpcoming,
|
||||
@@ -345,9 +352,16 @@ export class OverviewRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily successful-payment revenue for a `days`-wide window shifted back by
|
||||
* `offsetDays` — `0` (default) is the current window ending today,
|
||||
* `offsetDays: days` is the immediately preceding window (the ghost-line
|
||||
* comparison series on the overview chart).
|
||||
*/
|
||||
async getPaymentTrend(
|
||||
days: number,
|
||||
dirs?: string[],
|
||||
offsetDays = 0,
|
||||
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
|
||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||
const rows = await this.paymentRepository
|
||||
@@ -366,8 +380,8 @@ export class OverviewRepository {
|
||||
)
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||
{ days },
|
||||
`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`,
|
||||
{ days, offsetDays },
|
||||
)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
|
||||
@@ -546,6 +560,247 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookings created, revenue and tonnage for one `days`-wide window, shifted
|
||||
* back by `offsetDays`. Called twice by the service — `offsetDays: 0` for
|
||||
* the current period, `offsetDays: days` for the immediately preceding
|
||||
* one-of-the-same-length period — so the page can show a real vs-prior-period
|
||||
* delta instead of a bare count.
|
||||
*/
|
||||
async getPeriodTotals(
|
||||
days: number,
|
||||
offsetDays: number,
|
||||
dirs?: string[],
|
||||
): Promise<{
|
||||
bookingsCreated: number;
|
||||
revenueEtb: number;
|
||||
revenueUsd: number;
|
||||
tons: number;
|
||||
}> {
|
||||
const bookingScope = directionScopeSql("booking.trade_direction", dirs);
|
||||
const paymentScope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||
const cargoScope = directionScopeSql("booking.trade_direction", dirs);
|
||||
const windowSql = (column: string) =>
|
||||
`${column} >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND ${column} < CURRENT_DATE - :offsetDays::int + 1`;
|
||||
|
||||
const [bookingsCreated, revenueRow, tonsRow] = await Promise.all([
|
||||
this.bookingRepository
|
||||
.createQueryBuilder("booking")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(bookingScope.sql, bookingScope.params)
|
||||
.andWhere(windowSql("booking.created_at"), { days, offsetDays })
|
||||
.getCount(),
|
||||
this.paymentRepository
|
||||
.createQueryBuilder("payment")
|
||||
.select(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||||
"revenueEtb",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
"revenueUsd",
|
||||
)
|
||||
.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 }>(),
|
||||
this.cargoRepository
|
||||
.createQueryBuilder("cargo")
|
||||
.leftJoin(Booking, "booking", "booking.id = cargo.booking_id")
|
||||
.select(`COALESCE(SUM(cargo.weight), 0) / 1000`, "tons")
|
||||
.where("cargo.deleted_at IS NULL")
|
||||
.andWhere(windowSql("cargo.created_at"), { days, offsetDays })
|
||||
.andWhere(cargoScope.sql, cargoScope.params)
|
||||
.getRawOne<{ tons: string }>(),
|
||||
]);
|
||||
|
||||
return {
|
||||
bookingsCreated,
|
||||
revenueEtb: Number(revenueRow?.revenueEtb ?? 0),
|
||||
revenueUsd: Number(revenueRow?.revenueUsd ?? 0),
|
||||
tons: Number(tonsRow?.tons ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** Revenue for the selected range, split by booking trade direction. */
|
||||
async getRevenueByDirection(
|
||||
days: number,
|
||||
dirs?: string[],
|
||||
): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> {
|
||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder("payment")
|
||||
.leftJoin(Booking, "booking", "booking.id::text = payment.ref_id")
|
||||
.select("booking.trade_direction", "label")
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||||
"amountEtb",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
"amountUsd",
|
||||
)
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||
{ days },
|
||||
)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.andWhere("booking.trade_direction IS NOT NULL")
|
||||
.groupBy("booking.trade_direction")
|
||||
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: row.label,
|
||||
amountEtb: Number(row.amountEtb),
|
||||
amountUsd: Number(row.amountUsd),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Revenue for the selected range, split by booking freight type. */
|
||||
async getRevenueByFreightType(
|
||||
days: number,
|
||||
dirs?: string[],
|
||||
): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> {
|
||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder("payment")
|
||||
.leftJoin(Booking, "booking", "booking.id::text = payment.ref_id")
|
||||
.select("booking.freight_type", "label")
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||||
"amountEtb",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
"amountUsd",
|
||||
)
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||
{ days },
|
||||
)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.andWhere("booking.freight_type IS NOT NULL")
|
||||
.groupBy("booking.freight_type")
|
||||
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: row.label,
|
||||
amountEtb: Number(row.amountEtb),
|
||||
amountUsd: Number(row.amountUsd),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Daily cargo tonnage for the selected range — hero sparkline series. */
|
||||
async getTonsTrend(
|
||||
days: number,
|
||||
dirs?: string[],
|
||||
): Promise<{ date: string; tons: number }[]> {
|
||||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||
const rows = await this.cargoRepository
|
||||
.createQueryBuilder("cargo")
|
||||
.leftJoin(Booking, "booking", "booking.id = cargo.booking_id")
|
||||
.select(`to_char(cargo.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||
.addSelect(`COALESCE(SUM(cargo.weight), 0) / 1000`, "tons")
|
||||
.where("cargo.deleted_at IS NULL")
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.andWhere(`cargo.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy("cargo.created_at::date")
|
||||
.orderBy("cargo.created_at::date", "ASC")
|
||||
.getRawMany<{ date: string; tons: string }>();
|
||||
|
||||
return rows.map((row) => ({ date: row.date, tons: Number(row.tons) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Revenue for the selected range as direction → freight-type flows — the
|
||||
* Sankey on the overview. One row per (direction, freight type) pair.
|
||||
*/
|
||||
async getRevenueFlows(
|
||||
days: number,
|
||||
dirs?: string[],
|
||||
): Promise<
|
||||
{
|
||||
direction: string;
|
||||
freightType: string;
|
||||
amountEtb: number;
|
||||
amountUsd: number;
|
||||
}[]
|
||||
> {
|
||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder("payment")
|
||||
.leftJoin(Booking, "booking", "booking.id::text = payment.ref_id")
|
||||
.select("booking.trade_direction", "direction")
|
||||
.addSelect("booking.freight_type", "freightType")
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||||
"amountEtb",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
"amountUsd",
|
||||
)
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||
{ days },
|
||||
)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.andWhere("booking.trade_direction IS NOT NULL")
|
||||
.andWhere("booking.freight_type IS NOT NULL")
|
||||
.groupBy("booking.trade_direction")
|
||||
.addGroupBy("booking.freight_type")
|
||||
.getRawMany<{
|
||||
direction: string;
|
||||
freightType: string;
|
||||
amountEtb: string;
|
||||
amountUsd: string;
|
||||
}>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
direction: row.direction,
|
||||
freightType: row.freightType,
|
||||
amountEtb: Number(row.amountEtb),
|
||||
amountUsd: Number(row.amountUsd),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking arrivals bucketed by ISO weekday (1 = Mon … 7 = Sun) and 3-hour
|
||||
* block (0 = 00–03 … 7 = 21–24) — the demand-rhythm heatmap. Buckets use
|
||||
* the database server's timezone, same as every ::date grouping here.
|
||||
*/
|
||||
async getBookingHeatmap(
|
||||
days: number,
|
||||
dirs?: string[],
|
||||
): Promise<{ dow: number; block: number; count: number }[]> {
|
||||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder("booking")
|
||||
.select("EXTRACT(ISODOW FROM booking.created_at)::int", "dow")
|
||||
.addSelect("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int", "block")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy("EXTRACT(ISODOW FROM booking.created_at)::int")
|
||||
.addGroupBy("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int")
|
||||
.getRawMany<{ dow: string; block: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
dow: Number(row.dow),
|
||||
block: Number(row.block),
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
async getTrainStatusBreakdown(): Promise<
|
||||
{ status: string; count: number }[]
|
||||
> {
|
||||
|
||||
@@ -56,7 +56,14 @@ export class OverviewService {
|
||||
bookingTrend,
|
||||
statusCounts,
|
||||
paymentTrend,
|
||||
recentBookings,
|
||||
current,
|
||||
previous,
|
||||
revenueByDirection,
|
||||
revenueByFreightType,
|
||||
previousPaymentTrend,
|
||||
tonsTrend,
|
||||
revenueFlows,
|
||||
bookingHeatmap,
|
||||
] = await Promise.all([
|
||||
this.overviewRepository.getBookingKpis(dirs),
|
||||
this.overviewRepository.getContractKpis(dirs),
|
||||
@@ -67,7 +74,14 @@ export class OverviewService {
|
||||
this.overviewRepository.getBookingTrend(days, dirs),
|
||||
this.overviewRepository.getStatusCounts(dirs),
|
||||
this.overviewRepository.getPaymentTrend(days, dirs),
|
||||
this.overviewRepository.getRecentBookings(8, dirs),
|
||||
this.overviewRepository.getPeriodTotals(days, 0, dirs),
|
||||
this.overviewRepository.getPeriodTotals(days, days, dirs),
|
||||
this.overviewRepository.getRevenueByDirection(days, dirs),
|
||||
this.overviewRepository.getRevenueByFreightType(days, dirs),
|
||||
this.overviewRepository.getPaymentTrend(days, dirs, days),
|
||||
this.overviewRepository.getTonsTrend(days, dirs),
|
||||
this.overviewRepository.getRevenueFlows(days, dirs),
|
||||
this.overviewRepository.getBookingHeatmap(days, dirs),
|
||||
]);
|
||||
|
||||
const { bookingsByPipeline, bookingsByStatus } =
|
||||
@@ -86,10 +100,14 @@ export class OverviewService {
|
||||
bookingsByStatus,
|
||||
bookingsByPipeline,
|
||||
paymentTrend,
|
||||
recentBookings: recentBookings.map((row) => ({
|
||||
...row,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
})),
|
||||
current,
|
||||
previous,
|
||||
revenueByDirection,
|
||||
revenueByFreightType,
|
||||
previousPaymentTrend,
|
||||
tonsTrend,
|
||||
revenueFlows,
|
||||
bookingHeatmap,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user