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

@@ -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;

View File

@@ -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;

View File

@@ -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<string, number>,
): 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,

View File

@@ -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<OverviewContractsTabDto> {
return this.overviewService.getContractsTab(query.range ?? '30d');
}
@Get('billing')
@BookingView()
@ApiOperation({ summary: 'Billing tab metrics and charts' })

View File

@@ -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,
]),

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,
}));
}
}

View File

@@ -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<OverviewContractsTabDto> {
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<OverviewBillingTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];

View File

@@ -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 (
<Paper p="lg" radius="lg" withBorder>
<Stack gap="md">
<Text fw={600}>Recent contracts</Text>
{contracts.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="lg">
No recent contracts
</Text>
) : (
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Reference</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Kind</Table.Th>
<Table.Th>Cargo</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Valid until</Table.Th>
<Table.Th>Created</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{contracts.map((contract) => (
<Table.Tr
key={contract.id}
style={{ cursor: "pointer" }}
onClick={() =>
navigate(`/dashboard/contract-requests/${contract.id}`)
}
>
<Table.Td>
<Text fw={600} size="sm">
{contract.reference}
</Text>
</Table.Td>
<Table.Td>{contract.customerLabel}</Table.Td>
<Table.Td>
<Badge variant="light" color="gray" radius="sm">
{kindLabel(contract.contractKind)}
</Badge>
</Table.Td>
<Table.Td>
{contract.freightType === "CONTAINER" ? "Container" : "Bulk"}
</Table.Td>
<Table.Td>
<ContractStatusBadge status={contract.status} />
</Table.Td>
<Table.Td>
{contract.validUntil
? new Date(contract.validUntil).toLocaleDateString()
: "—"}
</Table.Td>
<Table.Td>
{new Date(contract.createdAt).toLocaleDateString()}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Paper>
);
}

View File

@@ -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 && (
<OverviewBookingsTabPanel data={bookings.data} />
)}
{tab === "contracts" && contracts.data && (
<OverviewContractsTabPanel data={contracts.data} />
)}
{tab === "billing" && billing.data && (
<OverviewBillingTabPanel data={billing.data} />
)}

View File

@@ -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<string, string> = {
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 (
<Stack gap="lg">
<OverviewKpiStrip
items={[
{
label: "Active contracts",
value: data.kpis.totalActive,
icon: FileSignature,
accent: "emerald",
hint: "Currently in workflow",
},
{
label: "Needs action",
value: data.kpis.needsAction,
icon: AlertCircle,
accent: "amber",
hint: "Awaiting your review",
},
{
label: "In approval",
value: data.kpis.inApproval,
icon: UserCheck,
accent: "sky",
hint: "Pending sign-off",
},
{
label: "In clearance",
value: data.kpis.inClearance,
icon: ShieldCheck,
accent: "rose",
hint: "Customs / documents",
},
{
label: "Created today",
value: data.kpis.createdToday,
icon: FileSignature,
hint: "New since midnight",
},
]}
/>
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 7 }}>
<OverviewBookingTrendChart data={data.contractTrend} />
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}>
<OverviewHorizontalBarChart
title="Pipeline by stage"
data={data.contractsByPipeline.map((item) => ({
label: PIPELINE_STAGE_LABELS[item.stage] ?? item.stage,
value: item.count,
}))}
/>
</Grid.Col>
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="By status"
data={data.contractsByStatus.map((item) => ({
name: CONTRACT_STATUS_META[item.status]?.title ?? item.status,
value: item.count,
}))}
emptyMessage="No contracts yet"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="By kind"
data={data.contractsByKind.map((item) => ({
name: kindLabel(item.label),
value: item.count,
}))}
/>
</Grid.Col>
</Grid>
<OverviewHorizontalBarChart
title="By freight type"
data={data.contractsByFreightType.map((item) => ({
label: item.label === "CONTAINER" ? "Container" : "Bulk",
value: item.count,
}))}
/>
<OverviewRecentContractsTable contracts={data.recentContracts} />
</Stack>
);
}

View File

@@ -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,

View File

@@ -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",

View File

@@ -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),

View File

@@ -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",

View File

@@ -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<IOverviewContractsTab> => {
const response = await client.get<IOverviewContractsTab>(O.CONTRACTS, {
params: range ? { range } : undefined,
});
return unwrap(response);
},
getBillingTab: async (range?: OverviewRange): Promise<IOverviewBillingTab> => {
const response = await client.get<IOverviewBillingTab>(O.BILLING, {
params: range ? { range } : undefined,

View File

@@ -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,

View File

@@ -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() {
</Group>
<Group gap="sm">
{canSign && (
{canSign ? (
<Button
color="edr-green"
radius="md"
@@ -252,6 +255,24 @@ export default function ContractDetailPage() {
>
View &amp; sign contract
</Button>
) : (
contractPdf && (
<Button
variant="default"
radius="md"
size="md"
leftSection={<FileText size={16} />}
onClick={() =>
view({
name: contractPdf.name,
url: fileViewUrl(contractPdf.id),
mimeType: contractPdf.mimeType,
})
}
>
View contract
</Button>
)
)}
{canBookShipment && (
<Button
@@ -561,7 +582,7 @@ export default function ContractDetailPage() {
)}
</Box>
<Text fz={14} fw={700} style={{ color: GREEN }}>
{item.unitPrice.toLocaleString()} {pricing.currency}{" "}
{(item.unitPrice ?? 0).toLocaleString()} {pricing.currency}{" "}
<Text span fz={12} fw={600} c="dimmed">
/ {formatRateUnit(item.unit)}
</Text>

View File

@@ -32,8 +32,17 @@ export interface IOverviewStaffKpis {
activeUsers: number;
}
export interface IOverviewContractKpis {
totalActive: number;
needsAction: number;
inApproval: number;
inClearance: number;
createdToday: number;
}
export interface IOverviewKpis {
bookings: IOverviewBookingKpis;
contracts: IOverviewContractKpis;
operations: IOverviewOperationsKpis;
customers: IOverviewCustomerKpis;
billing: IOverviewBillingKpis;
@@ -72,6 +81,18 @@ export interface IOverviewRecentBooking {
createdAt: string;
}
export interface IOverviewRecentContract {
id: string;
reference: string;
customerLabel: string;
status: string;
contractKind: string;
freightType: string;
paymentCurrency: string | null;
validUntil: string | null;
createdAt: string;
}
export interface IOverviewDashboard {
kpis: IOverviewKpis;
bookingTrend: IOverviewTrendPoint[];
@@ -110,6 +131,17 @@ export interface IOverviewBookingsTab {
generatedAt: string;
}
export interface IOverviewContractsTab {
kpis: IOverviewContractKpis;
contractTrend: IOverviewTrendPoint[];
contractsByStatus: IOverviewStatusCount[];
contractsByPipeline: IOverviewPipelineCount[];
contractsByKind: IOverviewLabelCount[];
contractsByFreightType: IOverviewLabelCount[];
recentContracts: IOverviewRecentContract[];
generatedAt: string;
}
export interface IOverviewBillingTab {
kpis: IOverviewBillingKpis;
paymentTrend: IOverviewPaymentTrendPoint[];
@@ -146,6 +178,7 @@ export interface IOverviewStaffTab {
export type OverviewTabKey =
| 'bookings'
| 'contracts'
| 'billing'
| 'operations'
| 'customers'