add contracts overview tab with KPIs, recent contracts, and charts

- Introduced OverviewContractKpisDto and OverviewRecentContractDto for contract metrics.
- Implemented OverviewContractsTabDto to structure the contracts tab response.
- Added contract-related constants for status groupings and pipeline stages.
- Created OverviewContractsTabPanel and OverviewRecentContractsTable components for UI representation.
- Updated OverviewService and OverviewRepository to fetch contract data.
- Integrated contracts tab into OverviewController and OverviewTabContent.
- Added hooks for fetching contracts data in useOverview.
- Updated types in the overview module to include contracts.
- Enhanced the contract detail page with a "View contract" button for generated PDFs.
This commit is contained in:
Marshal
2026-06-28 17:54:03 +00:00
parent 11c7f1bb74
commit a34e846d90
18 changed files with 688 additions and 11 deletions

View File

@@ -9,17 +9,26 @@ import { Repository, ObjectLiteral } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
import { Cargo } from "../cargoes/entities/cargoes.entity";
import { Container } from "../container-management/entities/container.entity";
import { Contract } from "../contracts/entities/contract.entity";
import { PaymentEntity } from "../payment/entities/payment.entity";
import { Train } from "../trains/entities/train.entity";
import { Wagon } from "../wagons/entities/wagon.entity";
import {
OVERVIEW_CLOSED_STATUSES,
OVERVIEW_CONTRACT_CLOSED_STATUSES,
OVERVIEW_CONTRACT_IN_APPROVAL_STATUSES,
OVERVIEW_CONTRACT_IN_CLEARANCE_STATUSES,
OVERVIEW_CONTRACT_NEEDS_ACTION_STATUSES,
OVERVIEW_IN_APPROVAL_STATUSES,
OVERVIEW_NEEDS_ACTION_STATUSES,
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
} from "./overview.constants";
import { Company } from "../companies/entities/company.entity";
/** Bookings carry a contract_kind column; GENERAL = umbrella contract row, not a shipment. */
const EXCLUDE_GENERAL_CONTRACT_BOOKINGS =
"(booking.contract_kind IS NULL OR booking.contract_kind <> 'GENERAL')";
export type OverviewBookingKpisRow = {
totalActive: number;
needsAction: number;
@@ -39,6 +48,26 @@ export type OverviewRecentBookingRow = {
createdAt: Date;
};
export type OverviewContractKpisRow = {
totalActive: number;
needsAction: number;
inApproval: number;
inClearance: number;
createdToday: number;
};
export type OverviewRecentContractRow = {
id: string;
reference: string;
customerLabel: string;
status: string;
contractKind: string;
freightType: string;
paymentCurrency: string | null;
validUntil: Date | null;
createdAt: Date;
};
@Injectable()
export class OverviewRepository {
constructor(
@@ -56,6 +85,8 @@ export class OverviewRepository {
private readonly containerRepository: Repository<Container>,
@InjectRepository(Cargo)
private readonly cargoRepository: Repository<Cargo>,
@InjectRepository(Contract)
private readonly contractRepository: Repository<Contract>,
@InjectRepository(Employee)
private readonly employeeRepository: Repository<Employee>,
@InjectRepository(User)
@@ -86,6 +117,7 @@ export class OverviewRepository {
"submittedToday",
)
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.setParameters({
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
@@ -235,6 +267,7 @@ export class OverviewRepository {
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy("booking.created_at::date")
.orderBy("booking.created_at::date", "ASC")
@@ -252,6 +285,7 @@ export class OverviewRepository {
.select("booking.status", "status")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.groupBy("booking.status")
.getRawMany<{ status: string; count: string }>();
@@ -306,6 +340,7 @@ export class OverviewRepository {
.addSelect("booking.payment_currency", "paymentCurrency")
.addSelect("booking.created_at", "createdAt")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.orderBy("booking.created_at", "DESC")
.limit(limit)
.getRawMany<{
@@ -339,6 +374,7 @@ export class OverviewRepository {
.select("booking.freight_type", "label")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere("booking.status != 'DRAFT'")
.groupBy("booking.freight_type")
.orderBy("count", "DESC")
@@ -356,6 +392,7 @@ export class OverviewRepository {
.select("booking.payment_currency", "label")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere("booking.status != 'DRAFT'")
.groupBy("booking.payment_currency")
.orderBy("count", "DESC")
@@ -592,4 +629,142 @@ export class OverviewRepository {
{ label: "Inactive", count: inactive },
];
}
// ── Contracts (overview Contract tab) ──────────────────────────────────────
async getContractKpis(): Promise<OverviewContractKpisRow> {
const row = await this.contractRepository
.createQueryBuilder("contract")
.select(
`COUNT(*) FILTER (WHERE contract.status NOT IN (:...closedStatuses) AND contract.status != 'DRAFT')::int`,
"totalActive",
)
.addSelect(
`COUNT(*) FILTER (WHERE contract.status IN (:...needsActionStatuses))::int`,
"needsAction",
)
.addSelect(
`COUNT(*) FILTER (WHERE contract.status IN (:...inApprovalStatuses))::int`,
"inApproval",
)
.addSelect(
`COUNT(*) FILTER (WHERE contract.status IN (:...inClearanceStatuses))::int`,
"inClearance",
)
.addSelect(
`COUNT(*) FILTER (WHERE contract.created_at >= CURRENT_DATE AND contract.status != 'DRAFT')::int`,
"createdToday",
)
.where("contract.deleted_at IS NULL")
.setParameters({
closedStatuses: [...OVERVIEW_CONTRACT_CLOSED_STATUSES],
needsActionStatuses: [...OVERVIEW_CONTRACT_NEEDS_ACTION_STATUSES],
inApprovalStatuses: [...OVERVIEW_CONTRACT_IN_APPROVAL_STATUSES],
inClearanceStatuses: [...OVERVIEW_CONTRACT_IN_CLEARANCE_STATUSES],
})
.getRawOne<Record<string, string>>();
return {
totalActive: Number(row?.totalActive ?? 0),
needsAction: Number(row?.needsAction ?? 0),
inApproval: Number(row?.inApproval ?? 0),
inClearance: Number(row?.inClearance ?? 0),
createdToday: Number(row?.createdToday ?? 0),
};
}
async getContractStatusCounts(): Promise<Record<string, number>> {
const rows = await this.contractRepository
.createQueryBuilder("contract")
.select("contract.status", "status")
.addSelect("COUNT(*)::int", "count")
.where("contract.deleted_at IS NULL")
.groupBy("contract.status")
.getRawMany<{ status: string; count: string }>();
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
}
async getContractTrend(days: number): Promise<{ date: string; count: number }[]> {
const rows = await this.contractRepository
.createQueryBuilder("contract")
.select(`to_char(contract.created_at::date, 'YYYY-MM-DD')`, "date")
.addSelect("COUNT(*)::int", "count")
.where("contract.deleted_at IS NULL")
.andWhere(`contract.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy("contract.created_at::date")
.orderBy("contract.created_at::date", "ASC")
.getRawMany<{ date: string; count: string }>();
return rows.map((row) => ({ date: row.date, count: Number(row.count) }));
}
async getContractsByKind(): Promise<{ label: string; count: number }[]> {
const rows = await this.contractRepository
.createQueryBuilder("contract")
.select("contract.contract_kind", "label")
.addSelect("COUNT(*)::int", "count")
.where("contract.deleted_at IS NULL")
.andWhere("contract.status != 'DRAFT'")
.groupBy("contract.contract_kind")
.orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
}
async getContractsByFreightType(): Promise<{ label: string; count: number }[]> {
const rows = await this.contractRepository
.createQueryBuilder("contract")
.select("contract.freight_type", "label")
.addSelect("COUNT(*)::int", "count")
.where("contract.deleted_at IS NULL")
.andWhere("contract.status != 'DRAFT'")
.groupBy("contract.freight_type")
.orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
}
async getRecentContracts(limit: number): Promise<OverviewRecentContractRow[]> {
const rows = await this.contractRepository
.createQueryBuilder("contract")
.leftJoin("contract.company", "company")
.select("contract.id", "id")
.addSelect("contract.reference", "reference")
.addSelect("COALESCE(company.name, '—')", "customerLabel")
.addSelect("contract.status", "status")
.addSelect("contract.contract_kind", "contractKind")
.addSelect("contract.freight_type", "freightType")
.addSelect("contract.payment_currency", "paymentCurrency")
.addSelect("contract.contract_valid_until", "validUntil")
.addSelect("contract.created_at", "createdAt")
.where("contract.deleted_at IS NULL")
.orderBy("contract.created_at", "DESC")
.limit(limit)
.getRawMany<{
id: string;
reference: string;
customerLabel: string;
status: string;
contractKind: string;
freightType: string;
paymentCurrency: string | null;
validUntil: Date | null;
createdAt: Date;
}>();
return rows.map((row) => ({
id: row.id,
reference: row.reference,
customerLabel: row.customerLabel,
status: row.status,
contractKind: row.contractKind,
freightType: row.freightType,
paymentCurrency: row.paymentCurrency,
validUntil: row.validUntil,
createdAt: row.createdAt,
}));
}
}