fix conflict

This commit is contained in:
yaschalew
2026-06-24 16:18:35 +03:00
67 changed files with 4312 additions and 2744 deletions

View File

View File

@@ -0,0 +1 @@
{"dependencies":{"pnpm":"11.1.1"}}

BIN
.pnpm-store/v11/index.db Normal file

Binary file not shown.

View File

@@ -61,7 +61,6 @@ import { ContainersModule } from './modules/container-management/containers.modu
import { CargoesModule } from './modules/cargoes/cargoes.module'; import { CargoesModule } from './modules/cargoes/cargoes.module';
import { RoutesModule } from './modules/routes/routes.module'; import { RoutesModule } from './modules/routes/routes.module';
import { WarehousesModule } from './modules/warehouses/warehouses.module'; import { WarehousesModule } from './modules/warehouses/warehouses.module';
import { FacilitiesModule } from './modules/facilities/facilities.module';
import { OverviewModule } from './modules/overview/overview.module'; import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module'; import { VehiclesModule } from './modules/vehicles/vehicles.module';
import { DriversModule } from './modules/drivers/drivers.module'; import { DriversModule } from './modules/drivers/drivers.module';
@@ -123,7 +122,6 @@ import { LastMileModule } from './modules/last-mile/last-mile.module';
ContainersModule, ContainersModule,
CargoesModule, CargoesModule,
RoutesModule, RoutesModule,
FacilitiesModule,
WarehousesModule, WarehousesModule,
OverviewModule, OverviewModule,
VehiclesModule, VehiclesModule,

View File

@@ -7,13 +7,13 @@ export function deriveTradeDirection(
originYard: YardLike, originYard: YardLike,
destinationYard: YardLike, destinationYard: YardLike,
): ScheduleTradeDirection { ): ScheduleTradeDirection {
const originCountry = originYard.country?.trim(); const originCountry = originYard.country?.trim().toLowerCase();
const destinationCountry = destinationYard.country?.trim(); const destinationCountry = destinationYard.country?.trim().toLowerCase();
if (originCountry === 'Djibouti') { if (originCountry === 'djibouti') {
return 'IMPORT'; return 'IMPORT';
} }
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') { if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') {
return 'EXPORT'; return 'EXPORT';
} }
return 'DOMESTIC'; return 'DOMESTIC';

View File

@@ -1,4 +1,4 @@
import { Module, forwardRef } from "@nestjs/common"; import { DynamicModule, Module, forwardRef } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm"; import { TypeOrmModule } from "@nestjs/typeorm";
import { HttpModule } from "@nestjs/axios"; import { HttpModule } from "@nestjs/axios";
import { ConfigModule, ConfigService } from "@nestjs/config"; import { ConfigModule, ConfigService } from "@nestjs/config";
@@ -25,14 +25,10 @@ import { PaymentRefundEntity } from "./entities/payment-refund.entity";
const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT]; const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
@Module({ function rabbitMQImport(): DynamicModule[] {
imports: [ if (!process.env.PAYMENT_RABBITMQ_URL) return [];
HttpModule.register({ timeout: 10_000 }),
ConfigModule, return [
DropdownSettingsModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
RabbitMQModule.forRootAsync({ RabbitMQModule.forRootAsync({
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService) => ({ useFactory: (config: ConfigService) => ({
@@ -53,6 +49,17 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
connectionInitOptions: { wait: false }, connectionInitOptions: { wait: false },
}), }),
}), }),
];
}
@Module({
imports: [
HttpModule.register({ timeout: 10_000 }),
ConfigModule,
DropdownSettingsModule,
forwardRef(() => TrainSchedulingModule),
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
...rabbitMQImport(),
], ],
providers: [ providers: [
PaymentRepository, PaymentRepository,

View File

@@ -436,7 +436,7 @@ export class TrainSchedulingController {
return this.trainSchedulingService.cancelTrainSchedule(id); return this.trainSchedulingService.cancelTrainSchedule(id);
} }
@Post("bulk/schedules/:id/cancel") @Post('bulk/schedules/:id/cancel')
@TrainSchedulingManage() @TrainSchedulingManage()
@ApiOperation({ summary: "Cancel bulk train schedule" }) @ApiOperation({ summary: "Cancel bulk train schedule" })
cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) { cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {

View File

@@ -1,4 +1,4 @@
import { import {
AllocationLoadType, AllocationLoadType,
SchedulingStatus, SchedulingStatus,
TrainCheckpointKind, TrainCheckpointKind,
@@ -931,6 +931,19 @@ export class TrainSchedulingService {
}); });
} }
await manager.query(
`UPDATE freight.bookings b
SET status = $2,
scheduling_status = $3
FROM freight.train_schedule_bookings tsb
WHERE tsb.booking_id = b.id
AND tsb.train_schedule_id = $1
AND tsb.deleted_at IS NULL
AND b.deleted_at IS NULL
AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED')`,
[scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched],
);
if (schedule.trainSet?.locomotiveId) { if (schedule.trainSet?.locomotiveId) {
const loco = await manager const loco = await manager
.getRepository(Locomotive) .getRepository(Locomotive)

View File

@@ -67,13 +67,12 @@ export class VehiclesRepository extends BaseRepository<Vehicle> {
}; };
} }
async createVehicle(vehicleData: any): Promise<Vehicle> { async createVehicle(vehicleData: Partial<Vehicle>): Promise<Vehicle> {
const vehicle = this.repository.create(vehicleData); const vehicle = this.repository.create(vehicleData);
const vehicles = await this.repository.save(vehicle); return this.repository.save(vehicle);
return vehicles?.[0] as Vehicle;
} }
async updateVehicle(vehicle: Vehicle): Promise<Vehicle> { async updateVehicle(vehicle: Vehicle): Promise<Vehicle> {
return (await this.repository.save(vehicle)) as Vehicle; return this.repository.save(vehicle);
} }
} }

View File

@@ -11,6 +11,8 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
export const WAGON_STATUSES = [ export const WAGON_STATUSES = [
WagonStatus.Available, WagonStatus.Available,
WagonStatus.Assigned, WagonStatus.Assigned,
WagonStatus.ImportReady,
WagonStatus.ExportReady,
WagonStatus.Maintenance, WagonStatus.Maintenance,
WagonStatus.Retired, WagonStatus.Retired,
] as const; ] as const;
@@ -65,7 +67,7 @@ export class Wagon extends BaseEntity {
@JoinColumn({ name: 'current_train_schedule_id' }) @JoinColumn({ name: 'current_train_schedule_id' })
currentTrainSchedule?: TrainSchedule | null; currentTrainSchedule?: TrainSchedule | null;
/** Fleet master consist grouping separate from operational train_schedules. */ /** Fleet master consist grouping — separate from operational train_schedules. */
@ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' }) @ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'train_id' }) @JoinColumn({ name: 'train_id' })
train!: Train | null; train!: Train | null;

View File

@@ -12,6 +12,11 @@ export class FilterWarehouseInventoryDto {
@IsUUID() @IsUUID()
warehouseId?: string; warehouseId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
facilityId?: string;
@ApiPropertyOptional({ format: 'uuid' }) @ApiPropertyOptional({ format: 'uuid' })
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
@@ -51,4 +56,14 @@ export class FilterWarehouseInventoryDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
search?: string; search?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
dateFrom?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
dateTo?: string;
} }

View File

@@ -10,6 +10,11 @@ export class InquiryWarehouseInventoryDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsString() @IsString()
bookingReference?: string;
@ApiPropertyOptional({ description: 'Legacy alias for bookingReference' })
@IsOptional()
@IsString()
bookingNumber?: string; bookingNumber?: string;
@ApiPropertyOptional() @ApiPropertyOptional()

View File

@@ -78,17 +78,13 @@ export class WarehouseAllocationService {
/** Resolve a concrete warehouse/yard/zone for the given criteria, or null if none configured. */ /** Resolve a concrete warehouse/yard/zone for the given criteria, or null if none configured. */
async resolveLocation(criteria: AllocationCriteria): Promise<AllocationResult | null> { async resolveLocation(criteria: AllocationCriteria): Promise<AllocationResult | null> {
const rule = await this.findMatchingRule(criteria); const rule = await this.findMatchingRule(criteria);
const yardCode = rule?.targetYardCode; if (!rule) return null;
// Resolve yard (by rule code, else first available yard with a zone). // Resolve yard by rule code.
const [yard] = await this.dataSource.query( const [yard] = await this.dataSource.query(
yardCode `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
? `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1`,
WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1` [rule.targetYardCode],
: `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
JOIN freight.warehouse_zones z ON z.yard_id = y.id AND z.deleted_at IS NULL
WHERE y.deleted_at IS NULL ORDER BY y.created_at ASC LIMIT 1`,
yardCode ? [yardCode] : [],
); );
if (!yard) return null; if (!yard) return null;

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, IsNull } from 'typeorm'; import { DataSource, FindManyOptions, IsNull, ObjectLiteral, Repository } from 'typeorm';
import { Warehouse } from './entities/warehouse.entity'; import { Warehouse } from './entities/warehouse.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity';
@@ -26,6 +26,29 @@ export interface WarehouseDashboard {
export class WarehouseDashboardService { export class WarehouseDashboardService {
constructor(private readonly dataSource: DataSource) {} constructor(private readonly dataSource: DataSource) {}
private async safeCount<T extends ObjectLiteral>(
repo: Repository<T>,
options?: FindManyOptions<T>,
): Promise<number> {
try {
return await repo.count(options);
} catch {
return 0;
}
}
private async safeReceivedToday(startOfToday: Date): Promise<number> {
try {
return await this.dataSource
.getRepository(WarehouseInventory)
.createQueryBuilder('inv')
.where('inv.arrived_at >= :start', { start: startOfToday })
.getCount();
} catch {
return 0;
}
}
async getDashboard(): Promise<WarehouseDashboard> { async getDashboard(): Promise<WarehouseDashboard> {
const warehouseRepo = this.dataSource.getRepository(Warehouse); const warehouseRepo = this.dataSource.getRepository(Warehouse);
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
@@ -47,21 +70,18 @@ export class WarehouseDashboardService {
delivered, delivered,
receivedToday, receivedToday,
] = await Promise.all([ ] = await Promise.all([
warehouseRepo.count(), this.safeCount(warehouseRepo),
inventoryRepo.count(), this.safeCount(inventoryRepo),
inventoryRepo.count({ where: { status: 'RECEIVED', inspectionStatus: IsNull() } }), this.safeCount(inventoryRepo, { where: { status: 'RECEIVED', inspectionStatus: IsNull() } }),
inventoryRepo.count({ where: { inspectionStatus: 'PASSED' } }), this.safeCount(inventoryRepo, { where: { inspectionStatus: 'PASSED' } }),
inventoryRepo.count({ where: { status: 'STORED' } }), this.safeCount(inventoryRepo, { where: { status: 'STORED' } }),
inventoryRepo.count({ where: { status: 'RESERVED' } }), this.safeCount(inventoryRepo, { where: { status: 'RESERVED' } }),
inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }), this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_LOADING' } }),
inventoryRepo.count({ where: { status: 'LOADED' } }), this.safeCount(inventoryRepo, { where: { status: 'LOADED' } }),
inventoryRepo.count({ where: { status: 'DISPATCHED' } }), this.safeCount(inventoryRepo, { where: { status: 'DISPATCHED' } }),
inventoryRepo.count({ where: { status: 'READY_FOR_PICKUP' } }), this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_PICKUP' } }),
inventoryRepo.count({ where: { status: 'DELIVERED' } }), this.safeCount(inventoryRepo, { where: { status: 'DELIVERED' } }),
inventoryRepo this.safeReceivedToday(startOfToday),
.createQueryBuilder('inv')
.where('inv.arrived_at >= :start', { start: startOfToday })
.getCount(),
]); ]);
return { return {

View File

@@ -13,6 +13,8 @@ interface ItemAttributes {
tradeDirection: string | null; tradeDirection: string | null;
cargoTypeCode: string | null; cargoTypeCode: string | null;
containerTypeCode: string | null; containerTypeCode: string | null;
inventoryQuantity: number;
bookingContainerCount: number;
facilityId: string | null; facilityId: string | null;
warehouseId: string | null; warehouseId: string | null;
yardId: string | null; yardId: string | null;
@@ -31,6 +33,8 @@ export interface FeePreview {
endIsOpen: boolean; // true when still accruing (no release/gate-clear yet) endIsOpen: boolean; // true when still accruing (no release/gate-clear yet)
elapsedDays: number; elapsedDays: number;
chargeableDays: number; chargeableDays: number;
containerCount: number;
billableUnits: number;
amount: number; amount: number;
} }
@@ -67,6 +71,7 @@ export class WarehouseFeeService {
`SELECT inv.arrived_at AS "arrivedAt", `SELECT inv.arrived_at AS "arrivedAt",
inv.gate_cleared_at AS "gateClearedAt", inv.gate_cleared_at AS "gateClearedAt",
inv.release_date AS "releaseDate", inv.release_date AS "releaseDate",
inv.quantity AS "inventoryQuantity",
inv.warehouse_id AS "warehouseId", inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId", inv.yard_id AS "yardId",
inv.zone_id AS "zoneId", inv.zone_id AS "zoneId",
@@ -74,7 +79,8 @@ export class WarehouseFeeService {
b.freight_type AS "freightType", b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection", b.trade_direction AS "tradeDirection",
cgt.code AS "cargoTypeCode", cgt.code AS "cargoTypeCode",
ctt.code AS "containerTypeCode" ctt.code AS "containerTypeCode",
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount"
FROM freight.warehouse_inventory inv FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.bookings b ON b.id = inv.booking_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id
@@ -82,6 +88,12 @@ export class WarehouseFeeService {
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
LEFT JOIN freight.containers ct ON ct.id = inv.container_id LEFT JOIN freight.containers ct ON ct.id = inv.container_id
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count
FROM freight.booking_container bc
WHERE bc.booking_id = inv.booking_id
AND bc.deleted_at IS NULL
) container_lines ON true
WHERE inv.id = $1 AND inv.deleted_at IS NULL`, WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
[inventoryId], [inventoryId],
); );
@@ -131,12 +143,18 @@ export class WarehouseFeeService {
const endIsOpen = !item.gateClearedAt && !item.releaseDate; const endIsOpen = !item.gateClearedAt && !item.releaseDate;
const freeDays = rule?.freeDays ?? 0; const freeDays = rule?.freeDays ?? 0;
const ratePerDay = Number(rule?.ratePerDay ?? 0); const ratePerDay = Number(rule?.ratePerDay ?? 0);
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1));
const containerCount = isContainer
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
: 1;
const elapsedDays = start const elapsedDays = start
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY)) ? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
: 0; : 0;
const chargeableDays = Math.max(0, elapsedDays - freeDays); const chargeableDays = Math.max(0, elapsedDays - freeDays);
const amount = Math.round(chargeableDays * ratePerDay * 100) / 100; const billableUnits = chargeableDays * containerCount;
const amount = Math.round(billableUnits * ratePerDay * 100) / 100;
return { return {
ruleType, ruleType,
@@ -150,6 +168,8 @@ export class WarehouseFeeService {
endIsOpen, endIsOpen,
elapsedDays, elapsedDays,
chargeableDays, chargeableDays,
containerCount,
billableUnits,
amount, amount,
}; };
} }

View File

@@ -18,7 +18,7 @@ export class WarehouseInspectionService {
private readonly filesService: FilesService, private readonly filesService: FilesService,
) {} ) {}
/** Create an inspection report for an inventory item and sync its inspectionStatus. */ /** Create or update the inspection report for an inventory item and sync its inspectionStatus. */
async create(inventoryId: string, dto: CreateInspectionReportDto): Promise<WarehouseInspectionReport> { async create(inventoryId: string, dto: CreateInspectionReportDto): Promise<WarehouseInspectionReport> {
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
const inventory = await inventoryRepo.findOne({ where: { id: inventoryId } }); const inventory = await inventoryRepo.findOne({ where: { id: inventoryId } });
@@ -29,8 +29,9 @@ export class WarehouseInspectionService {
const expected = dto.expectedWeight ?? null; const expected = dto.expectedWeight ?? null;
const actual = dto.actualWeight ?? null; const actual = dto.actualWeight ?? null;
const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null; const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null;
const inspectedAt = new Date();
const report = await this.inspectionRepository.create({ const payload = {
inventoryId, inventoryId,
bookingId: inventory.bookingId ?? null, bookingId: inventory.bookingId ?? null,
reportType: dto.reportType, reportType: dto.reportType,
@@ -46,13 +47,27 @@ export class WarehouseInspectionService {
missingItemsDescription: dto.missingItemsDescription ?? null, missingItemsDescription: dto.missingItemsDescription ?? null,
remarks: dto.remarks ?? null, remarks: dto.remarks ?? null,
inspectedById: dto.inspectedById ?? null, inspectedById: dto.inspectedById ?? null,
inspectedAt: new Date(), inspectedAt,
};
const [existingReport] = await this.inspectionRepository.findAll({
where: { inventoryId },
order: { createdAt: 'DESC' },
take: 1,
}); });
let report: WarehouseInspectionReport;
if (existingReport) {
await this.inspectionRepository.update(existingReport.id, payload);
report = await this.findById(existingReport.id);
} else {
report = await this.inspectionRepository.create(payload);
}
// Mirror the latest outcome onto the inventory item so loading rules can read it. // Mirror the latest outcome onto the inventory item so loading rules can read it.
await inventoryRepo.update(inventoryId, { await inventoryRepo.update(inventoryId, {
inspectionStatus: dto.inspectionStatus, inspectionStatus: dto.inspectionStatus,
inspectedAt: new Date(), inspectedAt,
}); });
return report; return report;

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { BulkReceiveDto } from './dto/bulk-receive.dto'; import { BulkReceiveDto } from './dto/bulk-receive.dto';
import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkInspectDto } from './dto/bulk-inspect.dto';
@@ -226,6 +227,16 @@ export class WarehouseInventoryController {
return this.inventoryService.release(id, dto); return this.inventoryService.release(id, dto);
} }
@Get(':id/release-document')
@ApiOperation({ summary: 'View warehouse release / exit paper PDF' })
async releaseDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.releaseDocument(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Post(':id/deliver') @Post(':id/deliver')
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {

View File

@@ -75,9 +75,9 @@ export class WarehouseInvoiceService {
feeType, feeType,
description: description:
p.ruleType === 'STORAGE_FEE' p.ruleType === 'STORAGE_FEE'
? `Storage fee ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free` ? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`
: `${isContainer ? 'Container' : 'Bulk'} demurrage ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`, : `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`,
quantity: p.chargeableDays, quantity: p.billableUnits,
unitRate: p.ratePerDay, unitRate: p.ratePerDay,
amount: p.amount, amount: p.amount,
currency: p.currency, currency: p.currency,

View File

@@ -15,6 +15,12 @@ export class WarehouseYardsController {
private readonly zonesService: WarehouseZonesService, private readonly zonesService: WarehouseZonesService,
) {} ) {}
@Get()
@ApiOperation({ summary: 'List all warehouse yards' })
findAll() {
return this.yardsService.findAll();
}
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get warehouse yard by ID' }) @ApiOperation({ summary: 'Get warehouse yard by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) { findOne(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -13,6 +13,13 @@ export class WarehouseYardsService {
private readonly warehousesService: WarehousesService, private readonly warehousesService: WarehousesService,
) {} ) {}
findAll(): Promise<WarehouseYard[]> {
return this.yardsRepository.findAll({
relations: { warehouse: true, zones: true },
order: { code: 'ASC' },
});
}
findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> { findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> {
return this.yardsRepository.findAll({ return this.yardsRepository.findAll({
where: { warehouseId }, where: { warehouseId },

View File

@@ -10,6 +10,12 @@ import { WarehouseZonesService } from './warehouse-zones.service';
export class WarehouseZonesController { export class WarehouseZonesController {
constructor(private readonly zonesService: WarehouseZonesService) {} constructor(private readonly zonesService: WarehouseZonesService) {}
@Get()
@ApiOperation({ summary: 'List all warehouse zones' })
findAll() {
return this.zonesService.findAll();
}
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get warehouse zone by ID' }) @ApiOperation({ summary: 'Get warehouse zone by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) { findOne(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -13,6 +13,13 @@ export class WarehouseZonesService {
private readonly yardsService: WarehouseYardsService, private readonly yardsService: WarehouseYardsService,
) {} ) {}
findAll(): Promise<WarehouseZone[]> {
return this.zonesRepository.findAll({
relations: { yard: { warehouse: true } },
order: { code: 'ASC' },
});
}
findByYard(yardId: string): Promise<WarehouseZone[]> { findByYard(yardId: string): Promise<WarehouseZone[]> {
return this.zonesRepository.findAll({ return this.zonesRepository.findAll({
where: { yardId }, where: { yardId },

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { FilesModule } from '../files/files.module'; import { FilesModule } from '../files/files.module';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
@@ -100,6 +101,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseInvoiceService, WarehouseInvoiceService,
WarehouseSchedulingAdapterService, WarehouseSchedulingAdapterService,
SchedulingReadFacade, SchedulingReadFacade,
ContractPdfService,
], ],
exports: [ exports: [
WarehousesService, WarehousesService,

View File

@@ -102,6 +102,12 @@ export class Batch5TestDataSeeder {
serviceTypeId: serviceType.id, serviceTypeId: serviceType.id,
status: 'PAID', status: 'PAID',
paymentStatus: 'PAID', paymentStatus: 'PAID',
scheduledDate: now,
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: 'EXPORT', tradeDirection: 'EXPORT',
freightType: 'BULK', freightType: 'BULK',
cargoTotalWeightVgm: seed.weight, cargoTotalWeightVgm: seed.weight,

View File

@@ -89,6 +89,7 @@ export class WarehouseDemoSeeder {
): Promise<Booking> => ): Promise<Booking> =>
bookingRepo.save( bookingRepo.save(
bookingRepo.create({ bookingRepo.create({
...this.demoBookingDefaults(),
reference, reference,
originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id, originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id,
destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id, destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id,
@@ -237,6 +238,7 @@ export class WarehouseDemoSeeder {
for (let i = 1; i <= 3; i++) { for (let i = 1; i <= 3; i++) {
const b = await bookingRepo.save( const b = await bookingRepo.save(
bookingRepo.create({ bookingRepo.create({
...this.demoBookingDefaults(),
reference: `WH-DEMO-ARR-${i}`, reference: `WH-DEMO-ARR-${i}`,
originYardId: djibYard.id, originYardId: djibYard.id,
destinationYardId: ethYard.id, destinationYardId: ethYard.id,
@@ -255,4 +257,15 @@ export class WarehouseDemoSeeder {
); );
} }
} }
private demoBookingDefaults(): Partial<Booking> {
return {
scheduledDate: new Date(),
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
};
}
} }

View File

@@ -12,7 +12,6 @@ import {
Paperclip, Paperclip,
Send, Send,
Settings, Settings,
ShieldCheck,
SlidersHorizontal, SlidersHorizontal,
Train, Train,
Truck, Truck,
@@ -28,7 +27,6 @@ import LoginPage from "./pages/auth/LoginPage";
import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import GlClearancePage from "./pages/bookings/GlClearancePage";
import NewBookingPage from "./pages/bookings/NewBookingPage"; import NewBookingPage from "./pages/bookings/NewBookingPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import CustomersPage from "./pages/customers/CustomersPage"; import CustomersPage from "./pages/customers/CustomersPage";
@@ -111,12 +109,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{ {
title: "Operations", title: "Operations",
items: [ items: [
{
label: "Document Clearance",
href: "/dashboard/clearance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.bookings.reviewDocuments,
},
{ {
label: "Train Schedules", label: "Train Schedules",
href: "/dashboard/operations/train-scheduling-v2", href: "/dashboard/operations/train-scheduling-v2",
@@ -384,14 +376,6 @@ const App = () => {
path="booking-requests/:id/contract" path="booking-requests/:id/contract"
element={<BookingContractPage />} element={<BookingContractPage />}
/> />
<Route
path="clearance"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.reviewDocuments}>
<GlClearancePage />
</RequirePermission>
}
/>
<Route path="warehouses" element={<WarehouseListPage />} /> <Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} /> <Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} /> <Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />

View File

@@ -1,77 +1,16 @@
import { useMemo, useState } from "react"; import { useCallback, useRef } from "react";
import { ArrowRight, Building2, Package } from "lucide-react"; import { useNavigate } from "react-router-dom";
import { import { ArrowRight, Calendar, Package, User } from "lucide-react";
Accordion, import { Group } from "@mantine/core";
Badge,
Button,
Checkbox,
Group,
Paper,
Stack,
Text,
Title,
} from "@mantine/core";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { canAllocateBooking } from "@/features/bookings/booking-actions.config"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import type { BookingListRow } from "@/types/booking"; import type { BookingListRow } from "@/types/booking";
import { groupBookingsForOperationsQueue } from "@/utils/groupBookingsForOperationsQueue"; import { cn } from "@/lib/utils";
import { Badge, DataTable, type ColumnDef } from "@edr/ui-common";
function BookingQueueRow({
booking,
selected,
disabled,
onToggle,
}: {
booking: BookingListRow;
selected: boolean;
disabled: boolean;
onToggle: () => void;
}) {
return (
<Group
align="flex-start"
wrap="nowrap"
p="sm"
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 8,
}}
>
<Checkbox checked={selected} disabled={disabled} onChange={onToggle} mt={4} />
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs">
<Package size={14} />
<Text fw={600} size="sm">{booking.reference}</Text>
{booking.isGovernment ? (
<Badge color="violet" size="xs" leftSection={<Building2 size={10} />}>
Government
</Badge>
) : null}
<Badge variant="outline" size="xs">{booking.freightType}</Badge>
{booking.schedulingStatus ? (
<Badge variant="light" size="xs">{booking.schedulingStatus}</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed">{booking.customerLabel}</Text>
<Group gap={6}>
<Text size="xs">{booking.originLabel}</Text>
<ArrowRight size={12} />
<Text size="xs">{booking.destinationLabel}</Text>
</Group>
<Group gap="sm">
<BookingPriorityBadge score={booking.priorityScore} />
{booking.serviceTypeLabel ? (
<Text size="xs" c="dimmed">
{booking.serviceTypeLabel}
{booking.serviceTypeBonus ? ` (+${booking.serviceTypeBonus} bonus)` : ""}
</Text>
) : null}
</Group>
</Stack>
</Group>
);
}
export function OperationsBookingQueue({ export function OperationsBookingQueue({
bookings, bookings,
@@ -82,144 +21,144 @@ export function OperationsBookingQueue({
isLoading?: boolean; isLoading?: boolean;
onAllocate: (bookingIds: string[]) => void; onAllocate: (bookingIds: string[]) => void;
}) { }) {
const { government, commercial } = useMemo( const navigate = useNavigate();
() => groupBookingsForOperationsQueue(bookings), const suppressRowClickRef = useRef(false);
[bookings],
const suppressRowClick = useCallback(() => {
suppressRowClickRef.current = true;
window.setTimeout(() => {
suppressRowClickRef.current = false;
}, 400);
}, []);
const handleRowClick = useCallback(
(row: BookingListRow) => {
if (suppressRowClickRef.current) return;
navigate(`/dashboard/booking-requests/${row.id}`);
},
[navigate],
); );
const [govSelected, setGovSelected] = useState<string[]>([]);
const [selectedByBucket, setSelectedByBucket] = useState<Record<string, string[]>>({});
const allocatable = (row: BookingListRow) => const columns: ColumnDef<BookingListRow>[] = [
row.status === "PAID" && {
canAllocateBooking({ status: row.status, schedulingStatus: row.schedulingStatus }); id: "booking",
header: () => <span className={bookingTable.headerCell}>Booking</span>,
const govSelection = govSelected.length cell: ({ row }) => {
? govSelected const booking = row.original;
: government.filter(allocatable).map((b) => b.id);
const bucketSelection = (bucketKey: string, bucketBookings: BookingListRow[]) => {
const existing = selectedByBucket[bucketKey];
if (existing) return existing;
return bucketBookings.filter(allocatable).map((b) => b.id);
};
const toggleGov = (bookingId: string) => {
setGovSelected((prev) => {
const base = prev.length ? prev : government.filter(allocatable).map((b) => b.id);
return base.includes(bookingId)
? base.filter((id) => id !== bookingId)
: [...base, bookingId];
});
};
const toggleBucket = (bucketKey: string, bookingId: string) => {
setSelectedByBucket((prev) => {
const current = prev[bucketKey] ?? [];
const next = current.includes(bookingId)
? current.filter((id) => id !== bookingId)
: [...current, bookingId];
return { ...prev, [bucketKey]: next };
});
};
if (isLoading) {
return <Text size="sm" c="dimmed">Loading operations queue</Text>;
}
if (!government.length && !commercial.length) {
return ( return (
<Text size="sm" c="dimmed"> <div className="flex items-center gap-3 py-1.5">
No PAID bookings ready to allocate. <div className={bookingTable.rowIcon}>
</Text> <Package className="size-4" strokeWidth={1.75} />
); </div>
} <div className="min-w-0">
<Group gap={6} wrap="nowrap">
return ( <p className="truncate font-medium text-foreground">{booking.reference}</p>
<Stack gap="lg"> {booking.isGovernment ? (
{government.length > 0 ? ( <Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
<Paper withBorder p="md" radius="md"> Government
<Group justify="space-between" mb="md"> </Badge>
<Stack gap={2}>
<Title order={5}>Government priority</Title>
<Text size="xs" c="dimmed">
Served first not grouped by 3-hour window
</Text>
</Stack>
<Group gap="xs">
<Badge variant="light">{govSelection.length} selected</Badge>
<Button
size="compact-sm"
color="violet"
disabled={!govSelection.length}
onClick={() => onAllocate(govSelection)}
>
Allocate
</Button>
</Group>
</Group>
<Stack gap="sm">
{government.map((booking) => (
<BookingQueueRow
key={booking.id}
booking={booking}
selected={govSelection.includes(booking.id)}
disabled={!allocatable(booking)}
onToggle={() => toggleGov(booking.id)}
/>
))}
</Stack>
</Paper>
) : null} ) : null}
{commercial.length > 0 ? (
<Accordion defaultValue={commercial[0]?.key} variant="separated" radius="md">
{commercial.map((bucket) => {
const selected = bucketSelection(bucket.key, bucket.bookings);
return (
<Accordion.Item key={bucket.key} value={bucket.key}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="md">
<Stack gap={2}>
<Text fw={600} size="sm">{bucket.label}</Text>
<Text size="xs" c="dimmed">
{bucket.bookings.length} commercial booking
{bucket.bookings.length === 1 ? "" : "s"}
</Text>
</Stack>
<Group gap="xs">
<Badge variant="light">{selected.length} selected</Badge>
<Button
size="compact-sm"
color="edr-green"
disabled={!selected.length}
onClick={(e) => {
e.stopPropagation();
onAllocate(selected);
}}
>
Allocate
</Button>
</Group> </Group>
</Group> <p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
</Accordion.Control> <User className="size-3 shrink-0 opacity-70" />
<Accordion.Panel> {booking.customerLabel}
<Stack gap="sm"> </p>
{bucket.bookings.map((booking) => ( </div>
<BookingQueueRow </div>
key={booking.id}
booking={booking}
selected={selected.includes(booking.id)}
disabled={!allocatable(booking)}
onToggle={() => toggleBucket(bucket.key, booking.id)}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
); );
},
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => {
const booking = row.original;
return (
<div className="space-y-1 py-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<span className="max-w-[8rem] truncate">{booking.originLabel}</span>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span className="max-w-[8rem] truncate">{booking.destinationLabel}</span>
</div>
<div className="flex gap-1.5">
<Badge variant="outline" className="h-5 px-1.5 text-[10px] uppercase">
{booking.tradeDirection}
</Badge>
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
{booking.freightType}
</Badge>
</div>
</div>
);
},
},
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<div className="space-y-1 py-1">
<BookingStatusBadge status={row.original.status} />
{row.original.schedulingStatus ? (
<SchedulingStatusBadge status={row.original.schedulingStatus} />
) : null}
</div>
),
},
{
id: "scheduled",
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" />
{row.original.scheduledDate}
</span>
),
},
{
id: "priority",
header: () => <span className={bookingTable.headerCell}>Priority</span>,
cell: ({ row }) => <BookingPriorityBadge score={row.original.priorityScore} />,
},
{
id: "amount",
header: () => <span className={bookingTable.headerCell}>Amount</span>,
cell: ({ row }) => (
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
{row.original.paymentCurrency}{" "}
{row.original.totalAmount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})} })}
</Accordion> </span>
) : null} ),
</Stack> },
{
id: "actions",
header: () => <span className={bookingTable.headerCell}>Actions</span>,
cell: ({ row }) => (
<BookingActionsMenu
row={row.original}
variant="table"
onSuppressRowClick={suppressRowClick}
onAllocateBooking={() => onAllocate([row.original.id])}
/>
),
},
];
return (
<DataTable
columns={columns}
data={bookings}
status={isLoading ? "loading" : "success"}
emptyMessage="No PAID bookings ready to load."
onRowClick={handleRowClick}
containerClassName={cn(
"border-0 shadow-none",
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
)}
/>
); );
} }

View File

@@ -106,6 +106,34 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Manage route definitions built from freight yards", subtitle: "Manage route definitions built from freight yards",
}, },
}, },
{
prefix: "/dashboard/warehouses/list",
meta: {
title: "Warehouses",
subtitle: "Manage warehouses, yards, and zones",
},
},
{
prefix: "/dashboard/warehouse-inventory",
meta: {
title: "Warehouse inventory",
subtitle: "Track received items through inspection and loading",
},
},
{
prefix: "/dashboard/inventory-inquiry",
meta: {
title: "Inventory inquiry",
subtitle: "Locate cargo, containers, and goods inside the warehouse network",
},
},
{
prefix: "/dashboard/warehouses",
meta: {
title: "Warehouse dashboard",
subtitle: "Live overview of warehouse capacity and inventory lifecycle",
},
},
...getFleetRouteMeta(), ...getFleetRouteMeta(),
{ {
prefix: "/dashboard/trains/", prefix: "/dashboard/trains/",

View File

@@ -5,8 +5,10 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api'; import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import { extractErrorMessage } from './options'; import { extractErrorMessage } from './options';
import type { FeePreview, WarehouseInvoiceStatus } from '@/types/warehouse'; import type { FeePreview, WarehouseInventoryItem, WarehouseInvoiceStatus } from '@/types/warehouse';
import { openPdfBlob } from './pdf';
const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = { const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray', DRAFT: 'gray',
@@ -32,6 +34,9 @@ function fmtDate(iso: string | null) {
return new Date(iso).toLocaleDateString(); return new Date(iso).toLocaleDateString();
} }
const money = (amount: number, currency: string) =>
`${Number(amount).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
function FeeCard({ fee }: { fee: FeePreview }) { function FeeCard({ fee }: { fee: FeePreview }) {
const meta = LABELS[fee.ruleType] ?? { label: fee.ruleType, color: 'gray' }; const meta = LABELS[fee.ruleType] ?? { label: fee.ruleType, color: 'gray' };
const configured = Boolean(fee.ruleId); const configured = Boolean(fee.ruleId);
@@ -48,7 +53,7 @@ function FeeCard({ fee }: { fee: FeePreview }) {
)} )}
</Group> </Group>
<Text fw={800} size="lg" c={`${meta.color}.7`}> <Text fw={800} size="lg" c={`${meta.color}.7`}>
{fee.amount.toLocaleString()} {fee.currency} {money(fee.amount, fee.currency)}
</Text> </Text>
</Group> </Group>
@@ -60,10 +65,12 @@ function FeeCard({ fee }: { fee: FeePreview }) {
<Stack gap={4}> <Stack gap={4}>
<Row label="Rule" value={fee.ruleName ?? '—'} /> <Row label="Rule" value={fee.ruleName ?? '—'} />
<Row label="Free days" value={String(fee.freeDays)} /> <Row label="Free days" value={String(fee.freeDays)} />
<Row label="Rate / day" value={`${fee.ratePerDay.toLocaleString()} ${fee.currency}`} /> <Row label="Rate / day" value={money(fee.ratePerDay, fee.currency)} />
<Row label="Period" value={`${fmtDate(fee.startDate)}${fmtDate(fee.endDate)}${fee.endIsOpen ? ' (today)' : ''}`} /> <Row label="Period" value={`${fmtDate(fee.startDate)}${fmtDate(fee.endDate)}${fee.endIsOpen ? ' (today)' : ''}`} />
<Row label="Elapsed days" value={String(fee.elapsedDays)} /> <Row label="Elapsed days" value={String(fee.elapsedDays)} />
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} /> <Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
<Row label="Containers" value={String(fee.containerCount ?? 1)} />
<Row label="Billable units" value={`${fee.billableUnits ?? fee.chargeableDays} container-day(s)`} />
</Stack> </Stack>
)} )}
</Card> </Card>
@@ -106,7 +113,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
if (!inventoryId) return; if (!inventoryId) return;
try { try {
const inv = await generate.mutateAsync({ inventoryId, confirmZero }); const inv = await generate.mutateAsync({ inventoryId, confirmZero });
toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} ${inv.totalAmount} ${inv.currency}` }); toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} - ${money(inv.totalAmount, inv.currency)}` });
} catch (error) { } catch (error) {
const msg = extractErrorMessage(error); const msg = extractErrorMessage(error);
if (/no payable warehouse fee/i.test(msg)) { if (/no payable warehouse fee/i.test(msg)) {
@@ -121,11 +128,21 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
const handleGateClearance = async () => { const handleGateClearance = async () => {
if (!inventoryId) return; if (!inventoryId) return;
const pdfWindow = window.open('', '_blank');
try { try {
await gateClear.mutateAsync(inventoryId); const releasedItem = await gateClear.mutateAsync(inventoryId) as WarehouseInventoryItem;
toast({ title: 'Gate clearance recorded', description: 'Item released from terminal.' }); const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId);
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`;
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);
toast({
title: 'Gate clearance recorded',
description: opened
? 'The release PDF opened in a browser tab.'
: 'The browser blocked the preview tab, so the PDF was downloaded.',
});
onClose(); onClose();
} catch (error) { } catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'Release blocked', description: extractErrorMessage(error) }); toast({ variant: 'destructive', title: 'Release blocked', description: extractErrorMessage(error) });
} }
}; };
@@ -165,7 +182,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
</Badge> </Badge>
</Group> </Group>
<Text size="sm"> <Text size="sm">
{Number(activeInvoice.balanceAmount).toLocaleString()} {activeInvoice.currency} due {money(Number(activeInvoice.balanceAmount), activeInvoice.currency)} due
</Text> </Text>
</Group> </Group>
) : ( ) : (

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
Button, Button,
Divider, Divider,
@@ -12,10 +12,8 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { Upload } from 'lucide-react'; import { Upload } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { useCreateInspectionReport, useInspectionReports, useUploadInspectionAttachments } from '@/hooks/useWarehouses';
import { import {
INSPECTION_REPORT_TYPES, INSPECTION_REPORT_TYPES,
INSPECTION_STATUSES, INSPECTION_STATUSES,
@@ -47,12 +45,9 @@ const STATUS_LABELS: Record<InspectionResultStatus, string> = {
/** Batch 4.5 — record an inspection / damage report with optional image upload. */ /** Batch 4.5 — record an inspection / damage report with optional image upload. */
export function InspectionReportModal({ opened, onClose, inventoryId }: InspectionReportModalProps) { export function InspectionReportModal({ opened, onClose, inventoryId }: InspectionReportModalProps) {
const { toast } = useToast(); const { toast } = useToast();
const createReport = useMutation( const createReport = useCreateInspectionReport();
api.warehouses.createInspectionReport.mutationOptions(), const uploadAttachments = useUploadInspectionAttachments();
); const reportsQuery = useInspectionReports(opened ? inventoryId ?? undefined : undefined);
const uploadAttachments = useMutation(
api.warehouses.uploadInspectionAttachments.mutationOptions(),
);
const [reportType, setReportType] = useState<InspectionReportType>('INSPECTION'); const [reportType, setReportType] = useState<InspectionReportType>('INSPECTION');
const [inspectionStatus, setInspectionStatus] = useState<InspectionResultStatus>('PASSED'); const [inspectionStatus, setInspectionStatus] = useState<InspectionResultStatus>('PASSED');
@@ -82,6 +77,27 @@ export function InspectionReportModal({ opened, onClose, inventoryId }: Inspecti
setFiles([]); setFiles([]);
}; };
useEffect(() => {
if (!opened) return;
const report = reportsQuery.data?.[0];
if (!report) {
reset();
return;
}
setReportType(report.reportType);
setInspectionStatus(report.inspectionStatus);
setHasDamage(report.hasDamage ?? false);
setDamageDescription(report.damageDescription ?? '');
setHasWeightLoss(report.hasWeightLoss ?? false);
setExpectedWeight(report.expectedWeight == null ? '' : Number(report.expectedWeight));
setActualWeight(report.actualWeight == null ? '' : Number(report.actualWeight));
setHasMissingItems(report.hasMissingItems ?? false);
setMissingItemsDescription(report.missingItemsDescription ?? '');
setRemarks(report.remarks ?? '');
setFiles([]);
}, [opened, reportsQuery.data]);
const handleSubmit = async () => { const handleSubmit = async () => {
if (!inventoryId) return; if (!inventoryId) return;
try { try {

View File

@@ -0,0 +1,91 @@
import { Badge, Divider, Group, Modal, SimpleGrid, Stack, Text } from '@mantine/core';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber } from './options';
interface InventoryDetailModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{label}
</Text>
<Text size="sm" fw={500}>
{value || '-'}
</Text>
</Stack>
);
}
export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) {
return (
<Modal opened={opened} onClose={onClose} title="Inventory detail" centered size="xl">
{!item ? (
<Text c="dimmed">No inventory item selected.</Text>
) : (
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<Stack gap={2}>
<Text size="lg" fw={800}>
{item.booking?.reference ?? item.bookingId ?? item.id}
</Text>
<Text size="sm" c="dimmed">
Inventory ID: {item.id}
</Text>
</Stack>
<InventoryStatusBadge status={item.status} />
</Group>
<Divider label="Location" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Warehouse" value={item.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : '-'} />
<DetailRow label="Yard" value={item.yard ? `${item.yard.name} (${item.yard.code})` : '-'} />
<DetailRow label="Zone" value={item.zone ? `${item.zone.name} (${item.zone.code})` : '-'} />
</SimpleGrid>
<Divider label="Booking & item" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Booking status" value={item.booking?.status ?? '-'} />
<DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} />
<DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} />
<DetailRow label="Container ID" value={item.containerId ?? '-'} />
<DetailRow label="Cargo ID" value={item.cargoId ?? '-'} />
<DetailRow label="Goods ID" value={item.goodsId ?? '-'} />
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />
</SimpleGrid>
<Divider label="Lifecycle" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Inspection" value={<Badge variant="light">{item.inspectionStatus ?? 'Not inspected'}</Badge>} />
<DetailRow label="Arrived" value={formatDate(item.arrivedAt)} />
<DetailRow label="Stored" value={formatDate(item.storedAt)} />
<DetailRow label="Reserved" value={formatDate(item.reservedAt)} />
<DetailRow label="Inspected" value={formatDate(item.inspectedAt)} />
<DetailRow label="Ready for loading" value={formatDate(item.readyForLoadingAt)} />
<DetailRow label="Loaded" value={formatDate(item.loadedAt)} />
<DetailRow label="Dispatched" value={formatDate(item.dispatchedAt)} />
<DetailRow label="Ready for pickup" value={formatDate(item.readyForPickupAt)} />
<DetailRow label="Released" value={formatDate(item.releaseDate)} />
<DetailRow label="Delivered" value={formatDate(item.deliveredAt)} />
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
</SimpleGrid>
{item.notes && (
<>
<Divider label="Notes" labelPosition="left" />
<Text size="sm">{item.notes}</Text>
</>
)}
</Stack>
)}
</Modal>
);
}

View File

@@ -0,0 +1,92 @@
import { Badge, Divider, Group, Modal, SimpleGrid, Stack, Text } from '@mantine/core';
import type { InventoryInquiryResult } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber } from './options';
interface InventoryInquiryDetailModalProps {
opened: boolean;
onClose: () => void;
result: InventoryInquiryResult | null;
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{label}
</Text>
<Text size="sm" fw={500}>
{value || '-'}
</Text>
</Stack>
);
}
function itemLabel(result: InventoryInquiryResult) {
if (result.containerNumber) return `Container ${result.containerNumber}`;
if (result.cargoType) return result.cargoType;
if (result.cargoDescription) return result.cargoDescription;
if (result.goodsId) return `Goods ${result.goodsId}`;
return '-';
}
export function InventoryInquiryDetailModal({ opened, onClose, result }: InventoryInquiryDetailModalProps) {
return (
<Modal opened={opened} onClose={onClose} title="Inventory inquiry detail" centered size="xl">
{!result ? (
<Text c="dimmed">No inquiry result selected.</Text>
) : (
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<Stack gap={2}>
<Text size="lg" fw={800}>
{result.bookingReference ?? result.bookingNumber ?? result.bookingId ?? result.id}
</Text>
<Text size="sm" c="dimmed">
Inventory ID: {result.inventoryId ?? 'Not yet in warehouse inventory'}
</Text>
</Stack>
{result.status ? (
<InventoryStatusBadge status={result.status} />
) : (
<Badge variant="light" color={result.trainStatus === 'ARRIVED' ? 'orange' : 'blue'}>
{result.trainStatus ?? result.bookingStatus ?? 'Not in warehouse'}
</Badge>
)}
</Group>
<Divider label="Booking" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Booking reference" value={result.bookingReference ?? result.bookingNumber ?? '-'} />
<DetailRow label="Booking status" value={result.bookingStatus ?? '-'} />
<DetailRow label="Customer" value={result.customerName ?? '-'} />
</SimpleGrid>
<Divider label="Item" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Item" value={itemLabel(result)} />
<DetailRow label="Quantity" value={formatNumber(result.quantity)} />
<DetailRow label="Weight" value={`${formatNumber(result.weight)} kg`} />
</SimpleGrid>
<Divider label="Location" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Warehouse" value={result.warehouse ? `${result.warehouse.name} (${result.warehouse.code})` : '-'} />
<DetailRow label="Yard" value={result.yard ? `${result.yard.name} (${result.yard.code})` : '-'} />
<DetailRow label="Zone" value={result.zone ? `${result.zone.name} (${result.zone.code})` : '-'} />
<DetailRow label="Current location" value={result.locationSummary ?? '-'} />
<DetailRow label="Train" value={result.trainNumber ?? '-'} />
<DetailRow label="Route" value={result.route ?? '-'} />
</SimpleGrid>
<Divider label="Dates" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Arrived" value={formatDate(result.arrivedAt)} />
<DetailRow label="Ready for loading" value={formatDate(result.readyForLoadingAt)} />
</SimpleGrid>
</Stack>
)}
</Modal>
);
}

View File

@@ -6,10 +6,12 @@ import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api'; import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse'; import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { DeliverInventoryModal } from './DeliverInventoryModal'; import { DeliverInventoryModal } from './DeliverInventoryModal';
import { FeePreviewModal } from './FeePreviewModal'; import { FeePreviewModal } from './FeePreviewModal';
import { InspectionReportModal } from './InspectionReportModal'; import { InspectionReportModal } from './InspectionReportModal';
import { InventoryDetailModal } from './InventoryDetailModal';
import { InventoryHistoryModal } from './InventoryHistoryModal'; import { InventoryHistoryModal } from './InventoryHistoryModal';
import { LoadInventoryModal } from './LoadInventoryModal'; import { LoadInventoryModal } from './LoadInventoryModal';
import { MoveInventoryModal } from './MoveInventoryModal'; import { MoveInventoryModal } from './MoveInventoryModal';
@@ -17,6 +19,7 @@ import { ReleaseOrderModal } from './ReleaseOrderModal';
import { ReserveInventoryModal } from './ReserveInventoryModal'; import { ReserveInventoryModal } from './ReserveInventoryModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable'; import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractErrorMessage } from './options'; import { extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface InventoryWorkbenchProps { interface InventoryWorkbenchProps {
items: WarehouseInventoryItem[]; items: WarehouseInventoryItem[];
@@ -33,6 +36,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null); const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null); const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null); const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null); const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null);
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null); const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null); const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
@@ -91,10 +95,46 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
} }
}; };
const downloadReleaseDocument = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadReleaseDocument(item.id);
const blob = response.data;
const filename = `release-${item.booking?.reference ?? item.bookingId ?? item.id}.pdf`;
const opened = openPdfBlob(blob, filename, pdfWindow);
toast({ title: opened ? 'Release exit paper opened' : 'Release exit paper downloaded' });
} catch (error) {
pdfWindow?.close();
toast({
variant: 'destructive',
title: 'Release paper preview failed',
description: extractErrorMessage(error),
});
} finally {
setBusyId(null);
}
};
const storeInventory = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
const stored = await storeMutation.mutateAsync(item.id);
toast({
title: 'Inventory stored',
description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '),
});
} catch (error) {
toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const advance = (item: WarehouseInventoryItem, action: InventoryAction) => { const advance = (item: WarehouseInventoryItem, action: InventoryAction) => {
switch (action) { switch (action) {
case 'store': case 'store':
return runDirect(item, () => storeMutation.mutateAsync(item.id), 'Inventory stored'); return storeInventory(item);
case 'reserve': case 'reserve':
setReserveItem(item); setReserveItem(item);
return; return;
@@ -151,8 +191,10 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
onAdvance={advance} onAdvance={advance}
onMove={setMoveItem} onMove={setMoveItem}
onHistory={setHistoryItem} onHistory={setHistoryItem}
onView={setViewItem}
onInspect={setInspectItem} onInspect={setInspectItem}
onFeePreview={setFeeItem} onFeePreview={setFeeItem}
onReleaseDocument={downloadReleaseDocument}
onLastMile={onLastMile} onLastMile={onLastMile}
selectedIds={selected} selectedIds={selected}
onToggleSelect={toggleSelect} onToggleSelect={toggleSelect}
@@ -174,6 +216,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
onClose={() => setHistoryItem(null)} onClose={() => setHistoryItem(null)}
item={historyItem} item={historyItem}
/> />
<InventoryDetailModal opened={Boolean(viewItem)} onClose={() => setViewItem(null)} item={viewItem} />
<InspectionReportModal <InspectionReportModal
opened={Boolean(inspectItem)} opened={Boolean(inspectItem)}
onClose={() => setInspectItem(null)} onClose={() => setInspectItem(null)}

View File

@@ -6,8 +6,10 @@ import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api'; import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import type { WarehouseInventoryItem } from '@/types/warehouse'; import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options'; import { extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface ReleaseOrderModalProps { interface ReleaseOrderModalProps {
opened: boolean; opened: boolean;
@@ -19,6 +21,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
const { toast } = useToast(); const { toast } = useToast();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
const [reference, setReference] = useState(''); const [reference, setReference] = useState('');
const [downloading, setDownloading] = useState(false);
useEffect(() => { useEffect(() => {
if (opened) setReference(item?.releaseOrderReference ?? ''); if (opened) setReference(item?.releaseOrderReference ?? '');
@@ -26,36 +29,53 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
const handleSubmit = async () => { const handleSubmit = async () => {
if (!item) return; if (!item) return;
const pdfWindow = window.open('', '_blank');
try { try {
await releaseMutation.mutateAsync({ id: item.id, payload: { reference: reference.trim() || undefined } }); const released = await releaseMutation.mutateAsync({
toast({ title: 'Release order issued' }); id: item.id,
payload: { reference: reference.trim() || undefined },
});
setDownloading(true);
const response = await warehouseService.downloadReleaseDocument(item.id);
const blob = response.data;
const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`;
const opened = openPdfBlob(blob, filename, pdfWindow);
toast({
title: 'Release exit paper issued',
description: opened
? 'The PDF opened in a browser tab for printing or saving.'
: 'The browser blocked the preview tab, so the PDF was downloaded.',
});
onClose(); onClose();
} catch (error) { } catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'Release failed', description: extractErrorMessage(error) }); toast({ variant: 'destructive', title: 'Release failed', description: extractErrorMessage(error) });
} finally {
setDownloading(false);
} }
}; };
return ( return (
<Modal opened={opened} onClose={onClose} title="Issue release order (DO)" centered size="md"> <Modal opened={opened} onClose={onClose} title="Issue release exit paper" centered size="md">
<Stack gap="md"> <Stack gap="md">
<Alert icon={<Info size={16} />} color="orange" variant="light"> <Alert icon={<Info size={16} />} color="orange" variant="light">
<Text size="sm"> <Text size="sm">
Records the delivery order / release order sent to the customer. Once issued, the goods can be Creates the warehouse release document with booking, customer, cargo and location details. The
picked up and delivered. printed paper authorizes the goods to leave the warehouse gate.
</Text> </Text>
</Alert> </Alert>
<TextInput <TextInput
label="Release order reference" label="Release document reference"
placeholder="e.g. DO-2026-001" placeholder="e.g. REL-2026-001"
value={reference} value={reference}
onChange={(e) => setReference(e.currentTarget.value)} onChange={(e) => setReference(e.currentTarget.value)}
/> />
<Group justify="flex-end" mt="sm"> <Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending}> <Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
Cancel Cancel
</Button> </Button>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending}> <Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
Issue release order Issue & view exit paper
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core'; import { ActionIcon, Box, Card, Divider, Group, Progress, SimpleGrid, Stack, Text, Tooltip } from '@mantine/core';
import { Building2, Eye, MapPin, Pencil } from 'lucide-react'; import { Building2, Eye, MapPin, Package, Pencil, Weight } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@@ -35,56 +35,101 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
return ( return (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{warehouses.map((warehouse) => ( {warehouses.map((warehouse) => (
<Card key={warehouse.id} withBorder radius="md" padding="lg"> <Card
<Stack gap="sm"> key={warehouse.id}
withBorder
radius="md"
padding={0}
style={{
overflow: 'hidden',
borderColor: 'var(--mantine-color-gray-2)',
background: 'white',
}}
>
<Box h={3} bg={warehouse.status === 'ACTIVE' ? 'green.5' : 'gray.4'} />
<Stack gap="md" p="lg">
<Group justify="space-between" wrap="nowrap" align="flex-start"> <Group justify="space-between" wrap="nowrap" align="flex-start">
<div> <Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Text fw={700}>{warehouse.name}</Text> <Box
<Text size="xs" c="dimmed"> style={{
width: 38,
height: 38,
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
background: 'var(--mantine-color-green-0)',
color: 'var(--mantine-color-green-7)',
border: '1px solid var(--mantine-color-green-2)',
}}
>
<Building2 size={18} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fw={800} size="md" truncate>
{warehouse.name}
</Text>
<Text size="xs" c="dimmed" fw={600} truncate>
{warehouse.code} {warehouse.code}
</Text> </Text>
</div> </Box>
</Group>
<Stack gap={6} align="flex-end">
<WarehouseStatusBadge status={warehouse.status} /> <WarehouseStatusBadge status={warehouse.status} />
</Group>
<Group gap="xs">
<WarehouseTypeBadge type={warehouse.type} /> <WarehouseTypeBadge type={warehouse.type} />
</Stack>
</Group> </Group>
<Stack gap={8}>
{warehouse.stationId && stationNameById.get(warehouse.stationId) && ( {warehouse.stationId && stationNameById.get(warehouse.stationId) && (
<Group gap={6} c="dimmed"> <Group gap={8} c="dimmed" wrap="nowrap">
<Building2 size={14} /> <Building2 size={15} />
<Text size="sm">{stationNameById.get(warehouse.stationId)}</Text> <Text size="sm" truncate>
{stationNameById.get(warehouse.stationId)}
</Text>
</Group> </Group>
)} )}
{warehouse.locationName && ( {warehouse.locationName && (
<Group gap={6} c="dimmed"> <Group gap={8} c="dimmed" wrap="nowrap">
<MapPin size={14} /> <MapPin size={15} />
<Text size="sm">{warehouse.locationName}</Text> <Text size="sm" truncate>
{warehouse.locationName}
</Text>
</Group> </Group>
)} )}
</Stack>
<Group justify="space-between"> <Divider />
<Text size="xs" c="dimmed">
Weight
</Text>
<Text size="sm">{formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}</Text>
</Group>
<Group justify="space-between">
<Text size="xs" c="dimmed">
Containers
</Text>
<Text size="sm">{formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}</Text>
</Group>
<Group justify="flex-end" gap="xs" mt="xs"> <Stack gap="md">
<ActionIcon variant="subtle" color="gray" onClick={() => onView(warehouse)} title="View"> <CapacityRow
icon={Weight}
label="Weight"
current={warehouse.currentWeight}
capacity={warehouse.capacityWeight}
/>
<CapacityRow
icon={Package}
label="Containers"
current={warehouse.currentContainers}
capacity={warehouse.capacityContainers}
/>
</Stack>
<Group justify="flex-end" gap="xs" mt="auto">
<Tooltip label="View warehouse" withArrow>
<ActionIcon variant="light" color="gray" onClick={() => onView(warehouse)} aria-label="View warehouse">
<Eye size={16} /> <Eye size={16} />
</ActionIcon> </ActionIcon>
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(warehouse)} title="Edit"> </Tooltip>
<Tooltip label="Edit warehouse" withArrow>
<ActionIcon variant="light" color="orange" onClick={() => onEdit(warehouse)} aria-label="Edit warehouse">
<Pencil size={16} /> <Pencil size={16} />
</ActionIcon> </ActionIcon>
</Tooltip>
</Group> </Group>
</Stack> </Stack>
</Card> </Card>
@@ -92,3 +137,40 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
</SimpleGrid> </SimpleGrid>
); );
} }
const capacityPercent = (current?: number | null, capacity?: number | null) => {
if (!capacity || capacity <= 0) return 0;
return Math.min(100, Math.max(0, ((current ?? 0) / capacity) * 100));
};
function CapacityRow({
icon: Icon,
label,
current,
capacity,
}: {
icon: typeof Weight;
label: string;
current?: number | null;
capacity?: number | null;
}) {
const percent = capacityPercent(current, capacity);
const color = percent >= 90 ? 'red' : percent >= 70 ? 'orange' : 'green';
return (
<Stack gap={6}>
<Group justify="space-between" wrap="nowrap">
<Group gap={7} c="dimmed" wrap="nowrap">
<Icon size={15} />
<Text size="xs" fw={700} tt="uppercase">
{label}
</Text>
</Group>
<Text size="sm" fw={700}>
{formatCapacity(Number(current) || 0, capacity)}
</Text>
</Group>
<Progress value={percent} color={color} size="xs" radius="xl" bg="var(--mantine-color-gray-1)" />
</Stack>
);
}

View File

@@ -1,5 +1,5 @@
import { Stack, Text } from '@mantine/core'; import { ActionIcon, Badge, Stack, Table, Text, Tooltip } from '@mantine/core';
import { DataTable, type ColumnDef } from '@edr/ui-common'; import { Eye } from 'lucide-react';
import type { InventoryInquiryResult } from '@/types/warehouse'; import type { InventoryInquiryResult } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges'; import { InventoryStatusBadge } from './badges';
@@ -7,71 +7,111 @@ import { formatDate, formatNumber } from './options';
interface WarehouseInquiryTableProps { interface WarehouseInquiryTableProps {
results: InventoryInquiryResult[]; results: InventoryInquiryResult[];
onView?: (result: InventoryInquiryResult) => void;
} }
const dash = '-';
const itemDescriptor = (result: InventoryInquiryResult) => { const itemDescriptor = (result: InventoryInquiryResult) => {
if (result.containerNumber) return `Container ${result.containerNumber}`; if (result.containerNumber) return `Container ${result.containerNumber}`;
if (result.cargoType) return `Cargo · ${result.cargoType}`; if (result.cargoType) return `Cargo - ${result.cargoType}`;
if (result.cargoDescription) return `Cargo · ${result.cargoDescription}`; if (result.cargoDescription) return `Cargo - ${result.cargoDescription}`;
if (result.goodsId) return 'Goods'; if (result.goodsId) return 'Goods';
return '—'; return dash;
}; };
const columns: ColumnDef<InventoryInquiryResult>[] = [ export function WarehouseInquiryTable({ results, onView }: WarehouseInquiryTableProps) {
{ if (results.length === 0) {
id: 'booking',
header: 'Booking',
cell: ({ row }) => (
<Text size="sm" fw={600}>
{row.original.bookingNumber ?? row.original.bookingId.slice(0, 8)}
</Text>
),
},
{ id: 'customer', header: 'Customer', cell: ({ row }) => row.original.customerName ?? '—' },
{ id: 'item', header: 'Item', cell: ({ row }) => itemDescriptor(row.original) },
{
id: 'warehouse',
header: 'Warehouse',
cell: ({ row }) => (
<Stack gap={0}>
<Text size="sm">{row.original.warehouse?.name ?? '—'}</Text>
{row.original.warehouse?.code && (
<Text size="xs" c="dimmed">
{row.original.warehouse.code}
</Text>
)}
</Stack>
),
},
{ id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard?.name ?? '—' },
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.name ?? '—' },
{
id: 'status',
header: 'Status',
cell: ({ row }) => <InventoryStatusBadge status={row.original.status} />,
},
{ id: 'qty', header: 'Qty', cell: ({ row }) => formatNumber(row.original.quantity) },
{ id: 'weight', header: 'Weight', cell: ({ row }) => formatNumber(row.original.weight) },
{
id: 'arrived',
header: 'Arrived',
cell: ({ row }) => <Text size="xs">{formatDate(row.original.arrivedAt)}</Text>,
},
{
id: 'ready',
header: 'Ready',
cell: ({ row }) => <Text size="xs">{formatDate(row.original.readyForLoadingAt)}</Text>,
},
];
export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
return ( return (
<DataTable <Text c="dimmed" ta="center" py="xl">
columns={columns} No matching items. Adjust your search to locate cargo, containers or goods.
data={results} </Text>
status="success" );
emptyMessage="No matching items. Adjust your search to locate cargo, containers or goods." }
containerClassName="border-0 shadow-none"
/> return (
<Table.ScrollContainer minWidth={1100}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Item</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Location</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th>Ready</Table.Th>
{onView ? <Table.Th ta="right">Actions</Table.Th> : null}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{results.map((result) => (
<Table.Tr key={result.id}>
<Table.Td>
<Text size="sm" fw={600}>
{result.bookingReference ?? result.bookingNumber ?? result.bookingId?.slice(0, 8) ?? dash}
</Text>
</Table.Td>
<Table.Td>{result.customerName ?? dash}</Table.Td>
<Table.Td>{itemDescriptor(result)}</Table.Td>
<Table.Td>
<Stack gap={0}>
<Text size="sm">{result.warehouse?.name ?? dash}</Text>
{result.warehouse?.code ? (
<Text size="xs" c="dimmed">
{result.warehouse.code}
</Text>
) : null}
</Stack>
</Table.Td>
<Table.Td>{result.yard?.name ?? dash}</Table.Td>
<Table.Td>{result.zone?.name ?? dash}</Table.Td>
<Table.Td>
<Stack gap={0}>
<Text size="sm">{result.locationSummary ?? dash}</Text>
{result.trainNumber ? (
<Text size="xs" c="dimmed">
{result.trainNumber}
{result.route ? ` - ${result.route}` : ''}
</Text>
) : null}
</Stack>
</Table.Td>
<Table.Td>
{result.status ? (
<InventoryStatusBadge status={result.status} />
) : (
<Badge variant="light" color={result.trainStatus === 'ARRIVED' ? 'orange' : 'blue'} size="sm">
{result.trainStatus ?? result.bookingStatus ?? 'Not in warehouse'}
</Badge>
)}
</Table.Td>
<Table.Td>{formatNumber(result.quantity)}</Table.Td>
<Table.Td>{formatNumber(result.weight)}</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(result.arrivedAt)}</Text>
</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(result.readyForLoadingAt)}</Text>
</Table.Td>
{onView ? (
<Table.Td>
<Tooltip label="View details" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onView(result)} ml="auto">
<Eye size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
) : null}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
); );
} }

View File

@@ -1,15 +1,13 @@
import { DataTable, type ColumnDef } from "@edr/ui-common"; import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
import { ActionIcon, Badge, Button, Group, Text, Tooltip } from "@mantine/core"; import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
import { ArrowRightLeft, ClipboardList, Coins, History } from "lucide-react";
import { useMemo } from "react";
import { import {
INVENTORY_NEXT_ACTION, getNextInventoryAction,
type InventoryAction, type InventoryAction,
type WarehouseInventoryItem, type WarehouseInventoryItem,
} from "@/types/warehouse"; } from '@/types/warehouse';
import { InventoryStatusBadge } from "./badges"; import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber, humanizeEnum } from "./options"; import { formatDate, formatNumber, humanizeEnum } from './options';
interface WarehouseInventoryTableProps { interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[]; items: WarehouseInventoryItem[];
@@ -17,11 +15,11 @@ interface WarehouseInventoryTableProps {
onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void; onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void;
onMove: (item: WarehouseInventoryItem) => void; onMove: (item: WarehouseInventoryItem) => void;
onHistory: (item: WarehouseInventoryItem) => void; onHistory: (item: WarehouseInventoryItem) => void;
onView?: (item: WarehouseInventoryItem) => void;
onInspect?: (item: WarehouseInventoryItem) => void; onInspect?: (item: WarehouseInventoryItem) => void;
onFeePreview?: (item: WarehouseInventoryItem) => void; onFeePreview?: (item: WarehouseInventoryItem) => void;
// Optional Last Mile action — only rendered for items whose booking requested door delivery. onReleaseDocument?: (item: WarehouseInventoryItem) => void;
onLastMile?: (item: WarehouseInventoryItem) => void; onLastMile?: (item: WarehouseInventoryItem) => void;
// Optional row selection (used for bulk Mark-as-Inspected).
selectedIds?: Set<string>; selectedIds?: Set<string>;
onToggleSelect?: (id: string) => void; onToggleSelect?: (id: string) => void;
onToggleSelectAll?: () => void; onToggleSelectAll?: () => void;
@@ -30,21 +28,21 @@ interface WarehouseInventoryTableProps {
} }
const itemKind = (item: WarehouseInventoryItem) => { const itemKind = (item: WarehouseInventoryItem) => {
if (item.containerId) return { label: "Container", color: "blue" }; if (item.containerId) return { label: 'Container', color: 'blue' };
if (item.cargoId) return { label: "Cargo", color: "grape" }; if (item.cargoId) return { label: 'Cargo', color: 'grape' };
if (item.goodsId) return { label: "Goods", color: "orange" }; if (item.goodsId) return { label: 'Goods', color: 'orange' };
return { label: "—", color: "gray" }; return { label: '-', color: 'gray' };
}; };
const actionColor: Record<InventoryAction, string> = { const actionColor: Record<InventoryAction, string> = {
store: "blue", store: 'blue',
reserve: "grape", reserve: 'grape',
"ready-for-loading": "cyan", 'ready-for-loading': 'cyan',
load: "teal", load: 'teal',
dispatch: "edr-green", dispatch: 'edr-green',
"ready-for-pickup": "orange", 'ready-for-pickup': 'orange',
release: "yellow", release: 'yellow',
deliver: "green", deliver: 'green',
}; };
export function WarehouseInventoryTable({ export function WarehouseInventoryTable({
@@ -53,97 +51,111 @@ export function WarehouseInventoryTable({
onAdvance, onAdvance,
onMove, onMove,
onHistory, onHistory,
onView,
onInspect, onInspect,
onFeePreview, onFeePreview,
onReleaseDocument,
onLastMile,
selectedIds,
onToggleSelect,
onToggleSelectAll,
allSelected,
someSelected,
}: WarehouseInventoryTableProps) { }: WarehouseInventoryTableProps) {
const columns = useMemo<ColumnDef<WarehouseInventoryItem>[]>( const selectable = Boolean(onToggleSelect);
() => [
{ if (items.length === 0) {
id: "booking", return (
header: "Booking", <Text size="sm" c="dimmed" ta="center" py="xl">
cell: ({ row }) => No inventory items found.
row.original.bookingId ? ( </Text>
<Tooltip label={row.original.bookingId} withArrow> );
}
return (
<Table.ScrollContainer minWidth={1150}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
{selectable && (
<Table.Th w={40}>
<Checkbox
aria-label="Select all"
checked={allSelected}
indeterminate={someSelected}
onChange={onToggleSelectAll}
/>
</Table.Th>
)}
<Table.Th>Booking</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Item</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item) => {
const kind = itemKind(item);
const busy = busyId === item.id;
const nextAction = getNextInventoryAction(item);
return (
<Table.Tr key={item.id}>
{selectable && (
<Table.Td>
<Checkbox
aria-label={`Select ${item.bookingId ?? item.id}`}
checked={selectedIds?.has(item.id) ?? false}
onChange={() => onToggleSelect?.(item.id)}
/>
</Table.Td>
)}
<Table.Td>
{item.bookingId ? (
<Tooltip label={item.bookingId} withArrow>
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
{row.original.bookingId.slice(0, 8)} {item.bookingId.slice(0, 8)}...
</Text> </Text>
</Tooltip> </Tooltip>
) : ( ) : (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
-
</Text> </Text>
), )}
}, </Table.Td>
{ <Table.Td>{item.warehouse?.facility?.name ?? '-'}</Table.Td>
id: "facility", <Table.Td>{item.warehouse?.code ?? '-'}</Table.Td>
header: "Facility", <Table.Td>{item.yard?.code ?? '-'}</Table.Td>
cell: ({ row }) => row.original.warehouse?.facility?.name ?? "—", <Table.Td>{item.zone?.code ?? '-'}</Table.Td>
}, <Table.Td>
{
id: "warehouse",
header: "Warehouse",
cell: ({ row }) => row.original.warehouse?.code ?? "—",
},
{
id: "yard",
header: "Yard",
cell: ({ row }) => row.original.yard?.code ?? "—",
},
{
id: "zone",
header: "Zone",
cell: ({ row }) => row.original.zone?.code ?? "—",
},
{
id: "item",
header: "Item",
cell: ({ row }) => {
const kind = itemKind(row.original);
return (
<Badge color={kind.color} variant="light" size="sm" radius="md"> <Badge color={kind.color} variant="light" size="sm" radius="md">
{kind.label} {kind.label}
</Badge> </Badge>
); </Table.Td>
}, <Table.Td>{formatNumber(item.quantity)}</Table.Td>
}, <Table.Td>{formatNumber(item.weight)}</Table.Td>
{ <Table.Td>
id: "qty", <InventoryStatusBadge status={item.status} />
header: "Qty", </Table.Td>
cell: ({ row }) => formatNumber(row.original.quantity), <Table.Td>
}, <Text size="xs">{formatDate(item.arrivedAt)}</Text>
{ </Table.Td>
id: "weight", <Table.Td>
header: "Weight", <Group gap="xs" justify="flex-end" wrap="nowrap">
cell: ({ row }) => formatNumber(row.original.weight), {onView && (
}, <Tooltip label="View details" withArrow>
{ <ActionIcon variant="subtle" color="gray" onClick={() => onView(item)}>
id: "status", <Eye size={16} />
header: "Status", </ActionIcon>
cell: ({ row }) => ( </Tooltip>
<InventoryStatusBadge status={row.original.status} /> )}
),
},
{
id: "arrived",
header: "Arrived",
cell: ({ row }) => (
<Text size="xs">{formatDate(row.original.arrivedAt)}</Text>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => {
const item = row.original;
const busy = busyId === item.id;
const nextAction = INVENTORY_NEXT_ACTION[item.status];
return (
<Group
gap="xs"
justify="flex-end"
wrap="nowrap"
onClick={(e) => e.stopPropagation()}
>
{nextAction && ( {nextAction && (
<Button <Button
size="compact-xs" size="compact-xs"
@@ -152,66 +164,82 @@ export function WarehouseInventoryTable({
loading={busy} loading={busy}
onClick={() => onAdvance(item, nextAction)} onClick={() => onAdvance(item, nextAction)}
> >
{humanizeEnum(nextAction.replace(/-/g, "_"))} {humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button> </Button>
)} )}
{item.status !== "DISPATCHED" && ( {item.status === 'READY_FOR_PICKUP' && (
<Tooltip label="Move" withArrow> <>
<ActionIcon <Button
variant="subtle" size="compact-xs"
color="gray" variant="light"
onClick={() => onMove(item)} color="blue"
loading={busy}
onClick={() => onAdvance(item, 'store')}
> >
Store
</Button>
<Button
size="compact-xs"
variant="light"
color="green"
loading={busy}
onClick={() => onAdvance(item, 'dispatch')}
>
Dispatch
</Button>
</>
)}
{item.status !== 'DISPATCHED' && (
<Tooltip label="Move" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>
<ArrowRightLeft size={16} /> <ArrowRightLeft size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
{onInspect && ( {onInspect && (
<Tooltip label="Inspection / Report" withArrow> <Tooltip label="Inspection / Report" withArrow>
<ActionIcon <ActionIcon variant="subtle" color="orange" onClick={() => onInspect(item)}>
variant="subtle"
color="orange"
onClick={() => onInspect(item)}
>
<ClipboardList size={16} /> <ClipboardList size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
{onFeePreview && ( {onFeePreview && (
<Tooltip label="Storage / Demurrage preview" withArrow> <Tooltip label="Storage / Demurrage preview" withArrow>
<ActionIcon <ActionIcon variant="subtle" color="teal" onClick={() => onFeePreview(item)}>
variant="subtle"
color="teal"
onClick={() => onFeePreview(item)}
>
<Coins size={16} /> <Coins size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
<Tooltip label="History" withArrow> {onReleaseDocument && item.releaseDate && (
<Tooltip label="View release exit paper" withArrow>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="orange"
onClick={() => onHistory(item)} onClick={() => onReleaseDocument(item)}
> >
<FileText size={16} />
</ActionIcon>
</Tooltip>
)}
{onLastMile && item.booking?.lastMileDeliveryAddress && (
<Tooltip label="Last mile delivery" withArrow>
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>
<MapPin size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
<History size={16} /> <History size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</Group> </Group>
</Table.Td>
</Table.Tr>
); );
}, })}
}, </Table.Tbody>
], </Table>
[busyId, onAdvance, onMove, onHistory, onInspect, onFeePreview], </Table.ScrollContainer>
);
return (
<DataTable
columns={columns}
data={items}
status="success"
emptyMessage="No inventory items found."
containerClassName="border-0 shadow-none"
/>
); );
} }

View File

@@ -1,10 +1,20 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { ActionIcon, Group, Text } from '@mantine/core'; import { ActionIcon, Group, Text } from '@mantine/core';
import { Eye, Pencil } from 'lucide-react'; import {
Building2,
Eye,
MapPin,
Package,
Pencil,
Scale,
Warehouse as WarehouseIcon,
} from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common'; import { DataTable, type ColumnDef } from '@edr/ui-common';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { bookingTable } from '@/components/bookings/booking-ui.styles';
import { api } from '@/services/api'; import { api } from '@/services/api';
import type { Warehouse } from '@/types/warehouse'; import type { Warehouse } from '@/types/warehouse';
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges'; import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
@@ -16,6 +26,43 @@ interface WarehouseTableProps {
onEdit: (warehouse: Warehouse) => void; onEdit: (warehouse: Warehouse) => void;
} }
const HEADER = bookingTable.headerCell;
function CapacityCell({
current,
capacity,
icon,
}: {
current?: number | null;
capacity?: number | null;
icon: ReactNode;
}) {
const numericCurrent = Number(current) || 0;
const numericCapacity = Number(capacity) || 0;
const hasCapacity = numericCapacity > 0;
const ratio = hasCapacity ? Math.min(100, Math.max(0, (numericCurrent / numericCapacity) * 100)) : 0;
const isOverCapacity = hasCapacity && numericCurrent > numericCapacity;
return (
<div className="min-w-[8rem] space-y-2 py-1">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<span className="flex size-7 shrink-0 items-center justify-center rounded-lg border border-border/50 bg-background/70 text-muted-foreground">
{icon}
</span>
<span className="whitespace-nowrap">{formatCapacity(numericCurrent, capacity)}</span>
</div>
{hasCapacity ? (
<div className="h-1.5 overflow-hidden rounded-full bg-muted/50">
<div
className={isOverCapacity ? 'h-full rounded-full bg-red-500' : 'h-full rounded-full bg-edr-green'}
style={{ width: `${ratio}%` }}
/>
</div>
) : null}
</div>
);
}
export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) { export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) {
const { data: stations } = useQuery( const { data: stations } = useQuery(
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }), api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
@@ -28,63 +75,98 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
const columns: ColumnDef<Warehouse>[] = [ const columns: ColumnDef<Warehouse>[] = [
{ {
id: 'code', id: 'code',
header: 'Code', header: () => <span className={HEADER}>Warehouse</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<Text <div className="flex min-w-[11rem] items-center gap-3 py-1.5">
fw={600} <div className={bookingTable.rowIcon}>
size="sm" <WarehouseIcon className="size-4" strokeWidth={1.75} />
c="edr-green.7" </div>
style={{ cursor: 'pointer' }} <div className="min-w-0">
onClick={() => onView(row.original)} <button
type="button"
className="block max-w-full truncate text-left text-sm font-semibold text-edr-green transition-colors hover:text-edr-green/80"
onClick={(e) => {
e.stopPropagation();
onView(row.original);
}}
> >
{row.original.code} {row.original.code}
</Text> </button>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{row.original.name}
</p>
</div>
</div>
), ),
}, },
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
{ {
id: 'facility', id: 'facility',
header: 'Facility', header: () => <span className={HEADER}>Facility</span>,
cell: ({ row }) => { cell: ({ row }) => {
const name = row.original.stationId const name = row.original.stationId
? stationNameById.get(row.original.stationId) ? stationNameById.get(row.original.stationId)
: undefined; : undefined;
return name ? ( return name ? (
<Text size="sm" fw={500}> <div className="flex min-w-[9rem] items-center gap-2 py-1 text-sm font-medium text-foreground">
{name} <Building2 className="size-4 shrink-0 text-muted-foreground" />
</Text> <span className="truncate">{name}</span>
</div>
) : ( ) : (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
-
</Text> </Text>
); );
}, },
}, },
{ {
id: 'type', id: 'type',
header: 'Type', header: () => <span className={HEADER}>Type</span>,
cell: ({ row }) => <WarehouseTypeBadge type={row.original.type} />, cell: ({ row }) => (
<div className="py-1">
<WarehouseTypeBadge type={row.original.type} />
</div>
),
}, },
{ {
id: 'location', id: 'location',
header: 'Location', header: () => <span className={HEADER}>Location</span>,
cell: ({ row }) => row.original.locationName ?? '—', cell: ({ row }) => (
<div className="flex min-w-[10rem] items-center gap-2 py-1 text-sm text-foreground">
<MapPin className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{row.original.locationName ?? '-'}</span>
</div>
),
}, },
{ {
id: 'weight', id: 'weight',
header: 'Weight (cur / cap)', header: () => <span className={HEADER}>Weight</span>,
cell: ({ row }) => formatCapacity(row.original.currentWeight, row.original.capacityWeight), cell: ({ row }) => (
<CapacityCell
current={row.original.currentWeight}
capacity={row.original.capacityWeight}
icon={<Scale className="size-3.5" />}
/>
),
}, },
{ {
id: 'containers', id: 'containers',
header: 'Containers (cur / cap)', header: () => <span className={HEADER}>Containers</span>,
cell: ({ row }) => cell: ({ row }) => (
formatCapacity(row.original.currentContainers, row.original.capacityContainers), <CapacityCell
current={row.original.currentContainers}
capacity={row.original.capacityContainers}
icon={<Package className="size-3.5" />}
/>
),
}, },
{ {
id: 'status', id: 'status',
header: 'Status', header: () => <span className={HEADER}>Status</span>,
cell: ({ row }) => <WarehouseStatusBadge status={row.original.status} />, cell: ({ row }) => (
<div className="py-1">
<WarehouseStatusBadge status={row.original.status} />
</div>
),
}, },
{ {
id: 'actions', id: 'actions',
@@ -109,7 +191,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
status="success" status="success"
onRowClick={(warehouse) => onView(warehouse)} onRowClick={(warehouse) => onView(warehouse)}
emptyMessage="No warehouses found." emptyMessage="No warehouses found."
containerClassName="border-0 shadow-none" containerClassName="border-0 bg-transparent shadow-none"
/> />
); );
} }

View File

@@ -57,6 +57,8 @@ const inventoryStatusColor: Record<InventoryStatus, string> = {
RECEIVED: "yellow", RECEIVED: "yellow",
STORED: "blue", STORED: "blue",
RESERVED: "grape", RESERVED: "grape",
ARRIVED_AT_WAREHOUSE: "orange",
UNDER_INSPECTION: "yellow",
READY_FOR_LOADING: "cyan", READY_FOR_LOADING: "cyan",
LOADED: "teal", LOADED: "teal",
READY_FOR_PICKUP: "teal", READY_FOR_PICKUP: "teal",

View File

@@ -16,6 +16,8 @@ export { ReserveInventoryModal } from './ReserveInventoryModal';
export { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable'; export { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable';
export { ActivityTimeline } from './ActivityTimeline'; export { ActivityTimeline } from './ActivityTimeline';
export { InventoryHistoryModal } from './InventoryHistoryModal'; export { InventoryHistoryModal } from './InventoryHistoryModal';
export { InventoryDetailModal } from './InventoryDetailModal';
export { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
export { InventoryWorkbench } from './InventoryWorkbench'; export { InventoryWorkbench } from './InventoryWorkbench';
export { BookingSelect } from './BookingSelect'; export { BookingSelect } from './BookingSelect';
export { WagonSelect } from './WagonSelect'; export { WagonSelect } from './WagonSelect';

View File

@@ -0,0 +1,24 @@
export function openPdfBlob(blob: Blob, filename: string, targetWindow?: Window | null) {
const url = URL.createObjectURL(blob);
if (targetWindow && !targetWindow.closed) {
targetWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60_000);
return true;
}
const opened = window.open(url, '_blank');
if (opened) {
setTimeout(() => URL.revokeObjectURL(url), 60_000);
return true;
}
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
return false;
}

View File

@@ -276,38 +276,43 @@ export const URL_CONSTANTS = {
}, },
WAREHOUSE_YARDS: { WAREHOUSE_YARDS: {
BASE: '/warehouse-yards',
BY_ID: (id: string) => `/warehouse-yards/${id}`, BY_ID: (id: string) => `/warehouse-yards/${id}`,
ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`, ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`,
}, },
WAREHOUSE_ZONES: { WAREHOUSE_ZONES: {
BASE: '/warehouse-zones',
BY_ID: (id: string) => `/warehouse-zones/${id}`, BY_ID: (id: string) => `/warehouse-zones/${id}`,
}, },
WAREHOUSE_INVENTORY: { WAREHOUSE_INVENTORY: {
BASE: '/warehouse-inventory', BASE: '/warehouse-inventory',
RECEIVE: '/warehouse-inventory/receive', RECEIVE: '/warehouse-inventory/receive',
DASHBOARD_SUMMARY: '/warehouse-inventory/dashboard/summary',
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
INQUIRY: '/warehouse-inventory/inquiry',
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`,
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
RESERVE: '/warehouse-inventory/reserve', RESERVE: '/warehouse-inventory/reserve',
ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue', ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue',
AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived', AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived',
AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready', AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready',
UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`, UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`,
INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`, INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`,
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
INQUIRY: '/warehouse-inventory/inquiry',
LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons', LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons',
BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`, BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`,
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`, MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`, ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`, LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`,
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
// Import branch // Import branch
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`, MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`, RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`, DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
// Receive (Import/Export bulk) // Receive (Import/Export bulk)
ELIGIBLE_BOOKINGS: (direction?: string) => ELIGIBLE_BOOKINGS: (direction?: string) =>

View File

@@ -0,0 +1,538 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { warehouseService } from '@/services/warehouse.service';
import type {
InspectionReportPayload,
SaveAllocationRulePayload,
SaveFeeRulePayload,
WarehouseInvoiceFilter,
PayInvoicePayload,
InventoryFilter,
InventoryInquiryFilter,
LoadInventoryPayload,
MoveInventoryPayload,
ReceiveInventoryPayload,
ReleaseOrderPayload,
DeliverInventoryPayload,
BulkReceivePayload,
BulkInspectPayload,
ReserveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
SaveZonePayload,
WarehouseFilter,
} from '@/types/warehouse';
export const warehouseKeys = {
all: ['warehouses'] as const,
list: (filter?: WarehouseFilter) => ['warehouses', 'list', filter ?? {}] as const,
facilities: () => ['warehouses', 'facilities'] as const,
detail: (id: string) => ['warehouses', 'detail', id] as const,
yards: (warehouseId: string) => ['warehouses', warehouseId, 'yards'] as const,
allYards: () => ['warehouse-yards', 'all'] as const,
zones: (yardId: string) => ['warehouse-yards', yardId, 'zones'] as const,
allZones: () => ['warehouse-zones', 'all'] as const,
inventory: (filter?: InventoryFilter) => ['warehouse-inventory', 'list', filter ?? {}] as const,
dashboardSummary: (filter?: InventoryFilter) => ['warehouse-dashboard', 'summary', filter ?? {}] as const,
inquiry: (filter: InventoryInquiryFilter) => ['warehouse-inventory', 'inquiry', filter] as const,
};
// ── Warehouses ─────────────────────────────────────────────────────────────
export function useWarehouses(filter?: WarehouseFilter) {
return useQuery({
queryKey: warehouseKeys.list(filter),
queryFn: () => warehouseService.list(filter).then((r) => r.data),
});
}
export function useWarehouse(id?: string) {
return useQuery({
queryKey: warehouseKeys.detail(id ?? ''),
queryFn: () => warehouseService.getById(id as string).then((r) => r.data),
enabled: Boolean(id),
});
}
export function useWarehouseFacilities() {
return useQuery({
queryKey: warehouseKeys.facilities(),
queryFn: () => warehouseService.listFacilities().then((r) => r.data),
});
}
export function useCreateWarehouse() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: SaveWarehousePayload) => warehouseService.create(payload),
onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }),
});
}
export function useUpdateWarehouse() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: Partial<SaveWarehousePayload> }) =>
warehouseService.update(id, payload),
onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: warehouseKeys.all });
qc.invalidateQueries({ queryKey: warehouseKeys.detail(id) });
},
});
}
// ── Yards ────────────────────────────────────────────────────────────────
export function useWarehouseYards(warehouseId?: string) {
return useQuery({
queryKey: warehouseKeys.yards(warehouseId ?? ''),
queryFn: () => warehouseService.listYards(warehouseId as string).then((r) => r.data),
enabled: Boolean(warehouseId),
});
}
export function useAllWarehouseYards() {
return useQuery({
queryKey: warehouseKeys.allYards(),
queryFn: () => warehouseService.listAllYards().then((r) => r.data),
});
}
export function useCreateYard() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ warehouseId, payload }: { warehouseId: string; payload: SaveYardPayload }) =>
warehouseService.createYard(warehouseId, payload),
onSuccess: (_, { warehouseId }) => {
qc.invalidateQueries({ queryKey: warehouseKeys.yards(warehouseId) });
qc.invalidateQueries({ queryKey: warehouseKeys.detail(warehouseId) });
},
});
}
export function useUpdateYard() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: Partial<SaveYardPayload> }) =>
warehouseService.updateYard(id, payload),
onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }),
});
}
// ── Zones ──────────────────────────────────────────────────────────────────
export function useWarehouseZones(yardId?: string) {
return useQuery({
queryKey: warehouseKeys.zones(yardId ?? ''),
queryFn: () => warehouseService.listZones(yardId as string).then((r) => r.data),
enabled: Boolean(yardId),
});
}
export function useAllWarehouseZones() {
return useQuery({
queryKey: warehouseKeys.allZones(),
queryFn: () => warehouseService.listAllZones().then((r) => r.data),
});
}
export function useCreateZone() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ yardId, payload }: { yardId: string; payload: SaveZonePayload }) =>
warehouseService.createZone(yardId, payload),
onSuccess: (_, { yardId }) => qc.invalidateQueries({ queryKey: warehouseKeys.zones(yardId) }),
});
}
export function useUpdateZone() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: Partial<SaveZonePayload> }) =>
warehouseService.updateZone(id, payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-yards'] }),
});
}
// ── Inventory ──────────────────────────────────────────────────────────────
export function useWarehouseInventory(filter?: InventoryFilter) {
return useQuery({
queryKey: warehouseKeys.inventory(filter),
queryFn: () => warehouseService.listInventory(filter).then((r) => r.data),
});
}
export function useWarehouseDashboardSummary(filter?: InventoryFilter) {
return useQuery({
queryKey: warehouseKeys.dashboardSummary(filter),
queryFn: () => warehouseService.getDashboardSummary(filter).then((r) => r.data),
});
}
export function useReceiveInventory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: ReceiveInventoryPayload) => warehouseService.receiveInventory(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: warehouseKeys.all });
qc.invalidateQueries({ queryKey: ['warehouse-dashboard'] });
},
});
}
function useInventoryMutation<TArgs>(fn: (args: TArgs) => Promise<unknown>) {
const qc = useQueryClient();
return useMutation({
mutationFn: fn,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: ['warehouse-loadings'] });
qc.invalidateQueries({ queryKey: warehouseKeys.all });
},
});
}
export const useStoreInventory = () => useInventoryMutation((id: string) => warehouseService.store(id));
export const useReserveInventory = () =>
useInventoryMutation((payload: ReserveInventoryPayload) => warehouseService.reserve(payload));
export const useMarkReadyForLoading = () =>
useInventoryMutation((id: string) => warehouseService.markReadyForLoading(id));
export const useLoadInventory = () =>
useInventoryMutation((args: { id: string; payload: LoadInventoryPayload }) =>
warehouseService.load(args.id, args.payload),
);
export const useDispatchInventory = () => useInventoryMutation((id: string) => warehouseService.dispatch(id));
export const useMoveInventory = () =>
useInventoryMutation((args: { id: string; payload: MoveInventoryPayload }) =>
warehouseService.move(args.id, args.payload),
);
// ── Import branch (READY_FOR_PICKUP → DELIVERED) ───────────────────────────
export const useMarkReadyForPickup = () =>
useInventoryMutation((id: string) => warehouseService.markReadyForPickup(id));
export const useReleaseInventory = () =>
useInventoryMutation((args: { id: string; payload: ReleaseOrderPayload }) =>
warehouseService.release(args.id, args.payload),
);
export const useDeliverInventory = () =>
useInventoryMutation((args: { id: string; payload: DeliverInventoryPayload }) =>
warehouseService.deliver(args.id, args.payload),
);
// ── Receive (Import/Export bulk) ───────────────────────────────────────────
/**
* All not-yet-received PAID bookings, classified IMPORT/EXPORT by route, in one call.
* Both Receive tabs share this single query (same key) — only one HTTP request fires —
* then filter client-side by direction.
*/
export function useEligibleBookings(enabled = true) {
return useQuery({
queryKey: ['warehouse-inventory', 'eligible-bookings'],
queryFn: () => warehouseService.eligibleBookings().then((r) => r.data),
enabled,
});
}
export const useBulkReceive = () =>
useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload));
export const useLoadPassedExport = () =>
useInventoryMutation(() => warehouseService.loadPassedExport());
export const useBulkMarkInspected = () =>
useInventoryMutation((payload: BulkInspectPayload) => warehouseService.bulkMarkInspected(payload));
export function useReadyToLoadExport(enabled = true) {
return useQuery({
queryKey: ['warehouse-inventory', 'ready-to-load-export'],
queryFn: () => warehouseService.readyToLoadExport().then((r) => r.data),
enabled,
});
}
export function useLoadedExport(enabled = true) {
return useQuery({
queryKey: ['warehouse-inventory', 'loaded-export'],
queryFn: () => warehouseService.loadedExport().then((r) => r.data),
enabled,
});
}
export const useBulkDispatchExport = () =>
useInventoryMutation((inventoryIds: string[]) => warehouseService.bulkDispatchExport(inventoryIds));
/** Arrived IMPORT trains (route-derived). Read-only. */
export function useImportArriveQueue(enabled = true) {
return useQuery({
queryKey: ['warehouse-inventory', 'import-arrive-queue'],
queryFn: () => warehouseService.importArriveQueue().then((r) => r.data),
enabled,
});
}
/** Assigned bookings/items for an arrived import train. Read-only. */
export function useImportTrainItems(scheduleId?: string) {
return useQuery({
queryKey: ['warehouse-inventory', 'import-train-items', scheduleId],
queryFn: () => warehouseService.importTrainItems(scheduleId as string).then((r) => r.data),
enabled: Boolean(scheduleId),
});
}
/** Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED). */
export const useAutoUnloadArrivedBookings = () =>
useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId));
/** IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection). Read-only. */
export function useImportUnloadedQueue(enabled = true) {
return useQuery({
queryKey: ['warehouse-inventory', 'import-unloaded-queue'],
queryFn: () => warehouseService.importUnloadedQueue().then((r) => r.data),
enabled,
});
}
/** IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch. Read-only. */
export function useImportPickupReadyQueue(enabled = true) {
return useQuery({
queryKey: ['warehouse-inventory', 'import-pickup-ready-queue'],
queryFn: () => warehouseService.importPickupReadyQueue().then((r) => r.data),
enabled,
});
}
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
export function useLoadableWagons(enabled = true) {
return useQuery({
queryKey: ['warehouse', 'loadable-wagons'],
queryFn: () => warehouseService.loadableWagons().then((r) => r.data),
enabled,
});
}
export function useWarehouseLoadings(params?: { bookingId?: string; wagonId?: string }) {
return useQuery({
queryKey: ['warehouse-loadings', params ?? {}],
queryFn: () => warehouseService.loadings(params).then((r) => r.data),
});
}
export function useBookingSchedule(bookingId?: string) {
return useQuery({
queryKey: ['warehouse', 'booking-schedule', bookingId ?? ''],
queryFn: () => warehouseService.bookingSchedule(bookingId as string).then((r) => r.data),
enabled: Boolean(bookingId),
});
}
export function useInventoryMovements(id?: string) {
return useQuery({
queryKey: ['warehouse-inventory', id, 'movements'],
queryFn: () => warehouseService.movements(id as string).then((r) => r.data),
enabled: Boolean(id),
});
}
export function useInventoryActivity(id?: string) {
return useQuery({
queryKey: ['warehouse-inventory', id, 'activity'],
queryFn: () => warehouseService.activity(id as string).then((r) => r.data),
enabled: Boolean(id),
});
}
export function useWarehouseDashboard() {
return useQuery({
queryKey: ['warehouses', 'dashboard'],
queryFn: () => warehouseService.dashboard().then((r) => r.data),
});
}
export function useInventoryInquiry(filter: InventoryInquiryFilter, enabled = true) {
return useQuery({
queryKey: warehouseKeys.inquiry(filter),
queryFn: () => warehouseService.inquiry(filter).then((r) => r.data),
enabled,
});
}
// ── Batch 4.5: Arrival / Unload / Inspection ────────────────────────────────
export function useArrivalQueue() {
return useQuery({
queryKey: ['warehouse-inventory', 'arrival-queue'],
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
});
}
function useArrivalInvalidation() {
const qc = useQueryClient();
return () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: warehouseKeys.all });
};
}
export function useAutoUnloadArrived() {
const onSuccess = useArrivalInvalidation();
return useMutation({ mutationFn: () => warehouseService.autoUnloadArrived(), onSuccess });
}
export function useAutoLoadReady() {
const onSuccess = useArrivalInvalidation();
return useMutation({ mutationFn: () => warehouseService.autoLoadReady(), onSuccess });
}
export function useUnloadBooking() {
const onSuccess = useArrivalInvalidation();
return useMutation({
mutationFn: (args: { bookingId: string; payload?: Record<string, unknown> }) =>
warehouseService.unloadBooking(args.bookingId, args.payload),
onSuccess,
});
}
export function useInspectionReports(inventoryId?: string) {
return useQuery({
queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'],
queryFn: () => warehouseService.listInspectionReports(inventoryId as string).then((r) => r.data),
enabled: Boolean(inventoryId),
});
}
export function useCreateInspectionReport() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ inventoryId, payload }: { inventoryId: string; payload: InspectionReportPayload }) =>
warehouseService.createInspectionReport(inventoryId, payload).then((r) => r.data),
onSuccess: (_, { inventoryId }) => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'] });
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
},
});
}
export function useUploadInspectionAttachments() {
return useMutation({
mutationFn: ({ reportId, files }: { reportId: string; files: File[] }) =>
warehouseService.uploadInspectionAttachments(reportId, files),
});
}
// ── Batch 5: Allocation + Fee rules / preview ───────────────────────────────
export function useAllocationRules() {
return useQuery({
queryKey: ['warehouse-allocation-rules'],
queryFn: () => warehouseService.listAllocationRules().then((r) => r.data),
});
}
export function useFeeRules() {
return useQuery({
queryKey: ['warehouse-fee-rules'],
queryFn: () => warehouseService.listFeeRules().then((r) => r.data),
});
}
function useRuleMutation<TArgs>(fn: (args: TArgs) => Promise<unknown>, keys: string[]) {
const qc = useQueryClient();
return useMutation({
mutationFn: fn,
onSuccess: () => keys.forEach((k) => qc.invalidateQueries({ queryKey: [k] })),
});
}
export const useCreateAllocationRule = () =>
useRuleMutation(
(payload: SaveAllocationRulePayload) => warehouseService.createAllocationRule(payload),
['warehouse-allocation-rules'],
);
export const useUpdateAllocationRule = () =>
useRuleMutation(
(args: { id: string; payload: Partial<SaveAllocationRulePayload> }) =>
warehouseService.updateAllocationRule(args.id, args.payload),
['warehouse-allocation-rules'],
);
export const useDeleteAllocationRule = () =>
useRuleMutation((id: string) => warehouseService.deleteAllocationRule(id), ['warehouse-allocation-rules']);
export const useCreateFeeRule = () =>
useRuleMutation((payload: SaveFeeRulePayload) => warehouseService.createFeeRule(payload), ['warehouse-fee-rules']);
export const useUpdateFeeRule = () =>
useRuleMutation(
(args: { id: string; payload: Partial<SaveFeeRulePayload> }) =>
warehouseService.updateFeeRule(args.id, args.payload),
['warehouse-fee-rules'],
);
export const useDeleteFeeRule = () =>
useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']);
export function useFeePreview(inventoryId?: string) {
return useQuery({
queryKey: ['warehouse-inventory', inventoryId, 'fee-preview'],
queryFn: () => warehouseService.feePreview(inventoryId as string).then((r) => r.data),
enabled: Boolean(inventoryId),
});
}
// ── Batch 6: Warehouse fee invoices ─────────────────────────────────────────
export function useWarehouseInvoices(filter?: WarehouseInvoiceFilter) {
return useQuery({
queryKey: ['warehouse-fee-invoices', filter ?? {}],
queryFn: () => warehouseService.listInvoices(filter).then((r) => r.data),
});
}
export function useWarehouseInvoice(id?: string) {
return useQuery({
queryKey: ['warehouse-fee-invoices', 'detail', id],
queryFn: () => warehouseService.getInvoice(id as string).then((r) => r.data),
enabled: Boolean(id),
});
}
export function useInvoicesForInventory(inventoryId?: string) {
return useQuery({
queryKey: ['warehouse-inventory', inventoryId, 'fee-invoices'],
queryFn: () => warehouseService.invoicesForInventory(inventoryId as string).then((r) => r.data),
enabled: Boolean(inventoryId),
});
}
function useInvoiceInvalidation() {
const qc = useQueryClient();
return () => {
qc.invalidateQueries({ queryKey: ['warehouse-fee-invoices'] });
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
};
}
export function useGenerateInvoice() {
const onSuccess = useInvoiceInvalidation();
return useMutation({
mutationFn: ({ inventoryId, confirmZero }: { inventoryId: string; confirmZero?: boolean }) =>
warehouseService.generateInvoice(inventoryId, confirmZero).then((r) => r.data),
onSuccess,
});
}
export function useCancelInvoice() {
const onSuccess = useInvoiceInvalidation();
return useMutation({ mutationFn: (id: string) => warehouseService.cancelInvoice(id), onSuccess });
}
export function usePayInvoice() {
const onSuccess = useInvoiceInvalidation();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: PayInvoicePayload }) =>
warehouseService.payInvoice(id, payload),
onSuccess,
});
}
export function useGateClearance() {
const onSuccess = useInvoiceInvalidation();
return useMutation({ mutationFn: (inventoryId: string) => warehouseService.gateClearance(inventoryId), onSuccess });
}

View File

@@ -103,9 +103,8 @@ export default function BookingRequestsPage() {
page: 1, page: 1,
pageSize: 100, pageSize: 100,
statuses: "PAID", statuses: "PAID",
schedulingStatuses: "NOT_SCHEDULED,HOLDING,ELIGIBLE",
assignedToSchedule: "false", assignedToSchedule: "false",
sortBy: "isGovernment", sortBy: "createdAt",
sortOrder: "DESC", sortOrder: "DESC",
tab: activeTab, tab: activeTab,
}; };

View File

@@ -1,197 +1,271 @@
import { useState } from 'react'; import { Fragment, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Badge, Badge,
Button, Button,
Card, Card,
Container,
Group, Group,
Loader,
Stack,
Table,
Text, Text,
} from '@mantine/core'; } from '@mantine/core';
import { ClipboardList, Eye, PackageOpen, Truck } from 'lucide-react'; import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { PageContainer, PageHeader } from '@/components/page'; import { PageHeader } from '@/components/page';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { import {
InspectionReportModal,
VisualEmptyState, VisualEmptyState,
WarehouseHero,
formatDate, formatDate,
formatNumber,
} from '@/components/warehouses'; } from '@/components/warehouses';
import { useMutation, useQuery } from '@tanstack/react-query'; import {
useAutoUnloadArrivedBookings,
import { api } from '@/services/api'; useImportArriveQueue,
useImportTrainItems,
} from '@/hooks/useWarehouses';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import type { ArrivalQueueItem } from '@/types/warehouse'; import type { AutoUnloadArrivedResult, ImportTrain, ImportTrainItem } from '@/types/warehouse';
function inspectionBadge(status: string | null) { const getErrorMessage = (error: unknown) => {
if (!status) return <Badge variant="light" color="gray" size="sm">Not inspected</Badge>; if (error && typeof error === 'object' && 'response' in error) {
const color = status === 'PASSED' ? 'edr-green' : status === 'FAILED' ? 'red' : 'orange'; const response = (error as { response?: { data?: { message?: unknown } } }).response;
return <Badge variant="light" color={color} size="sm">{status.replace(/_/g, ' ')}</Badge>; const message = response?.data?.message;
} if (Array.isArray(message)) return message.join(', ');
if (typeof message === 'string') return message;
/** Batch 4.5 — arrived bookings awaiting unload / inspection. */
export default function ArrivalQueuePage() {
const navigate = useNavigate();
const { toast } = useToast();
const { data, isLoading } = useQuery(api.warehouses.arrivalQueue.queryOptions());
const autoUnload = useMutation(api.warehouses.autoUnloadArrived.mutationOptions());
const unloadOne = useMutation(api.warehouses.unloadBooking.mutationOptions());
const [inspectInventoryId, setInspectInventoryId] = useState<string | null>(null);
const items = data ?? [];
const handleAutoUnload = async () => {
try {
const r = await autoUnload.mutateAsync();
toast({
title: 'Auto-unload complete',
description: `Processed ${r.processedCount}, skipped ${r.skippedCount}, failed ${r.failedCount}.`,
});
} catch {
toast({ variant: 'destructive', title: 'Auto-unload failed' });
} }
}; return error instanceof Error ? error.message : undefined;
};
const handleUnloadOne = async (item: ArrivalQueueItem) => { function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
try { const { data: items = [], isLoading } = useImportTrainItems(scheduleId);
await unloadOne.mutateAsync({ bookingId: item.bookingId });
toast({ title: 'Booking unloaded', description: `${item.bookingReference} stored as RECEIVED.` });
} catch {
toast({ variant: 'destructive', title: 'Unload failed' });
}
};
const columns: ColumnDef<ArrivalQueueItem>[] = [ if (isLoading) {
{
id: 'booking',
header: 'Booking',
cell: ({ row }) => (
<Text fw={600} size="sm">
{row.original.bookingReference}
</Text>
),
},
{ id: 'customer', header: 'Customer', cell: ({ row }) => row.original.customer ?? '—' },
{
id: 'cargo',
header: 'Cargo / Container',
cell: ({ row }) => row.original.container ?? row.original.cargo ?? '—',
},
{
id: 'arrival',
header: 'Arrival',
cell: ({ row }) => <Text size="xs">{formatDate(row.original.arrivalDate)}</Text>,
},
{ id: 'facility', header: 'Facility', cell: ({ row }) => row.original.facility ?? '—' },
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse ?? '—' },
{ id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard ?? '—' },
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone ?? '—' },
{
id: 'status',
header: 'Status',
cell: ({ row }) =>
row.original.unloaded ? (
<Badge variant="light" color="edr-green" size="sm">
{row.original.currentStatus ?? 'RECEIVED'}
</Badge>
) : (
<Badge variant="light" color="orange" size="sm">
Not unloaded
</Badge>
),
},
{
id: 'inspection',
header: 'Inspection',
cell: ({ row }) => inspectionBadge(row.original.inspectionStatus),
},
{
id: 'actions',
header: '',
cell: ({ row }) => {
const item = row.original;
return ( return (
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}> <Group justify="center" py="md">
{!item.unloaded && ( <Loader size="sm" />
<Button
size="compact-xs"
variant="light"
leftSection={<Truck size={14} />}
loading={unloadOne.isPending}
onClick={() => handleUnloadOne(item)}
>
Unload
</Button>
)}
{item.inventoryId && (
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<ClipboardList size={14} />}
onClick={() => setInspectInventoryId(item.inventoryId)}
>
Inspect
</Button>
)}
{item.inventoryId && (
<Button
size="compact-xs"
variant="subtle"
color="gray"
leftSection={<Eye size={14} />}
onClick={() => navigate('/dashboard/warehouse-inventory')}
>
Inventory
</Button>
)}
</Group> </Group>
); );
}, }
},
]; if (items.length === 0) {
return (
<Text c="dimmed" ta="center" py="md" size="sm">
No assigned bookings found for this train.
</Text>
);
}
return ( return (
<PageContainer> <Table highlightOnHover verticalSpacing="xs">
<PageHeader <Table.Thead>
title="Arrival / Unloading Queue" <Table.Tr>
subtitle="Arrived bookings ready to unload, store and inspect." <Table.Th>Booking</Table.Th>
action={ <Table.Th>Customer</Table.Th>
<Button <Table.Th>Container</Table.Th>
leftSection={<PackageOpen size={16} />} <Table.Th>Cargo</Table.Th>
loading={autoUnload.isPending} <Table.Th>Weight</Table.Th>
onClick={handleAutoUnload} <Table.Th>Arrival</Table.Th>
> <Table.Th>Status</Table.Th>
Auto Unload Arrived Bookings <Table.Th>Pickup</Table.Th>
</Button> </Table.Tr>
} </Table.Thead>
/> <Table.Tbody>
{items.map((item: ImportTrainItem) => (
<Card> <Table.Tr key={item.bookingId}>
<Text fw={600} mb="md"> <Table.Td>
{items.length} arrived booking(s) <Text size="sm" fw={600}>
{item.bookingReference ?? item.bookingId.slice(0, 8)}
</Text> </Text>
</Table.Td>
{!isLoading && items.length === 0 ? ( <Table.Td>{item.customerName ?? '-'}</Table.Td>
<VisualEmptyState <Table.Td>{item.containerNumber ?? '-'}</Table.Td>
variant="container" <Table.Td>{item.cargoType ?? '-'}</Table.Td>
title="No arrived bookings" <Table.Td>{formatNumber(item.weight)}</Table.Td>
description="Bookings in transit that arrive appear here for unloading and inspection." <Table.Td>{formatDate(item.arrivalTime)}</Table.Td>
/> <Table.Td>
) : ( <Badge
<DataTable variant="light"
columns={columns} color={item.currentStatus === 'UNLOADED' ? 'green' : 'orange'}
data={items} size="sm"
status={isLoading ? 'loading' : 'success'} >
containerClassName="border-0 shadow-none" {item.currentStatus ?? 'PENDING'}
/> </Badge>
)} </Table.Td>
</Card> <Table.Td>{item.pickupOption.replace(/_/g, ' ')}</Table.Td>
</Table.Tr>
<InspectionReportModal ))}
opened={Boolean(inspectInventoryId)} </Table.Tbody>
onClose={() => setInspectInventoryId(null)} </Table>
inventoryId={inspectInventoryId} );
/> }
</PageContainer>
/** Arrived import trains awaiting unload into warehouse inventory. */
export default function ArrivalQueuePage() {
const { toast } = useToast();
const { data: trains = [], isLoading } = useImportArriveQueue();
const autoUnload = useAutoUnloadArrivedBookings();
const [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
const unloadTrain = async (train: ImportTrain) => {
setBusyScheduleId(train.scheduleId);
try {
const res = (await autoUnload.mutateAsync(train.scheduleId)) as {
data: AutoUnloadArrivedResult;
};
const result = res.data;
const details = [
result.skippedCount ? `${result.skippedCount} skipped` : '',
result.failedCount ? `${result.failedCount} failed` : '',
]
.filter(Boolean)
.join(', ');
toast({
title: `${result.unloadedCount} booking(s) unloaded`,
description: details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`,
});
} catch (error) {
toast({
variant: 'destructive',
title: 'Auto unload failed',
description: getErrorMessage(error),
});
} finally {
setBusyScheduleId(null);
}
};
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Arrival queue' }]} />
<Stack gap="lg" mt="sm">
<PageHeader
title="Arrival / Unloading Queue"
subtitle="Arrived import trains ready to unload assigned bookings into warehouse inventory."
/>
<WarehouseHero
variant="container"
secondaryVariant="warehouse"
title="Arrival / Unloading Queue"
subtitle="Arrived import trains ready to unload assigned bookings into warehouse inventory."
/>
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md">
<Text fw={600}>{trains.length} arrived import train(s)</Text>
<Text size="sm" c="dimmed">
Open a train to review assigned bookings, then auto unload it.
</Text>
</Group>
{isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : trains.length === 0 ? (
<VisualEmptyState
variant="container"
title="No arrived import trains"
description="Import trains appear here once their train schedule status is ARRIVED."
/>
) : (
<Table.ScrollContainer minWidth={1150}>
<Table verticalSpacing="sm" highlightOnHover striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Train</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th>Origin</Table.Th>
<Table.Th>Destination</Table.Th>
<Table.Th>Arrival</Table.Th>
<Table.Th ta="center">Bookings</Table.Th>
<Table.Th ta="center">Containers</Table.Th>
<Table.Th ta="center">Cargoes</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{trains.map((train: ImportTrain) => {
const isOpen = openScheduleId === train.scheduleId;
return (
<Fragment key={train.scheduleId}>
<Table.Tr>
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={700}>
{train.trainNumber ?? '-'}
</Text>
<Text size="xs" c="dimmed">
{train.scheduleId.slice(0, 8)}
</Text>
</Stack>
</Table.Td>
<Table.Td>{train.route ?? '-'}</Table.Td>
<Table.Td>{train.origin ?? '-'}</Table.Td>
<Table.Td>{train.destination ?? '-'}</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(train.arrivalTime)}</Text>
</Table.Td>
<Table.Td ta="center">{train.totalBookings}</Table.Td>
<Table.Td ta="center">{train.totalContainers}</Table.Td>
<Table.Td ta="center">{train.totalCargoes}</Table.Td>
<Table.Td>
<Badge variant="light" color="teal" size="sm">
{train.status}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
variant="light"
leftSection={
isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />
}
onClick={() => setOpenScheduleId(isOpen ? null : train.scheduleId)}
>
Open
</Button>
<Button
size="compact-xs"
color="orange"
leftSection={
busyScheduleId === train.scheduleId ? (
<PackageOpen size={14} />
) : (
<Truck size={14} />
)
}
loading={busyScheduleId === train.scheduleId}
onClick={() => unloadTrain(train)}
>
Auto Unload
</Button>
</Group>
</Table.Td>
</Table.Tr>
{isOpen && (
<Table.Tr>
<Table.Td colSpan={10} bg="var(--mantine-color-gray-0)">
<ImportTrainDetailRows scheduleId={train.scheduleId} />
</Table.Td>
</Table.Tr>
)}
</Fragment>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
</Stack>
</Container>
); );
} }

View File

@@ -3,35 +3,30 @@ import { Button, Card, Center, Group, Loader, Select, Stack, TextInput } from '@
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page'; import { PageContainer, PageHeader } from '@/components/page';
import { useQuery } from '@tanstack/react-query'; import {
InventoryInquiryDetailModal,
import { VisualEmptyState, WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses'; VisualEmptyState,
import { api } from '@/services/api'; WarehouseInquiryTable,
import type { InventoryInquiryFilter, InventoryStatus } from '@/types/warehouse'; inventoryStatusOptions,
} from '@/components/warehouses';
import {
useAllWarehouseYards,
useAllWarehouseZones,
useInventoryInquiry,
useWarehouses,
} from '@/hooks/useWarehouses';
import type { InventoryInquiryFilter, InventoryInquiryResult, InventoryStatus } from '@/types/warehouse';
export default function InventoryInquiryPage() { export default function InventoryInquiryPage() {
const [draft, setDraft] = useState<InventoryInquiryFilter>({}); const [draft, setDraft] = useState<InventoryInquiryFilter>({});
const [applied, setApplied] = useState<InventoryInquiryFilter>({}); const [applied, setApplied] = useState<InventoryInquiryFilter>({});
const [viewResult, setViewResult] = useState<InventoryInquiryResult | null>(null);
const warehousesQuery = useQuery( const warehousesQuery = useWarehouses();
api.warehouses.list.queryOptions({ input: {} }), const yardsQuery = useAllWarehouseYards();
); const zonesQuery = useAllWarehouseZones();
const yardsQuery = useQuery(
api.warehouses.listYards.queryOptions({
input: { warehouseId: draft.warehouseId ?? '' },
enabled: Boolean(draft.warehouseId),
}),
);
const zonesQuery = useQuery(
api.warehouses.listZones.queryOptions({
input: { yardId: draft.yardId ?? '' },
enabled: Boolean(draft.yardId),
}),
);
const { data, isFetching } = useQuery( const { data, isFetching } = useInventoryInquiry(applied);
api.warehouses.inquiry.queryOptions({ input: { filter: applied } }),
);
const results = data ?? []; const results = data ?? [];
const warehouseOptions = useMemo( const warehouseOptions = useMemo(
@@ -39,15 +34,39 @@ export default function InventoryInquiryPage() {
[warehousesQuery.data], [warehousesQuery.data],
); );
const yardOptions = useMemo( const yardOptions = useMemo(
() => (yardsQuery.data ?? []).map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })), () =>
[yardsQuery.data], (yardsQuery.data ?? [])
.filter((y) => !draft.warehouseId || y.warehouseId === draft.warehouseId)
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
[draft.warehouseId, yardsQuery.data],
); );
const zoneOptions = useMemo( const zoneOptions = useMemo(
() => (zonesQuery.data ?? []).map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })), () => {
[zonesQuery.data], const visibleYardIds = new Set(
(yardsQuery.data ?? [])
.filter((y) => !draft.warehouseId || y.warehouseId === draft.warehouseId)
.map((y) => y.id),
);
return (zonesQuery.data ?? [])
.filter((z) => {
if (draft.yardId) return z.yardId === draft.yardId;
if (draft.warehouseId) return visibleYardIds.has(z.yardId);
return true;
})
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
},
[draft.warehouseId, draft.yardId, yardsQuery.data, zonesQuery.data],
); );
const runSearch = () => setApplied(draft); const normalizeDraft = (filter: InventoryInquiryFilter): InventoryInquiryFilter => ({
...filter,
bookingReference: filter.bookingReference?.trim() || undefined,
containerNumber: filter.containerNumber?.trim() || undefined,
cargoType: filter.cargoType?.trim() || undefined,
goodsName: filter.goodsName?.trim() || undefined,
});
const runSearch = () => setApplied(normalizeDraft(draft));
const reset = () => { const reset = () => {
setDraft({}); setDraft({});
setApplied({}); setApplied({});
@@ -60,14 +79,18 @@ export default function InventoryInquiryPage() {
subtitle="Locate any cargo, container or goods inside the warehouse network." subtitle="Locate any cargo, container or goods inside the warehouse network."
/> />
<Card> <Stack gap="lg" mt="sm">
<Card withBorder radius="md" padding="lg">
<Stack gap="md"> <Stack gap="md">
<Group gap="sm" wrap="wrap"> <Group gap="sm" wrap="wrap">
<TextInput <TextInput
label="Booking number" label="Booking reference"
placeholder="e.g. BKG-00123" placeholder="e.g. BKG-00123"
value={draft.bookingNumber ?? ''} value={draft.bookingReference ?? ''}
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingNumber: v || undefined })); }} onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingReference: v || undefined })); }}
onKeyDown={(e) => {
if (e.key === 'Enter') runSearch();
}}
w={200} w={200}
/> />
<TextInput <TextInput
@@ -101,7 +124,6 @@ export default function InventoryInquiryPage() {
placeholder="Any" placeholder="Any"
clearable clearable
searchable searchable
disabled={!draft.warehouseId}
data={yardOptions} data={yardOptions}
value={draft.yardId ?? null} value={draft.yardId ?? null}
onChange={(value) => setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))} onChange={(value) => setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
@@ -112,7 +134,6 @@ export default function InventoryInquiryPage() {
placeholder="Any" placeholder="Any"
clearable clearable
searchable searchable
disabled={!draft.yardId}
data={zoneOptions} data={zoneOptions}
value={draft.zoneId ?? null} value={draft.zoneId ?? null}
onChange={(value) => setDraft((f) => ({ ...f, zoneId: value ?? undefined }))} onChange={(value) => setDraft((f) => ({ ...f, zoneId: value ?? undefined }))}
@@ -140,7 +161,7 @@ export default function InventoryInquiryPage() {
</Stack> </Stack>
</Card> </Card>
<Card> <Card withBorder radius="md" padding="lg">
{isFetching ? ( {isFetching ? (
<Center py="xl"> <Center py="xl">
<Loader /> <Loader />
@@ -152,9 +173,15 @@ export default function InventoryInquiryPage() {
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network." description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
/> />
) : ( ) : (
<WarehouseInquiryTable results={results} /> <WarehouseInquiryTable results={results} onView={setViewResult} />
)} )}
</Card> </Card>
</Stack>
<InventoryInquiryDetailModal
opened={Boolean(viewResult)}
onClose={() => setViewResult(null)}
result={viewResult}
/>
</PageContainer> </PageContainer>
); );
} }

View File

@@ -1,5 +1,5 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Card, Center, Group, Loader, SimpleGrid, Text, ThemeIcon } from '@mantine/core'; import { Card, Center, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { import {
ClipboardCheck, ClipboardCheck,
ClipboardList, ClipboardList,
@@ -16,10 +16,8 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page'; import { PageContainer, PageHeader } from '@/components/page';
import { useQuery } from '@tanstack/react-query'; import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses';
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
import { WarehouseDashboardCharts } from '@/components/warehouses';
import { api } from '@/services/api';
import type { WarehouseDashboard } from '@/types/warehouse'; import type { WarehouseDashboard } from '@/types/warehouse';
interface Metric { interface Metric {
@@ -31,8 +29,8 @@ interface Metric {
theme: string; theme: string;
} }
const ORANGE = '#f08c00'; const ORANGE = 'rgb(241, 147, 23)';
const GREEN = '#22c55e'; const GREEN = '#084b21';
const METRICS: Metric[] = [ const METRICS: Metric[] = [
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE }, { key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
@@ -51,7 +49,7 @@ const METRICS: Metric[] = [
export default function WarehouseDashboardPage() { export default function WarehouseDashboardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { data, isLoading } = useQuery(api.warehouses.dashboard.queryOptions()); const { data, isError, isLoading } = useWarehouseDashboard();
return ( return (
<PageContainer> <PageContainer>
@@ -60,10 +58,22 @@ export default function WarehouseDashboardPage() {
subtitle="Live overview of warehouse capacity and inventory lifecycle." subtitle="Live overview of warehouse capacity and inventory lifecycle."
/> />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="train"
secondaryVariant="warehouse"
title="Warehouse Dashboard"
subtitle="Live overview of warehouse capacity and inventory lifecycle."
/>
{isLoading ? ( {isLoading ? (
<Center py="xl"> <Center py="xl">
<Loader /> <Loader />
</Center> </Center>
) : isError ? (
<Center py="xl">
<Text c="red">Failed to load warehouse dashboard.</Text>
</Center>
) : ( ) : (
<> <>
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md"> <SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
@@ -99,6 +109,7 @@ export default function WarehouseDashboardPage() {
<WarehouseDashboardCharts data={data} /> <WarehouseDashboardCharts data={data} />
</> </>
)} )}
</Stack>
</PageContainer> </PageContainer>
); );
} }

View File

@@ -5,16 +5,15 @@ import {
Button, Button,
Card, Card,
Center, Center,
Container,
Group, Group,
Loader, Loader,
Stack,
Select, Select,
Stack,
Tabs, Tabs,
Text, Text,
} from '@mantine/core'; } from '@mantine/core';
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react'; import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { DataTable, type ColumnDef } from '@edr/ui-common'; import { DataTable, type ColumnDef } from '@edr/ui-common';
import { KpiStrip, PageContainer, PageHeader } from '@/components/page'; import { KpiStrip, PageContainer, PageHeader } from '@/components/page';
@@ -27,8 +26,6 @@ import {
formatCapacity, formatCapacity,
humanizeEnum, humanizeEnum,
} from '@/components/warehouses'; } from '@/components/warehouses';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/services/api'; import { api } from '@/services/api';
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse'; import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
@@ -51,7 +48,6 @@ export default function WarehouseDetailPage() {
const [yardModalOpen, setYardModalOpen] = useState(false); const [yardModalOpen, setYardModalOpen] = useState(false);
const [editingYard, setEditingYard] = useState<WarehouseYard | null>(null); const [editingYard, setEditingYard] = useState<WarehouseYard | null>(null);
const [zoneModalOpen, setZoneModalOpen] = useState(false); const [zoneModalOpen, setZoneModalOpen] = useState(false);
const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null); const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null);
const [selectedYardId, setSelectedYardId] = useState<string | null>(null); const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
@@ -169,14 +165,18 @@ export default function WarehouseDetailPage() {
if (!warehouse) { if (!warehouse) {
return ( return (
<Container size="sm" py="xl"> <PageContainer>
<Stack align="center" gap="md"> <Stack align="center" gap="md" py="xl">
<Text fw={700}>Warehouse not found</Text> <Text fw={700}>Warehouse not found</Text>
<Button variant="default" leftSection={<ArrowLeft size={16} />} onClick={() => navigate('/dashboard/warehouses')}> <Button
variant="default"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate('/dashboard/warehouses')}
>
Back to warehouses Back to warehouses
</Button> </Button>
</Stack> </Stack>
</Container> </PageContainer>
); );
} }
@@ -189,9 +189,7 @@ export default function WarehouseDetailPage() {
]} ]}
backTo="/dashboard/warehouses" backTo="/dashboard/warehouses"
title={warehouse.name} title={warehouse.name}
subtitle={`${warehouse.code}${ subtitle={`${warehouse.code}${warehouse.locationName ? ` - ${warehouse.locationName}` : ''}`}
warehouse.locationName ? ` · ${warehouse.locationName}` : ''
}`}
meta={ meta={
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<WarehouseTypeBadge type={warehouse.type} /> <WarehouseTypeBadge type={warehouse.type} />
@@ -216,7 +214,6 @@ export default function WarehouseDetailPage() {
</Tabs.Tab> </Tabs.Tab>
</Tabs.List> </Tabs.List>
{/* OVERVIEW */}
<Tabs.Panel value="overview" pt="lg"> <Tabs.Panel value="overview" pt="lg">
<KpiStrip <KpiStrip
items={[ items={[
@@ -237,7 +234,6 @@ export default function WarehouseDetailPage() {
/> />
</Tabs.Panel> </Tabs.Panel>
{/* YARDS */}
<Tabs.Panel value="yards" pt="lg"> <Tabs.Panel value="yards" pt="lg">
<Card withBorder radius="md" padding="lg"> <Card withBorder radius="md" padding="lg">
<Stack gap="md"> <Stack gap="md">
@@ -259,11 +255,7 @@ export default function WarehouseDetailPage() {
columns={yardColumns} columns={yardColumns}
data={yards} data={yards}
status={ status={
yardsQuery.isLoading yardsQuery.isLoading ? 'loading' : yardsQuery.isError ? 'error' : 'success'
? 'loading'
: yardsQuery.isError
? 'error'
: 'success'
} }
emptyMessage="No yards yet." emptyMessage="No yards yet."
containerClassName="border-0 shadow-none" containerClassName="border-0 shadow-none"
@@ -280,7 +272,6 @@ export default function WarehouseDetailPage() {
</Card> </Card>
</Tabs.Panel> </Tabs.Panel>
{/* ZONES */}
<Tabs.Panel value="zones" pt="lg"> <Tabs.Panel value="zones" pt="lg">
<Card withBorder radius="md" padding="lg"> <Card withBorder radius="md" padding="lg">
<Stack gap="md"> <Stack gap="md">
@@ -316,11 +307,7 @@ export default function WarehouseDetailPage() {
columns={zoneColumns} columns={zoneColumns}
data={zonesQuery.data ?? []} data={zonesQuery.data ?? []}
status={ status={
zonesQuery.isLoading zonesQuery.isLoading ? 'loading' : zonesQuery.isError ? 'error' : 'success'
? 'loading'
: zonesQuery.isError
? 'error'
: 'success'
} }
emptyMessage="No zones in this yard yet." emptyMessage="No zones in this yard yet."
containerClassName="border-0 shadow-none" containerClassName="border-0 shadow-none"
@@ -338,10 +325,12 @@ export default function WarehouseDetailPage() {
</Card> </Card>
</Tabs.Panel> </Tabs.Panel>
{/* INVENTORY */}
<Tabs.Panel value="inventory" pt="lg"> <Tabs.Panel value="inventory" pt="lg">
<Card withBorder radius="md" padding="lg"> <Card withBorder radius="md" padding="lg">
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} /> <InventoryWorkbench
items={inventoryQuery.data ?? []}
isLoading={inventoryQuery.isLoading}
/>
</Card> </Card>
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>

View File

@@ -10,9 +10,12 @@ import {
ReceiveInventoryModal, ReceiveInventoryModal,
inventoryStatusOptions, inventoryStatusOptions,
} from '@/components/warehouses'; } from '@/components/warehouses';
import { useQuery } from '@tanstack/react-query'; import {
useWarehouseInventory,
import { api } from '@/services/api'; useWarehouseYards,
useWarehouseZones,
useWarehouses,
} from '@/hooks/useWarehouses';
import type { InventoryFilter, InventoryStatus } from '@/types/warehouse'; import type { InventoryFilter, InventoryStatus } from '@/types/warehouse';
export default function WarehouseInventoryPage() { export default function WarehouseInventoryPage() {
@@ -30,24 +33,10 @@ export default function WarehouseInventoryPage() {
[filter, debouncedSearch], [filter, debouncedSearch],
); );
const warehousesQuery = useQuery( const warehousesQuery = useWarehouses();
api.warehouses.list.queryOptions({ input: {} }), const yardsQuery = useWarehouseYards(filter.warehouseId);
); const zonesQuery = useWarehouseZones(filter.yardId);
const yardsQuery = useQuery( const inventoryQuery = useWarehouseInventory(queryFilter);
api.warehouses.listYards.queryOptions({
input: { warehouseId: filter.warehouseId ?? '' },
enabled: Boolean(filter.warehouseId),
}),
);
const zonesQuery = useQuery(
api.warehouses.listZones.queryOptions({
input: { yardId: filter.yardId ?? '' },
enabled: Boolean(filter.yardId),
}),
);
const inventoryQuery = useQuery(
api.warehouses.listInventory.queryOptions({ input: { filter: queryFilter } }),
);
const warehouseOptions = useMemo( const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), () => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
@@ -91,7 +80,12 @@ export default function WarehouseInventoryPage() {
data={warehouseOptions} data={warehouseOptions}
value={filter.warehouseId ?? null} value={filter.warehouseId ?? null}
onChange={(value) => onChange={(value) =>
setFilter((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined })) setFilter((f) => ({
...f,
warehouseId: value ?? undefined,
yardId: undefined,
zoneId: undefined,
}))
} }
w={220} w={220}
/> />
@@ -102,7 +96,9 @@ export default function WarehouseInventoryPage() {
disabled={!filter.warehouseId} disabled={!filter.warehouseId}
data={yardOptions} data={yardOptions}
value={filter.yardId ?? null} value={filter.yardId ?? null}
onChange={(value) => setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))} onChange={(value) =>
setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))
}
w={200} w={200}
/> />
<Select <Select
@@ -120,7 +116,9 @@ export default function WarehouseInventoryPage() {
clearable clearable
data={inventoryStatusOptions} data={inventoryStatusOptions}
value={filter.status ?? null} value={filter.status ?? null}
onChange={(value) => setFilter((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))} onChange={(value) =>
setFilter((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))
}
w={200} w={200}
/> />
</Group> </Group>

View File

@@ -37,7 +37,7 @@ const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
CANCELLED: 'gray', CANCELLED: 'gray',
}; };
const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c}`; const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c === 'ETB' ? 'Birr (ETB)' : c}`;
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—'); const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
export default function WarehouseInvoicesPage() { export default function WarehouseInvoicesPage() {

View File

@@ -12,9 +12,7 @@ import {
WarehouseTable, WarehouseTable,
type WarehouseView, type WarehouseView,
} from '@/components/warehouses'; } from '@/components/warehouses';
import { useQuery } from '@tanstack/react-query'; import { useWarehouses } from '@/hooks/useWarehouses';
import { api } from '@/services/api';
import type { Warehouse, WarehouseFilter } from '@/types/warehouse'; import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
export default function WarehouseListPage() { export default function WarehouseListPage() {
@@ -30,9 +28,7 @@ export default function WarehouseListPage() {
[filter, debouncedSearch], [filter, debouncedSearch],
); );
const { data, isLoading, isError } = useQuery( const { data, isLoading, isError } = useWarehouses(queryFilter);
api.warehouses.list.queryOptions({ input: { filter: queryFilter } }),
);
const warehouses = data ?? []; const warehouses = data ?? [];
const openCreate = () => { const openCreate = () => {

View File

@@ -1,26 +1,34 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
ActionIcon, ActionIcon,
Alert,
Badge, Badge,
Button, Button,
Card, Card,
Group, Group,
Loader,
Modal, Modal,
NumberInput, NumberInput,
Select, Select,
Stack, Stack,
Tabs, Tabs,
Table,
Text, Text,
TextInput, TextInput,
} from '@mantine/core'; } from '@mantine/core';
import { Plus, Trash2 } from 'lucide-react'; import { Info, Plus, Trash2 } from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { PageContainer, PageHeader } from '@/components/page'; import { PageContainer, PageHeader } from '@/components/page';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import {
useAllWarehouseYards,
useAllocationRules,
useCreateAllocationRule,
useCreateFeeRule,
useDeleteAllocationRule,
useDeleteFeeRule,
useFeeRules,
} from '@/hooks/useWarehouses';
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse'; import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
const FREIGHT = [ const FREIGHT = [
@@ -32,15 +40,26 @@ const TRADE = [
{ value: 'EXPORT', label: 'Export' }, { value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' }, { value: 'DOMESTIC', label: 'Domestic' },
]; ];
const CURRENCIES = [
{ value: 'USD', label: 'USD - Dollar' },
{ value: 'ETB', label: 'ETB - Birr' },
];
const clean = (s: string) => s.trim() || undefined; const clean = (s: string) => s.trim() || undefined;
const selectValue = (value: string | null, fallback = '') => value ?? fallback;
const numberValue = (value: string | number, fallback = 0) => {
const next = Number(value);
return Number.isFinite(next) ? next : fallback;
};
const anyLabel = (value: string, label: string) => value.trim() || `Any ${label}`;
const dash = '-';
export default function WarehouseRulesPage() { export default function WarehouseRulesPage() {
return ( return (
<PageContainer> <PageContainer>
<PageHeader <PageHeader
title="Allocation & Fee Rules" title="Allocation & Fee Rules"
subtitle="Configure deterministic yard allocation and storage / demurrage free time and rates." subtitle="Configure yard allocation and storage or demurrage free time and rates."
/> />
<Card> <Card>
<Tabs defaultValue="allocation"> <Tabs defaultValue="allocation">
@@ -62,11 +81,10 @@ export default function WarehouseRulesPage() {
function AllocationRules() { function AllocationRules() {
const { toast } = useToast(); const { toast } = useToast();
const { data, isLoading } = useQuery( const { data, isLoading } = useAllocationRules();
api.warehouses.allocationRules.queryOptions(), const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards();
); const create = useCreateAllocationRule();
const create = useMutation(api.warehouses.createAllocationRule.mutationOptions()); const remove = useDeleteAllocationRule();
const remove = useMutation(api.warehouses.deleteAllocationRule.mutationOptions());
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [form, setForm] = useState({ const [form, setForm] = useState({
name: '', name: '',
@@ -78,13 +96,33 @@ function AllocationRules() {
targetYardCode: '', targetYardCode: '',
storageType: '', storageType: '',
}); });
const rules = data ?? []; const rules = data ?? [];
const yardOptions = yards
.filter((yard) => yard.code)
.map((yard) => ({
value: yard.code,
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
}));
const resetForm = () =>
setForm({
name: '',
priority: 100,
freightType: '',
tradeDirection: '',
cargoTypeCode: '',
containerStatus: '',
targetYardCode: '',
storageType: '',
});
const submit = async () => { const submit = async () => {
if (!form.name.trim() || !form.targetYardCode.trim()) { if (!form.name.trim() || !form.targetYardCode.trim()) {
toast({ variant: 'destructive', title: 'Name and target yard code are required' }); toast({ variant: 'destructive', title: 'Name and target yard are required' });
return; return;
} }
await create.mutateAsync({ await create.mutateAsync({
name: form.name.trim(), name: form.name.trim(),
priority: form.priority, priority: form.priority,
@@ -98,77 +136,179 @@ function AllocationRules() {
} as never); } as never);
toast({ title: 'Allocation rule created' }); toast({ title: 'Allocation rule created' });
setOpen(false); setOpen(false);
setForm({ name: '', priority: 100, freightType: '', tradeDirection: '', cargoTypeCode: '', containerStatus: '', targetYardCode: '', storageType: '' }); resetForm();
}; };
const columns: ColumnDef<(typeof rules)[number]>[] = [
{ id: 'priority', header: 'Priority', cell: ({ row }) => row.original.priority },
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
{ id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? '—' },
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? '—' },
{ id: 'cargo', header: 'Cargo code', cell: ({ row }) => row.original.cargoTypeCode ?? '—' },
{
id: 'targetYard',
header: 'Target yard',
cell: ({ row }) => <Badge variant="light">{row.original.targetYardCode}</Badge>,
},
{
id: 'active',
header: 'Active',
cell: ({ row }) => (
<Badge color={row.original.isActive ? 'edr-green' : 'gray'} variant="light">
{row.original.isActive ? 'Yes' : 'No'}
</Badge>
),
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(row.original.id)} title="Delete">
<Trash2 size={16} />
</ActionIcon>
</Group>
),
},
];
return ( return (
<> <>
<Group justify="space-between" mb="sm"> <Group justify="space-between" mb="sm">
<Text c="dimmed" size="sm">{rules.length} rule(s) matched by ascending priority</Text> <Text c="dimmed" size="sm">
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New allocation rule</Button> {rules.length} rule(s) matched by ascending priority
</Text>
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
New allocation rule
</Button>
</Group> </Group>
<DataTable <Alert icon={<Info size={16} />} color="orange" variant="light" mb="md">
columns={columns} <Text size="sm">
data={rules} Allocation rules tell the system where to place a booking when it enters the warehouse.
status={isLoading ? 'loading' : 'success'} Lower priority numbers are checked first.
emptyMessage="No allocation rules yet." </Text>
containerClassName="border-0 shadow-none" </Alert>
/>
{isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : (
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Priority</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Freight</Table.Th>
<Table.Th>Trade</Table.Th>
<Table.Th>Cargo code</Table.Th>
<Table.Th>Target yard</Table.Th>
<Table.Th>Active</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rules.map((rule) => (
<Table.Tr key={rule.id}>
<Table.Td>{rule.priority}</Table.Td>
<Table.Td>{rule.name}</Table.Td>
<Table.Td>{rule.freightType ?? dash}</Table.Td>
<Table.Td>{rule.tradeDirection ?? dash}</Table.Td>
<Table.Td>{rule.cargoTypeCode ?? dash}</Table.Td>
<Table.Td>
<Badge variant="light">{rule.targetYardCode}</Badge>
</Table.Td>
<Table.Td>
<Badge color={rule.isActive ? 'green' : 'gray'} variant="light">
{rule.isActive ? 'Yes' : 'No'}
</Badge>
</Table.Td>
<Table.Td ta="right">
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg"> <Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
<Stack gap="sm"> <Stack gap="md">
<Card withBorder radius="md" padding="sm" bg="gray.0">
<Stack gap={4}>
<Text size="xs" c="dimmed" fw={700} tt="uppercase">
Rule preview
</Text>
<Text size="sm">
<b>When</b> {anyLabel(form.tradeDirection, 'trade direction').toLowerCase()} /{' '}
{anyLabel(form.freightType, 'freight type').toLowerCase()} booking
{form.cargoTypeCode.trim() ? ` with cargo code ${form.cargoTypeCode.trim()}` : ''}
{form.containerStatus.trim() ? ` and container status ${form.containerStatus.trim()}` : ''}{' '}
is received, <b>send it to</b> {form.targetYardCode || 'a selected target yard'}.
</Text>
</Stack>
</Card>
<Group grow> <Group grow>
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} /> <TextInput
<NumberInput label="Priority" value={form.priority} onChange={(v) => setForm((f) => ({ ...f, priority: Number(v) || 100 }))} /> label="Rule name"
placeholder="e.g. Import containers to open yard"
required
value={form.name}
onChange={(e) => {
const value = e.currentTarget.value;
setForm((f) => ({ ...f, name: value }));
}}
/>
<NumberInput
label="Priority"
value={form.priority}
onChange={(v) => setForm((f) => ({ ...f, priority: numberValue(v, 100) || 100 }))}
/>
</Group> </Group>
<Group grow> <Group grow>
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable /> <Select
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable /> label="Freight type"
placeholder="Any freight"
data={FREIGHT}
value={form.freightType || null}
onChange={(v) => setForm((f) => ({ ...f, freightType: selectValue(v) }))}
clearable
/>
<Select
label="Trade direction"
placeholder="Any direction"
data={TRADE}
value={form.tradeDirection || null}
onChange={(v) => setForm((f) => ({ ...f, tradeDirection: selectValue(v) }))}
clearable
/>
</Group> </Group>
<Group grow> <Group grow>
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} /> <TextInput
<TextInput label="Container status" placeholder="e.g. MAINTENANCE" value={form.containerStatus} onChange={(e) => setForm((f) => ({ ...f, containerStatus: e.currentTarget.value }))} /> label="Cargo type code"
placeholder="e.g. COFFEE"
value={form.cargoTypeCode}
onChange={(e) => {
const value = e.currentTarget.value;
setForm((f) => ({ ...f, cargoTypeCode: value }));
}}
/>
<TextInput
label="Container status"
placeholder="e.g. MAINTENANCE"
value={form.containerStatus}
onChange={(e) => {
const value = e.currentTarget.value;
setForm((f) => ({ ...f, containerStatus: value }));
}}
/>
</Group> </Group>
<Group grow> <Group grow>
<TextInput label="Target yard code" required value={form.targetYardCode} onChange={(e) => setForm((f) => ({ ...f, targetYardCode: e.currentTarget.value }))} /> <Select
<TextInput label="Storage type" value={form.storageType} onChange={(e) => setForm((f) => ({ ...f, storageType: e.currentTarget.value }))} /> label="Target yard"
required
searchable
clearable
data={yardOptions}
value={form.targetYardCode || null}
placeholder={yardsLoading ? 'Loading yards...' : 'Select target yard'}
nothingFoundMessage="No yards found"
disabled={yardsLoading}
onChange={(value) => setForm((f) => ({ ...f, targetYardCode: selectValue(value) }))}
/>
<TextInput
label="Storage type"
placeholder="e.g. OPEN_STACK"
value={form.storageType}
onChange={(e) => {
const value = e.currentTarget.value;
setForm((f) => ({ ...f, storageType: value }));
}}
/>
</Group> </Group>
<Group justify="flex-end" mt="sm"> <Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="default" onClick={() => setOpen(false)}>
<Button loading={create.isPending} onClick={submit}>Create</Button> Cancel
</Button>
<Button loading={create.isPending} onClick={submit}>
Create
</Button>
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>
@@ -178,9 +318,9 @@ function AllocationRules() {
function FeeRules() { function FeeRules() {
const { toast } = useToast(); const { toast } = useToast();
const { data, isLoading } = useQuery(api.warehouses.feeRules.queryOptions()); const { data, isLoading } = useFeeRules();
const create = useMutation(api.warehouses.createFeeRule.mutationOptions()); const create = useCreateFeeRule();
const remove = useMutation(api.warehouses.deleteFeeRule.mutationOptions()); const remove = useDeleteFeeRule();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [form, setForm] = useState({ const [form, setForm] = useState({
name: '', name: '',
@@ -199,6 +339,7 @@ function FeeRules() {
toast({ variant: 'destructive', title: 'Name is required' }); toast({ variant: 'destructive', title: 'Name is required' });
return; return;
} }
await create.mutateAsync({ await create.mutateAsync({
name: form.name.trim(), name: form.name.trim(),
ruleType: form.ruleType, ruleType: form.ruleType,
@@ -208,86 +349,158 @@ function FeeRules() {
freeDays: form.freeDays, freeDays: form.freeDays,
ratePerDay: form.ratePerDay, ratePerDay: form.ratePerDay,
currency: form.currency || 'USD', currency: form.currency || 'USD',
isActive: true,
} as never); } as never);
toast({ title: 'Fee rule created' }); toast({ title: 'Fee rule created' });
setOpen(false); setOpen(false);
}; };
const columns: ColumnDef<(typeof rules)[number]>[] = [
{
id: 'type',
header: 'Type',
cell: ({ row }) => (
<Badge color={row.original.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">
{row.original.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}
</Badge>
),
},
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
{ id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? '—' },
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? '—' },
{ id: 'freeDays', header: 'Free days', cell: ({ row }) => row.original.freeDays },
{
id: 'rate',
header: 'Rate / day',
cell: ({ row }) => `${Number(row.original.ratePerDay).toLocaleString()} ${row.original.currency}`,
},
{
id: 'active',
header: 'Active',
cell: ({ row }) => (
<Badge color={row.original.isActive ? 'edr-green' : 'gray'} variant="light">
{row.original.isActive ? 'Yes' : 'No'}
</Badge>
),
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(row.original.id)} title="Delete">
<Trash2 size={16} />
</ActionIcon>
</Group>
),
},
];
return ( return (
<> <>
<Group justify="space-between" mb="sm"> <Group justify="space-between" mb="sm">
<Text c="dimmed" size="sm">{rules.length} rule(s) most specific match applies</Text> <Text c="dimmed" size="sm">
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New fee rule</Button> {rules.length} rule(s) - most specific match applies
</Text>
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
New fee rule
</Button>
</Group> </Group>
<DataTable
columns={columns} {isLoading ? (
data={rules} <Group justify="center" py="xl">
status={isLoading ? 'loading' : 'success'} <Loader />
emptyMessage="No fee rules yet." </Group>
containerClassName="border-0 shadow-none" ) : (
/> <Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Freight</Table.Th>
<Table.Th>Trade</Table.Th>
<Table.Th>Free days</Table.Th>
<Table.Th>Rate / day</Table.Th>
<Table.Th>Active</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rules.map((rule) => (
<Table.Tr key={rule.id}>
<Table.Td>
<Badge color={rule.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">
{rule.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}
</Badge>
</Table.Td>
<Table.Td>{rule.name}</Table.Td>
<Table.Td>{rule.freightType ?? dash}</Table.Td>
<Table.Td>{rule.tradeDirection ?? dash}</Table.Td>
<Table.Td>{rule.freeDays}</Table.Td>
<Table.Td>
{Number(rule.ratePerDay).toLocaleString()} {rule.currency}
</Table.Td>
<Table.Td>
<Badge color={rule.isActive ? 'green' : 'gray'} variant="light">
{rule.isActive ? 'Yes' : 'No'}
</Badge>
</Table.Td>
<Table.Td ta="right">
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg"> <Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
<Stack gap="sm"> <Stack gap="sm">
<Group grow> <Group grow>
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} /> <TextInput
<Select label="Rule type" data={FEE_RULE_TYPES.map((t) => ({ value: t, label: t === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage' }))} value={form.ruleType} onChange={(v) => setForm((f) => ({ ...f, ruleType: (v as FeeRuleType) ?? 'DEMURRAGE_FEE' }))} allowDeselect={false} /> label="Name"
required
value={form.name}
onChange={(e) => {
const value = e.currentTarget.value;
setForm((f) => ({ ...f, name: value }));
}}
/>
<Select
label="Rule type"
data={FEE_RULE_TYPES.map((type) => ({
value: type,
label: type === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage',
}))}
value={form.ruleType}
onChange={(value) =>
setForm((f) => ({
...f,
ruleType: selectValue(value, 'DEMURRAGE_FEE') as FeeRuleType,
}))
}
allowDeselect={false}
/>
</Group> </Group>
<Group grow> <Group grow>
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable /> <Select
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable /> label="Freight type"
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} /> data={FREIGHT}
value={form.freightType || null}
onChange={(value) => setForm((f) => ({ ...f, freightType: selectValue(value) }))}
clearable
/>
<Select
label="Trade direction"
data={TRADE}
value={form.tradeDirection || null}
onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))}
clearable
/>
<TextInput
label="Cargo type code"
value={form.cargoTypeCode}
onChange={(e) => {
const value = e.currentTarget.value;
setForm((f) => ({ ...f, cargoTypeCode: value }));
}}
/>
</Group> </Group>
<Group grow> <Group grow>
<NumberInput label="Free days" min={0} value={form.freeDays} onChange={(v) => setForm((f) => ({ ...f, freeDays: Number(v) || 0 }))} /> <NumberInput
<NumberInput label="Rate / day" min={0} value={form.ratePerDay} onChange={(v) => setForm((f) => ({ ...f, ratePerDay: Number(v) || 0 }))} /> label="Free days"
<TextInput label="Currency" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.currentTarget.value }))} /> min={0}
value={form.freeDays}
onChange={(value) => setForm((f) => ({ ...f, freeDays: numberValue(value) }))}
/>
<NumberInput
label="Rate / day"
min={0}
value={form.ratePerDay}
onChange={(value) => setForm((f) => ({ ...f, ratePerDay: numberValue(value) }))}
/>
<Select
label="Currency"
data={CURRENCIES}
value={form.currency}
onChange={(value) => setForm((f) => ({ ...f, currency: selectValue(value, 'USD') }))}
allowDeselect={false}
/>
</Group> </Group>
<Group justify="flex-end" mt="sm"> <Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="default" onClick={() => setOpen(false)}>
<Button loading={create.isPending} onClick={submit}>Create</Button> Cancel
</Button>
<Button loading={create.isPending} onClick={submit}>
Create
</Button>
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>

View File

@@ -48,6 +48,7 @@ import type {
Warehouse, Warehouse,
WarehouseActivityLog, WarehouseActivityLog,
WarehouseDashboard, WarehouseDashboard,
WarehouseFacility,
WarehouseFilter, WarehouseFilter,
WarehouseInventoryItem, WarehouseInventoryItem,
WarehouseLoading, WarehouseLoading,
@@ -67,15 +68,19 @@ export const warehouseService = {
params: cleanParams(filter ?? {}), params: cleanParams(filter ?? {}),
}), }),
dashboard: () => apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD), dashboard: () => apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD),
getDashboardSummary: (_filter?: InventoryFilter) =>
apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD),
getById: (id: string) => apiClient.get<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id)), getById: (id: string) => apiClient.get<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id)),
create: (payload: SaveWarehousePayload) => create: (payload: SaveWarehousePayload) =>
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload), apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
update: (id: string, payload: Partial<SaveWarehousePayload>) => update: (id: string, payload: Partial<SaveWarehousePayload>) =>
apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload), apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload),
listFacilities: () => apiClient.get<WarehouseFacility[]>(URL_CONSTANTS.RULE_ENGINE.YARDS),
// ── Yards ──────────────────────────────────────────────────────────────── // ── Yards ────────────────────────────────────────────────────────────────
listYards: (warehouseId: string) => listYards: (warehouseId: string) =>
apiClient.get<WarehouseYard[]>(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId)), apiClient.get<WarehouseYard[]>(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId)),
listAllYards: () => apiClient.get<WarehouseYard[]>(URL_CONSTANTS.WAREHOUSE_YARDS.BASE),
createYard: (warehouseId: string, payload: SaveYardPayload) => createYard: (warehouseId: string, payload: SaveYardPayload) =>
apiClient.post<WarehouseYard>(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId), payload), apiClient.post<WarehouseYard>(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId), payload),
getYard: (id: string) => apiClient.get<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)), getYard: (id: string) => apiClient.get<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)),
@@ -85,6 +90,7 @@ export const warehouseService = {
// ── Zones ────────────────────────────────────────────────────────────── // ── Zones ──────────────────────────────────────────────────────────────
listZones: (yardId: string) => listZones: (yardId: string) =>
apiClient.get<WarehouseZone[]>(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId)), apiClient.get<WarehouseZone[]>(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId)),
listAllZones: () => apiClient.get<WarehouseZone[]>(URL_CONSTANTS.WAREHOUSE_ZONES.BASE),
createZone: (yardId: string, payload: SaveZonePayload) => createZone: (yardId: string, payload: SaveZonePayload) =>
apiClient.post<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId), payload), apiClient.post<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId), payload),
getZone: (id: string) => apiClient.get<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)), getZone: (id: string) => apiClient.get<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)),
@@ -124,6 +130,10 @@ export const warehouseService = {
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MARK_READY_PICKUP(id)), apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MARK_READY_PICKUP(id)),
release: (id: string, payload: ReleaseOrderPayload) => release: (id: string, payload: ReleaseOrderPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE(id), payload), apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE(id), payload),
downloadReleaseDocument: (id: string) =>
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), {
responseType: 'blob',
}),
deliver: (id: string, payload: DeliverInventoryPayload) => deliver: (id: string, payload: DeliverInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload), apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),

View File

@@ -27,6 +27,8 @@ export const INVENTORY_STATUSES = [
'RECEIVED', 'RECEIVED',
'STORED', 'STORED',
'RESERVED', 'RESERVED',
'ARRIVED_AT_WAREHOUSE',
'UNDER_INSPECTION',
'READY_FOR_LOADING', 'READY_FOR_LOADING',
'LOADED', 'LOADED',
'DISPATCHED', 'DISPATCHED',
@@ -57,6 +59,8 @@ export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | nu
RECEIVED: 'store', RECEIVED: 'store',
STORED: 'reserve', STORED: 'reserve',
RESERVED: 'ready-for-loading', RESERVED: 'ready-for-loading',
ARRIVED_AT_WAREHOUSE: null,
UNDER_INSPECTION: null,
READY_FOR_LOADING: 'load', READY_FOR_LOADING: 'load',
LOADED: 'dispatch', LOADED: 'dispatch',
DISPATCHED: null, DISPATCHED: null,
@@ -122,6 +126,7 @@ export interface WarehouseYard {
currentVolume: number; currentVolume: number;
status: WarehouseStatus; status: WarehouseStatus;
isActive: boolean; isActive: boolean;
warehouse?: Pick<Warehouse, 'id' | 'name' | 'code'> | null;
zones?: WarehouseZone[]; zones?: WarehouseZone[];
} }
@@ -146,6 +151,8 @@ export interface Facility {
isActive?: boolean; isActive?: boolean;
} }
export type WarehouseFacility = Facility;
export interface Warehouse { export interface Warehouse {
id: string; id: string;
name: string; name: string;
@@ -459,8 +466,11 @@ export interface ImportTrainItem {
export interface InventoryInquiryResult { export interface InventoryInquiryResult {
id: string; id: string;
bookingId: string; inventoryId: string | null;
bookingId: string | null;
bookingReference: string | null;
bookingNumber: string | null; bookingNumber: string | null;
bookingStatus: string | null;
customerName: string | null; customerName: string | null;
containerNumber: string | null; containerNumber: string | null;
cargoType: string | null; cargoType: string | null;
@@ -469,7 +479,11 @@ export interface InventoryInquiryResult {
warehouse: { id: string; name: string; code: string } | null; warehouse: { id: string; name: string; code: string } | null;
yard: { id: string; name: string; code: string } | null; yard: { id: string; name: string; code: string } | null;
zone: { id: string; name: string; code: string } | null; zone: { id: string; name: string; code: string } | null;
status: InventoryStatus; status: InventoryStatus | null;
trainNumber: string | null;
trainStatus: string | null;
route: string | null;
locationSummary: string | null;
quantity: number; quantity: number;
weight: number; weight: number;
arrivedAt: string | null; arrivedAt: string | null;
@@ -606,6 +620,8 @@ export interface FeePreview {
endIsOpen: boolean; endIsOpen: boolean;
elapsedDays: number; elapsedDays: number;
chargeableDays: number; chargeableDays: number;
containerCount: number;
billableUnits: number;
amount: number; amount: number;
} }
@@ -757,6 +773,28 @@ export interface ReceiveInventoryPayload {
notes?: string; notes?: string;
} }
export interface MoveInventoryPayload {
warehouseId: string;
yardId: string;
zoneId: string;
remarks?: string;
}
export interface ReserveInventoryPayload {
bookingId: string;
}
export interface WarehouseDashboardSummary {
totalWarehouses: number;
totalInventory: number;
receivedToday: number;
stored: number;
reserved: number;
readyForLoading: number;
loaded: number;
dispatched: number;
}
export interface WarehouseFilter { export interface WarehouseFilter {
search?: string; search?: string;
type?: WarehouseType; type?: WarehouseType;
@@ -765,6 +803,7 @@ export interface WarehouseFilter {
} }
export interface InventoryFilter { export interface InventoryFilter {
facilityId?: string;
warehouseId?: string; warehouseId?: string;
yardId?: string; yardId?: string;
zoneId?: string; zoneId?: string;
@@ -774,9 +813,12 @@ export interface InventoryFilter {
goodsId?: string; goodsId?: string;
status?: InventoryStatus; status?: InventoryStatus;
search?: string; search?: string;
dateFrom?: string;
dateTo?: string;
} }
export interface InventoryInquiryFilter { export interface InventoryInquiryFilter {
bookingReference?: string;
bookingNumber?: string; bookingNumber?: string;
containerNumber?: string; containerNumber?: string;
cargoType?: string; cargoType?: string;

View File

@@ -1,223 +0,0 @@
/**
* fhc.theme.ts — Federal Housing Corporation (FHC) look & feel preset.
*
* ┌─────────────────────────────────────────────────────────────────────────┐
* │ HOST-OWNED config. Lives in app-config/, NOT inside the user-management │
* │ module. At submodule-split time this whole folder moves to the host repo. │
* │ It is fully self-contained — no imports from the module. │
* └─────────────────────────────────────────────────────────────────────────┘
*
* WHAT IT GIVES YOU
* - The FHC Mantine color palettes: fhcBlue, fhcBrick, fhcGold, fhcGray
* - The FHC layout design tokens (brick-gradient sidebar, glassy header,
* page background, brand colors, sizes) under `theme.other.fhcLayout`
* (light) and `theme.other.fhcLayoutDark` (dark) — the classic shell reads
* these via useFhcLayout()
* - FHC typography (Plus Jakarta Sans), radii, shadows and component defaults
*
* HOW TO USE — in app-config/project.theme.ts:
*
* import { fhcMantineTheme } from "./fhc.theme";
*
* export const projectTheme: DesignConfig = {
* typography: { fontFamily: "Plus Jakarta Sans, sans-serif" },
* mantineTheme: fhcMantineTheme, // escape hatch — merges the FHC theme in
* };
*
* Load the font once in index.html:
* <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
*/
import type { MantineColorsTuple, MantineThemeOverride } from "@mantine/core";
/** Mantine 10-shade color scales used across the FHC UI. */
export const FHC_COLORS = {
fhcBlue: [
"#EEF4FC",
"#D9E8FA",
"#BCD5F5",
"#96BDEB",
"#6FA4E0",
"#4A90E2",
"#357ABD",
"#2C669D",
"#224F7A",
"#173654",
],
fhcBrick: [
"#F6ECE8",
"#EACFC4",
"#DBAD99",
"#C9876B",
"#B86B49",
"#A85735",
"#8C462B",
"#703622",
"#55281A",
"#3D1E14",
],
fhcGold: [
"#FFFBE6",
"#FFF3BF",
"#FEE98A",
"#FCDD57",
"#F9CF2F",
"#FFD700",
"#D9B700",
"#B39400",
"#8C7300",
"#665300",
],
fhcGray: [
"#F8FAFC",
"#F1F5F9",
"#E2E8F0",
"#CBD5E1",
"#94A3B8",
"#64748B",
"#475569",
"#334155",
"#1E293B",
"#0F172A",
],
} as const;
/**
* Layout design tokens — the brick-gradient sidebar, glassy header, page
* surfaces, brand colors and sizes. Mirrored under `theme.other.fhcLayout`.
*/
export const FHC_LAYOUT = {
sidebar: {
bg: "linear-gradient(180deg, #5D2E1F 0%, #3D1E14 100%)",
headerBg: "rgba(93, 46, 31, 0.82)",
footerBg: "rgba(61, 30, 20, 0.62)",
border: "rgba(255,255,255,0.10)",
text: "rgba(255,255,255,0.76)",
mutedText: "rgba(255,255,255,0.42)",
childText: "rgba(255,255,255,0.68)",
activeText: "#FFFFFF",
iconBg: "rgba(255,255,255,0.06)",
iconActiveBg: "rgba(255,255,255,0.12)",
hoverBg: "rgba(255,255,255,0.08)",
activeBg: "rgba(255,255,255,0.15)",
activeBorder: "rgba(255,255,255,0.14)",
sectionLine: "rgba(255,255,255,0.10)",
rail: "linear-gradient(180deg, #FFD700 0%, #4A90E2 100%)",
},
header: {
bg: "rgba(255,255,255,0.92)",
border: "rgba(15, 23, 42, 0.08)",
searchBg: "#F9FAFB",
searchBorder: "#E5E7EB",
title: "#1F2937",
subtitle: "#6B7280",
},
page: {
bg: "#F8FAFC",
cardBg: "rgba(255,255,255,0.92)",
},
brand: {
brick: "#5D2E1F",
brickDark: "#3D1E14",
blue: "#4A90E2",
blueDark: "#357ABD",
gold: "#FFD700",
text: "#1F2937",
},
sizes: {
sidebarExpanded: 288,
sidebarCollapsed: 80,
headerHeight: 64,
},
} as const;
/**
* Dark-mode counterpart of FHC_LAYOUT. The brick-gradient sidebar, accent rail
* and sizes are intentionally kept (they already read well on dark), while the
* glassy white header, page background, card surfaces and dark text are flipped
* to dark equivalents.
*/
export const FHC_LAYOUT_DARK = {
...FHC_LAYOUT,
header: {
bg: "rgba(26, 27, 30, 0.92)",
border: "rgba(255,255,255,0.08)",
searchBg: "#25262B",
searchBorder: "#2C2E33",
title: "#F1F5F9",
subtitle: "#9CA3AF",
},
page: {
bg: "#141517",
cardBg: "rgba(26, 27, 30, 0.92)",
},
brand: {
...FHC_LAYOUT.brand,
text: "#F1F5F9",
},
} as const;
/**
* Full Mantine theme override carrying the FHC palettes, layout tokens,
* typography, radii, shadows and component defaults. Pass this as the
* `mantineTheme` escape hatch in project.theme.ts.
*
* Note: BOTH `fhcLayout` (light) and `fhcLayoutDark` (dark) are published under
* `other` — the module's useFhcLayout() reads the matching one per color scheme.
*/
export const fhcMantineTheme: MantineThemeOverride = {
fontFamily: "Plus Jakarta Sans, sans-serif",
headings: {
fontFamily: "Plus Jakarta Sans, sans-serif",
},
defaultRadius: "md",
radius: {
xs: "6px",
sm: "8px",
md: "10px",
lg: "14px",
xl: "18px",
},
shadows: {
xs: "0 1px 2px rgba(15, 23, 42, 0.04)",
sm: "0 2px 8px rgba(15, 23, 42, 0.06)",
md: "0 4px 20px rgba(15, 23, 42, 0.08)",
lg: "0 8px 30px rgba(15, 23, 42, 0.12)",
},
colors: {
fhcBlue: FHC_COLORS.fhcBlue as unknown as MantineColorsTuple,
fhcBrick: FHC_COLORS.fhcBrick as unknown as MantineColorsTuple,
fhcGold: FHC_COLORS.fhcGold as unknown as MantineColorsTuple,
fhcGray: FHC_COLORS.fhcGray as unknown as MantineColorsTuple,
},
other: {
fhcLayout: FHC_LAYOUT,
fhcLayoutDark: FHC_LAYOUT_DARK,
},
components: {
Paper: {
defaultProps: {
radius: "lg",
shadow: "sm",
},
},
NavLink: {
defaultProps: {
radius: "md",
},
},
},
};
/**
* Optional convenience: the bits of a DesignConfig that carry the FHC look.
* Spread this into your projectTheme if you also want FHC as the primary brand
* (this re-tints buttons/links to fhcBlue). Leave it out to keep your own brand
* color while still getting the fhc* palettes + layout tokens via `mantineTheme`.
*/
export const fhcDesignPreset = {
colors: { primary: "#357ABD" },
typography: { fontFamily: "Plus Jakarta Sans, sans-serif" },
shape: { radius: "10px" },
mantineTheme: fhcMantineTheme,
};

View File

@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, shrink-to-fit=no"
/>
<title>User Management</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

View File

@@ -1,13 +0,0 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
// Consume the reusable module via its public barrel (@/ → ../user-management/src).
import { UserManagementApp } from "@/index";
// Your project's config lives HERE in the host folder (resolved via @app-config).
// The module never imports it; the host passes it in.
import { projectTheme } from "@app-config/project.theme";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<UserManagementApp config={projectTheme} />
</StrictMode>
);

View File

@@ -1,12 +0,0 @@
{
"name": "user-management-host",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "user-management-host",
"version": "0.0.0"
}
}
}

View File

@@ -1,12 +0,0 @@
{
"name": "user-management-host",
"private": true,
"version": "0.0.0",
"type": "module",
"description": "Host wrapper for the user-management module. Owns branding/theme (project.theme.ts / fhc.theme.ts), the Vite build (vite.config.ts), the HTML shell (index.html) and the entry (main.tsx). Consumes the module from ../user-management/src.",
"scripts": {
"dev": "vite --port 4202 --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --port 4202 --host"
}
}

View File

@@ -1,7 +0,0 @@
// Tailwind is handled by the @tailwindcss/vite plugin (see vite.config.ts), so
// PostCSS needs no plugins here. This local config exists to stop Vite from
// walking up to the monorepo root postcss.config.js (Tailwind v3), which would
// conflict with this package's Tailwind v4 setup.
export default {
plugins: {},
};

View File

@@ -1,216 +0,0 @@
/**
* project.theme.ts — HOST-OWNED config for THIS project / organisation (FHC).
*
* ┌─────────────────────────────────────────────────────────────────────────┐
* │ Lives in app-config/, OUTSIDE the user-management module. The module │
* │ never imports this file — the host passes it in via │
* │ <UserManagementApp config={projectTheme} /> (see ../src/main.tsx). │
* │ At submodule-split time, this whole folder moves to the host repo. │
* └─────────────────────────────────────────────────────────────────────────┘
*
* Every field is optional — remove lines you don't need to override.
*
* Flow:
* project.theme.ts → design.config.ts (module engine) → CSS vars + Mantine theme
* TenantConfig.ts → overrides --primary at runtime per hostname
*
* The TenantConfig layer runs AFTER this, so per-hostname primary-color overrides
* still work on top of whatever you set here.
*/
import type { DesignConfig } from "@/config/design.config";
import { fhcMantineTheme } from "./fhc.theme";
export const projectTheme: DesignConfig = {
// ─────────────────────────────────────────────────────────────────────────
// BRANDING
// Replace with your organisation's assets.
// ─────────────────────────────────────────────────────────────────────────
brand: {
appName: "Federal Housing Corporation", // FHC — shown in the browser tab
// Drop the FHC logo at this path in /public to show it in the sidebar brand
// and as the favicon. Until then the sidebar falls back to a building icon.
// logoUrl: "/assets/logo/fhc.png",
// faviconUrl: "/favicon.ico", // optional — defaults to logoUrl
},
// ─────────────────────────────────────────────────────────────────────────
// COLORS
// Change `primary` to your brand hex and everything cascades automatically.
// Shades primary-50 → primary-950 are computed via CSS color-mix in index.css.
// TenantConfig overrides this per-hostname, so localhost vs edrsc.com can
// still have different colors.
// ─────────────────────────────────────────────────────────────────────────
colors: {
primary: "#357ABD", // FHC blue (fhcBlue-6) — buttons, links, active states
// // "#5D2E1F" brick (FHC chrome) is used by the sidebar/modal skin below
// // "#2563eb" blue | "#7c3aed" purple
// // "#16a34a" green | "#dc2626" red
// // "#f59e0b" amber | "#0284c7" sky
// primaryForeground: "#ffffff", // text on primary-colored bg — rarely needs changing
// secondary: "#f1f5f9", // TODO: subtle secondary UI color
// background: "#ffffff", // TODO: page background
// foreground: "#0f172a", // TODO: main text color
// border: "#e2e8f0", // TODO: input / card borders
// muted: "#f8fafc", // TODO: disabled input / tag backgrounds
// mutedForeground: "#94a3b8", // TODO: placeholder / helper text
// card: "#ffffff", // TODO: card background (if different from page)
// sidebar: "#f8fafc", // TODO: sidebar background
// danger: "#dc2626", // TODO: error / destructive color
},
// ─────────────────────────────────────────────────────────────────────────
// TYPOGRAPHY
// Load the font FIRST in index.html (Google Fonts link or @font-face) then
// set fontFamily here. The fallback chain is used if the custom font fails.
// ─────────────────────────────────────────────────────────────────────────
typography: {
fontFamily: "Plus Jakarta Sans, Inter, ui-sans-serif, system-ui, sans-serif",
// // TODO: "Poppins, Inter, sans-serif"
// // TODO: "Cairo, Inter, sans-serif" (Arabic)
// // TODO: "Noto Serif Ethiopic, serif" (Amharic)
// headingFontFamily: undefined, // TODO: separate heading font if desired
// baseFontSize: "16px", // TODO: "14px" for compact dashboards
},
// ─────────────────────────────────────────────────────────────────────────
// SHAPE
// ─────────────────────────────────────────────────────────────────────────
shape: {
radius: "0.625rem", // TODO: "0" sharp | "0.5rem" subtle | "1rem" very rounded
},
// ─────────────────────────────────────────────────────────────────────────
// SHADOWS
// Leave commented to use Mantine/Tailwind defaults.
// ─────────────────────────────────────────────────────────────────────────
// shadows: {
// card: "0 1px 3px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.06)",
// dropdown: "0 8px 30px rgba(0,0,0,0.12)",
// modal: "0 20px 60px rgba(0,0,0,0.16)",
// },
// ─────────────────────────────────────────────────────────────────────────
// MANTINE COMPONENT DEFAULTS
// These become the <MantineProvider theme> defaults for every component.
// ─────────────────────────────────────────────────────────────────────────
components: {
buttonDefaultVariant: "filled", // TODO: "light" | "outline" | "subtle"
inputDefaultSize: "sm", // TODO: "xs" | "md" | "lg"
inputRadius: "md", // TODO: "xs" | "lg" | "xl"
modalRadius: "lg", // TODO: "md" | "xl"
tableHighlightOnHover: true,
tableStriped: false, // TODO: "odd" | "even" | true
},
// ─────────────────────────────────────────────────────────────────────────
// USER-MANAGEMENT LAYOUT / NAVIGATION
// Pick the navigation chrome and style the side menu — all from here.
// "classic" → app-wide SIDE MENU, no top tabs
// "legacy" → top TAB bar, no side menu
// Each value is also exposed as a --um-* CSS var, so tweaks apply instantly.
// ─────────────────────────────────────────────────────────────────────────
layout: {
userManagementView: "classic", // TODO: "legacy" for the top-tab UI
// showTopBar: false, // TODO: overrides VITE_SHOW_TOP_BAR
// ── Dimensions ──────────────────────────────────────────────────────────
sidebarWidth: "288px", // TODO: expanded side-menu width
sidebarCollapsedWidth: "80px", // TODO: icon-only width
headerHeight: "64px", // TODO: top bar height
// contentMaxWidth: "1440px", // TODO: cap the content column
// ── Side-menu skin (defaults follow the FHC brick theme) ───────────────
sidebarBackground: "linear-gradient(180deg, #5D2E1F 0%, #3D1E14 100%)",
sidebarColor: "rgba(255,255,255,0.76)",
sidebarMutedColor: "rgba(255,255,255,0.42)",
sidebarActiveBackground: "rgba(255,255,255,0.15)",
sidebarActiveColor: "#FFFFFF",
sidebarHoverBackground: "rgba(255,255,255,0.08)",
sidebarBorder: "rgba(255,255,255,0.10)",
sidebarRail: "linear-gradient(180deg, #FFD700 0%, #4A90E2 100%)",
sidebarBrandLabel: "User Management",
sidebarBrandSublabel: "Federal Housing",
// ── THE MENU (data, shared by the side menu AND the top tabs) ──────────
// Edit/add/remove freely. `icon` is a name from the registry in
// navConfig.tsx (users, dashboard, content, position, settings, excel,
// archive, units, activity, organizations, …). `label` is an i18n key
// under "organization.<label>" (raw string shown if no translation).
// Remove this array entirely to fall back to the built-in defaults.
//
// SHOW / HIDE A MENU: set `enabled: false` on any item to stop it
// rendering in BOTH the side menu and the top tabs — without deleting it.
// Omitting `enabled` (or `true`) keeps it visible. Toggle these per project.
navItems: [
// The menu is ROLE-FILTERED (see navConfig.tsx): each role sees only its own
// block. Every href below has a matching route in the embedded router
// (src/App.tsx), which is now a superset of org-admin + super-admin routes.
// ── Org-admin / unit-admin surface ──
{ label: "dashboard", href: "/user-management/user_management-dashboard", icon: "dashboard", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
{ label: "userManagement", href: "/user-management/user_management", icon: "users", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
{ label: "contentManagement", displayLabel: "contentManagement", href: "/user-management/content-management", icon: "content", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
{ label: "Position", displayLabel: "positionTypes", href: "/user-management/position-management", icon: "position", roles: ["admin", "unit_admin", "super_admin"], isPrimary: true, enabled: true },
{ label: "settings", displayLabel: "settings", href: "/user-management/organization-settings", icon: "settings", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
{ label: "Bulk", displayLabel: "bulkUpload", href: "/user-management/bulk-upload", icon: "excel", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
{ label: "Archive Users", displayLabel: "Archive Users", href: "/user-management/archives", icon: "archive", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
{ label: "Archived Units & Positions", displayLabel: "Archived Units & Positions", href: "/user-management/archived", icon: "units", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
// ── Super-admin surface (routes now wired in App.tsx) ──
{ label: "dashboard", href: "/user-management/dashboard", icon: "dashboard", roles: ["super_admin"], isPrimary: true, enabled: true },
{ label: "organizations", href: "/user-management/organizations", icon: "organizations", roles: ["super_admin"], isPrimary: true, enabled: true },
{ label: "organizationAdmins", href: "/user-management/organization_admins", icon: "admins", roles: ["super_admin"], isPrimary: true, enabled: true },
{ label: "externalUsers", href: "/user-management/external_users", icon: "admins", roles: ["super_admin"], isPrimary: true, enabled: true },
{ label: "Migrated Records", displayLabel: "migratedRecords", href: "/user-management/migrated-records-management", icon: "file", roles: ["super_admin"], isPrimary: true, enabled: true },
{ label: "Archive Users", displayLabel: "Archive Users", href: "/user-management/archive-users", icon: "archive", roles: ["super_admin"], isPrimary: true, enabled: true },
{ label: "Archived Organizations", displayLabel: "Archived Organizations", href: "/user-management/archived-organizations", icon: "archive", roles: ["super_admin"], isPrimary: true, enabled: true },
{ label: "activityLog", href: "/user-management/activity_log", icon: "activity", roles: ["super_admin"], isPrimary: false, enabled: true },
{ label: "setting", href: "/user-management/settings", icon: "settings", roles: ["super_admin"], isPrimary: false, enabled: true },
{ label: "Letter Template", href: "/user-management/templates", icon: "file", roles: ["super_admin"], isPrimary: false, enabled: true },
],
// ── Top tab bar skin (legacy view) ─────────────────────────────────────
menuBackground: "#ffffff", // TODO: tab bar background
menuColor: "#334155", // inactive tab text
menuActiveColor: "#357ABD", // active tab text (FHC blue)
menuActiveBorderColor: "#357ABD", // active tab underline
menuHoverColor: "#357ABD", // tab hover text
// ── Create / edit modal skin (shared BackofficeModal) ──────────────────
modalAccentColor: "#5D2E1F", // brick top strip
modalHeaderBackground: "#F6ECE8", // header bg (view)
modalHeaderEditBackground: "#EACFC4", // header bg (edit)
modalIconBackground: "#EACFC4", // header icon chip bg
modalIconColor: "#5D2E1F", // header icon chip color
modalTitleColor: "#1F2937", // modal title text
modalFocusColor: "#357ABD", // input focus ring inside modals
modalSurface: "#ffffff", // modal body surface
},
// ─────────────────────────────────────────────────────────────────────────
// EXTRA CSS VARS
// Inject any CSS custom property that isn't covered above.
// Keys are variable names WITHOUT the leading "--".
// ─────────────────────────────────────────────────────────────────────────
// cssVars: {
// "sidebar-width": "260px",
// "header-height": "64px",
// "content-max-width": "1440px",
// "custom-gradient": "linear-gradient(135deg, #18aa9d 0%, #0f7a70 100%)",
// },
// ─────────────────────────────────────────────────────────────────────────
// MANTINE THEME ESCAPE HATCH
// Any Mantine theme key — merged on top of everything above.
// Full list: https://mantine.dev/theming/theme-object/
// ─────────────────────────────────────────────────────────────────────────
// The FHC look & feel preset — provides the fhcBlue/fhcBrick/fhcGold/fhcGray
// palettes and the `other.fhcLayout` / `other.fhcLayoutDark` tokens that the
// "classic" user-management view renders with. Lives alongside this file in
// app-config/ so the whole host config moves together at submodule-split time.
mantineTheme: fhcMantineTheme,
};

View File

@@ -1,25 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"skipLibCheck": true,
"strict": false,
"baseUrl": ".",
"paths": {
"@/*": ["../user-management/src/*"],
"@app-config/*": ["./*"]
}
},
"include": ["main.tsx", "project.theme.ts", "fhc.theme.ts", "../user-management/src"]
}

View File

@@ -1,17 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["vite.config.ts"]
}

View File

@@ -1,107 +0,0 @@
import fs from "fs";
import path from "path";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig, loadEnv } from "vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
// Same-origin sub-path the host server serves the module from. `base` makes
// built asset URLs resolve under it AND is read back inside the module
// (import.meta.env.BASE_URL) to set the router basename. Override with UM_BASE.
const base = env.UM_BASE || "/_um/";
// Build straight into the host app's public dir so its ONE server serves the
// module at <origin>/_um/ — no second server, same origin as the host.
const outDir = path.resolve(__dirname, "../public/_um");
return {
base,
plugins: [
tailwindcss(),
react(),
{
// TinyMCE is self-hosted; the module references it at the ABSOLUTE path
// /tinymce/..., which resolves at the host origin. Mirror the assets into
// BOTH the module public dir AND the host public root. Regenerated on
// build, so neither copy is a hand-managed artifact.
name: "copy-tinymce-assets",
buildStart() {
const src = path.resolve(__dirname, "node_modules/tinymce");
const dests = [
path.resolve(__dirname, "../user-management/public/tinymce"),
path.resolve(__dirname, "../public/tinymce"),
];
const runtimeEntries = [
"tinymce.min.js",
"icons",
"models",
"plugins",
"skins",
"themes",
];
if (!fs.existsSync(src)) {
throw new Error(
"[copy-tinymce-assets] node_modules/tinymce not found in this app. Run `npm install` here first."
);
}
for (const dest of dests) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of runtimeEntries) {
const entrySrc = path.resolve(src, entry);
const entryDest = path.resolve(dest, entry);
if (!fs.existsSync(entrySrc)) continue;
fs.cpSync(entrySrc, entryDest, { recursive: true, force: true });
}
}
},
},
],
assetsInclude: ["**/*.TTF"],
// Static assets (incl. tinymce/) live in the module's public dir.
publicDir: path.resolve(__dirname, "../user-management/public"),
build: {
rollupOptions: {
external: [
"file-type",
"readable-web-to-node-stream",
"strtok3",
"token-types",
],
},
outDir,
assetsDir: "assets",
sourcemap: false,
emptyOutDir: true,
minify: "esbuild",
},
resolve: {
alias: [
// @app-config = this host folder itself (project.theme.ts / fhc.theme.ts).
{ find: /^@app-config\//, replacement: path.resolve(__dirname) + "/" },
// @/ = the reusable module's source in the SIBLING module folder.
{ find: /^@\//, replacement: path.resolve(__dirname, "../user-management/src") + "/" },
],
// node_modules is linked to the module's, but pin the singletons so the host
// entry and the module code share ONE React 18 (no "invalid hook call").
dedupe: ["react", "react-dom", "react-router-dom", "@tanstack/react-query", "@mantine/core", "@mantine/hooks"],
},
server: {
port: env.DEV_PORT ? Number(env.DEV_PORT) : 5173,
strictPort: true,
fs: { allow: [path.resolve(__dirname, "..")] },
},
optimizeDeps: {
exclude: ["file-type", "readable-web-to-node-stream", "strtok3", "token-types"],
},
esbuild: {
drop: mode === "production" ? ["console", "debugger"] : [],
},
define: {
global: {},
},
};
});

View File

@@ -23,7 +23,7 @@ export const GOVERNMENT_PRIORITY_BONUS = 50_000;
export interface GovernmentBookingFields { export interface GovernmentBookingFields {
isGovernment: boolean; isGovernment: boolean;
governmentInstitution?: string | null; governmentInstitution?: string | null;
} }
export enum ExceededAction { export enum ExceededAction {
@@ -173,6 +173,8 @@ export enum TrainSetWagonStatus {
export enum WagonStatus { export enum WagonStatus {
Available = "AVAILABLE", Available = "AVAILABLE",
Assigned = "ASSIGNED", Assigned = "ASSIGNED",
ImportReady = "IMPORT_READY",
ExportReady = "EXPORT_READY",
Maintenance = "MAINTENANCE", Maintenance = "MAINTENANCE",
Retired = "RETIRED", Retired = "RETIRED",
} }
@@ -505,7 +507,7 @@ export interface IInvoice extends BaseEntity {
dueAt: string; dueAt: string;
} }
// ── Reference Data (booking form catalog) ────────────────────────────────────── // ── Reference Data (booking form catalog) ──────────────────────────────────────
export interface BookingReferenceYard { export interface BookingReferenceYard {
id: string; id: string;

View File

@@ -4,6 +4,7 @@ packages:
- "apps/edr-passenger-web/*" - "apps/edr-passenger-web/*"
- "packages/*" - "packages/*"
- "packages/config/*" - "packages/config/*"
verifyDepsBeforeRun: warn
allowBuilds: allowBuilds:
"@nestjs/core": true "@nestjs/core": true
'@parcel/watcher': false '@parcel/watcher': false