mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -21,6 +21,7 @@ export class OverviewContractKpisDto {
|
|||||||
export class OverviewOperationsKpisDto {
|
export class OverviewOperationsKpisDto {
|
||||||
@ApiProperty() trainsActive!: number;
|
@ApiProperty() trainsActive!: number;
|
||||||
@ApiProperty() wagonsAvailable!: number;
|
@ApiProperty() wagonsAvailable!: number;
|
||||||
|
@ApiProperty() wagonsTotal!: number;
|
||||||
@ApiProperty() containersInTransit!: number;
|
@ApiProperty() containersInTransit!: number;
|
||||||
@ApiProperty() cargoesLoaded!: number;
|
@ApiProperty() cargoesLoaded!: number;
|
||||||
@ApiProperty() schedulesUpcoming!: number;
|
@ApiProperty() schedulesUpcoming!: number;
|
||||||
@@ -108,6 +109,41 @@ export class OverviewRecentContractDto {
|
|||||||
@ApiProperty() createdAt!: string;
|
@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 {
|
export class OverviewResponseDto {
|
||||||
@ApiProperty({ type: OverviewKpisDto })
|
@ApiProperty({ type: OverviewKpisDto })
|
||||||
kpis!: OverviewKpisDto;
|
kpis!: OverviewKpisDto;
|
||||||
@@ -124,8 +160,29 @@ export class OverviewResponseDto {
|
|||||||
@ApiProperty({ type: [OverviewPaymentTrendPointDto] })
|
@ApiProperty({ type: [OverviewPaymentTrendPointDto] })
|
||||||
paymentTrend!: OverviewPaymentTrendPointDto[];
|
paymentTrend!: OverviewPaymentTrendPointDto[];
|
||||||
|
|
||||||
@ApiProperty({ type: [OverviewRecentBookingDto] })
|
@ApiProperty({ type: OverviewPeriodTotalsDto })
|
||||||
recentBookings!: OverviewRecentBookingDto[];
|
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;
|
@ApiProperty() generatedAt!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export class OverviewRepository {
|
|||||||
async getOperationsKpis(): Promise<{
|
async getOperationsKpis(): Promise<{
|
||||||
trainsActive: number;
|
trainsActive: number;
|
||||||
wagonsAvailable: number;
|
wagonsAvailable: number;
|
||||||
|
wagonsTotal: number;
|
||||||
containersInTransit: number;
|
containersInTransit: number;
|
||||||
cargoesLoaded: number;
|
cargoesLoaded: number;
|
||||||
schedulesUpcoming: number;
|
schedulesUpcoming: number;
|
||||||
@@ -163,6 +164,7 @@ export class OverviewRepository {
|
|||||||
const [
|
const [
|
||||||
trainsActive,
|
trainsActive,
|
||||||
wagonsAvailable,
|
wagonsAvailable,
|
||||||
|
wagonsTotal,
|
||||||
containersInTransit,
|
containersInTransit,
|
||||||
cargoesLoaded,
|
cargoesLoaded,
|
||||||
schedulesUpcoming,
|
schedulesUpcoming,
|
||||||
@@ -185,6 +187,10 @@ export class OverviewRepository {
|
|||||||
status: Freight.WagonStatus.Available,
|
status: Freight.WagonStatus.Available,
|
||||||
})
|
})
|
||||||
.getCount(),
|
.getCount(),
|
||||||
|
this.wagonRepository
|
||||||
|
.createQueryBuilder("wagon")
|
||||||
|
.where("wagon.deleted_at IS NULL")
|
||||||
|
.getCount(),
|
||||||
this.containerRepository
|
this.containerRepository
|
||||||
.createQueryBuilder("container")
|
.createQueryBuilder("container")
|
||||||
.where("container.deleted_at IS NULL")
|
.where("container.deleted_at IS NULL")
|
||||||
@@ -218,6 +224,7 @@ export class OverviewRepository {
|
|||||||
return {
|
return {
|
||||||
trainsActive,
|
trainsActive,
|
||||||
wagonsAvailable,
|
wagonsAvailable,
|
||||||
|
wagonsTotal,
|
||||||
containersInTransit,
|
containersInTransit,
|
||||||
cargoesLoaded,
|
cargoesLoaded,
|
||||||
schedulesUpcoming,
|
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(
|
async getPaymentTrend(
|
||||||
days: number,
|
days: number,
|
||||||
dirs?: string[],
|
dirs?: string[],
|
||||||
|
offsetDays = 0,
|
||||||
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
|
): Promise<{ date: string; amountEtb: number; amountUsd: 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
|
||||||
@@ -366,8 +380,8 @@ export class OverviewRepository {
|
|||||||
)
|
)
|
||||||
.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 - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`,
|
||||||
{ days },
|
{ days, offsetDays },
|
||||||
)
|
)
|
||||||
.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`)
|
||||||
@@ -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<
|
async getTrainStatusBreakdown(): Promise<
|
||||||
{ status: string; count: number }[]
|
{ status: string; count: number }[]
|
||||||
> {
|
> {
|
||||||
|
|||||||
@@ -56,7 +56,14 @@ export class OverviewService {
|
|||||||
bookingTrend,
|
bookingTrend,
|
||||||
statusCounts,
|
statusCounts,
|
||||||
paymentTrend,
|
paymentTrend,
|
||||||
recentBookings,
|
current,
|
||||||
|
previous,
|
||||||
|
revenueByDirection,
|
||||||
|
revenueByFreightType,
|
||||||
|
previousPaymentTrend,
|
||||||
|
tonsTrend,
|
||||||
|
revenueFlows,
|
||||||
|
bookingHeatmap,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.overviewRepository.getBookingKpis(dirs),
|
this.overviewRepository.getBookingKpis(dirs),
|
||||||
this.overviewRepository.getContractKpis(dirs),
|
this.overviewRepository.getContractKpis(dirs),
|
||||||
@@ -67,7 +74,14 @@ export class OverviewService {
|
|||||||
this.overviewRepository.getBookingTrend(days, dirs),
|
this.overviewRepository.getBookingTrend(days, dirs),
|
||||||
this.overviewRepository.getStatusCounts(dirs),
|
this.overviewRepository.getStatusCounts(dirs),
|
||||||
this.overviewRepository.getPaymentTrend(days, 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 } =
|
const { bookingsByPipeline, bookingsByStatus } =
|
||||||
@@ -86,10 +100,14 @@ export class OverviewService {
|
|||||||
bookingsByStatus,
|
bookingsByStatus,
|
||||||
bookingsByPipeline,
|
bookingsByPipeline,
|
||||||
paymentTrend,
|
paymentTrend,
|
||||||
recentBookings: recentBookings.map((row) => ({
|
current,
|
||||||
...row,
|
previous,
|
||||||
createdAt: row.createdAt.toISOString(),
|
revenueByDirection,
|
||||||
})),
|
revenueByFreightType,
|
||||||
|
previousPaymentTrend,
|
||||||
|
tonsTrend,
|
||||||
|
revenueFlows,
|
||||||
|
bookingHeatmap,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
|||||||
import FinanceHubPage from "./pages/invoices/FinanceHubPage";
|
import FinanceHubPage from "./pages/invoices/FinanceHubPage";
|
||||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||||
|
import OverviewDomainPage from "./pages/dashboard/OverviewDomainPage";
|
||||||
|
import { OVERVIEW_DOMAINS } from "./components/overview/overview-domains.config";
|
||||||
import ReportsIndexRedirect from "./pages/reports/ReportsIndexRedirect";
|
import ReportsIndexRedirect from "./pages/reports/ReportsIndexRedirect";
|
||||||
import ReportPage from "./pages/reports/ReportPage";
|
import ReportPage from "./pages/reports/ReportPage";
|
||||||
import AuditLogsPage from "./pages/AuditLogsPage";
|
import AuditLogsPage from "./pages/AuditLogsPage";
|
||||||
@@ -222,6 +224,21 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* One drill-down route per overview domain — the old per-tab charts,
|
||||||
|
now each on its own page. Single source of truth for the
|
||||||
|
permission gate is OVERVIEW_DOMAINS, shared with the summary
|
||||||
|
page's "View all" links. */}
|
||||||
|
{OVERVIEW_DOMAINS.map((domain) => (
|
||||||
|
<Route
|
||||||
|
key={domain.key}
|
||||||
|
path={`overview/${domain.key}`}
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={domain.permission}>
|
||||||
|
<OverviewDomainPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
<Route
|
<Route
|
||||||
path="reports"
|
path="reports"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ChevronRight, ExternalLink, MoreHorizontal } from "lucide-react";
|
import { ExternalLink, MoreHorizontal } from "lucide-react";
|
||||||
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
|
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
|
||||||
|
|
||||||
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||||
@@ -64,17 +64,9 @@ export function BookingActionsMenu({
|
|||||||
|
|
||||||
const hasMenu = listRowHasActions(row, user);
|
const hasMenu = listRowHasActions(row, user);
|
||||||
|
|
||||||
|
// Row click already opens the detail page — no chevron affordance needed.
|
||||||
if (!hasMenu && variant === "table") {
|
if (!hasMenu && variant === "table") {
|
||||||
return (
|
return null;
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={() => navigate(`/dashboard/booking-requests/${row.id}`)}
|
|
||||||
aria-label="View booking"
|
|
||||||
>
|
|
||||||
<ChevronRight size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toolbar: lay every action out as a button row.
|
// Toolbar: lay every action out as a button row.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Badge, Group } from "@mantine/core";
|
import { Badge, Group } from "@mantine/core";
|
||||||
import { Link2 } from "lucide-react";
|
import { Link2 } from "lucide-react";
|
||||||
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
|
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
|
||||||
|
import { humanize } from "@/lib/format";
|
||||||
|
|
||||||
const statusColorMap: Record<string, string> = {
|
const statusColorMap: Record<string, string> = {
|
||||||
DRAFT: "gray",
|
DRAFT: "gray",
|
||||||
@@ -40,7 +41,7 @@ export function BookingStatusBadge({
|
|||||||
partnerReference,
|
partnerReference,
|
||||||
}: BookingStatusBadgeProps) {
|
}: BookingStatusBadgeProps) {
|
||||||
const style = BOOKING_STATUS_STYLES[status] ?? {
|
const style = BOOKING_STATUS_STYLES[status] ?? {
|
||||||
label: status,
|
label: humanize(status),
|
||||||
color: "gray",
|
color: "gray",
|
||||||
};
|
};
|
||||||
const color = statusColorMap[status] ?? "gray";
|
const color = statusColorMap[status] ?? "gray";
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ActionIcon, Indicator } from "@mantine/core";
|
||||||
|
import { Filter } from "lucide-react";
|
||||||
|
|
||||||
|
export interface FilterToggleProps {
|
||||||
|
/** Number of active advanced filters — shown as a badge on the button. */
|
||||||
|
count: number;
|
||||||
|
expanded: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle for the collapsible advanced-filters row on list pages. */
|
||||||
|
export function FilterToggle({ count, expanded, onClick }: FilterToggleProps) {
|
||||||
|
return (
|
||||||
|
<Indicator
|
||||||
|
label={count}
|
||||||
|
size={16}
|
||||||
|
color="edr-green"
|
||||||
|
disabled={count === 0}
|
||||||
|
offset={4}
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
variant={expanded ? "filled" : "default"}
|
||||||
|
color="edr-green"
|
||||||
|
size="lg"
|
||||||
|
radius="lg"
|
||||||
|
aria-label="Toggle advanced filters"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Filter size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Indicator>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core";
|
import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
import {
|
import {
|
||||||
CONTRACT_APPROVAL_ROLE_LABELS,
|
CONTRACT_APPROVAL_ROLE_LABELS,
|
||||||
HAZARDOUS_APPROVAL_ROLE_PERMISSION,
|
HAZARDOUS_APPROVAL_ROLE_PERMISSION,
|
||||||
@@ -54,10 +55,6 @@ function formatAgo(iso: string): string {
|
|||||||
return "just now";
|
return "just now";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
return new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
|
|
||||||
}
|
|
||||||
|
|
||||||
type MilestoneIcon = typeof Send;
|
type MilestoneIcon = typeof Send;
|
||||||
|
|
||||||
interface Milestone {
|
interface Milestone {
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import { Badge, ScrollArea, Tabs } from "@mantine/core";
|
|
||||||
import {
|
|
||||||
ClipboardCheck,
|
|
||||||
FileSignature,
|
|
||||||
Inbox,
|
|
||||||
LayoutGrid,
|
|
||||||
ShieldCheck,
|
|
||||||
Truck,
|
|
||||||
XCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import "@/components/overview/overview.css";
|
|
||||||
import {
|
|
||||||
CONTRACT_LIST_TABS,
|
|
||||||
type ContractStatusTabKey,
|
|
||||||
} from "@/features/contracts/contract-status.config";
|
|
||||||
|
|
||||||
const TAB_ICONS: Record<ContractStatusTabKey, React.ReactNode> = {
|
|
||||||
all: <LayoutGrid size={17} strokeWidth={1.85} />,
|
|
||||||
intake: <Inbox size={17} strokeWidth={1.85} />,
|
|
||||||
in_approval: <ClipboardCheck size={17} strokeWidth={1.85} />,
|
|
||||||
approved_contract: <FileSignature size={17} strokeWidth={1.85} />,
|
|
||||||
clearance: <ShieldCheck size={17} strokeWidth={1.85} />,
|
|
||||||
active: <Truck size={17} strokeWidth={1.85} />,
|
|
||||||
closed: <XCircle size={17} strokeWidth={1.85} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ContractStatusTabsProps {
|
|
||||||
active: ContractStatusTabKey;
|
|
||||||
onChange: (tab: ContractStatusTabKey) => void;
|
|
||||||
counts?: Partial<Record<ContractStatusTabKey, number>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContractStatusTabs({
|
|
||||||
active,
|
|
||||||
onChange,
|
|
||||||
counts,
|
|
||||||
}: ContractStatusTabsProps) {
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
value={active}
|
|
||||||
onChange={(value) => onChange((value as ContractStatusTabKey) ?? "all")}
|
|
||||||
variant="pills"
|
|
||||||
color="edr-green"
|
|
||||||
keepMounted={false}
|
|
||||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
|
||||||
>
|
|
||||||
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars="x">
|
|
||||||
<Tabs.List style={{ flexWrap: "nowrap", width: "max-content" }}>
|
|
||||||
{CONTRACT_LIST_TABS.map((tab) => {
|
|
||||||
const isActive = active === tab.key;
|
|
||||||
const count = counts?.[tab.key];
|
|
||||||
return (
|
|
||||||
<Tabs.Tab
|
|
||||||
key={tab.key}
|
|
||||||
value={tab.key}
|
|
||||||
leftSection={TAB_ICONS[tab.key]}
|
|
||||||
size={"sm"}
|
|
||||||
rightSection={
|
|
||||||
count !== undefined ? (
|
|
||||||
<Badge
|
|
||||||
size="sm"
|
|
||||||
radius="sm"
|
|
||||||
variant={isActive ? "white" : "light"}
|
|
||||||
color={isActive ? "edr-green" : "gray"}
|
|
||||||
styles={
|
|
||||||
isActive
|
|
||||||
? {
|
|
||||||
root: {
|
|
||||||
background: "rgba(255,255,255,0.9)",
|
|
||||||
color: "#15805f",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</Badge>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Tabs.Tab>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Tabs.List>
|
|
||||||
</ScrollArea>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { ContractStatusTabKey };
|
|
||||||
@@ -116,8 +116,9 @@ export function CompanyNationalityBadge({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Profile chips for a company row: one chip per role (Importer / Exporter / …)
|
* Profile chips for a company row: one chip per role (Importer / Exporter / …)
|
||||||
* carrying its reference code. Caps at three (a company has at most three
|
* carrying its reference code, colored by the profile's status (green active,
|
||||||
* profiles); any extra collapse into a `+N` chip.
|
* amber pending, red rejected/blacklisted). Caps at three (a company has at
|
||||||
|
* most three profiles); any extra collapse into a `+N` chip.
|
||||||
*/
|
*/
|
||||||
export function ProfileChips({
|
export function ProfileChips({
|
||||||
profiles,
|
profiles,
|
||||||
@@ -152,14 +153,15 @@ export function ProfileChips({
|
|||||||
withArrow
|
withArrow
|
||||||
>
|
>
|
||||||
<Badge
|
<Badge
|
||||||
color={PROFILE_TYPE_COLOR[profile.type] ?? "gray"}
|
color={STATUS_COLOR[profile.status] ?? "gray"}
|
||||||
variant="light"
|
variant="light"
|
||||||
size="sm"
|
size="sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
fw={600}
|
fw={600}
|
||||||
style={badgeStyle}
|
style={badgeStyle}
|
||||||
>
|
>
|
||||||
{humanize(profile.type)} · {profile.reference}
|
{humanize(profile.type)}
|
||||||
|
{profile.reference ? ` · ${profile.reference}` : ""}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,38 +1,2 @@
|
|||||||
/** Shared formatting helpers for the customer-management pages. */
|
/** @deprecated import from "@/lib/format" (or ../../lib/format) instead. */
|
||||||
|
export { humanize, formatDate, formatDateTime, formatMoney, formatBytes } from "../../lib/format";
|
||||||
/** snake_case / SCREAMING_CASE → Title Case. */
|
|
||||||
export function humanize(value: string): string {
|
|
||||||
return value
|
|
||||||
.toLowerCase()
|
|
||||||
.split(/[_\s]+/)
|
|
||||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDate(value: string | null | undefined): string {
|
|
||||||
if (!value) return "—";
|
|
||||||
const d = new Date(value);
|
|
||||||
return Number.isNaN(d.getTime())
|
|
||||||
? "—"
|
|
||||||
: d.toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatMoney(amount: number, currency: string): string {
|
|
||||||
return new Intl.NumberFormat(undefined, {
|
|
||||||
style: "currency",
|
|
||||||
currency,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatBytes(bytes: number): string {
|
|
||||||
if (!bytes) return "0 B";
|
|
||||||
const units = ["B", "KB", "MB", "GB"];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
const value = bytes / Math.pow(1024, i);
|
|
||||||
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -36,6 +36,34 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
|||||||
subtitle: "Dashboard summary and key metrics",
|
subtitle: "Dashboard summary and key metrics",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/overview/bookings",
|
||||||
|
meta: { title: "Bookings", subtitle: "Booking volume, pipeline, and recent activity" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/overview/contracts",
|
||||||
|
meta: { title: "Contracts", subtitle: "Contract volume, pipeline, and recent activity" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/overview/billing",
|
||||||
|
meta: { title: "Billing", subtitle: "Revenue, payments, and collection status" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/overview/operations",
|
||||||
|
meta: { title: "Operations", subtitle: "Trains, schedules, containers, and cargo" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/overview/fleet",
|
||||||
|
meta: { title: "Fleet", subtitle: "Wagon and train fleet status" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/overview/customers",
|
||||||
|
meta: { title: "Customers", subtitle: "Customer growth and top accounts" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/overview/staff",
|
||||||
|
meta: { title: "Staff", subtitle: "Employee and user account status" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
prefix: "/dashboard/profile",
|
prefix: "/dashboard/profile",
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
Banknote,
|
|
||||||
Box,
|
|
||||||
Clock,
|
|
||||||
Container,
|
|
||||||
CreditCard,
|
|
||||||
FileText,
|
|
||||||
Train,
|
|
||||||
Truck,
|
|
||||||
UserCheck,
|
|
||||||
Users,
|
|
||||||
Wallet,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Group, Paper, Stack, Text } from "@mantine/core";
|
|
||||||
|
|
||||||
import type { IOverviewKpis } from "@/types/overview";
|
|
||||||
import { OverviewKpiCard } from "./OverviewKpiCard";
|
|
||||||
|
|
||||||
function formatCurrency(amount: number, currency: "ETB" | "USD") {
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OverviewKpiSection({ kpis }: { kpis: IOverviewKpis }) {
|
|
||||||
const bookingItems = [
|
|
||||||
{
|
|
||||||
label: "Active bookings",
|
|
||||||
value: kpis.bookings.totalActive,
|
|
||||||
icon: FileText,
|
|
||||||
accent: "emerald" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Needs action",
|
|
||||||
value: kpis.bookings.needsAction,
|
|
||||||
icon: AlertCircle,
|
|
||||||
accent: "amber" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Urgent",
|
|
||||||
value: kpis.bookings.urgent,
|
|
||||||
icon: Clock,
|
|
||||||
accent: "rose" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "In approval",
|
|
||||||
value: kpis.bookings.inApproval,
|
|
||||||
icon: UserCheck,
|
|
||||||
accent: "sky" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Submitted today",
|
|
||||||
value: kpis.bookings.submittedToday,
|
|
||||||
icon: FileText,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const operationsItems = [
|
|
||||||
{
|
|
||||||
label: "Active trains",
|
|
||||||
value: kpis.operations.trainsActive,
|
|
||||||
icon: Train,
|
|
||||||
accent: "emerald" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Wagons available",
|
|
||||||
value: kpis.operations.wagonsAvailable,
|
|
||||||
icon: Truck,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Containers in transit",
|
|
||||||
value: kpis.operations.containersInTransit,
|
|
||||||
icon: Container,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Cargoes loaded",
|
|
||||||
value: kpis.operations.cargoesLoaded,
|
|
||||||
icon: Box,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const billingItems = [
|
|
||||||
{
|
|
||||||
label: "Revenue MTD (ETB)",
|
|
||||||
value: formatCurrency(kpis.billing.revenueMtdEtb, "ETB"),
|
|
||||||
icon: Banknote,
|
|
||||||
accent: "emerald" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Revenue MTD (USD)",
|
|
||||||
value: formatCurrency(kpis.billing.revenueMtdUsd, "USD"),
|
|
||||||
icon: Wallet,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Pending payments",
|
|
||||||
value: kpis.billing.pendingPayments,
|
|
||||||
icon: CreditCard,
|
|
||||||
accent: "amber" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Successful MTD",
|
|
||||||
value: kpis.billing.successfulPaymentsMtd,
|
|
||||||
icon: Banknote,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const peopleItems = [
|
|
||||||
{
|
|
||||||
label: "Total customers",
|
|
||||||
value: kpis.customers.totalCustomers,
|
|
||||||
icon: Users,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "New this month",
|
|
||||||
value: kpis.customers.newCustomersThisMonth,
|
|
||||||
icon: Users,
|
|
||||||
accent: "emerald" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Active employees",
|
|
||||||
value: kpis.staff.activeEmployees,
|
|
||||||
icon: UserCheck,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Active users",
|
|
||||||
value: kpis.staff.activeUsers,
|
|
||||||
icon: Users,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const sections = [
|
|
||||||
{ title: "Bookings", items: bookingItems },
|
|
||||||
{ title: "Operations", items: operationsItems },
|
|
||||||
{ title: "Billing", items: billingItems },
|
|
||||||
{ title: "Customers & staff", items: peopleItems },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
{sections.map((section) => (
|
|
||||||
<Paper
|
|
||||||
key={section.title}
|
|
||||||
p="md"
|
|
||||||
radius="lg"
|
|
||||||
withBorder
|
|
||||||
style={{
|
|
||||||
background: "white",
|
|
||||||
border: "1px solid var(--mantine-color-gray-2)",
|
|
||||||
overflowX: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text size="sm" fw={600} mb="sm" c="dimmed">
|
|
||||||
{section.title}
|
|
||||||
</Text>
|
|
||||||
<Group gap="md" style={{ flexWrap: "nowrap", minWidth: "min-content" }}>
|
|
||||||
{section.items.map((item) => (
|
|
||||||
<OverviewKpiCard key={item.label} item={item} />
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { ActionIcon, Group, SegmentedControl, Text } from "@mantine/core";
|
|
||||||
import { RefreshCw } from "lucide-react";
|
|
||||||
|
|
||||||
import type { OverviewRange } from "@/types/overview";
|
|
||||||
|
|
||||||
const RANGE_OPTIONS = [
|
|
||||||
{ label: "7 days", value: "7d" },
|
|
||||||
{ label: "30 days", value: "30d" },
|
|
||||||
{ label: "90 days", value: "90d" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatRelativeTime(iso: string | undefined) {
|
|
||||||
if (!iso) return "—";
|
|
||||||
const diffMs = Date.now() - new Date(iso).getTime();
|
|
||||||
const minutes = Math.floor(diffMs / 60_000);
|
|
||||||
if (minutes < 1) return "just now";
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
return new Date(iso).toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OverviewPageHeaderProps {
|
|
||||||
range: OverviewRange;
|
|
||||||
onRangeChange: (range: OverviewRange) => void;
|
|
||||||
generatedAt?: string;
|
|
||||||
onRefresh: () => void;
|
|
||||||
isRefreshing?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OverviewPageHeader({
|
|
||||||
range,
|
|
||||||
onRangeChange,
|
|
||||||
generatedAt,
|
|
||||||
onRefresh,
|
|
||||||
isRefreshing,
|
|
||||||
}: OverviewPageHeaderProps) {
|
|
||||||
return (
|
|
||||||
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
Updated {formatRelativeTime(generatedAt)}
|
|
||||||
</Text>
|
|
||||||
<Group gap="sm">
|
|
||||||
<SegmentedControl
|
|
||||||
value={range}
|
|
||||||
onChange={(value) => onRangeChange(value as OverviewRange)}
|
|
||||||
data={RANGE_OPTIONS}
|
|
||||||
size="sm"
|
|
||||||
radius="lg"
|
|
||||||
color="edr-green"
|
|
||||||
/>
|
|
||||||
<ActionIcon
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
size="lg"
|
|
||||||
radius="lg"
|
|
||||||
aria-label="Refresh dashboard"
|
|
||||||
onClick={onRefresh}
|
|
||||||
loading={isRefreshing}
|
|
||||||
>
|
|
||||||
<RefreshCw size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { History } from "lucide-react";
|
||||||
import { Paper, Stack, Table, Text } from "@mantine/core";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { Table, Text } from "@mantine/core";
|
||||||
|
|
||||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
import type { IOverviewRecentBooking } from "@/types/overview";
|
import type { IOverviewRecentBooking } from "@/types/overview";
|
||||||
|
import { SummaryCard } from "./summary/SummaryCard";
|
||||||
|
|
||||||
function formatAmount(amount: number | null, currency: string | null) {
|
function formatAmount(amount: number | null, currency: string | null) {
|
||||||
if (amount == null) return "—";
|
if (amount == null) return "—";
|
||||||
@@ -23,56 +25,90 @@ export function OverviewRecentBookingsTable({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper p="lg" radius="lg" withBorder>
|
<SummaryCard
|
||||||
<Stack gap="md">
|
icon={History}
|
||||||
<Text fw={600}>Recent bookings</Text>
|
accent="gray"
|
||||||
{bookings.length === 0 ? (
|
title="Recent bookings"
|
||||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
subtitle="Latest submissions, newest first"
|
||||||
No recent bookings
|
minHeight={260}
|
||||||
</Text>
|
action={
|
||||||
) : (
|
<Text
|
||||||
<Table highlightOnHover verticalSpacing="sm">
|
component={Link}
|
||||||
<Table.Thead>
|
to="/dashboard/booking-requests"
|
||||||
<Table.Tr>
|
size="xs"
|
||||||
<Table.Th>Reference</Table.Th>
|
fw={600}
|
||||||
<Table.Th>Customer</Table.Th>
|
c="edr-green"
|
||||||
<Table.Th>Status</Table.Th>
|
style={{ whiteSpace: "nowrap", textDecoration: "none" }}
|
||||||
<Table.Th>Priority</Table.Th>
|
>
|
||||||
<Table.Th>Amount</Table.Th>
|
View all →
|
||||||
<Table.Th>Created</Table.Th>
|
</Text>
|
||||||
</Table.Tr>
|
}
|
||||||
</Table.Thead>
|
>
|
||||||
<Table.Tbody>
|
{bookings.length === 0 ? (
|
||||||
{bookings.map((booking) => (
|
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||||
<Table.Tr
|
No recent bookings
|
||||||
key={booking.id}
|
</Text>
|
||||||
style={{ cursor: "pointer" }}
|
) : (
|
||||||
onClick={() => navigate(`/dashboard/booking-requests/${booking.id}`)}
|
<Table highlightOnHover verticalSpacing="sm" horizontalSpacing="sm">
|
||||||
>
|
<Table.Thead>
|
||||||
<Table.Td>
|
<Table.Tr>
|
||||||
<Text fw={600} size="sm">
|
{["Reference", "Customer", "Status", "Priority", "Amount", "Created"].map(
|
||||||
{booking.reference}
|
(header) => (
|
||||||
</Text>
|
<Table.Th
|
||||||
</Table.Td>
|
key={header}
|
||||||
<Table.Td>{booking.customerLabel}</Table.Td>
|
style={{
|
||||||
<Table.Td>
|
fontSize: 11,
|
||||||
<BookingStatusBadge status={booking.status} />
|
fontWeight: 700,
|
||||||
</Table.Td>
|
textTransform: "uppercase",
|
||||||
<Table.Td>
|
letterSpacing: 0.3,
|
||||||
<BookingPriorityBadge score={booking.priorityScore} />
|
color: "var(--mantine-color-edr-muted-6)",
|
||||||
</Table.Td>
|
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||||
<Table.Td>
|
}}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
</Table.Th>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{bookings.map((booking) => (
|
||||||
|
<Table.Tr
|
||||||
|
key={booking.id}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/dashboard/booking-requests/${booking.id}`)}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{booking.reference}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" truncate style={{ maxWidth: 180 }}>
|
||||||
|
{booking.customerLabel}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<BookingStatusBadge status={booking.status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<BookingPriorityBadge score={booking.priorityScore} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
{formatAmount(booking.totalAmount, booking.paymentCurrency)}
|
{formatAmount(booking.totalAmount, booking.paymentCurrency)}
|
||||||
</Table.Td>
|
</Text>
|
||||||
<Table.Td>
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
{new Date(booking.createdAt).toLocaleDateString()}
|
{new Date(booking.createdAt).toLocaleDateString()}
|
||||||
</Table.Td>
|
</Text>
|
||||||
</Table.Tr>
|
</Table.Td>
|
||||||
))}
|
</Table.Tr>
|
||||||
</Table.Tbody>
|
))}
|
||||||
</Table>
|
</Table.Tbody>
|
||||||
)}
|
</Table>
|
||||||
</Stack>
|
)}
|
||||||
</Paper>
|
</SummaryCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
import { AlertCircle } from "lucide-react";
|
|
||||||
import { Alert, Button, Center, Loader, Paper, Skeleton, Stack } from "@mantine/core";
|
|
||||||
|
|
||||||
import {
|
|
||||||
useOverviewBillingTab,
|
|
||||||
useOverviewBookingsTab,
|
|
||||||
useOverviewContractsTab,
|
|
||||||
useOverviewCustomersTab,
|
|
||||||
useOverviewOperationsTab,
|
|
||||||
useOverviewStaffTab,
|
|
||||||
} from "@/hooks/useOverview";
|
|
||||||
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
|
|
||||||
import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel";
|
|
||||||
import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel";
|
|
||||||
import { OverviewContractsTabPanel } from "./tabs/OverviewContractsTabPanel";
|
|
||||||
import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel";
|
|
||||||
import { OverviewFleetTabPanel } from "./tabs/OverviewFleetTabPanel";
|
|
||||||
import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel";
|
|
||||||
import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel";
|
|
||||||
|
|
||||||
function TabSkeleton() {
|
|
||||||
return (
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Skeleton height={120} radius="lg" />
|
|
||||||
<Skeleton height={320} radius="lg" />
|
|
||||||
<Skeleton height={320} radius="lg" />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OverviewTabContentProps {
|
|
||||||
tab: OverviewTabKey;
|
|
||||||
range: OverviewRange;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
|
||||||
const bookings = useOverviewBookingsTab(range, tab === "bookings");
|
|
||||||
const contracts = useOverviewContractsTab(range, tab === "contracts");
|
|
||||||
const billing = useOverviewBillingTab(range, tab === "billing");
|
|
||||||
// Fleet reuses the operations dataset — same query key, so switching between
|
|
||||||
// the two tabs costs one fetch.
|
|
||||||
const operations = useOverviewOperationsTab(
|
|
||||||
range,
|
|
||||||
tab === "operations" || tab === "fleet",
|
|
||||||
);
|
|
||||||
const customers = useOverviewCustomersTab(range, tab === "customers");
|
|
||||||
const staff = useOverviewStaffTab(range, tab === "staff");
|
|
||||||
|
|
||||||
const query =
|
|
||||||
tab === "bookings"
|
|
||||||
? bookings
|
|
||||||
: tab === "contracts"
|
|
||||||
? contracts
|
|
||||||
: tab === "billing"
|
|
||||||
? billing
|
|
||||||
: tab === "operations" || tab === "fleet"
|
|
||||||
? operations
|
|
||||||
: tab === "customers"
|
|
||||||
? customers
|
|
||||||
: staff;
|
|
||||||
|
|
||||||
const { isLoading, isError, refetch, isFetching } = query;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <TabSkeleton />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !query.data) {
|
|
||||||
return (
|
|
||||||
<Paper p="xl" radius="lg" withBorder>
|
|
||||||
<Alert
|
|
||||||
icon={<AlertCircle size={16} />}
|
|
||||||
color="red"
|
|
||||||
title="Failed to load tab data"
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
<Stack gap="sm" align="flex-start">
|
|
||||||
<span>Could not load {tab} metrics. Please try again.</span>
|
|
||||||
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md" pos="relative">
|
|
||||||
{isFetching && (
|
|
||||||
<Center style={{ position: "absolute", top: 8, right: 8, zIndex: 2 }}>
|
|
||||||
<Loader size="sm" color="edr-green" />
|
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "bookings" && bookings.data && (
|
|
||||||
<OverviewBookingsTabPanel data={bookings.data} />
|
|
||||||
)}
|
|
||||||
{tab === "contracts" && contracts.data && (
|
|
||||||
<OverviewContractsTabPanel data={contracts.data} />
|
|
||||||
)}
|
|
||||||
{tab === "billing" && billing.data && (
|
|
||||||
<OverviewBillingTabPanel data={billing.data} />
|
|
||||||
)}
|
|
||||||
{tab === "operations" && operations.data && (
|
|
||||||
<OverviewOperationsTabPanel data={operations.data} />
|
|
||||||
)}
|
|
||||||
{tab === "fleet" && operations.data && (
|
|
||||||
<OverviewFleetTabPanel data={operations.data} />
|
|
||||||
)}
|
|
||||||
{tab === "customers" && customers.data && (
|
|
||||||
<OverviewCustomersTabPanel data={customers.data} />
|
|
||||||
)}
|
|
||||||
{tab === "staff" && staff.data && (
|
|
||||||
<OverviewStaffTabPanel data={staff.data} />
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
Banknote,
|
||||||
|
FileSignature,
|
||||||
|
FileText,
|
||||||
|
Train,
|
||||||
|
TrainFront,
|
||||||
|
UserCheck,
|
||||||
|
Users,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { FREIGHT_PERMS } from "@/lib/permissions";
|
||||||
|
import type { OverviewTabKey } from "@/types/overview";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the seven overview drill-down pages — used both
|
||||||
|
* to build the `/dashboard/overview/:domain` routes in App.tsx and to render
|
||||||
|
* each page's header in OverviewDomainPage. One list, no duplicated permission
|
||||||
|
* arrays to drift out of sync.
|
||||||
|
*/
|
||||||
|
export const OVERVIEW_DOMAINS: Array<{
|
||||||
|
key: OverviewTabKey;
|
||||||
|
label: string;
|
||||||
|
subtitle: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
/** Any of these keys grants the page. */
|
||||||
|
permission: string[];
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: "bookings",
|
||||||
|
label: "Bookings",
|
||||||
|
subtitle: "Booking volume, pipeline, and recent activity",
|
||||||
|
icon: FileText,
|
||||||
|
permission: [FREIGHT_PERMS.bookings.view],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "contracts",
|
||||||
|
label: "Contracts",
|
||||||
|
subtitle: "Contract volume, pipeline, and recent activity",
|
||||||
|
icon: FileSignature,
|
||||||
|
permission: [FREIGHT_PERMS.contracts.view],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "billing",
|
||||||
|
label: "Billing",
|
||||||
|
subtitle: "Revenue, payments, and collection status",
|
||||||
|
icon: Banknote,
|
||||||
|
permission: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.payments.view],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "operations",
|
||||||
|
label: "Operations",
|
||||||
|
subtitle: "Trains, schedules, containers, and cargo",
|
||||||
|
icon: Train,
|
||||||
|
permission: [
|
||||||
|
FREIGHT_PERMS.trainScheduling.view,
|
||||||
|
FREIGHT_PERMS.warehouseInventory.view,
|
||||||
|
FREIGHT_PERMS.firstMile.view,
|
||||||
|
FREIGHT_PERMS.lastMile.view,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fleet",
|
||||||
|
label: "Fleet",
|
||||||
|
subtitle: "Wagon and train fleet status",
|
||||||
|
icon: TrainFront,
|
||||||
|
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.trainScheduling.view],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "customers",
|
||||||
|
label: "Customers",
|
||||||
|
subtitle: "Customer growth and top accounts",
|
||||||
|
icon: Users,
|
||||||
|
permission: [FREIGHT_PERMS.customers.view],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "staff",
|
||||||
|
label: "Staff",
|
||||||
|
subtitle: "Employee and user account status",
|
||||||
|
icon: UserCheck,
|
||||||
|
permission: [
|
||||||
|
FREIGHT_PERMS.admin,
|
||||||
|
FREIGHT_PERMS.staff.roles.view,
|
||||||
|
FREIGHT_PERMS.staff.employeeRegistration.view,
|
||||||
|
FREIGHT_PERMS.staff.roleAssignment.view,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
EDR Freight — Overview page styles (hero controls + tabs)
|
EDR Freight — shared "premium" tab bar + segmented control styles.
|
||||||
|
Named after the overview page they were first built for, but now shared
|
||||||
|
by BookingStatusTabs, ContractStatusTabs, and ReceiveInventoryModal —
|
||||||
|
do not remove `.ov-tablist` / `.ov-tab` without checking those importers.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
/* ---- Hero range segmented control (on gradient) ---- */
|
/* ---- Hero range segmented control (on gradient) ---- */
|
||||||
@@ -19,7 +22,7 @@
|
|||||||
color: var(--mantine-color-edr-green-7);
|
color: var(--mantine-color-edr-green-7);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Premium tab bar ---- */
|
/* ---- Premium tab bar (BookingStatusTabs, ContractStatusTabs, ReceiveInventoryModal) ---- */
|
||||||
.ov-tablist {
|
.ov-tablist {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const DURATION_MS = 750;
|
||||||
|
|
||||||
|
function prefersReducedMotion() {
|
||||||
|
return (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animates a number from 0 to `value` (ease-out) on mount and whenever the
|
||||||
|
* value changes — the hero-KPI count-up. Renders the final value immediately
|
||||||
|
* when the user prefers reduced motion.
|
||||||
|
*/
|
||||||
|
export function CountUp({
|
||||||
|
value,
|
||||||
|
format = (n) => Math.round(n).toLocaleString(),
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
format?: (n: number) => string;
|
||||||
|
}) {
|
||||||
|
const [display, setDisplay] = useState(() => (prefersReducedMotion() ? value : 0));
|
||||||
|
const frame = useRef<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prefersReducedMotion()) {
|
||||||
|
setDisplay(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = performance.now();
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const t = Math.min(1, (now - start) / DURATION_MS);
|
||||||
|
const eased = 1 - (1 - t) ** 3;
|
||||||
|
setDisplay(value * eased);
|
||||||
|
if (t < 1) frame.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
frame.current = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(frame.current);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return <>{format(display)}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { Fragment } from "react";
|
||||||
|
import { CalendarClock } from "lucide-react";
|
||||||
|
import { Badge, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { IOverviewHeatmapCell } from "@/types/overview";
|
||||||
|
import { SummaryCard } from "./SummaryCard";
|
||||||
|
|
||||||
|
/** ISO weekday order, 1 = Monday. */
|
||||||
|
const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
|
/** 3-hour blocks, 0 = 00–03 … 7 = 21–24. */
|
||||||
|
const BLOCK_LABELS = ["12a", "3a", "6a", "9a", "12p", "3p", "6p", "9p"];
|
||||||
|
|
||||||
|
/** Map an intensity in (0, 1] to the brand-green ramp; zero stays neutral. */
|
||||||
|
function cellColor(count: number, max: number) {
|
||||||
|
if (count === 0) return "var(--mantine-color-gray-1)";
|
||||||
|
const shade = Math.max(1, Math.min(7, Math.ceil((count / max) * 7)));
|
||||||
|
return `var(--mantine-color-edr-green-${shade})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewActivityHeatmapProps {
|
||||||
|
cells: IOverviewHeatmapCell[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When demand arrives: booking submissions by weekday × 3-hour block for the
|
||||||
|
* selected range. The bright cells (and the peak badge) are the hours the
|
||||||
|
* intake team needs to be staffed for.
|
||||||
|
*/
|
||||||
|
export function OverviewActivityHeatmap({ cells = [] }: OverviewActivityHeatmapProps) {
|
||||||
|
const countByCell = new Map(cells.map((c) => [`${c.dow}-${c.block}`, c.count]));
|
||||||
|
const max = Math.max(0, ...cells.map((c) => c.count));
|
||||||
|
const peak = cells.reduce<IOverviewHeatmapCell | null>(
|
||||||
|
(best, c) => (c.count > (best?.count ?? 0) ? c : best),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SummaryCard
|
||||||
|
icon={CalendarClock}
|
||||||
|
accent="violet"
|
||||||
|
title="Booking rhythm"
|
||||||
|
subtitle="When bookings arrive, by weekday and time"
|
||||||
|
action={
|
||||||
|
peak ? (
|
||||||
|
<Badge variant="light" color="violet" size="sm" style={{ textTransform: "none" }}>
|
||||||
|
Peak {DAY_LABELS[peak.dow - 1]} {BLOCK_LABELS[peak.block]}
|
||||||
|
</Badge>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{max === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
No bookings in this period
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap={4}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "36px repeat(8, 1fr)",
|
||||||
|
gap: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
{BLOCK_LABELS.map((label) => (
|
||||||
|
<Text key={label} size="xs" c="dimmed" ta="center">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
{DAY_LABELS.map((day, dayIndex) => (
|
||||||
|
<Fragment key={day}>
|
||||||
|
<Text size="xs" c="dimmed" style={{ lineHeight: "24px" }}>
|
||||||
|
{day}
|
||||||
|
</Text>
|
||||||
|
{BLOCK_LABELS.map((_, block) => {
|
||||||
|
const count = countByCell.get(`${dayIndex + 1}-${block}`) ?? 0;
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={`${day}-${block}`}
|
||||||
|
label={`${day} ${BLOCK_LABELS[block]}–${BLOCK_LABELS[(block + 1) % 8]} · ${count} booking${count === 1 ? "" : "s"}`}
|
||||||
|
withArrow
|
||||||
|
openDelay={150}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: cellColor(count, max),
|
||||||
|
cursor: "default",
|
||||||
|
transition: "transform 120ms ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Group gap={6} justify="flex-end" mt={4}>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Less
|
||||||
|
</Text>
|
||||||
|
{[0, 2, 4, 6].map((shade) => (
|
||||||
|
<div
|
||||||
|
key={shade}
|
||||||
|
style={{
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 4,
|
||||||
|
background:
|
||||||
|
shade === 0
|
||||||
|
? "var(--mantine-color-gray-1)"
|
||||||
|
: `var(--mantine-color-edr-green-${shade + 1})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
More
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</SummaryCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Banknote,
|
||||||
|
BellRing,
|
||||||
|
Check,
|
||||||
|
ChevronRight,
|
||||||
|
Clock,
|
||||||
|
FileSignature,
|
||||||
|
ShieldCheck,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Badge, Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { IOverviewBillingKpis, IOverviewBookingKpis, IOverviewContractKpis } from "@/types/overview";
|
||||||
|
import { SummaryCard } from "./SummaryCard";
|
||||||
|
|
||||||
|
interface OverviewAttentionCardProps {
|
||||||
|
bookings: IOverviewBookingKpis;
|
||||||
|
contracts: IOverviewContractKpis;
|
||||||
|
billing: IOverviewBillingKpis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The work queue, demoted below the money/volume story but still one glance
|
||||||
|
* away — this page is read by executives first, staff second. Every row
|
||||||
|
* links to the real filtered (or closest available) list; none are dead ends.
|
||||||
|
*/
|
||||||
|
export function OverviewAttentionCard({ bookings, contracts, billing }: OverviewAttentionCardProps) {
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
key: "needsAction",
|
||||||
|
label: "Bookings needing action",
|
||||||
|
count: bookings.needsAction ?? 0,
|
||||||
|
icon: AlertCircle,
|
||||||
|
href: "/dashboard/booking-requests",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "urgent",
|
||||||
|
label: "Urgent bookings",
|
||||||
|
count: bookings.urgent ?? 0,
|
||||||
|
icon: Clock,
|
||||||
|
href: "/dashboard/booking-requests",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "contractsApproval",
|
||||||
|
label: "Contracts in approval",
|
||||||
|
count: contracts.inApproval ?? 0,
|
||||||
|
icon: FileSignature,
|
||||||
|
href: "/dashboard/contract-requests",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "contractsClearance",
|
||||||
|
label: "Contracts in clearance",
|
||||||
|
count: contracts.inClearance ?? 0,
|
||||||
|
icon: ShieldCheck,
|
||||||
|
href: "/dashboard/contracts/clearance",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pendingPayments",
|
||||||
|
label: "Pending payments",
|
||||||
|
count: billing.pendingPayments ?? 0,
|
||||||
|
icon: Banknote,
|
||||||
|
href: "/dashboard/payments",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const openItems = rows.reduce((sum, row) => sum + row.count, 0);
|
||||||
|
const allClear = openItems === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SummaryCard
|
||||||
|
icon={BellRing}
|
||||||
|
accent="orange"
|
||||||
|
title="Needs attention"
|
||||||
|
subtitle="Waiting on someone here"
|
||||||
|
minHeight={260}
|
||||||
|
action={
|
||||||
|
allClear ? null : (
|
||||||
|
<Badge variant="light" color="orange" size="sm" style={{ textTransform: "none" }}>
|
||||||
|
{openItems.toLocaleString()} open
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{allClear ? (
|
||||||
|
<Stack align="center" gap={8} py="lg">
|
||||||
|
<ThemeIcon variant="light" color="edr-green" size={48} radius="xl">
|
||||||
|
<Check size={26} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={700}>All clear</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Nothing waiting on you right now.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack gap={4}>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const Icon = row.icon;
|
||||||
|
const active = row.count > 0;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={row.key}
|
||||||
|
to={row.href}
|
||||||
|
className="ov-row"
|
||||||
|
style={{ textDecoration: "none", color: "inherit", padding: "7px 10px" }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color={active ? "orange" : "gray"}
|
||||||
|
size={30}
|
||||||
|
radius={10}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" c="edr-text" truncate>
|
||||||
|
{row.label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={700}
|
||||||
|
ta="center"
|
||||||
|
c={active ? "orange.8" : "dimmed"}
|
||||||
|
style={{
|
||||||
|
minWidth: 34,
|
||||||
|
background: active
|
||||||
|
? "var(--mantine-color-orange-0)"
|
||||||
|
: "var(--mantine-color-gray-0)",
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: "1px 8px",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.count}
|
||||||
|
</Text>
|
||||||
|
<ChevronRight size={14} style={{ color: "var(--mantine-color-edr-muted-6)" }} />
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</SummaryCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
import { ActionIcon, Badge, Group, SegmentedControl, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
import { freightBrand } from "@/theme/freight-brand";
|
||||||
|
import type { OverviewRange } from "@/types/overview";
|
||||||
|
import "@/components/overview/overview.css";
|
||||||
|
|
||||||
|
const RANGE_OPTIONS = [
|
||||||
|
{ label: "7 days", value: "7d" },
|
||||||
|
{ label: "30 days", value: "30d" },
|
||||||
|
{ label: "90 days", value: "90d" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatRelativeTime(iso: string | undefined) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const diffMs = Date.now() - new Date(iso).getTime();
|
||||||
|
const minutes = Math.floor(diffMs / 60_000);
|
||||||
|
if (minutes < 1) return "just now";
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
return formatDateTime(iso);
|
||||||
|
}
|
||||||
|
|
||||||
|
function greeting(hour: number) {
|
||||||
|
if (hour < 12) return "Good morning";
|
||||||
|
if (hour < 18) return "Good afternoon";
|
||||||
|
return "Good evening";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewHeroProps {
|
||||||
|
range: OverviewRange;
|
||||||
|
onRangeChange: (range: OverviewRange) => void;
|
||||||
|
generatedAt?: string;
|
||||||
|
onRefresh: () => void;
|
||||||
|
isRefreshing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brand-gradient greeting banner: time-of-day greeting with the signed-in
|
||||||
|
* user's first name, today's date, freshness badge, and the range controls.
|
||||||
|
* Extra bottom padding leaves room for the KPI strip to overlap it.
|
||||||
|
*/
|
||||||
|
export function OverviewHero({
|
||||||
|
range,
|
||||||
|
onRangeChange,
|
||||||
|
generatedAt,
|
||||||
|
onRefresh,
|
||||||
|
isRefreshing,
|
||||||
|
}: OverviewHeroProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const fullName = (user?.name?.en ?? user?.name?.am)?.trim();
|
||||||
|
const firstName = fullName ? fullName.split(/\s+/)[0] : undefined;
|
||||||
|
const now = new Date();
|
||||||
|
const dateLabel = now.toLocaleDateString(undefined, {
|
||||||
|
weekday: "long",
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: freightBrand.gradient,
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: "28px 28px 76px",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Soft highlight so the flat gradient reads as a lit surface. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background:
|
||||||
|
"radial-gradient(640px 240px at 85% -40%, rgba(255, 255, 255, 0.2), transparent)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="flex-start"
|
||||||
|
wrap="wrap"
|
||||||
|
gap="md"
|
||||||
|
style={{ position: "relative" }}
|
||||||
|
>
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text c="white" fw={800} fz={26} lh={1.15} style={{ letterSpacing: "-0.02em" }}>
|
||||||
|
{greeting(now.getHours())}
|
||||||
|
{firstName ? `, ${firstName}` : ""} 👋
|
||||||
|
</Text>
|
||||||
|
<Group gap={10}>
|
||||||
|
<Text c="rgba(255,255,255,0.75)" size="sm">
|
||||||
|
{dateLabel}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.16)",
|
||||||
|
color: "rgba(255,255,255,0.9)",
|
||||||
|
textTransform: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Updated {formatRelativeTime(generatedAt)}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
<Group gap="sm">
|
||||||
|
<SegmentedControl
|
||||||
|
value={range}
|
||||||
|
onChange={(value) => onRangeChange(value as OverviewRange)}
|
||||||
|
data={RANGE_OPTIONS}
|
||||||
|
size="sm"
|
||||||
|
radius="lg"
|
||||||
|
classNames={{
|
||||||
|
root: "ov-seg-root",
|
||||||
|
indicator: "ov-seg-indicator",
|
||||||
|
label: "ov-seg-label",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
variant="white"
|
||||||
|
color="edr-green"
|
||||||
|
size="lg"
|
||||||
|
radius="lg"
|
||||||
|
aria-label="Refresh dashboard"
|
||||||
|
onClick={onRefresh}
|
||||||
|
loading={isRefreshing}
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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") {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Period-over-period % change, or undefined when there's no prior baseline to compare against. */
|
||||||
|
function pctDelta(current: number, previous: number): number | undefined {
|
||||||
|
if (previous === 0) return undefined;
|
||||||
|
return Math.round(((current - previous) / previous) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewHeroKpisProps {
|
||||||
|
kpis: IOverviewKpis;
|
||||||
|
current: IOverviewPeriodTotals;
|
||||||
|
previous: IOverviewPeriodTotals;
|
||||||
|
rangeLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four numbers an executive reads first: money and volume for the
|
||||||
|
* selected range, plus what's currently in flight. Revenue and cargo carry a
|
||||||
|
* real vs-prior-period delta; the two workflow snapshots don't, because
|
||||||
|
* "active bookings/contracts" is a point-in-time gauge, not a period total —
|
||||||
|
* showing a delta for it would mean inventing a comparison that isn't real.
|
||||||
|
*/
|
||||||
|
export function OverviewHeroKpis({ kpis, current, previous, rangeLabel }: OverviewHeroKpisProps) {
|
||||||
|
const items: KpiItem[] = [
|
||||||
|
// An API deployed before the overview revamp omits the period totals —
|
||||||
|
// drop the two tiles that need them rather than crash (or hide the strip).
|
||||||
|
...(current
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: `Revenue (${rangeLabel})`,
|
||||||
|
value: <CountUp value={current.revenueEtb} format={(n) => formatCurrency(n, "ETB")} />,
|
||||||
|
hint: formatCurrency(current.revenueUsd, "USD"),
|
||||||
|
icon: Banknote,
|
||||||
|
color: "yellow",
|
||||||
|
delta: pctDelta(current.revenueEtb, previous?.revenueEtb ?? 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Cargo moved",
|
||||||
|
value: <CountUp value={current.tons} format={(n) => `${Math.round(n).toLocaleString()} t`} />,
|
||||||
|
icon: Package,
|
||||||
|
color: "edr-green",
|
||||||
|
delta: pctDelta(current.tons, previous?.tons ?? 0),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
label: "Active bookings",
|
||||||
|
value: <CountUp value={kpis.bookings.totalActive} />,
|
||||||
|
icon: FileText,
|
||||||
|
color: "edr-green",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Active contracts",
|
||||||
|
value: <CountUp value={kpis.contracts.totalActive} />,
|
||||||
|
icon: FileSignature,
|
||||||
|
color: "edr-green",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return <KpiStrip items={items} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
CalendarClock,
|
||||||
|
Container as ContainerIcon,
|
||||||
|
Send,
|
||||||
|
Train,
|
||||||
|
TrainFront,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import { MiniRing } from "@/components/common/MiniGraph";
|
||||||
|
import type { IOverviewOperationsKpis } from "@/types/overview";
|
||||||
|
import { CountUp } from "./CountUp";
|
||||||
|
import { SummaryCard } from "./SummaryCard";
|
||||||
|
|
||||||
|
const STATS: Array<{
|
||||||
|
key: keyof IOverviewOperationsKpis;
|
||||||
|
label: string;
|
||||||
|
icon: typeof Train;
|
||||||
|
}> = [
|
||||||
|
{ key: "trainsActive", label: "Trains active", icon: Train },
|
||||||
|
{ key: "dispatchedToday", label: "Dispatched today", icon: Send },
|
||||||
|
{ key: "schedulesUpcoming", label: "Upcoming departures", icon: CalendarClock },
|
||||||
|
{ key: "containersInTransit", label: "Containers in transit", icon: ContainerIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Network snapshot: four operational stats plus real wagon-utilization (available / total), not a decorative gauge. */
|
||||||
|
export function OverviewNetworkCard({ kpis }: { kpis: IOverviewOperationsKpis }) {
|
||||||
|
// An API deployed before the overview revamp omits the wagon counters.
|
||||||
|
const wagonsTotal = kpis.wagonsTotal ?? 0;
|
||||||
|
const wagonsAvailable = kpis.wagonsAvailable ?? 0;
|
||||||
|
const utilizationPct = wagonsTotal > 0 ? (wagonsAvailable / wagonsTotal) * 100 : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SummaryCard
|
||||||
|
icon={TrainFront}
|
||||||
|
accent="edr-green"
|
||||||
|
title="Network"
|
||||||
|
subtitle="Fleet and movement, right now"
|
||||||
|
to="/dashboard/overview/fleet"
|
||||||
|
action={
|
||||||
|
<Text size="xs" fw={600} c="edr-green" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
View fleet →
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group align="center" gap="lg" className="ov-inset" p={14} wrap="nowrap">
|
||||||
|
<MiniRing pct={utilizationPct} accent="emerald" size={76} stroke={8}>
|
||||||
|
<Text size="16px" fw={800} lh={1}>
|
||||||
|
{utilizationPct != null ? `${Math.round(utilizationPct)}%` : "—"}
|
||||||
|
</Text>
|
||||||
|
</MiniRing>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="xs" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.3 }}>
|
||||||
|
Wagons available
|
||||||
|
</Text>
|
||||||
|
<Text fw={800} fz={22} lh={1.1}>
|
||||||
|
{wagonsAvailable.toLocaleString()}
|
||||||
|
<Text component="span" fz="sm" fw={600} c="dimmed">
|
||||||
|
{" "}
|
||||||
|
/ {wagonsTotal.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SimpleGrid cols={2} spacing="sm">
|
||||||
|
{STATS.map((stat) => {
|
||||||
|
const Icon = stat.icon;
|
||||||
|
return (
|
||||||
|
<Group key={stat.label} gap={10} wrap="nowrap" className="ov-inset" p={12}>
|
||||||
|
<Icon
|
||||||
|
size={18}
|
||||||
|
style={{ flexShrink: 0, color: "var(--mantine-color-edr-green-6)" }}
|
||||||
|
/>
|
||||||
|
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||||
|
<Text fw={800} fz="lg" lh={1.15}>
|
||||||
|
<CountUp value={kpis[stat.key] ?? 0} />
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{stat.label}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
</SummaryCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Filter } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Badge, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
|
||||||
|
import type { IOverviewPipelineCount } from "@/types/overview";
|
||||||
|
import { SummaryCard } from "./SummaryCard";
|
||||||
|
|
||||||
|
/** Sequential green ramp from the theme's own edr-green scale — rising intensity as bookings progress through the pipeline. */
|
||||||
|
const RAMP_SHADES = [3, 4, 5, 5, 6, 6, 7, 8, 9];
|
||||||
|
|
||||||
|
interface OverviewPipelineFunnelProps {
|
||||||
|
data: IOverviewPipelineCount[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Booking pipeline by stage, in workflow order. Each row deep-links to the exact statuses it represents. */
|
||||||
|
export function OverviewPipelineFunnel({ data = [] }: OverviewPipelineFunnelProps) {
|
||||||
|
const rows = data
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
tab: BOOKING_LIST_TABS.find((t) => t.key === item.stage),
|
||||||
|
}))
|
||||||
|
.filter((row) => row.tab);
|
||||||
|
const maxCount = Math.max(1, ...rows.map((r) => r.count));
|
||||||
|
const total = rows.reduce((sum, r) => sum + r.count, 0);
|
||||||
|
const hasData = total > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SummaryCard
|
||||||
|
icon={Filter}
|
||||||
|
accent="edr-green"
|
||||||
|
title="Booking pipeline"
|
||||||
|
subtitle="Every row opens the exact filtered list"
|
||||||
|
action={
|
||||||
|
hasData ? (
|
||||||
|
<Badge variant="light" color="edr-green" size="sm" style={{ textTransform: "none" }}>
|
||||||
|
{total.toLocaleString()} in pipeline
|
||||||
|
</Badge>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!hasData ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
No bookings in pipeline
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap={4}>
|
||||||
|
{rows.map((row, index) => {
|
||||||
|
const statuses = row.tab?.statuses;
|
||||||
|
const href = statuses?.length
|
||||||
|
? `/dashboard/booking-requests?statuses=${statuses.join(",")}`
|
||||||
|
: "/dashboard/booking-requests";
|
||||||
|
const shade = RAMP_SHADES[Math.min(index, RAMP_SHADES.length - 1)];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={row.stage}
|
||||||
|
to={href}
|
||||||
|
className="ov-row"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
textDecoration: "none",
|
||||||
|
padding: "7px 10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={500} c="edr-text" style={{ width: 140, flexShrink: 0 }} truncate>
|
||||||
|
{row.tab?.label ?? row.stage}
|
||||||
|
</Text>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "var(--mantine-color-gray-1)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${(row.count / maxCount) * 100}%`,
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: `linear-gradient(90deg, var(--mantine-color-edr-green-${Math.max(shade - 2, 1)}), var(--mantine-color-edr-green-${shade}))`,
|
||||||
|
transition: "width 300ms ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={700}
|
||||||
|
ta="center"
|
||||||
|
style={{
|
||||||
|
minWidth: 44,
|
||||||
|
flexShrink: 0,
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: "1px 8px",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.count}
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</SummaryCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { PieChart } from "lucide-react";
|
||||||
|
import { Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { IOverviewRevenueSlice } from "@/types/overview";
|
||||||
|
import { DIRECTION_COLORS, FLOW_FALLBACK_COLOR, FREIGHT_TYPE_COLORS } from "./flow-colors";
|
||||||
|
import { CountUp } from "./CountUp";
|
||||||
|
import { SummaryCard } from "./SummaryCard";
|
||||||
|
|
||||||
|
function formatEtb(amount: number) {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "ETB",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIRECTION_LABELS: Record<string, string> = {
|
||||||
|
IMPORT: "Import",
|
||||||
|
EXPORT: "Export",
|
||||||
|
DOMESTIC: "Domestic",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FREIGHT_TYPE_LABELS: Record<string, string> = {
|
||||||
|
CONTAINER: "Container",
|
||||||
|
BULK: "Bulk",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One breakdown's proportion bar + legend, sized by ETB revenue (no FX rate exists to fold USD in). */
|
||||||
|
function MixRow({
|
||||||
|
title,
|
||||||
|
slices,
|
||||||
|
labels,
|
||||||
|
colors,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
slices: IOverviewRevenueSlice[];
|
||||||
|
labels: Record<string, string>;
|
||||||
|
colors: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
const total = slices.reduce((sum, s) => sum + s.amountEtb, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={8} className="ov-inset" p={12}>
|
||||||
|
<Text size="xs" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.3 }}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{total === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No revenue in this period
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: "hidden",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{slices
|
||||||
|
.filter((s) => s.amountEtb > 0)
|
||||||
|
.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.label}
|
||||||
|
style={{
|
||||||
|
width: `${(s.amountEtb / total) * 100}%`,
|
||||||
|
background: colors[s.label] ?? FLOW_FALLBACK_COLOR,
|
||||||
|
borderRadius: 999,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Stack gap={4}>
|
||||||
|
{slices
|
||||||
|
.filter((s) => s.amountEtb > 0)
|
||||||
|
.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.label}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 999,
|
||||||
|
flexShrink: 0,
|
||||||
|
background: colors[s.label] ?? FLOW_FALLBACK_COLOR,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ flex: 1, color: "var(--mantine-color-edr-muted-6)" }}>
|
||||||
|
{labels[s.label] ?? s.label}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontWeight: 600 }}>{formatEtb(s.amountEtb)}</span>
|
||||||
|
<span style={{ color: "var(--mantine-color-edr-muted-6)", minWidth: 32, textAlign: "right" }}>
|
||||||
|
{Math.round((s.amountEtb / total) * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewRevenueMixProps {
|
||||||
|
byDirection: IOverviewRevenueSlice[];
|
||||||
|
byFreightType: IOverviewRevenueSlice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ETB revenue split two ways — trade direction and freight type — anchored by the range total. */
|
||||||
|
export function OverviewRevenueMix({ byDirection = [], byFreightType = [] }: OverviewRevenueMixProps) {
|
||||||
|
const total = byDirection.reduce((sum, s) => sum + s.amountEtb, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SummaryCard icon={PieChart} accent="yellow" title="Revenue mix" subtitle="ETB, selected range">
|
||||||
|
<Stack gap="md">
|
||||||
|
<div>
|
||||||
|
<Text fw={800} fz={24} lh={1.1} style={{ letterSpacing: "-0.02em" }}>
|
||||||
|
<CountUp value={total} format={formatEtb} />
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
attributed to a trade direction
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<MixRow
|
||||||
|
title="By trade direction"
|
||||||
|
slices={byDirection}
|
||||||
|
labels={DIRECTION_LABELS}
|
||||||
|
colors={DIRECTION_COLORS}
|
||||||
|
/>
|
||||||
|
<MixRow
|
||||||
|
title="By freight type"
|
||||||
|
slices={byFreightType}
|
||||||
|
labels={FREIGHT_TYPE_LABELS}
|
||||||
|
colors={FREIGHT_TYPE_COLORS}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</SummaryCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import {
|
||||||
|
Area,
|
||||||
|
Bar,
|
||||||
|
CartesianGrid,
|
||||||
|
ComposedChart,
|
||||||
|
Line,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { ChartColumnBig } from "lucide-react";
|
||||||
|
import { Group, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { IOverviewPaymentTrendPoint, IOverviewTrendPoint } from "@/types/overview";
|
||||||
|
import { overviewChartColors } from "../overview.styles";
|
||||||
|
import { chartAxisTick, chartGridStroke, chartTooltipStyle } from "./chart-style";
|
||||||
|
import { mergeTrend } from "./mergeTrend";
|
||||||
|
import { SummaryCard } from "./SummaryCard";
|
||||||
|
|
||||||
|
function formatDateLabel(date: string) {
|
||||||
|
return new Date(`${date}T00:00:00`).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEtb(amount: number) {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "ETB",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const compact = new Intl.NumberFormat("en-US", { notation: "compact" });
|
||||||
|
|
||||||
|
/** Dot-and-label legend row rendered in the card header instead of recharts' default. */
|
||||||
|
function LegendDot({ color, dashed, label }: { color: string; dashed?: boolean; label: string }) {
|
||||||
|
return (
|
||||||
|
<Group gap={5} wrap="nowrap">
|
||||||
|
{dashed ? (
|
||||||
|
<svg width={14} height={4} aria-hidden>
|
||||||
|
<line x1={0} y1={2} x2={14} y2={2} stroke={color} strokeWidth={2} strokeDasharray="3 2" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: 999, background: color }} />
|
||||||
|
)}
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewRevenueVolumeChartProps {
|
||||||
|
bookingTrend: IOverviewTrendPoint[];
|
||||||
|
paymentTrend: IOverviewPaymentTrendPoint[];
|
||||||
|
previousPaymentTrend: IOverviewPaymentTrendPoint[];
|
||||||
|
rangeDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Volume and money in one read: bars are bookings created per day, the gold
|
||||||
|
* area is ETB revenue per day, and the dashed ghost line is the preceding
|
||||||
|
* period's revenue shifted onto the same axis — "are we pacing ahead of last
|
||||||
|
* period" at a glance.
|
||||||
|
*/
|
||||||
|
export function OverviewRevenueVolumeChart({
|
||||||
|
bookingTrend = [],
|
||||||
|
paymentTrend = [],
|
||||||
|
previousPaymentTrend = [],
|
||||||
|
rangeDays,
|
||||||
|
}: OverviewRevenueVolumeChartProps) {
|
||||||
|
const data = mergeTrend(bookingTrend, paymentTrend, previousPaymentTrend, rangeDays);
|
||||||
|
const hasData = data.some((point) => point.bookings > 0 || point.revenueEtb > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SummaryCard
|
||||||
|
icon={ChartColumnBig}
|
||||||
|
accent="yellow"
|
||||||
|
title="Bookings & revenue"
|
||||||
|
subtitle="Daily volume vs ETB revenue"
|
||||||
|
action={
|
||||||
|
<Group gap="md" wrap="nowrap">
|
||||||
|
<LegendDot color="var(--mantine-color-edr-green-4)" label="Bookings" />
|
||||||
|
<LegendDot color={overviewChartColors.primary} label="Revenue" />
|
||||||
|
<LegendDot color="var(--mantine-color-gray-5)" dashed label="Prev period" />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!hasData ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
No activity in this period
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<ComposedChart data={data} margin={{ top: 8, right: 4, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="ov-revenue-fill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={overviewChartColors.primary} stopOpacity={0.22} />
|
||||||
|
<stop offset="100%" stopColor={overviewChartColors.primary} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid vertical={false} stroke={chartGridStroke} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={formatDateLabel}
|
||||||
|
tick={chartAxisTick}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
minTickGap={24}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="bookings"
|
||||||
|
allowDecimals={false}
|
||||||
|
tick={chartAxisTick}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
width={32}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="revenue"
|
||||||
|
orientation="right"
|
||||||
|
tickFormatter={(value) => compact.format(Number(value))}
|
||||||
|
tick={chartAxisTick}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
width={44}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
labelFormatter={(value) => formatDateLabel(String(value))}
|
||||||
|
formatter={(value, name) =>
|
||||||
|
name === "Bookings" ? [value, name] : [formatEtb(Number(value)), name]
|
||||||
|
}
|
||||||
|
contentStyle={chartTooltipStyle}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
yAxisId="bookings"
|
||||||
|
dataKey="bookings"
|
||||||
|
name="Bookings"
|
||||||
|
fill="var(--mantine-color-edr-green-4)"
|
||||||
|
radius={[5, 5, 0, 0]}
|
||||||
|
barSize={14}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
yAxisId="revenue"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="prevRevenueEtb"
|
||||||
|
name="Prev period"
|
||||||
|
stroke="var(--mantine-color-gray-5)"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray="5 4"
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
yAxisId="revenue"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenueEtb"
|
||||||
|
name="Revenue"
|
||||||
|
stroke={overviewChartColors.primary}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
fill="url(#ov-revenue-fill)"
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</SummaryCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { Waypoints } from "lucide-react";
|
||||||
|
import { ResponsiveContainer, Sankey, Tooltip, type SankeyLinkProps } from "recharts";
|
||||||
|
import { Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { IOverviewRevenueFlow } from "@/types/overview";
|
||||||
|
import { chartTooltipStyle } from "./chart-style";
|
||||||
|
import { SummaryCard } from "./SummaryCard";
|
||||||
|
import {
|
||||||
|
DIRECTION_COLORS,
|
||||||
|
FLOW_FALLBACK_COLOR,
|
||||||
|
FLOW_LABELS,
|
||||||
|
FREIGHT_TYPE_COLORS,
|
||||||
|
} from "./flow-colors";
|
||||||
|
|
||||||
|
function formatEtb(amount: number) {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "ETB",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SankeyNodeDatum {
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build recharts Sankey data: direction nodes on the left, freight types on the right. */
|
||||||
|
function toSankeyData(flows: IOverviewRevenueFlow[]) {
|
||||||
|
const active = flows.filter((f) => f.amountEtb > 0);
|
||||||
|
const nodes: SankeyNodeDatum[] = [];
|
||||||
|
const indexByKey = new Map<string, number>();
|
||||||
|
const nodeIndex = (key: string, color: string) => {
|
||||||
|
const existing = indexByKey.get(key);
|
||||||
|
if (existing != null) return existing;
|
||||||
|
nodes.push({ name: FLOW_LABELS[key] ?? key, key, color });
|
||||||
|
indexByKey.set(key, nodes.length - 1);
|
||||||
|
return nodes.length - 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register directions first so they all land on the left column.
|
||||||
|
for (const flow of active) {
|
||||||
|
nodeIndex(flow.direction, DIRECTION_COLORS[flow.direction] ?? FLOW_FALLBACK_COLOR);
|
||||||
|
}
|
||||||
|
const links = active.map((flow) => ({
|
||||||
|
source: indexByKey.get(flow.direction)!,
|
||||||
|
target: nodeIndex(
|
||||||
|
flow.freightType,
|
||||||
|
FREIGHT_TYPE_COLORS[flow.freightType] ?? FLOW_FALLBACK_COLOR,
|
||||||
|
),
|
||||||
|
value: flow.amountEtb,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { nodes, links };
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlowNode({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
index,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
index: number;
|
||||||
|
payload: { name?: string; value?: number; color?: string };
|
||||||
|
}) {
|
||||||
|
// Labels sit to the right of every bar: the right margin reserves room for
|
||||||
|
// the last column, and the pale ribbons stay readable under the left one.
|
||||||
|
return (
|
||||||
|
<g key={`node-${index}`}>
|
||||||
|
<rect x={x} y={y} width={width} height={height} fill={payload.color} rx={3} />
|
||||||
|
<text
|
||||||
|
x={x + width + 8}
|
||||||
|
y={y + height / 2 - 6}
|
||||||
|
textAnchor="start"
|
||||||
|
dominantBaseline="central"
|
||||||
|
fontSize={12}
|
||||||
|
fontWeight={600}
|
||||||
|
fill="#1f2937"
|
||||||
|
>
|
||||||
|
{payload.name}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={x + width + 8}
|
||||||
|
y={y + height / 2 + 9}
|
||||||
|
textAnchor="start"
|
||||||
|
dominantBaseline="central"
|
||||||
|
fontSize={11}
|
||||||
|
fill="var(--mantine-color-gray-6)"
|
||||||
|
>
|
||||||
|
{formatEtb(payload.value ?? 0)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ribbon tinted by its source direction — the corridor keeps its color across the chart. */
|
||||||
|
function FlowLink({
|
||||||
|
sourceX,
|
||||||
|
targetX,
|
||||||
|
sourceY,
|
||||||
|
targetY,
|
||||||
|
sourceControlX,
|
||||||
|
targetControlX,
|
||||||
|
linkWidth,
|
||||||
|
index,
|
||||||
|
payload,
|
||||||
|
}: SankeyLinkProps) {
|
||||||
|
// Custom node fields (color) ride along on the layout node recharts hands back.
|
||||||
|
const source = payload.source as { color?: string };
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={`link-${index}`}
|
||||||
|
d={`M${sourceX},${sourceY} C${sourceControlX},${sourceY} ${targetControlX},${targetY} ${targetX},${targetY}`}
|
||||||
|
fill="none"
|
||||||
|
stroke={source.color ?? FLOW_FALLBACK_COLOR}
|
||||||
|
strokeOpacity={0.3}
|
||||||
|
strokeWidth={linkWidth}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewSankeyFlowProps {
|
||||||
|
flows: IOverviewRevenueFlow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the money runs: ETB revenue as ribbons from trade direction to
|
||||||
|
* freight type. Ribbon thickness is proportional to revenue, so the biggest
|
||||||
|
* corridor is unmissable.
|
||||||
|
*/
|
||||||
|
export function OverviewSankeyFlow({ flows = [] }: OverviewSankeyFlowProps) {
|
||||||
|
const data = toSankeyData(flows);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SummaryCard
|
||||||
|
icon={Waypoints}
|
||||||
|
accent="sky"
|
||||||
|
title="Revenue flow"
|
||||||
|
subtitle="Trade direction → freight type, sized by ETB revenue"
|
||||||
|
>
|
||||||
|
{data.links.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
No revenue in this period
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={252}>
|
||||||
|
<Sankey
|
||||||
|
data={data}
|
||||||
|
node={FlowNode}
|
||||||
|
link={FlowLink}
|
||||||
|
nodePadding={32}
|
||||||
|
margin={{ top: 20, right: 96, bottom: 12, left: 4 }}
|
||||||
|
>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => formatEtb(Number(value))}
|
||||||
|
contentStyle={chartTooltipStyle}
|
||||||
|
/>
|
||||||
|
</Sankey>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</SummaryCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||||
|
import type { ElementType, ReactNode } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import "./overview-summary.css";
|
||||||
|
|
||||||
|
interface SummaryCardProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
/** Mantine color for the icon chip. */
|
||||||
|
accent?: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
/** Right side of the header — a legend, badge, or link. */
|
||||||
|
action?: ReactNode;
|
||||||
|
/** Makes the whole card a link (adds the hover lift). */
|
||||||
|
to?: string;
|
||||||
|
minHeight?: number;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared chrome for every overview card: soft gradient surface, layered
|
||||||
|
* shadow, icon-chip header with title/subtitle, optional action slot.
|
||||||
|
* One look for the whole page instead of eight flat white boxes.
|
||||||
|
*/
|
||||||
|
export function SummaryCard({
|
||||||
|
icon: Icon,
|
||||||
|
accent = "edr-green",
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
action,
|
||||||
|
to,
|
||||||
|
minHeight = 340,
|
||||||
|
children,
|
||||||
|
}: SummaryCardProps) {
|
||||||
|
const Root: ElementType = to ? Link : "div";
|
||||||
|
return (
|
||||||
|
<Root
|
||||||
|
{...(to ? { to } : {})}
|
||||||
|
className={to ? "ov-card ov-card--link" : "ov-card"}
|
||||||
|
style={{ minHeight }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md" gap="sm">
|
||||||
|
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<ThemeIcon variant="light" color={accent} size={38} radius={12} style={{ flexShrink: 0 }}>
|
||||||
|
<Icon size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={1} style={{ minWidth: 0 }}>
|
||||||
|
<Text fw={700} lh={1.25} c="edr-text" truncate>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle ? (
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
{action}
|
||||||
|
</Group>
|
||||||
|
{children}
|
||||||
|
</Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
/** Shared recharts styling for the overview summary charts. */
|
||||||
|
export const chartGridStroke = "#EEF1F5";
|
||||||
|
|
||||||
|
export const chartAxisTick = { fontSize: 11, fill: "#8fa0b2" } as const;
|
||||||
|
|
||||||
|
export const chartTooltipStyle: CSSProperties = {
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid #EEF1F5",
|
||||||
|
boxShadow: "0 8px 24px rgba(16, 32, 47, 0.1)",
|
||||||
|
fontSize: 12,
|
||||||
|
padding: "8px 12px",
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Fixed colors + labels for trade directions and freight types, shared by the
|
||||||
|
* revenue mix and Sankey so the same entity is always the same color (and
|
||||||
|
* matches the operations departure chart's direction hexes).
|
||||||
|
*/
|
||||||
|
export const DIRECTION_COLORS: Record<string, string> = {
|
||||||
|
EXPORT: "#D98A0B",
|
||||||
|
IMPORT: "#0369a1",
|
||||||
|
DOMESTIC: "#7c3aed",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FREIGHT_TYPE_COLORS: Record<string, string> = {
|
||||||
|
CONTAINER: "#1B9E7A",
|
||||||
|
BULK: "#34D9AE",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FLOW_LABELS: Record<string, string> = {
|
||||||
|
IMPORT: "Import",
|
||||||
|
EXPORT: "Export",
|
||||||
|
DOMESTIC: "Domestic",
|
||||||
|
CONTAINER: "Container",
|
||||||
|
BULK: "Bulk",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FLOW_FALLBACK_COLOR = "#94a3b8";
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { mergeTrend } from "./mergeTrend";
|
||||||
|
|
||||||
|
describe("mergeTrend", () => {
|
||||||
|
it("unions dates from both trends, zero-filling the side that has no row", () => {
|
||||||
|
const result = mergeTrend(
|
||||||
|
[
|
||||||
|
{ date: "2026-06-01", count: 3 },
|
||||||
|
{ date: "2026-06-02", count: 5 },
|
||||||
|
],
|
||||||
|
[{ date: "2026-06-02", amountEtb: 1000, amountUsd: 0 }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ date: "2026-06-01", bookings: 3, revenueEtb: 0 },
|
||||||
|
{ date: "2026-06-02", bookings: 5, revenueEtb: 1000 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts chronologically regardless of input order", () => {
|
||||||
|
const result = mergeTrend(
|
||||||
|
[{ date: "2026-06-03", count: 1 }],
|
||||||
|
[{ date: "2026-06-01", amountEtb: 500, amountUsd: 0 }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.map((p) => p.date)).toEqual(["2026-06-01", "2026-06-03"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty series when both trends are empty", () => {
|
||||||
|
expect(mergeTrend([], [])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shifts the previous period forward so day N lands on day N of the current window", () => {
|
||||||
|
const result = mergeTrend(
|
||||||
|
[{ date: "2026-06-08", count: 2 }],
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
// 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 },
|
||||||
|
],
|
||||||
|
7,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ date: "2026-06-08", bookings: 2, revenueEtb: 0, prevRevenueEtb: 400 },
|
||||||
|
{ date: "2026-06-09", bookings: 0, revenueEtb: 0, prevRevenueEtb: 250 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shifts across a month boundary without timezone drift", () => {
|
||||||
|
const result = mergeTrend([], [], [{ date: "2026-05-28", amountEtb: 100, amountUsd: 0 }], 7);
|
||||||
|
expect(result[0].date).toBe("2026-06-04");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { IOverviewPaymentTrendPoint, IOverviewTrendPoint } from "@/types/overview";
|
||||||
|
|
||||||
|
export interface RevenueVolumePoint {
|
||||||
|
date: string;
|
||||||
|
bookings: number;
|
||||||
|
revenueEtb: number;
|
||||||
|
/** Same-offset day of the preceding period — the ghost comparison line. */
|
||||||
|
prevRevenueEtb?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `date` (YYYY-MM-DD) plus `days` days, in UTC so no DST/timezone drift. */
|
||||||
|
export function shiftDate(date: string, days: number): string {
|
||||||
|
const parsed = new Date(`${date}T00:00:00Z`);
|
||||||
|
parsed.setUTCDate(parsed.getUTCDate() + days);
|
||||||
|
return parsed.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge the booking-count trend and the payment trend into one date-keyed
|
||||||
|
* series for the combined volume/revenue chart. Both trends only carry rows
|
||||||
|
* for days with activity (no zero-filled gaps), so this unions the dates
|
||||||
|
* rather than assuming they line up.
|
||||||
|
*
|
||||||
|
* When the previous period's payment trend is provided, each of its days is
|
||||||
|
* shifted forward by `shiftDays` (the range length) so day N of the prior
|
||||||
|
* window lands on day N of the current one, and lands in `prevRevenueEtb`.
|
||||||
|
*/
|
||||||
|
export function mergeTrend(
|
||||||
|
bookingTrend: IOverviewTrendPoint[],
|
||||||
|
paymentTrend: IOverviewPaymentTrendPoint[],
|
||||||
|
previousPaymentTrend: IOverviewPaymentTrendPoint[] = [],
|
||||||
|
shiftDays = 0,
|
||||||
|
): RevenueVolumePoint[] {
|
||||||
|
const byDate = new Map<string, RevenueVolumePoint>();
|
||||||
|
|
||||||
|
for (const point of bookingTrend) {
|
||||||
|
byDate.set(point.date, { date: point.date, bookings: point.count, revenueEtb: 0 });
|
||||||
|
}
|
||||||
|
for (const point of paymentTrend) {
|
||||||
|
const existing = byDate.get(point.date);
|
||||||
|
if (existing) {
|
||||||
|
existing.revenueEtb = point.amountEtb;
|
||||||
|
} else {
|
||||||
|
byDate.set(point.date, { date: point.date, bookings: 0, revenueEtb: point.amountEtb });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const point of previousPaymentTrend) {
|
||||||
|
const date = shiftDate(point.date, shiftDays);
|
||||||
|
const existing = byDate.get(date);
|
||||||
|
if (existing) {
|
||||||
|
existing.prevRevenueEtb = point.amountEtb;
|
||||||
|
} else {
|
||||||
|
byDate.set(date, {
|
||||||
|
date,
|
||||||
|
bookings: 0,
|
||||||
|
revenueEtb: 0,
|
||||||
|
prevRevenueEtb: point.amountEtb,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/* Staggered fade-up entrance for the overview bands. Delay is set inline per
|
||||||
|
band; disabled entirely for reduced-motion users. */
|
||||||
|
.ov-band {
|
||||||
|
animation: ov-rise 420ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ov-rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ov-band {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Shared card chrome (SummaryCard) ---- */
|
||||||
|
.ov-card {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #fbfdfc 100%);
|
||||||
|
border: 1px solid var(--mantine-color-edr-border-0);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 2px rgba(16, 32, 47, 0.04),
|
||||||
|
0 12px 32px -18px rgba(16, 32, 47, 0.14);
|
||||||
|
transition:
|
||||||
|
box-shadow 180ms ease,
|
||||||
|
transform 180ms ease,
|
||||||
|
border-color 180ms ease;
|
||||||
|
}
|
||||||
|
/* The lift is a click affordance — only linked cards get it. */
|
||||||
|
.ov-card--link:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 2px 4px rgba(16, 32, 47, 0.05),
|
||||||
|
0 20px 44px -18px rgba(16, 32, 47, 0.2);
|
||||||
|
border-color: var(--mantine-color-edr-green-2);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Soft inset panel for grouping content inside a card. */
|
||||||
|
.ov-inset {
|
||||||
|
background: var(--mantine-color-gray-0);
|
||||||
|
border: 1px solid var(--mantine-color-gray-1);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Interactive list row inside a card. */
|
||||||
|
.ov-row {
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: background 140ms ease;
|
||||||
|
}
|
||||||
|
.ov-row:hover {
|
||||||
|
background: var(--mantine-color-gray-0);
|
||||||
|
}
|
||||||
@@ -59,12 +59,6 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
|
|||||||
accent: "sky",
|
accent: "sky",
|
||||||
hint: "Pending sign-off",
|
hint: "Pending sign-off",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Submitted today",
|
|
||||||
value: data.kpis.submittedToday,
|
|
||||||
icon: FileText,
|
|
||||||
hint: "New since midnight",
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -76,12 +76,6 @@ export function OverviewContractsTabPanel({
|
|||||||
accent: "rose",
|
accent: "rose",
|
||||||
hint: "Customs / documents",
|
hint: "Customs / documents",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Created today",
|
|
||||||
value: data.kpis.createdToday,
|
|
||||||
icon: FileSignature,
|
|
||||||
hint: "New since midnight",
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Box,
|
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
Container as ContainerIcon,
|
Container as ContainerIcon,
|
||||||
Send,
|
Send,
|
||||||
@@ -78,11 +77,6 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP
|
|||||||
value: data.kpis.containersInTransit,
|
value: data.kpis.containersInTransit,
|
||||||
icon: ContainerIcon,
|
icon: ContainerIcon,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Cargoes loaded",
|
|
||||||
value: data.kpis.cargoesLoaded,
|
|
||||||
icon: Box,
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -103,8 +103,16 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
|||||||
component="span"
|
component="span"
|
||||||
fz="xs"
|
fz="xs"
|
||||||
fw={700}
|
fw={700}
|
||||||
c={item.delta > 0 ? "edr-green" : "red"}
|
c={item.delta > 0 ? "edr-green.7" : "red.7"}
|
||||||
style={{ whiteSpace: "nowrap" }}
|
style={{
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
background:
|
||||||
|
item.delta > 0
|
||||||
|
? "var(--mantine-color-edr-green-0)"
|
||||||
|
: "var(--mantine-color-red-0)",
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: "1px 7px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{item.delta > 0 ? "▲" : "▼"}
|
{item.delta > 0 ? "▲" : "▼"}
|
||||||
{Math.abs(item.delta)}
|
{Math.abs(item.delta)}
|
||||||
|
|||||||
@@ -99,18 +99,8 @@ export const formatDays = (value: number | null | undefined) => {
|
|||||||
return `${rounded} ${rounded === 1 ? 'day' : 'days'}`;
|
return `${rounded} ${rounded === 1 ? 'day' : 'days'}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatDate = (value: string | null | undefined) => {
|
// Despite the name, this has always rendered date + time — hence formatDateTime.
|
||||||
if (!value) return '—';
|
export { formatDateTime as formatDate } from '@/lib/format';
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return '—';
|
|
||||||
return date.toLocaleString(undefined, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Name fields (warehouse / fee rule / allocation rule) accept letters and spaces only — no numbers.
|
// Name fields (warehouse / fee rule / allocation rule) accept letters and spaces only — no numbers.
|
||||||
export const lettersOnly = (value: string) => value.replace(/[^A-Za-z\s]/g, '');
|
export const lettersOnly = (value: string) => value.replace(/[^A-Za-z\s]/g, '');
|
||||||
|
|||||||
62
apps/edr-freight-web/backoffice/src/lib/format.ts
Normal file
62
apps/edr-freight-web/backoffice/src/lib/format.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/** Shared display-formatting helpers for the backoffice. */
|
||||||
|
|
||||||
|
/** snake_case / SCREAMING_CASE → Title Case. */
|
||||||
|
export function humanize(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[_\s]+/)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
const d = new Date(value);
|
||||||
|
return Number.isNaN(d.getTime())
|
||||||
|
? "—"
|
||||||
|
: d.toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value: string | null | undefined): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
const d = new Date(value);
|
||||||
|
return Number.isNaN(d.getTime())
|
||||||
|
? "—"
|
||||||
|
: d.toLocaleString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pass `fractionDigits` where cents matter; the default matches the legacy whole-figure display. */
|
||||||
|
export function formatMoney(
|
||||||
|
amount: number,
|
||||||
|
currency: string,
|
||||||
|
fractionDigits?: number,
|
||||||
|
): string {
|
||||||
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
style: "currency",
|
||||||
|
currency,
|
||||||
|
...(fractionDigits === undefined
|
||||||
|
? { maximumFractionDigits: 0 }
|
||||||
|
: {
|
||||||
|
minimumFractionDigits: fractionDigits,
|
||||||
|
maximumFractionDigits: fractionDigits,
|
||||||
|
}),
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (!bytes) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
const value = bytes / Math.pow(1024, i);
|
||||||
|
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ import {
|
|||||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||||
|
import { formatDateTime, formatMoney } from "@/lib/format";
|
||||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import {
|
import {
|
||||||
@@ -186,7 +187,7 @@ export default function BookingRequestDetailPage() {
|
|||||||
const kpis: KpiItem[] = [
|
const kpis: KpiItem[] = [
|
||||||
{
|
{
|
||||||
label: "Total value",
|
label: "Total value",
|
||||||
value: `${booking.paymentCurrency} ${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}`,
|
value: formatMoney(amount, booking.paymentCurrency, 2),
|
||||||
hint: booking.paymentStatus,
|
hint: booking.paymentStatus,
|
||||||
icon: Wallet,
|
icon: Wallet,
|
||||||
color: "edr-green",
|
color: "edr-green",
|
||||||
@@ -326,7 +327,7 @@ export default function BookingRequestDetailPage() {
|
|||||||
|
|
||||||
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
|
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
|
||||||
<Text size="xs" c="orange.7">
|
<Text size="xs" c="orange.7">
|
||||||
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
|
Hold expires {formatDateTime(booking.holdExpiresAt)}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
Collapse,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
@@ -36,6 +37,8 @@ import { useNavigate, useSearchParams } from "react-router-dom";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||||
|
import { FilterToggle } from "@/components/common/FilterToggle";
|
||||||
|
import { formatDate, humanize } from "@/lib/format";
|
||||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
|
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
|
||||||
@@ -50,7 +53,6 @@ import {
|
|||||||
useBookingList,
|
useBookingList,
|
||||||
useBookingListSummary,
|
useBookingListSummary,
|
||||||
} from "@/hooks/bookings/useBookings";
|
} from "@/hooks/bookings/useBookings";
|
||||||
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { BookingListFilter } from "@/services/bookings.service";
|
import type { BookingListFilter } from "@/services/bookings.service";
|
||||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||||
@@ -116,18 +118,6 @@ function endOfDayIso(d: Date): string {
|
|||||||
return x.toISOString();
|
return x.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value: string | null | undefined): string {
|
|
||||||
if (!value) return "—";
|
|
||||||
const d = new Date(value);
|
|
||||||
return Number.isNaN(d.getTime())
|
|
||||||
? "—"
|
|
||||||
: d.toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BookingRequestsPage() {
|
export default function BookingRequestsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
|
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
|
||||||
@@ -159,6 +149,11 @@ export default function BookingRequestsPage() {
|
|||||||
const [createdTo, setCreatedTo] = useState<Date | null>(null);
|
const [createdTo, setCreatedTo] = useState<Date | null>(null);
|
||||||
const [scheduledFrom, setScheduledFrom] = useState<Date | null>(null);
|
const [scheduledFrom, setScheduledFrom] = useState<Date | null>(null);
|
||||||
const [scheduledTo, setScheduledTo] = useState<Date | null>(null);
|
const [scheduledTo, setScheduledTo] = useState<Date | null>(null);
|
||||||
|
// Direction is the only deep-linkable advanced filter — open the panel so a
|
||||||
|
// deep link never hides its own filter.
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(() =>
|
||||||
|
Boolean(paramDirection),
|
||||||
|
);
|
||||||
const [allocateOpen, setAllocateOpen] = useState(false);
|
const [allocateOpen, setAllocateOpen] = useState(false);
|
||||||
const [allocateIds, setAllocateIds] = useState<string[]>([]);
|
const [allocateIds, setAllocateIds] = useState<string[]>([]);
|
||||||
// Paid bookings with no train attached (staff removed them or a sweep
|
// Paid bookings with no train attached (staff removed them or a sweep
|
||||||
@@ -185,6 +180,7 @@ export default function BookingRequestsPage() {
|
|||||||
const next = paramStatuses.split(",").filter(Boolean);
|
const next = paramStatuses.split(",").filter(Boolean);
|
||||||
setStatusFilter((prev) => (prev.join(",") === next.join(",") ? prev : next));
|
setStatusFilter((prev) => (prev.join(",") === next.join(",") ? prev : next));
|
||||||
setDirectionFilter(paramDirection);
|
setDirectionFilter(paramDirection);
|
||||||
|
if (paramDirection) setShowAdvanced(true);
|
||||||
}, [paramStatuses, paramDirection]);
|
}, [paramStatuses, paramDirection]);
|
||||||
|
|
||||||
const filter: BookingListFilter = useMemo(() => {
|
const filter: BookingListFilter = useMemo(() => {
|
||||||
@@ -278,6 +274,10 @@ export default function BookingRequestsPage() {
|
|||||||
(createdFrom || createdTo ? 1 : 0) +
|
(createdFrom || createdTo ? 1 : 0) +
|
||||||
(scheduledFrom || scheduledTo ? 1 : 0);
|
(scheduledFrom || scheduledTo ? 1 : 0);
|
||||||
|
|
||||||
|
// Badge on the advanced-filters toggle — active filters hidden behind it.
|
||||||
|
const advancedFilterCount =
|
||||||
|
activeFilterCount - (kindFilter ? 1 : 0) - (statusFilter.length ? 1 : 0);
|
||||||
|
|
||||||
const clearFilters = useCallback(() => {
|
const clearFilters = useCallback(() => {
|
||||||
setKindFilter(null);
|
setKindFilter(null);
|
||||||
setStatusFilter([]);
|
setStatusFilter([]);
|
||||||
@@ -390,13 +390,22 @@ export default function BookingRequestsPage() {
|
|||||||
header: () => <span className={bookingTable.headerCell}>Booking</span>,
|
header: () => <span className={bookingTable.headerCell}>Booking</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const b = row.original;
|
const b = row.original;
|
||||||
|
const isGeneral = b.bookingKind === "GENERAL_CONTRACT";
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 py-1.5">
|
<div className="flex items-center gap-3 py-1.5">
|
||||||
<div className={bookingTable.rowIcon}>
|
<div className={bookingTable.rowIcon}>
|
||||||
<Package className="size-4" strokeWidth={1.75} />
|
<Package className="size-4" strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 max-w-[220px]">
|
||||||
<p className="truncate font-medium text-foreground">{b.reference}</p>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<p className="truncate font-medium text-foreground">{b.reference}</p>
|
||||||
|
<Badge
|
||||||
|
variant={isGeneral ? "secondary" : "outline"}
|
||||||
|
className="h-5 shrink-0 px-1.5 text-[10px] font-medium"
|
||||||
|
>
|
||||||
|
{isGeneral ? "General" : "One-time"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||||
<User className="size-3 shrink-0 opacity-70" />
|
<User className="size-3 shrink-0 opacity-70" />
|
||||||
{b.customerLabel}
|
{b.customerLabel}
|
||||||
@@ -406,49 +415,6 @@ export default function BookingRequestsPage() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "contract",
|
|
||||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const ref = row.original.contractReference;
|
|
||||||
return (
|
|
||||||
<div className="py-1">
|
|
||||||
{ref ? (
|
|
||||||
// Fall back to plain text when the id is missing — the reference is
|
|
||||||
// still worth showing, it just has nowhere to link to.
|
|
||||||
(row.original.contractId ? (
|
|
||||||
<ContractReferenceLink
|
|
||||||
contractId={row.original.contractId}
|
|
||||||
contractReference={ref}
|
|
||||||
className="truncate font-mono text-xs text-foreground underline underline-offset-2 hover:text-primary"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="truncate font-mono text-xs text-foreground">{ref}</span>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "bookingKind",
|
|
||||||
header: () => <span className={bookingTable.headerCell}>Type</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const isGeneral = row.original.bookingKind === "GENERAL_CONTRACT";
|
|
||||||
return (
|
|
||||||
<div className="py-1">
|
|
||||||
<Badge
|
|
||||||
variant={isGeneral ? "secondary" : "outline"}
|
|
||||||
className="h-5 px-1.5 text-[10px] font-medium"
|
|
||||||
>
|
|
||||||
{isGeneral ? "General" : "One-time"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "route",
|
id: "route",
|
||||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||||
@@ -464,15 +430,15 @@ export default function BookingRequestsPage() {
|
|||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
|
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
{b.tradeDirection}
|
{humanize(b.tradeDirection)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
|
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
|
||||||
>
|
>
|
||||||
{b.freightType}
|
{humanize(b.freightType)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -621,7 +587,7 @@ export default function BookingRequestsPage() {
|
|||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Box px="md" pt="md" pb="sm" w="100%">
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group justify="space-between" gap="md" wrap="wrap">
|
<Group gap="sm" wrap="wrap">
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Search booking, contract or customer…"
|
placeholder="Search booking, contract or customer…"
|
||||||
leftSection={<Search size={18} />}
|
leftSection={<Search size={18} />}
|
||||||
@@ -649,11 +615,6 @@ export default function BookingRequestsPage() {
|
|||||||
style={{ flex: 1, minWidth: "200px" }}
|
style={{ flex: 1, minWidth: "200px" }}
|
||||||
radius="lg"
|
radius="lg"
|
||||||
/>
|
/>
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{total} record{total !== 1 ? "s" : ""}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap="sm" wrap="wrap">
|
|
||||||
<Select
|
<Select
|
||||||
placeholder="All booking types"
|
placeholder="All booking types"
|
||||||
data={BOOKING_KIND_OPTIONS}
|
data={BOOKING_KIND_OPTIONS}
|
||||||
@@ -679,6 +640,25 @@ export default function BookingRequestsPage() {
|
|||||||
radius="lg"
|
radius="lg"
|
||||||
style={{ minWidth: 220 }}
|
style={{ minWidth: 220 }}
|
||||||
/>
|
/>
|
||||||
|
<FilterToggle
|
||||||
|
count={advancedFilterCount}
|
||||||
|
expanded={showAdvanced}
|
||||||
|
onClick={() => setShowAdvanced((v) => !v)}
|
||||||
|
/>
|
||||||
|
{activeFilterCount > 0 ? (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
radius="lg"
|
||||||
|
leftSection={<FilterX size={16} />}
|
||||||
|
onClick={clearFilters}
|
||||||
|
>
|
||||||
|
Clear filters ({activeFilterCount})
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Collapse expanded={showAdvanced}>
|
||||||
|
<Group gap="sm" wrap="wrap">
|
||||||
<Select
|
<Select
|
||||||
placeholder="All origins"
|
placeholder="All origins"
|
||||||
data={yardOptions}
|
data={yardOptions}
|
||||||
@@ -729,8 +709,6 @@ export default function BookingRequestsPage() {
|
|||||||
radius="lg"
|
radius="lg"
|
||||||
style={{ minWidth: 150 }}
|
style={{ minWidth: 150 }}
|
||||||
/>
|
/>
|
||||||
</Group>
|
|
||||||
<Group gap="sm" wrap="wrap">
|
|
||||||
<Select
|
<Select
|
||||||
placeholder="All payment statuses"
|
placeholder="All payment statuses"
|
||||||
data={PAYMENT_STATUS_OPTIONS}
|
data={PAYMENT_STATUS_OPTIONS}
|
||||||
@@ -793,18 +771,8 @@ export default function BookingRequestsPage() {
|
|||||||
radius="lg"
|
radius="lg"
|
||||||
style={{ minWidth: 230 }}
|
style={{ minWidth: 230 }}
|
||||||
/>
|
/>
|
||||||
{activeFilterCount > 0 ? (
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
radius="lg"
|
|
||||||
leftSection={<FilterX size={16} />}
|
|
||||||
onClick={clearFilters}
|
|
||||||
>
|
|
||||||
Clear filters ({activeFilterCount})
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
</Group>
|
||||||
|
</Collapse>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -471,9 +471,6 @@ export default function DocumentClearanceListPage({
|
|||||||
style={{ flex: 1, minWidth: 220 }}
|
style={{ flex: 1, minWidth: 220 }}
|
||||||
/>
|
/>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{total} record{total !== 1 ? "s" : ""}
|
|
||||||
</Text>
|
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
size="sm"
|
size="sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
|||||||
import { api as appApi } from "@/services/api";
|
import { api as appApi } from "@/services/api";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { customersService } from "@/services/customers.service";
|
import { customersService } from "@/services/customers.service";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
|
||||||
interface RefNamed {
|
interface RefNamed {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -705,7 +706,7 @@ export default function NewBookingPage() {
|
|||||||
/>
|
/>
|
||||||
<SummaryRow
|
<SummaryRow
|
||||||
label="Departure"
|
label="Departure"
|
||||||
value={effectiveDepartureIso ? new Date(effectiveDepartureIso).toLocaleString() : "Not set"}
|
value={effectiveDepartureIso ? formatDateTime(effectiveDepartureIso) : "Not set"}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { api } from "@/auth/http";
|
|||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
import { toDayString } from "@/hooks/useListControls";
|
import { toDayString } from "@/hooks/useListControls";
|
||||||
|
import { formatDate, formatMoney } from "@/lib/format";
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import {
|
import {
|
||||||
DataTable,
|
DataTable,
|
||||||
@@ -96,24 +97,6 @@ function StatusChip({ status }: { status: WagonCancellationStatus }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string | null | undefined): string {
|
|
||||||
if (!iso) return "—";
|
|
||||||
const d = new Date(iso);
|
|
||||||
return Number.isNaN(d.getTime())
|
|
||||||
? "—"
|
|
||||||
: d.toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAmount(amount: number, currency: string): string {
|
|
||||||
return `${currency} ${Number(amount).toLocaleString(undefined, {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
})}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Staff view of partial wagon cancellations: every slice of capacity a
|
* Staff view of partial wagon cancellations: every slice of capacity a
|
||||||
* customer gave back, its cancellation fee, and where the credit went
|
* customer gave back, its cancellation fee, and where the credit went
|
||||||
@@ -196,7 +179,9 @@ export default function WagonCancellationsPage() {
|
|||||||
id: "company",
|
id: "company",
|
||||||
header: () => <span>Company</span>,
|
header: () => <span>Company</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Text size="sm">{row.original.booking?.company?.name ?? "—"}</Text>
|
<Text size="sm" truncate maw={200}>
|
||||||
|
{row.original.booking?.company?.name ?? "—"}
|
||||||
|
</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -209,7 +194,7 @@ export default function WagonCancellationsPage() {
|
|||||||
header: () => <span>Fee</span>,
|
header: () => <span>Fee</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
|
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
{formatAmount(row.original.feeAmount, row.original.feeCurrency)}
|
{formatMoney(row.original.feeAmount, row.original.feeCurrency, 2)}
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -218,7 +203,7 @@ export default function WagonCancellationsPage() {
|
|||||||
header: () => <span>Credit</span>,
|
header: () => <span>Credit</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
|
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
{formatAmount(row.original.creditAmount, row.original.feeCurrency)}
|
{formatMoney(row.original.creditAmount, row.original.feeCurrency, 2)}
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -370,7 +355,7 @@ export default function WagonCancellationsPage() {
|
|||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{voiding.booking?.reference ?? voiding.bookingId} ·{" "}
|
{voiding.booking?.reference ?? voiding.bookingId} ·{" "}
|
||||||
{voiding.wagonsCancelled} wagon(s) · fee{" "}
|
{voiding.wagonsCancelled} wagon(s) · fee{" "}
|
||||||
{formatAmount(voiding.feeAmount, voiding.feeCurrency)}
|
{formatMoney(voiding.feeAmount, voiding.feeCurrency, 2)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
The pending fee is dropped and the wagons stay on the booking.
|
The pending fee is dropped and the wagons stay on the booking.
|
||||||
|
|||||||
@@ -305,9 +305,6 @@ export default function ClearanceDocumentsPage() {
|
|||||||
w={220}
|
w={220}
|
||||||
aria-label="Filter by status"
|
aria-label="Filter by status"
|
||||||
/>
|
/>
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{total} record{total !== 1 ? "s" : ""}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm" mt="sm" wrap="wrap">
|
<Group gap="sm" mt="sm" wrap="wrap">
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatDate, formatDateTime } from "@/lib/format";
|
||||||
import { directionLabel } from "@/lib/utils";
|
import { directionLabel } from "@/lib/utils";
|
||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -114,33 +115,6 @@ const CLEARANCE_REVIEW_STATUSES = [
|
|||||||
...CLEARANCE_DONE_STATUSES,
|
...CLEARANCE_DONE_STATUSES,
|
||||||
];
|
];
|
||||||
|
|
||||||
function formatDate(value: string | null | undefined): string {
|
|
||||||
if (!value) return "—";
|
|
||||||
const d = new Date(value);
|
|
||||||
return Number.isNaN(d.getTime())
|
|
||||||
? "—"
|
|
||||||
: d.toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same, plus the clock — for values the staff pick to the minute. */
|
|
||||||
function formatDateTime(value: string | null | undefined): string {
|
|
||||||
if (!value) return "—";
|
|
||||||
const d = new Date(value);
|
|
||||||
return Number.isNaN(d.getTime())
|
|
||||||
? "—"
|
|
||||||
: d.toLocaleString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContractRequestDetailPage() {
|
export default function ContractRequestDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
Collapse,
|
||||||
Group,
|
Group,
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
Select,
|
Select,
|
||||||
@@ -32,29 +33,25 @@ import {
|
|||||||
User,
|
User,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell";
|
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||||
import {
|
import { FilterToggle } from "@/components/common/FilterToggle";
|
||||||
ContractCourtBadge,
|
|
||||||
ContractStatusBadge,
|
|
||||||
} from "@/components/contracts/ContractStatusBadge";
|
|
||||||
import {
|
|
||||||
ContractStatusTabs,
|
|
||||||
type ContractStatusTabKey,
|
|
||||||
} from "@/components/contracts/ContractStatusTabs";
|
|
||||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||||
import {
|
import {
|
||||||
CONTRACT_LIST_TABS,
|
CONTRACT_LIST_TABS,
|
||||||
CONTRACT_STATUS_STYLES,
|
CONTRACT_STATUS_STYLES,
|
||||||
|
contractCourt,
|
||||||
} from "@/features/contracts/contract-status.config";
|
} from "@/features/contracts/contract-status.config";
|
||||||
|
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||||
import {
|
import {
|
||||||
getStaffRowAction,
|
getStaffRowAction,
|
||||||
toContractListRow,
|
toContractListRow,
|
||||||
type ContractListRow,
|
type ContractListRow,
|
||||||
} from "@/features/contracts/mapContractListRow";
|
} from "@/features/contracts/mapContractListRow";
|
||||||
|
import { formatDate, humanize } from "@/lib/format";
|
||||||
import {
|
import {
|
||||||
useContractList,
|
useContractList,
|
||||||
useContractListSummary,
|
useContractListSummary,
|
||||||
@@ -68,25 +65,13 @@ import {
|
|||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
function getStatusesForTab(tab: ContractStatusTabKey): string | undefined {
|
/** Every filterable status — the pill tabs are gone, so the select carries them all. */
|
||||||
const match = CONTRACT_LIST_TABS.find((t) => t.key === tab);
|
const STATUS_OPTIONS = CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []).map(
|
||||||
if (!match?.statuses?.length) return undefined;
|
(s) => ({
|
||||||
return match.statuses.join(",");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Statuses selectable in the status filter for a given tab ("all" → every tab status). */
|
|
||||||
function getStatusOptionsForTab(
|
|
||||||
tab: ContractStatusTabKey,
|
|
||||||
): { value: string; label: string }[] {
|
|
||||||
const match = CONTRACT_LIST_TABS.find((t) => t.key === tab);
|
|
||||||
const statuses = match?.statuses?.length
|
|
||||||
? match.statuses
|
|
||||||
: CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []);
|
|
||||||
return statuses.map((s) => ({
|
|
||||||
value: s,
|
value: s,
|
||||||
label: CONTRACT_STATUS_STYLES[s]?.label ?? s,
|
label: CONTRACT_STATUS_STYLES[s]?.label ?? s,
|
||||||
}));
|
}),
|
||||||
}
|
);
|
||||||
|
|
||||||
const TRADE_DIRECTION_OPTIONS = [
|
const TRADE_DIRECTION_OPTIONS = [
|
||||||
{ value: "IMPORT", label: "Import" },
|
{ value: "IMPORT", label: "Import" },
|
||||||
@@ -117,8 +102,11 @@ const SORT_OPTIONS = [
|
|||||||
{ value: "contractValidUntil:DESC", label: "Expiring latest" },
|
{ value: "contractValidUntil:DESC", label: "Expiring latest" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Every column is 120px wide and wraps its content instead of truncating. */
|
/**
|
||||||
const COLUMN_WIDTH = 120;
|
* Per-column widths — they must sum to the table's min-w (960px, set on the
|
||||||
|
* containerClassName below) because table-fixed distributes any difference.
|
||||||
|
*/
|
||||||
|
const COLUMN_WIDTHS = { contract: 250, route: 270, status: 250, validity: 190 };
|
||||||
const COLUMN_META = {
|
const COLUMN_META = {
|
||||||
headerClassName: "whitespace-normal break-words",
|
headerClassName: "whitespace-normal break-words",
|
||||||
cellClassName: "whitespace-normal break-words align-top",
|
cellClassName: "whitespace-normal break-words align-top",
|
||||||
@@ -138,24 +126,11 @@ function endOfDayIso(d: Date): string {
|
|||||||
return x.toISOString();
|
return x.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value: string | null | undefined): string {
|
|
||||||
if (!value) return "—";
|
|
||||||
const d = new Date(value);
|
|
||||||
return Number.isNaN(d.getTime())
|
|
||||||
? "—"
|
|
||||||
: d.toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContractRequestsPage() {
|
export default function ContractRequestsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||||
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
|
|
||||||
// Filter controls (empty/null = "all").
|
// Filter controls (empty/null = "all").
|
||||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||||
const { filterOptions } = useMyTradeAccess();
|
const { filterOptions } = useMyTradeAccess();
|
||||||
@@ -168,12 +143,8 @@ export default function ContractRequestsPage() {
|
|||||||
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
|
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
|
||||||
const [createdTo, setCreatedTo] = useState<Date | null>(null);
|
const [createdTo, setCreatedTo] = useState<Date | null>(null);
|
||||||
const [sort, setSort] = useState<string>("createdAt:DESC");
|
const [sort, setSort] = useState<string>("createdAt:DESC");
|
||||||
|
// All filters start empty (no URL params on this page), so collapsed is safe.
|
||||||
const tabStatuses = getStatusesForTab(activeTab);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
const statusOptions = useMemo(
|
|
||||||
() => getStatusOptionsForTab(activeTab),
|
|
||||||
[activeTab],
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetPage = useCallback(() => {
|
const resetPage = useCallback(() => {
|
||||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
@@ -186,16 +157,11 @@ export default function ContractRequestsPage() {
|
|||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
tab: activeTab,
|
// Kept as the React Query cache-key discriminator (tabs themselves are gone).
|
||||||
|
tab: "all",
|
||||||
// Server-side free-text search (contract reference, customer name).
|
// Server-side free-text search (contract reference, customer name).
|
||||||
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
|
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
|
||||||
// Explicit status picks narrow within the tab; otherwise the tab's
|
...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
|
||||||
// status group applies.
|
|
||||||
...(statusFilter.length
|
|
||||||
? { statuses: statusFilter.join(",") }
|
|
||||||
: tabStatuses
|
|
||||||
? { statuses: tabStatuses }
|
|
||||||
: {}),
|
|
||||||
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
||||||
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
||||||
...(kindFilter ? { contractKind: kindFilter } : {}),
|
...(kindFilter ? { contractKind: kindFilter } : {}),
|
||||||
@@ -206,8 +172,6 @@ export default function ContractRequestsPage() {
|
|||||||
}, [
|
}, [
|
||||||
pagination.pageIndex,
|
pagination.pageIndex,
|
||||||
pagination.pageSize,
|
pagination.pageSize,
|
||||||
activeTab,
|
|
||||||
tabStatuses,
|
|
||||||
debouncedQuery,
|
debouncedQuery,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
directionFilter,
|
directionFilter,
|
||||||
@@ -227,6 +191,10 @@ export default function ContractRequestsPage() {
|
|||||||
(currencyFilter ? 1 : 0) +
|
(currencyFilter ? 1 : 0) +
|
||||||
(createdFrom || createdTo ? 1 : 0);
|
(createdFrom || createdTo ? 1 : 0);
|
||||||
|
|
||||||
|
// Badge on the advanced-filters toggle — active filters hidden behind it.
|
||||||
|
const advancedFilterCount =
|
||||||
|
activeFilterCount - (statusFilter.length ? 1 : 0) - (kindFilter ? 1 : 0);
|
||||||
|
|
||||||
const clearFilters = useCallback(() => {
|
const clearFilters = useCallback(() => {
|
||||||
setStatusFilter([]);
|
setStatusFilter([]);
|
||||||
setDirectionFilter(null);
|
setDirectionFilter(null);
|
||||||
@@ -273,7 +241,7 @@ export default function ContractRequestsPage() {
|
|||||||
const columns: ColumnDef<ContractListRow>[] = [
|
const columns: ColumnDef<ContractListRow>[] = [
|
||||||
{
|
{
|
||||||
id: "contract",
|
id: "contract",
|
||||||
size: COLUMN_WIDTH,
|
size: COLUMN_WIDTHS.contract,
|
||||||
meta: COLUMN_META,
|
meta: COLUMN_META,
|
||||||
header: () => <span className={bookingTable.headerCell}>Customer</span>,
|
header: () => <span className={bookingTable.headerCell}>Customer</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -296,7 +264,7 @@ export default function ContractRequestsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "route",
|
id: "route",
|
||||||
size: COLUMN_WIDTH,
|
size: COLUMN_WIDTHS.route,
|
||||||
meta: COLUMN_META,
|
meta: COLUMN_META,
|
||||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -311,7 +279,7 @@ export default function ContractRequestsPage() {
|
|||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
|
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
{directionLabel(c.tradeDirection)}
|
{directionLabel(c.tradeDirection)}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -319,7 +287,7 @@ export default function ContractRequestsPage() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
|
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
|
||||||
>
|
>
|
||||||
{c.freightType}
|
{humanize(c.freightType)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -328,47 +296,77 @@ export default function ContractRequestsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
size: COLUMN_WIDTH,
|
size: COLUMN_WIDTHS.status,
|
||||||
meta: COLUMN_META,
|
meta: COLUMN_META,
|
||||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<div className="py-1">
|
const c = row.original;
|
||||||
<ContractStatusBadge
|
const court = contractCourt(c.status);
|
||||||
status={row.original.status}
|
const progress = formatContractApprovalProgress(
|
||||||
isRenewal={row.original.isRenewal}
|
c.status,
|
||||||
/>
|
c.approvalSteps,
|
||||||
</div>
|
);
|
||||||
),
|
const action = getStaffRowAction(c);
|
||||||
},
|
// One dimmed line: who it's waiting on, the staff verb, and the
|
||||||
{
|
// approval chain when one exists. "Open" adds nothing — row click
|
||||||
id: "court",
|
// already opens the detail page.
|
||||||
size: COLUMN_WIDTH,
|
const pieces: ReactNode[] = [];
|
||||||
meta: COLUMN_META,
|
if (court) {
|
||||||
header: () => (
|
pieces.push(court === "customer" ? "With customer" : "With EDR");
|
||||||
<span className={bookingTable.headerCell}>Waiting on</span>
|
}
|
||||||
),
|
if (action && action.variant === "filled") {
|
||||||
cell: ({ row }) => (
|
pieces.push(
|
||||||
<div className="py-1">
|
// "View & sign" pointed at the contract-view page, not the
|
||||||
<ContractCourtBadge status={row.original.status} />
|
// detail page — keep that deep link as an inline link.
|
||||||
</div>
|
action.to(c.id).endsWith("/view") ? (
|
||||||
),
|
<button
|
||||||
},
|
type="button"
|
||||||
{
|
className="underline underline-offset-2 hover:text-primary"
|
||||||
id: "approval",
|
onClick={(e) => {
|
||||||
size: COLUMN_WIDTH,
|
e.stopPropagation();
|
||||||
meta: COLUMN_META,
|
navigate(action.to(c.id));
|
||||||
header: () => <span className={bookingTable.headerCell}>Approval</span>,
|
}}
|
||||||
cell: ({ row }) => <ContractApprovalProgressCell row={row.original} />,
|
>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
action.label
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ((c.approvalSteps ?? []).length > 0) {
|
||||||
|
pieces.push(progress.label);
|
||||||
|
if (!progress.complete && progress.detail.startsWith("Next:")) {
|
||||||
|
pieces.push(progress.detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 py-1">
|
||||||
|
<ContractStatusBadge status={c.status} isRenewal={c.isRenewal} />
|
||||||
|
{pieces.length ? (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{pieces.map((piece, i) => (
|
||||||
|
<span key={i}>
|
||||||
|
{i > 0 ? " · " : null}
|
||||||
|
{piece}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "validity",
|
id: "validity",
|
||||||
size: COLUMN_WIDTH,
|
size: COLUMN_WIDTHS.validity,
|
||||||
meta: COLUMN_META,
|
meta: COLUMN_META,
|
||||||
header: () => <span className={bookingTable.headerCell}>Validity</span>,
|
header: () => <span className={bookingTable.headerCell}>Validity</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const c = row.original;
|
const c = row.original;
|
||||||
|
const isGeneral = c.contractKind === "GENERAL";
|
||||||
return (
|
return (
|
||||||
<Stack gap={2}>
|
<Stack gap={2} align="flex-start">
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
<CalendarClock className="size-3.5" />
|
<CalendarClock className="size-3.5" />
|
||||||
{c.validUntil
|
{c.validUntil
|
||||||
@@ -382,58 +380,22 @@ export default function ContractRequestsPage() {
|
|||||||
From {formatDate(c.validFrom)}
|
From {formatDate(c.validFrom)}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
|
||||||
|
>
|
||||||
|
{isGeneral ? (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Repeat className="size-3" /> General
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"One-time"
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "kind",
|
|
||||||
size: COLUMN_WIDTH,
|
|
||||||
meta: COLUMN_META,
|
|
||||||
header: () => <span className={bookingTable.headerCell}>Kind</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const isGeneral = row.original.contractKind === "GENERAL";
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
|
|
||||||
>
|
|
||||||
{isGeneral ? (
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<Repeat className="size-3" /> General
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"One-time"
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
size: COLUMN_WIDTH,
|
|
||||||
meta: COLUMN_META,
|
|
||||||
header: () => <span className={bookingTable.headerCell}>Action</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const action = getStaffRowAction(row.original);
|
|
||||||
if (!action) return null;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="compact-sm"
|
|
||||||
radius="md"
|
|
||||||
variant={action.variant === "filled" ? "filled" : action.variant}
|
|
||||||
color="edr-green"
|
|
||||||
onClick={(e) => {
|
|
||||||
// Don't let the row-click navigation fire as well.
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate(action.to(row.original.id));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -486,22 +448,11 @@ export default function ContractRequestsPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ContractStatusTabs
|
|
||||||
active={activeTab}
|
|
||||||
onChange={(tab) => {
|
|
||||||
setActiveTab(tab);
|
|
||||||
// Status picks belong to the previous tab's option set — reset.
|
|
||||||
setStatusFilter([]);
|
|
||||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
|
||||||
}}
|
|
||||||
counts={tabCounts}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card p={0}>
|
<Card p={0}>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Box px="md" pt="md" pb="sm" w="100%">
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group justify="space-between" gap="md" wrap="wrap">
|
<Group gap="sm" wrap="wrap">
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Search reference or customer…"
|
placeholder="Search reference or customer…"
|
||||||
leftSection={<Search size={18} />}
|
leftSection={<Search size={18} />}
|
||||||
@@ -541,16 +492,11 @@ export default function ContractRequestsPage() {
|
|||||||
style={{ minWidth: 170 }}
|
style={{ minWidth: 170 }}
|
||||||
aria-label="Sort contracts"
|
aria-label="Sort contracts"
|
||||||
/>
|
/>
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{total} record{total !== 1 ? "s" : ""}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap="sm" wrap="wrap">
|
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
placeholder={
|
placeholder={
|
||||||
statusFilter.length ? undefined : "All statuses"
|
statusFilter.length ? undefined : "All statuses"
|
||||||
}
|
}
|
||||||
data={statusOptions}
|
data={STATUS_OPTIONS}
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setStatusFilter(v);
|
setStatusFilter(v);
|
||||||
@@ -562,6 +508,38 @@ export default function ContractRequestsPage() {
|
|||||||
style={{ minWidth: 220 }}
|
style={{ minWidth: 220 }}
|
||||||
aria-label="Filter by status"
|
aria-label="Filter by status"
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="All kinds"
|
||||||
|
data={CONTRACT_KIND_OPTIONS}
|
||||||
|
value={kindFilter}
|
||||||
|
onChange={(v) => {
|
||||||
|
setKindFilter(v);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
clearable
|
||||||
|
radius="lg"
|
||||||
|
style={{ minWidth: 160 }}
|
||||||
|
aria-label="Filter by contract kind"
|
||||||
|
/>
|
||||||
|
<FilterToggle
|
||||||
|
count={advancedFilterCount}
|
||||||
|
expanded={showAdvanced}
|
||||||
|
onClick={() => setShowAdvanced((v) => !v)}
|
||||||
|
/>
|
||||||
|
{activeFilterCount > 0 ? (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
radius="lg"
|
||||||
|
leftSection={<FilterX size={16} />}
|
||||||
|
onClick={clearFilters}
|
||||||
|
>
|
||||||
|
Clear filters ({activeFilterCount})
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Collapse expanded={showAdvanced}>
|
||||||
|
<Group gap="sm" wrap="wrap">
|
||||||
<Select
|
<Select
|
||||||
placeholder="All directions"
|
placeholder="All directions"
|
||||||
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
|
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
|
||||||
@@ -588,19 +566,6 @@ export default function ContractRequestsPage() {
|
|||||||
style={{ minWidth: 160 }}
|
style={{ minWidth: 160 }}
|
||||||
aria-label="Filter by freight type"
|
aria-label="Filter by freight type"
|
||||||
/>
|
/>
|
||||||
<Select
|
|
||||||
placeholder="All kinds"
|
|
||||||
data={CONTRACT_KIND_OPTIONS}
|
|
||||||
value={kindFilter}
|
|
||||||
onChange={(v) => {
|
|
||||||
setKindFilter(v);
|
|
||||||
resetPage();
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
radius="lg"
|
|
||||||
style={{ minWidth: 160 }}
|
|
||||||
aria-label="Filter by contract kind"
|
|
||||||
/>
|
|
||||||
<Select
|
<Select
|
||||||
placeholder="All currencies"
|
placeholder="All currencies"
|
||||||
data={CURRENCY_OPTIONS}
|
data={CURRENCY_OPTIONS}
|
||||||
@@ -629,18 +594,8 @@ export default function ContractRequestsPage() {
|
|||||||
style={{ minWidth: 220 }}
|
style={{ minWidth: 220 }}
|
||||||
aria-label="Created date range"
|
aria-label="Created date range"
|
||||||
/>
|
/>
|
||||||
{activeFilterCount > 0 ? (
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
radius="lg"
|
|
||||||
leftSection={<FilterX size={16} />}
|
|
||||||
onClick={clearFilters}
|
|
||||||
>
|
|
||||||
Clear filters ({activeFilterCount})
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
</Group>
|
||||||
|
</Collapse>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -672,7 +627,7 @@ export default function ContractRequestsPage() {
|
|||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
pageCount,
|
pageCount,
|
||||||
}}
|
}}
|
||||||
// table-fixed makes the per-column 120px widths stick; without
|
// table-fixed makes the per-column widths stick; without
|
||||||
// it auto-layout re-widens columns once cells wrap.
|
// it auto-layout re-widens columns once cells wrap.
|
||||||
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]"
|
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]"
|
||||||
footer={DataTableFooter}
|
footer={DataTableFooter}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatDate } from "@/lib/format";
|
||||||
import { directionLabel } from "@/lib/utils";
|
import { directionLabel } from "@/lib/utils";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@@ -51,18 +52,6 @@ const prettyStatus = (s?: string | null) =>
|
|||||||
.replace(/_/g, " ")
|
.replace(/_/g, " ")
|
||||||
.replace(/^\w/, (c) => c.toUpperCase());
|
.replace(/^\w/, (c) => c.toUpperCase());
|
||||||
|
|
||||||
function formatDate(value?: string | null): string {
|
|
||||||
if (!value) return "—";
|
|
||||||
const d = new Date(value);
|
|
||||||
return Number.isNaN(d.getTime())
|
|
||||||
? "—"
|
|
||||||
: d.toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function yardLabel(
|
function yardLabel(
|
||||||
yard?: { label?: string; code?: string; name?: string } | null,
|
yard?: { label?: string; code?: string; name?: string } | null,
|
||||||
fallback = "—",
|
fallback = "—",
|
||||||
@@ -661,9 +650,6 @@ export default function GlDjiboutiClearanceListPage() {
|
|||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Text size="sm" c="dimmed" ml="auto">
|
|
||||||
{total} record{total !== 1 ? "s" : ""}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ export default function ShipmentRequestsPage() {
|
|||||||
{row.original.contractReference}
|
{row.original.contractReference}
|
||||||
</Text>
|
</Text>
|
||||||
{row.original.customerName ? (
|
{row.original.customerName ? (
|
||||||
<Text size="xs" c="dimmed" mt={2}>
|
<Text size="xs" c="dimmed" mt={2} truncate maw={200}>
|
||||||
{row.original.customerName}
|
{row.original.customerName}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export default function CustomersPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<Group gap={8} wrap="nowrap">
|
<Group gap={8} wrap="nowrap">
|
||||||
<Text fw={600} c="edr-text" truncate>
|
<Text fw={600} c="edr-text" truncate maw={200}>
|
||||||
{c.name}
|
{c.name}
|
||||||
</Text>
|
</Text>
|
||||||
<CompanyNationalityBadge nationality={c.nationality} />
|
<CompanyNationalityBadge nationality={c.nationality} />
|
||||||
@@ -156,6 +156,9 @@ export default function CustomersPage() {
|
|||||||
TIN {c.tin}
|
TIN {c.tin}
|
||||||
{c.country ? ` · ${c.country}` : ""}
|
{c.country ? ` · ${c.country}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Box mt={4}>
|
||||||
|
<CompanyStatusBadge status={c.status} />
|
||||||
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
@@ -164,16 +167,9 @@ export default function CustomersPage() {
|
|||||||
{
|
{
|
||||||
id: "profiles",
|
id: "profiles",
|
||||||
header: "Profiles",
|
header: "Profiles",
|
||||||
cell: ({ row }) => (
|
|
||||||
<ProfileChips profiles={row.original.companyProfiles} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "status",
|
|
||||||
header: "Status",
|
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
// A draft's profiles are all `pending` by construction, so the
|
// A draft's profiles are all `pending` by construction — the
|
||||||
// "N pending" review hint would be a lie until they submit.
|
// status-colored chips would be a lie until they submit.
|
||||||
if (isOnboardingDraft(row.original)) {
|
if (isOnboardingDraft(row.original)) {
|
||||||
return (
|
return (
|
||||||
<Tooltip label="Customer is still filling in the onboarding wizard">
|
<Tooltip label="Customer is still filling in the onboarding wizard">
|
||||||
@@ -183,23 +179,7 @@ export default function CustomersPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const pending = (row.original.companyProfiles ?? []).filter(
|
return <ProfileChips profiles={row.original.companyProfiles} />;
|
||||||
(p) => p.status === "pending",
|
|
||||||
).length;
|
|
||||||
return (
|
|
||||||
<Group gap={6} wrap="nowrap">
|
|
||||||
<CompanyStatusBadge status={row.original.status} />
|
|
||||||
{pending > 0 ? (
|
|
||||||
<Tooltip
|
|
||||||
label={`${pending} profile${pending > 1 ? "s" : ""} awaiting approval`}
|
|
||||||
>
|
|
||||||
<Badge color="yellow" variant="light" size="sm" radius="sm">
|
|
||||||
{pending} pending
|
|
||||||
</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -229,6 +209,7 @@ export default function CustomersPage() {
|
|||||||
c="dimmed"
|
c="dimmed"
|
||||||
className="inline-flex items-center gap-1"
|
className="inline-flex items-center gap-1"
|
||||||
truncate
|
truncate
|
||||||
|
maw={200}
|
||||||
>
|
>
|
||||||
<Mail size={12} /> {c.email}
|
<Mail size={12} /> {c.email}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -247,16 +228,6 @@ export default function CustomersPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "approved",
|
|
||||||
header: "Approved",
|
|
||||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{row.original.approvedAt ? formatDate(row.original.approvedAt) : "—"}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -376,9 +347,6 @@ export default function CustomersPage() {
|
|||||||
}}
|
}}
|
||||||
data={SORT_OPTIONS.map((o) => ({ ...o }))}
|
data={SORT_OPTIONS.map((o) => ({ ...o }))}
|
||||||
/>
|
/>
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{total} record{total !== 1 ? "s" : ""}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||||
|
import { ActionIcon, Alert, Button, Center, Loader, Paper, SegmentedControl, Skeleton, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
|
import { OVERVIEW_DOMAINS } from "@/components/overview/overview-domains.config";
|
||||||
|
import { OverviewBillingTabPanel } from "@/components/overview/tabs/OverviewBillingTabPanel";
|
||||||
|
import { OverviewBookingsTabPanel } from "@/components/overview/tabs/OverviewBookingsTabPanel";
|
||||||
|
import { OverviewContractsTabPanel } from "@/components/overview/tabs/OverviewContractsTabPanel";
|
||||||
|
import { OverviewCustomersTabPanel } from "@/components/overview/tabs/OverviewCustomersTabPanel";
|
||||||
|
import { OverviewFleetTabPanel } from "@/components/overview/tabs/OverviewFleetTabPanel";
|
||||||
|
import { OverviewOperationsTabPanel } from "@/components/overview/tabs/OverviewOperationsTabPanel";
|
||||||
|
import { OverviewStaffTabPanel } from "@/components/overview/tabs/OverviewStaffTabPanel";
|
||||||
|
import {
|
||||||
|
useOverviewBillingTab,
|
||||||
|
useOverviewBookingsTab,
|
||||||
|
useOverviewContractsTab,
|
||||||
|
useOverviewCustomersTab,
|
||||||
|
useOverviewOperationsTab,
|
||||||
|
useOverviewStaffTab,
|
||||||
|
} from "@/hooks/useOverview";
|
||||||
|
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
|
||||||
|
|
||||||
|
const RANGE_OPTIONS = [
|
||||||
|
{ label: "7 days", value: "7d" },
|
||||||
|
{ label: "30 days", value: "30d" },
|
||||||
|
{ label: "90 days", value: "90d" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function DomainSkeleton() {
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Skeleton height={92} radius="lg" />
|
||||||
|
<Skeleton height={320} radius="lg" />
|
||||||
|
<Skeleton height={320} radius="lg" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One domain's full depth — what used to be a tab panel on the overview page
|
||||||
|
* is now its own page, reached from that domain's "View all →" link. Same
|
||||||
|
* per-domain hooks and panel components as before; only the tab-switch
|
||||||
|
* wrapper (OverviewTabContent) is gone, replaced by a route param.
|
||||||
|
*/
|
||||||
|
export default function OverviewDomainPage() {
|
||||||
|
const { domain } = useParams<{ domain: OverviewTabKey }>();
|
||||||
|
const [range, setRange] = useState<OverviewRange>("30d");
|
||||||
|
|
||||||
|
const meta = OVERVIEW_DOMAINS.find((d) => d.key === domain);
|
||||||
|
|
||||||
|
const bookings = useOverviewBookingsTab(range, domain === "bookings");
|
||||||
|
const contracts = useOverviewContractsTab(range, domain === "contracts");
|
||||||
|
const billing = useOverviewBillingTab(range, domain === "billing");
|
||||||
|
// Fleet reuses the operations dataset — same query key, so switching between
|
||||||
|
// the two pages costs no extra fetch.
|
||||||
|
const operations = useOverviewOperationsTab(
|
||||||
|
range,
|
||||||
|
domain === "operations" || domain === "fleet",
|
||||||
|
);
|
||||||
|
const customers = useOverviewCustomersTab(range, domain === "customers");
|
||||||
|
const staff = useOverviewStaffTab(range, domain === "staff");
|
||||||
|
|
||||||
|
const query =
|
||||||
|
domain === "bookings"
|
||||||
|
? bookings
|
||||||
|
: domain === "contracts"
|
||||||
|
? contracts
|
||||||
|
: domain === "billing"
|
||||||
|
? billing
|
||||||
|
: domain === "operations" || domain === "fleet"
|
||||||
|
? operations
|
||||||
|
: domain === "customers"
|
||||||
|
? customers
|
||||||
|
: staff;
|
||||||
|
|
||||||
|
const { isLoading, isError, refetch, isFetching } = query;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title={meta?.label ?? "Overview"}
|
||||||
|
subtitle={meta?.subtitle}
|
||||||
|
backTo="/dashboard/overview"
|
||||||
|
action={
|
||||||
|
<>
|
||||||
|
<SegmentedControl
|
||||||
|
value={range}
|
||||||
|
onChange={(value) => setRange(value as OverviewRange)}
|
||||||
|
data={RANGE_OPTIONS}
|
||||||
|
size="sm"
|
||||||
|
radius="lg"
|
||||||
|
color="edr-green"
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
size="lg"
|
||||||
|
radius="lg"
|
||||||
|
aria-label="Refresh"
|
||||||
|
onClick={() => void refetch()}
|
||||||
|
loading={isFetching && !isLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<DomainSkeleton />
|
||||||
|
) : isError || !query.data ? (
|
||||||
|
<Paper p="xl" radius="lg" withBorder>
|
||||||
|
<Alert icon={<AlertCircle size={16} />} color="red" title="Failed to load" variant="light">
|
||||||
|
<Stack gap="sm" align="flex-start">
|
||||||
|
<span>Could not load {meta?.label ?? "this"} metrics. Please try again.</span>
|
||||||
|
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Stack gap="md" pos="relative">
|
||||||
|
{isFetching && (
|
||||||
|
<Center style={{ position: "absolute", top: 8, right: 8, zIndex: 2 }}>
|
||||||
|
<Loader size="sm" color="edr-green" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{domain === "bookings" && bookings.data && <OverviewBookingsTabPanel data={bookings.data} />}
|
||||||
|
{domain === "contracts" && contracts.data && <OverviewContractsTabPanel data={contracts.data} />}
|
||||||
|
{domain === "billing" && billing.data && <OverviewBillingTabPanel data={billing.data} />}
|
||||||
|
{domain === "operations" && operations.data && (
|
||||||
|
<OverviewOperationsTabPanel data={operations.data} />
|
||||||
|
)}
|
||||||
|
{domain === "fleet" && operations.data && <OverviewFleetTabPanel data={operations.data} />}
|
||||||
|
{domain === "customers" && customers.data && <OverviewCustomersTabPanel data={customers.data} />}
|
||||||
|
{domain === "staff" && staff.data && <OverviewStaffTabPanel data={staff.data} />}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,141 +1,61 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import { AlertCircle } from "lucide-react";
|
||||||
AlertCircle,
|
import { Alert, Button, Grid, Skeleton, Stack, Text } from "@mantine/core";
|
||||||
Banknote,
|
|
||||||
FileSignature,
|
|
||||||
FileText,
|
|
||||||
Train,
|
|
||||||
TrainFront,
|
|
||||||
UserCheck,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Container,
|
|
||||||
Skeleton,
|
|
||||||
Stack,
|
|
||||||
Tabs,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { PageContainer } from "@/components/page";
|
||||||
import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader";
|
import { OverviewActivityHeatmap } from "@/components/overview/summary/OverviewActivityHeatmap";
|
||||||
import { OverviewTabContent } from "@/components/overview/OverviewTabContent";
|
import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard";
|
||||||
import "@/components/overview/overview.css";
|
import { OverviewHero } from "@/components/overview/summary/OverviewHero";
|
||||||
|
import { OverviewHeroKpis } from "@/components/overview/summary/OverviewHeroKpis";
|
||||||
|
import { OverviewNetworkCard } from "@/components/overview/summary/OverviewNetworkCard";
|
||||||
|
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
|
||||||
|
import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix";
|
||||||
|
import { OverviewRevenueVolumeChart } from "@/components/overview/summary/OverviewRevenueVolumeChart";
|
||||||
|
import { OverviewSankeyFlow } from "@/components/overview/summary/OverviewSankeyFlow";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import { useOverview } from "@/hooks/useOverview";
|
import { useOverview } from "@/hooks/useOverview";
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import type { OverviewRange } from "@/types/overview";
|
||||||
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
|
import "@/components/overview/summary/overview-summary.css";
|
||||||
|
|
||||||
const TAB_ITEMS: Array<{
|
const RANGE_LABEL: Record<OverviewRange, string> = { "7d": "7d", "30d": "30d", "90d": "90d" };
|
||||||
value: OverviewTabKey;
|
const RANGE_DAYS: Record<OverviewRange, number> = { "7d": 7, "30d": 30, "90d": 90 };
|
||||||
label: string;
|
|
||||||
icon: typeof FileText;
|
|
||||||
kpiKey:
|
|
||||||
| "bookings"
|
|
||||||
| "contracts"
|
|
||||||
| "billing"
|
|
||||||
| "operations"
|
|
||||||
| "customers"
|
|
||||||
| "staff";
|
|
||||||
metricKey: string;
|
|
||||||
/** Any of these keys grants the tab. */
|
|
||||||
permission: string[];
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
value: "bookings",
|
|
||||||
label: "Bookings",
|
|
||||||
icon: FileText,
|
|
||||||
kpiKey: "bookings",
|
|
||||||
metricKey: "totalActive",
|
|
||||||
permission: [FREIGHT_PERMS.bookings.view],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "contracts",
|
|
||||||
label: "Contracts",
|
|
||||||
icon: FileSignature,
|
|
||||||
kpiKey: "contracts",
|
|
||||||
metricKey: "totalActive",
|
|
||||||
permission: [FREIGHT_PERMS.contracts.view],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "billing",
|
|
||||||
label: "Billing",
|
|
||||||
icon: Banknote,
|
|
||||||
kpiKey: "billing",
|
|
||||||
metricKey: "successfulPaymentsMtd",
|
|
||||||
permission: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.payments.view],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "operations",
|
|
||||||
label: "Operations",
|
|
||||||
icon: Train,
|
|
||||||
kpiKey: "operations",
|
|
||||||
metricKey: "trainsActive",
|
|
||||||
permission: [
|
|
||||||
FREIGHT_PERMS.trainScheduling.view,
|
|
||||||
FREIGHT_PERMS.warehouseInventory.view,
|
|
||||||
FREIGHT_PERMS.firstMile.view,
|
|
||||||
FREIGHT_PERMS.lastMile.view,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "fleet",
|
|
||||||
label: "Fleet",
|
|
||||||
icon: TrainFront,
|
|
||||||
kpiKey: "operations",
|
|
||||||
metricKey: "wagonsAvailable",
|
|
||||||
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.trainScheduling.view],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "customers",
|
|
||||||
label: "Customers",
|
|
||||||
icon: Users,
|
|
||||||
kpiKey: "customers",
|
|
||||||
metricKey: "totalCustomers",
|
|
||||||
permission: [FREIGHT_PERMS.customers.view],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "staff",
|
|
||||||
label: "Staff",
|
|
||||||
icon: UserCheck,
|
|
||||||
kpiKey: "staff",
|
|
||||||
metricKey: "activeEmployees",
|
|
||||||
permission: [
|
|
||||||
FREIGHT_PERMS.admin,
|
|
||||||
FREIGHT_PERMS.staff.roles.view,
|
|
||||||
FREIGHT_PERMS.staff.employeeRegistration.view,
|
|
||||||
FREIGHT_PERMS.staff.roleAssignment.view,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function HeaderSkeleton() {
|
/** Uppercase section eyebrow — matches the WarehouseDashboardPage convention. */
|
||||||
|
function SectionTitle({ children }: { children: string }) {
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Text fw={700} fz="sm" tt="uppercase" c="edr-muted" style={{ letterSpacing: 0.4 }}>
|
||||||
<Skeleton height={48} radius="md" />
|
{children}
|
||||||
<Skeleton height={52} radius="lg" />
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One page band: eyebrow + content, with a staggered entrance by index. */
|
||||||
|
function Band({ index, title, children }: { index: number; title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Stack gap="sm" className="ov-band" style={{ animationDelay: `${index * 70}ms` }}>
|
||||||
|
<SectionTitle>{title}</SectionTitle>
|
||||||
|
{children}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewSkeleton() {
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Skeleton height={92} radius="lg" />
|
||||||
|
<Skeleton height={340} radius="lg" />
|
||||||
|
<Skeleton height={340} radius="lg" />
|
||||||
|
<Skeleton height={340} radius="lg" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const OverviewPage = () => {
|
const OverviewPage = () => {
|
||||||
const [range, setRange] = useState<OverviewRange>("30d");
|
const [range, setRange] = useState<OverviewRange>("30d");
|
||||||
const [activeTab, setActiveTab] = useState<OverviewTabKey>("bookings");
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user } = useAuth();
|
const { data, isLoading, isError, error, refetch, isFetching } = useOverview(range);
|
||||||
const { data: summary, isLoading, isError, error, refetch, isFetching } = useOverview(range);
|
|
||||||
|
|
||||||
// Permission-scoped view: only tabs the user may see; a restricted role
|
|
||||||
// (e.g. operations) gets a summary 403 — that is not a connection problem.
|
|
||||||
const visibleTabs = TAB_ITEMS.filter((tab) =>
|
|
||||||
tab.permission.some((key) => hasPermission(user, key)),
|
|
||||||
);
|
|
||||||
const currentTab = visibleTabs.some((t) => t.value === activeTab)
|
|
||||||
? activeTab
|
|
||||||
: visibleTabs[0]?.value;
|
|
||||||
const accessDenied =
|
const accessDenied =
|
||||||
(error as { response?: { status?: number } } | null)?.response?.status === 403;
|
(error as { response?: { status?: number } } | null)?.response?.status === 403;
|
||||||
|
|
||||||
@@ -144,97 +64,113 @@ const OverviewPage = () => {
|
|||||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.OVERVIEW.ROOT });
|
void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.OVERVIEW.ROOT });
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTabBadge = (tab: (typeof TAB_ITEMS)[number]) => {
|
|
||||||
if (!summary?.kpis) return 0;
|
|
||||||
const group = summary.kpis[tab.kpiKey] as unknown as Record<string, number>;
|
|
||||||
return group[tab.metricKey] ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container fluid px="md" py="md">
|
<PageContainer>
|
||||||
<Stack gap="lg">
|
{/* Gradient greeting hero; the KPI strip overlaps its bottom edge. */}
|
||||||
{isLoading && !summary ? (
|
<div className="ov-band">
|
||||||
<HeaderSkeleton />
|
<OverviewHero
|
||||||
) : (
|
range={range}
|
||||||
<OverviewPageHeader
|
onRangeChange={setRange}
|
||||||
range={range}
|
generatedAt={data?.generatedAt}
|
||||||
onRangeChange={setRange}
|
onRefresh={handleRefresh}
|
||||||
generatedAt={summary?.generatedAt}
|
isRefreshing={isFetching && !isLoading}
|
||||||
onRefresh={handleRefresh}
|
/>
|
||||||
isRefreshing={isFetching && !isLoading}
|
{data ? (
|
||||||
/>
|
<div style={{ marginTop: -52, paddingInline: 20, position: "relative" }}>
|
||||||
)}
|
<OverviewHeroKpis
|
||||||
|
kpis={data.kpis}
|
||||||
|
current={data.current}
|
||||||
|
previous={data.previous}
|
||||||
|
rangeLabel={RANGE_LABEL[range]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{isError && !accessDenied && (
|
{isError && !accessDenied && (
|
||||||
<Alert
|
<Alert
|
||||||
icon={<AlertCircle size={16} />}
|
icon={<AlertCircle size={16} />}
|
||||||
color="red"
|
color="red"
|
||||||
title="Unable to load dashboard summary"
|
title="Unable to load dashboard summary"
|
||||||
variant="light"
|
variant="light"
|
||||||
>
|
mt="lg"
|
||||||
<Stack gap="sm" align="flex-start">
|
>
|
||||||
<span>Check your connection and try again.</span>
|
<Stack gap="sm" align="flex-start">
|
||||||
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
|
<span>Check your connection and try again.</span>
|
||||||
Retry
|
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
|
||||||
</Button>
|
Retry
|
||||||
</Stack>
|
</Button>
|
||||||
</Alert>
|
</Stack>
|
||||||
)}
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{visibleTabs.length === 0 && !isLoading && !isError && (
|
{accessDenied && (
|
||||||
<Alert color="gray" variant="light" title="No dashboard sections available">
|
<Alert color="gray" variant="light" title="No dashboard access" mt="lg">
|
||||||
Your role has no access to any overview section.
|
Your role has no access to the overview.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{visibleTabs.length > 0 && (
|
{isLoading && !data ? (
|
||||||
<Tabs
|
<Stack mt="lg">
|
||||||
value={currentTab}
|
<OverviewSkeleton />
|
||||||
onChange={(value) =>
|
</Stack>
|
||||||
setActiveTab((value as OverviewTabKey) ?? visibleTabs[0].value)
|
) : data ? (
|
||||||
}
|
<Stack gap="xl" mt="xl">
|
||||||
variant="pills"
|
{/* Band 1 — revenue & volume: growing, making money, pacing vs last period. */}
|
||||||
color="edr-green"
|
<Band index={1} title="Revenue & volume">
|
||||||
keepMounted={false}
|
<Grid gap="md">
|
||||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
>
|
<OverviewRevenueVolumeChart
|
||||||
<Tabs.List>
|
bookingTrend={data.bookingTrend}
|
||||||
{visibleTabs.map((tab) => {
|
paymentTrend={data.paymentTrend}
|
||||||
const Icon = tab.icon;
|
previousPaymentTrend={data.previousPaymentTrend}
|
||||||
const isActive = currentTab === tab.value;
|
rangeDays={RANGE_DAYS[range]}
|
||||||
return (
|
/>
|
||||||
<Tabs.Tab
|
</Grid.Col>
|
||||||
key={tab.value}
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
value={tab.value}
|
<OverviewRevenueMix
|
||||||
leftSection={<Icon size={17} />}
|
byDirection={data.revenueByDirection}
|
||||||
rightSection={
|
byFreightType={data.revenueByFreightType}
|
||||||
summary ? (
|
/>
|
||||||
<Badge
|
</Grid.Col>
|
||||||
size="sm"
|
</Grid>
|
||||||
radius="sm"
|
</Band>
|
||||||
variant={isActive ? "white" : "light"}
|
|
||||||
color={isActive ? "edr-green" : "gray"}
|
|
||||||
>
|
|
||||||
{getTabBadge(tab)}
|
|
||||||
</Badge>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Tabs.Tab>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
{visibleTabs.map((tab) => (
|
{/* Band 2 — where the money runs, and what's waiting on someone. */}
|
||||||
<Tabs.Panel key={tab.value} value={tab.value} pt="lg">
|
<Band index={2} title="Money flow & attention">
|
||||||
<OverviewTabContent tab={tab.value} range={range} />
|
<Grid gap="md">
|
||||||
</Tabs.Panel>
|
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||||
))}
|
<OverviewSankeyFlow flows={data.revenueFlows} />
|
||||||
</Tabs>
|
</Grid.Col>
|
||||||
)}
|
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||||
</Stack>
|
<OverviewAttentionCard
|
||||||
</Container>
|
bookings={data.kpis.bookings}
|
||||||
|
contracts={data.kpis.contracts}
|
||||||
|
billing={data.kpis.billing}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Band>
|
||||||
|
|
||||||
|
{/* Band 3 — the network now, and when demand arrives. */}
|
||||||
|
<Band index={3} title="Network & rhythm">
|
||||||
|
<Grid gap="md">
|
||||||
|
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||||
|
<OverviewNetworkCard kpis={data.kpis.operations} />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||||
|
<OverviewActivityHeatmap cells={data.bookingHeatmap} />
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Band>
|
||||||
|
|
||||||
|
{/* Band 4 — the booking pipeline, full width so every stage bar has room. */}
|
||||||
|
<Band index={4} title="Pipeline">
|
||||||
|
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
|
||||||
|
</Band>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -43,17 +43,13 @@ import {
|
|||||||
fetchViewableFile,
|
fetchViewableFile,
|
||||||
} from "@/services/files.service";
|
} from "@/services/files.service";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
|
||||||
const fmtDate = (iso?: string | null) => {
|
const fmtDate = (iso?: string | null) => {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString();
|
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString();
|
||||||
};
|
};
|
||||||
const fmtDateTime = (iso?: string | null) => {
|
|
||||||
if (!iso) return "—";
|
|
||||||
const d = new Date(iso);
|
|
||||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
|
|
||||||
};
|
|
||||||
const meta = (e: FleetHistoryEvent, k: string) => {
|
const meta = (e: FleetHistoryEvent, k: string) => {
|
||||||
const v = e.metadata?.[k];
|
const v = e.metadata?.[k];
|
||||||
return typeof v === "string" && v ? v : null;
|
return typeof v === "string" && v ? v : null;
|
||||||
@@ -400,7 +396,7 @@ const VehiclesTab = ({ driverId }: { driverId: string }) => {
|
|||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
<Table.Tr key={r.id}>
|
<Table.Tr key={r.id}>
|
||||||
<Table.Td>{r.plate}</Table.Td>
|
<Table.Td>{r.plate}</Table.Td>
|
||||||
<Table.Td>{fmtDateTime(r.at)}</Table.Td>
|
<Table.Td>{formatDateTime(r.at)}</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
@@ -425,7 +421,7 @@ const HistoryTab = ({ driverId }: { driverId: string }) => {
|
|||||||
.join(" · ")}
|
.join(" · ")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text size="xs" c="dimmed" mt={2}>{fmtDateTime(e.createdAt)}</Text>
|
<Text size="xs" c="dimmed" mt={2}>{formatDateTime(e.createdAt)}</Text>
|
||||||
</Timeline.Item>
|
</Timeline.Item>
|
||||||
))}
|
))}
|
||||||
</Timeline>
|
</Timeline>
|
||||||
@@ -471,7 +467,7 @@ const TripsTab = ({ driverId }: { driverId: string }) => {
|
|||||||
<Table.Td>{t.booking}</Table.Td>
|
<Table.Td>{t.booking}</Table.Td>
|
||||||
<Table.Td>{t.vehicle}</Table.Td>
|
<Table.Td>{t.vehicle}</Table.Td>
|
||||||
<Table.Td>{t.status}</Table.Td>
|
<Table.Td>{t.status}</Table.Td>
|
||||||
<Table.Td>{fmtDateTime(t.at)}</Table.Td>
|
<Table.Td>{formatDateTime(t.at)}</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
type GpsDevice,
|
type GpsDevice,
|
||||||
} from "@/services/gps-tracking.service";
|
} from "@/services/gps-tracking.service";
|
||||||
import { freightBrand } from "@/theme/freight-brand";
|
import { freightBrand } from "@/theme/freight-brand";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
|
||||||
// Maps JavaScript API keys are public client-side keys — lock them down by
|
// Maps JavaScript API keys are public client-side keys — lock them down by
|
||||||
// HTTP-referrer in the Google Cloud console. No fallback: a hardcoded default
|
// HTTP-referrer in the Google Cloud console. No fallback: a hardcoded default
|
||||||
@@ -51,11 +52,6 @@ const deviceLabel = (d: GpsDevice) =>
|
|||||||
? [d.vehicle.code, d.vehicle.plateNumber].filter(Boolean).join(" · ")
|
? [d.vehicle.code, d.vehicle.plateNumber].filter(Boolean).join(" · ")
|
||||||
: d.name || d.imei;
|
: d.name || d.imei;
|
||||||
|
|
||||||
const fmtTime = (iso?: string | null) => {
|
|
||||||
if (!iso) return "—";
|
|
||||||
const d = new Date(iso);
|
|
||||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const StatBox = ({ label, value }: { label: string; value: string }) => (
|
const StatBox = ({ label, value }: { label: string; value: string }) => (
|
||||||
<Box p="sm" style={{ backgroundColor: "#f8f9fa", borderRadius: 8 }}>
|
<Box p="sm" style={{ backgroundColor: "#f8f9fa", borderRadius: 8 }}>
|
||||||
@@ -530,7 +526,7 @@ export function TrackingPage() {
|
|||||||
Last fix
|
Last fix
|
||||||
</Text>
|
</Text>
|
||||||
<Text fw={500} size="sm">
|
<Text fw={500} size="sm">
|
||||||
{fmtTime(selected.lastFixAt)}
|
{formatDateTime(selected.lastFixAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
{selected.vehicleId && (
|
{selected.vehicleId && (
|
||||||
@@ -580,7 +576,7 @@ export function TrackingPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{toNum(d.lastSpeed) ?? 0} km/h ·{" "}
|
{toNum(d.lastSpeed) ?? 0} km/h ·{" "}
|
||||||
{fmtTime(d.lastFixAt)}
|
{formatDateTime(d.lastFixAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
} from "@/services/vehicles.service";
|
} from "@/services/vehicles.service";
|
||||||
import { driversService } from "@/services/drivers.service";
|
import { driversService } from "@/services/drivers.service";
|
||||||
import { fleetHistoryService } from "@/services/fleet-history.service";
|
import { fleetHistoryService } from "@/services/fleet-history.service";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
|
||||||
interface MaintenanceCost {
|
interface MaintenanceCost {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -77,11 +78,6 @@ const fmtDate = (iso?: string | null) => {
|
|||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString();
|
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString();
|
||||||
};
|
};
|
||||||
const fmtDateTime = (iso?: string | null) => {
|
|
||||||
if (!iso) return "—";
|
|
||||||
const d = new Date(iso);
|
|
||||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
|
|
||||||
};
|
|
||||||
const money = (n?: number | null) =>
|
const money = (n?: number | null) =>
|
||||||
n == null ? "—" : `ETB ${Number(n).toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
|
n == null ? "—" : `ETB ${Number(n).toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
|
||||||
|
|
||||||
@@ -359,7 +355,7 @@ const DriverTab = ({
|
|||||||
{drivers.map((d) => (
|
{drivers.map((d) => (
|
||||||
<Table.Tr key={d.id}>
|
<Table.Tr key={d.id}>
|
||||||
<Table.Td>{d.name}</Table.Td>
|
<Table.Td>{d.name}</Table.Td>
|
||||||
<Table.Td>{fmtDateTime(d.at)}</Table.Td>
|
<Table.Td>{formatDateTime(d.at)}</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
@@ -388,7 +384,7 @@ const HistoryTab = ({ vehicleId }: { vehicleId: string }) => {
|
|||||||
.join(" · ")}
|
.join(" · ")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text size="xs" c="dimmed" mt={2}>{fmtDateTime(e.createdAt)}</Text>
|
<Text size="xs" c="dimmed" mt={2}>{formatDateTime(e.createdAt)}</Text>
|
||||||
</Timeline.Item>
|
</Timeline.Item>
|
||||||
))}
|
))}
|
||||||
</Timeline>
|
</Timeline>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default function InvoicesPanel() {
|
|||||||
id: "billedTo",
|
id: "billedTo",
|
||||||
header: "Billed to",
|
header: "Billed to",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Text size="sm" c="edr-text">
|
<Text size="sm" c="edr-text" truncate maw={200}>
|
||||||
{row.original.company?.name ?? "—"}
|
{row.original.company?.name ?? "—"}
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
@@ -170,9 +170,6 @@ export default function InvoicesPanel() {
|
|||||||
{ label: "Overdue", value: "OVERDUE" },
|
{ label: "Overdue", value: "OVERDUE" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{total} record{total !== 1 ? "s" : ""}
|
|
||||||
</Text>
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export default function UsdPaymentsPanel() {
|
|||||||
id: "billedTo",
|
id: "billedTo",
|
||||||
header: "Customer",
|
header: "Customer",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Text size="sm" c="edr-text">
|
<Text size="sm" c="edr-text" truncate maw={200}>
|
||||||
{row.original.company?.name ?? "—"}
|
{row.original.company?.name ?? "—"}
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
@@ -304,9 +304,6 @@ export default function UsdPaymentsPanel() {
|
|||||||
{ label: "Overdue", value: "OVERDUE" },
|
{ label: "Overdue", value: "OVERDUE" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{total} record{total !== 1 ? "s" : ""}
|
|
||||||
</Text>
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { warehouseService } from "@/services/warehouse.service";
|
|||||||
import type { LastMileRecord } from "@/services/last-mile.service";
|
import type { LastMileRecord } from "@/services/last-mile.service";
|
||||||
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
||||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
|
||||||
interface EdrTruckExitPapersModalProps {
|
interface EdrTruckExitPapersModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
@@ -14,8 +15,6 @@ interface EdrTruckExitPapersModalProps {
|
|||||||
record: LastMileRecord | null;
|
record: LastMileRecord | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmt = (value?: string | null) =>
|
|
||||||
value ? new Date(value).toLocaleString() : "—";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-truck exit papers for an EDR last-mile delivery. Each assigned truck has
|
* Per-truck exit papers for an EDR last-mile delivery. Each assigned truck has
|
||||||
@@ -87,11 +86,11 @@ export function EdrTruckExitPapersModal({ opened, onClose, record }: EdrTruckExi
|
|||||||
<Text size="sm">{load}</Text>
|
<Text size="sm">{load}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm">{fmt(t.arrivedAt)}</Text>
|
<Text size="sm">{formatDateTime(t.arrivedAt)}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{t.departedAt ? (
|
{t.departedAt ? (
|
||||||
<Text size="sm">{fmt(t.departedAt)}</Text>
|
<Text size="sm">{formatDateTime(t.departedAt)}</Text>
|
||||||
) : (
|
) : (
|
||||||
<Badge size="sm" variant="light" color="gray">
|
<Badge size="sm" variant="light" color="gray">
|
||||||
Still on site
|
Still on site
|
||||||
|
|||||||
@@ -1139,7 +1139,11 @@ const FirstMilePage = () => {
|
|||||||
id: "customer",
|
id: "customer",
|
||||||
header: "Customer",
|
header: "Customer",
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => customerName(row.original),
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" truncate maw={200}>
|
||||||
|
{customerName(row.original)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "postPayment",
|
id: "postPayment",
|
||||||
@@ -1189,7 +1193,7 @@ const FirstMilePage = () => {
|
|||||||
id: "exactKm",
|
id: "exactKm",
|
||||||
header: "Actual Distance (KM)",
|
header: "Actual Distance (KM)",
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed">—</Text>,
|
cell: ({ row }) => row.original.exactKm != null ? `${row.original.exactKm} km` : <Text c="dimmed">—</Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "invoice",
|
id: "invoice",
|
||||||
|
|||||||
@@ -1296,7 +1296,11 @@ const LastMilePage = () => {
|
|||||||
id: "customer",
|
id: "customer",
|
||||||
header: "Customer",
|
header: "Customer",
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => customerName(row.original),
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" truncate maw={200}>
|
||||||
|
{customerName(row.original)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "postPayment",
|
id: "postPayment",
|
||||||
@@ -1346,7 +1350,7 @@ const LastMilePage = () => {
|
|||||||
id: "exactKm",
|
id: "exactKm",
|
||||||
header: "Actual Distance (KM)",
|
header: "Actual Distance (KM)",
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed">—</Text>,
|
cell: ({ row }) => row.original.exactKm != null ? `${row.original.exactKm} km` : <Text c="dimmed">—</Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "invoice",
|
id: "invoice",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +25,7 @@ import { useMemo, useState } from "react";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { KpiStrip } from "@/components/page";
|
import { KpiStrip } from "@/components/page";
|
||||||
|
import { formatDate, formatMoney } from "@/lib/format";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
|
import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
|
||||||
import {
|
import {
|
||||||
@@ -80,24 +80,6 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
refunded: "indigo",
|
refunded: "indigo",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatAmount(amount: number, currency: string): string {
|
|
||||||
return `${currency} ${Number(amount).toLocaleString(undefined, {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
})}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string | null): string {
|
|
||||||
if (!iso) return "—";
|
|
||||||
const d = new Date(iso);
|
|
||||||
return Number.isNaN(d.getTime())
|
|
||||||
? "—"
|
|
||||||
: d.toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableHeader =
|
const tableHeader =
|
||||||
"text-xs font-semibold uppercase tracking-wide text-muted-foreground";
|
"text-xs font-semibold uppercase tracking-wide text-muted-foreground";
|
||||||
|
|
||||||
@@ -166,7 +148,7 @@ export default function PaymentsPanel() {
|
|||||||
header: () => <span className={tableHeader}>Amount</span>,
|
header: () => <span className={tableHeader}>Amount</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
|
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
|
||||||
{formatAmount(row.original.amount, row.original.currency)}
|
{formatMoney(row.original.amount, row.original.currency, 2)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -327,9 +309,6 @@ export default function PaymentsPanel() {
|
|||||||
}}
|
}}
|
||||||
style={{ minWidth: 180 }}
|
style={{ minWidth: 180 }}
|
||||||
/>
|
/>
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{total} record{total !== 1 ? "s" : ""}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
useSetExchangeFallbackRate,
|
useSetExchangeFallbackRate,
|
||||||
} from "@/hooks/useExchangeSettings";
|
} from "@/hooks/useExchangeSettings";
|
||||||
import type { ExchangeRateSource } from "@/services/exchangeSettings.service";
|
import type { ExchangeRateSource } from "@/services/exchangeSettings.service";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
|
||||||
/** Feed health, phrased for an operator rather than a developer. */
|
/** Feed health, phrased for an operator rather than a developer. */
|
||||||
function feedLabel(source: ExchangeRateSource | null): {
|
function feedLabel(source: ExchangeRateSource | null): {
|
||||||
@@ -35,7 +36,7 @@ function feedLabel(source: ExchangeRateSource | null): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (value: string | null) =>
|
const formatTime = (value: string | null) =>
|
||||||
value ? new Date(value).toLocaleString() : "never";
|
value ? formatDateTime(value) : "never";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* USD→ETB fallback used when the CBE exchange-rate endpoint is unreachable.
|
* USD→ETB fallback used when the CBE exchange-rate endpoint is unreachable.
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import type {
|
|||||||
EmptyContainerReturn,
|
EmptyContainerReturn,
|
||||||
EmptyContainerReturnStatus,
|
EmptyContainerReturnStatus,
|
||||||
} from "@/types/importOperations";
|
} from "@/types/importOperations";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
|
||||||
type ReturnType = "all" | "edr" | "customer";
|
type ReturnType = "all" | "edr" | "customer";
|
||||||
|
|
||||||
@@ -635,7 +636,7 @@ export default function ContainerReturnsPage() {
|
|||||||
{(historyRow.statusHistory ?? []).map((entry: any, idx: number) => (
|
{(historyRow.statusHistory ?? []).map((entry: any, idx: number) => (
|
||||||
<Group key={idx} justify="space-between">
|
<Group key={idx} justify="space-between">
|
||||||
<Badge size="sm">{RETURN_STATUS_LABEL[entry.status as EmptyContainerReturnStatus] ?? entry.status}</Badge>
|
<Badge size="sm">{RETURN_STATUS_LABEL[entry.status as EmptyContainerReturnStatus] ?? entry.status}</Badge>
|
||||||
<Text size="sm" c="dimmed">{new Date(entry.changedAt).toLocaleString()}</Text>
|
<Text size="sm" c="dimmed">{formatDateTime(entry.changedAt)}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
{!(historyRow.statusHistory ?? []).length && (
|
{!(historyRow.statusHistory ?? []).length && (
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
toReleaseInventoryItem,
|
toReleaseInventoryItem,
|
||||||
} from "@/components/warehouses/options";
|
} from "@/components/warehouses/options";
|
||||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
import { useListControls } from "@/hooks/useListControls";
|
import { useListControls } from "@/hooks/useListControls";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
@@ -79,8 +80,6 @@ const TRUCK_COLUMNS = [
|
|||||||
const money = (amount: number, currency: string) =>
|
const money = (amount: number, currency: string) =>
|
||||||
`${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`;
|
`${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`;
|
||||||
|
|
||||||
const formatTime = (iso: string | null | undefined) =>
|
|
||||||
iso ? new Date(iso).toLocaleString() : "—";
|
|
||||||
|
|
||||||
export interface BookingGroup {
|
export interface BookingGroup {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
@@ -376,10 +375,10 @@ export function TruckRows({ group }: { group: BookingGroup }) {
|
|||||||
<Text size="sm">{t.containers.length ? t.containers.join(", ") : "Bulk"}</Text>
|
<Text size="sm">{t.containers.length ? t.containers.join(", ") : "Bulk"}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="xs">{formatTime(t.arrivedAt)}</Text>
|
<Text size="xs">{formatDateTime(t.arrivedAt)}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="xs">{formatTime(t.departedAt)}</Text>
|
<Text size="xs">{formatDateTime(t.departedAt)}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{formatNumber(t.weight)}</Table.Td>
|
<Table.Td>{formatNumber(t.weight)}</Table.Td>
|
||||||
<Table.Td>{money(t.demurrage, feeCurrency)}</Table.Td>
|
<Table.Td>{money(t.demurrage, feeCurrency)}</Table.Td>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
useInterchangeDocuments,
|
useInterchangeDocuments,
|
||||||
} from '@/hooks/useInterchangeDocuments';
|
} from '@/hooks/useInterchangeDocuments';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { humanize } from '@/lib/format';
|
||||||
import { interchangeDocumentsService } from '@/services/interchange-documents.service';
|
import { interchangeDocumentsService } from '@/services/interchange-documents.service';
|
||||||
import type { InterchangeDocument, InterchangeDocumentStatus } from '@/types/interchangeDocument';
|
import type { InterchangeDocument, InterchangeDocumentStatus } from '@/types/interchangeDocument';
|
||||||
|
|
||||||
@@ -336,7 +337,7 @@ export default function InterchangeDocumentsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ id: 'direction', header: 'Direction', cell: ({ row }) => row.original.direction },
|
{ id: 'direction', header: 'Direction', cell: ({ row }) => humanize(row.original.direction) },
|
||||||
{ id: 'train', header: 'Train No', cell: ({ row }) => row.original.trainNo ?? '-' },
|
{ id: 'train', header: 'Train No', cell: ({ row }) => row.original.trainNo ?? '-' },
|
||||||
{ id: 'handoverLocation', header: 'Handover Location', cell: ({ row }) => row.original.handoverLocation },
|
{ id: 'handoverLocation', header: 'Handover Location', cell: ({ row }) => row.original.handoverLocation },
|
||||||
{ id: 'handoverFrom', header: 'Handover From', cell: ({ row }) => row.original.handoverFrom },
|
{ id: 'handoverFrom', header: 'Handover From', cell: ({ row }) => row.original.handoverFrom },
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
|||||||
import { useListControls } from "@/hooks/useListControls";
|
import { useListControls } from "@/hooks/useListControls";
|
||||||
import { useTrucksOnSite } from "@/hooks/useWarehouses";
|
import { useTrucksOnSite } from "@/hooks/useWarehouses";
|
||||||
import type { TruckOnSite } from "@/types/warehouse";
|
import type { TruckOnSite } from "@/types/warehouse";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every truck inside the yard right now, across all bookings.
|
* Every truck inside the yard right now, across all bookings.
|
||||||
@@ -122,7 +123,7 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
|
|||||||
</Text>
|
</Text>
|
||||||
) : isLongDwell(row.arrivedAt) ? (
|
) : isLongDwell(row.arrivedAt) ? (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={`On site over ${LONG_DWELL_HOURS}h — arrived ${new Date(row.arrivedAt).toLocaleString()}`}
|
label={`On site over ${LONG_DWELL_HOURS}h — arrived ${formatDateTime(row.arrivedAt)}`}
|
||||||
withArrow
|
withArrow
|
||||||
>
|
>
|
||||||
<Text size="sm" c="red" fw={600}>
|
<Text size="sm" c="red" fw={600}>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { buildWarehouseExitPaperPdf } from '@/components/warehouses/warehousePdf
|
|||||||
import { extractErrorMessage } from '@/components/warehouses/options';
|
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||||
import { useAuth } from '@/auth/useAuth';
|
import { useAuth } from '@/auth/useAuth';
|
||||||
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
|
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
|
||||||
|
import { formatMoney, humanize } from '@/lib/format';
|
||||||
|
|
||||||
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||||
DRAFT: 'gray',
|
DRAFT: 'gray',
|
||||||
@@ -50,7 +51,7 @@ const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
|||||||
CANCELLED: 'gray',
|
CANCELLED: 'gray',
|
||||||
};
|
};
|
||||||
|
|
||||||
const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c === 'ETB' ? 'Birr (ETB)' : c}`;
|
const fmt = (n: number, c: string) => formatMoney(n, c, 2);
|
||||||
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
|
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
|
||||||
|
|
||||||
export default function WarehouseInvoicesPage() {
|
export default function WarehouseInvoicesPage() {
|
||||||
@@ -79,7 +80,7 @@ export default function WarehouseInvoicesPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ id: 'type', header: 'Type', cell: ({ row }) => row.original.invoiceType.replace(/_/g, ' ') },
|
{ id: 'type', header: 'Type', cell: ({ row }) => humanize(row.original.invoiceType) },
|
||||||
{ id: 'total', header: 'Total', cell: ({ row }) => fmt(row.original.totalAmount, row.original.currency) },
|
{ id: 'total', header: 'Total', cell: ({ row }) => fmt(row.original.totalAmount, row.original.currency) },
|
||||||
{ id: 'paid', header: 'Paid', cell: ({ row }) => fmt(row.original.paidAmount, row.original.currency) },
|
{ id: 'paid', header: 'Paid', cell: ({ row }) => fmt(row.original.paidAmount, row.original.currency) },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
type FeeRuleBasis,
|
type FeeRuleBasis,
|
||||||
type FeeRuleType,
|
type FeeRuleType,
|
||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
|
import { humanize } from '@/lib/format';
|
||||||
|
|
||||||
const RULE_TYPE_COLOR: Record<FeeRuleType, string> = {
|
const RULE_TYPE_COLOR: Record<FeeRuleType, string> = {
|
||||||
STORAGE_FEE: 'teal',
|
STORAGE_FEE: 'teal',
|
||||||
@@ -218,8 +219,8 @@ function AllocationRules() {
|
|||||||
const allocationColumns: ColumnDef<AllocationRule>[] = [
|
const allocationColumns: ColumnDef<AllocationRule>[] = [
|
||||||
{ id: 'priority', header: 'Priority', cell: ({ row }) => row.original.priority },
|
{ id: 'priority', header: 'Priority', cell: ({ row }) => row.original.priority },
|
||||||
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
||||||
{ id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? dash },
|
{ id: 'freight', header: 'Freight', cell: ({ row }) => (row.original.freightType ? humanize(row.original.freightType) : dash) },
|
||||||
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? dash },
|
{ id: 'trade', header: 'Trade', cell: ({ row }) => (row.original.tradeDirection ? humanize(row.original.tradeDirection) : dash) },
|
||||||
{ id: 'cargoCode', header: 'Cargo code', cell: ({ row }) => row.original.cargoTypeCode ?? dash },
|
{ id: 'cargoCode', header: 'Cargo code', cell: ({ row }) => row.original.cargoTypeCode ?? dash },
|
||||||
{
|
{
|
||||||
id: 'targetYard',
|
id: 'targetYard',
|
||||||
@@ -615,10 +616,10 @@ function FeeRules() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
||||||
{ id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? dash },
|
{ id: 'freight', header: 'Freight', cell: ({ row }) => (row.original.freightType ? humanize(row.original.freightType) : dash) },
|
||||||
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? dash },
|
{ id: 'trade', header: 'Trade', cell: ({ row }) => (row.original.tradeDirection ? humanize(row.original.tradeDirection) : dash) },
|
||||||
{ id: 'cargo', header: 'Cargo', cell: ({ row }) => row.original.cargoTypeCode ?? dash },
|
{ id: 'cargo', header: 'Cargo', cell: ({ row }) => row.original.cargoTypeCode ?? dash },
|
||||||
{ id: 'container', header: 'Container', cell: ({ row }) => row.original.containerType ?? dash },
|
{ id: 'container', header: 'Container', cell: ({ row }) => (row.original.containerType ? humanize(row.original.containerType) : dash) },
|
||||||
{
|
{
|
||||||
id: 'scope',
|
id: 'scope',
|
||||||
header: 'Location scope',
|
header: 'Location scope',
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export type {
|
|||||||
IOverviewTrendPoint,
|
IOverviewTrendPoint,
|
||||||
IOverviewDirectionTrendPoint,
|
IOverviewDirectionTrendPoint,
|
||||||
IOverviewTonnagePoint,
|
IOverviewTonnagePoint,
|
||||||
|
IOverviewPeriodTotals,
|
||||||
|
IOverviewRevenueSlice,
|
||||||
|
IOverviewRevenueFlow,
|
||||||
|
IOverviewHeatmapCell,
|
||||||
IOverviewStatusCount,
|
IOverviewStatusCount,
|
||||||
IOverviewPipelineCount,
|
IOverviewPipelineCount,
|
||||||
IOverviewPaymentTrendPoint,
|
IOverviewPaymentTrendPoint,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface IOverviewBookingKpis {
|
|||||||
export interface IOverviewOperationsKpis {
|
export interface IOverviewOperationsKpis {
|
||||||
trainsActive: number;
|
trainsActive: number;
|
||||||
wagonsAvailable: number;
|
wagonsAvailable: number;
|
||||||
|
wagonsTotal: number;
|
||||||
containersInTransit: number;
|
containersInTransit: number;
|
||||||
cargoesLoaded: number;
|
cargoesLoaded: number;
|
||||||
schedulesUpcoming: number;
|
schedulesUpcoming: number;
|
||||||
@@ -99,13 +100,60 @@ export interface IOverviewRecentContract {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bookings/revenue/tonnage totals for one range-wide window. */
|
||||||
|
export interface IOverviewPeriodTotals {
|
||||||
|
bookingsCreated: number;
|
||||||
|
revenueEtb: number;
|
||||||
|
revenueUsd: number;
|
||||||
|
tons: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One label's revenue split, e.g. a trade direction or freight type. */
|
||||||
|
export interface IOverviewRevenueSlice {
|
||||||
|
label: string;
|
||||||
|
amountEtb: number;
|
||||||
|
amountUsd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOverviewTonsTrendPoint {
|
||||||
|
date: string;
|
||||||
|
tons: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One direction → freight-type revenue flow (Sankey link). */
|
||||||
|
export interface IOverviewRevenueFlow {
|
||||||
|
direction: string;
|
||||||
|
freightType: string;
|
||||||
|
amountEtb: number;
|
||||||
|
amountUsd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Booking arrivals for one weekday × 3-hour block. */
|
||||||
|
export interface IOverviewHeatmapCell {
|
||||||
|
/** ISO weekday, 1 = Monday … 7 = Sunday. */
|
||||||
|
dow: number;
|
||||||
|
/** 3-hour block, 0 = 00–03 … 7 = 21–24. */
|
||||||
|
block: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IOverviewDashboard {
|
export interface IOverviewDashboard {
|
||||||
kpis: IOverviewKpis;
|
kpis: IOverviewKpis;
|
||||||
bookingTrend: IOverviewTrendPoint[];
|
bookingTrend: IOverviewTrendPoint[];
|
||||||
bookingsByStatus: IOverviewStatusCount[];
|
bookingsByStatus: IOverviewStatusCount[];
|
||||||
bookingsByPipeline: IOverviewPipelineCount[];
|
bookingsByPipeline: IOverviewPipelineCount[];
|
||||||
paymentTrend: IOverviewPaymentTrendPoint[];
|
paymentTrend: IOverviewPaymentTrendPoint[];
|
||||||
recentBookings: IOverviewRecentBooking[];
|
/** Totals for the selected range, ending today. */
|
||||||
|
current: IOverviewPeriodTotals;
|
||||||
|
/** Totals for the immediately preceding range of the same length — the delta baseline. */
|
||||||
|
previous: IOverviewPeriodTotals;
|
||||||
|
revenueByDirection: IOverviewRevenueSlice[];
|
||||||
|
revenueByFreightType: IOverviewRevenueSlice[];
|
||||||
|
/** The preceding same-length window's daily revenue — ghost-line comparison. */
|
||||||
|
previousPaymentTrend: IOverviewPaymentTrendPoint[];
|
||||||
|
tonsTrend: IOverviewTonsTrendPoint[];
|
||||||
|
revenueFlows: IOverviewRevenueFlow[];
|
||||||
|
bookingHeatmap: IOverviewHeatmapCell[];
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user