mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(freight-api): replace canned reports with a generic report engine
Nuke the 17 hand-written raw-SQL reports (no pagination, hard LIMITs) and the reports module built around them. Replace with a resolver contract: a report declares columns/filters/permission and a TypeORM QueryBuilder; ReportRunnerService applies filtering, a whitelisted sort, offset/limit paging, and a COUNT(*) FROM (query) wrapper for the total (getCount() is wrong for GROUP BY). ReportExportService re-runs the same resolver unpaginated for xlsx (exceljs) and pdf (existing PdfRenderService, now landscape-capable) exports. Ships with 4 reports: bookings-list, revenue-by-customer, aging-receivables, contract-utilization. Catalog + per-report permission checks live in the controller; adding a report is one new definitions/ file plus a REPORT_KEYS entry, no frontend change.
This commit is contained in:
@@ -69,6 +69,7 @@
|
|||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"handlebars": "^4.7.9",
|
"handlebars": "^4.7.9",
|
||||||
"jose": "^5.10.0",
|
"jose": "^5.10.0",
|
||||||
"libphonenumber-js": "^1.13.6",
|
"libphonenumber-js": "^1.13.6",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const PDF_PRINT_STYLES = `
|
|||||||
export interface PdfRenderOptions {
|
export interface PdfRenderOptions {
|
||||||
/** Label used in logs to identify the document kind. */
|
/** Label used in logs to identify the document kind. */
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** Landscape A4 instead of the default portrait — wide tables need it. */
|
||||||
|
landscape?: boolean;
|
||||||
/**
|
/**
|
||||||
* Degraded renderer used when Chromium is unavailable. Receives the
|
* Degraded renderer used when Chromium is unavailable. Receives the
|
||||||
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
|
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
|
||||||
@@ -59,6 +61,7 @@ export class PdfRenderService {
|
|||||||
|
|
||||||
const pdf = await page.pdf({
|
const pdf = await page.pdf({
|
||||||
format: "A4",
|
format: "A4",
|
||||||
|
landscape: opts.landscape ?? false,
|
||||||
printBackground: true,
|
printBackground: true,
|
||||||
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
|
import { Invoice } from '../../billing/entities/invoice.entity';
|
||||||
|
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||||
|
import { ReportContext, ReportDefinition } from '../report.types';
|
||||||
|
|
||||||
|
const OPEN_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'];
|
||||||
|
|
||||||
|
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||||
|
const { params, directions } = ctx;
|
||||||
|
// "As of" — invoices due after this instant aren't overdue yet. Defaults
|
||||||
|
// to now() in SQL when the filter is unset (see the COALESCE below).
|
||||||
|
const asOf = (params.asOf as string | null) ?? null;
|
||||||
|
|
||||||
|
const qb = ctx.ds
|
||||||
|
.createQueryBuilder()
|
||||||
|
.from(Invoice, 'i')
|
||||||
|
.innerJoin(Company, 'c', 'c.id = i.company_id')
|
||||||
|
.where('i.deleted_at IS NULL')
|
||||||
|
.andWhere('i.status IN (:...openStatuses)', { openStatuses: OPEN_STATUSES })
|
||||||
|
.andWhere('i.balance_amount > 0')
|
||||||
|
.setParameter('asOf', asOf);
|
||||||
|
|
||||||
|
// ACL: invoices.source_id is a varchar pointer at the originating booking.
|
||||||
|
// Rows not pointing at a booking (e.g. warehouse fee invoices) stay visible.
|
||||||
|
return applyBookingRefDirectionScope(qb, 'i.source_id', directions);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agingReceivablesReport: ReportDefinition = {
|
||||||
|
key: 'aging-receivables',
|
||||||
|
title: 'Aging Receivables',
|
||||||
|
description: 'Outstanding customer balances bucketed by days overdue',
|
||||||
|
group: 'Finance',
|
||||||
|
filters: [{ key: 'asOf', label: 'As of', type: 'date' }],
|
||||||
|
columns: [
|
||||||
|
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
|
||||||
|
{ key: 'invoices', label: 'Invoices', type: 'number' },
|
||||||
|
{ key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true },
|
||||||
|
{ key: 'current', label: 'Current', type: 'money' },
|
||||||
|
{ key: 'overdue0to30', label: '0-30d', type: 'money' },
|
||||||
|
{ key: 'overdue31to60', label: '31-60d', type: 'money' },
|
||||||
|
{ key: 'overdue61to90', label: '61-90d', type: 'money' },
|
||||||
|
{ key: 'overdue90plus', label: '90d+', type: 'money' },
|
||||||
|
],
|
||||||
|
defaultSort: { key: 'outstanding', dir: 'DESC' },
|
||||||
|
query(ctx) {
|
||||||
|
return baseQuery(ctx)
|
||||||
|
.select('c.name', 'customer')
|
||||||
|
.addSelect('COUNT(*)::int', 'invoices')
|
||||||
|
.addSelect('ROUND(SUM(i.balance_amount))::float8', 'outstanding')
|
||||||
|
.addSelect(
|
||||||
|
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= COALESCE(:asOf::timestamptz, now())), 0))::float8`,
|
||||||
|
'current',
|
||||||
|
)
|
||||||
|
.addSelect(
|
||||||
|
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now())
|
||||||
|
AND i.due_at >= COALESCE(:asOf::timestamptz, now()) - interval '30 days'), 0))::float8`,
|
||||||
|
'overdue0to30',
|
||||||
|
)
|
||||||
|
.addSelect(
|
||||||
|
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '30 days'
|
||||||
|
AND i.due_at >= COALESCE(:asOf::timestamptz, now()) - interval '60 days'), 0))::float8`,
|
||||||
|
'overdue31to60',
|
||||||
|
)
|
||||||
|
.addSelect(
|
||||||
|
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '60 days'
|
||||||
|
AND i.due_at >= COALESCE(:asOf::timestamptz, now()) - interval '90 days'), 0))::float8`,
|
||||||
|
'overdue61to90',
|
||||||
|
)
|
||||||
|
.addSelect(
|
||||||
|
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '90 days'), 0))::float8`,
|
||||||
|
'overdue90plus',
|
||||||
|
)
|
||||||
|
.groupBy('c.name');
|
||||||
|
},
|
||||||
|
async summary(ctx) {
|
||||||
|
const row = await baseQuery(ctx)
|
||||||
|
.select('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'outstanding')
|
||||||
|
.addSelect('COUNT(DISTINCT c.id)::int', 'customers')
|
||||||
|
.getRawOne();
|
||||||
|
return [
|
||||||
|
{ label: 'Outstanding', value: Number(row?.outstanding ?? 0), unit: 'ETB' },
|
||||||
|
{ label: 'Customers with balance', value: Number(row?.customers ?? 0) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
|
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
||||||
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
|
import { ReportContext, ReportDefinition } from '../report.types';
|
||||||
|
|
||||||
|
// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and
|
||||||
|
// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order
|
||||||
|
// (same guard as the retired report-queries.ts).
|
||||||
|
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||||
|
// adjusted_total_amount silently overrides total_amount when set.
|
||||||
|
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
||||||
|
// GENERAL contract_kind rows are umbrella contracts, not shipments; counting
|
||||||
|
// them double-counts every child booking.
|
||||||
|
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
|
||||||
|
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||||
|
|
||||||
|
function applyFilters(
|
||||||
|
ctx: ReportContext,
|
||||||
|
qb: SelectQueryBuilder<ObjectLiteral>,
|
||||||
|
): SelectQueryBuilder<ObjectLiteral> {
|
||||||
|
const { params, directions } = ctx;
|
||||||
|
qb.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`);
|
||||||
|
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||||
|
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
|
||||||
|
if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction });
|
||||||
|
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
|
||||||
|
const statuses = params.statuses as string[] | null;
|
||||||
|
if (statuses) {
|
||||||
|
qb.andWhere('b.status IN (:...statuses)', { statuses });
|
||||||
|
} else {
|
||||||
|
qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
|
||||||
|
}
|
||||||
|
if (params.search) {
|
||||||
|
qb.andWhere('(b.reference ILIKE :search OR c.name ILIKE :search)', {
|
||||||
|
search: `%${params.search}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (directions !== null) {
|
||||||
|
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', {
|
||||||
|
directions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bookingsListReport: ReportDefinition = {
|
||||||
|
key: 'bookings-list',
|
||||||
|
title: 'Bookings',
|
||||||
|
description: 'Every booking with customer, route, cargo and revenue',
|
||||||
|
group: 'Commercial',
|
||||||
|
filters: [
|
||||||
|
{ key: 'date', label: 'Created', type: 'daterange' },
|
||||||
|
{
|
||||||
|
key: 'direction',
|
||||||
|
label: 'Direction',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ value: 'IMPORT', label: 'Import' },
|
||||||
|
{ value: 'EXPORT', label: 'Export' },
|
||||||
|
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'freightType',
|
||||||
|
label: 'Freight type',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ value: 'CONTAINER', label: 'Container' },
|
||||||
|
{ value: 'BULK', label: 'Bulk' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'statuses', label: 'Status', type: 'multiselect' },
|
||||||
|
{ key: 'search', label: 'Search reference or customer', type: 'text' },
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
{ key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'b.reference' },
|
||||||
|
{ key: 'created', label: 'Created', type: 'date', sortable: true, sortExpr: 'b.created_at' },
|
||||||
|
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
|
||||||
|
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'b.status' },
|
||||||
|
{ key: 'direction', label: 'Direction', type: 'string' },
|
||||||
|
{ key: 'origin', label: 'Origin', type: 'string' },
|
||||||
|
{ key: 'destination', label: 'Destination', type: 'string' },
|
||||||
|
{ key: 'cargo', label: 'Cargo', type: 'string' },
|
||||||
|
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
|
||||||
|
{ key: 'amount', label: 'Amount', type: 'money', sortable: true },
|
||||||
|
],
|
||||||
|
defaultSort: { key: 'created', dir: 'DESC' },
|
||||||
|
query(ctx) {
|
||||||
|
const qb = ctx.ds
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select('b.reference', 'reference')
|
||||||
|
.addSelect(`to_char(b.created_at, 'YYYY-MM-DD')`, 'created')
|
||||||
|
.addSelect('c.name', 'customer')
|
||||||
|
.addSelect('b.status', 'status')
|
||||||
|
.addSelect('b.trade_direction', 'direction')
|
||||||
|
.addSelect('o.label', 'origin')
|
||||||
|
.addSelect('d.label', 'destination')
|
||||||
|
.addSelect('COALESCE(cty.cargo_type_name, b.cargo_free_text)', 'cargo')
|
||||||
|
.addSelect(`ROUND(${TONS})::float8`, 'tons')
|
||||||
|
.addSelect(`ROUND(${REVENUE})::float8`, 'amount')
|
||||||
|
.from(Booking, 'b')
|
||||||
|
.innerJoin(Company, 'c', 'c.id = b.company_id')
|
||||||
|
.innerJoin(Yard, 'o', 'o.id = b.origin_yard_id')
|
||||||
|
.innerJoin(Yard, 'd', 'd.id = b.destination_yard_id')
|
||||||
|
.leftJoin(CargoType, 'cty', 'cty.id = b.cargo_type_id');
|
||||||
|
return applyFilters(ctx, qb);
|
||||||
|
},
|
||||||
|
async summary(ctx) {
|
||||||
|
const qb = applyFilters(
|
||||||
|
ctx,
|
||||||
|
ctx.ds
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select('COUNT(*)::int', 'bookings')
|
||||||
|
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
|
||||||
|
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
|
||||||
|
.from(Booking, 'b')
|
||||||
|
.innerJoin(Company, 'c', 'c.id = b.company_id'),
|
||||||
|
);
|
||||||
|
const row = await qb.getRawOne();
|
||||||
|
return [
|
||||||
|
{ label: 'Bookings', value: Number(row?.bookings ?? 0) },
|
||||||
|
{ label: 'Tonnage', value: Number(row?.tons ?? 0), unit: 't' },
|
||||||
|
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
|
import { Contract } from '../../contracts/entities/contract.entity';
|
||||||
|
import { ReportContext, ReportDefinition } from '../report.types';
|
||||||
|
|
||||||
|
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||||
|
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||||
|
|
||||||
|
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||||
|
const { params, directions } = ctx;
|
||||||
|
const qb = ctx.ds
|
||||||
|
.createQueryBuilder()
|
||||||
|
.from(Contract, 'ct')
|
||||||
|
.leftJoin(Company, 'c', 'c.id = ct.company_id')
|
||||||
|
.leftJoin(
|
||||||
|
(sub) =>
|
||||||
|
sub
|
||||||
|
.select('s.contract_id', 'contract_id')
|
||||||
|
.addSelect('COALESCE(SUM(s.quantity_cap), 0)', 'committed')
|
||||||
|
.from('freight.contract_cargo_scope', 's')
|
||||||
|
.where('s.deleted_at IS NULL')
|
||||||
|
.groupBy('s.contract_id'),
|
||||||
|
'cap',
|
||||||
|
'cap.contract_id = ct.id',
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
(sub) =>
|
||||||
|
sub
|
||||||
|
.select('b.contract_id', 'contract_id')
|
||||||
|
.addSelect(`COALESCE(SUM(${TONS}), 0)`, 'tons')
|
||||||
|
.addSelect('COUNT(*)::int', 'cnt')
|
||||||
|
.from('freight.bookings', 'b')
|
||||||
|
.where('b.deleted_at IS NULL')
|
||||||
|
.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES })
|
||||||
|
.groupBy('b.contract_id'),
|
||||||
|
'booked',
|
||||||
|
'booked.contract_id = ct.id',
|
||||||
|
)
|
||||||
|
.where('ct.deleted_at IS NULL')
|
||||||
|
.andWhere("ct.status <> 'DRAFT'");
|
||||||
|
|
||||||
|
if (params.dateFrom) {
|
||||||
|
qb.andWhere(
|
||||||
|
"(ct.contract_valid_until IS NULL OR ct.contract_valid_until >= :dateFrom::timestamptz)",
|
||||||
|
{ dateFrom: params.dateFrom },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (params.dateTo) {
|
||||||
|
qb.andWhere('ct.contract_valid_from < :dateTo::timestamptz', { dateTo: params.dateTo });
|
||||||
|
}
|
||||||
|
const statuses = params.statuses as string[] | null;
|
||||||
|
if (statuses) qb.andWhere('ct.status IN (:...statuses)', { statuses });
|
||||||
|
if (params.contractId) {
|
||||||
|
qb.andWhere('ct.id = :contractId', { contractId: params.contractId });
|
||||||
|
}
|
||||||
|
if (directions !== null) {
|
||||||
|
qb.andWhere(directions.length ? 'ct.trade_direction IN (:...directions)' : '1 = 0', {
|
||||||
|
directions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contractUtilizationReport: ReportDefinition = {
|
||||||
|
key: 'contract-utilization',
|
||||||
|
title: 'Contract Utilization',
|
||||||
|
description: 'Committed volume vs. booked tonnage per contract',
|
||||||
|
group: 'Commercial',
|
||||||
|
idKey: { key: 'contractId', label: 'Contract' },
|
||||||
|
filters: [
|
||||||
|
{ key: 'date', label: 'Active during', type: 'daterange' },
|
||||||
|
{ key: 'statuses', label: 'Status', type: 'multiselect' },
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
{ key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'ct.reference' },
|
||||||
|
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
|
||||||
|
{ key: 'status', label: 'Status', type: 'string' },
|
||||||
|
{ key: 'kind', label: 'Kind', type: 'string' },
|
||||||
|
{ key: 'validFrom', label: 'Valid from', type: 'date' },
|
||||||
|
{ key: 'validUntil', label: 'Valid until', type: 'date' },
|
||||||
|
{ key: 'committed', label: 'Committed', type: 'tons' },
|
||||||
|
{ key: 'bookedTons', label: 'Booked', type: 'tons', sortable: true },
|
||||||
|
{ key: 'bookings', label: 'Bookings', type: 'number' },
|
||||||
|
{ key: 'utilizationPct', label: 'Utilization', type: 'percent', sortable: true },
|
||||||
|
],
|
||||||
|
defaultSort: { key: 'utilizationPct', dir: 'DESC' },
|
||||||
|
query(ctx) {
|
||||||
|
return baseQuery(ctx)
|
||||||
|
.select('ct.reference', 'reference')
|
||||||
|
.addSelect('c.name', 'customer')
|
||||||
|
.addSelect('ct.status', 'status')
|
||||||
|
.addSelect('ct.contract_kind', 'kind')
|
||||||
|
.addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, 'validFrom')
|
||||||
|
.addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD')`, 'validUntil')
|
||||||
|
.addSelect('COALESCE(cap.committed, 0)::float8', 'committed')
|
||||||
|
.addSelect('COALESCE(booked.tons, 0)::float8', 'bookedTons')
|
||||||
|
.addSelect('COALESCE(booked.cnt, 0)', 'bookings')
|
||||||
|
.addSelect(
|
||||||
|
`CASE WHEN COALESCE(cap.committed, 0) > 0
|
||||||
|
THEN ROUND(COALESCE(booked.tons, 0) / cap.committed * 100)::float8 END`,
|
||||||
|
'utilizationPct',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async summary(ctx) {
|
||||||
|
const row = await baseQuery(ctx)
|
||||||
|
.select('COUNT(*)::int', 'contracts')
|
||||||
|
.addSelect('COALESCE(SUM(booked.tons), 0)::float8', 'bookedTons')
|
||||||
|
.addSelect(
|
||||||
|
`AVG(CASE WHEN COALESCE(cap.committed, 0) > 0
|
||||||
|
THEN booked.tons / cap.committed * 100 END)::float8`,
|
||||||
|
'avgUtilization',
|
||||||
|
)
|
||||||
|
.getRawOne();
|
||||||
|
return [
|
||||||
|
{ label: 'Contracts', value: Number(row?.contracts ?? 0) },
|
||||||
|
{ label: 'Booked tonnage', value: Number(row?.bookedTons ?? 0), unit: 't' },
|
||||||
|
{ label: 'Avg utilization', value: Math.round(Number(row?.avgUtilization ?? 0)), unit: '%' },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
|
import { ReportContext, ReportDefinition } from '../report.types';
|
||||||
|
|
||||||
|
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||||
|
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
||||||
|
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
|
||||||
|
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||||
|
|
||||||
|
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||||
|
const { params, directions } = ctx;
|
||||||
|
const qb = ctx.ds
|
||||||
|
.createQueryBuilder()
|
||||||
|
.from(Booking, 'b')
|
||||||
|
.innerJoin(Company, 'c', 'c.id = b.company_id')
|
||||||
|
.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`);
|
||||||
|
|
||||||
|
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||||
|
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
|
||||||
|
if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction });
|
||||||
|
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
|
||||||
|
const statuses = params.statuses as string[] | null;
|
||||||
|
if (statuses) {
|
||||||
|
qb.andWhere('b.status IN (:...statuses)', { statuses });
|
||||||
|
} else {
|
||||||
|
qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
|
||||||
|
}
|
||||||
|
if (directions !== null) {
|
||||||
|
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', {
|
||||||
|
directions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const revenueByCustomerReport: ReportDefinition = {
|
||||||
|
key: 'revenue-by-customer',
|
||||||
|
title: 'Revenue by Customer',
|
||||||
|
description: 'Ranked customers by booking revenue',
|
||||||
|
group: 'Commercial',
|
||||||
|
filters: [
|
||||||
|
{ key: 'date', label: 'Created', type: 'daterange' },
|
||||||
|
{
|
||||||
|
key: 'direction',
|
||||||
|
label: 'Direction',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ value: 'IMPORT', label: 'Import' },
|
||||||
|
{ value: 'EXPORT', label: 'Export' },
|
||||||
|
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'freightType',
|
||||||
|
label: 'Freight type',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ value: 'CONTAINER', label: 'Container' },
|
||||||
|
{ value: 'BULK', label: 'Bulk' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'statuses', label: 'Status', type: 'multiselect' },
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
|
||||||
|
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
|
||||||
|
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
|
||||||
|
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
|
||||||
|
],
|
||||||
|
defaultSort: { key: 'revenue', dir: 'DESC' },
|
||||||
|
query(ctx) {
|
||||||
|
return baseQuery(ctx)
|
||||||
|
.select('c.name', 'customer')
|
||||||
|
.addSelect('COUNT(*)::int', 'bookings')
|
||||||
|
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
|
||||||
|
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
|
||||||
|
.groupBy('c.name');
|
||||||
|
},
|
||||||
|
async summary(ctx) {
|
||||||
|
const row = await baseQuery(ctx)
|
||||||
|
.select('COUNT(DISTINCT c.name)::int', 'customers')
|
||||||
|
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
|
||||||
|
.getRawOne();
|
||||||
|
return [
|
||||||
|
{ label: 'Customers', value: Number(row?.customers ?? 0) },
|
||||||
|
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class ReportQueryDto {
|
|
||||||
@ApiPropertyOptional({ description: 'Inclusive start date (YYYY-MM-DD). Default: 30 days ago.' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
dateFrom?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Inclusive end date (YYYY-MM-DD). Default: today.' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
dateTo?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: ['day', 'week', 'month'], default: 'day' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(['day', 'week', 'month'])
|
|
||||||
granularity?: 'day' | 'week' | 'month';
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Comma-separated company UUIDs' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
companyIds?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Comma-separated route UUIDs' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
routeIds?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Comma-separated yard UUIDs (matches origin or destination)' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
yardIds?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Comma-separated cargo type UUIDs' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
cargoTypeIds?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Comma-separated status values (report-specific)' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
statuses?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Trade direction filter' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
direction?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: ['CONTAINER', 'BULK'] })
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(['CONTAINER', 'BULK'])
|
|
||||||
freightType?: string;
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
export class ReportKpiDto {
|
|
||||||
@ApiProperty()
|
|
||||||
label!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
value!: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
unit?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ReportResultDto {
|
|
||||||
@ApiProperty({ type: [ReportKpiDto] })
|
|
||||||
kpis!: ReportKpiDto[];
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
type: 'array',
|
|
||||||
items: { type: 'object', additionalProperties: true },
|
|
||||||
description: 'Report rows; columns vary per report key',
|
|
||||||
})
|
|
||||||
rows!: Record<string, unknown>[];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
||||||
|
import { ReportColumn, ReportDefinition, ReportKpi } from './report.types';
|
||||||
|
|
||||||
|
// ponytail: in-memory Workbook, cap below. Switch to ExcelJS's streaming
|
||||||
|
// WorkbookWriter if a report ever needs to outgrow XLSX_ROW_CAP.
|
||||||
|
export const XLSX_ROW_CAP = 50_000;
|
||||||
|
// ponytail: HTML→PDF render cost grows with row count; larger exports must
|
||||||
|
// use XLSX instead.
|
||||||
|
export const PDF_ROW_CAP = 5_000;
|
||||||
|
|
||||||
|
const NUMBER_FORMAT: Partial<Record<ReportColumn['type'], string>> = {
|
||||||
|
money: '#,##0.00',
|
||||||
|
tons: '#,##0.0',
|
||||||
|
percent: '0"%"',
|
||||||
|
number: '#,##0',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatCell(value: unknown, type: ReportColumn['type']): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (type === 'money' || type === 'number') {
|
||||||
|
return Number(value).toLocaleString('en-US', { maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
if (type === 'tons') return `${Number(value).toLocaleString('en-US')} t`;
|
||||||
|
if (type === 'percent') return `${value}%`;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReportExportService {
|
||||||
|
constructor(private readonly pdfRender: PdfRenderService) {}
|
||||||
|
|
||||||
|
async toXlsx(
|
||||||
|
def: ReportDefinition,
|
||||||
|
rows: Record<string, unknown>[],
|
||||||
|
kpis: ReportKpi[],
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet(def.title.slice(0, 31));
|
||||||
|
|
||||||
|
if (kpis.length) {
|
||||||
|
sheet.addRow(kpis.map((k) => `${k.label}: ${k.value.toLocaleString()}${k.unit ? ` ${k.unit}` : ''}`));
|
||||||
|
sheet.addRow([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerRow = sheet.addRow(def.columns.map((c) => c.label));
|
||||||
|
headerRow.font = { bold: true };
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
sheet.addRow(def.columns.map((c) => row[c.key] ?? null));
|
||||||
|
}
|
||||||
|
|
||||||
|
def.columns.forEach((col, i) => {
|
||||||
|
const format = NUMBER_FORMAT[col.type];
|
||||||
|
const excelCol = sheet.getColumn(i + 1);
|
||||||
|
excelCol.width = Math.max(col.label.length + 2, 12);
|
||||||
|
if (format) excelCol.numFmt = format;
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
return Buffer.from(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async toPdf(
|
||||||
|
def: ReportDefinition,
|
||||||
|
rows: Record<string, unknown>[],
|
||||||
|
kpis: ReportKpi[],
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const html = this.buildHtml(def, rows, kpis);
|
||||||
|
return this.pdfRender.htmlToPdfBuffer(html, { label: `report:${def.key}`, landscape: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildHtml(
|
||||||
|
def: ReportDefinition,
|
||||||
|
rows: Record<string, unknown>[],
|
||||||
|
kpis: ReportKpi[],
|
||||||
|
): string {
|
||||||
|
const esc = (v: unknown) =>
|
||||||
|
String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
const kpiHtml = kpis.length
|
||||||
|
? `<div style="display:flex;gap:24px;margin-bottom:16px">${kpis
|
||||||
|
.map(
|
||||||
|
(k) =>
|
||||||
|
`<div><div style="font-size:11px;color:#666">${esc(k.label)}</div><div style="font-size:16px;font-weight:600">${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}</div></div>`,
|
||||||
|
)
|
||||||
|
.join('')}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const head = def.columns.map((c) => `<th>${esc(c.label)}</th>`).join('');
|
||||||
|
const body = rows
|
||||||
|
.map(
|
||||||
|
(row) =>
|
||||||
|
`<tr>${def.columns.map((c) => `<td>${esc(formatCell(row[c.key], c.type))}</td>`).join('')}</tr>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||||
|
body { font-family: Arial, sans-serif; font-size: 10px; color: #111; }
|
||||||
|
h1 { font-size: 16px; margin-bottom: 4px; }
|
||||||
|
p.desc { color: #666; margin-top: 0 0 12px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 4px 6px; text-align: left; }
|
||||||
|
th { background: #f3f3f3; }
|
||||||
|
</style></head><body>
|
||||||
|
<h1>${esc(def.title)}</h1>
|
||||||
|
<p class="desc">${esc(def.description)}</p>
|
||||||
|
${kpiHtml}
|
||||||
|
<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,669 +0,0 @@
|
|||||||
import { DataSource } from 'typeorm';
|
|
||||||
|
|
||||||
export interface ReportFilters {
|
|
||||||
/** ISO timestamp, inclusive lower bound. null = no lower bound (all time). */
|
|
||||||
dateFrom: string | null;
|
|
||||||
/** ISO timestamp, exclusive upper bound. null = no upper bound. */
|
|
||||||
dateTo: string | null;
|
|
||||||
granularity: 'day' | 'week' | 'month';
|
|
||||||
companyIds: string[] | null;
|
|
||||||
routeIds: string[] | null;
|
|
||||||
yardIds: string[] | null;
|
|
||||||
cargoTypeIds: string[] | null;
|
|
||||||
statuses: string[] | null;
|
|
||||||
/** Trade-scope-resolved directions. null = unrestricted, [] = show nothing. */
|
|
||||||
directions: string[] | null;
|
|
||||||
freightType: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReportKpi {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
unit?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReportResult {
|
|
||||||
kpis: ReportKpi[];
|
|
||||||
rows: Record<string, unknown>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReportQuery = (ds: DataSource, f: ReportFilters) => Promise<ReportResult>;
|
|
||||||
|
|
||||||
// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and
|
|
||||||
// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order.
|
|
||||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
|
||||||
// adjusted_total_amount silently overrides total_amount when set.
|
|
||||||
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
|
||||||
// GENERAL contract_kind rows are umbrella contracts, not shipments; counting
|
|
||||||
// them double-counts every child booking (same guard as overview.repository).
|
|
||||||
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
|
|
||||||
const DEAD_STATUSES = "'DRAFT','CANCELLED','REJECTED','EXPIRED'";
|
|
||||||
|
|
||||||
const num = (v: unknown): number => (v === null || v === undefined ? 0 : Number(v));
|
|
||||||
const sum = (rows: Record<string, unknown>[], col: string): number =>
|
|
||||||
rows.reduce((acc, r) => acc + num(r[col]), 0);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared WHERE for booking-based reports (alias `b`).
|
|
||||||
* Params occupy $1..$8 in this fixed order; report SQL continues at $9.
|
|
||||||
*/
|
|
||||||
function bookingWhere(f: ReportFilters): { where: string; params: unknown[] } {
|
|
||||||
return {
|
|
||||||
where: `
|
|
||||||
b.deleted_at IS NULL
|
|
||||||
AND ${NOT_UMBRELLA}
|
|
||||||
AND ($1::timestamptz IS NULL OR b.created_at >= $1)
|
|
||||||
AND ($2::timestamptz IS NULL OR b.created_at < $2)
|
|
||||||
AND ($3::uuid[] IS NULL OR b.company_id = ANY($3))
|
|
||||||
AND ($4::uuid[] IS NULL OR b.cargo_type_id = ANY($4))
|
|
||||||
AND ($5::text[] IS NULL OR b.trade_direction = ANY($5))
|
|
||||||
AND ($6::text IS NULL OR b.freight_type = $6)
|
|
||||||
AND (CASE WHEN $7::text[] IS NULL
|
|
||||||
THEN b.status NOT IN (${DEAD_STATUSES})
|
|
||||||
ELSE b.status = ANY($7) END)
|
|
||||||
AND ($8::uuid[] IS NULL OR b.origin_yard_id = ANY($8) OR b.destination_yard_id = ANY($8))`,
|
|
||||||
params: [
|
|
||||||
f.dateFrom,
|
|
||||||
f.dateTo,
|
|
||||||
f.companyIds,
|
|
||||||
f.cargoTypeIds,
|
|
||||||
f.directions,
|
|
||||||
f.freightType,
|
|
||||||
f.statuses,
|
|
||||||
f.yardIds,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Direction scope for rows that reference a booking through a varchar id
|
|
||||||
* column (invoices.source_id, payments.ref_id). Rows not pointing at a
|
|
||||||
* booking stay visible — they carry no direction to scope by.
|
|
||||||
* (Positional-param port of trade-scope.util's bookingRefScopeSql.)
|
|
||||||
*/
|
|
||||||
const refDirScope = (refColumn: string, param: string): string => `
|
|
||||||
(${param}::text[] IS NULL OR NOT EXISTS (
|
|
||||||
SELECT 1 FROM freight.bookings sb
|
|
||||||
WHERE sb.id::text = ${refColumn} AND NOT (sb.trade_direction = ANY(${param}))))`;
|
|
||||||
|
|
||||||
const bookingsTrend: ReportQuery = async (ds, f) => {
|
|
||||||
const { where, params } = bookingWhere(f);
|
|
||||||
const rows = await ds.query(
|
|
||||||
`SELECT to_char(date_trunc($9, b.created_at), 'YYYY-MM-DD') AS period,
|
|
||||||
COUNT(*)::int AS bookings,
|
|
||||||
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
|
|
||||||
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
|
|
||||||
FROM freight.bookings b
|
|
||||||
WHERE ${where}
|
|
||||||
GROUP BY 1 ORDER BY 1`,
|
|
||||||
[...params, f.granularity],
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Bookings', value: sum(rows, 'bookings') },
|
|
||||||
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
|
|
||||||
{ label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' },
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const revenueByCustomer: ReportQuery = async (ds, f) => {
|
|
||||||
const { where, params } = bookingWhere(f);
|
|
||||||
const rows = await ds.query(
|
|
||||||
`SELECT c.name AS customer,
|
|
||||||
COUNT(*)::int AS bookings,
|
|
||||||
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
|
|
||||||
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
|
|
||||||
FROM freight.bookings b
|
|
||||||
JOIN freight.companies c ON c.id = b.company_id
|
|
||||||
WHERE ${where}
|
|
||||||
GROUP BY c.name ORDER BY revenue DESC LIMIT 100`,
|
|
||||||
params,
|
|
||||||
);
|
|
||||||
const total = sum(rows, 'revenue');
|
|
||||||
return {
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Customers', value: rows.length },
|
|
||||||
{ label: 'Revenue', value: total, unit: 'ETB' },
|
|
||||||
{
|
|
||||||
label: 'Top customer share',
|
|
||||||
value: total > 0 ? Math.round((num(rows[0]?.revenue) / total) * 100) : 0,
|
|
||||||
unit: '%',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const revenueByLane: ReportQuery = async (ds, f) => {
|
|
||||||
const { where, params } = bookingWhere(f);
|
|
||||||
const rows = await ds.query(
|
|
||||||
`SELECT o.label AS origin, d.label AS destination,
|
|
||||||
COUNT(*)::int AS bookings,
|
|
||||||
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
|
|
||||||
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
|
|
||||||
FROM freight.bookings b
|
|
||||||
JOIN freight.yards o ON o.id = b.origin_yard_id
|
|
||||||
JOIN freight.yards d ON d.id = b.destination_yard_id
|
|
||||||
WHERE ${where}
|
|
||||||
GROUP BY 1, 2 ORDER BY revenue DESC LIMIT 100`,
|
|
||||||
params,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Lanes', value: rows.length },
|
|
||||||
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
|
|
||||||
{ label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' },
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const contractUtilization: ReportQuery = async (ds, f) => {
|
|
||||||
const rows = await ds.query(
|
|
||||||
`SELECT ct.reference, c.name AS customer, ct.status, ct.contract_kind AS kind,
|
|
||||||
to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from,
|
|
||||||
to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until,
|
|
||||||
cap.committed::float8 AS committed,
|
|
||||||
booked.tons::float8 AS booked_tons,
|
|
||||||
booked.cnt AS bookings,
|
|
||||||
CASE WHEN cap.committed > 0
|
|
||||||
THEN ROUND(booked.tons / cap.committed * 100)::float8 END AS utilization_pct
|
|
||||||
FROM freight.contracts ct
|
|
||||||
LEFT JOIN freight.companies c ON c.id = ct.company_id
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT COALESCE(SUM(s.quantity_cap), 0) AS committed
|
|
||||||
FROM freight.contract_cargo_scope s
|
|
||||||
WHERE s.contract_id = ct.id AND s.deleted_at IS NULL) cap ON true
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT COALESCE(SUM(${TONS}), 0) AS tons, COUNT(*)::int AS cnt
|
|
||||||
FROM freight.bookings b
|
|
||||||
WHERE b.contract_id = ct.id AND b.deleted_at IS NULL
|
|
||||||
AND b.status NOT IN (${DEAD_STATUSES})) booked ON true
|
|
||||||
WHERE ct.deleted_at IS NULL
|
|
||||||
AND ct.status NOT IN ('DRAFT')
|
|
||||||
AND ct.contract_valid_from < COALESCE($2::timestamptz, 'infinity')
|
|
||||||
AND (ct.contract_valid_until IS NULL
|
|
||||||
OR ct.contract_valid_until >= COALESCE($1::timestamptz, '-infinity'))
|
|
||||||
AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3))
|
|
||||||
AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4))
|
|
||||||
AND ($5::text[] IS NULL OR ct.status = ANY($5))
|
|
||||||
ORDER BY utilization_pct DESC NULLS LAST LIMIT 200`,
|
|
||||||
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses],
|
|
||||||
);
|
|
||||||
const capped = rows.filter((r: Record<string, unknown>) => num(r.committed) > 0);
|
|
||||||
return {
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Contracts', value: rows.length },
|
|
||||||
{
|
|
||||||
label: 'Avg utilization',
|
|
||||||
value: capped.length
|
|
||||||
? Math.round(sum(capped, 'utilization_pct') / capped.length)
|
|
||||||
: 0,
|
|
||||||
unit: '%',
|
|
||||||
},
|
|
||||||
{ label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' },
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// ponytail: 60-min departure grace is a constant; make it a query param if ops
|
|
||||||
// ever wants a configurable threshold.
|
|
||||||
const trainOnTime: ReportQuery = async (ds, f) => {
|
|
||||||
const rows = await ds.query(
|
|
||||||
`SELECT o.label AS origin, d.label AS destination,
|
|
||||||
COUNT(*)::int AS trips,
|
|
||||||
COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL)::int AS departed,
|
|
||||||
ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_departure_at - ts.scheduled_departure_date)) / 60)
|
|
||||||
FILTER (WHERE ts.actual_departure_at IS NOT NULL))::float8 AS avg_dep_delay_min,
|
|
||||||
ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.scheduled_arrival_date)) / 60)
|
|
||||||
FILTER (WHERE ts.actual_arrival_at IS NOT NULL
|
|
||||||
AND ts.scheduled_arrival_date IS NOT NULL))::float8 AS avg_arr_delay_min,
|
|
||||||
ROUND(100.0 * COUNT(*) FILTER (WHERE ts.actual_departure_at
|
|
||||||
<= ts.scheduled_departure_date + interval '60 minutes')
|
|
||||||
/ NULLIF(COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL), 0))::float8 AS on_time_pct
|
|
||||||
FROM freight.train_schedules ts
|
|
||||||
JOIN freight.yards o ON o.id = ts.origin_station_id
|
|
||||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
|
||||||
WHERE ts.deleted_at IS NULL
|
|
||||||
AND ts.status IN ('DISPATCHED', 'ARRIVED')
|
|
||||||
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
|
||||||
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
|
||||||
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
|
|
||||||
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
|
|
||||||
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
|
||||||
GROUP BY 1, 2 ORDER BY trips DESC`,
|
|
||||||
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
|
|
||||||
);
|
|
||||||
const departed = sum(rows, 'departed');
|
|
||||||
const weighted = rows.reduce(
|
|
||||||
(acc: number, r: Record<string, unknown>) =>
|
|
||||||
acc + (num(r.on_time_pct) * num(r.departed)) / 100,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Trips', value: sum(rows, 'trips') },
|
|
||||||
{
|
|
||||||
label: 'On-time departures',
|
|
||||||
value: departed > 0 ? Math.round((weighted / departed) * 100) : 0,
|
|
||||||
unit: '%',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Avg departure delay',
|
|
||||||
value: rows.length ? Math.round(sum(rows, 'avg_dep_delay_min') / rows.length) : 0,
|
|
||||||
unit: 'min',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleFillRate: ReportQuery = async (ds, f) => {
|
|
||||||
const rows = await ds.query(
|
|
||||||
`SELECT ts.train_number, ts.reference,
|
|
||||||
to_char(ts.scheduled_departure_date, 'YYYY-MM-DD') AS departure,
|
|
||||||
o.label AS origin, d.label AS destination, ts.direction, ts.status,
|
|
||||||
ts.max_wagons, tset.wagon_count,
|
|
||||||
ROUND(w.cap_tons)::float8 AS capacity_tons,
|
|
||||||
ROUND(w.booked_tons)::float8 AS booked_tons,
|
|
||||||
CASE WHEN w.cap_tons > 0
|
|
||||||
THEN ROUND(w.booked_tons / w.cap_tons * 100)::float8 END AS fill_pct
|
|
||||||
FROM freight.train_schedules ts
|
|
||||||
JOIN freight.yards o ON o.id = ts.origin_station_id
|
|
||||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
|
||||||
LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT COALESCE(SUM(tw.capacity_tons), 0) AS cap_tons,
|
|
||||||
COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons
|
|
||||||
FROM freight.train_set_wagons tw
|
|
||||||
WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true
|
|
||||||
WHERE ts.deleted_at IS NULL
|
|
||||||
AND ts.status <> 'CANCELLED'
|
|
||||||
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
|
||||||
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
|
||||||
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
|
|
||||||
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
|
|
||||||
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
|
||||||
ORDER BY ts.scheduled_departure_date DESC LIMIT 200`,
|
|
||||||
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
|
|
||||||
);
|
|
||||||
const withCap = rows.filter((r: Record<string, unknown>) => num(r.capacity_tons) > 0);
|
|
||||||
const capTons = sum(withCap, 'capacity_tons');
|
|
||||||
return {
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Schedules', value: rows.length },
|
|
||||||
{
|
|
||||||
label: 'Avg fill rate',
|
|
||||||
value: capTons > 0 ? Math.round((sum(withCap, 'booked_tons') / capTons) * 100) : 0,
|
|
||||||
unit: '%',
|
|
||||||
},
|
|
||||||
{ label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' },
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const tripsPerRoute: ReportQuery = async (ds, f) => {
|
|
||||||
const rows = await ds.query(
|
|
||||||
`SELECT o.label AS origin, d.label AS destination, ts.direction,
|
|
||||||
COUNT(*)::int AS trips,
|
|
||||||
ROUND(COALESCE(SUM(w.booked_tons), 0))::float8 AS tons_hauled,
|
|
||||||
ROUND(COALESCE(AVG(w.booked_tons), 0))::float8 AS avg_tons_per_trip
|
|
||||||
FROM freight.train_schedules ts
|
|
||||||
JOIN freight.yards o ON o.id = ts.origin_station_id
|
|
||||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons
|
|
||||||
FROM freight.train_set_wagons tw
|
|
||||||
WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true
|
|
||||||
WHERE ts.deleted_at IS NULL
|
|
||||||
AND ts.status IN ('DISPATCHED', 'ARRIVED')
|
|
||||||
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
|
||||||
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
|
||||||
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
|
|
||||||
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
|
|
||||||
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
|
||||||
GROUP BY 1, 2, 3 ORDER BY trips DESC`,
|
|
||||||
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Trips', value: sum(rows, 'trips') },
|
|
||||||
{ label: 'Routes served', value: rows.length },
|
|
||||||
{ label: 'Tonnage hauled', value: sum(rows, 'tons_hauled'), unit: 't' },
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const invoicedVsCollected: ReportQuery = async (ds, f) => {
|
|
||||||
const rows = await ds.query(
|
|
||||||
`SELECT to_char(date_trunc($5, COALESCE(i.issued_at, i.created_at)), 'YYYY-MM-DD') AS period,
|
|
||||||
COUNT(*)::int AS invoices,
|
|
||||||
ROUND(SUM(i.total_amount))::float8 AS invoiced,
|
|
||||||
ROUND(SUM(i.paid_amount))::float8 AS collected,
|
|
||||||
ROUND(SUM(i.balance_amount))::float8 AS outstanding
|
|
||||||
FROM freight.invoices i
|
|
||||||
WHERE i.deleted_at IS NULL
|
|
||||||
AND i.status NOT IN ('DRAFT', 'CANCELLED')
|
|
||||||
AND ($1::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) >= $1)
|
|
||||||
AND ($2::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) < $2)
|
|
||||||
AND ($3::uuid[] IS NULL OR i.company_id = ANY($3))
|
|
||||||
AND ${refDirScope('i.source_id', '$4')}
|
|
||||||
GROUP BY 1 ORDER BY 1`,
|
|
||||||
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.granularity],
|
|
||||||
);
|
|
||||||
const invoiced = sum(rows, 'invoiced');
|
|
||||||
const collected = sum(rows, 'collected');
|
|
||||||
return {
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Invoiced', value: invoiced, unit: 'ETB' },
|
|
||||||
{ label: 'Collected', value: collected, unit: 'ETB' },
|
|
||||||
{
|
|
||||||
label: 'Collection rate',
|
|
||||||
value: invoiced > 0 ? Math.round((collected / invoiced) * 100) : 0,
|
|
||||||
unit: '%',
|
|
||||||
},
|
|
||||||
{ label: 'Outstanding', value: sum(rows, 'outstanding'), unit: 'ETB' },
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Aging is an as-of snapshot: dateTo is the as-of moment (default now),
|
|
||||||
// dateFrom is ignored.
|
|
||||||
const agingReceivables: ReportQuery = async (ds, f) => {
|
|
||||||
const rows = await ds.query(
|
|
||||||
`SELECT c.name AS customer,
|
|
||||||
COUNT(*)::int AS invoices,
|
|
||||||
ROUND(SUM(i.balance_amount))::float8 AS outstanding,
|
|
||||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= COALESCE($1::timestamptz, now())), 0))::float8 AS current,
|
|
||||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now())
|
|
||||||
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '30 days'), 0))::float8 AS overdue_0_30,
|
|
||||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '30 days'
|
|
||||||
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '60 days'), 0))::float8 AS overdue_31_60,
|
|
||||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '60 days'
|
|
||||||
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_61_90,
|
|
||||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_90_plus
|
|
||||||
FROM freight.invoices i
|
|
||||||
JOIN freight.companies c ON c.id = i.company_id
|
|
||||||
WHERE i.deleted_at IS NULL
|
|
||||||
AND i.status IN ('ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE')
|
|
||||||
AND i.balance_amount > 0
|
|
||||||
AND ($1::timestamptz IS NULL OR i.created_at < $1)
|
|
||||||
AND ($2::uuid[] IS NULL OR i.company_id = ANY($2))
|
|
||||||
AND ${refDirScope('i.source_id', '$3')}
|
|
||||||
GROUP BY 1 ORDER BY outstanding DESC LIMIT 200`,
|
|
||||||
[f.dateTo, f.companyIds, f.directions],
|
|
||||||
);
|
|
||||||
const outstanding = sum(rows, 'outstanding');
|
|
||||||
return {
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Outstanding', value: outstanding, unit: 'ETB' },
|
|
||||||
{ label: 'Overdue', value: outstanding - sum(rows, 'current'), unit: 'ETB' },
|
|
||||||
{ label: 'Customers with balance', value: rows.length },
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const revenueByPaymentMethod: ReportQuery = async (ds, f) => {
|
|
||||||
// payments.status values are lowercase-hyphenated ('success'), unlike every
|
|
||||||
// other status enum in the schema. No deleted_at on this table.
|
|
||||||
const rows = await ds.query(
|
|
||||||
`SELECT p.method::text AS method,
|
|
||||||
COUNT(*)::int AS payments,
|
|
||||||
ROUND(SUM(p.amount))::float8 AS amount
|
|
||||||
FROM freight.payments p
|
|
||||||
WHERE p.status = 'success'
|
|
||||||
AND ($1::timestamptz IS NULL OR p.created_at >= $1)
|
|
||||||
AND ($2::timestamptz IS NULL OR p.created_at < $2)
|
|
||||||
AND ${refDirScope('p.ref_id', '$3')}
|
|
||||||
GROUP BY 1 ORDER BY amount DESC`,
|
|
||||||
[f.dateFrom, f.dateTo, f.directions],
|
|
||||||
);
|
|
||||||
const total = sum(rows, 'amount');
|
|
||||||
return {
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Collected', value: total, unit: 'ETB' },
|
|
||||||
{ label: 'Payments', value: sum(rows, 'payments') },
|
|
||||||
{
|
|
||||||
label: 'Top method share',
|
|
||||||
value: total > 0 ? Math.round((num(rows[0]?.amount) / total) * 100) : 0,
|
|
||||||
unit: '%',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Record-level list exports. Same engine, raw rows instead of aggregates.
|
|
||||||
// ponytail: flat LIMIT 5000 per list — stream/paginate the export if a table
|
|
||||||
// ever outgrows that.
|
|
||||||
const LIST_LIMIT = 5000;
|
|
||||||
|
|
||||||
const bookingsList: ReportQuery = async (ds, f) => {
|
|
||||||
const { where, params } = bookingWhere(f);
|
|
||||||
const rows = await ds.query(
|
|
||||||
`SELECT b.reference,
|
|
||||||
to_char(b.created_at, 'YYYY-MM-DD') AS created,
|
|
||||||
c.name AS customer, b.status, b.freight_type,
|
|
||||||
b.trade_direction AS direction,
|
|
||||||
o.label AS origin, d.label AS destination,
|
|
||||||
COALESCE(cty.cargo_type_name, b.cargo_free_text) AS cargo,
|
|
||||||
ROUND(${TONS})::float8 AS tons,
|
|
||||||
ROUND(${REVENUE})::float8 AS amount,
|
|
||||||
b.payment_status, b.scheduling_status
|
|
||||||
FROM freight.bookings b
|
|
||||||
JOIN freight.companies c ON c.id = b.company_id
|
|
||||||
JOIN freight.yards o ON o.id = b.origin_yard_id
|
|
||||||
JOIN freight.yards d ON d.id = b.destination_yard_id
|
|
||||||
LEFT JOIN freight.cargo_types cty ON cty.id = b.cargo_type_id
|
|
||||||
WHERE ${where}
|
|
||||||
ORDER BY b.created_at DESC LIMIT ${LIST_LIMIT}`,
|
|
||||||
params,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Bookings', value: rows.length },
|
|
||||||
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
|
|
||||||
{ label: 'Amount', value: sum(rows, 'amount'), unit: 'ETB' },
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const contractsList: ReportQuery = async (ds, f) => {
|
|
||||||
const rows = await ds.query(
|
|
||||||
`SELECT ct.reference, c.name AS customer, ct.contract_kind AS kind,
|
|
||||||
ct.status, ct.trade_direction AS direction, ct.freight_type,
|
|
||||||
to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from,
|
|
||||||
to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until,
|
|
||||||
to_char(ct.created_at, 'YYYY-MM-DD') AS created
|
|
||||||
FROM freight.contracts ct
|
|
||||||
LEFT JOIN freight.companies c ON c.id = ct.company_id
|
|
||||||
WHERE ct.deleted_at IS NULL
|
|
||||||
AND ($1::timestamptz IS NULL OR ct.created_at >= $1)
|
|
||||||
AND ($2::timestamptz IS NULL OR ct.created_at < $2)
|
|
||||||
AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3))
|
|
||||||
AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4))
|
|
||||||
AND ($5::text[] IS NULL OR ct.status = ANY($5))
|
|
||||||
ORDER BY ct.created_at DESC LIMIT ${LIST_LIMIT}`,
|
|
||||||
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses],
|
|
||||||
);
|
|
||||||
const active = rows.filter((r: Record<string, unknown>) =>
|
|
||||||
['CONTRACT_ACTIVE', 'ACTIVE_SHIPMENT_IN_PROGRESS'].includes(String(r.status)),
|
|
||||||
).length;
|
|
||||||
return {
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Contracts', value: rows.length },
|
|
||||||
{ label: 'Active', value: active },
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const schedulesList: ReportQuery = async (ds, f) => {
|
|
||||||
const rows = await ds.query(
|
|
||||||
`SELECT ts.train_number, ts.reference, ts.direction, ts.status,
|
|
||||||
o.label AS origin, d.label AS destination,
|
|
||||||
to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI') AS scheduled_departure,
|
|
||||||
to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI') AS actual_departure,
|
|
||||||
to_char(ts.scheduled_arrival_date, 'YYYY-MM-DD HH24:MI') AS scheduled_arrival,
|
|
||||||
to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI') AS actual_arrival,
|
|
||||||
ts.max_wagons, tset.wagon_count
|
|
||||||
FROM freight.train_schedules ts
|
|
||||||
JOIN freight.yards o ON o.id = ts.origin_station_id
|
|
||||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
|
||||||
LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
|
||||||
WHERE ts.deleted_at IS NULL
|
|
||||||
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
|
||||||
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
|
||||||
AND ($3::text[] IS NULL OR ts.direction = ANY($3))
|
|
||||||
AND ($4::text[] IS NULL OR ts.status = ANY($4))
|
|
||||||
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
|
||||||
ORDER BY ts.scheduled_departure_date DESC LIMIT ${LIST_LIMIT}`,
|
|
||||||
[f.dateFrom, f.dateTo, f.directions, f.statuses, f.yardIds],
|
|
||||||
);
|
|
||||||
const count = (s: string) =>
|
|
||||||
rows.filter((r: Record<string, unknown>) => r.status === s).length;
|
|
||||||
return {
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Schedules', value: rows.length },
|
|
||||||
{ label: 'Dispatched', value: count('DISPATCHED') },
|
|
||||||
{ label: 'Arrived', value: count('ARRIVED') },
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const fleetWagons: ReportQuery = async (ds, f) => {
|
|
||||||
const rows = await ds.query(
|
|
||||||
`SELECT w.wagon_number, wt.name AS type,
|
|
||||||
wt.capacity_tons::float8 AS capacity_tons,
|
|
||||||
w.status, y.label AS current_yard
|
|
||||||
FROM freight.wagons w
|
|
||||||
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
|
||||||
LEFT JOIN freight.yards y ON y.id = w.current_yard_id
|
|
||||||
WHERE w.deleted_at IS NULL
|
|
||||||
AND ($1::text[] IS NULL OR w.status = ANY($1))
|
|
||||||
AND ($2::uuid[] IS NULL OR w.current_yard_id = ANY($2))
|
|
||||||
ORDER BY w.wagon_number LIMIT ${LIST_LIMIT}`,
|
|
||||||
[f.statuses, f.yardIds],
|
|
||||||
);
|
|
||||||
const count = (s: string) =>
|
|
||||||
rows.filter((r: Record<string, unknown>) => r.status === s).length;
|
|
||||||
return {
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Wagons', value: rows.length },
|
|
||||||
{ label: 'Available', value: count('AVAILABLE') },
|
|
||||||
{ label: 'Assigned', value: count('ASSIGNED') },
|
|
||||||
{ label: 'Maintenance', value: count('MAINTENANCE') },
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const fleetLocomotives: ReportQuery = async (ds, f) => {
|
|
||||||
const rows = await ds.query(
|
|
||||||
`SELECT l.code, l.name, l.locomotive_type,
|
|
||||||
l.max_pull_weight_tons::float8 AS max_pull_tons,
|
|
||||||
l.status, y.label AS current_yard
|
|
||||||
FROM freight.locomotives l
|
|
||||||
LEFT JOIN freight.yards y ON y.id = l.current_yard_id
|
|
||||||
WHERE l.deleted_at IS NULL
|
|
||||||
AND ($1::text[] IS NULL OR l.status = ANY($1))
|
|
||||||
AND ($2::uuid[] IS NULL OR l.current_yard_id = ANY($2))
|
|
||||||
ORDER BY l.code LIMIT ${LIST_LIMIT}`,
|
|
||||||
[f.statuses, f.yardIds],
|
|
||||||
);
|
|
||||||
const available = rows.filter(
|
|
||||||
(r: Record<string, unknown>) => r.status === 'AVAILABLE',
|
|
||||||
).length;
|
|
||||||
return {
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Locomotives', value: rows.length },
|
|
||||||
{ label: 'Available', value: available },
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const customersList: ReportQuery = async (ds, f) => {
|
|
||||||
const rows = await ds.query(
|
|
||||||
`SELECT c.name, c.type, c.kind, c.status, c.tin,
|
|
||||||
to_char(c.approved_at, 'YYYY-MM-DD') AS approved,
|
|
||||||
to_char(c.created_at, 'YYYY-MM-DD') AS created
|
|
||||||
FROM freight.companies c
|
|
||||||
WHERE c.deleted_at IS NULL
|
|
||||||
AND ($1::timestamptz IS NULL OR c.created_at >= $1)
|
|
||||||
AND ($2::timestamptz IS NULL OR c.created_at < $2)
|
|
||||||
AND ($3::text[] IS NULL OR c.status = ANY($3))
|
|
||||||
ORDER BY c.created_at DESC LIMIT ${LIST_LIMIT}`,
|
|
||||||
[f.dateFrom, f.dateTo, f.statuses],
|
|
||||||
);
|
|
||||||
const active = rows.filter(
|
|
||||||
(r: Record<string, unknown>) => r.status === 'active',
|
|
||||||
).length;
|
|
||||||
return {
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Customers', value: rows.length },
|
|
||||||
{ label: 'Active', value: active },
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const paymentsList: ReportQuery = async (ds, f) => {
|
|
||||||
// No deleted_at on freight.payments; statuses are lowercase-hyphenated.
|
|
||||||
const rows = await ds.query(
|
|
||||||
`SELECT to_char(p.created_at, 'YYYY-MM-DD HH24:MI') AS created,
|
|
||||||
p.method::text AS method, p.status::text AS status,
|
|
||||||
p.currency::text AS currency,
|
|
||||||
ROUND(p.amount)::float8 AS amount,
|
|
||||||
p.transaction_id, p.merchant_order_id,
|
|
||||||
to_char(p.paid_at, 'YYYY-MM-DD') AS paid
|
|
||||||
FROM freight.payments p
|
|
||||||
WHERE ($1::timestamptz IS NULL OR p.created_at >= $1)
|
|
||||||
AND ($2::timestamptz IS NULL OR p.created_at < $2)
|
|
||||||
AND ($3::text[] IS NULL OR p.status::text = ANY($3))
|
|
||||||
AND ${refDirScope('p.ref_id', '$4')}
|
|
||||||
ORDER BY p.created_at DESC LIMIT ${LIST_LIMIT}`,
|
|
||||||
[f.dateFrom, f.dateTo, f.statuses, f.directions],
|
|
||||||
);
|
|
||||||
const success = rows.filter(
|
|
||||||
(r: Record<string, unknown>) => r.status === 'success',
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Payments', value: rows.length },
|
|
||||||
{ label: 'Successful', value: success.length },
|
|
||||||
{ label: 'Collected', value: sum(success, 'amount'), unit: 'ETB' },
|
|
||||||
],
|
|
||||||
rows,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const REPORT_QUERIES: Record<string, ReportQuery> = {
|
|
||||||
'bookings-list': bookingsList,
|
|
||||||
'contracts-list': contractsList,
|
|
||||||
'schedules-list': schedulesList,
|
|
||||||
'fleet-wagons': fleetWagons,
|
|
||||||
'fleet-locomotives': fleetLocomotives,
|
|
||||||
'customers-list': customersList,
|
|
||||||
'payments-list': paymentsList,
|
|
||||||
'bookings-trend': bookingsTrend,
|
|
||||||
'revenue-by-customer': revenueByCustomer,
|
|
||||||
'revenue-by-lane': revenueByLane,
|
|
||||||
'contract-utilization': contractUtilization,
|
|
||||||
'train-on-time': trainOnTime,
|
|
||||||
'schedule-fill-rate': scheduleFillRate,
|
|
||||||
'trips-per-route': tripsPerRoute,
|
|
||||||
'invoiced-vs-collected': invoicedVsCollected,
|
|
||||||
'aging-receivables': agingReceivables,
|
|
||||||
'revenue-by-payment-method': revenueByPaymentMethod,
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildPaginationMeta,
|
||||||
|
normalizePagination,
|
||||||
|
} from '../../common/utils/pagination.util';
|
||||||
|
import { applyBookingRefDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||||
|
import { ReportDefinition, ReportRunResult } from './report.types';
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Raw query params, minus the pagination/sort keys the runner owns. */
|
||||||
|
export type RawReportQuery = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce raw query strings into typed filter params per the report's own
|
||||||
|
* filter declarations. Unknown filter keys are ignored — `forbidNonWhitelisted`
|
||||||
|
* can't police a per-report bag, so extras are just dropped, not rejected.
|
||||||
|
*/
|
||||||
|
function coerceParams(
|
||||||
|
def: ReportDefinition,
|
||||||
|
raw: RawReportQuery,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
for (const filter of def.filters) {
|
||||||
|
if (filter.type === 'daterange') {
|
||||||
|
const from = raw[`${filter.key}From`];
|
||||||
|
const to = raw[`${filter.key}To`];
|
||||||
|
params[`${filter.key}From`] = from ? new Date(from).toISOString() : null;
|
||||||
|
// Inclusive end date, exclusive bound in SQL.
|
||||||
|
params[`${filter.key}To`] = to
|
||||||
|
? new Date(new Date(to).getTime() + DAY_MS).toISOString()
|
||||||
|
: null;
|
||||||
|
} else if (filter.type === 'multiselect') {
|
||||||
|
const csv = raw[filter.key];
|
||||||
|
const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? [];
|
||||||
|
params[filter.key] = items.length ? items : null;
|
||||||
|
} else {
|
||||||
|
params[filter.key] = raw[filter.key]?.trim() || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// idKey, when the report declares one, is a plain string param.
|
||||||
|
if (def.idKey) {
|
||||||
|
params[def.idKey.key] = raw[def.idKey.key]?.trim() || null;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a client-requested sort column against the report's own whitelist. */
|
||||||
|
function resolveSort(
|
||||||
|
def: ReportDefinition,
|
||||||
|
sortBy?: string,
|
||||||
|
sortOrder?: string,
|
||||||
|
): { key: string; expr: string; dir: 'ASC' | 'DESC' } | null {
|
||||||
|
const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||||
|
const requested = sortBy && def.columns.find((c) => c.key === sortBy && c.sortable);
|
||||||
|
if (requested) {
|
||||||
|
return { key: requested.key, expr: requested.sortExpr ?? requested.key, dir };
|
||||||
|
}
|
||||||
|
if (!def.defaultSort) return null;
|
||||||
|
const fallback = def.columns.find((c) => c.key === def.defaultSort!.key);
|
||||||
|
if (!fallback) return null;
|
||||||
|
return {
|
||||||
|
key: fallback.key,
|
||||||
|
expr: fallback.sortExpr ?? fallback.key,
|
||||||
|
dir: def.defaultSort.dir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReportRunnerService {
|
||||||
|
constructor(@InjectDataSource() private readonly ds: DataSource) {}
|
||||||
|
|
||||||
|
async run(
|
||||||
|
def: ReportDefinition,
|
||||||
|
raw: RawReportQuery,
|
||||||
|
directions: string[] | null,
|
||||||
|
): Promise<ReportRunResult> {
|
||||||
|
const params = coerceParams(def, raw);
|
||||||
|
const ctx = { ds: this.ds, params, directions };
|
||||||
|
|
||||||
|
const qb = def.query(ctx);
|
||||||
|
const sort = resolveSort(def, raw.sortBy, raw.sortOrder);
|
||||||
|
if (sort) qb.orderBy(sort.expr, sort.dir);
|
||||||
|
|
||||||
|
const { page: pageNum, pageSize, skip, take } = normalizePagination({
|
||||||
|
page: raw.page ? Number(raw.page) : undefined,
|
||||||
|
pageSize: raw.pageSize ? Number(raw.pageSize) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [sql, sqlParams] = qb.getQueryAndParameters();
|
||||||
|
// getCount() re-derives its own (wrong) select list for GROUP BY queries —
|
||||||
|
// wrapping the real query as a subquery counts exactly what will be paged.
|
||||||
|
const countRow = await this.ds.query(
|
||||||
|
`SELECT COUNT(*)::int AS c FROM (${sql}) report_count`,
|
||||||
|
sqlParams,
|
||||||
|
);
|
||||||
|
const total = Number(countRow[0]?.c ?? 0);
|
||||||
|
|
||||||
|
// .offset()/.limit(), not .skip()/.take() — skip/take route raw & grouped
|
||||||
|
// selects through TypeORM's DISTINCT-id subquery path, which is wrong here.
|
||||||
|
const items = await qb.offset(skip).limit(take).getRawMany();
|
||||||
|
|
||||||
|
const kpis = def.summary ? await def.summary(ctx) : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
columns: def.columns,
|
||||||
|
items,
|
||||||
|
meta: buildPaginationMeta(total, pageNum, pageSize),
|
||||||
|
kpis,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same query, no paging — used by the export path. */
|
||||||
|
async runAll(
|
||||||
|
def: ReportDefinition,
|
||||||
|
raw: RawReportQuery,
|
||||||
|
directions: string[] | null,
|
||||||
|
limit: number,
|
||||||
|
): Promise<{ columns: typeof def.columns; items: Record<string, unknown>[]; kpis: ReportRunResult['kpis'] }> {
|
||||||
|
const params = coerceParams(def, raw);
|
||||||
|
const ctx = { ds: this.ds, params, directions };
|
||||||
|
const qb = def.query(ctx);
|
||||||
|
const sort = resolveSort(def, undefined, undefined);
|
||||||
|
if (sort) qb.orderBy(sort.expr, sort.dir);
|
||||||
|
const items = await qb.limit(limit).getRawMany();
|
||||||
|
if (items.length >= limit) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Export exceeds the ${limit}-row cap for this format. Narrow the filters.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const kpis = def.summary ? await def.summary(ctx) : [];
|
||||||
|
return { columns: def.columns, items, kpis };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-exported so definitions can scope ACL columns without importing the
|
||||||
|
// trade-scope module directly.
|
||||||
|
export { applyBookingRefDirectionScope };
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { REPORT_KEYS } from '../../seed/freight-permissions.registry';
|
||||||
|
import { REPORTS, getReport } from './report.registry';
|
||||||
|
|
||||||
|
describe('REPORTS', () => {
|
||||||
|
it('has exactly one definition per seeded REPORT_KEYS entry', () => {
|
||||||
|
const defKeys = REPORTS.map((r) => r.key).sort();
|
||||||
|
expect(defKeys).toEqual([...REPORT_KEYS].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no duplicate keys', () => {
|
||||||
|
const keys = REPORTS.map((r) => r.key);
|
||||||
|
expect(new Set(keys).size).toBe(keys.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves every key via getReport', () => {
|
||||||
|
for (const key of REPORT_KEYS) {
|
||||||
|
expect(getReport(key)?.key).toBe(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every sortable column and defaultSort point at a real column key', () => {
|
||||||
|
for (const def of REPORTS) {
|
||||||
|
const columnKeys = new Set(def.columns.map((c) => c.key));
|
||||||
|
if (def.defaultSort) {
|
||||||
|
expect(columnKeys.has(def.defaultSort.key)).toBe(true);
|
||||||
|
}
|
||||||
|
// Every column marked sortable must have a resolvable key (itself, since
|
||||||
|
// the runner falls back to `key` when `sortExpr` is absent).
|
||||||
|
for (const col of def.columns.filter((c) => c.sortable)) {
|
||||||
|
expect(col.key.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('idKey, when declared, is not also listed as a user-facing filter', () => {
|
||||||
|
for (const def of REPORTS) {
|
||||||
|
if (!def.idKey) continue;
|
||||||
|
expect(def.filters.some((f) => f.key === def.idKey!.key)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
24
apps/edr-freight-api/src/modules/reports/report.registry.ts
Normal file
24
apps/edr-freight-api/src/modules/reports/report.registry.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { ReportKey } from '../../seed/freight-permissions.registry';
|
||||||
|
import { bookingsListReport } from './definitions/bookings-list.report';
|
||||||
|
import { revenueByCustomerReport } from './definitions/revenue-by-customer.report';
|
||||||
|
import { agingReceivablesReport } from './definitions/aging-receivables.report';
|
||||||
|
import { contractUtilizationReport } from './definitions/contract-utilization.report';
|
||||||
|
import { ReportDefinition } from './report.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every report the platform knows about. Adding one = a new file under
|
||||||
|
* definitions/ + a key in REPORT_KEYS (freight-permissions.registry.ts) +
|
||||||
|
* an entry here. Nothing else — no frontend edit, no route, no sidebar edit.
|
||||||
|
*/
|
||||||
|
export const REPORTS: ReportDefinition[] = [
|
||||||
|
bookingsListReport,
|
||||||
|
revenueByCustomerReport,
|
||||||
|
agingReceivablesReport,
|
||||||
|
contractUtilizationReport,
|
||||||
|
];
|
||||||
|
|
||||||
|
const BY_KEY = new Map<ReportKey, ReportDefinition>(REPORTS.map((r) => [r.key, r]));
|
||||||
|
|
||||||
|
export function getReport(key: string): ReportDefinition | undefined {
|
||||||
|
return BY_KEY.get(key as ReportKey);
|
||||||
|
}
|
||||||
95
apps/edr-freight-api/src/modules/reports/report.types.ts
Normal file
95
apps/edr-freight-api/src/modules/reports/report.types.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { DataSource, ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
import { ReportKey } from '../../seed/freight-permissions.registry';
|
||||||
|
|
||||||
|
export type { ReportKey };
|
||||||
|
|
||||||
|
export type ReportColumnType =
|
||||||
|
| 'string'
|
||||||
|
| 'number'
|
||||||
|
| 'money'
|
||||||
|
| 'tons'
|
||||||
|
| 'percent'
|
||||||
|
| 'date';
|
||||||
|
|
||||||
|
export interface ReportColumn {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: ReportColumnType;
|
||||||
|
sortable?: boolean;
|
||||||
|
/** SQL to ORDER BY when this column is sorted, if different from `key`. */
|
||||||
|
sortExpr?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text';
|
||||||
|
|
||||||
|
export interface ReportFilterOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportFilterDef {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: ReportFilterType;
|
||||||
|
/** Static option list for select/multiselect. */
|
||||||
|
options?: ReportFilterOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportKpi {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional entity scope a report can be embedded against — e.g. a
|
||||||
|
* contract-utilization report shown on a single contract's detail page.
|
||||||
|
* Purely descriptive; `query()` reads the resolved value off `ctx.params`
|
||||||
|
* like any other filter.
|
||||||
|
*/
|
||||||
|
export interface ReportIdKey {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportContext {
|
||||||
|
ds: DataSource;
|
||||||
|
/** Filter values, already coerced against `def.filters` (CSV → array, etc). */
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
/** Trade-scope-resolved directions. null = unrestricted, [] = show nothing. */
|
||||||
|
directions: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportDefinition {
|
||||||
|
key: ReportKey;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
group: 'Commercial' | 'Operations' | 'Finance';
|
||||||
|
idKey?: ReportIdKey;
|
||||||
|
filters: ReportFilterDef[];
|
||||||
|
columns: ReportColumn[];
|
||||||
|
defaultSort?: { key: string; dir: 'ASC' | 'DESC' };
|
||||||
|
query(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral>;
|
||||||
|
/** KPIs over the same filtered set; shown above the table and in exports. */
|
||||||
|
summary?(ctx: ReportContext): Promise<ReportKpi[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Catalog shape served by GET /reports — metadata only, no rows. */
|
||||||
|
export type ReportCatalogEntry = Omit<ReportDefinition, 'query' | 'summary'> & {
|
||||||
|
hasSummary: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ReportRunResult {
|
||||||
|
columns: ReportColumn[];
|
||||||
|
items: Record<string, unknown>[];
|
||||||
|
meta: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPreviousPage: boolean;
|
||||||
|
};
|
||||||
|
kpis: ReportKpi[];
|
||||||
|
}
|
||||||
@@ -1,34 +1,90 @@
|
|||||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
import { Controller, Get, NotFoundException, Param, Query, Res } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import type { Response } from 'express';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
import { BookingStaff } from '../../common/booking-guards';
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
||||||
|
import { FREIGHT_PERMS, reportPermissionKey } from '../../seed/freight-permissions.registry';
|
||||||
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||||
import { ReportQueryDto } from './dto/report-query.dto';
|
import { PDF_ROW_CAP, ReportExportService, XLSX_ROW_CAP } from './report-export.service';
|
||||||
import { ReportResultDto } from './dto/report-result.dto';
|
import { RawReportQuery, ReportRunnerService } from './report-runner.service';
|
||||||
import { ReportsService } from './reports.service';
|
import { REPORTS, getReport } from './report.registry';
|
||||||
|
import { ReportCatalogEntry, ReportDefinition } from './report.types';
|
||||||
|
|
||||||
|
const toCatalogEntry = (def: ReportDefinition): ReportCatalogEntry => {
|
||||||
|
const { query: _query, summary, ...meta } = def;
|
||||||
|
return { ...meta, hasSummary: Boolean(summary) };
|
||||||
|
};
|
||||||
|
|
||||||
@ApiTags('Reports')
|
@ApiTags('Reports')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('reports')
|
@Controller('reports')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.reports.view)
|
||||||
export class ReportsController {
|
export class ReportsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly reportsService: ReportsService,
|
private readonly runner: ReportRunnerService,
|
||||||
|
private readonly exportService: ReportExportService,
|
||||||
private readonly userTradeAccessService: UserTradeAccessService,
|
private readonly userTradeAccessService: UserTradeAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List reports the caller has permission to run' })
|
||||||
|
async catalog(@CurrentUser() user: TCurrentUser): Promise<ReportCatalogEntry[]> {
|
||||||
|
return REPORTS.filter((def) => hasFreightPermission(user, reportPermissionKey(def.key))).map(
|
||||||
|
toCatalogEntry,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':key')
|
@Get(':key')
|
||||||
@BookingStaff(FREIGHT_PERMS.reports.view)
|
@ApiOperation({ summary: 'Run a report by key, paginated/sorted/filtered' })
|
||||||
@ApiOperation({ summary: 'Run a canned report by key with optional filters' })
|
|
||||||
@ApiOkResponse({ type: ReportResultDto })
|
|
||||||
async run(
|
async run(
|
||||||
@Param('key') key: string,
|
@Param('key') key: string,
|
||||||
@Query() query: ReportQueryDto,
|
@Query() query: RawReportQuery,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
): Promise<ReportResultDto> {
|
) {
|
||||||
const allowed = await this.userTradeAccessService.resolveAllowedDirections(user);
|
const def = this.resolve(key, user);
|
||||||
return this.reportsService.run(key, query, allowed);
|
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
|
return this.runner.run(def, query, directions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':key/export')
|
||||||
|
@ApiOperation({ summary: 'Export a report to xlsx or pdf' })
|
||||||
|
async export(
|
||||||
|
@Param('key') key: string,
|
||||||
|
@Query() query: RawReportQuery & { format?: string },
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const def = this.resolve(key, user);
|
||||||
|
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
|
const format = query.format === 'pdf' ? 'pdf' : 'xlsx';
|
||||||
|
const cap = format === 'pdf' ? PDF_ROW_CAP : XLSX_ROW_CAP;
|
||||||
|
|
||||||
|
const { items, kpis } = await this.runner.runAll(def, query, directions, cap);
|
||||||
|
const buffer =
|
||||||
|
format === 'pdf'
|
||||||
|
? await this.exportService.toPdf(def, items, kpis)
|
||||||
|
: await this.exportService.toXlsx(def, items, kpis);
|
||||||
|
|
||||||
|
const filename = `${def.key}.${format === 'pdf' ? 'pdf' : 'xlsx'}`;
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Type',
|
||||||
|
format === 'pdf'
|
||||||
|
? 'application/pdf'
|
||||||
|
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
);
|
||||||
|
res.send(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolve(key: string, user: TCurrentUser): ReportDefinition {
|
||||||
|
const def = getReport(key);
|
||||||
|
if (!def) throw new NotFoundException(`Unknown report: ${key}`);
|
||||||
|
// Exact-match on purpose — unlike FreightPermissionGuard's :view/:read
|
||||||
|
// fallback, a report's own key is the only thing that opens it.
|
||||||
|
assertFreightPermission(user, reportPermissionKey(def.key));
|
||||||
|
return def;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { DocumentsModule } from '../billing/documents/documents.module';
|
||||||
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||||
|
import { ReportExportService } from './report-export.service';
|
||||||
|
import { ReportRunnerService } from './report-runner.service';
|
||||||
import { ReportsController } from './reports.controller';
|
import { ReportsController } from './reports.controller';
|
||||||
import { ReportsRepository } from './reports.repository';
|
|
||||||
import { ReportsService } from './reports.service';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UserTradeAccessModule],
|
imports: [UserTradeAccessModule, DocumentsModule],
|
||||||
controllers: [ReportsController],
|
controllers: [ReportsController],
|
||||||
providers: [ReportsService, ReportsRepository],
|
providers: [ReportRunnerService, ReportExportService],
|
||||||
})
|
})
|
||||||
export class ReportsModule {}
|
export class ReportsModule {}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
|
||||||
import { DataSource } from 'typeorm';
|
|
||||||
|
|
||||||
import { REPORT_QUERIES, ReportFilters, ReportResult } from './report-queries';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ReportsRepository {
|
|
||||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
|
||||||
|
|
||||||
run(key: keyof typeof REPORT_QUERIES, filters: ReportFilters): Promise<ReportResult> {
|
|
||||||
return REPORT_QUERIES[key](this.dataSource, filters);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { scopedDirections } from '../user-trade-access/trade-scope.util';
|
|
||||||
import { ReportQueryDto } from './dto/report-query.dto';
|
|
||||||
import { REPORT_QUERIES, ReportFilters, ReportResult } from './report-queries';
|
|
||||||
import { ReportsRepository } from './reports.repository';
|
|
||||||
import type { Freight } from '@edr/types';
|
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const list = (csv?: string): string[] | null => {
|
|
||||||
const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? [];
|
|
||||||
return items.length ? items : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ReportsService {
|
|
||||||
constructor(private readonly repository: ReportsRepository) {}
|
|
||||||
|
|
||||||
run(
|
|
||||||
key: string,
|
|
||||||
dto: ReportQueryDto,
|
|
||||||
allowedDirections: Freight.ScheduleTradeDirection[] | null,
|
|
||||||
): Promise<ReportResult> {
|
|
||||||
if (!(key in REPORT_QUERIES)) {
|
|
||||||
throw new NotFoundException(`Unknown report: ${key}`);
|
|
||||||
}
|
|
||||||
// No default range: absent dates mean all time, so exports cover everything.
|
|
||||||
const to = dto.dateTo ? new Date(dto.dateTo) : null;
|
|
||||||
const from = dto.dateFrom ? new Date(dto.dateFrom) : null;
|
|
||||||
const filters: ReportFilters = {
|
|
||||||
dateFrom: from ? from.toISOString() : null,
|
|
||||||
// dateTo is inclusive in the API; queries treat the bound as exclusive.
|
|
||||||
dateTo: to ? new Date(to.getTime() + DAY_MS).toISOString() : null,
|
|
||||||
granularity: dto.granularity ?? 'day',
|
|
||||||
companyIds: list(dto.companyIds),
|
|
||||||
routeIds: list(dto.routeIds),
|
|
||||||
yardIds: list(dto.yardIds),
|
|
||||||
cargoTypeIds: list(dto.cargoTypeIds),
|
|
||||||
statuses: list(dto.statuses),
|
|
||||||
directions: scopedDirections(allowedDirections, dto.direction),
|
|
||||||
freightType: dto.freightType ?? null,
|
|
||||||
};
|
|
||||||
return this.repository.run(key, filters);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
285
pnpm-lock.yaml
generated
285
pnpm-lock.yaml
generated
@@ -126,6 +126,9 @@ importers:
|
|||||||
dotenv-cli:
|
dotenv-cli:
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.0.0
|
version: 11.0.0
|
||||||
|
exceljs:
|
||||||
|
specifier: ^4.4.0
|
||||||
|
version: 4.4.0
|
||||||
handlebars:
|
handlebars:
|
||||||
specifier: ^4.7.9
|
specifier: ^4.7.9
|
||||||
version: 4.7.9
|
version: 4.7.9
|
||||||
@@ -601,7 +604,7 @@ importers:
|
|||||||
version: 5.101.0(react@19.2.6)
|
version: 5.101.0(react@19.2.6)
|
||||||
'@tria-plc/iamui':
|
'@tria-plc/iamui':
|
||||||
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
|
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
|
||||||
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)
|
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
|
||||||
'@vis.gl/react-google-maps':
|
'@vis.gl/react-google-maps':
|
||||||
specifier: ^1.8.3
|
specifier: ^1.8.3
|
||||||
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -13056,11 +13059,11 @@ snapshots:
|
|||||||
'@babel/helpers': 7.29.7
|
'@babel/helpers': 7.29.7
|
||||||
'@babel/parser': 7.29.7
|
'@babel/parser': 7.29.7
|
||||||
'@babel/template': 7.29.7
|
'@babel/template': 7.29.7
|
||||||
'@babel/traverse': 7.29.7
|
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||||
'@babel/types': 7.29.7
|
'@babel/types': 7.29.7
|
||||||
'@jridgewell/remapping': 2.3.5
|
'@jridgewell/remapping': 2.3.5
|
||||||
convert-source-map: 2.0.0
|
convert-source-map: 2.0.0
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
gensync: 1.0.0-beta.2
|
gensync: 1.0.0-beta.2
|
||||||
json5: 2.2.3
|
json5: 2.2.3
|
||||||
semver: 6.3.1
|
semver: 6.3.1
|
||||||
@@ -13095,7 +13098,7 @@ snapshots:
|
|||||||
'@babel/helper-optimise-call-expression': 7.29.7
|
'@babel/helper-optimise-call-expression': 7.29.7
|
||||||
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)
|
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)
|
||||||
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7
|
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7
|
||||||
'@babel/traverse': 7.29.7
|
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||||
semver: 6.3.1
|
semver: 6.3.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -13104,14 +13107,7 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/helper-member-expression-to-functions@7.29.7':
|
'@babel/helper-member-expression-to-functions@7.29.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/traverse': 7.29.7
|
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||||
'@babel/types': 7.29.7
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@babel/helper-module-imports@7.29.7':
|
|
||||||
dependencies:
|
|
||||||
'@babel/traverse': 7.29.7
|
|
||||||
'@babel/types': 7.29.7
|
'@babel/types': 7.29.7
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -13126,9 +13122,9 @@ snapshots:
|
|||||||
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
|
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.7
|
'@babel/core': 7.29.7
|
||||||
'@babel/helper-module-imports': 7.29.7
|
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||||
'@babel/helper-validator-identifier': 7.29.7
|
'@babel/helper-validator-identifier': 7.29.7
|
||||||
'@babel/traverse': 7.29.7
|
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -13143,13 +13139,13 @@ snapshots:
|
|||||||
'@babel/core': 7.29.7
|
'@babel/core': 7.29.7
|
||||||
'@babel/helper-member-expression-to-functions': 7.29.7
|
'@babel/helper-member-expression-to-functions': 7.29.7
|
||||||
'@babel/helper-optimise-call-expression': 7.29.7
|
'@babel/helper-optimise-call-expression': 7.29.7
|
||||||
'@babel/traverse': 7.29.7
|
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@babel/helper-skip-transparent-expression-wrappers@7.29.7':
|
'@babel/helper-skip-transparent-expression-wrappers@7.29.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/traverse': 7.29.7
|
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||||
'@babel/types': 7.29.7
|
'@babel/types': 7.29.7
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -13302,18 +13298,6 @@ snapshots:
|
|||||||
'@babel/parser': 7.29.7
|
'@babel/parser': 7.29.7
|
||||||
'@babel/types': 7.29.7
|
'@babel/types': 7.29.7
|
||||||
|
|
||||||
'@babel/traverse@7.29.7':
|
|
||||||
dependencies:
|
|
||||||
'@babel/code-frame': 7.29.7
|
|
||||||
'@babel/generator': 7.29.7
|
|
||||||
'@babel/helper-globals': 7.29.7
|
|
||||||
'@babel/parser': 7.29.7
|
|
||||||
'@babel/template': 7.29.7
|
|
||||||
'@babel/types': 7.29.7
|
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@babel/traverse@7.29.7(supports-color@5.5.0)':
|
'@babel/traverse@7.29.7(supports-color@5.5.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.7
|
'@babel/code-frame': 7.29.7
|
||||||
@@ -13798,7 +13782,7 @@ snapshots:
|
|||||||
|
|
||||||
'@emotion/babel-plugin@11.13.5':
|
'@emotion/babel-plugin@11.13.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-module-imports': 7.29.7
|
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||||
'@babel/runtime': 7.29.7
|
'@babel/runtime': 7.29.7
|
||||||
'@emotion/hash': 0.9.2
|
'@emotion/hash': 0.9.2
|
||||||
'@emotion/memoize': 0.9.0
|
'@emotion/memoize': 0.9.0
|
||||||
@@ -13964,7 +13948,7 @@ snapshots:
|
|||||||
'@eslint/eslintrc@2.1.4':
|
'@eslint/eslintrc@2.1.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
ajv: 6.15.0
|
ajv: 6.15.0
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
espree: 9.6.1
|
espree: 9.6.1
|
||||||
globals: 13.24.0
|
globals: 13.24.0
|
||||||
ignore: 5.3.2
|
ignore: 5.3.2
|
||||||
@@ -14124,7 +14108,7 @@ snapshots:
|
|||||||
'@humanwhocodes/config-array@0.13.0':
|
'@humanwhocodes/config-array@0.13.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@humanwhocodes/object-schema': 2.0.3
|
'@humanwhocodes/object-schema': 2.0.3
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
minimatch: 3.1.5
|
minimatch: 3.1.5
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -15723,7 +15707,7 @@ snapshots:
|
|||||||
|
|
||||||
'@puppeteer/browsers@2.13.2':
|
'@puppeteer/browsers@2.13.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
extract-zip: 2.0.1
|
extract-zip: 2.0.1
|
||||||
progress: 2.0.3
|
progress: 2.0.3
|
||||||
proxy-agent: 6.5.0
|
proxy-agent: 6.5.0
|
||||||
@@ -17797,7 +17781,7 @@ snapshots:
|
|||||||
|
|
||||||
'@tokenizer/inflate@0.4.1':
|
'@tokenizer/inflate@0.4.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
token-types: 6.1.2
|
token-types: 6.1.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -18084,130 +18068,6 @@ snapshots:
|
|||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
- vite
|
- vite
|
||||||
|
|
||||||
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)':
|
|
||||||
dependencies:
|
|
||||||
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
|
|
||||||
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
|
|
||||||
'@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6))
|
|
||||||
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
|
|
||||||
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
|
|
||||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@mantine/hooks': 7.17.8(react@19.2.6)
|
|
||||||
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6)
|
|
||||||
'@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@react-pdf/renderer': 4.5.1(react@19.2.6)
|
|
||||||
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
|
|
||||||
'@tabler/icons-react': 3.44.0(react@19.2.6)
|
|
||||||
'@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
|
|
||||||
'@tanstack/react-query': 5.101.0(react@19.2.6)
|
|
||||||
'@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6)
|
|
||||||
'@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3)
|
|
||||||
'@types/dompurify': 3.2.0
|
|
||||||
'@types/node': 24.13.1
|
|
||||||
'@types/tinymce': 4.6.9
|
|
||||||
axios: 1.17.0
|
|
||||||
class-variance-authority: 0.7.1
|
|
||||||
clsx: 2.1.1
|
|
||||||
cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
date-fns: 3.6.0
|
|
||||||
dayjs: 1.11.21
|
|
||||||
dompurify: 3.4.8
|
|
||||||
ethiopian-calendar-date-converter: 2.1.6
|
|
||||||
ethiopian-calendar-new: 1.1.0
|
|
||||||
file-type: 18.7.0
|
|
||||||
framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
html2canvas: 1.4.1
|
|
||||||
i18next: 25.10.10(typescript@5.9.3)
|
|
||||||
i18next-browser-languagedetector: 8.2.1
|
|
||||||
jquery: 3.7.1
|
|
||||||
js-cookie: 3.0.8
|
|
||||||
jspdf: 3.0.4
|
|
||||||
lodash: 4.18.1
|
|
||||||
lucide-react: 0.513.0(react@19.2.6)
|
|
||||||
mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d)
|
|
||||||
next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
path: 0.12.7
|
|
||||||
pdf-lib: 1.17.1
|
|
||||||
qs: 6.15.2
|
|
||||||
react: 19.2.6
|
|
||||||
react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6)
|
|
||||||
react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
|
|
||||||
react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6)
|
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
|
||||||
react-dropzone: 14.4.1(react@19.2.6)
|
|
||||||
react-hook-form: 7.77.0(react@19.2.6)
|
|
||||||
react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
|
|
||||||
react-icons: 5.6.0(react@19.2.6)
|
|
||||||
react-image-crop: 11.0.10(react@19.2.6)
|
|
||||||
react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6)
|
|
||||||
react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1)
|
|
||||||
react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)
|
|
||||||
rollup-plugin-visualizer: 7.0.1(rollup@4.61.1)
|
|
||||||
socket.io-client: 4.8.3
|
|
||||||
sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
tailwind-merge: 3.6.0
|
|
||||||
tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0)
|
|
||||||
tailwindcss: 4.3.0
|
|
||||||
tailwindcss-animate: 1.0.7(tailwindcss@4.3.0)
|
|
||||||
tinymce: 7.9.3
|
|
||||||
url: 0.11.4
|
|
||||||
vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
xlsx: 0.18.5
|
|
||||||
zod: 3.25.76
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@babel/core'
|
|
||||||
- '@emotion/is-prop-valid'
|
|
||||||
- '@mui/icons-material'
|
|
||||||
- '@mui/material'
|
|
||||||
- '@mui/x-date-pickers'
|
|
||||||
- '@types/prop-types'
|
|
||||||
- '@types/react'
|
|
||||||
- '@types/react-dom'
|
|
||||||
- bufferutil
|
|
||||||
- debug
|
|
||||||
- pdfjs-dist
|
|
||||||
- prop-types
|
|
||||||
- react-is
|
|
||||||
- react-native
|
|
||||||
- redux
|
|
||||||
- rolldown
|
|
||||||
- rollup
|
|
||||||
- supports-color
|
|
||||||
- typescript
|
|
||||||
- utf-8-validate
|
|
||||||
- vite
|
|
||||||
|
|
||||||
'@ts-morph/common@0.27.0':
|
'@ts-morph/common@0.27.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-glob: 3.3.3
|
fast-glob: 3.3.3
|
||||||
@@ -18605,7 +18465,7 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.60.1
|
'@typescript-eslint/types': 8.60.1
|
||||||
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
|
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
|
||||||
'@typescript-eslint/visitor-keys': 8.60.1
|
'@typescript-eslint/visitor-keys': 8.60.1
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -18615,7 +18475,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
|
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
|
||||||
'@typescript-eslint/types': 8.60.1
|
'@typescript-eslint/types': 8.60.1
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -18634,7 +18494,7 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.60.1
|
'@typescript-eslint/types': 8.60.1
|
||||||
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
|
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
|
||||||
'@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
|
'@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
@@ -18649,7 +18509,7 @@ snapshots:
|
|||||||
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
|
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
|
||||||
'@typescript-eslint/types': 8.60.1
|
'@typescript-eslint/types': 8.60.1
|
||||||
'@typescript-eslint/visitor-keys': 8.60.1
|
'@typescript-eslint/visitor-keys': 8.60.1
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
minimatch: 10.2.5
|
minimatch: 10.2.5
|
||||||
semver: 7.8.2
|
semver: 7.8.2
|
||||||
tinyglobby: 0.2.17
|
tinyglobby: 0.2.17
|
||||||
@@ -18938,7 +18798,7 @@ snapshots:
|
|||||||
|
|
||||||
agent-base@6.0.2:
|
agent-base@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -19447,16 +19307,6 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0):
|
|
||||||
dependencies:
|
|
||||||
'@babel/helper-annotate-as-pure': 7.29.7
|
|
||||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
|
||||||
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
|
|
||||||
picomatch: 4.0.4
|
|
||||||
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
babel-polyfill@6.26.0:
|
babel-polyfill@6.26.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
babel-runtime: 6.26.0
|
babel-runtime: 6.26.0
|
||||||
@@ -19612,7 +19462,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
bytes: 3.1.2
|
bytes: 3.1.2
|
||||||
content-type: 1.0.5
|
content-type: 1.0.5
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
http-errors: 2.0.1
|
http-errors: 2.0.1
|
||||||
iconv-lite: 0.7.2
|
iconv-lite: 0.7.2
|
||||||
on-finished: 2.4.1
|
on-finished: 2.4.1
|
||||||
@@ -20661,7 +20511,7 @@ snapshots:
|
|||||||
engine.io-client@6.6.5:
|
engine.io-client@6.6.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@socket.io/component-emitter': 3.1.2
|
'@socket.io/component-emitter': 3.1.2
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
engine.io-parser: 5.2.3
|
engine.io-parser: 5.2.3
|
||||||
ws: 8.20.1
|
ws: 8.20.1
|
||||||
xmlhttprequest-ssl: 2.1.2
|
xmlhttprequest-ssl: 2.1.2
|
||||||
@@ -20681,7 +20531,7 @@ snapshots:
|
|||||||
base64id: 2.0.0
|
base64id: 2.0.0
|
||||||
cookie: 0.7.2
|
cookie: 0.7.2
|
||||||
cors: 2.8.6
|
cors: 2.8.6
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
engine.io-parser: 5.2.3
|
engine.io-parser: 5.2.3
|
||||||
ws: 8.21.0
|
ws: 8.21.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -20912,7 +20762,7 @@ snapshots:
|
|||||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
|
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nolyfill/is-core-module': 1.0.39
|
'@nolyfill/is-core-module': 1.0.39
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
get-tsconfig: 4.14.0
|
get-tsconfig: 4.14.0
|
||||||
is-bun-module: 2.0.0
|
is-bun-module: 2.0.0
|
||||||
@@ -21040,7 +20890,7 @@ snapshots:
|
|||||||
ajv: 6.15.0
|
ajv: 6.15.0
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
doctrine: 3.0.0
|
doctrine: 3.0.0
|
||||||
escape-string-regexp: 4.0.0
|
escape-string-regexp: 4.0.0
|
||||||
eslint-scope: 7.2.2
|
eslint-scope: 7.2.2
|
||||||
@@ -21277,7 +21127,7 @@ snapshots:
|
|||||||
content-type: 1.0.5
|
content-type: 1.0.5
|
||||||
cookie: 0.7.2
|
cookie: 0.7.2
|
||||||
cookie-signature: 1.2.2
|
cookie-signature: 1.2.2
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
depd: 2.0.0
|
depd: 2.0.0
|
||||||
encodeurl: 2.0.0
|
encodeurl: 2.0.0
|
||||||
escape-html: 1.0.3
|
escape-html: 1.0.3
|
||||||
@@ -21330,7 +21180,7 @@ snapshots:
|
|||||||
|
|
||||||
extract-zip@2.0.1:
|
extract-zip@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
get-stream: 5.2.0
|
get-stream: 5.2.0
|
||||||
yauzl: 2.10.0
|
yauzl: 2.10.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -21485,7 +21335,7 @@ snapshots:
|
|||||||
|
|
||||||
finalhandler@2.1.1:
|
finalhandler@2.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
encodeurl: 2.0.0
|
encodeurl: 2.0.0
|
||||||
escape-html: 1.0.3
|
escape-html: 1.0.3
|
||||||
on-finished: 2.4.1
|
on-finished: 2.4.1
|
||||||
@@ -21733,7 +21583,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
basic-ftp: 5.3.1
|
basic-ftp: 5.3.1
|
||||||
data-uri-to-buffer: 6.0.2
|
data-uri-to-buffer: 6.0.2
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -22042,7 +21892,7 @@ snapshots:
|
|||||||
http-proxy-agent@7.0.2:
|
http-proxy-agent@7.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.4
|
agent-base: 7.1.4
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -22055,14 +21905,14 @@ snapshots:
|
|||||||
https-proxy-agent@5.0.1:
|
https-proxy-agent@5.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 6.0.2
|
agent-base: 6.0.2
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
https-proxy-agent@7.0.6:
|
https-proxy-agent@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.4
|
agent-base: 7.1.4
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -22502,7 +22352,7 @@ snapshots:
|
|||||||
|
|
||||||
istanbul-lib-source-maps@4.0.1:
|
istanbul-lib-source-maps@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
istanbul-lib-coverage: 3.2.2
|
istanbul-lib-coverage: 3.2.2
|
||||||
source-map: 0.6.1
|
source-map: 0.6.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -23162,7 +23012,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
commander: 13.1.0
|
commander: 13.1.0
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
execa: 8.0.1
|
execa: 8.0.1
|
||||||
lilconfig: 3.1.3
|
lilconfig: 3.1.3
|
||||||
listr2: 8.3.3
|
listr2: 8.3.3
|
||||||
@@ -23849,7 +23699,7 @@ snapshots:
|
|||||||
micromark@4.0.2:
|
micromark@4.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/debug': 4.1.13
|
'@types/debug': 4.1.13
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
decode-named-character-reference: 1.3.0
|
decode-named-character-reference: 1.3.0
|
||||||
devlop: 1.1.0
|
devlop: 1.1.0
|
||||||
micromark-core-commonmark: 2.0.3
|
micromark-core-commonmark: 2.0.3
|
||||||
@@ -24375,7 +24225,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tootallnate/quickjs-emscripten': 0.23.0
|
'@tootallnate/quickjs-emscripten': 0.23.0
|
||||||
agent-base: 7.1.4
|
agent-base: 7.1.4
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
get-uri: 6.0.5
|
get-uri: 6.0.5
|
||||||
http-proxy-agent: 7.0.2
|
http-proxy-agent: 7.0.2
|
||||||
https-proxy-agent: 7.0.6
|
https-proxy-agent: 7.0.6
|
||||||
@@ -24725,7 +24575,7 @@ snapshots:
|
|||||||
proxy-agent@6.5.0:
|
proxy-agent@6.5.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.4
|
agent-base: 7.1.4
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
http-proxy-agent: 7.0.2
|
http-proxy-agent: 7.0.2
|
||||||
https-proxy-agent: 7.0.6
|
https-proxy-agent: 7.0.6
|
||||||
lru-cache: 7.18.3
|
lru-cache: 7.18.3
|
||||||
@@ -24754,7 +24604,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@puppeteer/browsers': 2.13.2
|
'@puppeteer/browsers': 2.13.2
|
||||||
chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
|
chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
devtools-protocol: 0.0.1608973
|
devtools-protocol: 0.0.1608973
|
||||||
typed-query-selector: 2.12.2
|
typed-query-selector: 2.12.2
|
||||||
webdriver-bidi-protocol: 0.4.1
|
webdriver-bidi-protocol: 0.4.1
|
||||||
@@ -25007,15 +24857,6 @@ snapshots:
|
|||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- react-is
|
- react-is
|
||||||
|
|
||||||
react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
|
|
||||||
dependencies:
|
|
||||||
react: 19.2.6
|
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
|
||||||
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@babel/core'
|
|
||||||
- react-is
|
|
||||||
|
|
||||||
react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6):
|
react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
date-fns: 3.6.0
|
date-fns: 3.6.0
|
||||||
@@ -25626,7 +25467,7 @@ snapshots:
|
|||||||
|
|
||||||
router@2.2.0:
|
router@2.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
depd: 2.0.0
|
depd: 2.0.0
|
||||||
is-promise: 4.0.0
|
is-promise: 4.0.0
|
||||||
parseurl: 1.3.3
|
parseurl: 1.3.3
|
||||||
@@ -25748,7 +25589,7 @@ snapshots:
|
|||||||
|
|
||||||
send@1.2.1:
|
send@1.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
encodeurl: 2.0.0
|
encodeurl: 2.0.0
|
||||||
escape-html: 1.0.3
|
escape-html: 1.0.3
|
||||||
etag: 1.8.1
|
etag: 1.8.1
|
||||||
@@ -25964,7 +25805,7 @@ snapshots:
|
|||||||
|
|
||||||
socket.io-adapter@2.5.8:
|
socket.io-adapter@2.5.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
ws: 8.21.0
|
ws: 8.21.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- bufferutil
|
- bufferutil
|
||||||
@@ -25974,7 +25815,7 @@ snapshots:
|
|||||||
socket.io-client@4.8.3:
|
socket.io-client@4.8.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@socket.io/component-emitter': 3.1.2
|
'@socket.io/component-emitter': 3.1.2
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
engine.io-client: 6.6.5
|
engine.io-client: 6.6.5
|
||||||
socket.io-parser: 4.2.6
|
socket.io-parser: 4.2.6
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -25985,7 +25826,7 @@ snapshots:
|
|||||||
socket.io-parser@4.2.6:
|
socket.io-parser@4.2.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@socket.io/component-emitter': 3.1.2
|
'@socket.io/component-emitter': 3.1.2
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -25994,7 +25835,7 @@ snapshots:
|
|||||||
accepts: 1.3.8
|
accepts: 1.3.8
|
||||||
base64id: 2.0.0
|
base64id: 2.0.0
|
||||||
cors: 2.8.6
|
cors: 2.8.6
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
engine.io: 6.6.9
|
engine.io: 6.6.9
|
||||||
socket.io-adapter: 2.5.8
|
socket.io-adapter: 2.5.8
|
||||||
socket.io-parser: 4.2.6
|
socket.io-parser: 4.2.6
|
||||||
@@ -26006,7 +25847,7 @@ snapshots:
|
|||||||
socks-proxy-agent@8.0.5:
|
socks-proxy-agent@8.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.4
|
agent-base: 7.1.4
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
socks: 2.8.9
|
socks: 2.8.9
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -26301,24 +26142,6 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
|
|
||||||
styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
|
|
||||||
dependencies:
|
|
||||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
|
||||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
|
||||||
'@emotion/is-prop-valid': 1.4.0
|
|
||||||
'@emotion/stylis': 0.8.5
|
|
||||||
'@emotion/unitless': 0.7.5
|
|
||||||
babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0)
|
|
||||||
css-to-react-native: 3.2.0
|
|
||||||
hoist-non-react-statics: 3.3.2
|
|
||||||
react: 19.2.6
|
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
|
||||||
react-is: 19.2.7
|
|
||||||
shallowequal: 1.1.0
|
|
||||||
supports-color: 5.5.0
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@babel/core'
|
|
||||||
|
|
||||||
styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1):
|
styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
client-only: 0.0.1
|
client-only: 0.0.1
|
||||||
@@ -26344,7 +26167,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
component-emitter: 1.3.1
|
component-emitter: 1.3.1
|
||||||
cookiejar: 2.1.4
|
cookiejar: 2.1.4
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
fast-safe-stringify: 2.1.1
|
fast-safe-stringify: 2.1.1
|
||||||
form-data: 4.0.5
|
form-data: 4.0.5
|
||||||
formidable: 3.5.4
|
formidable: 3.5.4
|
||||||
@@ -26857,7 +26680,7 @@ snapshots:
|
|||||||
app-root-path: 3.1.0
|
app-root-path: 3.1.0
|
||||||
buffer: 6.0.3
|
buffer: 6.0.3
|
||||||
dayjs: 1.11.21
|
dayjs: 1.11.21
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
dedent: 1.7.2(babel-plugin-macros@3.1.0)
|
dedent: 1.7.2(babel-plugin-macros@3.1.0)
|
||||||
dotenv: 16.6.1
|
dotenv: 16.6.1
|
||||||
glob: 10.5.0
|
glob: 10.5.0
|
||||||
@@ -26881,7 +26704,7 @@ snapshots:
|
|||||||
app-root-path: 3.1.0
|
app-root-path: 3.1.0
|
||||||
buffer: 6.0.3
|
buffer: 6.0.3
|
||||||
dayjs: 1.11.21
|
dayjs: 1.11.21
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
dedent: 1.7.2(babel-plugin-macros@3.1.0)
|
dedent: 1.7.2(babel-plugin-macros@3.1.0)
|
||||||
dotenv: 16.6.1
|
dotenv: 16.6.1
|
||||||
glob: 10.5.0
|
glob: 10.5.0
|
||||||
@@ -27242,7 +27065,7 @@ snapshots:
|
|||||||
vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0):
|
vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
es-module-lexer: 1.7.0
|
es-module-lexer: 1.7.0
|
||||||
pathe: 1.1.2
|
pathe: 1.1.2
|
||||||
vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)
|
vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)
|
||||||
@@ -27260,7 +27083,7 @@ snapshots:
|
|||||||
vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0):
|
vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
es-module-lexer: 1.7.0
|
es-module-lexer: 1.7.0
|
||||||
pathe: 1.1.2
|
pathe: 1.1.2
|
||||||
vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)
|
vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)
|
||||||
@@ -27307,7 +27130,7 @@ snapshots:
|
|||||||
'@vitest/spy': 2.1.9
|
'@vitest/spy': 2.1.9
|
||||||
'@vitest/utils': 2.1.9
|
'@vitest/utils': 2.1.9
|
||||||
chai: 5.3.3
|
chai: 5.3.3
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
expect-type: 1.3.0
|
expect-type: 1.3.0
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
pathe: 1.1.2
|
pathe: 1.1.2
|
||||||
@@ -27343,7 +27166,7 @@ snapshots:
|
|||||||
'@vitest/spy': 2.1.9
|
'@vitest/spy': 2.1.9
|
||||||
'@vitest/utils': 2.1.9
|
'@vitest/utils': 2.1.9
|
||||||
chai: 5.3.3
|
chai: 5.3.3
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
expect-type: 1.3.0
|
expect-type: 1.3.0
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
pathe: 1.1.2
|
pathe: 1.1.2
|
||||||
|
|||||||
Reference in New Issue
Block a user