diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index afb214137..2bee84923 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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) { diff --git a/apps/edr-freight-api/src/migrations/2110000000000-RepairVehicleAvailabilityColumn.ts b/apps/edr-freight-api/src/migrations/2110000000000-RepairVehicleAvailabilityColumn.ts new file mode 100644 index 000000000..c68a6c720 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2110000000000-RepairVehicleAvailabilityColumn.ts @@ -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 { + 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 { + // No-op: dropping a column other code now depends on would reintroduce the + // drift. The original SeparateVehicleAvailability migration owns the column. + } +} 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 (/(? = [ + ['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(); + +@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 { + 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}).`); + } +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 2591d395c..18d382933 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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 = () => { /> }> } /> + {/* Dev/testing page for the mock AI booking assistant. */} + } + /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 55722f04a..93c3bf64e 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -1550,6 +1550,18 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: const [trainPickerOpen, setTrainPickerOpen] = useState(false); const [expandedRow, setExpandedRow] = useState(null); const [targetScheduleId, setTargetScheduleId] = useState(null); + const [selected, setSelected] = useState>(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?: - {rows.length} item{rows.length !== 1 ? 's' : ''} ready to load + {selected.size > 0 ? ( + <>{selected.size} of {rows.length} selected + ) : ( + <>{rows.length} item{rows.length !== 1 ? 's' : ''} ready to load + )} @@ -1670,6 +1699,14 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: + + + Booking Ref GRN @@ -1686,6 +1723,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: {rows.map((r: ReadyToLoadRow) => ( + + toggleOne(r.id)} + /> + { + 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 + + + +