diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index f6f32be91..a76378b0c 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -18,6 +18,7 @@ async function bootstrap() { // freight portal (5173), passenger portal (5174), backoffices (5183/5184) // and any other dev port can call the API with cookies + Authorization. // For production, restrict `origin` to known FQDNs. + app.enableCors({ origin: true, // reflect request origin credentials: true, @@ -52,9 +53,12 @@ async function bootstrap() { SwaggerModule.setup("api/docs", app, document); const port = parseInt(process.env.PORT ?? "3001", 10); - await app.listen(port); + // await app.listen(port, "0.0.0.0"); + await app.listen( + + port) // eslint-disable-next-line no-console - console.log(`[freight-api] listening on http://localhost:${port}`); + console.log(`[freight-api] listening on port ${port}`); } bootstrap(); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts index ce15125c2..332916727 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts @@ -25,10 +25,10 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{ key: 'approved_contract', statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'], }, - { key: 'payment', statuses: ['FULLY_EXECUTED', 'PAID'] }, + { key: 'payment', statuses: ['FULLY_EXECUTED'] }, { key: 'operations', - statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED'], + statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'], }, { key: 'completed', statuses: ['COMPLETED'] }, { key: 'closed', statuses: ['REJECTED', 'CANCELLED'] }, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts index c0a865949..c591a1db4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts @@ -34,8 +34,8 @@ export function computeNextStep( }; case 'APPROVED': return { - action: 'GENERATE_CONTRACT', - description: 'Generate the contract document', + action: 'CUSTOMER_SIGN', + description: 'Contract generated; customer must sign', }; case 'CONTRACT_READY': return { @@ -49,8 +49,8 @@ export function computeNextStep( }; case 'FULLY_EXECUTED': return { - action: 'PAY', - description: 'Complete in-app payment', + action: 'AWAIT_PAYMENT', + description: 'Awaiting customer payment', }; case 'PAID': return { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 56ab6394b..0fdfc5084 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -185,6 +185,11 @@ export class BookingTransitionService { await this.bookingsRepository.update(bookingId, updates as never); } + if (allDone) { + const generated = await this.contractService.generateContract(bookingId); + return this.bookingsService.findById(generated.id); + } + return this.bookingsService.findById(bookingId); } diff --git a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts new file mode 100644 index 000000000..d06953769 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts @@ -0,0 +1,67 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsInt, + IsNumber, + IsOptional, + IsString, + MaxLength, + Min, +} from 'class-validator'; + +const parseLoadTypes = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value.map((item) => String(item).trim()).filter(Boolean); + } + if (typeof value === 'string') { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + } + return []; +}; + +export class CreateWagonTypeDto { + @ApiProperty({ description: 'Display name, e.g. "Flat Wagon"', maxLength: 100 }) + @IsString() + @MaxLength(100) + name!: string; + + @ApiProperty({ description: 'Maximum payload capacity in metric tons' }) + @IsNumber() + @Min(0.001) + @Transform(({ value }) => Number(value)) + capacityTons!: number; + + @ApiProperty({ description: 'Wagon length in meters' }) + @IsNumber() + @Min(0.001) + @Transform(({ value }) => Number(value)) + lengthMeters!: number; + + @ApiPropertyOptional({ description: 'Maximum wagons of this type per train' }) + @IsOptional() + @IsInt() + @Min(1) + @Transform(({ value }) => (value === '' || value === null || value === undefined ? undefined : Number(value))) + maxWagonsPerTrain?: number; + + @ApiPropertyOptional({ + description: 'Supported load types, e.g. CONTAINER,BULK', + type: [String], + default: [], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + @Transform(({ value }) => parseLoadTypes(value)) + supportedLoadTypes?: string[]; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/dto/update-wagon-type.dto.ts b/apps/edr-freight-api/src/modules/wagon-types/dto/update-wagon-type.dto.ts new file mode 100644 index 000000000..846987556 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/dto/update-wagon-type.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/mapped-types'; + +import { CreateWagonTypeDto } from './create-wagon-type.dto'; + +export class UpdateWagonTypeDto extends PartialType(CreateWagonTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts index 0c0d65bd7..7717dd703 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts @@ -1,16 +1,67 @@ -import { Controller, Get } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; -import { WagonTypesService } from './wagon-types.service'; -import { WagonType } from './entities/wagon-type.entity'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -@ApiTags('Wagon Types') +import { RuleEngineManage, RuleEngineView } from '../../common/rule-engine-guards'; + +import { CreateWagonTypeDto } from './dto/create-wagon-type.dto'; +import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto'; +import { WagonTypesService } from './wagon-types.service'; + +@ApiTags('wagon-types') @Controller('wagon-types') +@ApiBearerAuth() export class WagonTypesController { constructor(private readonly wagonTypesService: WagonTypesService) {} @Get() - @ApiOperation({ summary: 'Get all active wagon types' }) - async findAll(): Promise { - return this.wagonTypesService.findAll(); + @RuleEngineView('wagon-types') + @ApiOperation({ summary: 'List wagon types' }) + findAll(@Query() query: Record) { + return this.wagonTypesService.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); } -} \ No newline at end of file + + @Get(':id') + @RuleEngineView('wagon-types') + @ApiOperation({ summary: 'Get a wagon type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonTypesService.findById(id); + } + + @Post() + @RuleEngineManage('wagon-types') + @ApiOperation({ summary: 'Create a wagon type' }) + create(@Body() dto: CreateWagonTypeDto) { + return this.wagonTypesService.create(dto); + } + + @Patch(':id') + @RuleEngineManage('wagon-types') + @ApiOperation({ summary: 'Update a wagon type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonTypeDto) { + return this.wagonTypesService.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('wagon-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a wagon type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonTypesService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts index 001ff0212..ce7166e67 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts @@ -13,4 +13,8 @@ export class WagonTypesRepository extends BaseRepository { ) { super(repository); } + + findByCode(code: string): Promise { + return this.repository.findOne({ where: { code } }); + } } diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts index 879245783..a3bbfa005 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts @@ -1,5 +1,13 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { generateCode } from '../../common/utils/generate-code.util'; + +import { CreateWagonTypeDto } from './dto/create-wagon-type.dto'; +import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto'; import { WagonType } from './entities/wagon-type.entity'; import { WagonTypesRepository } from './wagon-types.repository'; @@ -7,20 +15,86 @@ import { WagonTypesRepository } from './wagon-types.repository'; export class WagonTypesService { constructor(private readonly wagonTypesRepository: WagonTypesRepository) {} - async findAll(): Promise { - return this.wagonTypesRepository.findAll({ - where: { isActive: true }, + async findAll(filter: { + isActive?: boolean; + page?: number; + pageSize?: number; + } = {}): Promise<{ + data: WagonType[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; + }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.isActive !== undefined) { + where.isActive = filter.isActive; + } + + const [data, total] = await this.wagonTypesRepository.findAndCount({ + where, order: { code: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, }); + + return { + data, + meta: { + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }, + }; + } + + async findById(id: string): Promise { + const wagonType = await this.wagonTypesRepository.findById(id); + if (!wagonType) { + throw new NotFoundException(`Wagon type ${id} not found`); + } + return wagonType; } async findByCode(code: string): Promise { - const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } }); - + const wagonType = await this.wagonTypesRepository.findByCode(code); if (!wagonType) { throw new NotFoundException(`Wagon type ${code} not found`); } - return wagonType; } + + async create(dto: CreateWagonTypeDto): Promise { + const code = generateCode(dto.name); + const existing = await this.wagonTypesRepository.findByCode(code); + if (existing) { + throw new ConflictException( + `Wagon type with name "${dto.name}" conflicts with existing code "${code}"`, + ); + } + + return this.wagonTypesRepository.create({ + code, + name: dto.name, + capacityTons: dto.capacityTons, + lengthMeters: dto.lengthMeters, + maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null, + supportedLoadTypes: dto.supportedLoadTypes ?? [], + isActive: dto.isActive ?? true, + }); + } + + async update(id: string, dto: UpdateWagonTypeDto): Promise { + await this.findById(id); + const updated = await this.wagonTypesRepository.update(id, dto); + if (!updated) { + throw new NotFoundException(`Wagon type ${id} not found`); + } + return updated; + } + + async remove(id: string): Promise { + await this.findById(id); + await this.wagonTypesRepository.softDelete(id); + } } diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index dba80d96b..e82032cff 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -10,6 +10,7 @@ export type FreightPermissionSeed = { export const RULE_ENGINE_RESOURCE_SLUGS = [ 'cargo-types', 'container-types', + 'wagon-types', 'service-types', 'yards', 'shipping-lines', @@ -56,6 +57,7 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ const RULE_ENGINE_PERMISSION_IDS: Record = { 'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' }, 'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' }, + 'wagon-types': { view: 'b2000001-0001-4000-8000-000000000015', manage: 'b2000001-0001-4000-8000-000000000016' }, 'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' }, yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' }, 'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' }, diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 671436a73..fb8fbf2e6 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -353,6 +353,8 @@ export class PricingDataSeeder { ): Promise { const effectiveFrom = new Date("2026-01-01"); const now = new Date(); + // await rRepo.createQueryBuilder().delete().execute(); + const rateData = [ { rateType: "CONTAINER_IMPORT", diff --git a/apps/edr-freight-web/backoffice/index.css b/apps/edr-freight-web/backoffice/index.css index d232351ed..8d40838ea 100644 --- a/apps/edr-freight-web/backoffice/index.css +++ b/apps/edr-freight-web/backoffice/index.css @@ -1,6 +1,15 @@ @import "tailwindcss"; @import "@edr/ui-common/theme.css" layer(theme); +:root { + --freight-brand: #15803d; + --freight-brand-dark: #166534; + --freight-brand-light: #22c55e; + --freight-brand-muted: #f0fdf4; + --freight-brand-border: #bbf7d0; + --freight-brand-ring: rgb(21 128 61 / 0.2); +} + html, body, #root { diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 6bf5df01c..13e522996 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -14,6 +14,9 @@ "dependencies": { "@edr/types": "workspace:*", "@edr/ui-common": "workspace:*", + "@mantine/core": "^9.3.0", + "@mantine/hooks": "^9.3.0", + "@tabler/icons-react": "^3.44.0", "@hello-pangea/dnd": "^18.0.1", "@tanstack/react-query": "^5.100.11", "@tria-plc/iamui-common": "1.1.2", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f0d47b2e3..ea724b31e 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -35,16 +35,16 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import TrainsPage from "./pages/trains/TrainsPage"; -import { - CargoesCrudPage, - ContainersCrudPage, - LocomotivesCrudPage, - TrainMasterDataPage, - WagonsCrudPage, -} from "./pages/fleet/FleetCrudPages"; -import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; -import TrainDetailPage from "./pages/trains/TrainDetailPage"; -import RoutesPage from "./pages/fleet/RoutesPage"; +import { + CargoesCrudPage, + ContainersCrudPage, + LocomotivesCrudPage, + TrainMasterDataPage, + WagonsCrudPage, +} from "./pages/fleet/FleetCrudPages"; +import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; +import TrainDetailPage from "./pages/trains/TrainDetailPage"; +import RoutesPage from "./pages/fleet/RoutesPage"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { @@ -56,41 +56,41 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/overview", icon: , }, - { - label: "Booking requests", - href: "/dashboard/booking-requests", - icon: , - }, - ...demoItems, - ], - }, - { - title: "Operations", - items: [ - { - label: "Train Schedules", - href: "/dashboard/operations/train-scheduling", - icon: , - }, - ], - }, - { - title: "Fleet Management", - items: [ - { - label: "Routes", - href: "/dashboard/routes", - icon: , - }, - { - label: "Locomotives", - href: "/dashboard/locomotives", - icon: , - }, - { - label: "Trains", - href: "/dashboard/trains", - icon: , + { + label: "Booking requests", + href: "/dashboard/booking-requests", + icon: , + }, + ...demoItems, + ], + }, + { + title: "Operations", + items: [ + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling", + icon: , + }, + ], + }, + { + title: "Fleet Management", + items: [ + { + label: "Routes", + href: "/dashboard/routes", + icon: , + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + }, + { + label: "Trains", + href: "/dashboard/trains", + icon: , }, { label: "Wagons", @@ -240,10 +240,12 @@ const App = () => { path="booking-requests/:id/contract" element={} /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx index 293c17e2a..25b3816b7 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { Check, ShieldCheck } from "lucide-react"; +import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core"; import { BookingConfirmDialog } from "./BookingConfirmDialog"; import { useAuth } from "@/auth/useAuth"; @@ -11,9 +12,7 @@ import { } from "@/features/bookings/booking-actions.config"; import type { useBookingMutations } from "@/hooks/bookings/useBookings"; import type { BookingApprovalStep, BookingDetail } from "@/types/booking"; -import { bookingGlass, bookingSurface } from "./booking-ui.styles"; -import { Badge, Button } from "@edr/ui-common"; -import { cn } from "@/lib/utils"; +import { SectionCard } from "./detail/SectionCard"; type Mutations = ReturnType; @@ -26,23 +25,16 @@ interface ApprovalStepsCardProps { export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) { const { user } = useAuth(); const [confirmOpen, setConfirmOpen] = useState(false); - const [pendingStep, setPendingStep] = useState( - null, - ); + const [pendingStep, setPendingStep] = useState(null); const steps = useMemo( - () => - [...(booking.approvalSteps ?? [])].sort( - (a, b) => a.stepOrder - b.stepOrder, - ), + () => [...(booking.approvalSteps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder), [booking.approvalSteps], ); const nextPending = getNextPendingApprovalStep(steps); const summary = formatApprovalProgress(booking.status, steps); - const pendingAction = pendingStep - ? buildApproveActionForStep(pendingStep) - : null; + const pendingAction = pendingStep ? buildApproveActionForStep(pendingStep) : null; const openApprove = (step: BookingApprovalStep) => { setPendingStep(step); @@ -62,54 +54,60 @@ export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps ); }; + const subtitle = + summary.detail || + (nextPending + ? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}` + : steps.length + ? "All steps complete" + : "Accept submission to begin"); + return ( <> -
-
-
- -
-
-

- Approval chain -

-

- {summary.detail || - (nextPending - ? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}` - : steps.length - ? "All steps complete" - : "Accept submission to begin")} -

-
-
+ + {steps.filter((s) => s.status === "APPROVED").length}/{steps.length} + + } + > + + {subtitle} + -
- {steps.length === 0 ? ( -

- Use{" "} - - Accept for approval - {" "} - in staff actions to instantiate steps. -

- ) : ( -
    - {steps.map((step) => ( - - ))} -
- )} -
-
+ {steps.length === 0 ? ( + + Use Accept for approval in staff actions to instantiate steps. + + ) : ( + + {steps.map((step) => ( + + ))} + + )} + void; }) { const canApprove = canActOnApprovalStep(user, step, steps); - const statusStyles = + const statusColor = step.status === "APPROVED" - ? "border-emerald-500/25 bg-emerald-500/10 text-black" + ? "green" : step.status === "REJECTED" - ? "bg-red-500/10 text-red-800 dark:text-red-300" + ? "red" : isNext - ? "border-emerald-500/25 bg-emerald-500/10 text-black" - : "bg-muted/40 text-muted-foreground"; + ? "green" + : "gray"; return ( -
  • -
    - + {step.stepOrder} - -
    -

    + + + {step.requiredRole} -

    + {step.remarks && ( -

    + {step.remarks} -

    + )} -
    -
    -
    + + + {canApprove && ( )} - + {step.status} -
    -
  • + + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx index e4e1d8db9..49950d97b 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -1,10 +1,6 @@ import { useNavigate } from "react-router-dom"; -import { - ChevronRight, - ExternalLink, - Loader2, - MoreHorizontal, -} from "lucide-react"; +import { ChevronRight, ExternalLink, MoreHorizontal } from "lucide-react"; +import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core"; import { BookingConfirmDialog } from "./BookingConfirmDialog"; import { useBookingActionDialog } from "./useBookingActionDialog"; @@ -16,16 +12,6 @@ import { type BookingActionContext, } from "@/features/bookings/booking-actions.config"; import type { BookingListRow } from "@/types/booking"; -import { cn } from "@/lib/utils"; -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@edr/ui-common"; interface BookingActionsMenuProps { row: BookingListRow; @@ -39,7 +25,6 @@ interface BookingActionsMenuProps { export function BookingActionsMenu({ row, variant = "table", - className, onSuppressRowClick, }: BookingActionsMenuProps) { const navigate = useNavigate(); @@ -57,184 +42,179 @@ export function BookingActionsMenu({ const goToContract = () => navigate(`/dashboard/booking-requests/${row.id}/contract`); - const hasMenu = listRowHasActions(row, user); + const handleAction = (action: (typeof actions)[number]) => { + onSuppressRowClick?.(); + if (isContractNavAction(action.id)) { + goToContract(); + } else { + flow.openAction(action); + } + }; + const hasMenu = listRowHasActions(row, user); const primary = actions.find((a) => a.primary) ?? actions[0]; if (!hasMenu && variant === "table") { return ( - + + + ); + } + + // Toolbar: lay every action out as a button row. + if (variant === "toolbar" && actions.length > 0) { + return ( + <> + + {actions.map((action) => { + const Icon = action.icon; + const destructive = action.variant === "destructive"; + return ( + + ); + })} + + + ); } return ( - <> -
    e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - {variant === "table" && primary && ( - + )} + + + + - - {primary.shortLabel} - - )} - - {variant === "toolbar" && actions.length > 0 ? ( -
    - {actions.map((action) => { - const Icon = action.icon; - return ( - - ); - })} -
    - ) : ( - - - - - - + +
    +
    + + + {row.reference} - - - {actions.map((action) => { - const Icon = action.icon; - return ( - { - event.preventDefault(); - onSuppressRowClick?.(); - if (isContractNavAction(action.id)) { - goToContract(); - } else { - flow.openAction(action); - } - }} - > - - {action.label} - - ); - })} - {actions.length > 0 && } - { - event.preventDefault(); - onSuppressRowClick?.(); - navigate(`/dashboard/booking-requests/${row.id}`); - }} - > - - Open full details - - - - )} -
    + + + {actions.map((action) => { + const Icon = action.icon; + return ( + } + onClick={() => handleAction(action)} + > + {action.label} + + ); + })} + {actions.length > 0 && } + } + onClick={() => { + onSuppressRowClick?.(); + navigate(`/dashboard/booking-requests/${row.id}`); + }} + > + Open full details + + + - { - if (!open) onSuppressRowClick?.(); - flow.setDialogOpen(open); - }} - action={pendingAction} - reference={flow.mergedContext.reference} - inputValue={flow.inputValue} - onInputChange={flow.setInputValue} - selectedFile={flow.selectedFile} - onFileChange={flow.setSelectedFile} - onConfirm={() => { - onSuppressRowClick?.(); - flow.runAction(); - }} - isPending={mutations.isPending || flow.detailLoading} - confirmDisabled={flow.confirmDisabled} - extra={ - flow.detailLoading ? ( -

    - - Loading approval steps… -

    - ) : pendingAction?.id === "approve" && - !getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? ( -

    - No pending approval step. Refresh the page after staff accept, or - reject the booking. -

    - ) : null - } - /> - + + + ); +} + +function ActionDialog({ + flow, + pendingAction, + onSuppressRowClick, +}: { + flow: ReturnType; + pendingAction: ReturnType["pendingAction"]; + onSuppressRowClick?: () => void; +}) { + return ( + { + if (!open) onSuppressRowClick?.(); + flow.setDialogOpen(open); + }} + action={pendingAction} + reference={flow.mergedContext.reference} + inputValue={flow.inputValue} + onInputChange={flow.setInputValue} + selectedFile={flow.selectedFile} + onFileChange={flow.setSelectedFile} + onConfirm={() => { + onSuppressRowClick?.(); + flow.runAction(); + }} + isPending={flow.mutations.isPending || flow.detailLoading} + confirmDisabled={flow.confirmDisabled} + extra={ + flow.detailLoading ? ( + + Loading approval steps… + + ) : pendingAction?.id === "approve" && + !getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? ( + + No pending approval step. Refresh the page after staff accept, or reject the + booking. + + ) : null + } + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx index 006c57e11..e451f2d2c 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx @@ -1,12 +1,11 @@ -import { Download, Zap } from "lucide-react"; +import { Download, Zap, FileText, Clock } from "lucide-react"; +import { Stack, Text, Button } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; import { BookingActionsMenu } from "./BookingActionsMenu"; -import { bookingSurface } from "./booking-ui.styles"; +import { SectionCard } from "./detail/SectionCard"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import type { useBookingMutations } from "@/hooks/bookings/useBookings"; -import { Button } from "@edr/ui-common"; -import { cn } from "@/lib/utils"; type Mutations = ReturnType; @@ -16,10 +15,7 @@ interface BookingActionsToolbarProps { } /** Detail-page actions: primary toolbar + downloads. */ -export function BookingActionsToolbar({ - booking, - mutations, -}: BookingActionsToolbarProps) { +export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) { const row = toBookingListRow(booking); const { status } = booking; @@ -33,50 +29,84 @@ export function BookingActionsToolbar({ URL.revokeObjectURL(url); }; - if ( - status === "REJECTED" || - status === "CANCELLED" || - status === "COMPLETED" - ) { + if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") { return null; } if (status === "CHANGES_REQUESTED") { return ( - - {booking.latestChangeRequestNote && ( -

    - {booking.latestChangeRequestNote} -

    - )} -
    + + + + No staff actions until resubmit. + + {booking.latestChangeRequestNote && ( + + {booking.latestChangeRequestNote} + + )} + + ); } if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) { return ( - + + + Monitor until the customer or system advances status. + + + ); + } + + if ( + ["FULLY_EXECUTED", "PNR_GENERATED", "PAYMENT_VERIFICATION_IN_PROGRESS"].includes( + status, + ) + ) { + return ( + + + + + Payment is completed by the customer. The booking status updates + automatically once payment is confirmed, then moves to Operations. + + {status === "FULLY_EXECUTED" && ( + + )} + + + ); } return ( -
    - - - + + + + + Confirm each step before it is applied. + + + + {status === "CONTRACT_READY" && ( - + - + )} -
    - ); -} - -function PanelShell({ - title, - description, - children, - muted, -}: { - title: string; - description: string; - children: React.ReactNode; - muted?: boolean; -}) { - return ( -
    -
    -
    - -
    -
    -

    {title}

    -

    {description}

    -
    -
    -
    {children}
    -
    + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx index 69d01692b..be051371c 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx @@ -14,7 +14,7 @@ export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCell

    {summary.label} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx index 190fe4475..3eae09b49 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx @@ -1,17 +1,16 @@ -import { Loader2 } from "lucide-react"; +import type { ReactNode } from "react"; +import { + Modal, + Group, + Stack, + Text, + Box, + Button, + Textarea, + FileInput, +} from "@mantine/core"; import type { BookingActionDef } from "@/features/bookings/booking-actions.config"; -import { cn } from "@/lib/utils"; -import { - Button, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - Textarea, -} from "@edr/ui-common"; interface BookingConfirmDialogProps { open: boolean; @@ -25,7 +24,7 @@ interface BookingConfirmDialogProps { onConfirm: () => void; isPending: boolean; confirmDisabled?: boolean; - extra?: React.ReactNode; + extra?: ReactNode; } export function BookingConfirmDialog({ @@ -45,129 +44,119 @@ export function BookingConfirmDialog({ if (!action || !action.confirmTitle) return null; const Icon = action.icon; - const needsTextInput = - action.input === "note" || action.input === "reason"; + const needsTextInput = action.input === "note" || action.input === "reason"; const needsFileInput = action.input === "file"; const inputMissing = - (needsTextInput && !inputValue.trim()) || - (needsFileInput && !selectedFile); + (needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile); const isDestructive = action.variant === "destructive"; - - const preventClickThrough = (event: React.MouseEvent) => { - event.preventDefault(); - }; + const accent = isDestructive ? "red" : "green"; return ( -

    - event.preventDefault()} + onOpenChange(false)} + withCloseButton={false} + centered + radius="md" + size="md" + padding={0} + title={null} + > + {/* Header */} + -
    - -
    -
    - -
    -
    - - {action.confirmTitle} - - {reference && ( -

    - {reference} -

    - )} -
    -
    - - {action.confirmDescription} - -
    -
    - -
    - {needsTextInput && ( -
    - -