Merge pull request #626 from Tria-plc/uienhancement

Uienhancement
This commit is contained in:
Hagernesh Tadesse
2026-07-11 11:49:47 +03:00
committed by GitHub
17 changed files with 1165 additions and 367 deletions

View File

@@ -69,6 +69,7 @@ import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seed
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { EdrTruckFleetSeeder } from "./seed/edr-truck-fleet.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
//New Trains, Wagons, Container and Cargo management modules
@@ -93,6 +94,7 @@ import { FirstMileModule } from "./modules/first-mile/first-mile.module";
import { LastMileModule } from "./modules/last-mile/last-mile.module";
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module";
import { LoggerMiddleware } from "./logger.middleware";
@Module({
@@ -188,6 +190,7 @@ import { LoggerMiddleware } from "./logger.middleware";
ImportOperationsModule,
VerifaydaModule,
FleetHistoryModule,
AiModule,
],
providers: [
EdrOrgSeeder,
@@ -199,6 +202,7 @@ import { LoggerMiddleware } from "./logger.middleware";
FreightPermissionKeyMigrationSeeder,
DemoFreightDataSeeder,
GovCompaniesSeeder,
EdrTruckFleetSeeder,
IndodeFacilitySeeder,
Batch14TestDataSeeder,
Batch5TestDataSeeder,
@@ -231,6 +235,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
private readonly govCompaniesSeeder: GovCompaniesSeeder,
private readonly edrTruckFleetSeeder: EdrTruckFleetSeeder,
) { }
async onApplicationBootstrap() {
@@ -261,6 +266,7 @@ export class AppModule implements OnApplicationBootstrap {
// Government entities (with importer/exporter profiles) that government
// bookings bill to. Idempotent — keyed by fixed IDs.
await this.govCompaniesSeeder.run();
await this.edrTruckFleetSeeder.run();
}
configure(consumer: MiddlewareConsumer) {

View File

@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Repair migration. `SeparateVehicleAvailability1890000000000` is recorded in
* public.migrations but the `availability` column is absent on some databases
* (recorded-but-not-applied drift). Because the original is already recorded,
* TypeORM will not re-run it, so `vehiclesService.findAll` (a query builder that
* selects every entity column) 500s with `column "availability" does not exist`.
*
* This re-adds the column idempotently and backfills. Safe to run everywhere:
* `IF NOT EXISTS` makes it a no-op where the column already exists.
*/
export class RepairVehicleAvailabilityColumn2110000000000
implements MigrationInterface
{
name = "RepairVehicleAvailabilityColumn2110000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.vehicles
ADD COLUMN IF NOT EXISTS availability varchar DEFAULT 'FREE'
`);
await queryRunner.query(`
UPDATE freight.vehicles SET availability = 'FREE' WHERE availability IS NULL
`);
}
public async down(): Promise<void> {
// No-op: dropping a column other code now depends on would reintroduce the
// drift. The original SeparateVehicleAvailability migration owns the column.
}
}

View File

@@ -0,0 +1,31 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '@edr/api-common';
import { AiBookingRequestDto } from './dto/ai-booking-request.dto';
import { AiBookingResult } from './types/ai-booking-result.type';
import { MockAiService } from './mock-ai.service';
// @Public() — TODO: swap for real guard when this leaves dev/testing.
// Safe while public: extracts + validates text only, never creates or
// dispatches anything.
@Public()
@ApiTags('AI Assistant (mock)')
@Controller('ai')
export class AiController {
constructor(private readonly mockAiService: MockAiService) {}
@Post('booking/extract')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary:
'Mock AI: extract structured booking fields from free-text request',
})
@ApiOkResponse({
description:
'Extracted fields, validation result, and next-step recommendation',
})
extractBooking(@Body() dto: AiBookingRequestDto): AiBookingResult {
return this.mockAiService.extractBooking(dto.text);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AiController } from './ai.controller';
import { MockAiService } from './mock-ai.service';
@Module({
controllers: [AiController],
providers: [MockAiService],
exports: [MockAiService],
})
export class AiModule {}

View File

@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class AiBookingRequestDto {
@ApiProperty({
description: 'Free-text customer booking request to extract fields from',
example:
'Book 2x40ft containers from Djibouti to Indode. Cargo electronics. Customer ABC Logistics.',
minLength: 5,
})
@IsString()
@IsNotEmpty({ message: 'text must not be empty' })
@MinLength(5, { message: 'text must be at least 5 characters' })
text!: string;
}

View File

@@ -0,0 +1,277 @@
import { Injectable } from '@nestjs/common';
import {
AiBookingResult,
AiContainerType,
AiDirection,
AiExtractedBooking,
AiRecommendation,
AiValidationResult,
} from './types/ai-booking-result.type';
/**
* Deterministic keyword/regex "AI" for the booking assistant workflow.
* No external AI calls — this class is the single seam to swap for a real
* provider later (OllamaAiService / ClaudeAiService / OpenAiService): keep
* the `extractBooking(text): AiBookingResult` contract and replace the body.
*/
const KNOWN_LOCATIONS = [
'Djibouti',
'Indode',
'Modjo',
'Adama',
'Dire Dawa',
'Addis Ababa',
] as const;
const INLAND_LOCATIONS = new Set<string>([
'Indode',
'Modjo',
'Adama',
'Dire Dawa',
'Addis Ababa',
]);
// Longest names first so "Dire Dawa" wins before a shorter partial could.
const LOCATION_ALTERNATION = [...KNOWN_LOCATIONS]
.sort((a, b) => b.length - a.length)
.map((name) => name.replace(/\s+/g, '\\s+'))
.join('|');
// Checked in order; first hit wins, so specific cargo words beat the
// generic "refrigerated" fallback.
const CARGO_KEYWORDS: ReadonlyArray<readonly [RegExp, string]> = [
[/\belectronics\b/i, 'electronics'],
[/\bcoffee\b/i, 'coffee'],
[/\bwheat\b/i, 'wheat'],
[/\bfertilizers?\b/i, 'fertilizer'],
[/\bchemicals?\b/i, 'chemical'],
[/\bmachinery\b/i, 'machinery'],
[/\bmedicines?\b/i, 'medicine'],
[/\bsesame\b/i, 'sesame'],
[/\b(?:vehicles?|cars?)\b/i, 'vehicles'],
[/\brefrigerated\b/i, 'refrigerated cargo'],
];
const WORD_NUMBERS: Record<string, number> = {
one: 1,
two: 2,
three: 3,
four: 4,
five: 5,
six: 6,
seven: 7,
eight: 8,
nine: 9,
ten: 10,
};
// A capitalized-word run: "ABC Logistics", "Auto Import PLC", "Ethio Coffee
// Export". Stops at the first lowercase word ("wants", "needs", …).
const NAME_CAPTURE = String.raw`([A-Z][A-Za-z0-9&.'-]*(?:\s+[A-Z][A-Za-z0-9&.'-]*)*)`;
// No `i` flag: the capture relies on case ([A-Z] word starts) to know where
// the company name ends ("Customer ABC Logistics wants…" → "ABC Logistics").
const CUSTOMER_PATTERNS: ReadonlyArray<RegExp> = [
new RegExp(String.raw`\b[Cc]ustomer(?:\s+is)?\s*:?\s+${NAME_CAPTURE}`),
new RegExp(String.raw`\b[Ff]or\s+${NAME_CAPTURE}`),
];
const RECOMMEND_CREATE: AiRecommendation = {
action: 'CREATE_DRAFT_BOOKING',
message:
'Booking data looks complete. User can review and create a draft booking.',
confidence: 0.85,
};
const RECOMMEND_MISSING: AiRecommendation = {
action: 'REQUEST_MISSING_INFORMATION',
message:
'Some required booking information is missing. Ask the customer for the missing fields before creating a draft booking.',
confidence: 0.45,
};
@Injectable()
export class MockAiService {
extractBooking(text: string): AiBookingResult {
const input = text.trim();
const { origin, destination } = this.extractRoute(input);
const extracted: AiExtractedBooking = {
customerName: this.extractCustomerName(input),
origin,
destination,
cargoType: this.extractCargoType(input),
containerType: this.extractContainerType(input),
quantity: this.extractQuantity(input),
direction: this.resolveDirection(origin, destination),
weightKg: this.extractWeightKg(input),
pickupRequired: this.extractFlag(input, 'pickup'),
deliveryRequired: this.extractFlag(input, 'delivery'),
};
const validation = this.validate(extracted);
return {
provider: 'mock',
extracted,
validation,
recommendation: validation.valid ? RECOMMEND_CREATE : RECOMMEND_MISSING,
};
}
private extractCustomerName(text: string): string | null {
for (const pattern of CUSTOMER_PATTERNS) {
const match = text.match(pattern);
if (match?.[1]) {
const name = match[1].replace(/[.,;:!?]+$/, '').trim();
if (name) return name;
}
}
return null;
}
private extractRoute(text: string): {
origin: string | null;
destination: string | null;
} {
const fromMatch = text.match(
new RegExp(String.raw`\bfrom\s+(${LOCATION_ALTERNATION})\b`, 'i'),
);
const toMatch = text.match(
new RegExp(String.raw`\bto\s+(${LOCATION_ALTERNATION})\b`, 'i'),
);
let origin = fromMatch ? this.canonicalLocation(fromMatch[1]) : null;
let destination = toMatch ? this.canonicalLocation(toMatch[1]) : null;
if (!origin || !destination) {
// Fall back to order of appearance ("Djibouti to Indode" without
// "from", or a bare location mention).
const mentions: string[] = [];
const all = text.matchAll(
new RegExp(String.raw`\b(${LOCATION_ALTERNATION})\b`, 'gi'),
);
for (const m of all) {
const canonical = this.canonicalLocation(m[1]);
if (canonical && !mentions.includes(canonical)) mentions.push(canonical);
}
if (!origin && !destination) {
origin = mentions[0] ?? null;
destination = mentions[1] ?? null;
} else if (!origin) {
origin = mentions.find((loc) => loc !== destination) ?? null;
} else {
destination = mentions.find((loc) => loc !== origin) ?? null;
}
}
return { origin, destination };
}
private canonicalLocation(raw: string): string | null {
const normalized = raw.replace(/\s+/g, ' ').toLowerCase();
return (
KNOWN_LOCATIONS.find((loc) => loc.toLowerCase() === normalized) ?? null
);
}
private resolveDirection(
origin: string | null,
destination: string | null,
): AiDirection | null {
if (!origin || !destination) return null;
if (origin === 'Djibouti' && INLAND_LOCATIONS.has(destination)) {
return 'IMPORT';
}
if (INLAND_LOCATIONS.has(origin) && destination === 'Djibouti') {
return 'EXPORT';
}
return null;
}
private extractCargoType(text: string): string | null {
for (const [pattern, cargo] of CARGO_KEYWORDS) {
if (pattern.test(text)) return cargo;
}
return null;
}
private extractContainerType(text: string): AiContainerType | null {
// Lookbehind instead of \b: "2x40ft" has no word boundary before "40",
// but "140ft" must not read as a 40ft container.
if (/(?<!\d)40[\s-]?(?:ft|foot)\b/i.test(text)) return '40FT';
if (/(?<!\d)20[\s-]?(?:ft|foot)\b/i.test(text)) return '20FT';
if (/\bbulk\b/i.test(text)) return 'BULK';
if (/\b(?:vehicles?|cars?)\b/i.test(text)) return 'RO_RO';
return null;
}
private extractQuantity(text: string): number | null {
// "2x40ft", "2 x 40ft", "3x20ft", "1x20ft"
let match = text.match(/(\d+)\s*x\s*\d+\s*-?\s*(?:ft|foot)\b/i);
if (match) return parseInt(match[1], 10);
// "one 40ft container", "two containers"
match = text.match(
new RegExp(
String.raw`\b(${Object.keys(WORD_NUMBERS).join('|')})\s+(?:\d+\s*-?\s*(?:ft|foot)\s+)?containers?\b`,
'i',
),
);
if (match) return WORD_NUMBERS[match[1].toLowerCase()];
// "3 containers", "2 refrigerated containers"
match = text.match(/(\d+)\s+(?:[a-z]+\s+)?containers?\b/i);
if (match) return parseInt(match[1], 10);
// "5 vehicles", "3 cars"
match = text.match(/(\d+)\s+(?:vehicles?|cars?)\b/i);
if (match) return parseInt(match[1], 10);
return null;
}
private extractWeightKg(text: string): number | null {
const tons = text.match(/([\d,]+(?:\.\d+)?)\s*(?:tons?|tonnes?)\b/i);
if (tons) return Math.round(this.parseNumber(tons[1]) * 1000);
const kg = text.match(/([\d,]+(?:\.\d+)?)\s*kgs?\b/i);
if (kg) return Math.round(this.parseNumber(kg[1]));
return null;
}
private parseNumber(raw: string): number {
return parseFloat(raw.replace(/,/g, ''));
}
private extractFlag(
text: string,
kind: 'pickup' | 'delivery',
): boolean | null {
// "no pickup required" must read as false, so the negative wins.
if (new RegExp(String.raw`\bno\s+${kind}\b`, 'i').test(text)) return false;
if (new RegExp(String.raw`\b${kind}\s+required\b`, 'i').test(text)) {
return true;
}
return null;
}
private validate(extracted: AiExtractedBooking): AiValidationResult {
const errors: string[] = [];
if (!extracted.customerName) errors.push('Customer name is missing');
if (!extracted.origin) errors.push('Origin is missing');
if (!extracted.destination) errors.push('Destination is missing');
if (!extracted.cargoType) errors.push('Cargo type is missing');
if (!extracted.containerType) errors.push('Container type is missing');
if (extracted.quantity === null) errors.push('Quantity is missing');
if (!extracted.direction) errors.push('Direction is missing');
return { valid: errors.length === 0, errors };
}
}

View File

@@ -0,0 +1,47 @@
export const AI_CONTAINER_TYPES = ['20FT', '40FT', 'BULK', 'RO_RO'] as const;
export type AiContainerType = (typeof AI_CONTAINER_TYPES)[number];
export const AI_DIRECTIONS = ['IMPORT', 'EXPORT'] as const;
export type AiDirection = (typeof AI_DIRECTIONS)[number];
export const AI_RECOMMENDATION_ACTIONS = [
'CREATE_DRAFT_BOOKING',
'REQUEST_MISSING_INFORMATION',
] as const;
export type AiRecommendationAction = (typeof AI_RECOMMENDATION_ACTIONS)[number];
export interface AiExtractedBooking {
customerName: string | null;
origin: string | null;
destination: string | null;
cargoType: string | null;
containerType: AiContainerType | null;
quantity: number | null;
direction: AiDirection | null;
weightKg: number | null;
pickupRequired: boolean | null;
deliveryRequired: boolean | null;
}
export interface AiValidationResult {
valid: boolean;
errors: string[];
}
export interface AiRecommendation {
action: AiRecommendationAction;
message: string;
confidence: number;
}
/**
* Payload returned by the extract endpoint. The global
* ResponseTransformInterceptor wraps it as
* `{ success: true, data: AiBookingResult, timestamp }` on the wire.
*/
export interface AiBookingResult {
provider: 'mock';
extracted: AiExtractedBooking;
validation: AiValidationResult;
recommendation: AiRecommendation;
}

View File

@@ -0,0 +1,94 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
/**
* EDR-owned truck fleet used by first-mile / last-mile pickup & delivery.
* These 62 trucks used to be a hardcoded list in the truck-arrival UI; the
* first/last-mile flows now read the fleet from `freight.vehicles` via
* VehiclesService, so the fleet must exist as vehicle rows.
*
* `[powerPlate, trailerPlate]` in fleet order 1..62. Region code "03-ET" is
* shared by every truck. NB: 56 trucks are configured for 40ft containers, 6
* for 20ft only — the specific 6 are not yet confirmed, so all default to 40ft.
* List `TWENTY_FT_SEQS` when known.
*/
const EDR_TRUCK_FLEET: ReadonlyArray<readonly [string, string]> = [
['A45843', '43495'], ['A45866', '43470'], ['A45853', '43508'], ['A45849', '43414'],
['A45845', '43492'], ['A45820', '43487'], ['A45842', '43478'], ['A45841', '43515'],
['A45832', '43504'], ['A45856', '43510'], ['A45865', '43490'], ['A45855', '43485'],
['A45840', '43499'], ['A45867', '43493'], ['A45833', '43466'], ['A45858', '43496'],
['A45819', '43474'], ['A45834', '43502'], ['A45868', '43469'], ['A45831', '43488'],
['A45828', '43479'], ['A45850', '43505'], ['A45823', '43480'], ['A45838', '43472'],
['A45854', '43500'], ['A45839', '43486'], ['A45861', '43513'], ['A45830', '43501'],
['A45826', '43498'], ['A45836', '43467'], ['A45822', '43512'], ['A45821', '43210'],
['A45837', '43475'], ['A45860', '43497'], ['A45863', '43477'], ['A45825', '43483'],
['A45829', '43473'], ['A45824', '43491'], ['A45857', '43481'], ['A45851', '43509'],
['A45827', '43468'], ['A45859', '43887'], ['A45846', '43471'], ['A45847', '43511'],
['A45852', '43484'], ['A45844', '43476'], ['A45835', '43482'], ['A45864', '43503'],
['A45848', '43494'], ['A45862', '43465'], ['A39105', '41218'], ['A39098', '41220'],
['A29900', '41865'], ['A39097', '41226'], ['A39104', '41225'], ['A39103', '41223'],
['A39106', '41221'], ['A39107', '41215'], ['A39094', '41222'], ['A39099', '41216'],
['A39092', '41224'], ['A31801', '41214'],
];
/** Fleet sequence numbers (1-based) that are 20ft-only. 6 of 62 — fill once confirmed. */
const TWENTY_FT_SEQS = new Set<number>();
@Injectable()
export class EdrTruckFleetSeeder {
private readonly logger = new Logger(EdrTruckFleetSeeder.name);
constructor(private readonly dataSource: DataSource) {}
/**
* Idempotent: `ON CONFLICT (plate_number) DO NOTHING`. Uses raw SQL with an
* explicit column list on purpose — the `Vehicle` entity declares an
* `availability` column that does not exist in the DB (schema drift), so a
* repository insert would fail. This inserts only real columns.
*/
async run(): Promise<void> {
const columns = [
'code', 'plate_number', 'registration_number', 'power_plate_no', 'trailer_plate_no',
'vehicle_type', 'manufacturer', 'model', 'year', 'fuel_type', 'capacity',
'status', 'ownership', 'currency', 'description',
];
const rows: unknown[][] = EDR_TRUCK_FLEET.map(([power, trailer], i) => {
const seq = i + 1;
const ft = TWENTY_FT_SEQS.has(seq) ? '20ft' : '40ft';
return [
`EDR-TRK-${String(seq).padStart(3, '0')}`,
`03-ET ${power}`,
trailer,
`03-ET ${power}`,
trailer,
'TRUCK',
'EDR',
`${ft} Container Truck`,
2018,
'DIESEL',
TWENTY_FT_SEQS.has(seq) ? 1 : 2,
'ACTIVE',
'EDR',
'ETB',
`EDR-owned container truck configured for ${ft} containers.`,
];
});
const params: unknown[] = [];
const valueGroups = rows.map((row, r) => {
const placeholders = row.map((_, c) => `$${r * columns.length + c + 1}`);
params.push(...row);
return `(${placeholders.join(', ')})`;
});
const result = await this.dataSource.query(
`INSERT INTO freight.vehicles (${columns.join(', ')}) VALUES ${valueGroups.join(', ')} ` +
`ON CONFLICT (plate_number) DO NOTHING`,
params,
);
const inserted = Array.isArray(result) ? result.length : (result?.affectedRows ?? 0);
this.logger.log(`EDR truck fleet seed: ${EDR_TRUCK_FLEET.length} trucks ensured (new: ${inserted}).`);
}
}

View File

@@ -65,6 +65,7 @@ import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
import InvoicesPage from "./pages/invoices/InvoicesPage";
import MyProfilePage from "./pages/dashboard/MyProfilePage";
import OverviewPage from "./pages/dashboard/OverviewPage";
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
import PaymentsPage from "./pages/payments/PaymentsPage";
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import { RequirePermission } from "./components/auth/RequirePermission";
@@ -673,6 +674,11 @@ const App = () => {
/>
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
{/* Dev/testing page for the mock AI booking assistant. */}
<Route
path="ai-booking-mock-test"
element={<AiBookingMockTestPage />}
/>
<Route path="profile" element={<MyProfilePage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />

View File

@@ -1550,6 +1550,18 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
const [trainPickerOpen, setTrainPickerOpen] = useState(false);
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const [targetScheduleId, setTargetScheduleId] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const allSelected = rows.length > 0 && selected.size === rows.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
const toggleOne = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
// Loading is always onto a SPECIFIC pre-dispatch train. No train -> no auto-load.
const { data: trains = [], isLoading: trainsLoading } = useQuery({
queryKey: ['warehouse-inventory', 'loadable-trains'],
@@ -1557,11 +1569,20 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
enabled: enabled && trainPickerOpen,
});
const loadOntoTrain = useMutation({
mutationFn: async (scheduleId: string) => {
mutationFn: async ({ scheduleId, onlyIds }: { scheduleId: string; onlyIds: string[] }) => {
const items = await warehouseService.getTrainLoadableItems(scheduleId);
const loadableIds = items.filter((i) => i.loadable).map((i) => i.id);
let loadableIds = items.filter((i) => i.loadable).map((i) => i.id);
// When rows are checked, load only those; otherwise load every loadable item.
if (onlyIds.length) {
const picked = new Set(onlyIds);
loadableIds = loadableIds.filter((id) => picked.has(id));
}
if (!loadableIds.length) {
throw new Error('No ready items with an allocated wagon on this train');
throw new Error(
onlyIds.length
? 'None of the selected items have an allocated wagon on this train'
: 'No ready items with an allocated wagon on this train',
);
}
return warehouseService.loadItemsOntoTrain(scheduleId, loadableIds);
},
@@ -1578,7 +1599,10 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
return;
}
try {
const r = await loadOntoTrain.mutateAsync(targetScheduleId);
const r = await loadOntoTrain.mutateAsync({
scheduleId: targetScheduleId,
onlyIds: [...selected],
});
const train = trains.find((t) => t.scheduleId === targetScheduleId);
toast({
title: `${r.loadedCount} item(s) loaded onto train ${train?.trainNumber ?? ''}`.trim(),
@@ -1588,6 +1612,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
});
setTrainPickerOpen(false);
setTargetScheduleId(null);
setSelected(new Set());
onChanged?.();
} catch (error) {
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
@@ -1598,7 +1623,11 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
<Stack gap="sm" mt="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">
<b>{rows.length}</b> item{rows.length !== 1 ? 's' : ''} ready to load
{selected.size > 0 ? (
<><b>{selected.size}</b> of {rows.length} selected</>
) : (
<><b>{rows.length}</b> item{rows.length !== 1 ? 's' : ''} ready to load</>
)}
</Text>
<Button
size="compact-sm"
@@ -1608,7 +1637,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
disabled={rows.length === 0}
onClick={() => setTrainPickerOpen(true)}
>
Auto Load Ready Items
{selected.size > 0 ? `Load Selected (${selected.size})` : 'Auto Load Ready Items'}
</Button>
</Group>
@@ -1670,6 +1699,14 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
<Table.Tr>
<Table.Th w={40}>
<Checkbox
aria-label="Select all"
checked={allSelected}
indeterminate={someSelected}
onChange={toggleAll}
/>
</Table.Th>
<Table.Th w={34} />
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
@@ -1686,6 +1723,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
{rows.map((r: ReadyToLoadRow) => (
<Fragment key={r.id}>
<Table.Tr>
<Table.Td>
<Checkbox
aria-label={`Select ${r.bookingReference ?? r.id}`}
checked={selected.has(r.id)}
onChange={() => toggleOne(r.id)}
/>
</Table.Td>
<Table.Td>
<ActionIcon
variant="subtle"

View File

@@ -0,0 +1,221 @@
import { useState } from "react";
import {
Alert,
Badge,
Button,
Card,
Code,
Group,
Stack,
Table,
Text,
Textarea,
Title,
} from "@mantine/core";
import axios from "axios";
import {
AiBookingExtractResult,
extractBookingFromText,
} from "@/services/ai.service";
const EXAMPLE_TEXT =
"Book 2x40ft containers from Djibouti to Indode. Cargo electronics. Customer ABC Logistics.";
const formatValue = (value: string | number | boolean | null): string => {
if (value === null) return "—";
if (typeof value === "boolean") return value ? "Yes" : "No";
return String(value);
};
const EXTRACTED_FIELD_LABELS: Array<{
key: keyof AiBookingExtractResult["extracted"];
label: string;
}> = [
{ key: "customerName", label: "Customer Name" },
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "cargoType", label: "Cargo Type" },
{ key: "containerType", label: "Container Type" },
{ key: "quantity", label: "Quantity" },
{ key: "direction", label: "Direction" },
{ key: "weightKg", label: "Weight (kg)" },
{ key: "pickupRequired", label: "Pickup Required" },
{ key: "deliveryRequired", label: "Delivery Required" },
];
export default function AiBookingMockTestPage() {
const [text, setText] = useState("");
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<AiBookingExtractResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [showRawJson, setShowRawJson] = useState(false);
const handleTest = async () => {
setLoading(true);
setError(null);
setResult(null);
try {
setResult(await extractBookingFromText(text));
} catch (err) {
const backendMessage = axios.isAxiosError(err)
? (err.response?.data as { message?: string | string[] } | undefined)
?.message
: null;
setError(
backendMessage
? `Mock AI request failed: ${
Array.isArray(backendMessage)
? backendMessage.join(", ")
: backendMessage
}`
: "Mock AI request failed",
);
} finally {
setLoading(false);
}
};
const handleCreateDraftBooking = () => {
window.alert("Draft booking creation will be connected in the next step.");
};
const canCreateDraft = Boolean(result?.validation.valid);
return (
<Stack gap="lg" p="md" maw={860}>
<Title order={2}>Mock AI Booking Assistant</Title>
<Card withBorder radius="md" padding="lg">
<Stack gap="sm">
<Textarea
label="Customer booking request"
placeholder="Enter customer booking request..."
description={`Example: ${EXAMPLE_TEXT}`}
minRows={4}
autosize
value={text}
onChange={(event) => setText(event.currentTarget.value)}
/>
<Group>
<Button
onClick={handleTest}
loading={loading}
disabled={text.trim().length < 5}
>
{loading ? "Testing..." : "Test Mock AI"}
</Button>
<Button
variant="light"
color="gray"
onClick={() => setText(EXAMPLE_TEXT)}
>
Use example
</Button>
</Group>
</Stack>
</Card>
{error && (
<Alert color="red" title="Request failed">
{error}
</Alert>
)}
{result && (
<>
<Card withBorder radius="md" padding="lg">
<Stack gap="sm">
<Group justify="space-between">
<Title order={4}>Extracted Booking Data</Title>
<Badge variant="light" color="gray">
provider: {result.provider}
</Badge>
</Group>
<Table withTableBorder={false} verticalSpacing="xs">
<Table.Tbody>
{EXTRACTED_FIELD_LABELS.map(({ key, label }) => (
<Table.Tr key={key}>
<Table.Td w={200}>
<Text size="sm" c="dimmed">
{label}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>
{formatValue(result.extracted[key])}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</Card>
<Card withBorder radius="md" padding="lg">
<Stack gap="sm">
<Group>
<Title order={4}>Validation</Title>
<Badge color={result.validation.valid ? "green" : "red"}>
{result.validation.valid ? "Valid" : "Invalid"}
</Badge>
</Group>
{result.validation.errors.length > 0 && (
<Stack gap={4}>
{result.validation.errors.map((message) => (
<Text key={message} size="sm" c="red">
{message}
</Text>
))}
</Stack>
)}
</Stack>
</Card>
<Card withBorder radius="md" padding="lg">
<Stack gap="sm">
<Group>
<Title order={4}>Recommendation</Title>
<Badge
color={
result.recommendation.action === "CREATE_DRAFT_BOOKING"
? "green"
: "yellow"
}
>
{result.recommendation.action}
</Badge>
<Badge variant="light">
confidence {Math.round(result.recommendation.confidence * 100)}%
</Badge>
</Group>
<Text size="sm">{result.recommendation.message}</Text>
</Stack>
</Card>
<Group>
<Button
color="green"
disabled={!canCreateDraft}
onClick={handleCreateDraftBooking}
>
Create Draft Booking
</Button>
<Button
variant="subtle"
color="gray"
onClick={() => setShowRawJson((open) => !open)}
>
{showRawJson ? "Hide raw JSON" : "Show raw JSON"}
</Button>
</Group>
{showRawJson && (
<Code block>{JSON.stringify(result, null, 2)}</Code>
)}
</>
)}
</Stack>
);
}

View File

@@ -3,7 +3,6 @@ import {
Badge,
Button,
Card,
Container,
Group,
Loader,
Select,
@@ -13,10 +12,9 @@ import {
} from '@mantine/core';
import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { PageContainer, PageHeader } from '@/components/page';
import {
VisualEmptyState,
WarehouseHero,
formatDate,
formatNumber,
} from '@/components/warehouses';
@@ -286,18 +284,14 @@ export default function ArrivalQueuePage() {
};
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Arrival queue' }]} />
<PageContainer>
<PageHeader
title="Arrival / Unloading Queue"
subtitle="Arrived import trains ready to unload assigned bookings into warehouse inventory."
breadcrumbs={[{ label: 'Arrival queue' }]}
/>
<Stack gap="lg" mt="sm">
<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">
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md">
<Stack gap={2}>
<Text fw={600}>{trains.length} arrived import train(s)</Text>
@@ -428,8 +422,7 @@ export default function ArrivalQueuePage() {
</Table>
</Table.ScrollContainer>
)}
</Card>
</Stack>
</Container>
</Card>
</PageContainer>
);
}

View File

@@ -1,14 +1,12 @@
import { Fragment, useState } from 'react';
import {
Alert,
Badge,
Button,
Card,
Container,
Group,
Loader,
Modal,
Alert,
Stack,
Table,
Tabs,
Text,
@@ -26,13 +24,11 @@ import {
Truck,
} from 'lucide-react';
import { PageHeader } from '@/components/page';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { PageContainer, PageHeader } from '@/components/page';
import {
ActivityTimeline,
InventoryMovementHistoryTable,
VisualEmptyState,
WarehouseHero,
formatDate,
formatNumber,
} from '@/components/warehouses';
@@ -289,23 +285,14 @@ export default function ExportDjiboutiUnloadingQueuePage() {
};
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Djibouti Arrival / Unloading Queue' }]} />
<PageContainer>
<PageHeader
title="Djibouti Arrival / Unloading Queue"
subtitle="Arrived export trains at Djibouti-side destinations, auto unloading, and signed interchange handover."
breadcrumbs={[{ label: 'Djibouti Arrival / Unloading Queue' }]}
/>
<Stack gap="lg" mt="sm">
<PageHeader
title="Djibouti Arrival / Unloading Queue"
subtitle="Arrived export trains at Djibouti-side destinations, auto unloading, and signed interchange handover."
/>
<WarehouseHero
variant="train"
secondaryVariant="container"
title="Export Unloading at Djibouti Port"
subtitle="Review arrived trains, auto unload eligible export items, then generate the EDR and Djibouti Port signed interchange document."
/>
<Card withBorder radius="md" padding="lg">
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md">
<Text fw={600}>{trains.length} arrived export train(s)</Text>
<Text size="sm" c="dimmed">
@@ -451,8 +438,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
</Table>
</Table.ScrollContainer>
)}
</Card>
</Stack>
</Card>
<Modal
opened={Boolean(historyInventoryId)}
@@ -475,6 +461,6 @@ export default function ExportDjiboutiUnloadingQueuePage() {
</Tabs>
) : null}
</Modal>
</Container>
</PageContainer>
);
}

View File

@@ -15,6 +15,8 @@ import {
import { CheckCircle2, Download, Eye, FileText, Printer, Search, XCircle } from 'lucide-react';
import type { ReactNode } from 'react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { PageContainer, PageHeader } from '@/components/page';
import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
import {
@@ -323,6 +325,132 @@ export default function InterchangeDocumentsPage() {
run(() => dispute.mutateAsync({ id: document.id, remarks }), 'Interchange document disputed');
};
const documentColumns: ColumnDef<InterchangeDocument>[] = [
{
id: 'documentNo',
header: 'Document No',
cell: ({ row }) => (
<Text fw={700} size="sm">
{row.original.documentNo}
</Text>
),
},
{ id: 'direction', header: 'Direction', cell: ({ row }) => row.original.direction },
{
id: 'train',
header: 'Train No / Schedule',
cell: ({ row }) => (
<Stack gap={0}>
<Text size="sm">{row.original.trainNo ?? '-'}</Text>
<Text size="xs" c="dimmed">
{row.original.scheduleId?.slice(0, 8) ?? '-'}
</Text>
</Stack>
),
},
{ id: 'route', header: 'Route', cell: ({ row }) => row.original.routeId?.slice(0, 8) ?? '-' },
{ id: 'handoverLocation', header: 'Handover Location', cell: ({ row }) => row.original.handoverLocation },
{ id: 'handoverFrom', header: 'Handover From', cell: ({ row }) => row.original.handoverFrom },
{ id: 'handoverTo', header: 'Handover To', cell: ({ row }) => row.original.handoverTo },
{
id: 'status',
header: 'Status',
cell: ({ row }) => (
<Badge variant="light" color={statusColor[row.original.status]}>
{row.original.status}
</Badge>
),
},
{
id: 'signedBy',
header: 'Signed By',
cell: ({ row }) => (
<Stack gap={0}>
<Text size="sm">{row.original.generatedBy ?? '-'}</Text>
<Text size="xs" c="dimmed">
{row.original.acknowledgedBy ?? 'Awaiting Djibouti Port'}
</Text>
</Stack>
),
},
{ id: 'generatedAt', header: 'Generated At', cell: ({ row }) => formatDate(row.original.generatedAt) },
{
id: 'actions',
header: '',
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
cell: ({ row }) => {
const doc = row.original;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
variant="light"
leftSection={<Eye size={14} />}
onClick={() => setViewId(doc.id)}
>
View
</Button>
{doc.status !== 'ACKNOWLEDGED' && doc.status !== 'CANCELLED' ? (
<Button
size="compact-xs"
color="green"
variant="light"
leftSection={<CheckCircle2 size={14} />}
onClick={() => acknowledgeDocument(doc)}
>
Acknowledge
</Button>
) : null}
{doc.status === 'ACKNOWLEDGED' ? (
<>
<Button
size="compact-xs"
color="blue"
variant="light"
leftSection={<Printer size={14} />}
onClick={() => run(() => printDocument(doc), 'Print view opened')}
>
Print
</Button>
<Button
size="compact-xs"
color="blue"
variant="light"
leftSection={<Download size={14} />}
onClick={() => run(() => downloadDocument(doc), 'Document downloaded')}
>
Download
</Button>
</>
) : null}
{doc.status !== 'CANCELLED' ? (
<Button
size="compact-xs"
color="orange"
variant="light"
leftSection={<FileText size={14} />}
onClick={() => disputeDocument(doc)}
>
Dispute
</Button>
) : null}
{doc.status === 'DRAFT' || doc.status === 'GENERATED' ? (
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<XCircle size={14} />}
onClick={() => run(() => cancel.mutateAsync(doc.id), 'Interchange document cancelled')}
>
Cancel
</Button>
) : null}
</Group>
);
},
},
];
return (
<PageContainer>
<PageHeader
@@ -342,143 +470,19 @@ export default function InterchangeDocumentsPage() {
/>
</Group>
{isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : documents.length === 0 ? (
{!isLoading && documents.length === 0 ? (
<VisualEmptyState
variant="container"
title="No interchange documents"
description="Generated freight handover documents appear here."
/>
) : (
<Table.ScrollContainer minWidth={1060}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Document No</Table.Th>
<Table.Th>Direction</Table.Th>
<Table.Th>Train No / Schedule</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th>Handover Location</Table.Th>
<Table.Th>Handover From</Table.Th>
<Table.Th>Handover To</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Signed By</Table.Th>
<Table.Th>Generated At</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{documents.map((document) => (
<Table.Tr key={document.id}>
<Table.Td>
<Text fw={700} size="sm">
{document.documentNo}
</Text>
</Table.Td>
<Table.Td>{document.direction}</Table.Td>
<Table.Td>
<Stack gap={0}>
<Text size="sm">{document.trainNo ?? '-'}</Text>
<Text size="xs" c="dimmed">
{document.scheduleId?.slice(0, 8) ?? '-'}
</Text>
</Stack>
</Table.Td>
<Table.Td>{document.routeId?.slice(0, 8) ?? '-'}</Table.Td>
<Table.Td>{document.handoverLocation}</Table.Td>
<Table.Td>{document.handoverFrom}</Table.Td>
<Table.Td>{document.handoverTo}</Table.Td>
<Table.Td>
<Badge variant="light" color={statusColor[document.status]}>
{document.status}
</Badge>
</Table.Td>
<Table.Td>
<Stack gap={0}>
<Text size="sm">{document.generatedBy ?? '-'}</Text>
<Text size="xs" c="dimmed">
{document.acknowledgedBy ?? 'Awaiting Djibouti Port'}
</Text>
</Stack>
</Table.Td>
<Table.Td>{formatDate(document.generatedAt)}</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
variant="light"
leftSection={<Eye size={14} />}
onClick={() => setViewId(document.id)}
>
View
</Button>
{document.status !== 'ACKNOWLEDGED' && document.status !== 'CANCELLED' ? (
<Button
size="compact-xs"
color="green"
variant="light"
leftSection={<CheckCircle2 size={14} />}
onClick={() => acknowledgeDocument(document)}
>
Acknowledge
</Button>
) : null}
{document.status === 'ACKNOWLEDGED' ? (
<>
<Button
size="compact-xs"
color="blue"
variant="light"
leftSection={<Printer size={14} />}
onClick={() => run(() => printDocument(document), 'Print view opened')}
>
Print
</Button>
<Button
size="compact-xs"
color="blue"
variant="light"
leftSection={<Download size={14} />}
onClick={() => run(() => downloadDocument(document), 'Document downloaded')}
>
Download
</Button>
</>
) : null}
{document.status !== 'CANCELLED' ? (
<Button
size="compact-xs"
color="orange"
variant="light"
leftSection={<FileText size={14} />}
onClick={() => disputeDocument(document)}
>
Dispute
</Button>
) : null}
{document.status === 'DRAFT' || document.status === 'GENERATED' ? (
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<XCircle size={14} />}
onClick={() =>
run(() => cancel.mutateAsync(document.id), 'Interchange document cancelled')
}
>
Cancel
</Button>
) : null}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<DataTable
columns={documentColumns}
data={documents}
status={isLoading ? 'loading' : 'success'}
containerClassName="border-0 shadow-none"
/>
)}
</Card>

View File

@@ -1,5 +1,5 @@
import { useNavigate } from 'react-router-dom';
import { Card, Center, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { Card, Center, Group, Loader, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
import {
ClipboardCheck,
ClipboardList,
@@ -16,7 +16,7 @@ import {
} from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page';
import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses';
import { WarehouseDashboardCharts } from '@/components/warehouses';
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
import type { WarehouseDashboard } from '@/types/warehouse';
@@ -58,58 +58,49 @@ export default function WarehouseDashboardPage() {
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 ? (
<Center py="xl">
<Loader />
</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">
{METRICS.map((metric) => (
<Card
key={metric.key}
padding="lg"
onClick={() => navigate(metric.to)}
className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
{metric.label}
</Text>
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
{data ? data[metric.key] : 0}
</Text>
</div>
<ThemeIcon
variant="light"
size={46}
radius="md"
style={{ backgroundColor: `${metric.theme}1a`, color: metric.theme }}
>
{metric.icon}
</ThemeIcon>
</Group>
</Card>
))}
</SimpleGrid>
{isLoading ? (
<Center py="xl">
<Loader />
</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">
{METRICS.map((metric) => (
<Card
key={metric.key}
padding="lg"
onClick={() => navigate(metric.to)}
className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
{metric.label}
</Text>
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
{data ? data[metric.key] : 0}
</Text>
</div>
<ThemeIcon
variant="light"
size={46}
radius="md"
style={{ backgroundColor: `${metric.theme}1a`, color: metric.theme }}
>
{metric.icon}
</ThemeIcon>
</Group>
</Card>
))}
</SimpleGrid>
<WarehouseDashboardCharts data={data} />
</>
)}
</Stack>
<WarehouseDashboardCharts data={data} />
</>
)}
</PageContainer>
);
}

View File

@@ -6,13 +6,11 @@ import {
Button,
Card,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Tabs,
Table,
Text,
TextInput,
} from '@mantine/core';
@@ -20,6 +18,8 @@ import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { PageContainer, PageHeader } from '@/components/page';
import { useToast } from '@/hooks/use-toast';
import {
@@ -209,6 +209,48 @@ function AllocationRules() {
}
};
const allocationColumns: ColumnDef<AllocationRule>[] = [
{ 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 ?? dash },
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? dash },
{ id: 'cargoCode', header: 'Cargo code', cell: ({ row }) => row.original.cargoTypeCode ?? dash },
{
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 ? 'green' : 'gray'} variant="light">
{row.original.isActive ? 'Yes' : 'No'}
</Badge>
),
},
{
id: 'actions',
header: '',
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
cell: ({ row }) => (
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(row.original)} title="Edit">
<Pencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(row.original.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
),
},
];
return (
<>
<Group justify="space-between" mb="sm">
@@ -226,62 +268,13 @@ function AllocationRules() {
</Text>
</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">
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
<Pencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<DataTable
columns={allocationColumns}
data={rules}
status={isLoading ? 'loading' : 'success'}
emptyMessage="No allocation rules yet. Create one to route inventory to a yard automatically."
containerClassName="border-0 shadow-none"
/>
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
<Stack gap="md">
@@ -576,6 +569,75 @@ function FeeRules() {
}
};
const feeColumns: ColumnDef<FeeRule>[] = [
{
id: 'type',
header: 'Type',
cell: ({ row }) => (
<Badge color={RULE_TYPE_COLOR[row.original.ruleType] ?? 'gray'} variant="light">
{FEE_RULE_TYPE_LABELS[row.original.ruleType] ?? row.original.ruleType}
</Badge>
),
},
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
{ id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? dash },
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? dash },
{ id: 'cargo', header: 'Cargo', cell: ({ row }) => row.original.cargoTypeCode ?? dash },
{ id: 'container', header: 'Container', cell: ({ row }) => row.original.containerType ?? dash },
{
id: 'scope',
header: 'Location scope',
cell: ({ row }) => {
const rule = row.original;
const hasScope = [rule.facilityId, rule.warehouseId, rule.yardId, rule.zoneId].some(Boolean);
if (!hasScope) return dash;
return (
<Stack gap={2}>
{rule.facilityId && <Text size="xs">Facility: {rule.facilityId}</Text>}
{rule.warehouseId && <Text size="xs">Warehouse: {rule.warehouseId}</Text>}
{rule.yardId && <Text size="xs">Yard: {rule.yardId}</Text>}
{rule.zoneId && <Text size="xs">Zone: {rule.zoneId}</Text>}
</Stack>
);
},
},
{ 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 ? 'green' : 'gray'} variant="light">
{row.original.isActive ? 'Yes' : 'No'}
</Badge>
),
},
{
id: 'actions',
header: '',
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
cell: ({ row }) => (
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(row.original)} title="Edit">
<Pencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(row.original.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
),
},
];
return (
<>
<Group justify="space-between" mb="sm">
@@ -587,83 +649,13 @@ function FeeRules() {
</Button>
</Group>
{isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : (
<Table.ScrollContainer minWidth={1100}>
<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>Cargo</Table.Th>
<Table.Th>Container</Table.Th>
<Table.Th>Location scope</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_TYPE_COLOR[rule.ruleType] ?? 'gray'} variant="light">
{FEE_RULE_TYPE_LABELS[rule.ruleType] ?? rule.ruleType}
</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.cargoTypeCode ?? dash}</Table.Td>
<Table.Td>{rule.containerType ?? dash}</Table.Td>
<Table.Td>
{[rule.facilityId, rule.warehouseId, rule.yardId, rule.zoneId].some(Boolean) ? (
<Stack gap={2}>
{rule.facilityId && <Text size="xs">Facility: {rule.facilityId}</Text>}
{rule.warehouseId && <Text size="xs">Warehouse: {rule.warehouseId}</Text>}
{rule.yardId && <Text size="xs">Yard: {rule.yardId}</Text>}
{rule.zoneId && <Text size="xs">Zone: {rule.zoneId}</Text>}
</Stack>
) : (
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">
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
<Pencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<DataTable
columns={feeColumns}
data={rules}
status={isLoading ? 'loading' : 'success'}
emptyMessage="No storage or demurrage fee rules yet."
containerClassName="border-0 shadow-none"
/>
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
<Stack gap="sm">

View File

@@ -0,0 +1,48 @@
import { api as client } from "../auth/http";
export interface AiExtractedBooking {
customerName: string | null;
origin: string | null;
destination: string | null;
cargoType: string | null;
containerType: "20FT" | "40FT" | "BULK" | "RO_RO" | null;
quantity: number | null;
direction: "IMPORT" | "EXPORT" | null;
weightKg: number | null;
pickupRequired: boolean | null;
deliveryRequired: boolean | null;
}
export interface AiValidationResult {
valid: boolean;
errors: string[];
}
export interface AiRecommendation {
action: "CREATE_DRAFT_BOOKING" | "REQUEST_MISSING_INFORMATION";
message: string;
confidence: number;
}
export interface AiBookingExtractResult {
provider: "mock";
extracted: AiExtractedBooking;
validation: AiValidationResult;
recommendation: AiRecommendation;
}
interface AiBookingExtractResponse {
success: boolean;
data: AiBookingExtractResult;
timestamp: string;
}
export const extractBookingFromText = async (
text: string,
): Promise<AiBookingExtractResult> => {
const response = await client.post<AiBookingExtractResponse>(
"/ai/booking/extract",
{ text },
);
return response.data.data;
};