mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
Cleaned warehouse related UI and made all 15 pages consisitent
This commit is contained in:
@@ -93,6 +93,7 @@ import { FirstMileModule } from "./modules/first-mile/first-mile.module";
|
|||||||
import { LastMileModule } from "./modules/last-mile/last-mile.module";
|
import { LastMileModule } from "./modules/last-mile/last-mile.module";
|
||||||
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
||||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||||
|
import { AiModule } from "./modules/ai/ai.module";
|
||||||
import { LoggerMiddleware } from "./logger.middleware";
|
import { LoggerMiddleware } from "./logger.middleware";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -188,6 +189,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
ImportOperationsModule,
|
ImportOperationsModule,
|
||||||
VerifaydaModule,
|
VerifaydaModule,
|
||||||
FleetHistoryModule,
|
FleetHistoryModule,
|
||||||
|
AiModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
EdrOrgSeeder,
|
EdrOrgSeeder,
|
||||||
|
|||||||
31
apps/edr-freight-api/src/modules/ai/ai.controller.ts
Normal file
31
apps/edr-freight-api/src/modules/ai/ai.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/edr-freight-api/src/modules/ai/ai.module.ts
Normal file
11
apps/edr-freight-api/src/modules/ai/ai.module.ts
Normal 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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
277
apps/edr-freight-api/src/modules/ai/mock-ai.service.ts
Normal file
277
apps/edr-freight-api/src/modules/ai/mock-ai.service.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
|||||||
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
||||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||||
|
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
|
||||||
import PaymentsPage from "./pages/payments/PaymentsPage";
|
import PaymentsPage from "./pages/payments/PaymentsPage";
|
||||||
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||||
import { RequirePermission } from "./components/auth/RequirePermission";
|
import { RequirePermission } from "./components/auth/RequirePermission";
|
||||||
@@ -673,6 +674,11 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
<Route path="/dashboard" element={<DashboardShell />}>
|
<Route path="/dashboard" element={<DashboardShell />}>
|
||||||
<Route path="overview" element={<OverviewPage />} />
|
<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="profile" element={<MyProfilePage />} />
|
||||||
|
|
||||||
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Container,
|
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Select,
|
Select,
|
||||||
@@ -13,10 +12,9 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
|
import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
|
||||||
|
|
||||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
import {
|
import {
|
||||||
VisualEmptyState,
|
VisualEmptyState,
|
||||||
WarehouseHero,
|
|
||||||
formatDate,
|
formatDate,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
} from '@/components/warehouses';
|
} from '@/components/warehouses';
|
||||||
@@ -286,18 +284,14 @@ export default function ArrivalQueuePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xxl" py="lg">
|
<PageContainer>
|
||||||
<Breadcrumbs items={[{ label: 'Arrival queue' }]} />
|
<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">
|
<Card withBorder radius="md" padding="lg">
|
||||||
<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">
|
<Group justify="space-between" mb="md">
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Text fw={600}>{trains.length} arrived import train(s)</Text>
|
<Text fw={600}>{trains.length} arrived import train(s)</Text>
|
||||||
@@ -428,8 +422,7 @@ export default function ArrivalQueuePage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</Table.ScrollContainer>
|
</Table.ScrollContainer>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Stack>
|
</PageContainer>
|
||||||
</Container>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { Fragment, useState } from 'react';
|
import { Fragment, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Container,
|
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
Alert,
|
|
||||||
Stack,
|
|
||||||
Table,
|
Table,
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
@@ -26,13 +24,11 @@ import {
|
|||||||
Truck,
|
Truck,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
|
||||||
import {
|
import {
|
||||||
ActivityTimeline,
|
ActivityTimeline,
|
||||||
InventoryMovementHistoryTable,
|
InventoryMovementHistoryTable,
|
||||||
VisualEmptyState,
|
VisualEmptyState,
|
||||||
WarehouseHero,
|
|
||||||
formatDate,
|
formatDate,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
} from '@/components/warehouses';
|
} from '@/components/warehouses';
|
||||||
@@ -289,23 +285,14 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xxl" py="lg">
|
<PageContainer>
|
||||||
<Breadcrumbs items={[{ label: 'Djibouti Arrival / Unloading Queue' }]} />
|
<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">
|
<Card withBorder radius="md" padding="lg">
|
||||||
<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">
|
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
<Text fw={600}>{trains.length} arrived export train(s)</Text>
|
<Text fw={600}>{trains.length} arrived export train(s)</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
@@ -451,8 +438,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</Table.ScrollContainer>
|
</Table.ScrollContainer>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={Boolean(historyInventoryId)}
|
opened={Boolean(historyInventoryId)}
|
||||||
@@ -475,6 +461,6 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
</Container>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
import { CheckCircle2, Download, Eye, FileText, Printer, Search, XCircle } from 'lucide-react';
|
import { CheckCircle2, Download, Eye, FileText, Printer, Search, XCircle } from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
|
import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
|
||||||
import {
|
import {
|
||||||
@@ -323,6 +325,132 @@ export default function InterchangeDocumentsPage() {
|
|||||||
run(() => dispute.mutateAsync({ id: document.id, remarks }), 'Interchange document disputed');
|
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 (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -342,143 +470,19 @@ export default function InterchangeDocumentsPage() {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{isLoading ? (
|
{!isLoading && documents.length === 0 ? (
|
||||||
<Group justify="center" py="xl">
|
|
||||||
<Loader />
|
|
||||||
</Group>
|
|
||||||
) : documents.length === 0 ? (
|
|
||||||
<VisualEmptyState
|
<VisualEmptyState
|
||||||
variant="container"
|
variant="container"
|
||||||
title="No interchange documents"
|
title="No interchange documents"
|
||||||
description="Generated freight handover documents appear here."
|
description="Generated freight handover documents appear here."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Table.ScrollContainer minWidth={1060}>
|
<DataTable
|
||||||
<Table striped highlightOnHover verticalSpacing="sm">
|
columns={documentColumns}
|
||||||
<Table.Thead>
|
data={documents}
|
||||||
<Table.Tr>
|
status={isLoading ? 'loading' : 'success'}
|
||||||
<Table.Th>Document No</Table.Th>
|
containerClassName="border-0 shadow-none"
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
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 {
|
import {
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses';
|
import { WarehouseDashboardCharts } from '@/components/warehouses';
|
||||||
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
|
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
|
||||||
import type { WarehouseDashboard } from '@/types/warehouse';
|
import type { WarehouseDashboard } from '@/types/warehouse';
|
||||||
|
|
||||||
@@ -58,58 +58,49 @@ 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">
|
{isLoading ? (
|
||||||
<WarehouseHero
|
<Center py="xl">
|
||||||
variant="train"
|
<Loader />
|
||||||
secondaryVariant="warehouse"
|
</Center>
|
||||||
title="Warehouse Dashboard"
|
) : isError ? (
|
||||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
<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 ? (
|
<WarehouseDashboardCharts data={data} />
|
||||||
<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>
|
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ import {
|
|||||||
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';
|
||||||
@@ -20,6 +18,8 @@ import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
|
|||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between" mb="sm">
|
<Group justify="space-between" mb="sm">
|
||||||
@@ -226,62 +268,13 @@ function AllocationRules() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{isLoading ? (
|
<DataTable
|
||||||
<Group justify="center" py="xl">
|
columns={allocationColumns}
|
||||||
<Loader />
|
data={rules}
|
||||||
</Group>
|
status={isLoading ? 'loading' : 'success'}
|
||||||
) : (
|
emptyMessage="No allocation rules yet. Create one to route inventory to a yard automatically."
|
||||||
<Table.ScrollContainer minWidth={900}>
|
containerClassName="border-0 shadow-none"
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
|
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
|
||||||
<Stack gap="md">
|
<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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between" mb="sm">
|
<Group justify="space-between" mb="sm">
|
||||||
@@ -587,83 +649,13 @@ function FeeRules() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{isLoading ? (
|
<DataTable
|
||||||
<Group justify="center" py="xl">
|
columns={feeColumns}
|
||||||
<Loader />
|
data={rules}
|
||||||
</Group>
|
status={isLoading ? 'loading' : 'success'}
|
||||||
) : (
|
emptyMessage="No storage or demurrage fee rules yet."
|
||||||
<Table.ScrollContainer minWidth={1100}>
|
containerClassName="border-0 shadow-none"
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
|
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
|
|||||||
48
apps/edr-freight-web/backoffice/src/services/ai.service.ts
Normal file
48
apps/edr-freight-web/backoffice/src/services/ai.service.ts
Normal 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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user