mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
Merge branch 'freight_feature/contrat' of github.com:Tria-plc/edr-platform into freight_feature/contrat
This commit is contained in:
@@ -8,6 +8,14 @@ export class OverviewBookingKpisDto {
|
|||||||
@ApiProperty() submittedToday!: number;
|
@ApiProperty() submittedToday!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class OverviewContractKpisDto {
|
||||||
|
@ApiProperty() totalActive!: number;
|
||||||
|
@ApiProperty() needsAction!: number;
|
||||||
|
@ApiProperty() inApproval!: number;
|
||||||
|
@ApiProperty() inClearance!: number;
|
||||||
|
@ApiProperty() createdToday!: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class OverviewOperationsKpisDto {
|
export class OverviewOperationsKpisDto {
|
||||||
@ApiProperty() trainsActive!: number;
|
@ApiProperty() trainsActive!: number;
|
||||||
@ApiProperty() wagonsAvailable!: number;
|
@ApiProperty() wagonsAvailable!: number;
|
||||||
@@ -36,6 +44,9 @@ export class OverviewKpisDto {
|
|||||||
@ApiProperty({ type: OverviewBookingKpisDto })
|
@ApiProperty({ type: OverviewBookingKpisDto })
|
||||||
bookings!: OverviewBookingKpisDto;
|
bookings!: OverviewBookingKpisDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: OverviewContractKpisDto })
|
||||||
|
contracts!: OverviewContractKpisDto;
|
||||||
|
|
||||||
@ApiProperty({ type: OverviewOperationsKpisDto })
|
@ApiProperty({ type: OverviewOperationsKpisDto })
|
||||||
operations!: OverviewOperationsKpisDto;
|
operations!: OverviewOperationsKpisDto;
|
||||||
|
|
||||||
@@ -81,6 +92,18 @@ export class OverviewRecentBookingDto {
|
|||||||
@ApiProperty() createdAt!: string;
|
@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 {
|
export class OverviewResponseDto {
|
||||||
@ApiProperty({ type: OverviewKpisDto })
|
@ApiProperty({ type: OverviewKpisDto })
|
||||||
kpis!: OverviewKpisDto;
|
kpis!: OverviewKpisDto;
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { ApiProperty } from '@nestjs/swagger';
|
|||||||
import {
|
import {
|
||||||
OverviewBillingKpisDto,
|
OverviewBillingKpisDto,
|
||||||
OverviewBookingKpisDto,
|
OverviewBookingKpisDto,
|
||||||
|
OverviewContractKpisDto,
|
||||||
OverviewCustomerKpisDto,
|
OverviewCustomerKpisDto,
|
||||||
OverviewOperationsKpisDto,
|
OverviewOperationsKpisDto,
|
||||||
OverviewPaymentTrendPointDto,
|
OverviewPaymentTrendPointDto,
|
||||||
OverviewPipelineCountDto,
|
OverviewPipelineCountDto,
|
||||||
OverviewRecentBookingDto,
|
OverviewRecentBookingDto,
|
||||||
|
OverviewRecentContractDto,
|
||||||
OverviewStaffKpisDto,
|
OverviewStaffKpisDto,
|
||||||
OverviewStatusCountDto,
|
OverviewStatusCountDto,
|
||||||
OverviewTrendPointDto,
|
OverviewTrendPointDto,
|
||||||
@@ -56,6 +58,32 @@ export class OverviewBookingsTabDto {
|
|||||||
generatedAt!: string;
|
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 {
|
export class OverviewBillingTabDto {
|
||||||
@ApiProperty({ type: OverviewBillingKpisDto })
|
@ApiProperty({ type: OverviewBillingKpisDto })
|
||||||
kpis!: OverviewBillingKpisDto;
|
kpis!: OverviewBillingKpisDto;
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
export const OVERVIEW_URGENT_PRIORITY_THRESHOLD = 70;
|
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 = [
|
export const OVERVIEW_NEEDS_ACTION_STATUSES = [
|
||||||
'SUBMITTED',
|
'SUBMITTED',
|
||||||
'PENDING_APPROVAL',
|
'PENDING_APPROVAL',
|
||||||
'APPROVED_PENDING_SIGNATURE',
|
'APPROVED_PENDING_SIGNATURE',
|
||||||
|
'AWAITING_DOCUMENTS',
|
||||||
|
'DOCUMENTS_UNDER_REVIEW',
|
||||||
|
'OPERATION_REQUEST_PENDING',
|
||||||
|
'OPERATION_CHANGES_REQUESTED',
|
||||||
|
'OPERATION_PRICE_PENDING_CONFIRM',
|
||||||
|
'PENDING_CONSOLIDATION',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const OVERVIEW_IN_APPROVAL_STATUSES = [
|
export const OVERVIEW_IN_APPROVAL_STATUSES = [
|
||||||
@@ -15,8 +25,82 @@ export const OVERVIEW_CLOSED_STATUSES = [
|
|||||||
'REJECTED',
|
'REJECTED',
|
||||||
'CANCELLED',
|
'CANCELLED',
|
||||||
'COMPLETED',
|
'COMPLETED',
|
||||||
|
'DELIVERED',
|
||||||
|
'EXPIRED',
|
||||||
|
'CONTRACT_CLOSED',
|
||||||
] as const;
|
] 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 = {
|
export const OVERVIEW_RANGE_DAYS = {
|
||||||
'7d': 7,
|
'7d': 7,
|
||||||
'30d': 30,
|
'30d': 30,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { OverviewResponseDto } from './dto/overview-response.dto';
|
|||||||
import {
|
import {
|
||||||
OverviewBillingTabDto,
|
OverviewBillingTabDto,
|
||||||
OverviewBookingsTabDto,
|
OverviewBookingsTabDto,
|
||||||
|
OverviewContractsTabDto,
|
||||||
OverviewCustomersTabDto,
|
OverviewCustomersTabDto,
|
||||||
OverviewOperationsTabDto,
|
OverviewOperationsTabDto,
|
||||||
OverviewStaffTabDto,
|
OverviewStaffTabDto,
|
||||||
@@ -40,6 +41,14 @@ export class OverviewController {
|
|||||||
return this.overviewService.getBookingsTab(query.range ?? '30d');
|
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')
|
@Get('billing')
|
||||||
@BookingView()
|
@BookingView()
|
||||||
@ApiOperation({ summary: 'Billing tab metrics and charts' })
|
@ApiOperation({ summary: 'Billing tab metrics and charts' })
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Booking } from "../bookings/entities/booking.entity";
|
|||||||
import { Cargo } from "../cargoes/entities/cargoes.entity";
|
import { Cargo } from "../cargoes/entities/cargoes.entity";
|
||||||
import { Container } from "../container-management/entities/container.entity";
|
import { Container } from "../container-management/entities/container.entity";
|
||||||
import { Company } from "../companies/entities/company.entity";
|
import { Company } from "../companies/entities/company.entity";
|
||||||
|
import { Contract } from "../contracts/entities/contract.entity";
|
||||||
import { PaymentEntity } from "../payment/entities/payment.entity";
|
import { PaymentEntity } from "../payment/entities/payment.entity";
|
||||||
import { Train } from "../trains/entities/train.entity";
|
import { Train } from "../trains/entities/train.entity";
|
||||||
import { Wagon } from "../wagons/entities/wagon.entity";
|
import { Wagon } from "../wagons/entities/wagon.entity";
|
||||||
@@ -24,6 +25,7 @@ import { OverviewService } from "./overview.service";
|
|||||||
Wagon,
|
Wagon,
|
||||||
Container,
|
Container,
|
||||||
Cargo,
|
Cargo,
|
||||||
|
Contract,
|
||||||
Employee,
|
Employee,
|
||||||
User,
|
User,
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -9,17 +9,26 @@ import { Repository, ObjectLiteral } from "typeorm";
|
|||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { Cargo } from "../cargoes/entities/cargoes.entity";
|
import { Cargo } from "../cargoes/entities/cargoes.entity";
|
||||||
import { Container } from "../container-management/entities/container.entity";
|
import { Container } from "../container-management/entities/container.entity";
|
||||||
|
import { Contract } from "../contracts/entities/contract.entity";
|
||||||
import { PaymentEntity } from "../payment/entities/payment.entity";
|
import { PaymentEntity } from "../payment/entities/payment.entity";
|
||||||
import { Train } from "../trains/entities/train.entity";
|
import { Train } from "../trains/entities/train.entity";
|
||||||
import { Wagon } from "../wagons/entities/wagon.entity";
|
import { Wagon } from "../wagons/entities/wagon.entity";
|
||||||
import {
|
import {
|
||||||
OVERVIEW_CLOSED_STATUSES,
|
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_IN_APPROVAL_STATUSES,
|
||||||
OVERVIEW_NEEDS_ACTION_STATUSES,
|
OVERVIEW_NEEDS_ACTION_STATUSES,
|
||||||
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
|
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
|
||||||
} from "./overview.constants";
|
} from "./overview.constants";
|
||||||
import { Company } from "../companies/entities/company.entity";
|
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 = {
|
export type OverviewBookingKpisRow = {
|
||||||
totalActive: number;
|
totalActive: number;
|
||||||
needsAction: number;
|
needsAction: number;
|
||||||
@@ -39,6 +48,26 @@ export type OverviewRecentBookingRow = {
|
|||||||
createdAt: Date;
|
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()
|
@Injectable()
|
||||||
export class OverviewRepository {
|
export class OverviewRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -56,6 +85,8 @@ export class OverviewRepository {
|
|||||||
private readonly containerRepository: Repository<Container>,
|
private readonly containerRepository: Repository<Container>,
|
||||||
@InjectRepository(Cargo)
|
@InjectRepository(Cargo)
|
||||||
private readonly cargoRepository: Repository<Cargo>,
|
private readonly cargoRepository: Repository<Cargo>,
|
||||||
|
@InjectRepository(Contract)
|
||||||
|
private readonly contractRepository: Repository<Contract>,
|
||||||
@InjectRepository(Employee)
|
@InjectRepository(Employee)
|
||||||
private readonly employeeRepository: Repository<Employee>,
|
private readonly employeeRepository: Repository<Employee>,
|
||||||
@InjectRepository(User)
|
@InjectRepository(User)
|
||||||
@@ -86,6 +117,7 @@ export class OverviewRepository {
|
|||||||
"submittedToday",
|
"submittedToday",
|
||||||
)
|
)
|
||||||
.where("booking.deleted_at IS NULL")
|
.where("booking.deleted_at IS NULL")
|
||||||
|
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||||
.setParameters({
|
.setParameters({
|
||||||
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
|
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
|
||||||
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
|
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
|
||||||
@@ -235,6 +267,7 @@ export class OverviewRepository {
|
|||||||
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
|
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||||
.addSelect("COUNT(*)::int", "count")
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where("booking.deleted_at IS NULL")
|
.where("booking.deleted_at IS NULL")
|
||||||
|
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||||
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||||
.groupBy("booking.created_at::date")
|
.groupBy("booking.created_at::date")
|
||||||
.orderBy("booking.created_at::date", "ASC")
|
.orderBy("booking.created_at::date", "ASC")
|
||||||
@@ -252,6 +285,7 @@ export class OverviewRepository {
|
|||||||
.select("booking.status", "status")
|
.select("booking.status", "status")
|
||||||
.addSelect("COUNT(*)::int", "count")
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where("booking.deleted_at IS NULL")
|
.where("booking.deleted_at IS NULL")
|
||||||
|
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||||
.groupBy("booking.status")
|
.groupBy("booking.status")
|
||||||
.getRawMany<{ status: string; count: string }>();
|
.getRawMany<{ status: string; count: string }>();
|
||||||
|
|
||||||
@@ -306,6 +340,7 @@ export class OverviewRepository {
|
|||||||
.addSelect("booking.payment_currency", "paymentCurrency")
|
.addSelect("booking.payment_currency", "paymentCurrency")
|
||||||
.addSelect("booking.created_at", "createdAt")
|
.addSelect("booking.created_at", "createdAt")
|
||||||
.where("booking.deleted_at IS NULL")
|
.where("booking.deleted_at IS NULL")
|
||||||
|
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||||
.orderBy("booking.created_at", "DESC")
|
.orderBy("booking.created_at", "DESC")
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.getRawMany<{
|
.getRawMany<{
|
||||||
@@ -339,6 +374,7 @@ export class OverviewRepository {
|
|||||||
.select("booking.freight_type", "label")
|
.select("booking.freight_type", "label")
|
||||||
.addSelect("COUNT(*)::int", "count")
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where("booking.deleted_at IS NULL")
|
.where("booking.deleted_at IS NULL")
|
||||||
|
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||||
.andWhere("booking.status != 'DRAFT'")
|
.andWhere("booking.status != 'DRAFT'")
|
||||||
.groupBy("booking.freight_type")
|
.groupBy("booking.freight_type")
|
||||||
.orderBy("count", "DESC")
|
.orderBy("count", "DESC")
|
||||||
@@ -356,6 +392,7 @@ export class OverviewRepository {
|
|||||||
.select("booking.payment_currency", "label")
|
.select("booking.payment_currency", "label")
|
||||||
.addSelect("COUNT(*)::int", "count")
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where("booking.deleted_at IS NULL")
|
.where("booking.deleted_at IS NULL")
|
||||||
|
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||||
.andWhere("booking.status != 'DRAFT'")
|
.andWhere("booking.status != 'DRAFT'")
|
||||||
.groupBy("booking.payment_currency")
|
.groupBy("booking.payment_currency")
|
||||||
.orderBy("count", "DESC")
|
.orderBy("count", "DESC")
|
||||||
@@ -592,4 +629,142 @@ export class OverviewRepository {
|
|||||||
{ label: "Inactive", count: inactive },
|
{ 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ import type { OverviewResponseDto } from './dto/overview-response.dto';
|
|||||||
import type {
|
import type {
|
||||||
OverviewBillingTabDto,
|
OverviewBillingTabDto,
|
||||||
OverviewBookingsTabDto,
|
OverviewBookingsTabDto,
|
||||||
|
OverviewContractsTabDto,
|
||||||
OverviewCustomersTabDto,
|
OverviewCustomersTabDto,
|
||||||
OverviewOperationsTabDto,
|
OverviewOperationsTabDto,
|
||||||
OverviewStaffTabDto,
|
OverviewStaffTabDto,
|
||||||
} from './dto/overview-tab-response.dto';
|
} 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';
|
import { OverviewRepository } from './overview.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -41,6 +45,7 @@ export class OverviewService {
|
|||||||
|
|
||||||
const [
|
const [
|
||||||
bookingKpis,
|
bookingKpis,
|
||||||
|
contractKpis,
|
||||||
operationsKpis,
|
operationsKpis,
|
||||||
customerKpis,
|
customerKpis,
|
||||||
billingKpis,
|
billingKpis,
|
||||||
@@ -51,6 +56,7 @@ export class OverviewService {
|
|||||||
recentBookings,
|
recentBookings,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.overviewRepository.getBookingKpis(),
|
this.overviewRepository.getBookingKpis(),
|
||||||
|
this.overviewRepository.getContractKpis(),
|
||||||
this.overviewRepository.getOperationsKpis(),
|
this.overviewRepository.getOperationsKpis(),
|
||||||
this.overviewRepository.getCustomerKpis(),
|
this.overviewRepository.getCustomerKpis(),
|
||||||
this.overviewRepository.getBillingKpis(),
|
this.overviewRepository.getBillingKpis(),
|
||||||
@@ -67,6 +73,7 @@ export class OverviewService {
|
|||||||
return {
|
return {
|
||||||
kpis: {
|
kpis: {
|
||||||
bookings: bookingKpis,
|
bookings: bookingKpis,
|
||||||
|
contracts: contractKpis,
|
||||||
operations: operationsKpis,
|
operations: operationsKpis,
|
||||||
customers: customerKpis,
|
customers: customerKpis,
|
||||||
billing: billingKpis,
|
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> {
|
async getBillingTab(range: OverviewRangeQuery = '30d'): Promise<OverviewBillingTabDto> {
|
||||||
const days = OVERVIEW_RANGE_DAYS[range];
|
const days = OVERVIEW_RANGE_DAYS[range];
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Alert, Button, Center, Loader, Paper, Skeleton, Stack } from "@mantine/
|
|||||||
import {
|
import {
|
||||||
useOverviewBillingTab,
|
useOverviewBillingTab,
|
||||||
useOverviewBookingsTab,
|
useOverviewBookingsTab,
|
||||||
|
useOverviewContractsTab,
|
||||||
useOverviewCustomersTab,
|
useOverviewCustomersTab,
|
||||||
useOverviewOperationsTab,
|
useOverviewOperationsTab,
|
||||||
useOverviewStaffTab,
|
useOverviewStaffTab,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
|
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
|
||||||
import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel";
|
import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel";
|
||||||
import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel";
|
import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel";
|
||||||
|
import { OverviewContractsTabPanel } from "./tabs/OverviewContractsTabPanel";
|
||||||
import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel";
|
import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel";
|
||||||
import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel";
|
import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel";
|
||||||
import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel";
|
import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel";
|
||||||
@@ -32,6 +34,7 @@ interface OverviewTabContentProps {
|
|||||||
|
|
||||||
export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
||||||
const bookings = useOverviewBookingsTab(range, tab === "bookings");
|
const bookings = useOverviewBookingsTab(range, tab === "bookings");
|
||||||
|
const contracts = useOverviewContractsTab(range, tab === "contracts");
|
||||||
const billing = useOverviewBillingTab(range, tab === "billing");
|
const billing = useOverviewBillingTab(range, tab === "billing");
|
||||||
const operations = useOverviewOperationsTab(tab === "operations");
|
const operations = useOverviewOperationsTab(tab === "operations");
|
||||||
const customers = useOverviewCustomersTab(range, tab === "customers");
|
const customers = useOverviewCustomersTab(range, tab === "customers");
|
||||||
@@ -40,13 +43,15 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
|||||||
const query =
|
const query =
|
||||||
tab === "bookings"
|
tab === "bookings"
|
||||||
? bookings
|
? bookings
|
||||||
: tab === "billing"
|
: tab === "contracts"
|
||||||
? billing
|
? contracts
|
||||||
: tab === "operations"
|
: tab === "billing"
|
||||||
? operations
|
? billing
|
||||||
: tab === "customers"
|
: tab === "operations"
|
||||||
? customers
|
? operations
|
||||||
: staff;
|
: tab === "customers"
|
||||||
|
? customers
|
||||||
|
: staff;
|
||||||
|
|
||||||
const { isLoading, isError, refetch, isFetching } = query;
|
const { isLoading, isError, refetch, isFetching } = query;
|
||||||
|
|
||||||
@@ -85,6 +90,9 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
|||||||
{tab === "bookings" && bookings.data && (
|
{tab === "bookings" && bookings.data && (
|
||||||
<OverviewBookingsTabPanel data={bookings.data} />
|
<OverviewBookingsTabPanel data={bookings.data} />
|
||||||
)}
|
)}
|
||||||
|
{tab === "contracts" && contracts.data && (
|
||||||
|
<OverviewContractsTabPanel data={contracts.data} />
|
||||||
|
)}
|
||||||
{tab === "billing" && billing.data && (
|
{tab === "billing" && billing.data && (
|
||||||
<OverviewBillingTabPanel data={billing.data} />
|
<OverviewBillingTabPanel data={billing.data} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -127,6 +127,7 @@ export const QUERY_KEYS = {
|
|||||||
ROOT: ["overview"] as const,
|
ROOT: ["overview"] as const,
|
||||||
dashboard: (range?: string) => ["overview", "dashboard", range ?? "30d"] as const,
|
dashboard: (range?: string) => ["overview", "dashboard", range ?? "30d"] as const,
|
||||||
bookingsTab: (range?: string) => ["overview", "bookings", 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,
|
billingTab: (range?: string) => ["overview", "billing", range ?? "30d"] as const,
|
||||||
operationsTab: () => ["overview", "operations"] as const,
|
operationsTab: () => ["overview", "operations"] as const,
|
||||||
customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const,
|
customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const,
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export const URL_CONSTANTS = {
|
|||||||
OVERVIEW: {
|
OVERVIEW: {
|
||||||
BASE: "/overview",
|
BASE: "/overview",
|
||||||
BOOKINGS: "/overview/bookings",
|
BOOKINGS: "/overview/bookings",
|
||||||
|
CONTRACTS: "/overview/contracts",
|
||||||
BILLING: "/overview/billing",
|
BILLING: "/overview/billing",
|
||||||
OPERATIONS: "/overview/operations",
|
OPERATIONS: "/overview/operations",
|
||||||
CUSTOMERS: "/overview/customers",
|
CUSTOMERS: "/overview/customers",
|
||||||
|
|||||||
@@ -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) {
|
export function useOverviewBillingTab(range: OverviewRange, enabled: boolean) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: QUERY_KEYS.OVERVIEW.billingTab(range),
|
queryKey: QUERY_KEYS.OVERVIEW.billingTab(range),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Banknote,
|
Banknote,
|
||||||
|
FileSignature,
|
||||||
FileText,
|
FileText,
|
||||||
Train,
|
Train,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
@@ -31,7 +32,13 @@ const TAB_ITEMS: Array<{
|
|||||||
value: OverviewTabKey;
|
value: OverviewTabKey;
|
||||||
label: string;
|
label: string;
|
||||||
icon: typeof FileText;
|
icon: typeof FileText;
|
||||||
kpiKey: "bookings" | "billing" | "operations" | "customers" | "staff";
|
kpiKey:
|
||||||
|
| "bookings"
|
||||||
|
| "contracts"
|
||||||
|
| "billing"
|
||||||
|
| "operations"
|
||||||
|
| "customers"
|
||||||
|
| "staff";
|
||||||
metricKey: string;
|
metricKey: string;
|
||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
@@ -41,6 +48,13 @@ const TAB_ITEMS: Array<{
|
|||||||
kpiKey: "bookings",
|
kpiKey: "bookings",
|
||||||
metricKey: "totalActive",
|
metricKey: "totalActive",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "contracts",
|
||||||
|
label: "Contracts",
|
||||||
|
icon: FileSignature,
|
||||||
|
kpiKey: "contracts",
|
||||||
|
metricKey: "totalActive",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "billing",
|
value: "billing",
|
||||||
label: "Billing",
|
label: "Billing",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { URL_CONSTANTS } from "@/constants/URLS";
|
|||||||
import type {
|
import type {
|
||||||
IOverviewBillingTab,
|
IOverviewBillingTab,
|
||||||
IOverviewBookingsTab,
|
IOverviewBookingsTab,
|
||||||
|
IOverviewContractsTab,
|
||||||
IOverviewCustomersTab,
|
IOverviewCustomersTab,
|
||||||
IOverviewDashboard,
|
IOverviewDashboard,
|
||||||
IOverviewOperationsTab,
|
IOverviewOperationsTab,
|
||||||
@@ -28,6 +29,13 @@ export const overviewService = {
|
|||||||
return unwrap(response);
|
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> => {
|
getBillingTab: async (range?: OverviewRange): Promise<IOverviewBillingTab> => {
|
||||||
const response = await client.get<IOverviewBillingTab>(O.BILLING, {
|
const response = await client.get<IOverviewBillingTab>(O.BILLING, {
|
||||||
params: range ? { range } : undefined,
|
params: range ? { range } : undefined,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type {
|
|||||||
IOverviewDashboard,
|
IOverviewDashboard,
|
||||||
IOverviewKpis,
|
IOverviewKpis,
|
||||||
IOverviewBookingKpis,
|
IOverviewBookingKpis,
|
||||||
|
IOverviewContractKpis,
|
||||||
IOverviewOperationsKpis,
|
IOverviewOperationsKpis,
|
||||||
IOverviewCustomerKpis,
|
IOverviewCustomerKpis,
|
||||||
IOverviewBillingKpis,
|
IOverviewBillingKpis,
|
||||||
@@ -11,10 +12,12 @@ export type {
|
|||||||
IOverviewPipelineCount,
|
IOverviewPipelineCount,
|
||||||
IOverviewPaymentTrendPoint,
|
IOverviewPaymentTrendPoint,
|
||||||
IOverviewRecentBooking,
|
IOverviewRecentBooking,
|
||||||
|
IOverviewRecentContract,
|
||||||
IOverviewLabelCount,
|
IOverviewLabelCount,
|
||||||
IOverviewPaymentMethodBreakdown,
|
IOverviewPaymentMethodBreakdown,
|
||||||
IOverviewCurrencyAmount,
|
IOverviewCurrencyAmount,
|
||||||
IOverviewBookingsTab,
|
IOverviewBookingsTab,
|
||||||
|
IOverviewContractsTab,
|
||||||
IOverviewBillingTab,
|
IOverviewBillingTab,
|
||||||
IOverviewOperationsTab,
|
IOverviewOperationsTab,
|
||||||
IOverviewCustomersTab,
|
IOverviewCustomersTab,
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export default function MyPortalPage() {
|
|||||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||||
<RecentContractsSection
|
<RecentContractsSection
|
||||||
contracts={recentContracts}
|
contracts={recentContracts}
|
||||||
|
bookings={allBookings}
|
||||||
isLoading={contractsQuery.isPending}
|
isLoading={contractsQuery.isPending}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ArrowRight, FileSignature, Package, Plus } from "lucide-react";
|
import { ArrowRight, FileSignature, Package, Plus, RefreshCw } from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
ContractDocButton,
|
ContractDocButton,
|
||||||
ContractStatusBadge,
|
ContractStatusBadge,
|
||||||
} from "@/pages/contracts/contract-ui";
|
} from "@/pages/contracts/contract-ui";
|
||||||
|
import { getContractBookingAction } from "@/pages/contracts/contract-booking-action";
|
||||||
import { Card } from "./Card";
|
import { Card } from "./Card";
|
||||||
import { EmptyState } from "./EmptyState";
|
import { EmptyState } from "./EmptyState";
|
||||||
|
|
||||||
@@ -14,16 +15,15 @@ const INK = "#10202F";
|
|||||||
const MUTED = "#6B7C8E";
|
const MUTED = "#6B7C8E";
|
||||||
const BORDER = "#E6ECF2";
|
const BORDER = "#E6ECF2";
|
||||||
|
|
||||||
// Statuses where a Path A customer may book directly against the contract.
|
|
||||||
const BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
|
|
||||||
|
|
||||||
interface RecentContractsSectionProps {
|
interface RecentContractsSectionProps {
|
||||||
contracts: Freight.IContract[];
|
contracts: Freight.IContract[];
|
||||||
|
bookings: Freight.IBooking[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RecentContractsSection = memo(function RecentContractsSection({
|
export const RecentContractsSection = memo(function RecentContractsSection({
|
||||||
contracts,
|
contracts,
|
||||||
|
bookings,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: RecentContractsSectionProps) {
|
}: RecentContractsSectionProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -65,7 +65,7 @@ export const RecentContractsSection = memo(function RecentContractsSection({
|
|||||||
const isGeneral = c.contractKind === "GENERAL";
|
const isGeneral = c.contractKind === "GENERAL";
|
||||||
const isContainer = c.freightType === "CONTAINER";
|
const isContainer = c.freightType === "CONTAINER";
|
||||||
const canSign = c.status === "CONTRACT_READY";
|
const canSign = c.status === "CONTRACT_READY";
|
||||||
const canBook = !c.customsClearingEnabled && BOOKABLE.includes(c.status);
|
const bookingAction = getContractBookingAction(c, bookings);
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
key={c.id}
|
key={c.id}
|
||||||
@@ -109,18 +109,24 @@ export const RecentContractsSection = memo(function RecentContractsSection({
|
|||||||
>
|
>
|
||||||
Sign
|
Sign
|
||||||
</Button>
|
</Button>
|
||||||
) : canBook ? (
|
) : bookingAction.kind !== "none" ? (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
radius="md"
|
radius="md"
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
leftSection={<Package size={13} />}
|
leftSection={
|
||||||
|
bookingAction.kind === "rebook" ? (
|
||||||
|
<RefreshCw size={13} />
|
||||||
|
) : (
|
||||||
|
<Package size={13} />
|
||||||
|
)
|
||||||
|
}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigate(`/contracts/${c.id}/bookings/new`);
|
navigate(bookingAction.to);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Book
|
{bookingAction.kind === "rebook" ? "Re-book" : "Book"}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||||
const { view, viewer } = useFileViewer();
|
const { view, viewer } = useFileViewer();
|
||||||
|
|
||||||
|
// Re-book opens the New Shipment Booking form for the same contract, not the
|
||||||
|
// New Contract page. Fall back to /contracts/new only if the link is missing.
|
||||||
|
const rebookTo = booking.contractId
|
||||||
|
? `/contracts/${booking.contractId}/bookings/new`
|
||||||
|
: "/contracts/new";
|
||||||
|
const onRebook = () => navigate(rebookTo);
|
||||||
|
|
||||||
// POST /payments/initiate creates the intent and returns the provider's
|
// POST /payments/initiate creates the intent and returns the provider's
|
||||||
// redirect URL (clientAction.url). Send the browser straight there; fall back
|
// redirect URL (clientAction.url). Send the browser straight there; fall back
|
||||||
// to the public /payments/checkout page if no redirect URL came back.
|
// to the public /payments/checkout page if no redirect URL came back.
|
||||||
@@ -98,7 +105,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
}
|
}
|
||||||
menuActions={{
|
menuActions={{
|
||||||
onViewContract: booking.signedByCeoAt ? () => {} : undefined,
|
onViewContract: booking.signedByCeoAt ? () => {} : undefined,
|
||||||
onRebook: () => navigate("/bookings/new", { state: { fresh: true } }),
|
onRebook,
|
||||||
onSupport: () => navigate("/support"),
|
onSupport: () => navigate("/support"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -115,14 +122,14 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
: "This booking process has been terminated."
|
: "This booking process has been terminated."
|
||||||
}
|
}
|
||||||
reason={booking.latestChangeRequestNote}
|
reason={booking.latestChangeRequestNote}
|
||||||
onRebook={() => navigate("/bookings/new", { state: { fresh: true } })}
|
onRebook={onRebook}
|
||||||
/>
|
/>
|
||||||
) : isExpired ? (
|
) : isExpired ? (
|
||||||
<CancelledBanner
|
<CancelledBanner
|
||||||
pillLabel="Expired"
|
pillLabel="Expired"
|
||||||
title={`The payment window expired on ${fmtDate(booking.updatedAt)}.`}
|
title={`The payment window expired on ${fmtDate(booking.updatedAt)}.`}
|
||||||
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
|
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
|
||||||
onRebook={() => navigate("/bookings/new", { state: { fresh: true } })}
|
onRebook={onRebook}
|
||||||
/>
|
/>
|
||||||
) : isPendingConsolidation ? (
|
) : isPendingConsolidation ? (
|
||||||
<ConsolidationWaitingBanner
|
<ConsolidationWaitingBanner
|
||||||
|
|||||||
@@ -199,6 +199,9 @@ export default function ContractDetailPage() {
|
|||||||
const pricing = contract.pricingBreakdown;
|
const pricing = contract.pricingBreakdown;
|
||||||
const files = contract.files ?? [];
|
const files = contract.files ?? [];
|
||||||
const docGroups = groupContractDocuments(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 canSign = contract.status === "CONTRACT_READY";
|
||||||
const customsPath = contract.customsClearingEnabled;
|
const customsPath = contract.customsClearingEnabled;
|
||||||
@@ -242,7 +245,7 @@ export default function ContractDetailPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
{canSign && (
|
{canSign ? (
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -252,6 +255,24 @@ export default function ContractDetailPage() {
|
|||||||
>
|
>
|
||||||
View & sign contract
|
View & sign contract
|
||||||
</Button>
|
</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 && (
|
{canBookShipment && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { ContractListFilter } from "@/services/contracts.service";
|
import type { ContractListFilter } from "@/services/contracts.service";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
import { getContractBookingAction } from "./contract-booking-action";
|
||||||
import { usePagination } from "@edr/ui-common";
|
import { usePagination } from "@edr/ui-common";
|
||||||
import {
|
import {
|
||||||
BORDER,
|
BORDER,
|
||||||
@@ -52,7 +53,6 @@ function primaryRoute(contract: Freight.IContract) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const PATH_A_BOOKABLE_STATUSES = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
|
|
||||||
const PATH_B_CLEARANCE_STATUSES = [
|
const PATH_B_CLEARANCE_STATUSES = [
|
||||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||||
"CLEARANCE_UNDER_REVIEW",
|
"CLEARANCE_UNDER_REVIEW",
|
||||||
@@ -62,6 +62,7 @@ const PATH_B_CLEARANCE_STATUSES = [
|
|||||||
/** The single most relevant next action for a customer's contract row. */
|
/** The single most relevant next action for a customer's contract row. */
|
||||||
function getCustomerRowAction(
|
function getCustomerRowAction(
|
||||||
contract: Freight.IContract,
|
contract: Freight.IContract,
|
||||||
|
bookings: Freight.IBooking[],
|
||||||
): { label: string; to: string; primary: boolean } {
|
): { label: string; to: string; primary: boolean } {
|
||||||
const id = contract.id;
|
const id = contract.id;
|
||||||
if (contract.status === "CONTRACT_READY") {
|
if (contract.status === "CONTRACT_READY") {
|
||||||
@@ -80,15 +81,12 @@ function getCustomerRowAction(
|
|||||||
primary: true,
|
primary: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (
|
const booking = getContractBookingAction(contract, bookings);
|
||||||
!contract.customsClearingEnabled &&
|
if (booking.kind === "book") {
|
||||||
PATH_A_BOOKABLE_STATUSES.includes(contract.status)
|
return { label: "Book shipment", to: booking.to, primary: true };
|
||||||
) {
|
}
|
||||||
return {
|
if (booking.kind === "rebook") {
|
||||||
label: "Book shipment",
|
return { label: "Re-book shipment", to: booking.to, primary: true };
|
||||||
to: `/contracts/${id}/bookings/new`,
|
|
||||||
primary: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return { label: "View", to: `/contracts/${id}`, primary: false };
|
return { label: "View", to: `/contracts/${id}`, primary: false };
|
||||||
}
|
}
|
||||||
@@ -139,6 +137,15 @@ export default function ContractsList() {
|
|||||||
api.contracts.list.queryOptions({ input: filter }),
|
api.contracts.list.queryOptions({ input: filter }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Bookings have no embedded link on the contract list, so cross-reference
|
||||||
|
// them to decide Book / Re-book per row (see getContractBookingAction).
|
||||||
|
const { data: bookingsData } = useQuery(
|
||||||
|
api.bookings.list.queryOptions({
|
||||||
|
input: { sortBy: "createdAt", sortOrder: "DESC" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const bookings = bookingsData?.items ?? [];
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const items = data?.items ?? [];
|
const items = data?.items ?? [];
|
||||||
if (!query.trim()) return items;
|
if (!query.trim()) return items;
|
||||||
@@ -407,7 +414,7 @@ export default function ContractsList() {
|
|||||||
const tradeLabel = dir
|
const tradeLabel = dir
|
||||||
? dir.charAt(0) + dir.slice(1).toLowerCase()
|
? dir.charAt(0) + dir.slice(1).toLowerCase()
|
||||||
: "—";
|
: "—";
|
||||||
const action = getCustomerRowAction(c);
|
const action = getCustomerRowAction(c, bookings);
|
||||||
return (
|
return (
|
||||||
<Table.Tr
|
<Table.Tr
|
||||||
key={c.id}
|
key={c.id}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking statuses that no longer occupy the single active-booking slot of a
|
||||||
|
* ONE_TIME contract. Mirrors the API source of truth in
|
||||||
|
* `contract-booking.service.ts`.
|
||||||
|
*/
|
||||||
|
export const TERMINAL_BOOKING_STATUSES = [
|
||||||
|
"EXPIRED",
|
||||||
|
"CANCELLED",
|
||||||
|
"COMPLETED",
|
||||||
|
"REJECTED",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Path A statuses where a customer (no customs) may book against the contract. */
|
||||||
|
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
|
||||||
|
|
||||||
|
export type ContractBookingActionKind = "book" | "rebook" | "none";
|
||||||
|
|
||||||
|
export interface ContractBookingAction {
|
||||||
|
kind: ContractBookingActionKind;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single source of truth for whether a contract row should show a
|
||||||
|
* Book / Re-book shipment button, and where it should navigate.
|
||||||
|
*
|
||||||
|
* - ONE_TIME: book only while there is no active booking. If the previous
|
||||||
|
* booking expired, offer Re-book. Once a live booking exists, no button.
|
||||||
|
* - GENERAL: bookable while CONTRACT_ACTIVE (CONTRACT_CLOSED / EXPIRED are
|
||||||
|
* already excluded by the PATH_A_BOOKABLE gate).
|
||||||
|
*/
|
||||||
|
export function getContractBookingAction(
|
||||||
|
contract: Freight.IContract,
|
||||||
|
bookings: Freight.IBooking[],
|
||||||
|
): ContractBookingAction {
|
||||||
|
if (contract.customsClearingEnabled) return { kind: "none", to: "" };
|
||||||
|
if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" };
|
||||||
|
|
||||||
|
const to = `/contracts/${contract.id}/bookings/new`;
|
||||||
|
|
||||||
|
if (contract.contractKind === "ONE_TIME") {
|
||||||
|
const mine = bookings.filter((b) => b.contractId === contract.id);
|
||||||
|
const hasActive = mine.some(
|
||||||
|
(b) => !TERMINAL_BOOKING_STATUSES.includes(b.status),
|
||||||
|
);
|
||||||
|
if (hasActive) return { kind: "none", to: "" };
|
||||||
|
const hasExpired = mine.some((b) => b.status === "EXPIRED");
|
||||||
|
return { kind: hasExpired ? "rebook" : "book", to };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "book", to };
|
||||||
|
}
|
||||||
@@ -354,6 +354,8 @@ export interface IYard extends BaseEntity {
|
|||||||
export interface IBooking extends BaseEntity {
|
export interface IBooking extends BaseEntity {
|
||||||
reference: string;
|
reference: string;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
|
/** The contract this booking was created under (Path A / Path B). */
|
||||||
|
contractId?: string | null;
|
||||||
trainId?: string | null;
|
trainId?: string | null;
|
||||||
status: BookingStatus;
|
status: BookingStatus;
|
||||||
/** ONE_TIME for normal bookings; GENERAL_CONTRACT for umbrella contracts. */
|
/** ONE_TIME for normal bookings; GENERAL_CONTRACT for umbrella contracts. */
|
||||||
|
|||||||
@@ -32,8 +32,17 @@ export interface IOverviewStaffKpis {
|
|||||||
activeUsers: number;
|
activeUsers: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IOverviewContractKpis {
|
||||||
|
totalActive: number;
|
||||||
|
needsAction: number;
|
||||||
|
inApproval: number;
|
||||||
|
inClearance: number;
|
||||||
|
createdToday: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IOverviewKpis {
|
export interface IOverviewKpis {
|
||||||
bookings: IOverviewBookingKpis;
|
bookings: IOverviewBookingKpis;
|
||||||
|
contracts: IOverviewContractKpis;
|
||||||
operations: IOverviewOperationsKpis;
|
operations: IOverviewOperationsKpis;
|
||||||
customers: IOverviewCustomerKpis;
|
customers: IOverviewCustomerKpis;
|
||||||
billing: IOverviewBillingKpis;
|
billing: IOverviewBillingKpis;
|
||||||
@@ -72,6 +81,18 @@ export interface IOverviewRecentBooking {
|
|||||||
createdAt: string;
|
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 {
|
export interface IOverviewDashboard {
|
||||||
kpis: IOverviewKpis;
|
kpis: IOverviewKpis;
|
||||||
bookingTrend: IOverviewTrendPoint[];
|
bookingTrend: IOverviewTrendPoint[];
|
||||||
@@ -110,6 +131,17 @@ export interface IOverviewBookingsTab {
|
|||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IOverviewContractsTab {
|
||||||
|
kpis: IOverviewContractKpis;
|
||||||
|
contractTrend: IOverviewTrendPoint[];
|
||||||
|
contractsByStatus: IOverviewStatusCount[];
|
||||||
|
contractsByPipeline: IOverviewPipelineCount[];
|
||||||
|
contractsByKind: IOverviewLabelCount[];
|
||||||
|
contractsByFreightType: IOverviewLabelCount[];
|
||||||
|
recentContracts: IOverviewRecentContract[];
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IOverviewBillingTab {
|
export interface IOverviewBillingTab {
|
||||||
kpis: IOverviewBillingKpis;
|
kpis: IOverviewBillingKpis;
|
||||||
paymentTrend: IOverviewPaymentTrendPoint[];
|
paymentTrend: IOverviewPaymentTrendPoint[];
|
||||||
@@ -146,6 +178,7 @@ export interface IOverviewStaffTab {
|
|||||||
|
|
||||||
export type OverviewTabKey =
|
export type OverviewTabKey =
|
||||||
| 'bookings'
|
| 'bookings'
|
||||||
|
| 'contracts'
|
||||||
| 'billing'
|
| 'billing'
|
||||||
| 'operations'
|
| 'operations'
|
||||||
| 'customers'
|
| 'customers'
|
||||||
|
|||||||
Reference in New Issue
Block a user