diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts index 767a217a6..ab914deff 100644 --- a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts @@ -8,6 +8,14 @@ export class OverviewBookingKpisDto { @ApiProperty() submittedToday!: number; } +export class OverviewContractKpisDto { + @ApiProperty() totalActive!: number; + @ApiProperty() needsAction!: number; + @ApiProperty() inApproval!: number; + @ApiProperty() inClearance!: number; + @ApiProperty() createdToday!: number; +} + export class OverviewOperationsKpisDto { @ApiProperty() trainsActive!: number; @ApiProperty() wagonsAvailable!: number; @@ -36,6 +44,9 @@ export class OverviewKpisDto { @ApiProperty({ type: OverviewBookingKpisDto }) bookings!: OverviewBookingKpisDto; + @ApiProperty({ type: OverviewContractKpisDto }) + contracts!: OverviewContractKpisDto; + @ApiProperty({ type: OverviewOperationsKpisDto }) operations!: OverviewOperationsKpisDto; @@ -81,6 +92,18 @@ export class OverviewRecentBookingDto { @ApiProperty() createdAt!: string; } +export class OverviewRecentContractDto { + @ApiProperty() id!: string; + @ApiProperty() reference!: string; + @ApiProperty() customerLabel!: string; + @ApiProperty() status!: string; + @ApiProperty() contractKind!: string; + @ApiProperty() freightType!: string; + @ApiProperty({ nullable: true }) paymentCurrency!: string | null; + @ApiProperty({ nullable: true }) validUntil!: string | null; + @ApiProperty() createdAt!: string; +} + export class OverviewResponseDto { @ApiProperty({ type: OverviewKpisDto }) kpis!: OverviewKpisDto; diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts index c19a8baee..d4e5c2782 100644 --- a/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts @@ -3,11 +3,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { OverviewBillingKpisDto, OverviewBookingKpisDto, + OverviewContractKpisDto, OverviewCustomerKpisDto, OverviewOperationsKpisDto, OverviewPaymentTrendPointDto, OverviewPipelineCountDto, OverviewRecentBookingDto, + OverviewRecentContractDto, OverviewStaffKpisDto, OverviewStatusCountDto, OverviewTrendPointDto, @@ -56,6 +58,32 @@ export class OverviewBookingsTabDto { generatedAt!: string; } +export class OverviewContractsTabDto { + @ApiProperty({ type: OverviewContractKpisDto }) + kpis!: OverviewContractKpisDto; + + @ApiProperty({ type: [OverviewTrendPointDto] }) + contractTrend!: OverviewTrendPointDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + contractsByStatus!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewPipelineCountDto] }) + contractsByPipeline!: OverviewPipelineCountDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + contractsByKind!: OverviewLabelCountDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + contractsByFreightType!: OverviewLabelCountDto[]; + + @ApiProperty({ type: [OverviewRecentContractDto] }) + recentContracts!: OverviewRecentContractDto[]; + + @ApiProperty() + generatedAt!: string; +} + export class OverviewBillingTabDto { @ApiProperty({ type: OverviewBillingKpisDto }) kpis!: OverviewBillingKpisDto; diff --git a/apps/edr-freight-api/src/modules/overview/overview.constants.ts b/apps/edr-freight-api/src/modules/overview/overview.constants.ts index 6181d446e..b184924d9 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.constants.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.constants.ts @@ -1,9 +1,19 @@ export const OVERVIEW_URGENT_PRIORITY_THRESHOLD = 70; +// ── Booking status groupings ───────────────────────────────────────────────── +// Derived from the current BookingStatus enum (@edr/types). "needs action" = +// anything sitting on a staff queue; "closed" = terminal states. + export const OVERVIEW_NEEDS_ACTION_STATUSES = [ 'SUBMITTED', 'PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE', + 'AWAITING_DOCUMENTS', + 'DOCUMENTS_UNDER_REVIEW', + 'OPERATION_REQUEST_PENDING', + 'OPERATION_CHANGES_REQUESTED', + 'OPERATION_PRICE_PENDING_CONFIRM', + 'PENDING_CONSOLIDATION', ] as const; export const OVERVIEW_IN_APPROVAL_STATUSES = [ @@ -15,8 +25,82 @@ export const OVERVIEW_CLOSED_STATUSES = [ 'REJECTED', 'CANCELLED', 'COMPLETED', + 'DELIVERED', + 'EXPIRED', + 'CONTRACT_CLOSED', ] as const; +// ── Contract status groupings ──────────────────────────────────────────────── +// Derived from CONTRACT_STATUSES (@edr/types). Separate machine from bookings. + +export const OVERVIEW_CONTRACT_NEEDS_ACTION_STATUSES = [ + 'SUBMITTED', + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', + 'PRICE_CHANGED_PENDING_CONFIRM', + 'CHANGES_REQUESTED', + 'RENEWAL_SUBMITTED', + 'RENEWAL_PENDING_APPROVAL', + 'AMENDMENTS_PROPOSED', +] as const; + +export const OVERVIEW_CONTRACT_IN_APPROVAL_STATUSES = [ + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', + 'RENEWAL_PENDING_APPROVAL', +] as const; + +export const OVERVIEW_CONTRACT_IN_CLEARANCE_STATUSES = [ + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_UNDER_REVIEW', + 'CLEARANCE_READY_FOR_BOOKING', +] as const; + +export const OVERVIEW_CONTRACT_CLOSED_STATUSES = [ + 'CONTRACT_CLOSED', + 'EXPIRED', + 'REJECTED', + 'CANCELLED', + 'ARCHIVED', +] as const; + +/** Contract pipeline stages for the status chart, in workflow order. */ +export const OVERVIEW_CONTRACT_PIPELINE: ReadonlyArray<{ + stage: string; + statuses: readonly string[]; +}> = [ + { stage: 'draft', statuses: ['DRAFT', 'RENEWAL_DRAFT'] }, + { + stage: 'intake', + statuses: ['SUBMITTED', 'RENEWAL_SUBMITTED', 'PRICE_CHANGED_PENDING_CONFIRM', 'CHANGES_REQUESTED', 'AMENDMENTS_PROPOSED'], + }, + { + stage: 'in_approval', + statuses: ['PENDING_APPROVAL', 'APPROVED', 'APPROVED_PENDING_SIGNATURE', 'RENEWAL_PENDING_APPROVAL'], + }, + { stage: 'signing', statuses: ['CONTRACT_READY', 'SIGNED_CUSTOMER'] }, + { + stage: 'clearance', + statuses: ['AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING'], + }, + { + stage: 'active', + statuses: ['FULLY_EXECUTED', 'CONTRACT_ACTIVE', 'ACTIVE_SHIPMENT_IN_PROGRESS'], + }, + { stage: 'closed', statuses: ['CONTRACT_CLOSED', 'EXPIRED', 'ARCHIVED'] }, + { stage: 'cancelled', statuses: ['REJECTED', 'CANCELLED'] }, +]; + +/** Map a raw status→count record onto the contract pipeline stages. */ +export function mapContractStatusCountsToPipeline( + statusCounts: Record, +): Array<{ stage: string; count: number }> { + return OVERVIEW_CONTRACT_PIPELINE.map(({ stage, statuses }) => ({ + stage, + count: statuses.reduce((sum, s) => sum + (statusCounts[s] ?? 0), 0), + })); +} + export const OVERVIEW_RANGE_DAYS = { '7d': 7, '30d': 30, diff --git a/apps/edr-freight-api/src/modules/overview/overview.controller.ts b/apps/edr-freight-api/src/modules/overview/overview.controller.ts index fe545b452..e63cc5d01 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.controller.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.controller.ts @@ -12,6 +12,7 @@ import { OverviewResponseDto } from './dto/overview-response.dto'; import { OverviewBillingTabDto, OverviewBookingsTabDto, + OverviewContractsTabDto, OverviewCustomersTabDto, OverviewOperationsTabDto, OverviewStaffTabDto, @@ -40,6 +41,14 @@ export class OverviewController { return this.overviewService.getBookingsTab(query.range ?? '30d'); } + @Get('contracts') + @BookingView() + @ApiOperation({ summary: 'Contracts tab metrics and charts' }) + @ApiOkResponse({ type: OverviewContractsTabDto }) + getContractsTab(@Query() query: OverviewQueryDto): Promise { + return this.overviewService.getContractsTab(query.range ?? '30d'); + } + @Get('billing') @BookingView() @ApiOperation({ summary: 'Billing tab metrics and charts' }) diff --git a/apps/edr-freight-api/src/modules/overview/overview.module.ts b/apps/edr-freight-api/src/modules/overview/overview.module.ts index 87cdc62d5..43fcdac0f 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.module.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.module.ts @@ -7,6 +7,7 @@ import { Booking } from "../bookings/entities/booking.entity"; import { Cargo } from "../cargoes/entities/cargoes.entity"; import { Container } from "../container-management/entities/container.entity"; import { Company } from "../companies/entities/company.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"; @@ -24,6 +25,7 @@ import { OverviewService } from "./overview.service"; Wagon, Container, Cargo, + Contract, Employee, User, ]), diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index a57ba24db..4e56f415f 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -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, @InjectRepository(Cargo) private readonly cargoRepository: Repository, + @InjectRepository(Contract) + private readonly contractRepository: Repository, @InjectRepository(Employee) private readonly employeeRepository: Repository, @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 { + 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>(); + + 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> { + 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 { + 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, + })); + } } diff --git a/apps/edr-freight-api/src/modules/overview/overview.service.ts b/apps/edr-freight-api/src/modules/overview/overview.service.ts index feadf2409..8a7b71b69 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.service.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.service.ts @@ -9,11 +9,15 @@ import type { OverviewResponseDto } from './dto/overview-response.dto'; import type { OverviewBillingTabDto, OverviewBookingsTabDto, + OverviewContractsTabDto, OverviewCustomersTabDto, OverviewOperationsTabDto, OverviewStaffTabDto, } from './dto/overview-tab-response.dto'; -import { OVERVIEW_RANGE_DAYS } from './overview.constants'; +import { + OVERVIEW_RANGE_DAYS, + mapContractStatusCountsToPipeline, +} from './overview.constants'; import { OverviewRepository } from './overview.repository'; @Injectable() @@ -41,6 +45,7 @@ export class OverviewService { const [ bookingKpis, + contractKpis, operationsKpis, customerKpis, billingKpis, @@ -51,6 +56,7 @@ export class OverviewService { recentBookings, ] = await Promise.all([ this.overviewRepository.getBookingKpis(), + this.overviewRepository.getContractKpis(), this.overviewRepository.getOperationsKpis(), this.overviewRepository.getCustomerKpis(), this.overviewRepository.getBillingKpis(), @@ -67,6 +73,7 @@ export class OverviewService { return { kpis: { bookings: bookingKpis, + contracts: contractKpis, operations: operationsKpis, customers: customerKpis, billing: billingKpis, @@ -121,6 +128,48 @@ export class OverviewService { }; } + async getContractsTab( + range: OverviewRangeQuery = '30d', + ): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + + const [ + kpis, + contractTrend, + statusCounts, + contractsByKind, + contractsByFreightType, + recentContracts, + ] = await Promise.all([ + this.overviewRepository.getContractKpis(), + this.overviewRepository.getContractTrend(days), + this.overviewRepository.getContractStatusCounts(), + this.overviewRepository.getContractsByKind(), + this.overviewRepository.getContractsByFreightType(), + this.overviewRepository.getRecentContracts(8), + ]); + + const contractsByStatus = Object.entries(statusCounts) + .map(([status, count]) => ({ status, count })) + .sort((a, b) => b.count - a.count); + const contractsByPipeline = mapContractStatusCountsToPipeline(statusCounts); + + return { + kpis, + contractTrend, + contractsByStatus, + contractsByPipeline, + contractsByKind, + contractsByFreightType, + recentContracts: recentContracts.map((row) => ({ + ...row, + validUntil: row.validUntil ? row.validUntil.toISOString() : null, + createdAt: row.createdAt.toISOString(), + })), + generatedAt: new Date().toISOString(), + }; + } + async getBillingTab(range: OverviewRangeQuery = '30d'): Promise { const days = OVERVIEW_RANGE_DAYS[range]; diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentContractsTable.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentContractsTable.tsx new file mode 100644 index 000000000..219bf45fb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentContractsTable.tsx @@ -0,0 +1,81 @@ +import { useNavigate } from "react-router-dom"; +import { Badge, Paper, Stack, Table, Text } from "@mantine/core"; + +import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"; +import type { IOverviewRecentContract } from "@/types/overview"; + +function kindLabel(kind: string) { + return kind === "GENERAL" ? "General" : "One-time"; +} + +export function OverviewRecentContractsTable({ + contracts, +}: { + contracts: IOverviewRecentContract[]; +}) { + const navigate = useNavigate(); + + return ( + + + Recent contracts + {contracts.length === 0 ? ( + + No recent contracts + + ) : ( + + + + Reference + Customer + Kind + Cargo + Status + Valid until + Created + + + + {contracts.map((contract) => ( + + navigate(`/dashboard/contract-requests/${contract.id}`) + } + > + + + {contract.reference} + + + {contract.customerLabel} + + + {kindLabel(contract.contractKind)} + + + + {contract.freightType === "CONTAINER" ? "Container" : "Bulk"} + + + + + + {contract.validUntil + ? new Date(contract.validUntil).toLocaleDateString() + : "—"} + + + {new Date(contract.createdAt).toLocaleDateString()} + + + ))} + +
+ )} +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx index 2438b0368..13a357cce 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx @@ -4,6 +4,7 @@ import { Alert, Button, Center, Loader, Paper, Skeleton, Stack } from "@mantine/ import { useOverviewBillingTab, useOverviewBookingsTab, + useOverviewContractsTab, useOverviewCustomersTab, useOverviewOperationsTab, useOverviewStaffTab, @@ -11,6 +12,7 @@ import { import type { OverviewRange, OverviewTabKey } from "@/types/overview"; import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel"; import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel"; +import { OverviewContractsTabPanel } from "./tabs/OverviewContractsTabPanel"; import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel"; import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel"; import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel"; @@ -32,6 +34,7 @@ interface OverviewTabContentProps { export function OverviewTabContent({ tab, range }: OverviewTabContentProps) { const bookings = useOverviewBookingsTab(range, tab === "bookings"); + const contracts = useOverviewContractsTab(range, tab === "contracts"); const billing = useOverviewBillingTab(range, tab === "billing"); const operations = useOverviewOperationsTab(tab === "operations"); const customers = useOverviewCustomersTab(range, tab === "customers"); @@ -40,13 +43,15 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) { const query = tab === "bookings" ? bookings - : tab === "billing" - ? billing - : tab === "operations" - ? operations - : tab === "customers" - ? customers - : staff; + : tab === "contracts" + ? contracts + : tab === "billing" + ? billing + : tab === "operations" + ? operations + : tab === "customers" + ? customers + : staff; const { isLoading, isError, refetch, isFetching } = query; @@ -85,6 +90,9 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) { {tab === "bookings" && bookings.data && ( )} + {tab === "contracts" && contracts.data && ( + + )} {tab === "billing" && billing.data && ( )} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx new file mode 100644 index 000000000..9786b11e2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx @@ -0,0 +1,129 @@ +import { + AlertCircle, + FileSignature, + ShieldCheck, + UserCheck, +} from "lucide-react"; +import { Grid, Stack } from "@mantine/core"; + +import { CONTRACT_STATUS_META } from "@/features/contracts/contract-status.config"; +import type { IOverviewContractsTab } from "@/types/overview"; +import { OverviewBookingTrendChart } from "../OverviewBookingTrendChart"; +import { OverviewDonutChart } from "../OverviewDonutChart"; +import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart"; +import { OverviewKpiStrip } from "../OverviewKpiStrip"; +import { OverviewRecentContractsTable } from "../OverviewRecentContractsTable"; + +/** Human labels for the contract pipeline stages (see OVERVIEW_CONTRACT_PIPELINE on the API). */ +const PIPELINE_STAGE_LABELS: Record = { + draft: "Draft", + intake: "Intake", + in_approval: "In approval", + signing: "Signing", + clearance: "Clearance", + active: "Active", + closed: "Closed", + cancelled: "Cancelled", +}; + +function kindLabel(kind: string) { + return kind === "GENERAL" ? "General" : kind === "ONE_TIME" ? "One-time" : kind; +} + +interface OverviewContractsTabPanelProps { + data: IOverviewContractsTab; +} + +export function OverviewContractsTabPanel({ + data, +}: OverviewContractsTabPanelProps) { + return ( + + + + + + + + + ({ + label: PIPELINE_STAGE_LABELS[item.stage] ?? item.stage, + value: item.count, + }))} + /> + + + + + + ({ + name: CONTRACT_STATUS_META[item.status]?.title ?? item.status, + value: item.count, + }))} + emptyMessage="No contracts yet" + /> + + + ({ + name: kindLabel(item.label), + value: item.count, + }))} + /> + + + + ({ + label: item.label === "CONTAINER" ? "Container" : "Bulk", + value: item.count, + }))} + /> + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 2a0422113..90cb52e08 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -127,6 +127,7 @@ export const QUERY_KEYS = { ROOT: ["overview"] as const, dashboard: (range?: string) => ["overview", "dashboard", range ?? "30d"] as const, bookingsTab: (range?: string) => ["overview", "bookings", range ?? "30d"] as const, + contractsTab: (range?: string) => ["overview", "contracts", range ?? "30d"] as const, billingTab: (range?: string) => ["overview", "billing", range ?? "30d"] as const, operationsTab: () => ["overview", "operations"] as const, customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const, diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index bcbe07dfa..261d8db11 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -88,6 +88,7 @@ export const URL_CONSTANTS = { OVERVIEW: { BASE: "/overview", BOOKINGS: "/overview/bookings", + CONTRACTS: "/overview/contracts", BILLING: "/overview/billing", OPERATIONS: "/overview/operations", CUSTOMERS: "/overview/customers", diff --git a/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts b/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts index cf89c32e6..023d3d9f1 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts @@ -19,6 +19,14 @@ export function useOverviewBookingsTab(range: OverviewRange, enabled: boolean) { }); } +export function useOverviewContractsTab(range: OverviewRange, enabled: boolean) { + return useQuery({ + queryKey: QUERY_KEYS.OVERVIEW.contractsTab(range), + queryFn: () => overviewService.getContractsTab(range), + enabled, + }); +} + export function useOverviewBillingTab(range: OverviewRange, enabled: boolean) { return useQuery({ queryKey: QUERY_KEYS.OVERVIEW.billingTab(range), diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx index 427787146..0813460f7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { AlertCircle, Banknote, + FileSignature, FileText, Train, UserCheck, @@ -31,7 +32,13 @@ const TAB_ITEMS: Array<{ value: OverviewTabKey; label: string; icon: typeof FileText; - kpiKey: "bookings" | "billing" | "operations" | "customers" | "staff"; + kpiKey: + | "bookings" + | "contracts" + | "billing" + | "operations" + | "customers" + | "staff"; metricKey: string; }> = [ { @@ -41,6 +48,13 @@ const TAB_ITEMS: Array<{ kpiKey: "bookings", metricKey: "totalActive", }, + { + value: "contracts", + label: "Contracts", + icon: FileSignature, + kpiKey: "contracts", + metricKey: "totalActive", + }, { value: "billing", label: "Billing", diff --git a/apps/edr-freight-web/backoffice/src/services/overview.service.ts b/apps/edr-freight-web/backoffice/src/services/overview.service.ts index d0dd79922..44258ec9b 100644 --- a/apps/edr-freight-web/backoffice/src/services/overview.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/overview.service.ts @@ -4,6 +4,7 @@ import { URL_CONSTANTS } from "@/constants/URLS"; import type { IOverviewBillingTab, IOverviewBookingsTab, + IOverviewContractsTab, IOverviewCustomersTab, IOverviewDashboard, IOverviewOperationsTab, @@ -28,6 +29,13 @@ export const overviewService = { return unwrap(response); }, + getContractsTab: async (range?: OverviewRange): Promise => { + const response = await client.get(O.CONTRACTS, { + params: range ? { range } : undefined, + }); + return unwrap(response); + }, + getBillingTab: async (range?: OverviewRange): Promise => { const response = await client.get(O.BILLING, { params: range ? { range } : undefined, diff --git a/apps/edr-freight-web/backoffice/src/types/overview.ts b/apps/edr-freight-web/backoffice/src/types/overview.ts index e534f44e8..126b5f79e 100644 --- a/apps/edr-freight-web/backoffice/src/types/overview.ts +++ b/apps/edr-freight-web/backoffice/src/types/overview.ts @@ -2,6 +2,7 @@ export type { IOverviewDashboard, IOverviewKpis, IOverviewBookingKpis, + IOverviewContractKpis, IOverviewOperationsKpis, IOverviewCustomerKpis, IOverviewBillingKpis, @@ -11,10 +12,12 @@ export type { IOverviewPipelineCount, IOverviewPaymentTrendPoint, IOverviewRecentBooking, + IOverviewRecentContract, IOverviewLabelCount, IOverviewPaymentMethodBreakdown, IOverviewCurrencyAmount, IOverviewBookingsTab, + IOverviewContractsTab, IOverviewBillingTab, IOverviewOperationsTab, IOverviewCustomersTab, diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 4f08103b0..34665bb4f 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -199,6 +199,9 @@ export default function ContractDetailPage() { const pricing = contract.pricingBreakdown; const files = contract.files ?? []; const docGroups = groupContractDocuments(files); + // The generated contract PDF — surfaced via a dedicated "View contract" button + // in the header (it's excluded from the Documents tab groups). + const contractPdf = files.find((f) => f.code === "contract"); const canSign = contract.status === "CONTRACT_READY"; const customsPath = contract.customsClearingEnabled; @@ -242,7 +245,7 @@ export default function ContractDetailPage() { - {canSign && ( + {canSign ? ( + ) : ( + contractPdf && ( + + ) )} {canBookShipment && (