From eec3d1863a9f3052ca4f69bc9c09333fc6af5cbf Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 11 Jul 2026 06:50:33 +0000 Subject: [PATCH 1/3] Cleaned warehouse related UI and made all 15 pages consisitent --- apps/edr-freight-api/src/app.module.ts | 2 + .../src/modules/ai/ai.controller.ts | 31 ++ .../src/modules/ai/ai.module.ts | 11 + .../modules/ai/dto/ai-booking-request.dto.ts | 15 + .../src/modules/ai/mock-ai.service.ts | 277 ++++++++++++++++++ .../ai/types/ai-booking-result.type.ts | 47 +++ apps/edr-freight-web/backoffice/src/App.tsx | 6 + .../src/pages/ai/AiBookingMockTestPage.tsx | 221 ++++++++++++++ .../src/pages/warehouses/ArrivalQueuePage.tsx | 27 +- .../ExportDjiboutiUnloadingQueuePage.tsx | 36 +-- .../warehouses/InterchangeDocumentsPage.tsx | 266 ++++++++--------- .../warehouses/WarehouseDashboardPage.tsx | 97 +++--- .../pages/warehouses/WarehouseRulesPage.tsx | 262 ++++++++--------- .../backoffice/src/services/ai.service.ts | 48 +++ 14 files changed, 985 insertions(+), 361 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/ai/ai.controller.ts create mode 100644 apps/edr-freight-api/src/modules/ai/ai.module.ts create mode 100644 apps/edr-freight-api/src/modules/ai/dto/ai-booking-request.dto.ts create mode 100644 apps/edr-freight-api/src/modules/ai/mock-ai.service.ts create mode 100644 apps/edr-freight-api/src/modules/ai/types/ai-booking-result.type.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/ai/AiBookingMockTestPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/ai.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index afb214137..224a75ada 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -93,6 +93,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 +189,7 @@ import { LoggerMiddleware } from "./logger.middleware"; ImportOperationsModule, VerifaydaModule, FleetHistoryModule, + AiModule, ], providers: [ EdrOrgSeeder, diff --git a/apps/edr-freight-api/src/modules/ai/ai.controller.ts b/apps/edr-freight-api/src/modules/ai/ai.controller.ts new file mode 100644 index 000000000..eb87fe97d --- /dev/null +++ b/apps/edr-freight-api/src/modules/ai/ai.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/ai/ai.module.ts b/apps/edr-freight-api/src/modules/ai/ai.module.ts new file mode 100644 index 000000000..e274d90b6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/ai/ai.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/ai/dto/ai-booking-request.dto.ts b/apps/edr-freight-api/src/modules/ai/dto/ai-booking-request.dto.ts new file mode 100644 index 000000000..7ec64e310 --- /dev/null +++ b/apps/edr-freight-api/src/modules/ai/dto/ai-booking-request.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/ai/mock-ai.service.ts b/apps/edr-freight-api/src/modules/ai/mock-ai.service.ts new file mode 100644 index 000000000..7883bf9ff --- /dev/null +++ b/apps/edr-freight-api/src/modules/ai/mock-ai.service.ts @@ -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([ + '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 = [ + [/\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 = { + 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 = [ + 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 (/(? { /> }> } /> + {/* Dev/testing page for the mock AI booking assistant. */} + } + /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/ai/AiBookingMockTestPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ai/AiBookingMockTestPage.tsx new file mode 100644 index 000000000..bd9f1d968 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/ai/AiBookingMockTestPage.tsx @@ -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(null); + const [error, setError] = useState(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 ( + + Mock AI Booking Assistant + + + +