From 88df20be6b38766cade3ec22b7fc6a43a6e8bbbc Mon Sep 17 00:00:00 2001 From: marshal Date: Mon, 8 Jun 2026 10:04:23 +0300 Subject: [PATCH 001/100] change price logic on the ,rule engine ui, auto generate the contrat --- apps/edr-freight-api/src/app.module.ts | 2 + .../bookings/booking-next-step.util.ts | 5 + .../bookings/booking-pricing.service.ts | 225 ++++--- .../bookings/booking-transition.service.ts | 118 +++- .../modules/bookings/bookings.controller.ts | 30 +- .../modules/bookings/bookings.repository.ts | 20 + .../src/modules/bookings/bookings.service.ts | 66 +++ .../dto/submit-booking-response.dto.ts | 29 + .../bookings/entities/booking.entity.ts | 1 + .../overview/dto/overview-query.dto.ts | 17 + .../overview/dto/overview-response.dto.ts | 104 ++++ .../overview/dto/overview-tab-response.dto.ts | 131 +++++ .../modules/overview/overview.constants.ts | 26 + .../modules/overview/overview.controller.ts | 74 +++ .../src/modules/overview/overview.module.ts | 34 ++ .../modules/overview/overview.repository.ts | 553 ++++++++++++++++++ .../src/modules/overview/overview.service.ts | 210 +++++++ .../controllers/approval-rules.controller.ts | 18 + .../controllers/cargo-types.controller.ts | 18 + .../controllers/container-types.controller.ts | 18 + .../controllers/service-types.controller.ts | 18 + .../controllers/yards.controller.ts | 18 + .../dto/create-approval-rule.dto.ts | 12 +- .../rule-engine/dto/create-cargo-type.dto.ts | 5 + .../dto/create-container-type.dto.ts | 7 +- .../dto/create-service-type.dto.ts | 7 +- .../rule-engine/dto/create-yard.dto.ts | 7 +- .../modules/rule-engine/dto/move-order.dto.ts | 8 + .../rule-engine/dto/reorder-items.dto.ts | 17 + .../modules/rule-engine/rule-engine.module.ts | 2 + .../rule-engine/rule-engine.service.ts | 20 +- .../services/approval-rules.service.ts | 36 +- .../services/cargo-types.service.ts | 22 +- .../services/container-types.service.ts | 22 +- .../services/display-order.service.ts | 175 ++++++ .../rule-engine/services/rates.service.ts | 2 +- .../services/service-types.service.ts | 22 +- .../rule-engine/services/yards.service.ts | 22 +- .../overview/OverviewBookingTrendChart.tsx | 65 ++ .../overview/OverviewDonutChart.tsx | 70 +++ .../overview/OverviewHorizontalBarChart.tsx | 81 +++ .../components/overview/OverviewKpiCard.tsx | 70 +++ .../overview/OverviewKpiSection.tsx | 167 ++++++ .../components/overview/OverviewKpiStrip.tsx | 34 ++ .../overview/OverviewPageHeader.tsx | 69 +++ .../overview/OverviewPaymentChart.tsx | 79 +++ .../overview/OverviewQuickLinks.tsx | 72 +++ .../overview/OverviewRecentBookingsTable.tsx | 78 +++ .../overview/OverviewStatusChart.tsx | 69 +++ .../overview/OverviewTabContent.tsx | 102 ++++ .../components/overview/overview.styles.ts | 24 + .../overview/tabs/OverviewBillingTabPanel.tsx | 137 +++++ .../tabs/OverviewBookingsTabPanel.tsx | 101 ++++ .../tabs/OverviewCustomersTabPanel.tsx | 120 ++++ .../tabs/OverviewOperationsTabPanel.tsx | 88 +++ .../overview/tabs/OverviewStaffTabPanel.tsx | 118 ++++ .../ManageRuleEngineOrderDialog.tsx | 354 +++++++++++ .../ruleEngine/RuleEngineCardGrid.tsx | 37 +- .../ruleEngine/RuleEngineFormDialog.tsx | 28 + .../ruleEngine/RuleEngineListFooter.tsx | 73 +++ .../ruleEngine/RuleEngineOrderControls.tsx | 60 ++ .../ruleEngine/RuleEngineToolbar.tsx | 63 +- .../ruleEngine/ruleEngineOrder.utils.ts | 32 + .../backoffice/src/constants/QUERY_KEYS.ts | 12 + .../backoffice/src/constants/URLS.ts | 13 +- .../src/hooks/rule-engine/useRuleEngine.ts | 46 ++ .../backoffice/src/hooks/useOverview.ts | 52 ++ .../src/pages/dashboard/OverviewPage.tsx | 191 +++++- .../ruleEngine/RuleEngineResourcePage.tsx | 172 ++++-- .../src/pages/ruleEngine/config/resources.ts | 23 +- .../backoffice/src/services/api.ts | 24 + .../src/services/overview.service.ts | 56 ++ .../services/ruleEngine/ruleEngine.service.ts | 54 +- .../backoffice/src/types/overview.ts | 24 + packages/types/src/freight/index.ts | 1 + packages/types/src/freight/overview.ts | 152 +++++ 76 files changed, 4907 insertions(+), 225 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/submit-booking-response.dto.ts create mode 100644 apps/edr-freight-api/src/modules/overview/dto/overview-query.dto.ts create mode 100644 apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts create mode 100644 apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts create mode 100644 apps/edr-freight-api/src/modules/overview/overview.constants.ts create mode 100644 apps/edr-freight-api/src/modules/overview/overview.controller.ts create mode 100644 apps/edr-freight-api/src/modules/overview/overview.module.ts create mode 100644 apps/edr-freight-api/src/modules/overview/overview.repository.ts create mode 100644 apps/edr-freight-api/src/modules/overview/overview.service.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/move-order.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/reorder-items.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/display-order.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewBookingTrendChart.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewDonutChart.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewHorizontalBarChart.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiSection.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewQuickLinks.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewStatusChart.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/overview.styles.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewCustomersTabPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewStaffTabPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/ruleEngine/ManageRuleEngineOrderDialog.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineListFooter.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineOrderControls.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineOrder.utils.ts create mode 100644 apps/edr-freight-web/backoffice/src/hooks/useOverview.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/overview.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/types/overview.ts create mode 100644 packages/types/src/freight/overview.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index b6898322b..b753cfc99 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -48,6 +48,7 @@ import { WagonsModule } from './modules/wagons/wagons.module'; import { ContainersModule } from './modules/container-management/containers.module'; import { CargoesModule } from './modules/cargoes/cargoes.module'; import { RoutesModule } from './modules/routes/routes.module'; +import { OverviewModule } from './modules/overview/overview.module'; @Module({ imports: [ @@ -100,6 +101,7 @@ import { RoutesModule } from './modules/routes/routes.module'; ContainersModule, CargoesModule, RoutesModule, + OverviewModule, ], providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder], }) 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 c591a1db4..b79c4ef20 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 @@ -14,6 +14,11 @@ export function computeNextStep( const { status } = booking; switch (status) { + case 'PRICE_CHANGED_PENDING_CONFIRM': + return { + action: 'CONFIRM_SUBMIT', + description: 'Price has changed since preview; confirm to submit booking', + }; case 'SUBMITTED': return { action: 'ACCEPT_INTAKE', diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 4e47456e0..d0b74dfc8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -14,6 +14,24 @@ import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; +export interface ComputedPriceResult { + lineItems: PriceLineItemDto[]; + totalAmount: number; + currency: string; + usedRates: Rate[]; + appliedModifiers: AppliedCargoModifier[]; + priorityScore: number; + warnings: string[]; + hardBlocked: string[]; +} + +type StoredPricingBreakdown = { + lineItems?: PriceLineItemDto[]; + totalAmount?: number; + currency?: string; + generatedAt?: string; +} | null; + @Injectable() export class BookingPricingService { constructor( @@ -26,22 +44,56 @@ export class BookingPricingService { async generatePrice(bookingId: string): Promise { const booking = await this.requireBooking(bookingId); - assertBookingStatus(booking, ['DRAFT']); + assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']); + const computed = await this.computePriceForBooking(booking); + this.ruleEngineService.assertNoHardBlocks({ + priorityScore: computed.priorityScore, + appliedModifiers: computed.appliedModifiers, + containerWeightResults: [], + warnings: computed.warnings, + hardBlocked: computed.hardBlocked, + requiresDirectorApproval: false, + }); + + await this.bookingsRepository.update(bookingId, { + totalAmount: computed.totalAmount, + priorityScore: computed.priorityScore, + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + } as never); + + return { + bookingId, + totalAmount: computed.totalAmount, + currency: computed.currency, + lineItems: computed.lineItems, + warnings: computed.warnings, + }; + } + + async computePriceForBooking(booking: Booking): Promise { const evalInput = await this.buildEvalInputForBooking(booking); - console.log('evalInput----', evalInput); const ruleResult = await this.ruleEngineService.evaluate(evalInput); - this.ruleEngineService.assertNoHardBlocks(ruleResult); const lineItems: PriceLineItemDto[] = []; let total = 0; - const baseLines = await this.computeBaseRailLines(booking, evalInput); + const { lineItems: baseLines, usedRates: baseRates } = + await this.computeBaseRailLinesWithRates(booking, evalInput); for (const line of baseLines) { lineItems.push(line); total += line.amount; } + const liveRates = await this.ratesService.findLiveRates(); + const rateById = new Map(liveRates.map((r) => [r.id, r])); + const usedRatesMap = new Map(baseRates.map((r) => [r.id, r])); + for (const mod of ruleResult.appliedModifiers) { const item: PriceLineItemDto = { code: mod.surchargeTypeCode, @@ -51,30 +103,60 @@ export class BookingPricingService { }; lineItems.push(item); total += mod.calculatedAmount; + + const rate = rateById.get(mod.rateId); + if (rate) usedRatesMap.set(rate.id, rate); } - await this.persistPriceRun(bookingId, ruleResult.appliedModifiers, total); - - await this.bookingsRepository.update(bookingId, { - totalAmount: total, - priorityScore: ruleResult.priorityScore, - pricingBreakdown: { - lineItems, - totalAmount: total, - currency: booking.paymentCurrency, - generatedAt: new Date().toISOString(), - }, - } as never); - return { - bookingId, + lineItems, totalAmount: total, currency: booking.paymentCurrency, - lineItems, + usedRates: [...usedRatesMap.values()], + appliedModifiers: ruleResult.appliedModifiers, + priorityScore: ruleResult.priorityScore, warnings: ruleResult.warnings, + hardBlocked: ruleResult.hardBlocked, }; } + pricesMatch(stored: StoredPricingBreakdown, computed: ComputedPriceResult): boolean { + if (!stored?.lineItems?.length) return false; + if (Number(stored.totalAmount) !== computed.totalAmount) return false; + return ( + this.lineItemsSignature(stored.lineItems) === + this.lineItemsSignature(computed.lineItems) + ); + } + + async createPricingSnapshots( + bookingId: string, + usedRates: Rate[], + appliedModifiers: AppliedCargoModifier[], + ): Promise { + await this.bookingsRepository.clearPricingArtifacts(bookingId); + const snapshots = await this.ruleEngineService.snapshotRates(bookingId, usedRates); + + const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id])); + const rows = appliedModifiers + .map((m) => { + const snapshotId = snapshotByRateId.get(m.rateId); + if (!snapshotId) return null; + return { + bookingId, + surchargeTypeId: m.surchargeTypeId, + triggerValue: m.triggerValue, + calculatedAmount: m.calculatedAmount, + rateSnapshotId: snapshotId, + }; + }) + .filter((r): r is NonNullable => r !== null); + + if (rows.length > 0) { + await this.bookingsRepository.createCargoModifiers(rows); + } + } + async buildEvalInputForBooking(booking: Booking): Promise { const containers = await Promise.all( (booking.bookingContainers ?? []).map(async (bc) => { @@ -115,11 +197,7 @@ export class BookingPricingService { totalAmount: number; currency: string; }> { - const stored = booking.pricingBreakdown as { - lineItems?: PriceLineItemDto[]; - totalAmount?: number; - currency?: string; - } | null; + const stored = booking.pricingBreakdown as StoredPricingBreakdown; if (stored?.lineItems?.length) { return { @@ -129,41 +207,28 @@ export class BookingPricingService { }; } - const evalInput = await this.buildEvalInputForBooking(booking); - const ruleResult = await this.ruleEngineService.evaluate(evalInput); - const lineItems: PriceLineItemDto[] = []; - let total = 0; + const computed = await this.computePriceForBooking(booking); - const baseLines = await this.computeBaseRailLines(booking, evalInput); - for (const line of baseLines) { - lineItems.push(line); - total += line.amount; - } - - for (const mod of ruleResult.appliedModifiers) { - lineItems.push({ - code: mod.surchargeTypeCode, - description: `Surcharge: ${mod.surchargeTypeCode}`, - amount: mod.calculatedAmount, - currency: mod.currency, - }); - total += mod.calculatedAmount; - } - - if (lineItems.length === 0) { - total = Number(booking.totalAmount); - lineItems.push({ - code: 'TOTAL', - description: 'Contract total', - amount: total, + if (computed.lineItems.length === 0) { + const total = Number(booking.totalAmount); + return { + lineItems: [ + { + code: 'TOTAL', + description: 'Contract total', + amount: total, + currency: booking.paymentCurrency, + }, + ], + totalAmount: total, currency: booking.paymentCurrency, - }); + }; } return { - lineItems, - totalAmount: total || Number(booking.totalAmount), - currency: booking.paymentCurrency, + lineItems: computed.lineItems, + totalAmount: computed.totalAmount || Number(booking.totalAmount), + currency: computed.currency, }; } @@ -190,14 +255,14 @@ export class BookingPricingService { return score; } - private async computeBaseRailLines( + private async computeBaseRailLinesWithRates( booking: Booking, evalInput: BookingEvaluationInput, - ): Promise { + ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> { const liveRates = await this.ratesService.findLiveRates(); const currency = booking.paymentCurrency; const isBulk = booking.freightType === 'BULK'; -console.log('liveRates----', liveRates); + const rateType = booking.tradeDirection === 'IMPORT' ? isBulk @@ -209,18 +274,15 @@ console.log('liveRates----', liveRates); : 'CONTAINER_EXPORT' : 'INTERCITY_CONTAINER'; - - console.log('rateType----', rateType); - const lines: PriceLineItemDto[] = []; + const usedRatesMap = new Map(); const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); for (const container of evalInput.containers) { - console.log('container----', container); const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency); - console.log('rate----', rate); if (!rate) continue; + usedRatesMap.set(rate.id, rate); const amount = this.amountForRate(rate, container.quantity, wagonCount); lines.push({ code: rateType, @@ -235,6 +297,7 @@ console.log('liveRates----', liveRates); (r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE', ); if (fallback) { + usedRatesMap.set(fallback.id, fallback); const amount = this.amountForRate(fallback, 1, wagonCount); lines.push({ code: rateType, @@ -245,7 +308,7 @@ console.log('liveRates----', liveRates); } } - return lines; + return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; } private pickRate( @@ -281,31 +344,15 @@ console.log('liveRates----', liveRates); } } - private async persistPriceRun( - bookingId: string, - modifiers: AppliedCargoModifier[], - _total: number, - ): Promise { - await this.bookingsRepository.clearPricingArtifacts(bookingId); - const snapshots = await this.ruleEngineService.snapshotLiveRates(bookingId); - - const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id])); - const rows = modifiers - .map((m) => { - const snapshotId = snapshotByRateId.get(m.rateId); - if (!snapshotId) return null; - return { - bookingId, - surchargeTypeId: m.surchargeTypeId, - triggerValue: m.triggerValue, - calculatedAmount: m.calculatedAmount, - rateSnapshotId: snapshotId, - }; - }) - .filter((r): r is NonNullable => r !== null); - - if (rows.length > 0) { - await this.bookingsRepository.createCargoModifiers(rows); - } + private lineItemsSignature(items: PriceLineItemDto[]): string { + return JSON.stringify( + [...items] + .map((item) => ({ + code: item.code, + amount: item.amount, + currency: item.currency, + })) + .sort((a, b) => a.code.localeCompare(b.code)), + ); } } 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 0fdfc5084..9974b807a 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 @@ -8,6 +8,8 @@ import { BookingPricingService } from './booking-pricing.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; import { computeNextStep, type BookingNextStep } from './booking-next-step.util'; +import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; +import { PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { BookingsService } from './bookings.service'; @@ -22,7 +24,7 @@ export class BookingTransitionService { private readonly bookingsService: BookingsService, ) {} - async submit(bookingId: string): Promise { + async submit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']); @@ -32,14 +34,119 @@ export class BookingTransitionService { ); } - const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); - await this.ruleEngineService.snapshotLiveRates(bookingId); + const computed = await this.pricingService.computePriceForBooking(booking); + this.ruleEngineService.assertNoHardBlocks({ + priorityScore: computed.priorityScore, + appliedModifiers: computed.appliedModifiers, + containerWeightResults: [], + warnings: computed.warnings, + hardBlocked: computed.hardBlocked, + requiresDirectorApproval: false, + }); + const stored = booking.pricingBreakdown as { + lineItems?: PriceLineItemDto[]; + totalAmount?: number; + } | null; + const unchanged = this.pricingService.pricesMatch(stored, computed); + const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + + if (unchanged) { + await this.pricingService.createPricingSnapshots( + bookingId, + computed.usedRates, + computed.appliedModifiers, + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'SUBMITTED', + priorityScore, + } as never); + + const finalBooking = await this.bookingsService.findById(updated!.id); + return { + bookingId: finalBooking.id, + status: finalBooking.status, + priceChanged: false, + totalAmount: Number(finalBooking.totalAmount), + currency: finalBooking.paymentCurrency, + lineItems: computed.lineItems, + }; + } + + const previousTotalAmount = Number(booking.totalAmount); + await this.bookingsRepository.update(bookingId, { + totalAmount: computed.totalAmount, + priorityScore: computed.priorityScore, + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + status: 'PRICE_CHANGED_PENDING_CONFIRM', + } as never); + + const updatedBooking = await this.bookingsService.findById(bookingId); + return { + bookingId: updatedBooking.id, + status: updatedBooking.status, + priceChanged: true, + previousTotalAmount, + totalAmount: computed.totalAmount, + currency: computed.currency, + lineItems: computed.lineItems, + message: 'Price has changed since preview. Confirm to submit with the updated price.', + }; + } + + async confirmSubmit(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']); + + if (Number(booking.totalAmount) <= 0) { + throw new BadRequestException('No price to confirm'); + } + + const computed = await this.pricingService.computePriceForBooking(booking); + this.ruleEngineService.assertNoHardBlocks({ + priorityScore: computed.priorityScore, + appliedModifiers: computed.appliedModifiers, + containerWeightResults: [], + warnings: computed.warnings, + hardBlocked: computed.hardBlocked, + requiresDirectorApproval: false, + }); + + await this.pricingService.createPricingSnapshots( + bookingId, + computed.usedRates, + computed.appliedModifiers, + ); + + const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); const updated = await this.bookingsRepository.update(bookingId, { status: 'SUBMITTED', priorityScore, + totalAmount: computed.totalAmount, + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, } as never); - return this.bookingsService.findById(updated!.id); + + const finalBooking = await this.bookingsService.findById(updated!.id); + return { + bookingId: finalBooking.id, + status: finalBooking.status, + priceChanged: false, + totalAmount: Number(finalBooking.totalAmount), + currency: finalBooking.paymentCurrency, + lineItems: computed.lineItems, + message: 'Booking submitted with confirmed price.', + }; } async requestChanges( @@ -279,6 +386,7 @@ export class BookingTransitionService { assertBookingStatus(booking, [ 'DRAFT', 'SUBMITTED', + 'PRICE_CHANGED_PENDING_CONFIRM', 'CHANGES_REQUESTED', 'PENDING_APPROVAL', 'CONTRACT_READY', @@ -321,4 +429,4 @@ export class BookingTransitionService { nextStep, }; } -} +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index ccd598577..50cf3f345 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -39,6 +39,7 @@ import { CreateBookingDto } from './dto/create-booking.dto'; import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; import { FilterBookingDto } from './dto/filter-booking.dto'; import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; +import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { ApproveStepDto, CancelBookingDto, @@ -165,17 +166,36 @@ export class BookingsController { } @Post(':id/generate-price') - @ApiOperation({ summary: 'Generate price preview (DRAFT only)' }) + @ApiOperation({ + summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)', + description: + 'Computes and stores a price preview on the booking. Does not create rate snapshots.', + }) @ApiOkResponse({ type: GeneratePriceResponseDto }) generatePrice(@Param('id', ParseUUIDPipe) id: string) { return this.pricingService.generatePrice(id); } @Post(':id/submit') - @ApiOperation({ summary: 'Customer submit booking' }) - async submit(@Param('id', ParseUUIDPipe) id: string) { - const booking = await this.transitionService.submit(id); - return this.transitionService.enrichBookingResponse(booking); + @ApiOperation({ + summary: 'Customer submit booking', + description: + 'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.', + }) + @ApiOkResponse({ type: SubmitBookingResponseDto }) + submit(@Param('id', ParseUUIDPipe) id: string) { + return this.transitionService.submit(id); + } + + @Post(':id/confirm-submit') + @ApiOperation({ + summary: 'Confirm submit after price change', + description: + 'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.', + }) + @ApiOkResponse({ type: SubmitBookingResponseDto }) + confirmSubmit(@Param('id', ParseUUIDPipe) id: string) { + return this.transitionService.confirmSubmit(id); } @Post(':id/staff/request-changes') diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 9c17eff3e..17664f6ca 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -345,6 +345,26 @@ export class BookingsRepository extends BaseRepository { await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId }); } + async hasPricingArtifacts(bookingId: string): Promise { + const snapshotCount = await this.dataSource + .getRepository(BookingRateSnapshot) + .count({ where: { bookingId } }); + const modifierCount = await this.dataSource + .getRepository(BookingCargoModifier) + .count({ where: { bookingId } }); + return snapshotCount > 0 || modifierCount > 0; + } + + async invalidatePricingPreview(bookingId: string): Promise { + if (await this.hasPricingArtifacts(bookingId)) { + await this.clearPricingArtifacts(bookingId); + } + await this.update(bookingId, { + totalAmount: 0, + pricingBreakdown: null, + } as never); + } + /** Queue listing with optional bulk exclusion for LINE_STAFF. */ async findQueue(options: { status: string | string[]; diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 87e9298ff..4553e7a59 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -348,6 +348,15 @@ export class BookingsService { this.ruleEngineService.assertNoHardBlocks(ruleResult); warnings.push(...ruleResult.warnings); + const pricingFieldsChanged = this.pricingRelevantFieldsChanged( + existing, + dto, + freightType, + cargoTypeId, + allowConsolidation, + containers, + ); + const updates: Record = { ...dto, freightType, @@ -375,6 +384,10 @@ export class BookingsService { ); } + if (pricingFieldsChanged) { + await this.bookingsRepository.invalidatePricingPreview(id); + } + if (files.length > 0) { await this.filesService.uploadMany(id, 'bookings', files); } @@ -648,4 +661,57 @@ export class BookingsService { ), }; } + + private pricingRelevantFieldsChanged( + existing: Booking, + dto: UpdateBookingDto, + freightType: FreightType, + cargoTypeId: string | null | undefined, + allowConsolidation: boolean, + containers: CreateBookingContainerDto[], + ): boolean { + if (dto.freightType !== undefined && dto.freightType !== existing.freightType) { + return true; + } + if (dto.tradeDirection !== undefined && dto.tradeDirection !== existing.tradeDirection) { + return true; + } + if (dto.paymentCurrency !== undefined && dto.paymentCurrency !== existing.paymentCurrency) { + return true; + } + if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) { + return true; + } + if ( + dto.allowConsolidation !== undefined && + dto.allowConsolidation !== existing.allowConsolidation + ) { + return true; + } + if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) { + return true; + } + if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) { + return true; + } + if (dto.containers !== undefined) { + const existingContainers = + existing.bookingContainers?.map((bc) => ({ + containerTypeId: bc.containerTypeId, + quantity: bc.quantity, + vgmPerUnitTons: Number(bc.vgmPerUnitTons), + })) ?? []; + if (JSON.stringify(existingContainers) !== JSON.stringify(containers)) { + return true; + } + } + if ( + freightType !== existing.freightType || + (cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) || + allowConsolidation !== existing.allowConsolidation + ) { + return true; + } + return false; + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/submit-booking-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/submit-booking-response.dto.ts new file mode 100644 index 000000000..2828f0237 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/submit-booking-response.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +import { PriceLineItemDto } from './generate-price-response.dto'; + +export class SubmitBookingResponseDto { + @ApiProperty() + bookingId!: string; + + @ApiProperty() + status!: string; + + @ApiProperty() + priceChanged!: boolean; + + @ApiPropertyOptional() + previousTotalAmount?: number; + + @ApiProperty() + totalAmount!: number; + + @ApiProperty() + currency!: string; + + @ApiPropertyOptional({ type: [PriceLineItemDto] }) + lineItems?: PriceLineItemDto[]; + + @ApiPropertyOptional() + message?: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index fb3679a60..59ab1cb7a 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -17,6 +17,7 @@ import { BookingReviewNote } from './booking-review-note.entity'; export const BOOKING_STATUSES = [ 'DRAFT', 'SUBMITTED', + 'PRICE_CHANGED_PENDING_CONFIRM', 'CHANGES_REQUESTED', 'PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE', diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-query.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-query.dto.ts new file mode 100644 index 000000000..591fb6b6a --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-query.dto.ts @@ -0,0 +1,17 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional } from 'class-validator'; + +const OVERVIEW_RANGES = ['7d', '30d', '90d'] as const; + +export type OverviewRangeQuery = (typeof OVERVIEW_RANGES)[number]; + +export class OverviewQueryDto { + @ApiPropertyOptional({ + enum: OVERVIEW_RANGES, + default: '30d', + description: 'Time range for trend charts', + }) + @IsOptional() + @IsIn(OVERVIEW_RANGES) + range?: OverviewRangeQuery = '30d'; +} diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts new file mode 100644 index 000000000..767a217a6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts @@ -0,0 +1,104 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class OverviewBookingKpisDto { + @ApiProperty() totalActive!: number; + @ApiProperty() needsAction!: number; + @ApiProperty() urgent!: number; + @ApiProperty() inApproval!: number; + @ApiProperty() submittedToday!: number; +} + +export class OverviewOperationsKpisDto { + @ApiProperty() trainsActive!: number; + @ApiProperty() wagonsAvailable!: number; + @ApiProperty() containersInTransit!: number; + @ApiProperty() cargoesLoaded!: number; +} + +export class OverviewCustomerKpisDto { + @ApiProperty() totalCustomers!: number; + @ApiProperty() newCustomersThisMonth!: number; +} + +export class OverviewBillingKpisDto { + @ApiProperty() revenueMtdEtb!: number; + @ApiProperty() revenueMtdUsd!: number; + @ApiProperty() pendingPayments!: number; + @ApiProperty() successfulPaymentsMtd!: number; +} + +export class OverviewStaffKpisDto { + @ApiProperty() activeEmployees!: number; + @ApiProperty() activeUsers!: number; +} + +export class OverviewKpisDto { + @ApiProperty({ type: OverviewBookingKpisDto }) + bookings!: OverviewBookingKpisDto; + + @ApiProperty({ type: OverviewOperationsKpisDto }) + operations!: OverviewOperationsKpisDto; + + @ApiProperty({ type: OverviewCustomerKpisDto }) + customers!: OverviewCustomerKpisDto; + + @ApiProperty({ type: OverviewBillingKpisDto }) + billing!: OverviewBillingKpisDto; + + @ApiProperty({ type: OverviewStaffKpisDto }) + staff!: OverviewStaffKpisDto; +} + +export class OverviewTrendPointDto { + @ApiProperty({ example: '2026-06-01' }) date!: string; + @ApiProperty() count!: number; +} + +export class OverviewStatusCountDto { + @ApiProperty() status!: string; + @ApiProperty() count!: number; +} + +export class OverviewPipelineCountDto { + @ApiProperty() stage!: string; + @ApiProperty() count!: number; +} + +export class OverviewPaymentTrendPointDto { + @ApiProperty({ example: '2026-06-01' }) date!: string; + @ApiProperty() amountEtb!: number; + @ApiProperty() amountUsd!: number; +} + +export class OverviewRecentBookingDto { + @ApiProperty() id!: string; + @ApiProperty() reference!: string; + @ApiProperty() customerLabel!: string; + @ApiProperty() status!: string; + @ApiProperty() priorityScore!: number; + @ApiProperty({ nullable: true }) totalAmount!: number | null; + @ApiProperty({ nullable: true }) paymentCurrency!: string | null; + @ApiProperty() createdAt!: string; +} + +export class OverviewResponseDto { + @ApiProperty({ type: OverviewKpisDto }) + kpis!: OverviewKpisDto; + + @ApiProperty({ type: [OverviewTrendPointDto] }) + bookingTrend!: OverviewTrendPointDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + bookingsByStatus!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewPipelineCountDto] }) + bookingsByPipeline!: OverviewPipelineCountDto[]; + + @ApiProperty({ type: [OverviewPaymentTrendPointDto] }) + paymentTrend!: OverviewPaymentTrendPointDto[]; + + @ApiProperty({ type: [OverviewRecentBookingDto] }) + recentBookings!: OverviewRecentBookingDto[]; + + @ApiProperty() generatedAt!: string; +} diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts new file mode 100644 index 000000000..c19a8baee --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts @@ -0,0 +1,131 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { + OverviewBillingKpisDto, + OverviewBookingKpisDto, + OverviewCustomerKpisDto, + OverviewOperationsKpisDto, + OverviewPaymentTrendPointDto, + OverviewPipelineCountDto, + OverviewRecentBookingDto, + OverviewStaffKpisDto, + OverviewStatusCountDto, + OverviewTrendPointDto, +} from './overview-response.dto'; + +export class OverviewLabelCountDto { + @ApiProperty() label!: string; + @ApiProperty() count!: number; +} + +export class OverviewPaymentMethodDto { + @ApiProperty() method!: string; + @ApiProperty() count!: number; + @ApiProperty() amountEtb!: number; + @ApiProperty() amountUsd!: number; +} + +export class OverviewCurrencyAmountDto { + @ApiProperty() currency!: string; + @ApiProperty() amount!: number; +} + +export class OverviewBookingsTabDto { + @ApiProperty({ type: OverviewBookingKpisDto }) + kpis!: OverviewBookingKpisDto; + + @ApiProperty({ type: [OverviewTrendPointDto] }) + bookingTrend!: OverviewTrendPointDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + bookingsByStatus!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewPipelineCountDto] }) + bookingsByPipeline!: OverviewPipelineCountDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + bookingsByFreightType!: OverviewLabelCountDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + bookingsByCurrency!: OverviewLabelCountDto[]; + + @ApiProperty({ type: [OverviewRecentBookingDto] }) + recentBookings!: OverviewRecentBookingDto[]; + + @ApiProperty() + generatedAt!: string; +} + +export class OverviewBillingTabDto { + @ApiProperty({ type: OverviewBillingKpisDto }) + kpis!: OverviewBillingKpisDto; + + @ApiProperty({ type: [OverviewPaymentTrendPointDto] }) + paymentTrend!: OverviewPaymentTrendPointDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + paymentsByStatus!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewPaymentMethodDto] }) + paymentsByMethod!: OverviewPaymentMethodDto[]; + + @ApiProperty({ type: [OverviewCurrencyAmountDto] }) + revenueByCurrency!: OverviewCurrencyAmountDto[]; + + @ApiProperty() + generatedAt!: string; +} + +export class OverviewOperationsTabDto { + @ApiProperty({ type: OverviewOperationsKpisDto }) + kpis!: OverviewOperationsKpisDto; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + trainStatusBreakdown!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + wagonStatusBreakdown!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + containerStatusBreakdown!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + cargoStatusBreakdown!: OverviewStatusCountDto[]; + + @ApiProperty() + generatedAt!: string; +} + +export class OverviewCustomersTabDto { + @ApiProperty({ type: OverviewCustomerKpisDto }) + kpis!: OverviewCustomerKpisDto; + + @ApiProperty({ type: [OverviewTrendPointDto] }) + customerGrowthTrend!: OverviewTrendPointDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + customersByType!: OverviewLabelCountDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + topCustomersByBookings!: OverviewLabelCountDto[]; + + @ApiProperty() + generatedAt!: string; +} + +export class OverviewStaffTabDto { + @ApiProperty({ type: OverviewStaffKpisDto }) + kpis!: OverviewStaffKpisDto; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + usersByStatus!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewTrendPointDto] }) + employeeGrowthTrend!: OverviewTrendPointDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + activeUsersBreakdown!: OverviewLabelCountDto[]; + + @ApiProperty() + generatedAt!: string; +} diff --git a/apps/edr-freight-api/src/modules/overview/overview.constants.ts b/apps/edr-freight-api/src/modules/overview/overview.constants.ts new file mode 100644 index 000000000..fed9a76c7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/overview.constants.ts @@ -0,0 +1,26 @@ +export const OVERVIEW_URGENT_PRIORITY_THRESHOLD = 1000; + +export const OVERVIEW_NEEDS_ACTION_STATUSES = [ + 'SUBMITTED', + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', +] as const; + +export const OVERVIEW_IN_APPROVAL_STATUSES = [ + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', +] as const; + +export const OVERVIEW_CLOSED_STATUSES = [ + 'REJECTED', + 'CANCELLED', + 'COMPLETED', +] as const; + +export const OVERVIEW_RANGE_DAYS = { + '7d': 7, + '30d': 30, + '90d': 90, +} as const; + +export type OverviewRange = keyof typeof OVERVIEW_RANGE_DAYS; diff --git a/apps/edr-freight-api/src/modules/overview/overview.controller.ts b/apps/edr-freight-api/src/modules/overview/overview.controller.ts new file mode 100644 index 000000000..fe545b452 --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/overview.controller.ts @@ -0,0 +1,74 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; + +import { BookingView } from '../../common/booking-guards'; +import { OverviewQueryDto } from './dto/overview-query.dto'; +import { OverviewResponseDto } from './dto/overview-response.dto'; +import { + OverviewBillingTabDto, + OverviewBookingsTabDto, + OverviewCustomersTabDto, + OverviewOperationsTabDto, + OverviewStaffTabDto, +} from './dto/overview-tab-response.dto'; +import { OverviewService } from './overview.service'; + +@ApiTags('Overview') +@ApiBearerAuth() +@Controller('overview') +export class OverviewController { + constructor(private readonly overviewService: OverviewService) {} + + @Get() + @BookingView() + @ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' }) + @ApiOkResponse({ type: OverviewResponseDto }) + getDashboard(@Query() query: OverviewQueryDto): Promise { + return this.overviewService.getDashboard(query.range ?? '30d'); + } + + @Get('bookings') + @BookingView() + @ApiOperation({ summary: 'Bookings tab metrics and charts' }) + @ApiOkResponse({ type: OverviewBookingsTabDto }) + getBookingsTab(@Query() query: OverviewQueryDto): Promise { + return this.overviewService.getBookingsTab(query.range ?? '30d'); + } + + @Get('billing') + @BookingView() + @ApiOperation({ summary: 'Billing tab metrics and charts' }) + @ApiOkResponse({ type: OverviewBillingTabDto }) + getBillingTab(@Query() query: OverviewQueryDto): Promise { + return this.overviewService.getBillingTab(query.range ?? '30d'); + } + + @Get('operations') + @BookingView() + @ApiOperation({ summary: 'Operations tab metrics and charts' }) + @ApiOkResponse({ type: OverviewOperationsTabDto }) + getOperationsTab(): Promise { + return this.overviewService.getOperationsTab(); + } + + @Get('customers') + @BookingView() + @ApiOperation({ summary: 'Customers tab metrics and charts' }) + @ApiOkResponse({ type: OverviewCustomersTabDto }) + getCustomersTab(@Query() query: OverviewQueryDto): Promise { + return this.overviewService.getCustomersTab(query.range ?? '30d'); + } + + @Get('staff') + @BookingView() + @ApiOperation({ summary: 'Staff tab metrics and charts' }) + @ApiOkResponse({ type: OverviewStaffTabDto }) + getStaffTab(@Query() query: OverviewQueryDto): Promise { + return this.overviewService.getStaffTab(query.range ?? '30d'); + } +} diff --git a/apps/edr-freight-api/src/modules/overview/overview.module.ts b/apps/edr-freight-api/src/modules/overview/overview.module.ts new file mode 100644 index 000000000..50893b626 --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/overview.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Employee } from '@tria-plc/iamapi-common'; +import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { Cargo } from '../cargoes/entities/cargoes.entity'; +import { Container } from '../container-management/entities/container.entity'; +import { Customer } from '../customers/entities/customer.entity'; +import { PaymentEntity } from '../payment/entities/payment.entity'; +import { Train } from '../trains/entities/train.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { OverviewController } from './overview.controller'; +import { OverviewRepository } from './overview.repository'; +import { OverviewService } from './overview.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Booking, + PaymentEntity, + Customer, + Train, + Wagon, + Container, + Cargo, + Employee, + User, + ]), + ], + controllers: [OverviewController], + providers: [OverviewService, OverviewRepository], +}) +export class OverviewModule {} diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts new file mode 100644 index 000000000..3275a6a8f --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -0,0 +1,553 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; +import { Employee } from '@tria-plc/iamapi-common'; +import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; +import { Freight } from '@edr/types'; +import { Repository, ObjectLiteral } from 'typeorm'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { Cargo } from '../cargoes/entities/cargoes.entity'; +import { Container } from '../container-management/entities/container.entity'; +import { Customer } from '../customers/entities/customer.entity'; +import { PaymentEntity } from '../payment/entities/payment.entity'; +import { Train } from '../trains/entities/train.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { + OVERVIEW_CLOSED_STATUSES, + OVERVIEW_IN_APPROVAL_STATUSES, + OVERVIEW_NEEDS_ACTION_STATUSES, + OVERVIEW_URGENT_PRIORITY_THRESHOLD, +} from './overview.constants'; + +export type OverviewBookingKpisRow = { + totalActive: number; + needsAction: number; + urgent: number; + inApproval: number; + submittedToday: number; +}; + +export type OverviewRecentBookingRow = { + id: string; + reference: string; + customerLabel: string; + status: string; + priorityScore: number; + totalAmount: number | null; + paymentCurrency: string | null; + createdAt: Date; +}; + +@Injectable() +export class OverviewRepository { + constructor( + @InjectRepository(Booking) + private readonly bookingRepository: Repository, + @InjectRepository(PaymentEntity) + private readonly paymentRepository: Repository, + @InjectRepository(Customer) + private readonly customerRepository: Repository, + @InjectRepository(Train) + private readonly trainRepository: Repository, + @InjectRepository(Wagon) + private readonly wagonRepository: Repository, + @InjectRepository(Container) + private readonly containerRepository: Repository, + @InjectRepository(Cargo) + private readonly cargoRepository: Repository, + @InjectRepository(Employee) + private readonly employeeRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + async getBookingKpis(): Promise { + const row = await this.bookingRepository + .createQueryBuilder('booking') + .select( + `COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`, + 'totalActive', + ) + .addSelect( + `COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`, + 'needsAction', + ) + .addSelect( + `COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`, + 'urgent', + ) + .addSelect( + `COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`, + 'inApproval', + ) + .addSelect( + `COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`, + 'submittedToday', + ) + .where('booking.deleted_at IS NULL') + .setParameters({ + closedStatuses: [...OVERVIEW_CLOSED_STATUSES], + needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES], + inApprovalStatuses: [...OVERVIEW_IN_APPROVAL_STATUSES], + urgentThreshold: OVERVIEW_URGENT_PRIORITY_THRESHOLD, + }) + .getRawOne>(); + + return { + totalActive: Number(row?.totalActive ?? 0), + needsAction: Number(row?.needsAction ?? 0), + urgent: Number(row?.urgent ?? 0), + inApproval: Number(row?.inApproval ?? 0), + submittedToday: Number(row?.submittedToday ?? 0), + }; + } + + async getOperationsKpis(): Promise<{ + trainsActive: number; + wagonsAvailable: number; + containersInTransit: number; + cargoesLoaded: number; + }> { + const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] = + await Promise.all([ + this.trainRepository + .createQueryBuilder('train') + .where('train.deleted_at IS NULL') + .andWhere('train.status IN (:...statuses)', { + statuses: [ + Freight.TrainStatus.InService, + Freight.TrainStatus.Scheduled, + ], + }) + .getCount(), + this.wagonRepository + .createQueryBuilder('wagon') + .where('wagon.deleted_at IS NULL') + .andWhere('wagon.status = :status', { status: 'AVAILABLE' }) + .getCount(), + this.containerRepository + .createQueryBuilder('container') + .where('container.deleted_at IS NULL') + .andWhere('container.status = :status', { status: 'IN_TRANSIT' }) + .getCount(), + this.cargoRepository + .createQueryBuilder('cargo') + .where('cargo.deleted_at IS NULL') + .andWhere('cargo.status IN (:...statuses)', { + statuses: ['LOADED', 'IN_TRANSIT'], + }) + .getCount(), + ]); + + return { trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded }; + } + + async getCustomerKpis(): Promise<{ + totalCustomers: number; + newCustomersThisMonth: number; + }> { + const row = await this.customerRepository + .createQueryBuilder('customer') + .select('COUNT(*)::int', 'totalCustomers') + .addSelect( + `COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`, + 'newCustomersThisMonth', + ) + .where('customer.deleted_at IS NULL') + .getRawOne>(); + + return { + totalCustomers: Number(row?.totalCustomers ?? 0), + newCustomersThisMonth: Number(row?.newCustomersThisMonth ?? 0), + }; + } + + async getBillingKpis(): Promise<{ + revenueMtdEtb: number; + revenueMtdUsd: number; + pendingPayments: number; + successfulPaymentsMtd: number; + }> { + const revenueRow = await this.paymentRepository + .createQueryBuilder('payment') + .select( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, + 'revenueMtdEtb', + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, + 'revenueMtdUsd', + ) + .addSelect(`COUNT(*)::int`, 'successfulPaymentsMtd') + .where('payment.status = :status', { status: 'success' }) + .andWhere( + `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, + ) + .getRawOne>(); + + const pendingPayments = await this.paymentRepository + .createQueryBuilder('payment') + .where('payment.status IN (:...statuses)', { + statuses: ['action-required', 'processing'], + }) + .getCount(); + + return { + revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0), + revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0), + pendingPayments, + successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0), + }; + } + + async getStaffKpis(): Promise<{ activeEmployees: number; activeUsers: number }> { + const [activeEmployees, activeUsers] = await Promise.all([ + this.employeeRepository.count({ + where: { isCurrent: true }, + }), + this.userRepository.count({ + where: { + isActive: true, + status: EUserStatus.ACCEPTED, + }, + }), + ]); + + return { activeEmployees, activeUsers }; + } + + async getBookingTrend(days: number): Promise<{ date: string; count: number }[]> { + const rows = await this.bookingRepository + .createQueryBuilder('booking') + .select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, 'date') + .addSelect('COUNT(*)::int', 'count') + .where('booking.deleted_at IS NULL') + .andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days }) + .groupBy('booking.created_at::date') + .orderBy('booking.created_at::date', 'ASC') + .getRawMany<{ date: string; count: string }>(); + + return rows.map((row) => ({ + date: row.date, + count: Number(row.count), + })); + } + + async getStatusCounts(): Promise> { + const rows = await this.bookingRepository + .createQueryBuilder('booking') + .select('booking.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .where('booking.deleted_at IS NULL') + .groupBy('booking.status') + .getRawMany<{ status: string; count: string }>(); + + return Object.fromEntries( + rows.map((row) => [row.status, Number(row.count)]), + ); + } + + async getPaymentTrend( + days: number, + ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { + const rows = await this.paymentRepository + .createQueryBuilder('payment') + .select( + `to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`, + 'date', + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, + 'amountEtb', + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, + 'amountUsd', + ) + .where('payment.status = :status', { status: 'success' }) + .andWhere( + `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, + { days }, + ) + .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) + .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, 'ASC') + .getRawMany<{ date: string; amountEtb: string; amountUsd: string }>(); + + return rows.map((row) => ({ + date: row.date, + amountEtb: Number(row.amountEtb), + amountUsd: Number(row.amountUsd), + })); + } + + async getRecentBookings(limit: number): Promise { + const rows = await this.bookingRepository + .createQueryBuilder('booking') + .leftJoin('booking.company', 'company') + .select('booking.id', 'id') + .addSelect('booking.reference', 'reference') + .addSelect('COALESCE(company.name, \'—\')', 'customerLabel') + .addSelect('booking.status', 'status') + .addSelect('booking.priority_score', 'priorityScore') + .addSelect('booking.total_amount', 'totalAmount') + .addSelect('booking.payment_currency', 'paymentCurrency') + .addSelect('booking.created_at', 'createdAt') + .where('booking.deleted_at IS NULL') + .orderBy('booking.created_at', 'DESC') + .limit(limit) + .getRawMany<{ + id: string; + reference: string; + customerLabel: string; + status: string; + priorityScore: string; + totalAmount: string | null; + paymentCurrency: string | null; + createdAt: Date; + }>(); + + return rows.map((row) => ({ + id: row.id, + reference: row.reference, + customerLabel: row.customerLabel, + status: row.status, + priorityScore: Number(row.priorityScore), + totalAmount: row.totalAmount != null ? Number(row.totalAmount) : null, + paymentCurrency: row.paymentCurrency, + createdAt: row.createdAt, + })); + } + + async getBookingsByFreightType(): Promise<{ label: string; count: number }[]> { + const rows = await this.bookingRepository + .createQueryBuilder('booking') + .select('booking.freight_type', 'label') + .addSelect('COUNT(*)::int', 'count') + .where('booking.deleted_at IS NULL') + .andWhere("booking.status != 'DRAFT'") + .groupBy('booking.freight_type') + .orderBy('count', 'DESC') + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ + label: row.label, + count: Number(row.count), + })); + } + + async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> { + const rows = await this.bookingRepository + .createQueryBuilder('booking') + .select('booking.payment_currency', 'label') + .addSelect('COUNT(*)::int', 'count') + .where('booking.deleted_at IS NULL') + .andWhere("booking.status != 'DRAFT'") + .groupBy('booking.payment_currency') + .orderBy('count', 'DESC') + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ + label: row.label, + count: Number(row.count), + })); + } + + async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> { + const rows = await this.paymentRepository + .createQueryBuilder('payment') + .select('payment.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .groupBy('payment.status') + .orderBy('count', 'DESC') + .getRawMany<{ status: string; count: string }>(); + + return rows.map((row) => ({ + status: row.status, + count: Number(row.count), + })); + } + + async getPaymentsByMethod(): Promise< + { method: string; count: number; amountEtb: number; amountUsd: number }[] + > { + const rows = await this.paymentRepository + .createQueryBuilder('payment') + .select('payment.method', 'method') + .addSelect('COUNT(*)::int', 'count') + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`, + 'amountEtb', + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`, + 'amountUsd', + ) + .groupBy('payment.method') + .orderBy('count', 'DESC') + .getRawMany<{ method: string; count: string; amountEtb: string; amountUsd: string }>(); + + return rows.map((row) => ({ + method: row.method, + count: Number(row.count), + amountEtb: Number(row.amountEtb), + amountUsd: Number(row.amountUsd), + })); + } + + async getRevenueByCurrency(): Promise<{ currency: string; amount: number }[]> { + const rows = await this.paymentRepository + .createQueryBuilder('payment') + .select('payment.currency', 'currency') + .addSelect('COALESCE(SUM(payment.amount), 0)', 'amount') + .where('payment.status = :status', { status: 'success' }) + .andWhere( + `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, + ) + .groupBy('payment.currency') + .getRawMany<{ currency: string; amount: string }>(); + + return rows.map((row) => ({ + currency: row.currency, + amount: Number(row.amount), + })); + } + + async getTrainStatusBreakdown(): Promise<{ status: string; count: number }[]> { + return this.statusBreakdown(this.trainRepository, 'train'); + } + + async getWagonStatusBreakdown(): Promise<{ status: string; count: number }[]> { + return this.statusBreakdown(this.wagonRepository, 'wagon'); + } + + async getContainerStatusBreakdown(): Promise<{ status: string; count: number }[]> { + return this.statusBreakdown(this.containerRepository, 'container'); + } + + async getCargoStatusBreakdown(): Promise<{ status: string; count: number }[]> { + return this.statusBreakdown(this.cargoRepository, 'cargo'); + } + + private async statusBreakdown( + repository: Repository, + alias: string, + ): Promise<{ status: string; count: number }[]> { + const rows = await repository + .createQueryBuilder(alias) + .select(`${alias}.status`, 'status') + .addSelect('COUNT(*)::int', 'count') + .where(`${alias}.deleted_at IS NULL`) + .groupBy(`${alias}.status`) + .orderBy('count', 'DESC') + .getRawMany<{ status: string; count: string }>(); + + return rows.map((row) => ({ + status: row.status, + count: Number(row.count), + })); + } + + async getCustomerGrowthTrend(days: number): Promise<{ date: string; count: number }[]> { + const rows = await this.customerRepository + .createQueryBuilder('customer') + .select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, 'date') + .addSelect('COUNT(*)::int', 'count') + .where('customer.deleted_at IS NULL') + .andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { days }) + .groupBy('customer.created_at::date') + .orderBy('customer.created_at::date', 'ASC') + .getRawMany<{ date: string; count: string }>(); + + return rows.map((row) => ({ + date: row.date, + count: Number(row.count), + })); + } + + async getCustomersByType(): Promise<{ label: string; count: number }[]> { + const rows = await this.customerRepository + .createQueryBuilder('customer') + .select(`COALESCE(NULLIF(customer.customer_type, ''), 'Unknown')`, 'label') + .addSelect('COUNT(*)::int', 'count') + .where('customer.deleted_at IS NULL') + .groupBy('customer.customer_type') + .orderBy('count', 'DESC') + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ + label: row.label, + count: Number(row.count), + })); + } + + async getTopCustomersByBookings(limit: number): Promise<{ label: string; count: number }[]> { + const rows = await this.bookingRepository + .createQueryBuilder('booking') + .leftJoin('booking.company', 'company') + .select(`COALESCE(company.name, 'Unknown')`, 'label') + .addSelect('COUNT(*)::int', 'count') + .where('booking.deleted_at IS NULL') + .andWhere("booking.status != 'DRAFT'") + .groupBy('company.name') + .orderBy('count', 'DESC') + .limit(limit) + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ + label: row.label, + count: Number(row.count), + })); + } + + async getUsersByStatus(): Promise<{ status: string; count: number }[]> { + const rows = await this.userRepository + .createQueryBuilder('user') + .select('user.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .groupBy('user.status') + .orderBy('count', 'DESC') + .getRawMany<{ status: string; count: string }>(); + + return rows.map((row) => ({ + status: row.status, + count: Number(row.count), + })); + } + + async getEmployeeGrowthTrend(days: number): Promise<{ date: string; count: number }[]> { + const rows = await this.employeeRepository + .createQueryBuilder('employee') + .select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, 'date') + .addSelect('COUNT(*)::int', 'count') + .where('employee.is_current = true') + .andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { days }) + .groupBy('employee.created_at::date') + .orderBy('employee.created_at::date', 'ASC') + .getRawMany<{ date: string; count: string }>(); + + return rows.map((row) => ({ + date: row.date, + count: Number(row.count), + })); + } + + async getActiveUsersBreakdown(): Promise<{ label: string; count: number }[]> { + const [active, inactive] = await Promise.all([ + this.userRepository.count({ + where: { isActive: true, status: EUserStatus.ACCEPTED }, + }), + this.userRepository + .createQueryBuilder('user') + .where('user.is_active = false OR user.status != :status', { + status: EUserStatus.ACCEPTED, + }) + .getCount(), + ]); + + return [ + { label: 'Active', count: active }, + { label: 'Inactive', count: inactive }, + ]; + } +} diff --git a/apps/edr-freight-api/src/modules/overview/overview.service.ts b/apps/edr-freight-api/src/modules/overview/overview.service.ts new file mode 100644 index 000000000..feadf2409 --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/overview.service.ts @@ -0,0 +1,210 @@ +import { Injectable } from '@nestjs/common'; + +import { + BOOKING_LIST_TABS, + mapStatusCountsToTabs, +} from '../bookings/booking-list-tabs.config'; +import type { OverviewRangeQuery } from './dto/overview-query.dto'; +import type { OverviewResponseDto } from './dto/overview-response.dto'; +import type { + OverviewBillingTabDto, + OverviewBookingsTabDto, + OverviewCustomersTabDto, + OverviewOperationsTabDto, + OverviewStaffTabDto, +} from './dto/overview-tab-response.dto'; +import { OVERVIEW_RANGE_DAYS } from './overview.constants'; +import { OverviewRepository } from './overview.repository'; + +@Injectable() +export class OverviewService { + constructor(private readonly overviewRepository: OverviewRepository) {} + + private mapStatusCounts(statusCounts: Record) { + const pipelineTabs = mapStatusCountsToTabs(statusCounts); + const bookingsByPipeline = BOOKING_LIST_TABS.filter( + (tab) => tab.key !== 'all', + ).map((tab) => ({ + stage: tab.key, + count: pipelineTabs[tab.key], + })); + + const bookingsByStatus = Object.entries(statusCounts) + .map(([status, count]) => ({ status, count })) + .sort((a, b) => b.count - a.count); + + return { bookingsByPipeline, bookingsByStatus }; + } + + async getDashboard(range: OverviewRangeQuery = '30d'): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + + const [ + bookingKpis, + operationsKpis, + customerKpis, + billingKpis, + staffKpis, + bookingTrend, + statusCounts, + paymentTrend, + recentBookings, + ] = await Promise.all([ + this.overviewRepository.getBookingKpis(), + this.overviewRepository.getOperationsKpis(), + this.overviewRepository.getCustomerKpis(), + this.overviewRepository.getBillingKpis(), + this.overviewRepository.getStaffKpis(), + this.overviewRepository.getBookingTrend(days), + this.overviewRepository.getStatusCounts(), + this.overviewRepository.getPaymentTrend(days), + this.overviewRepository.getRecentBookings(8), + ]); + + const { bookingsByPipeline, bookingsByStatus } = + this.mapStatusCounts(statusCounts); + + return { + kpis: { + bookings: bookingKpis, + operations: operationsKpis, + customers: customerKpis, + billing: billingKpis, + staff: staffKpis, + }, + bookingTrend, + bookingsByStatus, + bookingsByPipeline, + paymentTrend, + recentBookings: recentBookings.map((row) => ({ + ...row, + createdAt: row.createdAt.toISOString(), + })), + generatedAt: new Date().toISOString(), + }; + } + + async getBookingsTab(range: OverviewRangeQuery = '30d'): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + + const [ + kpis, + bookingTrend, + statusCounts, + bookingsByFreightType, + bookingsByCurrency, + recentBookings, + ] = await Promise.all([ + this.overviewRepository.getBookingKpis(), + this.overviewRepository.getBookingTrend(days), + this.overviewRepository.getStatusCounts(), + this.overviewRepository.getBookingsByFreightType(), + this.overviewRepository.getBookingsByCurrency(), + this.overviewRepository.getRecentBookings(8), + ]); + + const { bookingsByPipeline, bookingsByStatus } = + this.mapStatusCounts(statusCounts); + + return { + kpis, + bookingTrend, + bookingsByStatus, + bookingsByPipeline, + bookingsByFreightType, + bookingsByCurrency, + recentBookings: recentBookings.map((row) => ({ + ...row, + createdAt: row.createdAt.toISOString(), + })), + generatedAt: new Date().toISOString(), + }; + } + + async getBillingTab(range: OverviewRangeQuery = '30d'): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + + const [kpis, paymentTrend, paymentsByStatus, paymentsByMethod, revenueByCurrency] = + await Promise.all([ + this.overviewRepository.getBillingKpis(), + this.overviewRepository.getPaymentTrend(days), + this.overviewRepository.getPaymentsByStatus(), + this.overviewRepository.getPaymentsByMethod(), + this.overviewRepository.getRevenueByCurrency(), + ]); + + return { + kpis, + paymentTrend, + paymentsByStatus, + paymentsByMethod, + revenueByCurrency, + generatedAt: new Date().toISOString(), + }; + } + + async getOperationsTab(): Promise { + const [ + kpis, + trainStatusBreakdown, + wagonStatusBreakdown, + containerStatusBreakdown, + cargoStatusBreakdown, + ] = await Promise.all([ + this.overviewRepository.getOperationsKpis(), + this.overviewRepository.getTrainStatusBreakdown(), + this.overviewRepository.getWagonStatusBreakdown(), + this.overviewRepository.getContainerStatusBreakdown(), + this.overviewRepository.getCargoStatusBreakdown(), + ]); + + return { + kpis, + trainStatusBreakdown, + wagonStatusBreakdown, + containerStatusBreakdown, + cargoStatusBreakdown, + generatedAt: new Date().toISOString(), + }; + } + + async getCustomersTab(range: OverviewRangeQuery = '30d'): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + + const [kpis, customerGrowthTrend, customersByType, topCustomersByBookings] = + await Promise.all([ + this.overviewRepository.getCustomerKpis(), + this.overviewRepository.getCustomerGrowthTrend(days), + this.overviewRepository.getCustomersByType(), + this.overviewRepository.getTopCustomersByBookings(8), + ]); + + return { + kpis, + customerGrowthTrend, + customersByType, + topCustomersByBookings, + generatedAt: new Date().toISOString(), + }; + } + + async getStaffTab(range: OverviewRangeQuery = '30d'): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + + const [kpis, usersByStatus, employeeGrowthTrend, activeUsersBreakdown] = + await Promise.all([ + this.overviewRepository.getStaffKpis(), + this.overviewRepository.getUsersByStatus(), + this.overviewRepository.getEmployeeGrowthTrend(days), + this.overviewRepository.getActiveUsersBreakdown(), + ]); + + return { + kpis, + usersByStatus, + employeeGrowthTrend, + activeUsersBreakdown, + generatedAt: new Date().toISOString(), + }; + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts index 72e35b296..8e13d3ec7 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts @@ -5,6 +5,8 @@ import { import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; +import { MoveOrderDto } from '../dto/move-order.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto'; import { ApprovalRulesService } from '../services/approval-rules.service'; @@ -35,6 +37,22 @@ export class ApprovalRulesController { return this.service.findChain(flag === 'true'); } + @Post('reorder') + @RuleEngineManage('approval-rules') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Bulk reorder approval steps within a chain' }) + reorder(@Body() dto: ReorderItemsDto) { + return this.service.reorder(dto); + } + + @Post(':id/move-order') + @RuleEngineManage('approval-rules') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Move an approval step up or down within its chain' }) + moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { + return this.service.moveOrder(id, dto.direction); + } + @Get(':id') @RuleEngineView('approval-rules') @ApiOperation({ summary: 'Get an approval rule by ID' }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts index 4941a5ebb..e2b8425bf 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts @@ -5,6 +5,8 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; +import { MoveOrderDto } from '../dto/move-order.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; import { CargoTypesService } from '../services/cargo-types.service'; @@ -32,6 +34,22 @@ export class CargoTypesController { }); } + @Post('reorder') + @RuleEngineManage('cargo-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Bulk reorder cargo types by ID list' }) + reorder(@Body() dto: ReorderItemsDto) { + return this.service.reorder(dto); + } + + @Post(':id/move-order') + @RuleEngineManage('cargo-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Move a cargo type up or down in display order' }) + moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { + return this.service.moveOrder(id, dto.direction); + } + @Get(':id') @RuleEngineView('cargo-types') @ApiOperation({ summary: 'Get a cargo type by ID' }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts index 43dfcec33..624cf4b03 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts @@ -5,6 +5,8 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; +import { MoveOrderDto } from '../dto/move-order.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateContainerTypeDto } from '../dto/update-container-type.dto'; import { ContainerTypesService } from '../services/container-types.service'; @@ -25,6 +27,22 @@ export class ContainerTypesController { }); } + @Post('reorder') + @RuleEngineManage('container-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Bulk reorder container types by ID list' }) + reorder(@Body() dto: ReorderItemsDto) { + return this.service.reorder(dto); + } + + @Post(':id/move-order') + @RuleEngineManage('container-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Move a container type up or down in display order' }) + moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { + return this.service.moveOrder(id, dto.direction); + } + @Get(':id') @RuleEngineView('container-types') @ApiOperation({ summary: 'Get a container type by ID' }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts index 3044515fb..18c597b38 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts @@ -5,6 +5,8 @@ import { import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; +import { MoveOrderDto } from '../dto/move-order.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateServiceTypeDto } from '../dto/update-service-type.dto'; import { ServiceTypesService } from '../services/service-types.service'; @@ -29,6 +31,22 @@ export class ServiceTypesController { }); } + @Post('reorder') + @RuleEngineManage('service-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Bulk reorder service types by ID list' }) + reorder(@Body() dto: ReorderItemsDto) { + return this.service.reorder(dto); + } + + @Post(':id/move-order') + @RuleEngineManage('service-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Move a service type up or down in display order' }) + moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { + return this.service.moveOrder(id, dto.direction); + } + @Get(':id') @RuleEngineView('service-types') @ApiOperation({ summary: 'Get a service type by ID' }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts index 88523967e..d18d0b748 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts @@ -5,6 +5,8 @@ import { import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateYardDto } from '../dto/create-yard.dto'; +import { MoveOrderDto } from '../dto/move-order.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateYardDto } from '../dto/update-yard.dto'; import { YardsService } from '../services/yards.service'; @@ -26,6 +28,22 @@ export class YardsController { }); } + @Post('reorder') + @RuleEngineManage('yards') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Bulk reorder yards by ID list' }) + reorder(@Body() dto: ReorderItemsDto) { + return this.service.reorder(dto); + } + + @Post(':id/move-order') + @RuleEngineManage('yards') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Move a yard up or down in display order' }) + moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { + return this.service.moveOrder(id, dto.direction); + } + @Get(':id') @RuleEngineView('yards') @ApiOperation({ summary: 'Get a yard by ID' }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts index 6ccc95384..5861b1ad8 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; const ROLES = ['LINE_STAFF', 'DIRECTOR', 'CEO'] as const; @@ -8,10 +8,16 @@ export class CreateApprovalRuleDto { @IsBoolean() requiresDirectorApproval!: boolean; - @ApiProperty({ description: 'Step sequence number (1 = first, 2 = second)', minimum: 1 }) + @ApiPropertyOptional({ description: 'Step sequence number (auto-assigned if omitted)', minimum: 1 }) + @IsOptional() @IsInt() @Min(1) - stepOrder!: number; + stepOrder?: number; + + @ApiPropertyOptional({ description: 'Insert after this step ID within the same chain' }) + @IsOptional() + @IsUUID('4') + insertAfterId?: string; @ApiProperty({ enum: ROLES, description: 'Role required to action this step' }) @IsString() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index ae2e23c33..57fe48fed 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -32,4 +32,9 @@ export class CreateCargoTypeDto { @IsInt() @Min(1) displayOrder?: number; + + @ApiPropertyOptional({ description: 'Insert after this record ID' }) + @IsOptional() + @IsUUID('4') + insertAfterId?: string; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts index dbfb5ca2b..52cfe274b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; +import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; export class CreateContainerTypeDto { @ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 }) @@ -40,4 +40,9 @@ export class CreateContainerTypeDto { @IsInt() @Min(1) displayOrder?: number; + + @ApiPropertyOptional({ description: 'Insert after this record ID' }) + @IsOptional() + @IsUUID('4') + insertAfterId?: string; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts index b20203e13..4683d448d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; export class CreateServiceTypeDto { @ApiProperty({ description: 'Service type display name', maxLength: 255 }) @@ -48,4 +48,9 @@ export class CreateServiceTypeDto { @IsInt() @Min(1) displayOrder?: number; + + @ApiPropertyOptional({ description: 'Insert after this record ID' }) + @IsOptional() + @IsUUID('4') + insertAfterId?: string; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts index f0d9ff012..38f2bc58b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; export class CreateYardDto { @ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 }) @@ -22,4 +22,9 @@ export class CreateYardDto { @IsInt() @Min(1) displayOrder?: number; + + @ApiPropertyOptional({ description: 'Insert after this record ID' }) + @IsOptional() + @IsUUID('4') + insertAfterId?: string; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/move-order.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/move-order.dto.ts new file mode 100644 index 000000000..91eadc0d8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/move-order.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn } from 'class-validator'; + +export class MoveOrderDto { + @ApiProperty({ enum: ['up', 'down'] }) + @IsIn(['up', 'down']) + direction!: 'up' | 'down'; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/reorder-items.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/reorder-items.dto.ts new file mode 100644 index 000000000..48a3e6b6a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/reorder-items.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsUUID } from 'class-validator'; + +export class ReorderItemsDto { + @ApiProperty({ description: 'Ordered list of record IDs (new display/step order)', type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + ids!: string[]; + + @ApiPropertyOptional({ + description: 'Approval-rules only: scope reorder to this chain', + }) + @IsOptional() + @IsBoolean() + requiresDirectorApproval?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 9657e6865..4af5066ce 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -46,6 +46,7 @@ import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.re import { YardsRepository } from './repositories/yards.repository'; import { ApprovalRulesService } from './services/approval-rules.service'; +import { DisplayOrderService } from './services/display-order.service'; import { CargoTypesService } from './services/cargo-types.service'; import { ContainerTypesService } from './services/container-types.service'; import { PriorityRulesService } from './services/priority-rules.service'; @@ -126,6 +127,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ShippingLinesService, RatesService, ApprovalRulesService, + DisplayOrderService, RuleEngineService, ], exports: [ diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 98aeaffbf..bf4162f15 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -315,15 +315,27 @@ export class RuleEngineService { } /** - * Snapshot all LIVE rates into booking_rate_snapshot for a booking. + * Snapshot only the rates used in a booking's final price. */ - async snapshotLiveRates(bookingId: string): Promise { - const liveRates = await this.ratesRepo.findLiveRates(); + async snapshotRates( + bookingId: string, + rates: Array<{ + id: string; + rateType: string; + rateValue: number; + rateUnit: string; + currency: string; + }>, + ): Promise { const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot); const now = new Date(); + const seen = new Set(); const snapshots: BookingRateSnapshot[] = []; - for (const rate of liveRates) { + for (const rate of rates) { + if (seen.has(rate.id)) continue; + seen.add(rate.id); + const snapshot = snapshotRepo.create({ bookingId, rateId: rate.id, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts index 4a4e33442..063cc6439 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts @@ -1,17 +1,20 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto'; import { ApprovalRule } from '../entities/approval-rule.entity'; import { APPROVAL_RULES_REPOSITORY, IApprovalRulesRepository, } from '../interfaces/approval-rules.repository.interface'; +import { DisplayOrderService } from './display-order.service'; @Injectable() export class ApprovalRulesService { constructor( @Inject(APPROVAL_RULES_REPOSITORY) private readonly repository: IApprovalRulesRepository, + private readonly displayOrder: DisplayOrderService, ) {} /** List approval rules. */ @@ -21,7 +24,7 @@ export class ApprovalRulesService { pageSize?: number; }): Promise<{ data: ApprovalRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 20; + const pageSize = filter.pageSize ?? 10; const where: Record = {}; if (filter.requiresDirectorApproval !== undefined) { where.requiresDirectorApproval = filter.requiresDirectorApproval; @@ -50,9 +53,20 @@ export class ApprovalRulesService { /** Create an approval rule step. */ async create(dto: CreateApprovalRuleDto): Promise { + if (dto.stepOrder !== undefined && dto.insertAfterId) { + throw new BadRequestException('Cannot set both stepOrder and insertAfterId'); + } + + const scopeWhere = { requiresDirectorApproval: dto.requiresDirectorApproval }; + const stepOrder = await this.displayOrder.resolveCreateOrder(ApprovalRule, 'stepOrder', { + explicitOrder: dto.stepOrder, + insertAfterId: dto.insertAfterId, + scopeWhere, + }); + return this.repository.create({ requiresDirectorApproval: dto.requiresDirectorApproval, - stepOrder: dto.stepOrder, + stepOrder, requiredRole: dto.requiredRole, actionLabel: dto.actionLabel, blocksRole: dto.blocksRole, @@ -72,4 +86,20 @@ export class ApprovalRulesService { await this.findById(id); await this.repository.softDelete(id); } + + async reorder(dto: ReorderItemsDto): Promise { + if (dto.requiresDirectorApproval === undefined) { + throw new BadRequestException('requiresDirectorApproval is required for approval rule reorder'); + } + await this.displayOrder.reorderByIds(ApprovalRule, 'stepOrder', dto.ids, { + requiresDirectorApproval: dto.requiresDirectorApproval, + }); + } + + async moveOrder(id: string, direction: 'up' | 'down'): Promise { + const rule = await this.findById(id); + await this.displayOrder.moveOne(ApprovalRule, 'stepOrder', id, direction, { + requiresDirectorApproval: rule.requiresDirectorApproval, + }); + } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 130b1d605..634ac5faa 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -2,18 +2,21 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestj import { ILike } from 'typeorm'; import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; import { CargoType } from '../entities/cargo-type.entity'; import { CARGO_TYPES_REPOSITORY, ICargoTypesRepository, } from '../interfaces/cargo-types.repository.interface'; +import { DisplayOrderService } from './display-order.service'; @Injectable() export class CargoTypesService { constructor( @Inject(CARGO_TYPES_REPOSITORY) private readonly repository: ICargoTypesRepository, + private readonly displayOrder: DisplayOrderService, ) {} /** List cargo types with pagination and optional filtering. */ @@ -28,7 +31,7 @@ export class CargoTypesService { sortOrder?: 'ASC' | 'DESC'; }): Promise<{ data: CargoType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 20; + const pageSize = filter.pageSize ?? 10; const where: Record = {}; if (filter.isActive !== undefined) where.isActive = filter.isActive; if (filter.requiresDirectorApproval !== undefined) where.requiresDirectorApproval = filter.requiresDirectorApproval; @@ -66,6 +69,12 @@ export class CargoTypesService { const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } + + const displayOrder = await this.displayOrder.resolveCreateOrder(CargoType, 'displayOrder', { + explicitOrder: dto.displayOrder, + insertAfterId: dto.insertAfterId, + }); + return this.repository.create({ code, cargoTypeName: dto.cargoTypeName, @@ -73,7 +82,7 @@ export class CargoTypesService { showFreeTextBox: dto.showFreeTextBox ?? false, requiresDirectorApproval: dto.requiresDirectorApproval ?? false, isActive: dto.isActive ?? true, - displayOrder: dto.displayOrder ?? 1, + displayOrder, }); } @@ -95,4 +104,13 @@ export class CargoTypesService { await this.findById(id); await this.repository.softDelete(id); } + + async reorder(dto: ReorderItemsDto): Promise { + await this.displayOrder.reorderByIds(CargoType, 'displayOrder', dto.ids); + } + + async moveOrder(id: string, direction: 'up' | 'down'): Promise { + await this.findById(id); + await this.displayOrder.moveOne(CargoType, 'displayOrder', id, direction); + } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts index 9b0209311..38407f36a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts @@ -1,18 +1,21 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateContainerTypeDto } from '../dto/update-container-type.dto'; import { ContainerType } from '../entities/container-type.entity'; import { CONTAINER_TYPES_REPOSITORY, IContainerTypesRepository, } from '../interfaces/container-types.repository.interface'; +import { DisplayOrderService } from './display-order.service'; @Injectable() export class ContainerTypesService { constructor( @Inject(CONTAINER_TYPES_REPOSITORY) private readonly repository: IContainerTypesRepository, + private readonly displayOrder: DisplayOrderService, ) {} /** List container types with pagination. */ @@ -22,7 +25,7 @@ export class ContainerTypesService { pageSize?: number; }): Promise<{ data: ContainerType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 20; + const pageSize = filter.pageSize ?? 10; const where: Record = {}; if (filter.isActive !== undefined) where.isActive = filter.isActive; @@ -47,6 +50,12 @@ export class ContainerTypesService { const code = generateCode(dto.label); const existing = await this.repository.findByCode(code); if (existing) throw new ConflictException(`Container type with label "${dto.label}" conflicts with existing code "${code}"`); + + const displayOrder = await this.displayOrder.resolveCreateOrder(ContainerType, 'displayOrder', { + explicitOrder: dto.displayOrder, + insertAfterId: dto.insertAfterId, + }); + return this.repository.create({ code, label: dto.label, @@ -55,7 +64,7 @@ export class ContainerTypesService { isReefer: dto.isReefer ?? false, isOpenTop: dto.isOpenTop ?? false, isActive: dto.isActive ?? true, - displayOrder: dto.displayOrder ?? 1, + displayOrder, }); } @@ -72,4 +81,13 @@ export class ContainerTypesService { await this.findById(id); await this.repository.softDelete(id); } + + async reorder(dto: ReorderItemsDto): Promise { + await this.displayOrder.reorderByIds(ContainerType, 'displayOrder', dto.ids); + } + + async moveOrder(id: string, direction: 'up' | 'down'): Promise { + await this.findById(id); + await this.displayOrder.moveOne(ContainerType, 'displayOrder', id, direction); + } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/display-order.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/display-order.service.ts new file mode 100644 index 000000000..e0ee48106 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/display-order.service.ts @@ -0,0 +1,175 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, EntityTarget, FindOptionsWhere, ObjectLiteral } from 'typeorm'; + +export type OrderField = 'displayOrder' | 'stepOrder'; + +@Injectable() +export class DisplayOrderService { + constructor(private readonly dataSource: DataSource) {} + + async getMaxOrder( + entity: EntityTarget, + field: OrderField, + where?: FindOptionsWhere, + ): Promise { + const repo = this.dataSource.getRepository(entity); + const qb = repo.createQueryBuilder('e').select(`MAX(e.${field})`, 'max'); + if (where) { + Object.entries(where).forEach(([key, value]) => { + if (value !== undefined) { + qb.andWhere(`e.${key} = :${key}`, { [key]: value }); + } + }); + } + const row = await qb.getRawOne<{ max: string | null }>(); + return row?.max ? Number(row.max) : 0; + } + + async resolveCreateOrder( + entity: EntityTarget, + field: OrderField, + options: { + explicitOrder?: number; + insertAfterId?: string; + scopeWhere?: FindOptionsWhere; + }, + ): Promise { + const { explicitOrder, insertAfterId, scopeWhere } = options; + + if (insertAfterId) { + if (explicitOrder !== undefined) { + throw new BadRequestException('Cannot set both explicit order and insertAfterId'); + } + const repo = this.dataSource.getRepository(entity); + const after = await repo.findOne({ + where: { id: insertAfterId, ...scopeWhere } as unknown as FindOptionsWhere, + }); + if (!after) { + throw new NotFoundException(`Record ${insertAfterId} not found in scope`); + } + const afterOrder = Number((after as Record)[field]); + await this.shiftOrdersFrom(entity, field, afterOrder + 1, 1, scopeWhere); + return afterOrder + 1; + } + + if (explicitOrder !== undefined) { + return explicitOrder; + } + + const max = await this.getMaxOrder(entity, field, scopeWhere); + return max + 1; + } + + async reorderByIds( + entity: EntityTarget, + field: OrderField, + ids: string[], + scopeWhere?: FindOptionsWhere, + ): Promise { + const repo = this.dataSource.getRepository(entity); + const existing = await repo.find({ + where: scopeWhere, + order: { [field]: 'ASC' } as never, + }); + + const scopedIds = new Set(existing.map((row) => String(row.id))); + if (ids.length !== scopedIds.size) { + throw new BadRequestException('Reorder list must include every item in scope exactly once'); + } + for (const id of ids) { + if (!scopedIds.has(id)) { + throw new BadRequestException(`ID ${id} is not in the reorder scope`); + } + } + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + for (let i = 0; i < ids.length; i++) { + await queryRunner.manager.update(entity, ids[i], { [field]: -(i + 1) } as never); + } + for (let i = 0; i < ids.length; i++) { + await queryRunner.manager.update(entity, ids[i], { [field]: i + 1 } as never); + } + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } + + async moveOne( + entity: EntityTarget, + field: OrderField, + id: string, + direction: 'up' | 'down', + scopeWhere?: FindOptionsWhere, + ): Promise { + const repo = this.dataSource.getRepository(entity); + const items = await repo.find({ + where: scopeWhere, + order: { [field]: 'ASC' } as never, + }); + + const index = items.findIndex((row) => String(row.id) === id); + if (index === -1) { + throw new NotFoundException(`Record ${id} not found in scope`); + } + + const targetIndex = direction === 'up' ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= items.length) { + throw new BadRequestException(`Cannot move ${direction}`); + } + + const current = items[index] as Record; + const neighbor = items[targetIndex] as Record; + const currentOrder = Number(current[field]); + const neighborOrder = Number(neighbor[field]); + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + await queryRunner.manager.update(entity, String(current.id), { [field]: -1 } as never); + await queryRunner.manager.update(entity, String(neighbor.id), { [field]: -2 } as never); + await queryRunner.manager.update(entity, String(current.id), { [field]: neighborOrder } as never); + await queryRunner.manager.update(entity, String(neighbor.id), { [field]: currentOrder } as never); + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } + + private async shiftOrdersFrom( + entity: EntityTarget, + field: OrderField, + fromOrder: number, + delta: number, + scopeWhere?: FindOptionsWhere, + ): Promise { + const repo = this.dataSource.getRepository(entity); + const orderColumn = repo.metadata.findColumnWithPropertyName(field)?.databaseName ?? field; + const qb = repo + .createQueryBuilder() + .update() + .set({ [field]: () => `"${orderColumn}" + ${delta}` } as never) + .where(`"${orderColumn}" >= :fromOrder`, { fromOrder }); + + if (scopeWhere) { + Object.entries(scopeWhere).forEach(([key, value]) => { + if (value !== undefined) { + const col = repo.metadata.findColumnWithPropertyName(key)?.databaseName ?? key; + qb.andWhere(`"${col}" = :scope_${key}`, { [`scope_${key}`]: value }); + } + }); + } + + await qb.execute(); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 0202ef44a..525185058 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -30,7 +30,7 @@ export class RatesService { skip: (page - 1) * pageSize, take: pageSize, }); - return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + return { data, meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) } }; } /** Return all currently LIVE rates. */ diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts index 1d54582a1..2ad8753c3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts @@ -2,18 +2,21 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestj import { ILike } from 'typeorm'; import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateServiceTypeDto } from '../dto/update-service-type.dto'; import { ServiceType } from '../entities/service-type.entity'; import { IServiceTypesRepository, SERVICE_TYPES_REPOSITORY, } from '../interfaces/service-types.repository.interface'; +import { DisplayOrderService } from './display-order.service'; @Injectable() export class ServiceTypesService { constructor( @Inject(SERVICE_TYPES_REPOSITORY) private readonly repository: IServiceTypesRepository, + private readonly displayOrder: DisplayOrderService, ) {} /** List service types with pagination and optional filtering. */ @@ -27,7 +30,7 @@ export class ServiceTypesService { sortOrder?: 'ASC' | 'DESC'; }): Promise<{ data: ServiceType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 20; + const pageSize = filter.pageSize ?? 10; const where: Record = {}; if (filter.isActive !== undefined) where.isActive = filter.isActive; if (filter.canBeBookedAlone !== undefined) where.canBeBookedAlone = filter.canBeBookedAlone; @@ -59,6 +62,12 @@ export class ServiceTypesService { const code = generateCode(dto.serviceName); const existing = await this.repository.findByCode(code); if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${code}"`); + + const displayOrder = await this.displayOrder.resolveCreateOrder(ServiceType, 'displayOrder', { + explicitOrder: dto.displayOrder, + insertAfterId: dto.insertAfterId, + }); + return this.repository.create({ code, serviceName: dto.serviceName, @@ -69,7 +78,7 @@ export class ServiceTypesService { includesCustoms: dto.includesCustoms ?? false, priorityBonusPoints: dto.priorityBonusPoints ?? 0, isActive: dto.isActive ?? true, - displayOrder: dto.displayOrder ?? 1, + displayOrder, }); } @@ -87,4 +96,13 @@ export class ServiceTypesService { await this.findById(id); await this.repository.softDelete(id); } + + async reorder(dto: ReorderItemsDto): Promise { + await this.displayOrder.reorderByIds(ServiceType, 'displayOrder', dto.ids); + } + + async moveOrder(id: string, direction: 'up' | 'down'): Promise { + await this.findById(id); + await this.displayOrder.moveOne(ServiceType, 'displayOrder', id, direction); + } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts index 5e53cb1fd..0c95582af 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts @@ -1,15 +1,18 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateYardDto } from '../dto/create-yard.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateYardDto } from '../dto/update-yard.dto'; import { Yard } from '../entities/yard.entity'; import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface'; +import { DisplayOrderService } from './display-order.service'; @Injectable() export class YardsService { constructor( @Inject(YARDS_REPOSITORY) private readonly repository: IYardsRepository, + private readonly displayOrder: DisplayOrderService, ) {} /** List yards with pagination. */ @@ -20,7 +23,7 @@ export class YardsService { pageSize?: number; }): Promise<{ data: Yard[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 20; + const pageSize = filter.pageSize ?? 10; const where: Record = {}; if (filter.isActive !== undefined) where.isActive = filter.isActive; if (filter.country) where.country = filter.country; @@ -46,12 +49,18 @@ export class YardsService { const code = generateCode(dto.label); const existing = await this.repository.findByCode(code); if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`); + + const displayOrder = await this.displayOrder.resolveCreateOrder(Yard, 'displayOrder', { + explicitOrder: dto.displayOrder, + insertAfterId: dto.insertAfterId, + }); + return this.repository.create({ code, label: dto.label, country: dto.country, isActive: dto.isActive ?? true, - displayOrder: dto.displayOrder ?? 1, + displayOrder, }); } @@ -68,4 +77,13 @@ export class YardsService { await this.findById(id); await this.repository.softDelete(id); } + + async reorder(dto: ReorderItemsDto): Promise { + await this.displayOrder.reorderByIds(Yard, 'displayOrder', dto.ids); + } + + async moveOrder(id: string, direction: 'up' | 'down'): Promise { + await this.findById(id); + await this.displayOrder.moveOne(Yard, 'displayOrder', id, direction); + } } diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewBookingTrendChart.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewBookingTrendChart.tsx new file mode 100644 index 000000000..db92c6b41 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewBookingTrendChart.tsx @@ -0,0 +1,65 @@ +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Paper, Stack, Text } from "@mantine/core"; + +import type { IOverviewTrendPoint } from "@/types/overview"; +import { overviewChartColors } from "./overview.styles"; + +function formatDateLabel(date: string) { + const parsed = new Date(`${date}T00:00:00`); + return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +export function OverviewBookingTrendChart({ data }: { data: IOverviewTrendPoint[] }) { + const hasData = data.some((point) => point.count > 0); + + return ( + + + Booking trend + {!hasData ? ( + + No bookings in this period + + ) : ( + + + + + + + + + + + + formatDateLabel(String(value))} + formatter={(value) => [value, "Bookings"]} + /> + + + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewDonutChart.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewDonutChart.tsx new file mode 100644 index 000000000..494b3d653 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewDonutChart.tsx @@ -0,0 +1,70 @@ +import { + Cell, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, +} from "recharts"; +import { Paper, Stack, Text } from "@mantine/core"; + +import { overviewChartColors } from "./overview.styles"; + +export interface DonutChartItem { + name: string; + value: number; +} + +interface OverviewDonutChartProps { + title: string; + data: DonutChartItem[]; + emptyMessage?: string; +} + +export function OverviewDonutChart({ + title, + data, + emptyMessage = "No data available", +}: OverviewDonutChartProps) { + const filtered = data.filter((item) => item.value > 0); + const hasData = filtered.length > 0; + + return ( + + + {title} + {!hasData ? ( + + {emptyMessage} + + ) : ( + + + + {filtered.map((entry, index) => ( + + ))} + + [value, "Count"]} /> + + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewHorizontalBarChart.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewHorizontalBarChart.tsx new file mode 100644 index 000000000..e5b5d3f8e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewHorizontalBarChart.tsx @@ -0,0 +1,81 @@ +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Paper, Stack, Text } from "@mantine/core"; + +import { overviewChartColors } from "./overview.styles"; + +export interface HorizontalBarItem { + label: string; + value: number; +} + +interface OverviewHorizontalBarChartProps { + title: string; + data: HorizontalBarItem[]; + emptyMessage?: string; + valueLabel?: string; +} + +export function OverviewHorizontalBarChart({ + title, + data, + emptyMessage = "No data available", + valueLabel = "Count", +}: OverviewHorizontalBarChartProps) { + const chartData = data + .filter((item) => item.value > 0) + .map((item) => ({ name: item.label, value: item.value })); + const hasData = chartData.length > 0; + + return ( + + + {title} + {!hasData ? ( + + {emptyMessage} + + ) : ( + + + + + + [value, valueLabel]} /> + + {chartData.map((entry, index) => ( + + ))} + + + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiCard.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiCard.tsx new file mode 100644 index 000000000..bdbfc8850 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiCard.tsx @@ -0,0 +1,70 @@ +import type { LucideIcon } from "lucide-react"; +import { Card, Group, Stack, Text } from "@mantine/core"; + +const accentColors = { + default: { bg: "var(--mantine-color-gray-1)", color: "var(--mantine-color-gray-6)" }, + amber: { bg: "var(--mantine-color-yellow-1)", color: "var(--mantine-color-yellow-6)" }, + emerald: { bg: "var(--freight-brand-muted)", color: "var(--freight-brand)" }, + rose: { bg: "var(--mantine-color-red-1)", color: "var(--mantine-color-red-6)" }, + sky: { bg: "var(--mantine-color-blue-1)", color: "var(--mantine-color-blue-6)" }, +}; + +export interface OverviewKpiItem { + label: string; + value: number | string; + hint?: string; + icon: LucideIcon; + accent?: keyof typeof accentColors; +} + +export function OverviewKpiCard({ item }: { item: OverviewKpiItem }) { + const Icon = item.icon; + const accent = item.accent ?? "default"; + const accentStyle = accentColors[accent]; + + return ( + + + + + {item.label} + + + {item.value} + + {item.hint && ( + + {item.hint} + + )} + +
+ +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiSection.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiSection.tsx new file mode 100644 index 000000000..ea99d098e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiSection.tsx @@ -0,0 +1,167 @@ +import { + AlertCircle, + Banknote, + Box, + Clock, + Container, + CreditCard, + FileText, + Train, + Truck, + UserCheck, + Users, + Wallet, +} from "lucide-react"; +import { Group, Paper, Stack, Text } from "@mantine/core"; + +import type { IOverviewKpis } from "@/types/overview"; +import { OverviewKpiCard } from "./OverviewKpiCard"; + +function formatCurrency(amount: number, currency: "ETB" | "USD") { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + maximumFractionDigits: 0, + }).format(amount); +} + +export function OverviewKpiSection({ kpis }: { kpis: IOverviewKpis }) { + const bookingItems = [ + { + label: "Active bookings", + value: kpis.bookings.totalActive, + icon: FileText, + accent: "emerald" as const, + }, + { + label: "Needs action", + value: kpis.bookings.needsAction, + icon: AlertCircle, + accent: "amber" as const, + }, + { + label: "Urgent", + value: kpis.bookings.urgent, + icon: Clock, + accent: "rose" as const, + }, + { + label: "In approval", + value: kpis.bookings.inApproval, + icon: UserCheck, + accent: "sky" as const, + }, + { + label: "Submitted today", + value: kpis.bookings.submittedToday, + icon: FileText, + }, + ]; + + const operationsItems = [ + { + label: "Active trains", + value: kpis.operations.trainsActive, + icon: Train, + accent: "emerald" as const, + }, + { + label: "Wagons available", + value: kpis.operations.wagonsAvailable, + icon: Truck, + }, + { + label: "Containers in transit", + value: kpis.operations.containersInTransit, + icon: Container, + }, + { + label: "Cargoes loaded", + value: kpis.operations.cargoesLoaded, + icon: Box, + }, + ]; + + const billingItems = [ + { + label: "Revenue MTD (ETB)", + value: formatCurrency(kpis.billing.revenueMtdEtb, "ETB"), + icon: Banknote, + accent: "emerald" as const, + }, + { + label: "Revenue MTD (USD)", + value: formatCurrency(kpis.billing.revenueMtdUsd, "USD"), + icon: Wallet, + }, + { + label: "Pending payments", + value: kpis.billing.pendingPayments, + icon: CreditCard, + accent: "amber" as const, + }, + { + label: "Successful MTD", + value: kpis.billing.successfulPaymentsMtd, + icon: Banknote, + }, + ]; + + const peopleItems = [ + { + label: "Total customers", + value: kpis.customers.totalCustomers, + icon: Users, + }, + { + label: "New this month", + value: kpis.customers.newCustomersThisMonth, + icon: Users, + accent: "emerald" as const, + }, + { + label: "Active employees", + value: kpis.staff.activeEmployees, + icon: UserCheck, + }, + { + label: "Active users", + value: kpis.staff.activeUsers, + icon: Users, + }, + ]; + + const sections = [ + { title: "Bookings", items: bookingItems }, + { title: "Operations", items: operationsItems }, + { title: "Billing", items: billingItems }, + { title: "Customers & staff", items: peopleItems }, + ]; + + return ( + + {sections.map((section) => ( + + + {section.title} + + + {section.items.map((item) => ( + + ))} + + + ))} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx new file mode 100644 index 000000000..e4f388c8f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx @@ -0,0 +1,34 @@ +import { Group, Paper, Text } from "@mantine/core"; + +import { OverviewKpiCard, type OverviewKpiItem } from "./OverviewKpiCard"; + +interface OverviewKpiStripProps { + title?: string; + items: OverviewKpiItem[]; +} + +export function OverviewKpiStrip({ title, items }: OverviewKpiStripProps) { + return ( + + {title && ( + + {title} + + )} + + {items.map((item) => ( + + ))} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx new file mode 100644 index 000000000..6e261e615 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx @@ -0,0 +1,69 @@ +import { ActionIcon, Group, SegmentedControl, Stack, Text, Title } from "@mantine/core"; +import { RefreshCw } from "lucide-react"; + +import type { OverviewRange } from "@/types/overview"; + +const RANGE_OPTIONS = [ + { label: "7 days", value: "7d" }, + { label: "30 days", value: "30d" }, + { label: "90 days", value: "90d" }, +]; + +function formatRelativeTime(iso: string | undefined) { + if (!iso) return "—"; + const diffMs = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return new Date(iso).toLocaleString(); +} + +interface OverviewPageHeaderProps { + range: OverviewRange; + onRangeChange: (range: OverviewRange) => void; + generatedAt?: string; + onRefresh: () => void; + isRefreshing?: boolean; +} + +export function OverviewPageHeader({ + range, + onRangeChange, + generatedAt, + onRefresh, + isRefreshing, +}: OverviewPageHeaderProps) { + return ( + + + + Operations overview + + + Updated {formatRelativeTime(generatedAt)} + + + + + onRangeChange(value as OverviewRange)} + data={RANGE_OPTIONS} + size="sm" + /> + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx new file mode 100644 index 000000000..58b90d02a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx @@ -0,0 +1,79 @@ +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Paper, Stack, Text } from "@mantine/core"; + +import type { IOverviewPaymentTrendPoint } from "@/types/overview"; +import { overviewChartColors } from "./overview.styles"; + +function formatDateLabel(date: string) { + const parsed = new Date(`${date}T00:00:00`); + return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function formatAmount(value: number, currency: "ETB" | "USD") { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + maximumFractionDigits: 0, + }).format(value); +} + +export function OverviewPaymentChart({ data }: { data: IOverviewPaymentTrendPoint[] }) { + const hasData = data.some((point) => point.amountEtb > 0 || point.amountUsd > 0); + + return ( + + + Payment trend + {!hasData ? ( + + No successful payments in this period + + ) : ( + + + + + + formatDateLabel(String(value))} + formatter={(value, name) => [ + formatAmount(Number(value), name === "amountUsd" ? "USD" : "ETB"), + name === "amountUsd" ? "USD" : "ETB", + ]} + /> + + + + + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewQuickLinks.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewQuickLinks.tsx new file mode 100644 index 000000000..470bd8487 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewQuickLinks.tsx @@ -0,0 +1,72 @@ +import { useNavigate } from "react-router-dom"; +import { ArrowRight, FileText, Train, Users } from "lucide-react"; +import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core"; + +const links = [ + { + title: "Booking requests", + description: "Review and action incoming freight bookings", + href: "/dashboard/booking-requests", + icon: FileText, + }, + { + title: "Train scheduling", + description: "Schedule container trains and eligible bookings", + href: "/dashboard/operations/train-scheduling", + icon: Train, + }, + { + title: "Trains", + description: "Manage train master data and fleet status", + href: "/dashboard/trains", + icon: Train, + }, + { + title: "User management", + description: "Employees, roles, and permissions", + href: "/dashboard/user-management", + icon: Users, + }, +]; + +export function OverviewQuickLinks() { + const navigate = useNavigate(); + + return ( + + Quick links + + {links.map((link) => { + const Icon = link.icon; + return ( + navigate(link.href)} + > + + + + + + + + {link.title} + + + {link.description} + + + + + + + ); + })} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx new file mode 100644 index 000000000..b13f5c473 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx @@ -0,0 +1,78 @@ +import { useNavigate } from "react-router-dom"; +import { Paper, Stack, Table, Text } from "@mantine/core"; + +import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; +import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; +import type { IOverviewRecentBooking } from "@/types/overview"; + +function formatAmount(amount: number | null, currency: string | null) { + if (amount == null) return "—"; + const code = currency === "USD" ? "USD" : "ETB"; + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: code, + maximumFractionDigits: 0, + }).format(amount); +} + +export function OverviewRecentBookingsTable({ + bookings, +}: { + bookings: IOverviewRecentBooking[]; +}) { + const navigate = useNavigate(); + + return ( + + + Recent bookings + {bookings.length === 0 ? ( + + No recent bookings + + ) : ( + + + + Reference + Customer + Status + Priority + Amount + Created + + + + {bookings.map((booking) => ( + navigate(`/dashboard/booking-requests/${booking.id}`)} + > + + + {booking.reference} + + + {booking.customerLabel} + + + + + + + + {formatAmount(booking.totalAmount, booking.paymentCurrency)} + + + {new Date(booking.createdAt).toLocaleDateString()} + + + ))} + +
+ )} +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewStatusChart.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewStatusChart.tsx new file mode 100644 index 000000000..4842d1d1a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewStatusChart.tsx @@ -0,0 +1,69 @@ +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Paper, Stack, Text } from "@mantine/core"; + +import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config"; +import type { IOverviewPipelineCount } from "@/types/overview"; +import { overviewChartColors } from "./overview.styles"; + +function getPipelineLabel(stage: string) { + return BOOKING_LIST_TABS.find((tab) => tab.key === stage)?.label ?? stage; +} + +export function OverviewStatusChart({ data }: { data: IOverviewPipelineCount[] }) { + const chartData = data.map((item) => ({ + ...item, + label: getPipelineLabel(item.stage), + })); + const hasData = chartData.some((item) => item.count > 0); + + return ( + + + Pipeline by stage + {!hasData ? ( + + No bookings in pipeline + + ) : ( + + + + + + [value, "Bookings"]} /> + + {chartData.map((entry, index) => ( + + ))} + + + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx new file mode 100644 index 000000000..b39d5def8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx @@ -0,0 +1,102 @@ +import { AlertCircle } from "lucide-react"; +import { Alert, Button, Center, Loader, Paper, Skeleton, Stack } from "@mantine/core"; + +import { + useOverviewBillingTab, + useOverviewBookingsTab, + useOverviewCustomersTab, + useOverviewOperationsTab, + useOverviewStaffTab, +} from "@/hooks/useOverview"; +import type { OverviewRange, OverviewTabKey } from "@/types/overview"; +import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel"; +import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel"; +import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel"; +import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel"; +import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel"; + +function TabSkeleton() { + return ( + + + + + + ); +} + +interface OverviewTabContentProps { + tab: OverviewTabKey; + range: OverviewRange; +} + +export function OverviewTabContent({ tab, range }: OverviewTabContentProps) { + const bookings = useOverviewBookingsTab(range, tab === "bookings"); + const billing = useOverviewBillingTab(range, tab === "billing"); + const operations = useOverviewOperationsTab(tab === "operations"); + const customers = useOverviewCustomersTab(range, tab === "customers"); + const staff = useOverviewStaffTab(range, tab === "staff"); + + const query = + tab === "bookings" + ? bookings + : tab === "billing" + ? billing + : tab === "operations" + ? operations + : tab === "customers" + ? customers + : staff; + + const { isLoading, isError, refetch, isFetching } = query; + + if (isLoading) { + return ; + } + + if (isError || !query.data) { + return ( + + } + color="red" + title="Failed to load tab data" + variant="light" + > + + Could not load {tab} metrics. Please try again. + + + + + ); + } + + return ( + + {isFetching && ( +
+ +
+ )} + + {tab === "bookings" && bookings.data && ( + + )} + {tab === "billing" && billing.data && ( + + )} + {tab === "operations" && operations.data && ( + + )} + {tab === "customers" && customers.data && ( + + )} + {tab === "staff" && staff.data && ( + + )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/overview.styles.ts b/apps/edr-freight-web/backoffice/src/components/overview/overview.styles.ts new file mode 100644 index 000000000..81758126e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/overview.styles.ts @@ -0,0 +1,24 @@ +import { freightBrand } from "@/theme/freight-brand"; + +export const overviewChartColors = { + primary: freightBrand.primary, + primaryLight: freightBrand.primaryLight, + primaryDark: freightBrand.primaryDark, + muted: freightBrand.mutedBg, + etb: freightBrand.primary, + usd: "#0369a1", + pipeline: [ + freightBrand.primary, + "#22c55e", + "#0ea5e9", + "#6366f1", + "#f59e0b", + "#14b8a6", + "#64748b", + ], +} as const; + +export const overviewCardStyle = { + background: "white", + border: "1px solid var(--mantine-color-gray-2)", +} as const; diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx new file mode 100644 index 000000000..fd87793c3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx @@ -0,0 +1,137 @@ +import { Banknote, CreditCard, Wallet } from "lucide-react"; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Grid, Paper, Stack, Text } from "@mantine/core"; + +import type { IOverviewBillingTab } from "@/types/overview"; +import { OverviewDonutChart } from "../OverviewDonutChart"; +import { OverviewKpiStrip } from "../OverviewKpiStrip"; +import { OverviewPaymentChart } from "../OverviewPaymentChart"; +import { overviewChartColors } from "../overview.styles"; + +function formatCurrency(amount: number, currency: "ETB" | "USD") { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + maximumFractionDigits: 0, + }).format(amount); +} + +const METHOD_LABELS: Record = { + telebirr: "Telebirr", + "cbe-birr": "CBE Birr", + ebirr: "eBirr", +}; + +interface OverviewBillingTabPanelProps { + data: IOverviewBillingTab; +} + +export function OverviewBillingTabPanel({ data }: OverviewBillingTabPanelProps) { + const methodChartData = data.paymentsByMethod.map((item) => ({ + name: METHOD_LABELS[item.method] ?? item.method, + count: item.count, + })); + + return ( + + + + + + + + + ({ + name: item.currency, + value: item.amount, + }))} + emptyMessage="No revenue this month" + /> + + + + + + ({ + name: item.status.replace(/-/g, " "), + value: item.count, + }))} + /> + + + + + Payments by method + {methodChartData.length === 0 ? ( + + No payment methods recorded + + ) : ( + + + + + + + + + {methodChartData.map((entry, index) => ( + + ))} + + + + )} + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx new file mode 100644 index 000000000..fabac4abf --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx @@ -0,0 +1,101 @@ +import { + AlertCircle, + Clock, + FileText, + UserCheck, +} from "lucide-react"; +import { Grid, Stack } from "@mantine/core"; + +import { BOOKING_STATUS_META } from "@/features/bookings/booking-status.config"; +import type { IOverviewBookingsTab } from "@/types/overview"; +import { OverviewBookingTrendChart } from "../OverviewBookingTrendChart"; +import { OverviewDonutChart } from "../OverviewDonutChart"; +import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart"; +import { OverviewKpiStrip } from "../OverviewKpiStrip"; +import { OverviewRecentBookingsTable } from "../OverviewRecentBookingsTable"; +import { OverviewStatusChart } from "../OverviewStatusChart"; + +interface OverviewBookingsTabPanelProps { + data: IOverviewBookingsTab; +} + +export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps) { + return ( + + + + + + + + + + + + + + + ({ + name: BOOKING_STATUS_META[item.status]?.title ?? item.status, + value: item.count, + }))} + emptyMessage="No bookings yet" + /> + + + ({ + name: item.label, + value: item.count, + }))} + /> + + + + ({ + label: item.label, + value: item.count, + }))} + /> + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewCustomersTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewCustomersTabPanel.tsx new file mode 100644 index 000000000..3788dfed6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewCustomersTabPanel.tsx @@ -0,0 +1,120 @@ +import { Users } from "lucide-react"; +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Grid, Paper, Stack, Text } from "@mantine/core"; + +import type { IOverviewCustomersTab } from "@/types/overview"; +import { OverviewDonutChart } from "../OverviewDonutChart"; +import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart"; +import { OverviewKpiStrip } from "../OverviewKpiStrip"; +import { overviewChartColors } from "../overview.styles"; + +function formatDateLabel(date: string) { + const parsed = new Date(`${date}T00:00:00`); + return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +interface OverviewCustomersTabPanelProps { + data: IOverviewCustomersTab; +} + +export function OverviewCustomersTabPanel({ data }: OverviewCustomersTabPanelProps) { + const hasGrowth = data.customerGrowthTrend.some((point) => point.count > 0); + + return ( + + + + + + + + Customer growth + {!hasGrowth ? ( + + No new customers in this period + + ) : ( + + + + + + + + + + + + formatDateLabel(String(value))} + formatter={(value) => [value, "New customers"]} + /> + + + + )} + + + + + ({ + name: item.label, + value: item.count, + }))} + /> + + + + ({ + label: item.label, + value: item.count, + }))} + valueLabel="Bookings" + /> + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx new file mode 100644 index 000000000..355fb6b57 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx @@ -0,0 +1,88 @@ +import { Box, Container as ContainerIcon, Train, Truck } from "lucide-react"; +import { Grid, Stack } from "@mantine/core"; + +import type { IOverviewOperationsTab } from "@/types/overview"; +import { OverviewDonutChart } from "../OverviewDonutChart"; +import { OverviewKpiStrip } from "../OverviewKpiStrip"; + +interface OverviewOperationsTabPanelProps { + data: IOverviewOperationsTab; +} + +function formatStatusLabel(status: string) { + return status + .replace(/_/g, " ") + .toLowerCase() + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelProps) { + return ( + + + + + + ({ + name: formatStatusLabel(item.status), + value: item.count, + }))} + /> + + + ({ + name: formatStatusLabel(item.status), + value: item.count, + }))} + /> + + + ({ + name: formatStatusLabel(item.status), + value: item.count, + }))} + /> + + + ({ + name: formatStatusLabel(item.status), + value: item.count, + }))} + /> + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewStaffTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewStaffTabPanel.tsx new file mode 100644 index 000000000..27f0b1271 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewStaffTabPanel.tsx @@ -0,0 +1,118 @@ +import { UserCheck, Users } from "lucide-react"; +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Grid, Paper, Stack, Text } from "@mantine/core"; + +import type { IOverviewStaffTab } from "@/types/overview"; +import { OverviewDonutChart } from "../OverviewDonutChart"; +import { OverviewKpiStrip } from "../OverviewKpiStrip"; +import { overviewChartColors } from "../overview.styles"; + +function formatDateLabel(date: string) { + const parsed = new Date(`${date}T00:00:00`); + return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +interface OverviewStaffTabPanelProps { + data: IOverviewStaffTab; +} + +export function OverviewStaffTabPanel({ data }: OverviewStaffTabPanelProps) { + const hasGrowth = data.employeeGrowthTrend.some((point) => point.count > 0); + + return ( + + + + + + + + Employee onboarding trend + {!hasGrowth ? ( + + No new employees in this period + + ) : ( + + + + + + + + + + + + formatDateLabel(String(value))} + formatter={(value) => [value, "New employees"]} + /> + + + + )} + + + + + ({ + name: item.label, + value: item.count, + }))} + /> + + + + ({ + name: item.status.replace(/_/g, " "), + value: item.count, + }))} + /> + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ManageRuleEngineOrderDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ManageRuleEngineOrderDialog.tsx new file mode 100644 index 000000000..df5da1b17 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ManageRuleEngineOrderDialog.tsx @@ -0,0 +1,354 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { + DragDropContext, + Draggable, + Droppable, + type DraggableProvided, + type DraggableStateSnapshot, + type DropResult, +} from "@hello-pangea/dnd"; +import { GripVertical, Loader2 } from "lucide-react"; +import { + Badge, + Box, + Button, + Group, + Modal, + Stack, + Tabs, + Text, + TextInput, +} from "@mantine/core"; + +import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources"; +import type { RuleEngineRecord } from "@/types/rule-engine"; + +import { getOrderItemLabel, getOrderValue } from "./ruleEngineOrder.utils"; + +interface OrderDraftItem { + id: string; + label: string; + code?: string; + order: number; +} + +export interface ManageRuleEngineOrderDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + config: RuleEngineResourceConfig; + items: RuleEngineRecord[]; + isLoading: boolean; + isSaving: boolean; + onSave: (payload: { ids: string[]; requiresDirectorApproval?: boolean }) => void; +} + +const toDraftItems = ( + rows: RuleEngineRecord[], + config: RuleEngineResourceConfig, +): OrderDraftItem[] => { + const field = config.orderConfig!.field; + return [...rows] + .sort((a, b) => getOrderValue(a, field) - getOrderValue(b, field)) + .map((row) => ({ + id: String(row.id), + label: getOrderItemLabel(row, config.slug), + code: row.code ? String(row.code) : undefined, + order: getOrderValue(row, field), + })); +}; + +/** Reparent dragged row to body — fixes position:fixed inside Modal transforms. */ +const PortalAwareRow = ({ + snapshot, + children, +}: { + snapshot: DraggableStateSnapshot; + children: ReactNode; +}) => { + if (snapshot.isDragging) { + return createPortal(children, document.body); + } + return <>{children}; +}; + +const OrderRow = ({ + item, + index, + dragProvided, + snapshot, +}: { + item: OrderDraftItem; + index: number; + dragProvided: DraggableProvided; + snapshot: DraggableStateSnapshot; +}) => ( + + + + + + + {index + 1} + + + + {item.label} + + {item.code ? ( + + {item.code} + + ) : null} + + + +); + +const ManageRuleEngineOrderDialog = ({ + open, + onOpenChange, + config, + items, + isLoading, + isSaving, + onSave, +}: ManageRuleEngineOrderDialogProps) => { + const isScoped = config.orderConfig?.scopeField === "requiresDirectorApproval"; + const [tab, setTab] = useState<"standard" | "director">("standard"); + const [filter, setFilter] = useState(""); + const [standardItems, setStandardItems] = useState([]); + const [directorItems, setDirectorItems] = useState([]); + + useEffect(() => { + if (!open) return; + if (isScoped) { + setStandardItems( + toDraftItems( + items.filter((row) => !row.requiresDirectorApproval), + config, + ), + ); + setDirectorItems( + toDraftItems( + items.filter((row) => row.requiresDirectorApproval), + config, + ), + ); + } else { + setStandardItems(toDraftItems(items, config)); + } + setFilter(""); + }, [open, items, config, isScoped]); + + const activeItems = isScoped + ? tab === "director" + ? directorItems + : standardItems + : standardItems; + + const setActiveItems = isScoped + ? tab === "director" + ? setDirectorItems + : setStandardItems + : setStandardItems; + + const filteredItems = useMemo(() => { + const q = filter.trim().toLowerCase(); + if (!q) return activeItems; + return activeItems.filter( + (item) => + item.label.toLowerCase().includes(q) || + (item.code?.toLowerCase().includes(q) ?? false), + ); + }, [activeItems, filter]); + + const droppableId = isScoped + ? `rule-engine-order-${tab}` + : "rule-engine-order-list"; + + const onDragEnd = (result: DropResult) => { + if (!result.destination || filter.trim()) return; + const sourceIndex = result.source.index; + const destIndex = result.destination.index; + if (sourceIndex === destIndex) return; + + setActiveItems((prev) => { + const next = [...prev]; + const [removed] = next.splice(sourceIndex, 1); + next.splice(destIndex, 0, removed!); + return next.map((item, index) => ({ ...item, order: index + 1 })); + }); + }; + + const handleSave = () => { + if (isScoped) { + onSave({ + ids: (tab === "director" ? directorItems : standardItems).map((item) => item.id), + requiresDirectorApproval: tab === "director", + }); + return; + } + onSave({ ids: standardItems.map((item) => item.id) }); + }; + + const renderList = (listItems: OrderDraftItem[]) => ( + + {(provided) => ( + + {listItems.length === 0 ? ( + + No items to reorder. + + ) : ( + listItems.map((item, index) => ( + + {(dragProvided, snapshot) => ( + + )} + + )) + )} + {provided.placeholder} + + )} + + ); + + return ( + onOpenChange(false)} + title={`Manage order · ${config.label}`} + centered + size="lg" + radius="lg" + transitionProps={{ duration: 0, transition: "fade" }} + styles={{ + content: { + transform: "none", + overflow: "visible", + }, + body: { + overflow: "visible", + }, + }} + > + + + + Drag items anywhere in the list to set display order. Changes apply when you save. + + + {isLoading ? ( + + + + ) : isScoped ? ( + setTab((value as "standard" | "director") ?? "standard")} + > + + Standard chain ({standardItems.length}) + Director chain ({directorItems.length}) + + + + setFilter(e.currentTarget.value)} + /> + + {renderList(filteredItems)} + + + + + + setFilter(e.currentTarget.value)} + /> + + {renderList(filteredItems)} + + + + + ) : ( + <> + setFilter(e.currentTarget.value)} + /> + + {renderList(filteredItems)} + + + )} + + {filter.trim() ? ( + + Clear the filter to drag and reorder items. + + ) : null} + + + + + + + + + ); +}; + +export default ManageRuleEngineOrderDialog; diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx index ae0ac7821..9e8fb72c2 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx @@ -1,8 +1,10 @@ -import { Stack, Group, Text, Pagination, Card, SimpleGrid } from "@mantine/core"; +import type { OnChangeFn, PaginationState } from "@tanstack/react-table"; +import { Stack, Group, Text, Card, SimpleGrid } from "@mantine/core"; import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources"; import type { RuleEngineRecord } from "@/types/rule-engine"; +import RuleEngineListFooter from "./RuleEngineListFooter"; import RuleEngineRecordActions from "./RuleEngineRecordActions"; import { cardInitials, resolveCardPresentation } from "./ruleEngineCardMeta"; import { formatCell } from "./ruleEngineFormat"; @@ -13,12 +15,10 @@ export interface RuleEngineCardGridProps { status: "loading" | "error" | "success"; emptyMessage: string; itemLabel: string; - pagination: { - pageIndex: number; - pageSize: number; - pageCount: number; - totalCount: number; - }; + pagination: PaginationState; + pageCount: number; + totalCount: number; + onPaginationChange: OnChangeFn; onEdit?: (record: RuleEngineRecord) => void; onDelete?: (record: RuleEngineRecord) => void; readOnly?: boolean; @@ -71,6 +71,9 @@ const RuleEngineCardGrid = ({ emptyMessage, itemLabel, pagination, + pageCount, + totalCount, + onPaginationChange, onEdit, onDelete, onViewChain, @@ -249,19 +252,13 @@ const RuleEngineCardGrid = ({ })} - {pagination.pageCount > 1 && ( - - - Showing {Math.min(rows.length, pagination.pageSize)} of {pagination.totalCount} {itemLabel} - - - - )} + ); }; diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index ae5914370..cc8a68688 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -20,6 +20,7 @@ import { type FormFieldDef, } from "@/pages/ruleEngine/config/resources"; import type { RuleEngineRecord } from "@/types/rule-engine"; +import { RULE_ENGINE_POSITION_END } from "./ruleEngineOrder.utils"; export interface RuleEngineFormDialogProps { open: boolean; @@ -30,6 +31,8 @@ export interface RuleEngineFormDialogProps { initialRecord?: RuleEngineRecord | null; isSubmitting: boolean; selectOptionsLoading?: boolean; + positionOptions?: { label: string; value: string }[]; + positionLoading?: boolean; onSubmit: (values: Record) => void; } @@ -146,15 +149,19 @@ const RuleEngineFormDialog = ({ initialRecord, isSubmitting, selectOptionsLoading = false, + positionOptions, + positionLoading = false, onSubmit, }: RuleEngineFormDialogProps) => { const [values, setValues] = useState>(() => buildInitialValues(fields, initialRecord), ); + const [position, setPosition] = useState(RULE_ENGINE_POSITION_END); useEffect(() => { if (open) { setValues(buildInitialValues(fields, initialRecord)); + setPosition(RULE_ENGINE_POSITION_END); } }, [open, fields, initialRecord]); @@ -192,6 +199,10 @@ const RuleEngineFormDialog = ({ payload.code = String(payload.code).toUpperCase(); } + if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) { + payload.insertAfterId = position; + } + onSubmit(payload); }; @@ -315,6 +326,23 @@ const RuleEngineFormDialog = ({ + {!initialRecord && positionOptions ? ( + value && setPageSize(Number(value))} + data={PAGE_SIZE_OPTIONS} + size="xs" + w={70} + allowDeselect={false} + /> + + + Showing {start}–{end} of {totalCount} {itemLabel} + + + + {pageCount > 1 && ( + setPageIndex(page - 1)} + /> + )} + + ); +}; + +export default RuleEngineListFooter; diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineOrderControls.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineOrderControls.tsx new file mode 100644 index 000000000..811b2106a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineOrderControls.tsx @@ -0,0 +1,60 @@ +import { ActionIcon, Group, Tooltip } from "@mantine/core"; +import { ChevronDown, ChevronUp } from "lucide-react"; + +import type { RuleEngineOrderConfig } from "@/pages/ruleEngine/config/resources"; +import type { RuleEngineRecord } from "@/types/rule-engine"; + +import { getOrderValue } from "./ruleEngineOrder.utils"; + +export interface RuleEngineOrderControlsProps { + record: RuleEngineRecord; + orderConfig: RuleEngineOrderConfig; + totalCount: number; + disabled?: boolean; + onMove: (id: string, direction: "up" | "down") => void; +} + +const RuleEngineOrderControls = ({ + record, + orderConfig, + totalCount, + disabled = false, + onMove, +}: RuleEngineOrderControlsProps) => { + const id = String(record.id); + const order = getOrderValue(record, orderConfig.field); + + const canMoveUp = order > 1; + const canMoveDown = orderConfig.scopeField ? true : order < totalCount; + + return ( + + + onMove(id, "up")} + aria-label="Move up" + > + + + + + onMove(id, "down")} + aria-label="Move down" + > + + + + + ); +}; + +export default RuleEngineOrderControls; diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineToolbar.tsx index 05fa72b7f..5f49acc2b 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineToolbar.tsx @@ -1,42 +1,50 @@ -import { LayoutGrid, Plus, Search, Table2 } from "lucide-react"; +import { LayoutGrid, ListOrdered, Plus, Search, Table2 } from "lucide-react"; import { Button, TextInput, Group, SegmentedControl } from "@mantine/core"; import type { RuleEngineViewMode } from "./useRuleEngineViewMode"; export interface RuleEngineToolbarProps { - search: string; - onSearchChange: (value: string) => void; - searchPlaceholder: string; + search?: string; + onSearchChange?: (value: string) => void; + searchPlaceholder?: string; + showSearch?: boolean; onAdd?: () => void; addLabel?: string; + onManageOrder?: () => void; viewMode: RuleEngineViewMode; onViewModeChange: (mode: RuleEngineViewMode) => void; } const RuleEngineToolbar = ({ - search, + search = "", onSearchChange, - searchPlaceholder, + searchPlaceholder = "Search…", + showSearch = true, onAdd, addLabel = "Add", + onManageOrder, viewMode, onViewModeChange, }: RuleEngineToolbarProps) => ( - onSearchChange(e.currentTarget.value)} - leftSection={} - size="md" - radius="lg" - style={{ flex: 1, minWidth: 0 }} - styles={{ - input: { - borderColor: "var(--mantine-color-gray-3)", - }, - }} - /> + {showSearch && onSearchChange ? ( + onSearchChange(e.currentTarget.value)} + leftSection={} + size="md" + radius="lg" + style={{ flex: 1, minWidth: 0 }} + styles={{ + input: { + borderColor: "var(--mantine-color-gray-3)", + }, + }} + /> + ) : ( +
+ )} + {onManageOrder ? ( + + ) : null} + {onAdd ? ( + + + )} + + + setActiveTab((value as OverviewTabKey) ?? "bookings")} + variant="pills" + color="green" + keepMounted={false} + > + + {TAB_ITEMS.map((tab) => { + const Icon = tab.icon; + return ( + } + rightSection={ + summary ? ( + + {getTabBadge(tab)} + + ) : undefined + } + style={{ fontWeight: 600 }} + > + {tab.label} + + ); + })} + + + {TAB_ITEMS.map((tab) => ( + + + + ))} + + + + + + + + ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 5900ba5c6..57c06a42c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Navigate, useLocation, useParams } from "react-router-dom"; import { useAuth } from "@/auth/useAuth"; import { canAccessRuleEngineResource } from "@/lib/permissions"; @@ -8,7 +8,10 @@ import { Card, Button, Modal, Stack, Group, Text, List } from "@mantine/core"; import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid"; import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog"; +import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog"; +import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls"; import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordActions"; +import { getOrderItemLabel } from "@/components/ruleEngine/ruleEngineOrder.utils"; import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar"; import { formatCell } from "@/components/ruleEngine/ruleEngineFormat"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; @@ -29,6 +32,8 @@ import { useRateWorkflow, useRuleEngineList, useRuleEngineMutations, + useRuleEngineOrderList, + useRuleEngineOrderMutations, } from "@/hooks/rule-engine/useRuleEngine"; import type { RuleEngineRecord } from "@/types/rule-engine"; import { @@ -65,6 +70,7 @@ const RuleEngineResourcePage = () => { const [editing, setEditing] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [chainOpen, setChainOpen] = useState(false); + const [orderDialogOpen, setOrderDialogOpen] = useState(false); const { viewMode, setViewMode } = useRuleEngineViewMode( config?.slug ?? DEFAULT_CONFIGURATION_SLUG, ); @@ -81,10 +87,27 @@ const RuleEngineResourcePage = () => { search: config?.supportsSearch ? search.trim() || undefined : undefined, page: pagination.pageIndex + 1, pageSize: pagination.pageSize, + ...(config?.orderConfig + ? { + sortBy: config.orderConfig.field, + sortOrder: "ASC" as const, + } + : {}), }), - [config?.supportsSearch, search, pagination.pageIndex, pagination.pageSize], + [ + config?.orderConfig, + config?.supportsSearch, + search, + pagination.pageIndex, + pagination.pageSize, + ], ); + useEffect(() => { + setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); + setSearch(""); + }, [config?.slug, setPagination]); + const { data, isLoading, isError, error } = useRuleEngineList( config?.slug ?? DEFAULT_CONFIGURATION_SLUG, listParams, @@ -93,6 +116,14 @@ const RuleEngineResourcePage = () => { const { create, update, remove } = useRuleEngineMutations( config?.slug ?? DEFAULT_CONFIGURATION_SLUG, ); + const { reorder, moveOrder } = useRuleEngineOrderMutations( + config?.slug ?? DEFAULT_CONFIGURATION_SLUG, + ); + const { data: orderListData, isLoading: orderListLoading } = useRuleEngineOrderList( + config?.slug ?? DEFAULT_CONFIGURATION_SLUG, + Boolean(orderDialogOpen && config?.orderConfig), + config?.orderConfig?.field, + ); const { submit, approve } = useRateWorkflow(); const { data: chainData, isLoading: chainLoading } = useApprovalChain( chainOpen && config?.slug === "approval-rules", @@ -149,25 +180,24 @@ const RuleEngineResourcePage = () => { const rows = data?.data ?? []; const meta = data?.meta; const pageCount = meta?.totalPages ?? 1; + const totalCount = meta?.total ?? rows.length; - const filteredRows = useMemo(() => { - if (config?.supportsSearch || !search.trim()) return rows; - const q = search.trim().toLowerCase(); - return rows.filter((row) => - JSON.stringify(row).toLowerCase().includes(q), - ); - }, [rows, search, config?.supportsSearch]); - - const paginationState = useMemo( - () => ({ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount, - totalCount: meta?.total ?? filteredRows.length, - }), - [filteredRows.length, meta?.total, pageCount, pagination.pageIndex, pagination.pageSize], + const { data: createPositionList, isLoading: createPositionLoading } = useRuleEngineOrderList( + config?.slug ?? DEFAULT_CONFIGURATION_SLUG, + Boolean(formOpen && !editing && config?.orderConfig), + config?.orderConfig?.field, ); + const createPositionOptions = useMemo(() => { + if (!config?.orderConfig || !createPositionList?.data?.length) return undefined; + return createPositionList.data + .filter((row) => row.id) + .map((row) => ({ + label: getOrderItemLabel(row, config.slug), + value: String(row.id), + })); + }, [config?.orderConfig, config?.slug, createPositionList?.data]); + const handleApproveRate = useCallback( (record: RuleEngineRecord) => { @@ -176,6 +206,13 @@ const RuleEngineResourcePage = () => { [approve], ); + const handleMoveOrder = useCallback( + (id: string, direction: "up" | "down") => { + moveOrder.mutate({ id, direction }); + }, + [moveOrder], + ); + const columns = useMemo((): ColumnDef[] => { if (!config) return []; @@ -192,36 +229,47 @@ const RuleEngineResourcePage = () => { base.push({ id: "actions", header: "Actions", - size: 140, - minSize: 120, + size: config.orderConfig ? 200 : 140, + minSize: config.orderConfig ? 180 : 120, meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap`, }, cell: ({ row }) => (
e.stopPropagation()} data-stop-row-click> - { - setEditing(record); - setFormOpen(true); - }} - onDelete={setDeleteTarget} - onViewChain={ - config.slug === "approval-rules" ? () => setChainOpen(true) : undefined - } - onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined} - onApproveRate={canManage ? handleApproveRate : undefined} - /> + + {config.orderConfig && canManage ? ( + + ) : null} + { + setEditing(record); + setFormOpen(true); + }} + onDelete={setDeleteTarget} + onViewChain={ + config.slug === "approval-rules" ? () => setChainOpen(true) : undefined + } + onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined} + onApproveRate={canManage ? handleApproveRate : undefined} + /> +
), }); return base; - }, [canManage, config, submit, handleApproveRate]); + }, [canManage, config, submit, handleApproveRate, handleMoveOrder, moveOrder.isPending, totalCount]); const tableStatus = isLoading ? "loading" : isError ? "error" : "success"; @@ -276,13 +324,21 @@ const RuleEngineResourcePage = () => { { - setSearch(v); - setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); - }} + onSearchChange={ + config.supportsSearch + ? (v) => { + setSearch(v); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + } + : undefined + } + showSearch={Boolean(config.supportsSearch)} searchPlaceholder={config.searchPlaceholder} onAdd={canManage ? openCreate : undefined} addLabel={`Add ${config.label.replace(/s$/, "")}`} + onManageOrder={ + canManage && config.orderConfig ? () => setOrderDialogOpen(true) : undefined + } viewMode={viewMode} onViewModeChange={setViewMode} /> @@ -290,7 +346,7 @@ const RuleEngineResourcePage = () => { {viewMode === "table" ? ( { : undefined } emptyMessage={`No ${itemLabel} found.`} - pagination={paginationState} + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount, + }} tableOptions={{ manualPagination: true, pageCount, @@ -328,11 +389,14 @@ const RuleEngineResourcePage = () => { ) : ( { (usesContainerTypeField && containerTypeOptionsLoading) || (usesLiveRateField && liveRateOptionsLoading) } + positionOptions={!editing ? createPositionOptions : undefined} + positionLoading={createPositionLoading} onSubmit={handleFormSubmit} /> + {config.orderConfig ? ( + { + reorder.mutate(payload, { + onSuccess: () => setOrderDialogOpen(false), + }); + }} + /> + ) : null} + setDeleteTarget(null)} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index d95da8365..f7f301a00 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -36,6 +36,12 @@ export interface FormFieldDef { placeholder?: string; } +export interface RuleEngineOrderConfig { + field: "displayOrder" | "stepOrder"; + scopeField?: "requiresDirectorApproval"; + label: string; +} + export interface RuleEngineResourceConfig { slug: RuleEngineResourceSlug; label: string; @@ -45,6 +51,7 @@ export interface RuleEngineResourceConfig { columns: ResourceColumn[]; formFields: FormFieldDef[]; supportsSearch?: boolean; + orderConfig?: RuleEngineOrderConfig; /** Primary line on card view (inferred from columns when omitted). */ cardTitleKey?: string; /** Secondary line under title on card view (inferred when omitted). */ @@ -130,6 +137,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ subtitle: "Manage freight cargo classification and approval rules", searchPlaceholder: "Search cargo types by name or code...", supportsSearch: true, + orderConfig: { field: "displayOrder", label: "Display order" }, columns: [ codeColumn("code"), { id: "cargoTypeName", header: "Name", accessorKey: "cargoTypeName" }, @@ -154,7 +162,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ { name: "showFreeTextBox", label: "Show free text box", type: "boolean" }, { name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" }, { name: "isActive", label: "Active", type: "boolean" }, - { name: "displayOrder", label: "Display order", type: "number" }, ], }, { @@ -163,9 +170,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ category: "configuration", subtitle: "Configure container sizes and wagon capacity", searchPlaceholder: "Search container types...", + orderConfig: { field: "displayOrder", label: "Display order" }, columns: [ codeColumn("code"), { id: "label", header: "Label", accessorKey: "label" }, + { id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" }, { id: "sizeFt", header: "Size (ft)", accessorKey: "sizeFt", format: "number" }, { id: "wagonsPerUnit", header: "Wagons / unit", accessorKey: "wagonsPerUnit", format: "number" }, activeColumn, @@ -177,7 +186,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ { name: "isReefer", label: "Reefer", type: "boolean" }, { name: "isOpenTop", label: "Open top", type: "boolean" }, { name: "isActive", label: "Active", type: "boolean" }, - { name: "displayOrder", label: "Display order", type: "number" }, ], }, { @@ -254,9 +262,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ subtitle: "Freight service offerings and booking options", searchPlaceholder: "Search service types...", supportsSearch: true, + orderConfig: { field: "displayOrder", label: "Display order" }, columns: [ codeColumn("code"), { id: "serviceName", header: "Service name", accessorKey: "serviceName" }, + { id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" }, { id: "priorityBonusPoints", header: "Bonus pts", accessorKey: "priorityBonusPoints", format: "number" }, activeColumn, ], @@ -269,7 +279,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ { name: "includesCustoms", label: "Includes customs", type: "boolean" }, { name: "priorityBonusPoints", label: "Priority bonus points", type: "number" }, { name: "isActive", label: "Active", type: "boolean" }, - { name: "displayOrder", label: "Display order", type: "number" }, ], }, { @@ -350,6 +359,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ category: "configuration", subtitle: "Terminal and yard locations", searchPlaceholder: "Search yards...", + orderConfig: { field: "displayOrder", label: "Display order" }, columns: [ codeColumn("code"), { id: "label", header: "Label", accessorKey: "label" }, @@ -361,7 +371,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ { name: "label", label: "Label", type: "text", required: true }, { name: "country", label: "Country", type: "text", required: true }, { name: "isActive", label: "Active", type: "boolean" }, - { name: "displayOrder", label: "Display order", type: "number" }, ], }, { @@ -436,6 +445,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ cardSubtitleKey: "requiredRole", subtitle: "Multi-step booking approval chain", searchPlaceholder: "Search approval rules...", + orderConfig: { + field: "stepOrder", + scopeField: "requiresDirectorApproval", + label: "Step order", + }, columns: [ { id: "requiresDirectorApproval", @@ -450,7 +464,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ ], formFields: [ { name: "requiresDirectorApproval", label: "Requires director approval chain", type: "boolean" }, - { name: "stepOrder", label: "Step order", type: "number", required: true }, { name: "requiredRole", label: "Required role", diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 4aab4bd98..14bf3454b 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -35,6 +35,8 @@ import { type RejectStepPayload, } from "./bookings.service"; import type { BookingDetail } from "@/types/booking"; +import type { IOverviewDashboard, OverviewRange } from "@/types/overview"; +import { overviewService } from "./overview.service"; export const api = { fileUploadSettings: { @@ -233,6 +235,20 @@ export const api = { () => ruleEngineService.getApprovalChain(), () => QUERY_KEYS.RULE_ENGINE.chain, ), + + reorder: endpoint< + { resource: RuleEngineResourceSlug; payload: { ids: string[]; requiresDirectorApproval?: boolean } }, + void + >("rule-engine", "reorder", ({ resource, payload }) => + ruleEngineService.reorder(resource, payload), + ), + + moveOrder: endpoint< + { resource: RuleEngineResourceSlug; id: string; direction: "up" | "down" }, + void + >("rule-engine", "moveOrder", ({ resource, id, direction }) => + ruleEngineService.moveOrder(resource, id, direction), + ), }, bookings: { @@ -329,4 +345,12 @@ export const api = { ({ id, reason }) => bookingsService.cancel(id, reason), ), }, + + overview: { + get: endpoint<{ range?: OverviewRange }, IOverviewDashboard>( + "overview", + "get", + ({ range }) => overviewService.getDashboard(range), + ), + }, }; diff --git a/apps/edr-freight-web/backoffice/src/services/overview.service.ts b/apps/edr-freight-web/backoffice/src/services/overview.service.ts new file mode 100644 index 000000000..d0dd79922 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/overview.service.ts @@ -0,0 +1,56 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; +import { URL_CONSTANTS } from "@/constants/URLS"; +import type { + IOverviewBillingTab, + IOverviewBookingsTab, + IOverviewCustomersTab, + IOverviewDashboard, + IOverviewOperationsTab, + IOverviewStaffTab, + OverviewRange, +} from "@/types/overview"; + +const O = URL_CONSTANTS.OVERVIEW; + +export const overviewService = { + getDashboard: async (range?: OverviewRange): Promise => { + const response = await client.get(O.BASE, { + params: range ? { range } : undefined, + }); + return unwrap(response); + }, + + getBookingsTab: async (range?: OverviewRange): Promise => { + const response = await client.get(O.BOOKINGS, { + params: range ? { range } : undefined, + }); + return unwrap(response); + }, + + getBillingTab: async (range?: OverviewRange): Promise => { + const response = await client.get(O.BILLING, { + params: range ? { range } : undefined, + }); + return unwrap(response); + }, + + getOperationsTab: async (): Promise => { + const response = await client.get(O.OPERATIONS); + return unwrap(response); + }, + + getCustomersTab: async (range?: OverviewRange): Promise => { + const response = await client.get(O.CUSTOMERS, { + params: range ? { range } : undefined, + }); + return unwrap(response); + }, + + getStaffTab: async (range?: OverviewRange): Promise => { + const response = await client.get(O.STAFF, { + params: range ? { range } : undefined, + }); + return unwrap(response); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts index b1846e0cc..38328c782 100644 --- a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts @@ -14,6 +14,14 @@ export interface RuleEngineListParams { pageSize?: number; isActive?: boolean; status?: string; + sortBy?: string; + sortOrder?: "ASC" | "DESC"; + requiresDirectorApproval?: boolean; +} + +export interface RuleEngineReorderPayload { + ids: string[]; + requiresDirectorApproval?: boolean; } const RESOURCE_BASE: Record = { @@ -59,25 +67,39 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => { } }; -const defaultMeta = (dataLength: number, page = 1, pageSize = 20): RuleEngineListMeta => ({ +const defaultMeta = (dataLength: number, page = 1, pageSize = 10): RuleEngineListMeta => ({ total: dataLength, page, pageSize, totalPages: Math.max(1, Math.ceil(dataLength / pageSize)), }); +const isPaginatedListResult = ( + value: unknown, +): value is RuleEngineListResult => + Boolean(value) && + typeof value === "object" && + "data" in value && + Array.isArray((value as RuleEngineListResult).data); + const normalizeList = ( payload: unknown, page = 1, - pageSize = 20, + pageSize = 10, ): RuleEngineListResult => { + if (isPaginatedListResult(payload)) { + return { + data: payload.data, + meta: payload.meta ?? defaultMeta(payload.data.length, page, pageSize), + }; + } + const body = unwrap(payload as { data: unknown }) as unknown; - if (body && typeof body === "object" && "data" in body && Array.isArray((body as RuleEngineListResult).data)) { - const typed = body as RuleEngineListResult; + if (isPaginatedListResult(body)) { return { - data: typed.data, - meta: typed.meta ?? defaultMeta(typed.data.length, page, pageSize), + data: body.data, + meta: body.meta ?? defaultMeta(body.data.length, page, pageSize), }; } @@ -98,7 +120,7 @@ export const ruleEngineService = { params?: RuleEngineListParams, ): Promise> => { const page = params?.page ?? 1; - const pageSize = params?.pageSize ?? 20; + const pageSize = params?.pageSize ?? 10; const response = await client.get(RESOURCE_BASE[resource], { params: { page, @@ -106,6 +128,9 @@ export const ruleEngineService = { search: params?.search, isActive: params?.isActive, status: params?.status, + sortBy: params?.sortBy, + sortOrder: params?.sortOrder, + requiresDirectorApproval: params?.requiresDirectorApproval, }, }); return normalizeList(response.data, page, pageSize); @@ -140,6 +165,21 @@ export const ruleEngineService = { await client.delete(byIdPath(resource, id)); }, + reorder: async ( + resource: RuleEngineResourceSlug, + payload: RuleEngineReorderPayload, + ): Promise => { + await client.post(`${RESOURCE_BASE[resource]}/reorder`, payload); + }, + + moveOrder: async ( + resource: RuleEngineResourceSlug, + id: string, + direction: "up" | "down", + ): Promise => { + await client.post(`${byIdPath(resource, id)}/move-order`, { direction }); + }, + submitRate: async (id: string): Promise => { const response = await client.post(URL_CONSTANTS.RULE_ENGINE.RATE_SUBMIT(id)); return normalizeEntity(response.data); diff --git a/apps/edr-freight-web/backoffice/src/types/overview.ts b/apps/edr-freight-web/backoffice/src/types/overview.ts new file mode 100644 index 000000000..e534f44e8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/types/overview.ts @@ -0,0 +1,24 @@ +export type { + IOverviewDashboard, + IOverviewKpis, + IOverviewBookingKpis, + IOverviewOperationsKpis, + IOverviewCustomerKpis, + IOverviewBillingKpis, + IOverviewStaffKpis, + IOverviewTrendPoint, + IOverviewStatusCount, + IOverviewPipelineCount, + IOverviewPaymentTrendPoint, + IOverviewRecentBooking, + IOverviewLabelCount, + IOverviewPaymentMethodBreakdown, + IOverviewCurrencyAmount, + IOverviewBookingsTab, + IOverviewBillingTab, + IOverviewOperationsTab, + IOverviewCustomersTab, + IOverviewStaffTab, + OverviewRange, + OverviewTabKey, +} from "@edr/types/freight"; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 7e20712d3..50c8993e8 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -2,6 +2,7 @@ import type { BaseEntity } from "../common"; export * from "./file_upload_settings"; export * from "./dropdown_settings"; +export * from "./overview"; export enum TradeDirection { IMPORT = 'IMPORT', diff --git a/packages/types/src/freight/overview.ts b/packages/types/src/freight/overview.ts new file mode 100644 index 000000000..bae92b607 --- /dev/null +++ b/packages/types/src/freight/overview.ts @@ -0,0 +1,152 @@ +export type OverviewRange = '7d' | '30d' | '90d'; + +export interface IOverviewBookingKpis { + totalActive: number; + needsAction: number; + urgent: number; + inApproval: number; + submittedToday: number; +} + +export interface IOverviewOperationsKpis { + trainsActive: number; + wagonsAvailable: number; + containersInTransit: number; + cargoesLoaded: number; +} + +export interface IOverviewCustomerKpis { + totalCustomers: number; + newCustomersThisMonth: number; +} + +export interface IOverviewBillingKpis { + revenueMtdEtb: number; + revenueMtdUsd: number; + pendingPayments: number; + successfulPaymentsMtd: number; +} + +export interface IOverviewStaffKpis { + activeEmployees: number; + activeUsers: number; +} + +export interface IOverviewKpis { + bookings: IOverviewBookingKpis; + operations: IOverviewOperationsKpis; + customers: IOverviewCustomerKpis; + billing: IOverviewBillingKpis; + staff: IOverviewStaffKpis; +} + +export interface IOverviewTrendPoint { + date: string; + count: number; +} + +export interface IOverviewStatusCount { + status: string; + count: number; +} + +export interface IOverviewPipelineCount { + stage: string; + count: number; +} + +export interface IOverviewPaymentTrendPoint { + date: string; + amountEtb: number; + amountUsd: number; +} + +export interface IOverviewRecentBooking { + id: string; + reference: string; + customerLabel: string; + status: string; + priorityScore: number; + totalAmount: number | null; + paymentCurrency: string | null; + createdAt: string; +} + +export interface IOverviewDashboard { + kpis: IOverviewKpis; + bookingTrend: IOverviewTrendPoint[]; + bookingsByStatus: IOverviewStatusCount[]; + bookingsByPipeline: IOverviewPipelineCount[]; + paymentTrend: IOverviewPaymentTrendPoint[]; + recentBookings: IOverviewRecentBooking[]; + generatedAt: string; +} + +export interface IOverviewLabelCount { + label: string; + count: number; +} + +export interface IOverviewPaymentMethodBreakdown { + method: string; + count: number; + amountEtb: number; + amountUsd: number; +} + +export interface IOverviewCurrencyAmount { + currency: string; + amount: number; +} + +export interface IOverviewBookingsTab { + kpis: IOverviewBookingKpis; + bookingTrend: IOverviewTrendPoint[]; + bookingsByStatus: IOverviewStatusCount[]; + bookingsByPipeline: IOverviewPipelineCount[]; + bookingsByFreightType: IOverviewLabelCount[]; + bookingsByCurrency: IOverviewLabelCount[]; + recentBookings: IOverviewRecentBooking[]; + generatedAt: string; +} + +export interface IOverviewBillingTab { + kpis: IOverviewBillingKpis; + paymentTrend: IOverviewPaymentTrendPoint[]; + paymentsByStatus: IOverviewStatusCount[]; + paymentsByMethod: IOverviewPaymentMethodBreakdown[]; + revenueByCurrency: IOverviewCurrencyAmount[]; + generatedAt: string; +} + +export interface IOverviewOperationsTab { + kpis: IOverviewOperationsKpis; + trainStatusBreakdown: IOverviewStatusCount[]; + wagonStatusBreakdown: IOverviewStatusCount[]; + containerStatusBreakdown: IOverviewStatusCount[]; + cargoStatusBreakdown: IOverviewStatusCount[]; + generatedAt: string; +} + +export interface IOverviewCustomersTab { + kpis: IOverviewCustomerKpis; + customerGrowthTrend: IOverviewTrendPoint[]; + customersByType: IOverviewLabelCount[]; + topCustomersByBookings: IOverviewLabelCount[]; + generatedAt: string; +} + +export interface IOverviewStaffTab { + kpis: IOverviewStaffKpis; + usersByStatus: IOverviewStatusCount[]; + employeeGrowthTrend: IOverviewTrendPoint[]; + activeUsersBreakdown: IOverviewLabelCount[]; + generatedAt: string; +} + +export type OverviewTabKey = + | 'bookings' + | 'billing' + | 'operations' + | 'customers' + | 'staff'; From 41061462ff4d1916e95faf9cf2f53fdb00cb6d17 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Mon, 8 Jun 2026 16:05:03 +0300 Subject: [PATCH 002/100] feat(ui): Integrate Mantine UI library, introduce AppLayout, and refactor dashboard --- apps/edr-freight-web/portal/package.json | 2 + apps/edr-freight-web/portal/src/App.tsx | 46 +- .../portal/src/components/AppLayout.tsx | 427 +++++++++ apps/edr-freight-web/portal/src/main.tsx | 15 +- .../portal/src/pages/MyPortalPage.tsx | 809 +++++++++++------- .../portal/src/theme/mantine.ts | 147 ++++ pnpm-lock.yaml | 6 + 7 files changed, 1110 insertions(+), 342 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/components/AppLayout.tsx create mode 100644 apps/edr-freight-web/portal/src/theme/mantine.ts diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 582a2fc0b..ae79d7562 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -15,6 +15,8 @@ "@edr/types": "workspace:*", "@edr/ui-common": "workspace:*", "@hookform/resolvers": "^5.4.0", + "@mantine/core": "^9.3.0", + "@mantine/hooks": "^9.3.0", "@tanstack/react-query": "^5.59.0", "@tria-plc/iamui-common": "1.1.2", "axios": "^1.7.7", diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 36922cf4a..048c11945 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -3,10 +3,9 @@ import { useLocation, Routes, Route, - Navigate, Outlet, } from "react-router-dom"; -import { DashboardLayout, type SidebarItem } from "@edr/ui-common"; +import { AppLayout, type SidebarItem } from "@/components/AppLayout"; import { CalendarCheck, MapPin, @@ -39,12 +38,37 @@ import BillingPage from "./pages/billing/BillingPage"; import { useEffect } from "react"; const sidebarItems: SidebarItem[] = [ - { label: "Home", href: "/portal", icon: }, - { label: "My Bookings", href: "/bookings", icon: }, - { label: "Tracking", href: "/tracking", icon: }, - { label: "Billing", href: "/billing", icon: }, - { label: "Profile", href: "/profile", icon: }, - { label: "Settings", href: "/settings", icon: }, + { section: "Overview", label: "Home", href: "/portal", icon: }, + { + section: "Operations", + label: "My Bookings", + href: "/bookings", + icon: , + }, + { + section: "Operations", + label: "Tracking", + href: "/tracking", + icon: , + }, + { + section: "Operations", + label: "Billing", + href: "/billing", + icon: , + }, + { + section: "Account", + label: "Profile", + href: "/profile", + icon: , + }, + { + section: "Account", + label: "Settings", + href: "/settings", + icon: , + }, ]; const App = () => { @@ -65,7 +89,7 @@ const App = () => { if (user && location.pathname === "/") navigate("/portal"); else if (!customer && !!isInProtectedRoutes) navigate("/onboarding"); - }, [user, location, customer]); + }, [user, location, customer, customerQuery.isPending]); if (isPending) { return ( @@ -94,7 +118,7 @@ const App = () => { { onLogout={logout} > - + } > } /> diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx new file mode 100644 index 000000000..fcc26ea4a --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -0,0 +1,427 @@ +import { Fragment, type ReactNode, useState } from "react"; +import { + ActionIcon, + AppShell, + Avatar, + Box, + Burger, + Divider, + Group, + Indicator, + Menu, + NavLink, + ScrollArea, + Stack, + Text, + UnstyledButton, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { + Bell, + ChevronDown, + Languages, + LogOut, + Moon, + Sun, + Train, + User, +} from "lucide-react"; + +export interface SidebarItem { + label: string; + href: string; + icon?: ReactNode; + children?: SidebarItem[]; + /** Optional group heading; a small label is rendered when it changes. */ + section?: string; +} + +export interface AppLayoutProps { + title?: string; + sidebarItems: SidebarItem[]; + activeHref?: string; + onNavigate?: (href: string) => void; + enableThemeToggle?: boolean; + userName?: string; + userEmail?: string; + onLogout?: () => void; + children: ReactNode; +} + +type Theme = "light" | "dark"; +const THEME_KEY = "edr-theme"; + +function getStoredTheme(): Theme { + if (typeof window === "undefined") return "light"; + const stored = localStorage.getItem(THEME_KEY); + if (stored === "dark" || stored === "light") return stored; + return window.matchMedia?.("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +function getInitials(name: string): string { + return name + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((n) => n[0].toUpperCase()) + .join(""); +} + +function getActivePage( + items: SidebarItem[], + activePath: string, +): { label: string } | null { + const path = activePath.toLowerCase(); + for (const item of items) { + if ( + path === item.href.toLowerCase() || + path.startsWith(item.href.toLowerCase() + "/") + ) { + return { label: item.label }; + } + if (item.children) { + const childMatch = item.children.find( + (c) => + path === c.href.toLowerCase() || + path.startsWith(c.href.toLowerCase() + "/"), + ); + if (childMatch) return { label: childMatch.label }; + } + } + return null; +} + +// Shared NavLink styling — green tint only when active, quiet neutral otherwise. +const navLinkStyles = { + root: { + borderRadius: "var(--mantine-radius-md)", + fontWeight: 500, + }, + label: { fontSize: "var(--mantine-font-size-sm)" }, +} as const; + +export function AppLayout({ + title = "EDR Freight", + sidebarItems, + activeHref = "", + onNavigate, + enableThemeToggle = false, + userName = "User", + userEmail, + onLogout, + children, +}: AppLayoutProps) { + const [mobileOpen, { toggle: toggleMobile }] = useDisclosure(); + const [theme, setTheme] = useState(() => + enableThemeToggle ? getStoredTheme() : "light", + ); + + const activePath = activeHref.toLowerCase(); + const navigate = (href: string) => onNavigate?.(href); + + const toggleTheme = () => { + const next: Theme = theme === "dark" ? "light" : "dark"; + setTheme(next); + document.documentElement.classList.toggle("dark", next === "dark"); + localStorage.setItem(THEME_KEY, next); + }; + + const initials = getInitials(userName); + const activePage = getActivePage(sidebarItems, activePath); + + const isItemActive = (item: SidebarItem) => + activePath === item.href.toLowerCase() || + activePath.startsWith(item.href.toLowerCase() + "/"); + + return ( + + {/* ── Header ──────────────────────────────────────────────────────────── */} + + + + + + {activePage ? activePage.label : title} + + + + {/* Right: utility actions + user menu */} + + + + + + + + + + + + {enableThemeToggle && ( + + {theme === "dark" ? : } + + )} + + + + + + + + {initials} + + + + {userName} + + + + + + + + + + {userName} + + {userEmail && ( + + {userEmail} + + )} + + + } + onClick={() => navigate("/profile")} + > + Profile + + } + color="red" + onClick={onLogout} + > + Logout + + + + + + + + {/* ── Sidebar ─────────────────────────────────────────────────────────── */} + + {/* Brand */} + + + + + + + {title} + + + + + {/* Nav links */} + + + {sidebarItems.map((item, i) => { + const active = isItemActive(item); + const hasChildren = !!item.children?.length; + const childActive = + item.children?.some((c) => + activePath.startsWith(c.href.toLowerCase()), + ) ?? false; + + const prevSection = sidebarItems[i - 1]?.section; + const sectionLabel = + item.section && item.section !== prevSection ? ( + + {item.section} + + ) : null; + + if (hasChildren) { + return ( + + {sectionLabel} + + {item.children!.map((child) => { + const cActive = + activePath === child.href.toLowerCase(); + return ( + navigate(child.href)} + styles={navLinkStyles} + /> + ); + })} + + + ); + } + + return ( + + {sectionLabel} + navigate(item.href)} + styles={navLinkStyles} + /> + + ); + })} + + + + {/* Bottom user */} + + + + + {initials} + + + + + {userName} + + {userEmail && ( + + {userEmail} + + )} + + + + + + {/* ── Main ────────────────────────────────────────────────────────────── */} + + {children} + + + ); +} + +export default AppLayout; diff --git a/apps/edr-freight-web/portal/src/main.tsx b/apps/edr-freight-web/portal/src/main.tsx index 8c4fbeb96..56d6838df 100644 --- a/apps/edr-freight-web/portal/src/main.tsx +++ b/apps/edr-freight-web/portal/src/main.tsx @@ -2,9 +2,12 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MantineProvider } from "@mantine/core"; +import "@mantine/core/styles.css"; import "@edr/ui-common/styles.css"; import "../index.css"; import "@edr/ui-common/theme.css"; +import { mantineTheme } from "./theme/mantine"; import App from "./App"; @@ -31,10 +34,12 @@ if (!rootElement) { createRoot(document.getElementById("root")!).render( - - - - - + + + + + + + , ); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx index b6b17e71f..1c97a13f9 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx @@ -2,42 +2,43 @@ import { useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { format } from "date-fns"; import { useQuery } from "@tanstack/react-query"; +import { + Anchor, + Badge, + Box, + Button, + Card, + Grid, + Group, + Progress, + RingProgress, + SimpleGrid, + Skeleton, + Stack, + Table, + Text, + ThemeIcon, + Title, +} from "@mantine/core"; import { ArrowRight, - Building2, - CheckCircle2, + ArrowUpRight, + CalendarDays, Clock, - DollarSign, - Eye, - LoaderCircle, - Mail, MapPin, - Package, - Phone, + Package2, Plus, Receipt, - Truck, + Train, UploadCloud, - X, } from "lucide-react"; -import { - getCurrentCustomer, - getMyInvoices, - getMyShipments, -} from "@/lib/currentCustomer"; +import { getMyInvoices, getMyShipments } from "@/lib/currentCustomer"; import { formatCurrency } from "@/pages/billing/invoices.mock"; import type { ShipmentStatus } from "@/pages/tracking/shipments.mock"; import type { InvoiceStatus } from "@/pages/billing/invoices.mock"; -import { - Button, - Card, - CardContent, - CardHeader, - CardTitle, - CardDescription, -} from "@edr/ui-common"; import { api } from "@/services/api"; +import useAuth from "@/hooks/useAuth"; const ACTIVE_STATUSES = [ "DRAFT", @@ -46,12 +47,36 @@ const ACTIVE_STATUSES = [ "IN_TRANSIT", ]; +const BOOKING_STATUS_META: Record = { + DRAFT: { label: "Draft", color: "gray" }, + SUBMITTED: { label: "Submitted", color: "blue" }, + PENDING_APPROVAL: { label: "Pending", color: "orange" }, + IN_TRANSIT: { label: "In Transit", color: "teal" }, + COMPLETED: { label: "Completed", color: "edr-green" }, + CANCELLED: { label: "Cancelled", color: "red" }, + REJECTED: { label: "Rejected", color: "red" }, +}; + +const SHIPMENT_STATUS_META: Record = { + "In Transit": { color: "teal" }, + Delivered: { color: "edr-green" }, + Delayed: { color: "red" }, +}; + +const INVOICE_STATUS_META: Record = { + Draft: { color: "gray" }, + Sent: { color: "blue" }, + Paid: { color: "edr-green" }, + Overdue: { color: "red" }, + Cancelled: { color: "gray" }, +}; + export default function MyPortalPage() { - const me = useMemo(() => getCurrentCustomer(), []); + const { user, customer } = useAuth(); const myShipments = useMemo(() => getMyShipments(), []); const myInvoices = useMemo(() => getMyInvoices(), []); - const navigate = useNavigate(); + const [dismissed, setDismissed] = useState(false); const bookingsQuery = useQuery( api.bookings.list.queryOptions({ @@ -71,337 +96,469 @@ export default function MyPortalPage() { const outstandingInvoices = myInvoices.filter( (inv) => inv.status === "Sent" || inv.status === "Overdue", ); - const [dismissed, setDismissed] = useState(false); const totalOutstanding = outstandingInvoices .filter((inv) => inv.currency === "USD") .reduce((sum, inv) => sum + inv.amount, 0); - const totalSpent = myInvoices - .filter((inv) => inv.status === "Paid" && inv.currency === "USD") - .reduce((sum, inv) => sum + inv.amount, 0); - const recentBookings = myBookings.slice(0, 5); - const recentInvoices = [...myInvoices].slice(0, 4); + const recentBookings = myBookings.slice(0, 6); + const recentInvoices = myInvoices.slice(0, 4); + const completedInvoices = myInvoices.filter( + (inv) => inv.status === "Paid", + ).length; + const invoiceTotal = myInvoices.length || 1; + const paidPct = Math.round((completedInvoices / invoiceTotal) * 100); + + const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—"; + const documentsComplete = !!(customer as any)?.documentsComplete; + const hasOutstanding = outstandingInvoices.length > 0; return ( -
-
- {/* Documents banner */} - {!me.documentsComplete && !dismissed && ( -
- -
-

Upload your documents

-

- To enable all account features, please upload your Business - License, TIN Certificate, and National ID / Passport. -

- + {/* ── Document setup notice ───────────────────────────────────── */} + {!documentsComplete && !dismissed && ( + + + + + + + + Finish setting up your account + + + Upload your Business License,{" "} + TIN Certificate, and{" "} + National ID / Passport to unlock all features. + + + + + + )} + + {/* ── Welcome (branded band) ──────────────────────────────────── */} + + {/* faint rail-line motif */} + + + + + Welcome back + + + {displayName} + + + + + + + {/* ── Stats Row ───────────────────────────────────────────────── */} + + } + color="blue" + /> + } + color="teal" + /> + } + color={hasOutstanding ? "red" : "edr-green"} + /> + } + color="edr-green" + ring={{ value: paidPct, color: "edr-green" }} + /> + + + {/* ── Main Grid: bookings + side panel ────────────────────────── */} + + {/* Recent Bookings (left, wider) */} + + + + + Recent Bookings + + Your latest freight requests + + +
- -
- )} + View all + + - {/* Welcome banner */} -
-
-
-
- {me.company.charAt(0)} -
-
-

Welcome back

-

{me.name}

-

- - {me.company} - · - - {me.customerType} - -

-
-
- -
- - - - - - -
-
-
- - {/* Active Shipments */} - - -
- Active Shipments - - Live tracking for your in-flight cargo - -
- - View all - - -
- - - {activeShipments.length === 0 ? ( -

- No shipments currently in transit. -

- ) : ( -
- {activeShipments.slice(0, 4).map((shipment) => ( -
-
- - {shipment.reference} - - -
-

- {shipment.originStation} - - {shipment.destinationStation} -

-
- - - {shipment.currentLocation} - - ETA {shipment.eta} -
-
-
-
-
+ {bookingsQuery.isPending ? ( + + {[1, 2, 3, 4].map((i) => ( + ))} -
- )} - - - - {/* Recent bookings */} - - -
- Recent Bookings - Your latest freight requests -
- - View all - - -
- - - {recentBookings.length === 0 ? ( -

- You haven't booked any freight yet. -

+ + ) : recentBookings.length === 0 ? ( + ) : ( -
- - - - - - - - - - - - {recentBookings.map((booking) => ( - navigate(`/bookings/${booking.id}`)} + +
ReferenceRouteCargoDateStatus
+ + + Reference + Route + Date + Status + + + + {recentBookings.map((booking) => { + const meta = BOOKING_STATUS_META[booking.status]; + return ( + navigate(`/bookings/${booking.id}`)} + > + + + {booking.reference} + + + + + {booking.originYard?.label ?? + booking.originYard?.code ?? + "—"} + {" → "} + {booking.destinationYard?.label ?? + booking.destinationYard?.code ?? + "—"} + + + + + {format( + new Date(booking.createdAt), + "MMM d, yyyy", + )} + + + + + {meta?.label ?? booking.status.replace(/_/g, " ")} + + + + ); + })} + +
+ + )} + + + + {/* Right side panel */} + + + {/* Active Shipments */} + + + + Shipments + + In-flight cargo + + + + + All + + + + + {activeShipments.length === 0 ? ( + + ) : ( + + {activeShipments.slice(0, 3).map((shipment) => ( + + ))} + + )} + + + {/* Invoice Summary */} + + + + Invoices + + {outstandingInvoices.length} outstanding + + + + + All + + + + + {recentInvoices.length === 0 ? ( + + ) : ( + + {recentInvoices.map((invoice) => { + const meta = INVOICE_STATUS_META[invoice.status]; + return ( + - - {booking.reference} - - - {booking.originYard?.label ?? booking.originYard?.code ?? "—"} → {booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"} - - - {booking.freightType === "CONTAINER" ? "Container" : booking.freightType} - - - {format(new Date(booking.createdAt), "MMM d, yyyy HH:mm")} - - - - - - ))} - - -
- )} -
-
- - {/* Invoices */} - - -
- Recent Invoices - - {outstandingInvoices.length} outstanding · {myInvoices.length}{" "} - total - -
- - View all - - -
- - - {recentInvoices.length === 0 ? ( -

- No invoices yet. -

- ) : ( -
- {recentInvoices.map((invoice) => ( -
-
- - -
-

- {formatCurrency(invoice.amount, invoice.currency)} -

-

- - Due {invoice.dueDate} -

-
- ))} -
- )} -
-
-
-
+ + + + + + + {formatCurrency(invoice.amount, invoice.currency)} + + + + + Due {invoice.dueDate} + + + + + + {invoice.status} + + + ); + })} + + )} + + + + + ); } -function ProfileRow({ - icon, +// ── Sub-components ──────────────────────────────────────────────────────────── + +function StatCard({ label, value, + sub, + icon, + color, + ring, }: { - icon: React.ReactNode; label: string; value: string; + sub?: string; + icon: React.ReactNode; + color: string; + ring?: { value: number; color: string }; }) { return ( -
-
{icon}
-
-

{label}

-

{value}

-
-
+ + + + + {label} + + + {value} + + {sub && ( + + {sub} + + )} + + {ring ? ( + + {ring.value}% + + } + /> + ) : ( + + {icon} + + )} + + ); } -function ShipmentBadge({ status }: { status: ShipmentStatus }) { - const styles: Record = { - "In Transit": "bg-muted text-foreground", - Delivered: "bg-primary/10 text-primary", - Delayed: "bg-destructive/10 text-destructive", - }; +function ShipmentCard({ + shipment, +}: { + shipment: ReturnType[number]; +}) { + const meta = SHIPMENT_STATUS_META[shipment.status as ShipmentStatus]; + const color = meta?.color ?? "gray"; return ( - - {status} - + + + {shipment.reference} + + + {shipment.status} + + + + {shipment.originStation} → {shipment.destinationStation} + + + + + + + {shipment.currentLocation} + + + + ETA {shipment.eta} + + + ); } -function BookingBadge({ status }: { status: string }) { - const styles: Record = { - DRAFT: "bg-amber-100 text-amber-700", - SUBMITTED: "bg-primary/10 text-primary", - PENDING_APPROVAL: "bg-muted text-foreground", - IN_TRANSIT: "bg-muted text-foreground", - COMPLETED: "bg-primary/10 text-primary", - CANCELLED: "bg-destructive/10 text-destructive", - REJECTED: "bg-destructive/10 text-destructive", - }; +function EmptyState({ message }: { message: string }) { return ( - - {status.replace(/_/g, " ")} - - ); -} - -function InvoiceBadge({ status }: { status: InvoiceStatus }) { - const styles: Record = { - Draft: "bg-muted text-muted-foreground", - Sent: "bg-primary/10 text-primary", - Paid: "bg-primary/10 text-primary", - Overdue: "bg-destructive/10 text-destructive", - Cancelled: "bg-amber-100 text-amber-700", - }; - return ( - - {status} - + + {message} + + ); } diff --git a/apps/edr-freight-web/portal/src/theme/mantine.ts b/apps/edr-freight-web/portal/src/theme/mantine.ts new file mode 100644 index 000000000..1a90fc0f8 --- /dev/null +++ b/apps/edr-freight-web/portal/src/theme/mantine.ts @@ -0,0 +1,147 @@ +import { createTheme, type MantineColorsTuple } from "@mantine/core"; + +// Brand accent — used sparingly: primary actions, active nav, key highlights. +const edrGreen: MantineColorsTuple = [ + "#ecfdf5", + "#d1fae5", + "#a7f3d0", + "#6ee7b7", + "#34d399", + "#10b981", + "#059669", + "#047857", + "#065f46", + "#064e3b", +]; + +// Neutral gray ramp tuned for clean, low-contrast surfaces (Stripe/Notion feel). +const neutral: MantineColorsTuple = [ + "#f8fafc", + "#f1f5f9", + "#e8edf2", + "#dbe2ea", + "#c2cbd6", + "#9aa6b4", + "#6b7785", + "#4b5563", + "#2f3742", + "#1c2129", +]; + +export const mantineTheme = createTheme({ + colors: { + "edr-green": edrGreen, + gray: neutral, + }, + primaryColor: "edr-green", + primaryShade: { light: 6, dark: 5 }, + + white: "#ffffff", + black: "#1c2129", + + fontFamily: + 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + + defaultRadius: "md", + + radius: { + xs: "4px", + sm: "6px", + md: "8px", + lg: "12px", + xl: "16px", + }, + + spacing: { + xs: "8px", + sm: "12px", + md: "16px", + lg: "24px", + xl: "32px", + }, + + fontSizes: { + xs: "12px", + sm: "13px", + md: "14px", + lg: "16px", + xl: "18px", + }, + + lineHeights: { + xs: "1.4", + sm: "1.45", + md: "1.55", + lg: "1.55", + xl: "1.5", + }, + + // Hierarchy comes from a strong, exponential size scale (~1.25 modular ratio), + // not from heavy weights. Big steps at the top, calm weights throughout. + // "Plus Jakarta Sans" gives headings a distinctive geometric character while + // body text stays on the neutral system stack. + headings: { + fontFamily: '"Plus Jakarta Sans", var(--mantine-font-family)', + fontWeight: "600", + sizes: { + h1: { fontSize: "40px", lineHeight: "1.1", fontWeight: "700" }, + h2: { fontSize: "30px", lineHeight: "1.2", fontWeight: "650" }, + h3: { fontSize: "23px", lineHeight: "1.3", fontWeight: "600" }, + h4: { fontSize: "18px", lineHeight: "1.4", fontWeight: "600" }, + h5: { fontSize: "15px", lineHeight: "1.45", fontWeight: "600" }, + h6: { fontSize: "13px", lineHeight: "1.45", fontWeight: "600" }, + }, + }, + + shadows: { + xs: "0 1px 2px rgba(16, 24, 40, 0.04)", + sm: "0 1px 3px rgba(16, 24, 40, 0.06), 0 1px 2px rgba(16, 24, 40, 0.04)", + md: "0 4px 12px rgba(16, 24, 40, 0.06)", + }, + + components: { + Card: { + defaultProps: { + radius: "lg", + withBorder: true, + shadow: "none", + padding: "lg", + }, + }, + Button: { + defaultProps: { + radius: "md", + }, + styles: { + root: { fontWeight: 550 }, + }, + }, + Badge: { + defaultProps: { + radius: "sm", + variant: "light", + }, + styles: { + root: { fontWeight: 550, textTransform: "none" }, + }, + }, + Paper: { + defaultProps: { + radius: "lg", + shadow: "none", + withBorder: true, + }, + }, + Table: { + defaultProps: { + verticalSpacing: "sm", + horizontalSpacing: "md", + }, + }, + Title: { + styles: { + root: { letterSpacing: "-0.01em" }, + }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8071fdce3..1f411c920 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -299,6 +299,12 @@ importers: '@hookform/resolvers': specifier: ^5.4.0 version: 5.4.0(react-hook-form@7.76.0(react@19.2.6)) + '@mantine/core': + specifier: ^9.3.0 + version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.29)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': + specifier: ^9.3.0 + version: 9.3.0(react@19.2.6) '@tanstack/react-query': specifier: ^5.59.0 version: 5.100.11(react@19.2.6) From 456603304635c2938518ece3e10d8ae43a377416 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 9 Jun 2026 09:27:14 +0300 Subject: [PATCH 003/100] style: ui revamp --- apps/edr-freight-web/portal/index.html | 6 + .../portal/src/components/AppLayout.tsx | 195 +- .../portal/src/components/auth/AuthLayout.tsx | 142 +- .../portal/src/pages/MyPortalPage.tsx | 319 +-- .../src/pages/bookings/BookingDetailPage.tsx | 2149 ++++++++--------- .../src/pages/bookings/EditBookingPage.tsx | 120 +- .../portal/src/pages/bookings/MyBookings.tsx | 395 +-- .../src/pages/bookings/NewBookingPage.tsx | 95 +- .../new-booking-form/StepIndicator.tsx | 16 +- .../bookings/new-booking-form/shared.tsx | 136 +- .../new-booking-form/step1-contract-type.tsx | 29 +- .../new-booking-form/step2-service-type.tsx | 144 +- .../bookings/new-booking-form/step4-route.tsx | 131 +- .../new-booking-form/step5-cargo-details.tsx | 260 +- .../new-booking-form/step8-review.tsx | 353 ++- .../portal/src/theme/mantine.ts | 6 +- 16 files changed, 2188 insertions(+), 2308 deletions(-) diff --git a/apps/edr-freight-web/portal/index.html b/apps/edr-freight-web/portal/index.html index 2233dc861..61b3dcff7 100644 --- a/apps/edr-freight-web/portal/index.html +++ b/apps/edr-freight-web/portal/index.html @@ -5,6 +5,12 @@ EDR Freight Portal + + + diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index fcc26ea4a..3484ba1f5 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -19,6 +19,7 @@ import { useDisclosure } from "@mantine/hooks"; import { Bell, ChevronDown, + ChevronRight, Languages, LogOut, Moon, @@ -93,14 +94,24 @@ function getActivePage( return null; } -// Shared NavLink styling — green tint only when active, quiet neutral otherwise. -const navLinkStyles = { - root: { - borderRadius: "var(--mantine-radius-md)", - fontWeight: 500, - }, - label: { fontSize: "var(--mantine-font-size-sm)" }, -} as const; +// NavLink classNames for the dark sidebar — Tailwind utilities (with v4 `!` +// important suffix) override Mantine's default active styling. +const navClassNames = (active: boolean) => { + const base = + "rounded-[10px] font-medium transition-all duration-150 active:scale-[0.98]"; + if (active) { + return { + root: `${base} bg-gradient-to-br! from-emerald-600! to-emerald-500! text-white! shadow-[0_2px_8px_-4px_rgba(16,185,129,0.45)]`, + label: "text-white!", + section: "text-white!", + }; + } + return { + root: `${base} text-white/60! hover:bg-white/[0.07]! hover:text-white!`, + label: "text-inherit!", + section: "text-inherit! opacity-90", + }; +}; export function AppLayout({ title = "EDR Freight", @@ -139,7 +150,7 @@ export function AppLayout({ {/* ── Header ──────────────────────────────────────────────────────────── */} @@ -159,9 +170,24 @@ export function AppLayout({ hiddenFrom="sm" size="sm" /> - - {activePage ? activePage.label : title} - + + + {title} + + + + + + {activePage ? activePage.label : title} + + {/* Right: utility actions + user menu */} @@ -170,16 +196,20 @@ export function AppLayout({ variant="subtle" color="gray" size="lg" + radius="md" + className="transition-transform hover:-translate-y-px" aria-label="Change language" > - + @@ -191,6 +221,8 @@ export function AppLayout({ variant="subtle" color="gray" size="lg" + radius="md" + className="transition-transform hover:-translate-y-px" onClick={toggleTheme} aria-label="Toggle theme" > @@ -198,38 +230,42 @@ export function AppLayout({ )} + + - - + + {initials} - - {userName} - - + + + {userName} + + + Customer + + + @@ -267,49 +303,29 @@ export function AppLayout({ {/* ── Sidebar ─────────────────────────────────────────────────────────── */} {/* Brand */} - + - + - - {title} - + + + {title} + + + Logistics Portal + + {/* Nav links */} - + {sidebarItems.map((item, i) => { const active = isItemActive(item); const hasChildren = !!item.children?.length; @@ -324,13 +340,11 @@ export function AppLayout({ {item.section} @@ -344,23 +358,18 @@ export function AppLayout({ label={item.label} leftSection={item.icon} active={active || childActive} - color="edr-green" - variant="filled" defaultOpened={childActive} - styles={navLinkStyles} + classNames={navClassNames(active || childActive)} > {item.children!.map((child) => { - const cActive = - activePath === child.href.toLowerCase(); + const cActive = activePath === child.href.toLowerCase(); return ( navigate(child.href)} - styles={navLinkStyles} + classNames={navClassNames(cActive)} /> ); })} @@ -376,10 +385,8 @@ export function AppLayout({ label={item.label} leftSection={item.icon} active={active} - color="edr-green" - variant="filled" onClick={() => navigate(item.href)} - styles={navLinkStyles} + classNames={navClassNames(active)} /> ); @@ -388,26 +395,24 @@ export function AppLayout({ {/* Bottom user */} - + - - + + {initials} - - + + {userName} {userEmail && ( - + {userEmail} )} @@ -417,7 +422,7 @@ export function AppLayout({ {/* ── Main ────────────────────────────────────────────────────────────── */} - + {children} diff --git a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx index 9d023a3ec..f66df0afa 100644 --- a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { Box, Group, Stack, Text, ThemeIcon, Title } from "@mantine/core"; import { ShieldCheck, Train } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -27,67 +28,102 @@ export default function AuthLayout({ left, }: AuthLayoutProps) { return ( -
-
-
-
-
-
-
- -
-
-

EDR Freight

-

- Railway Logistics Platform -

-
-
-
-
- {left.badge} -
-

- {left.title} -

-

- {left.description} -

-
-
+ + + {/* ── Left: branded panel ─────────────────────────────────────── */} + + {/* rail-line motif */} + + {/* corner glow */} + + + {/* Brand */} + + + + + + + EDR Freight + + + Railway Logistics Platform + + + + + {/* Headline + features */} + + + {left.badge} + + + {left.title} + + + {left.description} + + + {left.features.map((item) => ( -
-
+ + -
- {item} -
+
+ + {item} + + ))} -
-
-
-
+ + + {/* spacer keeps brand pinned top / content centered */} + + + + {/* ── Right: form area ────────────────────────────────────────── */} + -
-
-
+ + {/* Mobile brand */} + + -
-
-

EDR Freight

-

+ + + + EDR Freight + + Railway Logistics Platform -

-
-
-
{children}
-
-
-
-
+ + + + {children} + + + + ); } diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx index 1c97a13f9..81ab28011 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx @@ -4,6 +4,7 @@ import { format } from "date-fns"; import { useQuery } from "@tanstack/react-query"; import { Anchor, + Avatar, Badge, Box, Button, @@ -30,6 +31,7 @@ import { Plus, Receipt, Train, + TrendingUp, UploadCloud, } from "lucide-react"; @@ -71,6 +73,10 @@ const INVOICE_STATUS_META: Record = { Cancelled: { color: "gray" }, }; +// Card hover-lift, shared via Tailwind utilities. +const LIFT = + "transition-all duration-200 hover:-translate-y-[3px] hover:shadow-[0_16px_34px_-16px_rgba(16,24,40,0.22)] hover:border-emerald-300!"; + export default function MyPortalPage() { const { user, customer } = useAuth(); const myShipments = useMemo(() => getMyShipments(), []); @@ -109,39 +115,30 @@ export default function MyPortalPage() { const paidPct = Math.round((completedInvoices / invoiceTotal) * 100); const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—"; + const initials = displayName + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((n) => n[0]?.toUpperCase()) + .join(""); const documentsComplete = !!(customer as any)?.documentsComplete; const hasOutstanding = outstandingInvoices.length > 0; + const today = format(new Date(), "EEEE, MMMM d"); return ( - + {/* ── Document setup notice ───────────────────────────────────── */} {!documentsComplete && !dismissed && ( - - + + - + Finish setting up your account @@ -157,12 +154,7 @@ export default function MyPortalPage() { to="/settings?tab=documents" size="sm" radius="xl" - style={{ - background: "#f59e0b", - color: "white", - fontWeight: 600, - flexShrink: 0, - }} + className="flex-shrink-0 bg-gradient-to-br from-amber-500 to-amber-600! font-semibold text-white!" rightSection={} > Upload docs @@ -170,56 +162,58 @@ export default function MyPortalPage() { )} - {/* ── Welcome (branded band) ──────────────────────────────────── */} - + {/* ── Welcome (branded hero) ──────────────────────────────────── */} + {/* faint rail-line motif */} + - - - Welcome back - - - {displayName} - - + + + + {initials} + + + + + {today} + + + Welcome back, {displayName.split(" ")[0]} + + + Here's what's moving across your account today. + + + @@ -231,13 +225,13 @@ export default function MyPortalPage() { } + icon={} color="blue" /> } + icon={} color="teal" /> } + icon={} color={hasOutstanding ? "red" : "edr-green"} /> } + icon={} color="edr-green" ring={{ value: paidPct, color: "edr-green" }} /> @@ -262,20 +256,31 @@ export default function MyPortalPage() { {/* Recent Bookings (left, wider) */} - + - - Recent Bookings - - Your latest freight requests - - + + + + + + Recent Bookings + + Your latest freight requests + + +
-
- ) : ( -

- Pricing will be calculated after submission. -

- )} - + + + + + + + + + Draft Booking Request + + + {booking.reference} + + + + · + + Created {format(new Date(booking.createdAt), "MMM d, yyyy")} + + + + + + + + + - - - - - Required Documents - - - Provide the necessary documents for this booking. Some information - is pre-filled from your company profile. - - - - {docError && ( -
- -

{docError}

-
- )} -
-

- - Company Information (from profile) -

-
- - - - -
-

- To update your company information, go to{" "} - + + + {/* Step 2 */} + 0 + ? "border-amber-200 bg-amber-50/30" + : "border-gray-200 bg-white" + }`} + > + + 0 ? "bg-amber-500" : "bg-gray-300" + }`} > - Settings - - . -

-
+ {allDocsUploaded ? : 2} +
+ 0 ? "orange.7" : "dimmed"} + > + Step 2 + +
+ Upload Documents + + {allDocsUploaded + ? "All 4 documents uploaded." + : `${uploadedCount} of ${REQUIRED_DOC_FIELDS.length} documents uploaded.`} + + {!allDocsUploaded && ( + + )} + - + {/* Step 3 */} + + + + 3 + + Step 3 + + Submit Request + + Send your booking to EDR staff for review and approval. + + + + + -
-

- - Upload Booking Documents -

-
+ {/* ── Main grid ────────────────────────────────────────────────── */} + + {/* Left: Documents */} + + + + + + + Required Documents + + + All 4 documents are required before you can submit. + + + {allDocsUploaded ? ( + }> + All uploaded + + ) : ( + + {uploadedCount}/{REQUIRED_DOC_FIELDS.length} uploaded + + )} + + + {docError && ( + } mb="md"> + {docError} + + )} + + {/* Company info */} + + + + + Company Info (pre-filled from profile) + + + + + + + + + + Update in{" "} + navigate("/settings")}> + Settings + + + + + + + {/* Document slots */} + {REQUIRED_DOC_FIELDS.map((doc) => { const isUploaded = uploadedCodes.has(doc.key); + const selectedFile = selectedFiles[doc.key]; return ( -
- -
- {isUploaded ? ( - - - Uploaded - - ) : ( - <> + + + + {isUploaded ? : } + + + {doc.label} + {isUploaded && ( + Uploaded ✓ + )} + {selectedFile && !isUploaded && ( + {selectedFile.name} + )} + {!isUploaded && !selectedFile && ( + Required · Not yet uploaded + )} + + + {!isUploaded && ( + { - fileInputRefs.current[doc.key] = el; - }} + ref={(el) => { fileInputRefs.current[doc.key] = el; }} type="file" accept=".pdf,.jpg,.jpeg,.png" className="hidden" - onChange={(e) => { - handleFileSelect( - doc.key, - e.target.files?.[0] ?? null, - ); - }} + onChange={(e) => handleFileSelect(doc.key, e.target.files?.[0] ?? null)} /> - - {selectedFiles[doc.key] && ( - + + )} - + )} -
-
+ + ); })} -
+ -
- - {uploadMutation.isSuccess && ( -

- - Documents uploaded successfully -

- )} -
-
- - - - - - - - Cancel Booking - - - If you no longer need this booking, you can cancel it. - - - -

- Cancelling will terminate this booking request and cannot be - undone. -

- - - - - - - Cancel Booking - - Are you sure you want to cancel this booking? This action - cannot be undone. - - -
- - setCancelReason(e.target.value)} - autoFocus - /> -
- - - - + {anyFileSelected && ( + - -
-
-
+ {uploadMutation.isSuccess && ( + + + Documents uploaded successfully + + )} + + )} +
+ + + {/* Right: Pricing + Booking summary */} + + + {/* Pricing */} + + + + Pricing Estimate + + + Estimated cost based on your current booking details. + + {pricing ? ( + + ) : ( + + + Pricing will be calculated automatically. + + + )} + + + {/* Booking summary */} + + + + Booking Summary + + + + + Route + + + + {booking.originYard?.label ?? booking.originYard?.code ?? "—"} + + + + {booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"} + + + + + + + + + + + + + + + + {/* ── Cancel zone ──────────────────────────────────────────────── */} + + + + Danger Zone + + + Cancelling this booking is permanent and cannot be undone. + + -
- + + {/* Cancel modal */} + setCancelDialogOpen(false)} + title={Cancel Booking} + radius="lg" + centered + > + + + Are you sure you want to cancel {booking.reference}? This action + cannot be undone. + + setCancelReason(e.currentTarget.value)} + radius="md" + data-autofocus + /> + + + + + + +
+
); } +// ─── Readonly View ──────────────────────────────────────────────────────────── + function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { const navigate = useNavigate(); - const queryClient = useQueryClient(); const payMutation = useMutation({ mutationFn: () => api.bookings.pay.call({ id: booking.id }), onSuccess: (data) => { - if (data.redirectUrl) { - window.location.href = data.redirectUrl; - } + if (data.redirectUrl) window.location.href = data.redirectUrl; }, }); const normalizedStatus = booking.status as keyof typeof STATUS_MAP; const statusConfig = STATUS_MAP[normalizedStatus] || STATUS_MAP.DRAFT; const currentStageIndex = statusConfig.stage; - const pricing = booking.pricingBreakdown; - const uploadedCodes = useMemo( - () => new Set(booking.files?.map((f) => f.code) ?? []), - [booking.files], - ); - return ( -
-
+ + - - -
-
- -
-
-
-

- {booking.reference} -

+ {/* ── Hero ─────────────────────────────────────────────────────── */} + + + + + + + + + + {booking.freightType === "CONTAINER" ? "Container" : "Bulk"} ·{" "} + {booking.tradeDirection ?? "Booking"} + + + {booking.reference} + + -
-
- - + · + + {format( new Date(booking.scheduledDate ?? booking.createdAt), - "MMM d, yyyy HH:mm", + "MMM d, yyyy", )} - -
-
- {normalizedStatus === "FULLY_EXECUTED" && booking.paymentStatus !== "PAID" && ( - - )} -
-
+ + +
+ + {normalizedStatus === "FULLY_EXECUTED" && booking.paymentStatus !== "PAID" && ( + + )} + - {renderContractCard(booking, navigate, payMutation)} + {/* ── Contract card ────────────────────────────────────────────── */} + {renderContractCard(booking, navigate)} - {pricing && ( - - - - - Pricing Breakdown - - - -
- - - - - - - - - {pricing.lineItems.map((item, i) => ( - - - - - ))} - - - - - -
DescriptionAmount
{item.description} - {item.amount.toLocaleString()} {item.currency} -
Total Estimated Cost - {pricing.totalAmount.toLocaleString()} {pricing.currency} -
-
-
-
- )} + {/* ── Progress & status ────────────────────────────────────────── */} + + + + Booking Progress + - {booking.files && booking.files.length > 0 && ( - - - - - Uploaded Documents ({booking.files.length}) - - - - - - - )} - - - - - - Booking Status Lifecycle - - - Track the journey from request to completion - - - -
-
-
= 0 - ? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%` - : "0%", - }} - /> -
- - {PROGRESS_STAGES.map((stage, idx) => { - const isCompleted = idx < currentStageIndex; - const isActive = idx === currentStageIndex; - - return ( -
: } + + -
- {isCompleted ? ( - - ) : ( - - )} -
- - {stage.label} - -
- ); - })} -
+ {stage.label} + + + ); + })} + -
-
- {normalizedStatus === "CANCELLED" ? ( - + {/* Current status banner */} + + + + {normalizedStatus === "CANCELLED" || normalizedStatus === "REJECTED" ? ( + ) : ( - + )} -
-
-

+ + {statusConfig.title} -

-

+ + {statusConfig.description} -

-
+ + {normalizedStatus !== "CANCELLED" && - normalizedStatus !== "DELIVERED" && ( -
-
-

- Est. Waiting -

-

- 1-2 Working Days -

-
- -
+ normalizedStatus !== "DELIVERED" && + normalizedStatus !== "COMPLETED" && ( + + Est. Waiting + 1–2 Working Days + )} -
- + + -
-
- - - - - Route & Service - - - -
- } - /> -
-
- - -
- + {/* ── Route + Cargo (2 col) ─────────────────────────────────────── */} + + + + + + Route & Service + + + {/* Origin → Destination */} + + + + + Origin + + + + + + {booking.originYard?.label ?? booking.originYard?.code ?? "—"} + + + + + + + + Rail -
- } - /> -
+ + + + Destination + + + + + + {booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"} + + + + -
- } - label="Service" - value={ - booking.serviceType === "RAIL_AND_FORWARDING" - ? "Rail & Forwarding" - : "Rail Only" - } - /> - } - label="Return" - value={ - booking.equipmentReturn === "WITH_RETURN" - ? "With Return" - : "Without Return" - } - /> - } - label="Trade" - value={ - booking.tradeDirection === "IMPORT" ? "Import" : "Export" - } - /> -
-
+ + } label="Service" value={booking.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail Only"} /> + } label="Return" value={booking.equipmentReturn === "WITH_RETURN" ? "With Return" : "Without Return"} /> + } label="Trade" value={booking.tradeDirection === "IMPORT" ? "Import" : "Export"} /> +
+ - - - - - Mile Services - - - -
-

+ + + + + Cargo Specifications + + + + } label="Freight Type" value={booking.freightType === "BULK" ? "Bulk" : "Break Bulk"} /> + } label="Total Weight" value={`${booking.cargoTotalWeightVgm} t`} /> + } label="Currency" value={booking.paymentCurrency} /> + } label="Hazardous" value={booking.isHazardous ? "Yes" : "No"} /> + + + {booking.containers && booking.containers.length > 0 && ( + <> + + + Load Details + + + + + + Type + Qty + VGM + + + + {booking.containers.map((c, i) => ( + + {c.type} + {c.qty} + {c.vgm}t + + ))} + +
+
+ + )} +
+
+ + + {/* ── Mile services + Contract info ─────────────────────────────── */} + + + + + + Mile Services + + + + First Mile -

- -
-
-

+ + + {booking.firstMileEnabled && booking.firstMilePickupAddress + ? booking.firstMilePickupAddress + : "Not requested"} + + + + Last Mile -

-

+ + {booking.lastMileEnabled && booking.lastMileDeliveryAddress ? booking.lastMileDeliveryAddress : "Not requested"} -

-
-
+ + +
+ - - - - - Cargo Specifications - - - -
- } - label="Freight Type" - value={ - booking.freightType === "BULK" ? "Bulk" : "Break Bulk" - } - /> - } - label="Weight (VGM)" - value={`${booking.cargoTotalWeightVgm} Tons`} - /> - } - label="Currency" - value={booking.paymentCurrency} - /> -
- - {booking.containers && booking.containers.length > 0 && ( - <> - -
-

- Load Details -

-
- - - - - - - - - - {booking.containers.map((c, i) => ( - - - - - - ))} - -
Type - Quantity - - VGM (Tons) -
- {c.type} - - {c.qty} Units - - {c.vgm}t -
-
-
- - )} -
-
-
- -
- - - - - Contract Info - - - - - - -
- + + + + + Contract Info + + + + + + + Hazardous: {booking.isHazardous ? "Yes" : "No"} - + Refrigerated: {booking.isRefrigerated ? "Yes" : "No"} -
-
+ +
+ + - - - Additional Info - - - {booking.freightSubtype && ( -
-

- Cargo Description -

-

- "{booking.freightSubtype}" -

-
- )} - {booking.financialTerms && ( - <> - -
-

- Financial Terms -

-
-

- - {booking.financialTerms} -

-
-
- - )} - {!booking.freightSubtype && !booking.financialTerms && ( -

- No additional information provided. -

- )} -
-
-
-
-
-
+ {/* ── Pricing + Documents ───────────────────────────────────────── */} + {(pricing || (booking.files && booking.files.length > 0)) && ( + + {pricing && ( + + + + + Pricing Breakdown + + + + + )} + {booking.files && booking.files.length > 0 && ( + + + + + Uploaded Documents ({booking.files.length}) + + + {booking.files.map((file) => ( + + + + + + {file.name} + {file.code.replace(/_/g, " ")} + + + ))} + + + + )} + + )} + + {/* ── Additional info ───────────────────────────────────────────── */} + {(booking.freightSubtype || booking.financialTerms) && ( + + Additional Information + + {booking.freightSubtype && ( + + + Cargo Description + + "{booking.freightSubtype}" + + )} + {booking.financialTerms && ( + <> + {booking.freightSubtype && } + + + Financial Terms + + + + + {booking.financialTerms} + + + + + )} + + + )} + + ); } +// ─── Contract card ──────────────────────────────────────────────────────────── + function renderContractCard( booking: Freight.IBooking, navigate: ReturnType, - payMutation: { mutate: () => void; isPending: boolean }, ) { const s = booking.status; if ( @@ -1283,145 +1178,177 @@ function renderContractCard( return null; } - const config: Record< - string, - { title: string; description: string; buttonLabel?: string } - > = { + const config: Record = { APPROVED_PENDING_SIGNATURE: { title: "Contract being prepared", - description: - "Your booking has been approved. The contract is being generated and will be available shortly.", + description: "Your booking has been approved. The contract will be available shortly.", }, CONTRACT_READY: { - title: "Contract ready for signature", - description: - "Review the agreement and apply your digital signature.", - buttonLabel: "View & sign contract", + title: "Action required — sign your contract", + description: "Your contract is ready. Review the agreement and apply your digital signature to proceed.", + buttonLabel: "View & Sign Contract", + urgent: true, }, SIGNED_CUSTOMER: { title: "You have signed the contract", - description: - "Your signature has been submitted. Awaiting staff signature to finalize.", - buttonLabel: "View contract", + description: "Your signature has been submitted. Awaiting the final staff signature.", + buttonLabel: "View Contract", }, FULLY_EXECUTED: { title: "Contract fully executed", - description: - "The contract has been fully signed and executed by all parties.", - buttonLabel: "View contract", + description: "The contract has been signed by all parties. You can now proceed to payment.", + buttonLabel: "View Contract", }, }; const c = config[s]; + const isUrgent = c.urgent; return ( - -
-

{c.title}

-

{c.description}

-
+ + + + + + + + {c.title} + + + {c.description} + + + {c.buttonLabel && ( - + )} -
+
); } -function RouteEndpoint({ - label, - station, - icon, +// ─── Shared sub-components ──────────────────────────────────────────────────── + +function PricingTable({ + pricing, }: { - label: string; - station: string; - icon: React.ReactNode; + pricing: { + lineItems: { description: string; amount: number; currency: string }[]; + totalAmount: number; + currency: string; + }; }) { return ( -
-
- {icon &&
{icon}
} -
-
-

- {label} -

-

{station}

-
-
+ + + + + Description + Amount + + + + {pricing.lineItems.map((item, i) => ( + + {item.description} + + {item.amount.toLocaleString()} {item.currency} + + + ))} + + Total Estimated Cost + + {pricing.totalAmount.toLocaleString()} {pricing.currency} + + + +
+
); } -function InfoItem({ - icon, +function MiniInfo({ label, value, + icon, }: { - icon?: React.ReactNode; label: string; value?: string | number | null; + icon?: React.ReactNode; }) { return ( -
- {icon && ( -
- {icon} -
- )} -
-

+ + {icon ? ( + + {icon} + + {label} + + + ) : ( + {label} -

-

{value ?? "—"}

-
-
+ + )} + {value ?? "—"} + ); } function StatusBadge({ status }: { status: string }) { - const statusColors: Record = { - DRAFT: "bg-muted text-muted-foreground border-border", - CHANGES_REQUESTED: "bg-amber-50 text-amber-700 border-amber-200", - SUBMITTED: "bg-primary/10 text-primary border-primary/20", - PENDING_APPROVAL: "bg-primary/10 text-primary border-primary/20", - APPROVED_PENDING_SIGNATURE: "bg-primary/10 text-primary border-primary/20", - APPROVED: "bg-primary/10 text-primary border-primary/20", - CONTRACT_READY: "bg-primary/10 text-primary border-primary/20", - SIGNED_CUSTOMER: "bg-primary/10 text-primary border-primary/20", - FULLY_EXECUTED: "bg-primary/10 text-primary border-primary/20", - PNR_GENERATED: "bg-primary/10 text-primary border-primary/20", - PAYMENT_VERIFICATION_IN_PROGRESS: "bg-primary/10 text-primary border-primary/20", - PAID: "bg-primary/10 text-primary border-primary/20", - CONFIRMED: "bg-primary/10 text-primary border-primary/20", - IN_TRANSIT: "bg-primary/10 text-primary border-primary/20", - PENDING_CONSOLIDATION: "bg-primary/10 text-primary border-primary/20", - CONSOLIDATED: "bg-primary/10 text-primary border-primary/20", - COMPLETED: "bg-muted text-foreground border-border", - DELIVERED: "bg-muted text-foreground border-border", - REJECTED: "bg-destructive/10 text-destructive border-destructive/20", - CANCELLED: "bg-destructive/10 text-destructive border-destructive/20", + const colorMap: Record = { + DRAFT: "gray", + CHANGES_REQUESTED: "yellow", + SUBMITTED: "edr-green", + PENDING_APPROVAL: "edr-green", + APPROVED_PENDING_SIGNATURE: "edr-green", + APPROVED: "edr-green", + CONTRACT_READY: "edr-green", + SIGNED_CUSTOMER: "edr-green", + FULLY_EXECUTED: "edr-green", + PNR_GENERATED: "edr-green", + PAYMENT_VERIFICATION_IN_PROGRESS: "edr-green", + PAID: "edr-green", + CONFIRMED: "edr-green", + IN_TRANSIT: "edr-green", + PENDING_CONSOLIDATION: "edr-green", + CONSOLIDATED: "edr-green", + COMPLETED: "gray", + DELIVERED: "gray", + REJECTED: "red", + CANCELLED: "red", }; return ( {status.replace(/_/g, " ")} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx index b72d18ee1..543817f92 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx @@ -41,11 +41,7 @@ import { type BookingFormValues, type RouteDirection, } from "./new-booking-form/schema"; -import { - SelectField, - SelectItem, - AlertBox, -} from "./new-booking-form/shared"; +import { SelectField, AlertBox } from "./new-booking-form/shared"; function yardNameFromBooking(yard: { label?: string; code?: string; name?: string } | undefined | null): string { return yard?.label ?? yard?.name ?? yard?.code ?? ""; @@ -399,10 +395,11 @@ export default function EditBookingPage() { error={fieldState.error} label="Contract Type *" placeholder="Select contract type..." - > - New Contract - Contract Renewal - + data={[ + { value: "new", label: "New Contract" }, + { value: "renewal", label: "Contract Renewal" }, + ]} + /> )} /> @@ -431,10 +428,11 @@ export default function EditBookingPage() { error={fieldState.error} label="Service Type *" placeholder="Select service type..." - > - Rail Transport Only - Logistics (Rail + Forwarding) - + data={[ + { value: "rail", label: "Rail Transport Only" }, + { value: "rail_forwarding", label: "Logistics (Rail + Forwarding)" }, + ]} + /> )} /> @@ -447,10 +445,11 @@ export default function EditBookingPage() { error={fieldState.error} label="Equipment Return" placeholder="Select..." - > - With Return - Without Return - + data={[ + { value: "with_return", label: "With Return" }, + { value: "without_return", label: "Without Return" }, + ]} + /> )} />
@@ -602,19 +601,8 @@ export default function EditBookingPage() { label="Origin Yard *" placeholder="Select origin..." disabled={yardOptions.length === 0} - > - {yardOptions.length === 0 ? ( - No yards available - ) : ( - yardOptions - .filter((y) => y.value !== destinationYard) - .map((y) => ( - - {y.label} - - )) - )} - + data={yardOptions.filter((y) => y.value !== destinationYard)} + /> )} /> @@ -628,19 +616,8 @@ export default function EditBookingPage() { label="Destination Yard *" placeholder="Select destination..." disabled={yardOptions.length === 0} - > - {yardOptions.length === 0 ? ( - No yards available - ) : ( - yardOptions - .filter((y) => y.value !== originYard) - .map((y) => ( - - {y.label} - - )) - )} - + data={yardOptions.filter((y) => y.value !== originYard)} + /> )} /> @@ -664,13 +641,8 @@ export default function EditBookingPage() { error={fieldState.error} label="Shipping Line" placeholder="Select shipping line..." - > - {shippingLineOptions.map((sl) => ( - - {sl.label} - - ))} - + data={shippingLineOptions} + /> )} /> )} @@ -736,10 +708,11 @@ export default function EditBookingPage() { error={fieldState.error} label="Cargo Type *" placeholder="Select cargo type..." - > - Containerized - General Cargo - + data={[ + { value: "container", label: "Containerized" }, + { value: "bulk", label: "General Cargo" }, + ]} + /> )} /> @@ -782,13 +755,11 @@ export default function EditBookingPage() { error={fieldState.error} label="Freight Type *" placeholder="Select freight type..." - > - {freightTypeGroups.map((group) => ( - - {group.name} - - ))} - + data={freightTypeGroups.map((g) => ({ + value: g.code.toLowerCase(), + label: g.name, + }))} + /> )} /> @@ -802,13 +773,8 @@ export default function EditBookingPage() { error={fieldState.error} label="Commodity *" placeholder="Select commodity..." - > - {commodityOptions.map((option) => ( - - {option} - - ))} - + data={commodityOptions} + /> )} /> )} @@ -903,10 +869,11 @@ export default function EditBookingPage() { error={fieldState.error} label="Size *" placeholder="Size..." - > - 20ft (TEU) - 40ft (FEU) - + data={[ + { value: "20ft", label: "20ft (TEU)" }, + { value: "40ft", label: "40ft (FEU)" }, + ]} + /> )} /> @@ -919,13 +886,8 @@ export default function EditBookingPage() { error={fieldState.error} label="Type *" placeholder="Type..." - > - {containerTypeOptions.map((option) => ( - - {option} - - ))} - + data={containerTypeOptions} + /> )} /> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx index ac0b3661f..3c863eb3d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx @@ -1,6 +1,21 @@ import { useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; +import { + ActionIcon, + Badge, + Box, + Button, + Card, + Group, + Menu, + SimpleGrid, + Stack, + Text, + TextInput, + ThemeIcon, + Title, +} from "@mantine/core"; import { ArrowRight, Clock, @@ -20,17 +35,6 @@ import { DataTableFooter, type ColumnDef, usePagination, - Button, - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - Input, - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, } from "@edr/ui-common"; export default function MyBookings() { @@ -80,15 +84,19 @@ export default function MyBookings() { cell: ({ row }) => { const booking = row.original; return ( -
-
+ + -
-
-

{booking.reference}

-

{booking.scheduledDate ?? booking.createdAt}

-
-
+ + + + {booking.reference} + + + {booking.scheduledDate ?? booking.createdAt} + + + ); }, }, @@ -96,11 +104,15 @@ export default function MyBookings() { id: "route", header: "Route", cell: ({ row }) => ( -
- {row.original.originYard?.label ?? row.original.originYard?.code ?? "—"} - - {row.original.destinationYard?.label ?? row.original.destinationYard?.code ?? "—"} -
+ + + {row.original.originYard?.label ?? row.original.originYard?.code ?? "—"} + + + + {row.original.destinationYard?.label ?? row.original.destinationYard?.code ?? "—"} + + ), }, { @@ -111,12 +123,15 @@ export default function MyBookings() { const containerCount = b.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0; const containerType = b.containers?.[0]?.type ?? null; return ( -
-

{b.freightType === "BULK" ? "Bulk" : "Break Bulk"}

-

- {containerType && containerCount > 0 ? `${containerCount} × ${containerType} · ` : ""}{b.cargoTotalWeightVgm}t -

-
+ + + {b.freightType === "BULK" ? "Bulk" : "Break Bulk"} + + + {containerType && containerCount > 0 ? `${containerCount} × ${containerType} · ` : ""} + {b.cargoTotalWeightVgm}t + + ); }, }, @@ -124,9 +139,9 @@ export default function MyBookings() { id: "transportMode", header: "Transport", cell: ({ row }) => ( - + {row.original.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail"} - + ), }, { @@ -140,26 +155,23 @@ export default function MyBookings() { cell: ({ row }) => { const booking = row.original; return ( -
e.stopPropagation()} - > - - - - - - e.stopPropagation()}> + + + + + + + + } onClick={() => navigate(`/bookings/${booking.id}`)} > - View - - - -
+ + + + ); }, }, @@ -168,148 +180,191 @@ export default function MyBookings() { const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success"; return ( -
-
- -
-

- My Bookings -

-

- View and manage your freight booking requests. -

-
+ + + {/* ── Header band ─────────────────────────────────────────── */} + + + + + + My Bookings + + + View and manage your freight booking requests. + + -
-
- - + setSearchTerm(e.target.value)} - className="pl-8!" + onChange={(e) => setSearchTerm(e.currentTarget.value)} + leftSection={} + radius="md" + className="w-full sm:w-80" + styles={{ input: { background: "white" } }} /> -
- - - - -
+
+
-
- - -
-

Total Bookings

-

- {bookings.length} -

-
-
- -
-
-
+ {/* ── Stat cards ──────────────────────────────────────────── */} + + } + gradient="from-emerald-500 to-emerald-700 shadow-emerald-500/30" + /> + } + gradient="from-sky-500 to-blue-600 shadow-sky-500/30" + /> + } + gradient="from-amber-400 to-orange-500 shadow-amber-500/30" + /> + - - -
-

Active Bookings

-

- {activeCount} -

-
-
- -
-
-
- - - -
-

Pending Approval

-

- {pendingCount} -

-
-
- -
-
-
-
- - - -
- Recent Requests - + {/* ── Table ───────────────────────────────────────────────── */} + + + + Recent Requests + A list of your recent freight bookings and their statuses. - -
- - -
+ - - {total === 0 && dataTableStatus === "success" ? ( -
- -

No bookings found

-

- {searchTerm ? "No bookings match your current search filter." : "You haven't requested any bookings yet."} -

-
- ) : ( - navigate(`/bookings/${(row as Freight.IBooking).id}`)} - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount: pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination }, - onPaginationChange: setPagination, - }} - containerClassName="border-b shadow-none" - footer={DataTableFooter} - /> - )} -
+ {total === 0 && dataTableStatus === "success" ? ( + + + + + + No bookings found + + + {searchTerm + ? "No bookings match your current search filter." + : "You haven't requested any bookings yet."} + + {!searchTerm && ( + + )} + + ) : ( + navigate(`/bookings/${(row as Freight.IBooking).id}`)} + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount: pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination }, + onPaginationChange: setPagination, + }} + containerClassName="border-0 shadow-none" + footer={DataTableFooter} + /> + )}
-
-
+
+ + ); +} + +function StatCard({ + label, + value, + icon, + gradient, +}: { + label: string; + value: number; + icon: React.ReactNode; + gradient: string; +}) { + return ( + + + + + {label} + + + {value} + + + + {icon} + + + ); } function StatusBadge({ status }: { status: string }) { - const styles: Record = { - DRAFT: "bg-amber-100 text-amber-700", - CONFIRMED: "bg-primary/10 text-primary", - IN_TRANSIT: "bg-muted text-foreground", - DELIVERED: "bg-primary/10 text-primary", - CANCELLED: "bg-destructive/10 text-destructive", + const colorMap: Record = { + DRAFT: "amber", + CONFIRMED: "edr-green", + IN_TRANSIT: "blue", + DELIVERED: "edr-green", + CANCELLED: "red", }; return ( - - {status.replace(/_/g, ' ')} - + {status.replace(/_/g, " ")} + ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 78ba91af6..eb088c883 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -3,14 +3,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; -import { - AlertCircle, - Check, - ChevronLeft, - ChevronRight, - LoaderCircle, -} from "lucide-react"; -import { Button } from "@edr/ui-common"; +import { AlertCircle, Check, ChevronLeft, ChevronRight } from "lucide-react"; +import { Alert, Box, Button, Text } from "@mantine/core"; import { api } from "@/services/api"; import type { CreateBookingPayload } from "@/services/bookings.service"; import { @@ -210,27 +204,34 @@ export default function NewBookingPage() { className="flex flex-col" onSubmit={handleSubmit} > -
-
+ {/* Step indicator — sticky */} + + -
-
+ + -
-
+ {/* Step content */} + + {createMutation.isError && ( -
- -
-

Failed to save draft

-

- {createMutation.error instanceof Error - ? createMutation.error.message - : "An unexpected error occurred. Please try again."} -

-
-
+ } + radius="md" + mb="lg" + > + + Failed to save draft + + + {createMutation.error instanceof Error + ? createMutation.error.message + : "An unexpected error occurred. Please try again."} + + )} + {step === 1 && } {step === 2 && } {step === 3 && ( @@ -251,43 +252,49 @@ export default function NewBookingPage() { {step === 5 && ( )} -
-
+ + -
-
+ {/* Navigation footer — sticky */} + + + {step < STEPS.length ? ( - ) : ( )} -
-
+ + ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx index 76ef8e30d..d7ca51476 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx @@ -9,19 +9,19 @@ export function StepIndicator({ step }: { step: number }) {
item.id - ? "bg-primary text-primary-foreground" + ? "bg-emerald-600 text-white shadow-sm shadow-emerald-600/40" : step === item.id - ? "border-2 border-primary text-primary" - : "bg-muted text-muted-foreground" + ? "border-2 border-emerald-500 text-emerald-600 shadow-sm shadow-emerald-500/30" + : "bg-gray-100 text-gray-400" }`} > {step > item.id ? : item.id}
= item.id ? "text-foreground" : "text-muted-foreground" + className={`hidden text-[10px] font-medium lg:block transition-colors ${ + step >= item.id ? "text-gray-800" : "text-gray-400" }`} > {item.short} @@ -29,8 +29,8 @@ export function StepIndicator({ step }: { step: number }) {
{index < STEPS.length - 1 && (
item.id ? "bg-primary" : "bg-border" + className={`mx-1 h-0.5 flex-1 rounded-full transition-all duration-300 ${ + step > item.id ? "bg-emerald-500" : "bg-gray-200" }`} /> )} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx index a731d081c..4d5f8e33a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx @@ -1,31 +1,16 @@ import type { ReactNode } from "react"; -import type { - ControllerRenderProps, - FieldError as RhfFieldError, -} from "react-hook-form"; -import { - AlertTriangle, - Check, - CheckCircle2, - Info, - XCircle, -} from "lucide-react"; -import { - Field, - FieldDescription, - FieldError, - FieldLabel, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@edr/ui-common"; -import type { BookingFormInputValues, BookingFormValues } from "./schema"; -import { cn } from "@/lib/utils"; +import type { ControllerRenderProps, FieldError as RhfFieldError } from "react-hook-form"; +import { AlertTriangle, Check, CheckCircle2, Info, XCircle } from "lucide-react"; +import { Alert, Select, Text, Title } from "@mantine/core"; +import type { BookingFormInputValues } from "./schema"; export function OptionFieldError({ error }: { error?: { message?: string } }) { - return ; + if (!error?.message) return null; + return ( + + {error.message} + + ); } export function OptionCard({ @@ -44,16 +29,17 @@ export function OptionCard({ type="button" onClick={onClick} disabled={disabled} - className={`relative w-full rounded-xl border-2 p-4 text-left transition ${disabled - ? "cursor-not-allowed border-border bg-muted opacity-60" + className={`relative w-full rounded-xl border-2 p-4 text-left transition-all duration-150 ${ + disabled + ? "cursor-not-allowed border-gray-200 bg-gray-100 opacity-60" : selected - ? "border-primary bg-primary/5" - : "border-border bg-card hover:border-primary/40" - }`} + ? "border-emerald-500 bg-emerald-50 shadow-sm shadow-emerald-500/20" + : "border-gray-200 bg-white hover:border-emerald-300 hover:shadow-sm" + }`} > {selected && !disabled && ( - - + + )} {children} @@ -68,34 +54,25 @@ export function AlertBox({ tone: "warning" | "error" | "success" | "info"; children: ReactNode; }) { - const styles = { - warning: "bg-amber-50 border-amber-200 text-amber-800", - error: "bg-red-50 border-red-200 text-red-800", - success: "bg-emerald-50 border-emerald-200 text-emerald-800", - info: "bg-sky-50 border-sky-200 text-sky-800", + const map: Record = { + warning: { color: "yellow", icon: }, + error: { color: "red", icon: }, + success: { color: "teal", icon: }, + info: { color: "blue", icon: }, }; - const icons = { - warning: , - error: , - success: , - info: , - }; - + const { color, icon } = map[tone]; return ( -
- {icons[tone]} -
{children}
-
+ + {children} + ); } export function StepLabel({ children }: { children: ReactNode }) { return ( -

+ {children} -

+ ); } @@ -108,8 +85,12 @@ export function StepHeader({ }) { return (
-

{title}

-

{description}

+ + {title} + + + {description} +
); } @@ -120,46 +101,27 @@ export function SelectField({ label, placeholder, disabled, - children, + data, }: { field: ControllerRenderProps; error?: RhfFieldError; label: string; placeholder: string; disabled?: boolean; - children: ReactNode; + data: string[] | { value: string; label: string }[]; }) { return ( - - {label} - - - - ); -} - -export { SelectItem }; - -export function SelectOptions({ options }: { options: readonly string[] }) { - return ( - <> - {options.map((option) => ( - - {option} - - ))} - + - - + )} /> )}
+ {/* Last Mile */}
(
- +
-

- Last Mile - Delivery -

-

+

Last Mile — Delivery

+

Truck delivery from the destination rail yard to the final address (Port to Door).

@@ -194,7 +173,8 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
{ + onChange={(e) => { + const value = e.currentTarget.checked; field.onChange(value); if (!value) { form.setValue("lastMile.deliveryAddress", "", { @@ -206,6 +186,7 @@ export function Step2ServiceType({ form }: { form: BookingForm }) { }); } }} + color="edr-green" />
)} @@ -215,19 +196,19 @@ export function Step2ServiceType({ form }: { form: BookingForm }) { name="lastMile.deliveryAddress" control={form.control} render={({ field, fieldState }) => ( - - - - + )} /> )}
+ {/* Equipment Return */} {lastMileEnabled && (
(
-
-
-

Equipment Return

-

- {field.value === "with_return" - ? "Container returned to EDR after unloading." - : "Container retained by the customer after delivery."} -

-
+
+

Equipment Return

+

+ {field.value === "with_return" + ? "Container returned to EDR after unloading." + : "Container retained by the customer after delivery."} +

{ + onChange={(e) => { field.onChange( - value ? "with_return" : "without_return", + e.currentTarget.checked ? "with_return" : "without_return", ); }} + color="edr-green" />
)} @@ -259,6 +239,7 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
)} + {/* Customs Clearing */}
(
- +
-

- Customs Clearing Service -

-

+

Customs Clearing Service

+

EDR handles customs documentation and clearance on your behalf.

@@ -279,7 +258,8 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
field.onChange(e.currentTarget.checked)} + color="edr-green" />
)} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index f42e19449..c72d62d79 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo } from "react"; import { Controller, type UseFormReturn } from "react-hook-form"; import { Flame, MapPin, Snowflake } from "lucide-react"; -import { Field, SelectItem, Separator, Skeleton, Switch } from "@edr/ui-common"; +import { Divider, Skeleton, Stack, Switch } from "@mantine/core"; import type { Freight } from "@edr/types"; import { BookingFormInputValues, @@ -10,11 +10,7 @@ import { } from "./schema"; import { SelectField, StepHeader, StepLabel } from "./shared"; -type BookingForm = UseFormReturn< - BookingFormInputValues, - any, - BookingFormValues ->; +type BookingForm = UseFormReturn; export function Step4Route({ form, @@ -30,26 +26,29 @@ export function Step4Route({ const yardOptions = useMemo(() => { if (!referenceData?.yard) return []; - return referenceData.yard.map((y) => ({ - value: y.name, - label: y.name, - country: y.country, - })); + return referenceData.yard.map((y) => ({ value: y.name, label: y.name })); }, [referenceData]); const shippingLineOptions = useMemo(() => { if (!referenceData?.shipping_line) return []; - return referenceData.shipping_line.map((sl) => ({ - value: sl.name, - label: sl.name, - })); + return referenceData.shipping_line.map((sl) => ({ value: sl.name, label: sl.name })); }, [referenceData]); + const originData = useMemo( + () => yardOptions.filter((o) => o.value !== destinationYard), + [yardOptions, destinationYard], + ); + const destData = useMemo( + () => yardOptions.filter((o) => o.value !== originYard), + [yardOptions, originYard], + ); + const direction = getRouteDirection(originYard, destinationYard); + const directionStyle: Record = { export: "bg-sky-50 text-sky-800 border-sky-200", import: "bg-amber-50 text-amber-800 border-amber-200", - domestic: "bg-muted text-muted-foreground border-border", + domestic: "bg-gray-100 text-gray-600 border-gray-200", }; const directionLabel: Record = { export: "Export workflow (inside country to outside country)", @@ -85,15 +84,11 @@ export function Step4Route({ - - + data={originData} + /> )} /> - - + data={destData} + /> )} />
@@ -126,7 +117,7 @@ export function Step4Route({
)} - {direction && direction != "domestic" && ( + {direction && direction !== "domestic" && ( - {shippingLineOptions.map((sl) => ( - - {sl.label} - - ))} - + data={shippingLineOptions} + /> )} /> )} - -
+ + +

Hazardous Material

-

+

Applies a Hazard Surcharge to the final bill.

- + field.onChange(e.currentTarget.checked)} + color="edr-green" + />
)} /> @@ -176,13 +167,17 @@ export function Step4Route({

Refrigerated Cargo

-

+

Temperature-controlled transport applies a Refrigerator Surcharge.

- + field.onChange(e.currentTarget.checked)} + color="edr-green" + />
)} /> @@ -193,48 +188,18 @@ export function Step4Route({ function LoadingSkeleton() { return ( -
+
-
- - -
-
- - -
+ + + + + + + +
- +
); } - -function YardSelectOptions({ - options, - excludeValue, -}: { - options: Array<{ value: string; label: string; country: string }>; - excludeValue: string; -}) { - if (options.length === 0) { - return ( - - No yards available - - ); - } - - const availableOptions = options.filter( - (option) => option.value !== excludeValue, - ); - - return ( - <> - {availableOptions.map((option) => ( - - {option.label} - - ))} - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index cbd70f5ac..5f2b428f5 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -1,14 +1,7 @@ import { useMemo } from "react"; import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form"; import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react"; -import { - Button, - Field, - FieldError, - FieldLabel, - Input, - Skeleton, -} from "@edr/ui-common"; +import { ActionIcon, Button, Skeleton, Stack, Text, TextInput } from "@mantine/core"; import type { Freight } from "@edr/types"; import { BookingFormInputValues, @@ -19,17 +12,13 @@ import { import { AlertBox, OptionCard, + OptionFieldError, SelectField, - SelectItem, StepHeader, StepLabel, } from "./shared"; -type BookingForm = UseFormReturn< - BookingFormInputValues, - any, - BookingFormValues ->; +type BookingForm = UseFormReturn; export function Step5CargoDetails({ form, @@ -44,7 +33,6 @@ export function Step5CargoDetails({ }) { const cargoType = form.watch("cargoType"); const freightType = form.watch("freightType"); - const bulkCommoditytype = form.watch("bulkCommoditytype"); const containers = form.watch("containers"); const { fields, append, remove } = useFieldArray({ @@ -61,9 +49,7 @@ export function Step5CargoDetails({ const freightTypeGroups = useMemo(() => { if (!referenceData?.cargo_type) return []; - return referenceData.cargo_type.filter( - (g) => g.code !== "CONTAINER", - ); + return referenceData.cargo_type.filter((g) => g.code !== "CONTAINER"); }, [referenceData]); const commodityOptions = useMemo(() => { @@ -97,14 +83,14 @@ export function Step5CargoDetails({ title="Cargo Details" description="Define your cargo type, weight, and container configuration." /> -
- +
+
- - + +
- - + +
); @@ -117,28 +103,27 @@ export function Step5CargoDetails({ description="Define your cargo type, weight, and container configuration." /> + {/* Cargo Type */}
Cargo Type * ( - +
{ field.onChange("container"); - form.setValue("freightType", "", { - shouldDirty: true, - }); + form.setValue("freightType", "", { shouldDirty: true }); }} > -
- +
+

Containerized

-

+

Pre-packed containerized cargo (20ft / 40ft).

@@ -153,45 +138,41 @@ export function Step5CargoDetails({

General Cargo

-

+

Bulk commodities or break-bulk cargo.

- - + +
)} />
+ {/* Weight */}
Weight ( - - - Total Cargo Weight(Tons)* - -
- - -
- -
+ } + error={fieldState.error?.message} + radius="md" + min={0} + step={0.01} + /> )} />
+ + {/* Bulk freight type */} {cargoType === "bulk" && (
Freight Type * @@ -199,7 +180,7 @@ export function Step5CargoDetails({ name="freightType" control={form.control} render={({ field, fieldState }) => ( - +
{freightTypeGroups.map((group) => { const val = group.code.toLowerCase(); @@ -219,36 +200,30 @@ export function Step5CargoDetails({ ); })}
- - + +
)} /> {freightType && commodityOptions.length > 0 && ( -
- ( - - {commodityOptions.map((option) => ( - - {option} - - ))} - - )} - /> -
+ ( + + )} + /> )}
)} + {/* Container list */} {cargoType === "container" && ( <>
@@ -256,18 +231,14 @@ export function Step5CargoDetails({ Containers
@@ -280,25 +251,31 @@ export function Step5CargoDetails({ return (
+ + Container {index + 1} + {fields.length > 1 && ( - + + )}
+ {/* Container size */} ( - +
{[ { @@ -321,52 +298,47 @@ export function Step5CargoDetails({ onClick={() => typeField.onChange(ct.val)} >
- +

{ct.label}

-

- {ct.limit} -

+

{ct.limit}

))}
- - + +
)} /> + {/* Qty + VGM + Type */}
( - - Quantity * +
+ + Quantity * +
- - qtyField.onChange(e.target.value) - } + onChange={(e) => qtyField.onChange(e.target.value)} onBlur={qtyField.onBlur} type="number" - aria-invalid={fieldState.invalid} - className="text-center" - min="1" + min={1} + className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" />
- - + {fieldState.error?.message && ( + + {fieldState.error.message} + + )} +
)} /> @@ -389,20 +365,18 @@ export function Step5CargoDetails({ name={`containers.${index}.vgm`} control={form.control} render={({ field: vgmField, fieldState }) => ( - - Tons* - vgmField.onChange(e.target.value)} - onBlur={vgmField.onBlur} - type="number" - aria-invalid={fieldState.invalid} - placeholder="e.g. 18.5" - min="0" - step="0.1" - /> - - + vgmField.onChange(e.target.value)} + onBlur={vgmField.onBlur} + type="number" + label="Tons *" + placeholder="e.g. 18.5" + error={fieldState.error?.message} + radius="md" + min={0} + step={0.1} + /> )} /> @@ -415,13 +389,8 @@ export function Step5CargoDetails({ error={fieldState.error} label="Container Type *" placeholder="Select type..." - > - {containerTypeOptions.map((option) => ( - - {option} - - ))} - + data={containerTypeOptions} + /> )} />
@@ -441,18 +410,13 @@ export function Step5CargoDetails({ if (result.hasOddUnit) { return ( -
-
-

Unpaired 20ft Container

-

- One 20ft container occupies only half a wagon. The wagon - will depart once a co-loader is found to fill the - remaining slot, which{" "} - may delay departure beyond the standard - lead time. -

-
-
+

Unpaired 20ft Container

+

+ One 20ft container occupies only half a wagon. The wagon + will depart once a co-loader is found to fill the remaining + slot, which may delay departure beyond the + standard lead time. +

); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx index 2c1b2e4d0..3d2e087db 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -1,15 +1,5 @@ import { Controller, type UseFormReturn } from "react-hook-form"; -import { Check } from "lucide-react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, - Field, - FieldError, - FieldLabel, - Textarea, -} from "@edr/ui-common"; +import { Box, Card, Checkbox, SimpleGrid, Text, Textarea, Title } from "@mantine/core"; import { BookingFormInputValues, type BookingFormValues, @@ -17,11 +7,7 @@ import { } from "./schema"; import { StepHeader } from "./shared"; -type BookingForm = UseFormReturn< - BookingFormInputValues, - any, - BookingFormValues ->; +type BookingForm = UseFormReturn; export function Step8Review({ form, @@ -47,13 +33,17 @@ export function Step8Review({ return (
-

{label}

-

{value || "-"}

+ + {label} + + + {value || "—"} +
@@ -64,26 +54,53 @@ export function Step8Review({ const containerSummary = values.cargoType === "container" && values.containers.length > 0 ? values.containers - .filter((c) => +c.qty > 0) - .map((c) => `${c.qty} × ${c.type}`) - .join(", ") + .filter((c) => +c.qty > 0) + .map((c) => `${c.qty} × ${c.type}`) + .join(", ") : ""; + const totalVgm = values.cargoType === "container" ? values.containers.reduce( - (sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0), - 0, - ) + (sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0), + 0, + ) : 0; const cargoValue = values.cargoType === "container" ? containerSummary : values.freightType === "bulk" - ? `Bulk - ${values.bulkCommodity === "Others" ? values.bulkCommodityOther : values.bulkCommodity}` + ? `Bulk — ${values.bulkCommoditytype}` : values.freightType === "break_bulk" - ? `Break-Bulk - ${values.breakBulkType === "Others" ? values.breakBulkTypeOther : values.breakBulkType}` + ? `Break-Bulk` : ""; + + function ReviewCard({ + title, + children, + }: { + title: string; + children: React.ReactNode; + }) { + return ( + + + + {title} + + + + {children} + + + ); + } + return (
-
- - - - Contract & Service - - - - - - - - - - - - First & Last Mile - - - - - - - - - - - - - - Route & Cargo - - - - ${values.destinationYard}`} - target={3} - /> - + + + - - - - - + } + target={2} + /> + - - - - Container & Wagons - - - - - 0 ? `${totalVgm.toFixed(1)} tons` : ""} - target={4} - /> - - -
+ + + + + + + + + + + + + + + + + + 0 ? `${totalVgm.toFixed(1)} tons` : ""} + target={4} + /> + + ( - - Additional Notes - ",E.noCloneChecked=!!t.cloneNode(!0).lastChild.defaultValue,t.innerHTML="",E.option=!!t.lastChild})();var We={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};We.tbody=We.tfoot=We.colgroup=We.caption=We.thead,We.th=We.td,E.option||(We.optgroup=We.option=[1,""]);function Ie(e,t){var i;return typeof e.getElementsByTagName<"u"?i=e.getElementsByTagName(t||"*"):typeof e.querySelectorAll<"u"?i=e.querySelectorAll(t||"*"):i=[],t===void 0||t&&ae(e,t)?u.merge([e],i):i}function Kt(e,t){for(var i=0,l=e.length;i-1){f&&f.push(h);continue}if(w=pt(h),x=Ie(I.appendChild(h),"script"),w&&Kt(x),i)for(R=0;h=x[R++];)br.test(h.type||"")&&i.push(h)}return I}var Dr=/^([^.]*)(?:\.(.+)|)/;function xt(){return!0}function gt(){return!1}function Qt(e,t,i,l,f,h){var x,b;if(typeof t=="object"){typeof i!="string"&&(l=l||i,i=void 0);for(b in t)Qt(e,b,i,l,t[b],h);return e}if(l==null&&f==null?(f=i,l=i=void 0):f==null&&(typeof i=="string"?(f=l,l=void 0):(f=l,l=i,i=void 0)),f===!1)f=gt;else if(!f)return e;return h===1&&(x=f,f=function(v){return u().off(v),x.apply(this,arguments)},f.guid=x.guid||(x.guid=u.guid++)),e.each(function(){u.event.add(this,t,f,l,i)})}u.event={global:{},add:function(e,t,i,l,f){var h,x,b,v,w,R,I,C,F,ne,de,oe=V.get(e);if(Be(e))for(i.handler&&(h=i,i=h.handler,f=h.selector),f&&u.find.matchesSelector(ut,f),i.guid||(i.guid=u.guid++),(v=oe.events)||(v=oe.events=Object.create(null)),(x=oe.handle)||(x=oe.handle=function(Se){return typeof u<"u"&&u.event.triggered!==Se.type?u.event.dispatch.apply(e,arguments):void 0}),t=(t||"").match(Ne)||[""],w=t.length;w--;)b=Dr.exec(t[w])||[],F=de=b[1],ne=(b[2]||"").split(".").sort(),F&&(I=u.event.special[F]||{},F=(f?I.delegateType:I.bindType)||F,I=u.event.special[F]||{},R=u.extend({type:F,origType:de,data:l,handler:i,guid:i.guid,selector:f,needsContext:f&&u.expr.match.needsContext.test(f),namespace:ne.join(".")},h),(C=v[F])||(C=v[F]=[],C.delegateCount=0,(!I.setup||I.setup.call(e,l,ne,x)===!1)&&e.addEventListener&&e.addEventListener(F,x)),I.add&&(I.add.call(e,R),R.handler.guid||(R.handler.guid=i.guid)),f?C.splice(C.delegateCount++,0,R):C.push(R),u.event.global[F]=!0)},remove:function(e,t,i,l,f){var h,x,b,v,w,R,I,C,F,ne,de,oe=V.hasData(e)&&V.get(e);if(!(!oe||!(v=oe.events))){for(t=(t||"").match(Ne)||[""],w=t.length;w--;){if(b=Dr.exec(t[w])||[],F=de=b[1],ne=(b[2]||"").split(".").sort(),!F){for(F in v)u.event.remove(e,F+t[w],i,l,!0);continue}for(I=u.event.special[F]||{},F=(l?I.delegateType:I.bindType)||F,C=v[F]||[],b=b[2]&&new RegExp("(^|\\.)"+ne.join("\\.(?:.*\\.|)")+"(\\.|$)"),x=h=C.length;h--;)R=C[h],(f||de===R.origType)&&(!i||i.guid===R.guid)&&(!b||b.test(R.namespace))&&(!l||l===R.selector||l==="**"&&R.selector)&&(C.splice(h,1),R.selector&&C.delegateCount--,I.remove&&I.remove.call(e,R));x&&!C.length&&((!I.teardown||I.teardown.call(e,ne,oe.handle)===!1)&&u.removeEvent(e,F,oe.handle),delete v[F])}u.isEmptyObject(v)&&V.remove(e,"handle events")}},dispatch:function(e){var t,i,l,f,h,x,b=new Array(arguments.length),v=u.event.fix(e),w=(V.get(this,"events")||Object.create(null))[v.type]||[],R=u.event.special[v.type]||{};for(b[0]=v,t=1;t=1)){for(;w!==this;w=w.parentNode||this)if(w.nodeType===1&&!(e.type==="click"&&w.disabled===!0)){for(h=[],x={},i=0;i-1:u.find(f,this,null,[w]).length),x[f]&&h.push(l);h.length&&b.push({elem:w,handlers:h})}}return w=this,v\s*$/g;function kr(e,t){return ae(e,"table")&&ae(t.nodeType!==11?t:t.firstChild,"tr")&&u(e).children("tbody")[0]||e}function nn(e){return e.type=(e.getAttribute("type")!==null)+"/"+e.type,e}function an(e){return(e.type||"").slice(0,5)==="true/"?e.type=e.type.slice(5):e.removeAttribute("type"),e}function wr(e,t){var i,l,f,h,x,b,v;if(t.nodeType===1){if(V.hasData(e)&&(h=V.get(e),v=h.events,v)){V.remove(t,"handle events");for(f in v)for(i=0,l=v[f].length;i1&&typeof F=="string"&&!E.checkClone&&tn.test(F))return e.each(function(de){var oe=e.eq(de);ne&&(t[0]=F.call(this,de,oe.html())),yt(oe,t,i,l)});if(I&&(f=jr(t,e[0].ownerDocument,!1,e,l),h=f.firstChild,f.childNodes.length===1&&(f=h),h||l)){for(x=u?.map(Ie(f,"script"),nn),b=x.length;R0&&Kt(x,!v&&Ie(e,"script")),b},cleanData:function(e){for(var t,i,l,f=u.event.special,h=0;(i=e[h])!==void 0;h++)if(Be(i)){if(t=i[V.expando]){if(t.events)for(l in t.events)f[l]?u.event.remove(i,l):u.removeEvent(i,l,t.handle);i[V.expando]=void 0}i[_e.expando]&&(i[_e.expando]=void 0)}}}),u.fn.extend({detach:function(e){return Er(this,e,!0)},remove:function(e){return Er(this,e)},text:function(e){return xe(this,function(t){return t===void 0?u.text(this):this.empty().each(function(){(this.nodeType===1||this.nodeType===11||this.nodeType===9)&&(this.textContent=t)})},null,e,arguments.length)},append:function(){return yt(this,arguments,function(e){if(this.nodeType===1||this.nodeType===11||this.nodeType===9){var t=kr(this,e);t.appendChild(e)}})},prepend:function(){return yt(this,arguments,function(e){if(this.nodeType===1||this.nodeType===11||this.nodeType===9){var t=kr(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return yt(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return yt(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},empty:function(){for(var e,t=0;(e=this[t])!=null;t++)e.nodeType===1&&(u.cleanData(Ie(e,!1)),e.textContent="");return this},clone:function(e,t){return e=e??!1,t=t??e,this?.map(function(){return u.clone(this,e,t)})},html:function(e){return xe(this,function(t){var i=this[0]||{},l=0,f=this.length;if(t===void 0&&i.nodeType===1)return i.innerHTML;if(typeof t=="string"&&!en.test(t)&&!We[(vr.exec(t)||["",""])[1].toLowerCase()]){t=u.htmlPrefilter(t);try{for(;l=0&&(v+=Math.max(0,Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-h-v-b-.5))||0),v+w}function Or(e,t,i){var l=Ht(e),f=!E.boxSizingReliable()||i,h=f&&u.css(e,"boxSizing",!1,l)==="border-box",x=h,b=St(e,t,l),v="offset"+t[0].toUpperCase()+t.slice(1);if(Gt.test(b)){if(!i)return b;b="auto"}return(!E.boxSizingReliable()&&h||!E.reliableTrDimensions()&&ae(e,"tr")||b==="auto"||!parseFloat(b)&&u.css(e,"display",!1,l)==="inline")&&e.getClientRects().length&&(h=u.css(e,"boxSizing",!1,l)==="border-box",x=v in e,x&&(b=e[v])),b=parseFloat(b)||0,b+Zt(e,t,i||(h?"border":"content"),x,l,b)+"px"}u.extend({cssHooks:{opacity:{get:function(e,t){if(t){var i=St(e,"opacity");return i===""?"1":i}}}},cssNumber:{animationIterationCount:!0,aspectRatio:!0,borderImageSlice:!0,columnCount:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,gridArea:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnStart:!0,gridRow:!0,gridRowEnd:!0,gridRowStart:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,scale:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeMiterlimit:!0,strokeOpacity:!0},cssProps:{},style:function(e,t,i,l){if(!(!e||e.nodeType===3||e.nodeType===8||!e.style)){var f,h,x,b=je(t),v=Xt.test(t),w=e.style;if(v||(t=$t(b)),x=u.cssHooks[t]||u.cssHooks[b],i!==void 0){if(h=typeof i,h==="string"&&(f=Nt.exec(i))&&f[1]&&(i=gr(e,t,f),h="number"),i==null||i!==i)return;h==="number"&&!v&&(i+=f&&f[3]||(u.cssNumber[b]?"":"px")),!E.clearCloneStyle&&i===""&&t.indexOf("background")===0&&(w[t]="inherit"),(!x||!("set"in x)||(i=x.set(e,i,l))!==void 0)&&(v?w.setProperty(t,i):w[t]=i)}else return x&&"get"in x&&(f=x.get(e,!1,l))!==void 0?f:w[t]}},css:function(e,t,i,l){var f,h,x,b=je(t),v=Xt.test(t);return v||(t=$t(b)),x=u.cssHooks[t]||u.cssHooks[b],x&&"get"in x&&(f=x.get(e,!0,i)),f===void 0&&(f=St(e,t,l)),f==="normal"&&t in Mr&&(f=Mr[t]),i===""||i?(h=parseFloat(f),i===!0||isFinite(h)?h||0:f):f}}),u.each(["height","width"],function(e,t){u.cssHooks[t]={get:function(i,l,f){if(l)return un.test(u.css(i,"display"))&&(!i.getClientRects().length||!i.getBoundingClientRect().width)?Nr(i,dn,function(){return Or(i,t,f)}):Or(i,t,f)},set:function(i,l,f){var h,x=Ht(i),b=!E.scrollboxSize()&&x.position==="absolute",v=b||f,w=v&&u.css(i,"boxSizing",!1,x)==="border-box",R=f?Zt(i,t,f,w,x):0;return w&&b&&(R-=Math.ceil(i["offset"+t[0].toUpperCase()+t.slice(1)]-parseFloat(x[t])-Zt(i,t,"border",!1,x)-.5)),R&&(h=Nt.exec(l))&&(h[3]||"px")!=="px"&&(i.style[t]=l,l=u.css(i,t)),Tr(i,l,R)}}}),u.cssHooks.marginLeft=Cr(E.reliableMarginLeft,function(e,t){if(t)return(parseFloat(St(e,"marginLeft"))||e.getBoundingClientRect().left-Nr(e,{marginLeft:0},function(){return e.getBoundingClientRect().left}))+"px"}),u.each({margin:"",padding:"",border:"Width"},function(e,t){u.cssHooks[e+t]={expand:function(i){for(var l=0,f={},h=typeof i=="string"?i.split(" "):[i];l<4;l++)f[e+Ze[l]+t]=h[l]||h[l-2]||h[0];return f}},e!=="margin"&&(u.cssHooks[e+t].set=Tr)}),u.fn.extend({css:function(e,t){return xe(this,function(i,l,f){var h,x,b={},v=0;if(Array.isArray(l)){for(h=Ht(i),x=l.length;v1)}});function Ae(e,t,i,l,f){return new Ae.prototype.init(e,t,i,l,f)}u.Tween=Ae,Ae.prototype={constructor:Ae,init:function(e,t,i,l,f,h){this.elem=e,this.prop=i,this.easing=f||u.easing._default,this.options=t,this.start=this.now=this.cur(),this.end=l,this.unit=h||(u.cssNumber[i]?"":"px")},cur:function(){var e=Ae.propHooks[this.prop];return e&&e.get?e.get(this):Ae.propHooks._default.get(this)},run:function(e){var t,i=Ae.propHooks[this.prop];return this.options.duration?this.pos=t=u.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):this.pos=t=e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),i&&i.set?i.set(this):Ae.propHooks._default.set(this),this}},Ae.prototype.init.prototype=Ae.prototype,Ae.propHooks={_default:{get:function(e){var t;return e.elem.nodeType!==1||e.elem[e.prop]!=null&&e.elem.style[e.prop]==null?e.elem[e.prop]:(t=u.css(e.elem,e.prop,""),!t||t==="auto"?0:t)},set:function(e){u.fx.step[e.prop]?u.fx.step[e.prop](e):e.elem.nodeType===1&&(u.cssHooks[e.prop]||e.elem.style[$t(e.prop)]!=null)?u.style(e.elem,e.prop,e.now+e.unit):e.elem[e.prop]=e.now}}},Ae.propHooks.scrollTop=Ae.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},u.easing={linear:function(e){return e},swing:function(e){return .5-Math.cos(e*Math.PI)/2},_default:"swing"},u.fx=Ae.prototype.init,u.fx.step={};var vt,Yt,fn=/^(?:toggle|show|hide)$/,hn=/queueHooks$/;function er(){Yt&&(A.hidden===!1&&o.requestAnimationFrame?o.requestAnimationFrame(er):o.setTimeout(er,u.fx.interval),u.fx.tick())}function Lr(){return o.setTimeout(function(){vt=void 0}),vt=Date.now()}function Bt(e,t){var i,l=0,f={height:e};for(t=t?1:0;l<4;l+=2-t)i=Ze[l],f["margin"+i]=f["padding"+i]=e;return t&&(f.opacity=f.width=e),f}function Ir(e,t,i){for(var l,f=(qe.tweeners[t]||[]).concat(qe.tweeners["*"]),h=0,x=f.length;h1)},removeAttr:function(e){return this.each(function(){u.removeAttr(this,e)})}}),u.extend({attr:function(e,t,i){var l,f,h=e.nodeType;if(!(h===3||h===8||h===2)){if(typeof e.getAttribute>"u")return u.prop(e,t,i);if((h!==1||!u.isXMLDoc(e))&&(f=u.attrHooks[t.toLowerCase()]||(u.expr.match.bool.test(t)?Ar:void 0)),i!==void 0){if(i===null){u.removeAttr(e,t);return}return f&&"set"in f&&(l=f.set(e,i,t))!==void 0?l:(e.setAttribute(t,i+""),i)}return f&&"get"in f&&(l=f.get(e,t))!==null?l:(l=u.find.attr(e,t),l??void 0)}},attrHooks:{type:{set:function(e,t){if(!E.radioValue&&t==="radio"&&ae(e,"input")){var i=e.value;return e.setAttribute("type",t),i&&(e.value=i),t}}}},removeAttr:function(e,t){var i,l=0,f=t&&t.match(Ne);if(f&&e.nodeType===1)for(;i=f[l++];)e.removeAttribute(i)}}),Ar={set:function(e,t,i){return t===!1?u.removeAttr(e,i):e.setAttribute(i,i),i}},u.each(u.expr.match.bool.source.match(/\w+/g),function(e,t){var i=_t[t]||u.find.attr;_t[t]=function(l,f,h){var x,b,v=f.toLowerCase();return h||(b=_t[v],_t[v]=x,x=i(l,f,h)!=null?v:null,_t[v]=b),x}});var xn=/^(?:input|select|textarea|button)$/i,gn=/^(?:a|area)$/i;u.fn.extend({prop:function(e,t){return xe(this,u.prop,e,t,arguments.length>1)},removeProp:function(e){return this.each(function(){delete this[u.propFix[e]||e]})}}),u.extend({prop:function(e,t,i){var l,f,h=e.nodeType;if(!(h===3||h===8||h===2))return(h!==1||!u.isXMLDoc(e))&&(t=u.propFix[t]||t,f=u.propHooks[t]),i!==void 0?f&&"set"in f&&(l=f.set(e,i,t))!==void 0?l:e[t]=i:f&&"get"in f&&(l=f.get(e,t))!==null?l:e[t]},propHooks:{tabIndex:{get:function(e){var t=u.find.attr(e,"tabindex");return t?parseInt(t,10):xn.test(e.nodeName)||gn.test(e.nodeName)&&e.href?0:-1}}},propFix:{for:"htmlFor",class:"className"}}),E.optSelected||(u.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null},set:function(e){var t=e.parentNode;t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex)}}),u.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){u.propFix[this.toLowerCase()]=this});function ct(e){var t=e.match(Ne)||[];return t.join(" ")}function dt(e){return e.getAttribute&&e.getAttribute("class")||""}function tr(e){return Array.isArray(e)?e:typeof e=="string"?e.match(Ne)||[]:[]}u.fn.extend({addClass:function(e){var t,i,l,f,h,x;return M(e)?this.each(function(b){u(this).addClass(e.call(this,b,dt(this)))}):(t=tr(e),t.length?this.each(function(){if(l=dt(this),i=this.nodeType===1&&" "+ct(l)+" ",i){for(h=0;h-1;)i=i.replace(" "+f+" "," ");x=ct(i),l!==x&&this.setAttribute("class",x)}}):this):this.attr("class","")},toggleClass:function(e,t){var i,l,f,h,x=typeof e,b=x==="string"||Array.isArray(e);return M(e)?this.each(function(v){u(this).toggleClass(e.call(this,v,dt(this),t),t)}):typeof t=="boolean"&&b?t?this.addClass(e):this.removeClass(e):(i=tr(e),this.each(function(){if(b)for(h=u(this),f=0;f-1)return!0;return!1}});var yn=/\r/g;u.fn.extend({val:function(e){var t,i,l,f=this[0];return arguments.length?(l=M(e),this.each(function(h){var x;this.nodeType===1&&(l?x=e.call(this,h,u(this).val()):x=e,x==null?x="":typeof x=="number"?x+="":Array.isArray(x)&&(x=u?.map(x,function(b){return b==null?"":b+""})),t=u.valHooks[this.type]||u.valHooks[this.nodeName.toLowerCase()],(!t||!("set"in t)||t.set(this,x,"value")===void 0)&&(this.value=x))})):f?(t=u.valHooks[f.type]||u.valHooks[f.nodeName.toLowerCase()],t&&"get"in t&&(i=t.get(f,"value"))!==void 0?i:(i=f.value,typeof i=="string"?i.replace(yn,""):i??"")):void 0}}),u.extend({valHooks:{option:{get:function(e){var t=u.find.attr(e,"value");return t??ct(u.text(e))}},select:{get:function(e){var t,i,l,f=e.options,h=e.selectedIndex,x=e.type==="select-one",b=x?null:[],v=x?h+1:f.length;for(h<0?l=v:l=x?h:0;l-1)&&(i=!0);return i||(e.selectedIndex=-1),h}}}}),u.each(["radio","checkbox"],function(){u.valHooks[this]={set:function(e,t){if(Array.isArray(t))return e.checked=u.inArray(u(e).val(),t)>-1}},E.checkOn||(u.valHooks[this].get=function(e){return e.getAttribute("value")===null?"on":e.value})});var Rt=o.location,Pr={guid:Date.now()},rr=/\?/;u.parseXML=function(e){var t,i;if(!e||typeof e!="string")return null;try{t=new o.DOMParser().parseFromString(e,"text/xml")}catch{}return i=t&&t.getElementsByTagName("parsererror")[0],(!t||i)&&u.error("Invalid XML: "+(i?u?.map(i.childNodes,function(l){return l.textContent}).join(` +`):e)),t};var Fr=/^(?:focusinfocus|focusoutblur)$/,Wr=function(e){e.stopPropagation()};u.extend(u.event,{trigger:function(e,t,i,l){var f,h,x,b,v,w,R,I,C=[i||A],F=k.call(e,"type")?e.type:e,ne=k.call(e,"namespace")?e.namespace.split("."):[];if(h=I=x=i=i||A,!(i.nodeType===3||i.nodeType===8)&&!Fr.test(F+u.event.triggered)&&(F.indexOf(".")>-1&&(ne=F.split("."),F=ne.shift(),ne.sort()),v=F.indexOf(":")<0&&"on"+F,e=e[u.expando]?e:new u.Event(F,typeof e=="object"&&e),e.isTrigger=l?2:3,e.namespace=ne.join("."),e.rnamespace=e.namespace?new RegExp("(^|\\.)"+ne.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,e.result=void 0,e.target||(e.target=i),t=t==null?[e]:u.makeArray(t,[e]),R=u.event.special[F]||{},!(!l&&R.trigger&&R.trigger.apply(i,t)===!1))){if(!l&&!R.noBubble&&!P(i)){for(b=R.delegateType||F,Fr.test(b+F)||(h=h.parentNode);h;h=h.parentNode)C.push(h),x=h;x===(i.ownerDocument||A)&&C.push(x.defaultView||x.parentWindow||o)}for(f=0;(h=C[f++])&&!e.isPropagationStopped();)I=h,e.type=f>1?b:R.bindType||F,w=(V.get(h,"events")||Object.create(null))[e.type]&&V.get(h,"handle"),w&&w.apply(h,t),w=v&&h[v],w&&w.apply&&Be(h)&&(e.result=w.apply(h,t),e.result===!1&&e.preventDefault());return e.type=F,!l&&!e.isDefaultPrevented()&&(!R._default||R._default.apply(C.pop(),t)===!1)&&Be(i)&&v&&M(i[F])&&!P(i)&&(x=i[v],x&&(i[v]=null),u.event.triggered=F,e.isPropagationStopped()&&I.addEventListener(F,Wr),i[F](),e.isPropagationStopped()&&I.removeEventListener(F,Wr),u.event.triggered=void 0,x&&(i[v]=x)),e.result}},simulate:function(e,t,i){var l=u.extend(new u.Event,i,{type:e,isSimulated:!0});u.event.trigger(l,null,t)}}),u.fn.extend({trigger:function(e,t){return this.each(function(){u.event.trigger(e,t,this)})},triggerHandler:function(e,t){var i=this[0];if(i)return u.event.trigger(e,t,i,!0)}});var vn=/\[\]$/,Hr=/\r?\n/g,bn=/^(?:submit|button|image|reset|file)$/i,jn=/^(?:input|select|textarea|keygen)/i;function nr(e,t,i,l){var f;if(Array.isArray(t))u.each(t,function(h,x){i||vn.test(e)?l(e,x):nr(e+"["+(typeof x=="object"&&x!=null?h:"")+"]",x,i,l)});else if(!i&&se(t)==="object")for(f in t)nr(e+"["+f+"]",t[f],i,l);else l(e,t)}u.param=function(e,t){var i,l=[],f=function(h,x){var b=M(x)?x():x;l[l.length]=encodeURIComponent(h)+"="+encodeURIComponent(b??"")};if(e==null)return"";if(Array.isArray(e)||e.jquery&&!u.isPlainObject(e))u.each(e,function(){f(this.name,this.value)});else for(i in e)nr(i,e[i],t,f);return l.join("&")},u.fn.extend({serialize:function(){return u.param(this.serializeArray())},serializeArray:function(){return this?.map(function(){var e=u.prop(this,"elements");return e?u.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!u(this).is(":disabled")&&jn.test(this.nodeName)&&!bn.test(e)&&(this.checked||!Ct.test(e))})?.map(function(e,t){var i=u(this).val();return i==null?null:Array.isArray(i)?u?.map(i,function(l){return{name:t.name,value:l.replace(Hr,`\r +`)}}):{name:t.name,value:i.replace(Hr,`\r +`)}}).get()}});var Dn=/%20/g,kn=/#.*$/,wn=/([?&])_=[^&]*/,En=/^(.*?):[ \t]*([^\r\n]*)$/mg,Nn=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,Cn=/^(?:GET|HEAD)$/,Sn=/^\/\//,Yr={},ar={},Br="*/".concat("*"),ir=A.createElement("a");ir.href=Rt.href;function qr(e){return function(t,i){typeof t!="string"&&(i=t,t="*");var l,f=0,h=t.toLowerCase().match(Ne)||[];if(M(i))for(;l=h[f++];)l[0]==="+"?(l=l.slice(1)||"*",(e[l]=e[l]||[]).unshift(i)):(e[l]=e[l]||[]).push(i)}}function Jr(e,t,i,l){var f={},h=e===ar;function x(b){var v;return f[b]=!0,u.each(e[b]||[],function(w,R){var I=R(t,i,l);if(typeof I=="string"&&!h&&!f[I])return t.dataTypes.unshift(I),x(I),!1;if(h)return!(v=I)}),v}return x(t.dataTypes[0])||!f["*"]&&x("*")}function sr(e,t){var i,l,f=u.ajaxSettings.flatOptions||{};for(i in t)t[i]!==void 0&&((f[i]?e:l||(l={}))[i]=t[i]);return l&&u.extend(!0,e,l),e}function _n(e,t,i){for(var l,f,h,x,b=e.contents,v=e.dataTypes;v[0]==="*";)v.shift(),l===void 0&&(l=e.mimeType||t.getResponseHeader("Content-Type"));if(l){for(f in b)if(b[f]&&b[f].test(l)){v.unshift(f);break}}if(v[0]in i)h=v[0];else{for(f in i){if(!v[0]||e.converters[f+" "+v[0]]){h=f;break}x||(x=f)}h=h||x}if(h)return h!==v[0]&&v.unshift(h),i[h]}function Rn(e,t,i,l){var f,h,x,b,v,w={},R=e.dataTypes.slice();if(R[1])for(x in e.converters)w[x.toLowerCase()]=e.converters[x];for(h=R.shift();h;)if(e.responseFields[h]&&(i[e.responseFields[h]]=t),!v&&l&&e.dataFilter&&(t=e.dataFilter(t,e.dataType)),v=h,h=R.shift(),h){if(h==="*")h=v;else if(v!=="*"&&v!==h){if(x=w[v+" "+h]||w["* "+h],!x){for(f in w)if(b=f.split(" "),b[1]===h&&(x=w[v+" "+b[0]]||w["* "+b[0]],x)){x===!0?x=w[f]:w[f]!==!0&&(h=b[0],R.unshift(b[1]));break}}if(x!==!0)if(x&&e.throws)t=x(t);else try{t=x(t)}catch(I){return{state:"parsererror",error:x?I:"No conversion from "+v+" to "+h}}}}return{state:"success",data:t}}u.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:Rt.href,type:"GET",isLocal:Nn.test(Rt.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":Br,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":u.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?sr(sr(e,u.ajaxSettings),t):sr(u.ajaxSettings,e)},ajaxPrefilter:qr(Yr),ajaxTransport:qr(ar),ajax:function(e,t){typeof e=="object"&&(t=e,e=void 0),t=t||{};var i,l,f,h,x,b,v,w,R,I,C=u.ajaxSetup({},t),F=C.context||C,ne=C.context&&(F.nodeType||F.jquery)?u(F):u.event,de=u.Deferred(),oe=u.Callbacks("once memory"),Se=C.statusCode||{},Ce={},Ke={},Qe="canceled",ce={readyState:0,getResponseHeader:function(he){var we;if(v){if(!h)for(h={};we=En.exec(f);)h[we[1].toLowerCase()+" "]=(h[we[1].toLowerCase()+" "]||[]).concat(we[2]);we=h[he.toLowerCase()+" "]}return we==null?null:we.join(", ")},getAllResponseHeaders:function(){return v?f:null},setRequestHeader:function(he,we){return v==null&&(he=Ke[he.toLowerCase()]=Ke[he.toLowerCase()]||he,Ce[he]=we),this},overrideMimeType:function(he){return v==null&&(C.mimeType=he),this},statusCode:function(he){var we;if(he)if(v)ce.always(he[ce.status]);else for(we in he)Se[we]=[Se[we],he[we]];return this},abort:function(he){var we=he||Qe;return i&&i.abort(we),ft(0,we),this}};if(de.promise(ce),C.url=((e||C.url||Rt.href)+"").replace(Sn,Rt.protocol+"//"),C.type=t.method||t.type||C.method||C.type,C.dataTypes=(C.dataType||"*").toLowerCase().match(Ne)||[""],C.crossDomain==null){b=A.createElement("a");try{b.href=C.url,b.href=b.href,C.crossDomain=ir.protocol+"//"+ir.host!=b.protocol+"//"+b.host}catch{C.crossDomain=!0}}if(C.data&&C.processData&&typeof C.data!="string"&&(C.data=u.param(C.data,C.traditional)),Jr(Yr,C,t,ce),v)return ce;w=u.event&&C.global,w&&u.active++===0&&u.event.trigger("ajaxStart"),C.type=C.type.toUpperCase(),C.hasContent=!Cn.test(C.type),l=C.url.replace(kn,""),C.hasContent?C.data&&C.processData&&(C.contentType||"").indexOf("application/x-www-form-urlencoded")===0&&(C.data=C.data.replace(Dn,"+")):(I=C.url.slice(l.length),C.data&&(C.processData||typeof C.data=="string")&&(l+=(rr.test(l)?"&":"?")+C.data,delete C.data),C.cache===!1&&(l=l.replace(wn,"$1"),I=(rr.test(l)?"&":"?")+"_="+Pr.guid+++I),C.url=l+I),C.ifModified&&(u.lastModified[l]&&ce.setRequestHeader("If-Modified-Since",u.lastModified[l]),u.etag[l]&&ce.setRequestHeader("If-None-Match",u.etag[l])),(C.data&&C.hasContent&&C.contentType!==!1||t.contentType)&&ce.setRequestHeader("Content-Type",C.contentType),ce.setRequestHeader("Accept",C.dataTypes[0]&&C.accepts[C.dataTypes[0]]?C.accepts[C.dataTypes[0]]+(C.dataTypes[0]!=="*"?", "+Br+"; q=0.01":""):C.accepts["*"]);for(R in C.headers)ce.setRequestHeader(R,C.headers[R]);if(C.beforeSend&&(C.beforeSend.call(F,ce,C)===!1||v))return ce.abort();if(Qe="abort",oe.add(C.complete),ce.done(C.success),ce.fail(C.error),i=Jr(ar,C,t,ce),!i)ft(-1,"No Transport");else{if(ce.readyState=1,w&&ne.trigger("ajaxSend",[ce,C]),v)return ce;C.async&&C.timeout>0&&(x=o.setTimeout(function(){ce.abort("timeout")},C.timeout));try{v=!1,i.send(Ce,ft)}catch(he){if(v)throw he;ft(-1,he)}}function ft(he,we,Tt,lr){var Ge,Ot,Xe,it,st,He=we;v||(v=!0,x&&o.clearTimeout(x),i=void 0,f=lr||"",ce.readyState=he>0?4:0,Ge=he>=200&&he<300||he===304,Tt&&(it=_n(C,ce,Tt)),!Ge&&u.inArray("script",C.dataTypes)>-1&&u.inArray("json",C.dataTypes)<0&&(C.converters["text script"]=function(){}),it=Rn(C,it,ce,Ge),Ge?(C.ifModified&&(st=ce.getResponseHeader("Last-Modified"),st&&(u.lastModified[l]=st),st=ce.getResponseHeader("etag"),st&&(u.etag[l]=st)),he===204||C.type==="HEAD"?He="nocontent":he===304?He="notmodified":(He=it.state,Ot=it.data,Xe=it.error,Ge=!Xe)):(Xe=He,(he||!He)&&(He="error",he<0&&(he=0))),ce.status=he,ce.statusText=(we||He)+"",Ge?de.resolveWith(F,[Ot,He,ce]):de.rejectWith(F,[ce,He,Xe]),ce.statusCode(Se),Se=void 0,w&&ne.trigger(Ge?"ajaxSuccess":"ajaxError",[ce,C,Ge?Ot:Xe]),oe.fireWith(F,[ce,He]),w&&(ne.trigger("ajaxComplete",[ce,C]),--u.active||u.event.trigger("ajaxStop")))}return ce},getJSON:function(e,t,i){return u.get(e,t,i,"json")},getScript:function(e,t){return u.get(e,void 0,t,"script")}}),u.each(["get","post"],function(e,t){u[t]=function(i,l,f,h){return M(l)&&(h=h||f,f=l,l=void 0),u.ajax(u.extend({url:i,type:t,dataType:h,data:l,success:f},u.isPlainObject(i)&&i))}}),u.ajaxPrefilter(function(e){var t;for(t in e.headers)t.toLowerCase()==="content-type"&&(e.contentType=e.headers[t]||"")}),u._evalUrl=function(e,t,i){return u.ajax({url:e,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,converters:{"text script":function(){}},dataFilter:function(l){u.globalEval(l,t,i)}})},u.fn.extend({wrapAll:function(e){var t;return this[0]&&(M(e)&&(e=e.call(this[0])),t=u(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t?.map(function(){for(var i=this;i.firstElementChild;)i=i.firstElementChild;return i}).append(this)),this},wrapInner:function(e){return M(e)?this.each(function(t){u(this).wrapInner(e.call(this,t))}):this.each(function(){var t=u(this),i=t.contents();i.length?i.wrapAll(e):t.append(e)})},wrap:function(e){var t=M(e);return this.each(function(i){u(this).wrapAll(t?e.call(this,i):e)})},unwrap:function(e){return this.parent(e).not("body").each(function(){u(this).replaceWith(this.childNodes)}),this}}),u.expr.pseudos.hidden=function(e){return!u.expr.pseudos.visible(e)},u.expr.pseudos.visible=function(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)},u.ajaxSettings.xhr=function(){try{return new o.XMLHttpRequest}catch{}};var Mn={0:200,1223:204},Mt=u.ajaxSettings.xhr();E.cors=!!Mt&&"withCredentials"in Mt,E.ajax=Mt=!!Mt,u.ajaxTransport(function(e){var t,i;if(E.cors||Mt&&!e.crossDomain)return{send:function(l,f){var h,x=e.xhr();if(x.open(e.type,e.url,e.async,e.username,e.password),e.xhrFields)for(h in e.xhrFields)x[h]=e.xhrFields[h];e.mimeType&&x.overrideMimeType&&x.overrideMimeType(e.mimeType),!e.crossDomain&&!l["X-Requested-With"]&&(l["X-Requested-With"]="XMLHttpRequest");for(h in l)x.setRequestHeader(h,l[h]);t=function(b){return function(){t&&(t=i=x.onload=x.onerror=x.onabort=x.ontimeout=x.onreadystatechange=null,b==="abort"?x.abort():b==="error"?typeof x.status!="number"?f(0,"error"):f(x.status,x.statusText):f(Mn[x.status]||x.status,x.statusText,(x.responseType||"text")!=="text"||typeof x.responseText!="string"?{binary:x.response}:{text:x.responseText},x.getAllResponseHeaders()))}},x.onload=t(),i=x.onerror=x.ontimeout=t("error"),x.onabort!==void 0?x.onabort=i:x.onreadystatechange=function(){x.readyState===4&&o.setTimeout(function(){t&&i()})},t=t("abort");try{x.send(e.hasContent&&e.data||null)}catch(b){if(t)throw b}},abort:function(){t&&t()}}}),u.ajaxPrefilter(function(e){e.crossDomain&&(e.contents.script=!1)}),u.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(e){return u.globalEval(e),e}}}),u.ajaxPrefilter("script",function(e){e.cache===void 0&&(e.cache=!1),e.crossDomain&&(e.type="GET")}),u.ajaxTransport("script",function(e){if(e.crossDomain||e.scriptAttrs){var t,i;return{send:function(l,f){t=u(" + + + +
+ + diff --git a/apps/edr-freight-web/backoffice/public/_um/tinymce/CHANGELOG.md b/apps/edr-freight-web/backoffice/public/_um/tinymce/CHANGELOG.md new file mode 100644 index 000000000..dfa4751f4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/tinymce/CHANGELOG.md @@ -0,0 +1,3802 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), +and is generated by [Changie](https://github.com/miniscruff/changie). + +## 7.9.3 - 2026-05-19 + +### Security +- Fixed media plugin `data-mce-object` injection leading to stored XSS. #TINY-14357 +- Fixed stored XSS vulnerability through `mce:protected` comments. #TINY-14353 +- Fixed stored XSS vulnerability through `data-mce-` prefixed `src`, `href`, `style` attributes. #TINY-14333 + +## 7.9.2 - 2026-02-11 + +### Deprecated +- The default value of `allow_html_in_comments` will change from `true` to `false` in TinyMCE 8.x. #TINY-11900 + +### Security +- Updated dependencies and parsing logic for enhanced content sanitization. HTML-like content in comments and certain legacy patterns are now sanitized more strictly when `xss_sanitization` is enabled (default). The Introduced `allow_html_in_comments` option provides control over comment node sanitization behavior. + #TINY-11900 +- Introduced `allow_html_in_comments` option (boolean, default: `true`) to control handling of HTML-like syntax in comment nodes. This option will default to `false` in TinyMCE 8.x. #TINY-11900 + +## 7.9.1 - 2025-05-29 + +### Improved +- Update `Notices` file and minified notices. #TINY-12091 + +## 7.9.0 - 2025-05-15 + +### Added +- Added new `disc` style option for unordered lists. #TINY-12015 + +### Improved +- The resize cursor now points in the correct direction for each resize mode. Patch contributed by daniloff200. ##GH-10189 +- If `style_formats` is empty, the button is now disabled. #TINY-12005 +- Inline dialog dropdowns reposition when the dialog is dragged or the window is scrolled. #TINY-11368 +- Bullet list icons were have been updated to better represent the default styles. #TINY-12014 + +### Changed +- The ContextFormSizeInput lock button is now centered instead of aligned to the end. #TINY-11916 +- Changed the default value of `advlist_bullet_styles` option to `default,disc,circle,square`. #TINY-12083 + +### Fixed +- Autolink no longer overrides already existing links when autolinking. #TINY-11836 +- Removed the deprecated CSS media selector `-ms-high-contrast`. #TINY-11876 +- The `mceInsertContent` command no longer deletes the parent block element when an anchor is selected. #TINY-11953 +- Table resizers are now visible when inline editor has a z-index property. #TINY-11981 +- Tabbing inside a `figcaption` element no longer displays two text insertion carets. #TINY-11997 +- Pressing Enter before a floating image no longer duplicates the image. #TINY-11676 +- Editor did not scroll into viewport on receiving focus on Chrome and Safari. #TINY-12017 +- Select UI elements was not properly styled on Chrome version 136. #TINY-12131 + +## 7.8.0 - 2025-04-09 + +### Added +- New subtoolbar support for context toolbars. #TINY-11748 +- New `extended_mathml_attributes` and `extended_mathml_elements` options. #TINY-11756 +- New `onboarding` option. #TINY-11931 + +### Improved +- Focus outline was misaligned with comment card border on saving an edit. #TINY-11329 +- The `editor.selection.scrollIntoView()` method now pads the target scroll area with a small margin, ensuring content doesn't sit at the very edge of the viewport. #TINY-11786 + +### Changed +- Changed promotional text and link. #TINY-11905 + +### Fixed +- Setting editor height to a `pt` or `em` value was ignoring min/max height settings. #TINY-11108 + +## 7.7.2 - 2025-03-19 + +### Fixed +- Error was thrown when pressing tab in the last cell of a non-editable table. #TINY-11797 +- Error was thrown when trying to use the context form API after a component was detached. #TINY-11781 +- Deleting an empty block within an
  • element would move cursor to the end of the
  • . #TINY-11763 +- Deleting an empty block that was between two lists would throw an Error when all three elements were nested inside a list. #TINY-11763 + +## 7.7.1 - 2025-03-05 + +### Fixed +- Skin UI content CSS was truncated when bundling, causing CSS styles to be missing. #TINY-11875 +- Context forms used to disappear if their input was disabled in the `onSetup` API. #TINY-11890 + +## 7.7.0 - 2025-02-20 + +### Added +- `link_attributes_postprocess` option that allows overriding attributes of a link that would be inserted through the link dialog. #TINY-11707 + +### Improved +- Improved visual indication of keyboard focus in annotations that contain an image. #TINY-11596 +- The type now defaults to `info` when `editor.notificationManager.open()` is used without a specified type or with an invalid one. #TINY-11661 + +### Changed +- Updated the `link` plugin behavior to move the cursor outside of the link when inserted or edited via the UI. Patch contributed by Philipp91. #GH-9998 + +### Fixed +- Keyboard navigation for size inputs in context forms. #TINY-11394 +- Keyboard navigation for context form sliders. #TINY-11482 +- The `insertContent` API was not replacing selected non-editable elements correctly. #TINY-11714 +- Context toolbar inputs had incorrect margins. #TINY-11624 +- Iframe aria text no longer suggests opening the help dialog when the help plugin is not enabled. #TINY-11672 +- Preview dialog no longer opens anchor links in a new tab. #TINY-11740 +- The `float` property was not properly removed on the image when converting a image into a captioned image. #TINY-11670 +- Expanding selection to word didn't work inside inline editing host elements. #TINY-11304 +- The `semantics` element in MathML was not properly retained when `annotation` elements were allowed. #TINY-11755 +- It was possible to tab to a toolbar group that had all children disabled. #TINY-11665 +- Keyboard navigation would get stuck on the 'more' toolbar button. #TINY-11762 +- Toolbar groups had both a `title` attribute and a custom tooltip, causing overlapping tooltips #TINY-11768 +- Toolbar text field did not render focus correctly. #TINY-11658 + +## 7.6.1 - 2025-01-22 + +### Fixed +- Text input was prevented in form elements in the contents of the editor. #TINY-11446 +- Opening a notification when the toolbar is positioned at the bottom of the editor threw an error. #TINY-11498 +- Table resize bars were not properly aligned for inline editors inside scrollable containers. #TINY-11215 + +## 7.6.0 - 2024-12-11 + +### Added +- It is now possible to create labeled groups in context toolbars. #TINY-11095 +- New `contextsliderform` and `contextsizeinput` context form types. #TINY-11342 +- New `back` function in `ContextFormApi` to go back to the previous toolbar. #TINY-11344 +- New `QuickbarInsertImage` command that is executed by the `quickimage` button. #TINY-11399 +- New `onSetup` function to the context form API. #TINY-11494 +- New `placeholder` to the context form input field API. #TINY-11459 +- New `disabled` option to restore the previous `readonly` mode behavior, allowing the editor to be displayed in a disabled state. #TINY-11488 + +### Improved +- Base64 data was not properly decoded due to unhandled URL-encoded characters. #TINY-9548 +- The `latin` list style type is now recognized as an alias for the `alpha` list style type. #TINY-11515 + +### Fixed +- Image selection was removed when calling `editor.nodeChanged()` while having focus inside the editor UI. #TINY-11437 +- Tooltip would not show for group toolbar button. #TINY-11391 +- Changing the table row type when a `contenteditable=false` cell was selected would not work as expected. #TINY-11383 +- The `samp` format was being applied as a `block` level format, instead of an `inline` format. #TINY-11390 +- Removed title attribute from dialog tree elements as they already have a tooltip. #TINY-11470 +- Fixed CSS bundling for skin UI content CSS. #TINY-11558 +- Fixed incorrect resource keys for CSS bundling JS files. #TINY-11558 + +## 7.5.0 - 2024-11-06 + +### Added +- Added support for using raw CSS in the list of possible colours, using the `color_map_raw` property. #GH-9788 + +### Improved +- Improved color picker aria support. #TINY-11291 + +### Fixed +- Autocompleter would not activate after applying an inline format like font size in some cases. #TINY-11273 +- The `toolbar-sticky-offset` would still be applied after entering fullscreen mode. #TINY-11137 +- Text and background color toolbar buttons would not be fully greyed out in readonly mode. #TINY-11313 +- Closing a nested modal dialog would lose focus from the editor. #TINY-11153 +- Inability to type '{' character on German keyboard layouts. #TINY-11395 + +## 7.4.1 - 2024-10-10 + +### Fixed +- Invalid HTML elements within SVG elements were not removed. #TINY-11332 + +## 7.4.0 - 2024-10-09 + +### Added +- New `context` property for all ui components. This allows buttons and menu items to be enabled or disabled based on whether their context matches a given predicate; status updates are checked on `init`, `NodeChange`, and `SwitchMode` events. #TINY-11211 +- Tree component now allows the addition of a custom icon. #TINY-11131 +- Added focus function to view button api. #TINY-11122 +- New option `allow_mathml_annotation_encodings` to opt-in to keep math annotations with specific encodings. #TINY-11166 +- Added global `color-active` LESS variable for use in editor skins. #TINY-11266 + +### Improved +- In read-only mode the editor now allows normal cursor movement and block element selection, including video playback. #TINY-11264 +- Pasting a table now places the cursor after the table instead of into the last cell. #TINY-11082 +- Dialog list dropdown menus now close when the browser window resizes. #TINY-11123 + +### Fixed +- Mouse hover on partially visible dialog collection elements no longer scrolls. #TINY-9915 +- Caret would unexpectedly shift to the non-editable table row above when pressing Enter. #TINY-11077 +- Deleting a selection in a list element would sometimes prevent the `input` event from being dispatched. #TINY-11100 +- Placing the cursor after a table with a br after it would misplace added newlines before the table instead of after. #TINY-11110 +- Sidebar could not be toggled until the skin was loaded. #TINY-11155 +- The image dialog lost focus after closing an image upload error alert. #TINY-11159 +- Copying tables to the clipboard did not correctly separate cells and rows for the "text/plain" MIME type. #TINY-10847 +- The editor resize handle was incorrectly rendered when all components were removed from the status bar. #TINY-11257 + +## 7.3.0 - 2024-08-07 + +### Added +- Colorpicker number input fields now show an error tooltip and error icon when invalid text has been entered. #TINY-10799 +- New `format-code` icon. #TINY-11018 + +### Improved +- When a full document was loaded as editor content the head elements were added to the body. #TINY-11053 + +### Fixed +- Unnecessary nbsp entities were inserted when typing at the edges of inline elements. #TINY-10854 +- Fixed JavaScript error when inserting a table using the context menu by adjusting the event order in `renderInsertTableMenuItem`. #TINY-6887 +- Notifications didn't position and resize properly when resizing the editor or toggling views. #TINY-10894 +- The pattern commands would execute even if the command was not enabled. #TINY-10994 +- Split button popups were incorrectly positioned when switching to fullscreen mode if the editor was inside a scrollable container. #TINY-10973 +- Sequential html comments would in some cases generate unwanted elements. #TINY-10955 +- The listbox component had a fixed width and was not a responsive ui element. #TINY-10884 +- Prevent default mousedown on toolbar buttons was causing misplaced focus bugs. #TINY-10638 +- Attempting to use focus commands on an editor where the cursor had last been in certain contentEditable="true" elements would fail. #TINY-11085 +- Colorpicker's hex-based input field showed the wrong validation error message. #TINY-11115 + +## 7.2.1 - 2024-07-03 + +### Fixed +- Text content could move unexpectedly when deleting a paragraph. #TINY-10590 +- Cursor would shift to the start of the editor body when focus was shifted to a noneditable cell of a table. #TINY-10127 +- Long translations of the bottom help text would cause minor graphical issues. #TINY-10961 +- Open Link button was disabled when selection partially covered a link or when multiple links were selected. #TINY-11009 + +## 7.2.0 - 2024-06-19 + +### Added +- Added `options.debug` API that logs the initial raw editor options to console. #TINY-10605 +- Added `referrerpolicy` as a valid attribute for an iframe element. #TINY-10374 +- New `onInit` and `stretched` properties to the `HtmlPanel` dialog component. #TINY-10900 +- Added support for querying the state of the `mceTogglePlainTextPaste` command. #TINY-10938 +- Added `for` option to dialog label components to improve accessibility. The value must be another component on the same dialog. #TINY-10971 + +### Improved +- Dialog slider components now emit an onChange event when using arrow keys. #TINY-10428 +- Accessibility for element path buttons, added tooltip to describe the button and removed incorrect `aria-level` attribute. #TINY-10891 +- Improve merging of inserted inline elements by removing nodes with redundant inheritable styles. #TINY-10869 +- Improved Find & Replace dialog accessibility by changing placeholders to labels. #TINY-10871 + +### Changed +- Replaced tiny branding logo with `Build with TinyMCE` text and logo. #TINY-11001 + +### Fixed +- Deleting in a `div` with preceeding `br` elements would sometimes throw errors. #TINY-10840 +- `autoresize_bottom_margin` was not reliably applied in some situations. #TINY-10793 +- Fixed cases where adding a newline around a br, table or img would not move the cursor to a new line. #TINY-10384 +- Focusing on `contenteditable="true"` element when using `editable_root: false` and inline mode causing selection to be shifted. #TINY-10820 +- Corrected the `role` attribute on listbox dialog components to `combobox` when there are no nested menu items. #TINY-10807 +- HTML entities that were double decoded in `noscript` elements caused an XSS vulnerability. #TINY-11019 +- It was possible to inject XSS HTML that was not matching the regexp when using the `noneditable_regexp` option. #TINY-11022 + +## 7.1.2 - 2024-06-05 + +### Fixed +- CSS color values set to `transparent` were incorrectly converted to '#000000`. #TINY-10916 + +## 7.1.1 - 2024-05-22 + +### Fixed +- Insert/Edit image dialog lost focus after the image upload completed. #TINY-10885 +- Deleting into a list from a paragraph that has an `img` tag could cause extra inline styles to be added. #TINY-10892 +- Resolved an issue where emojis configured with the `emojiimages` database were not loading correctly due to a broken CDN. #TINY-10878 +- Iframes in dialogs were not rendering rounded borders correctly. #TINY-10901 +- Autocompleter possible values are no longer capped at a length of 10. #TINY-10942 + +## 7.1.0 - 2024-05-08 + +### Added +- Parser support for math elements. #TINY-10809 +- New `math-equation` icon. #TINY-10804 + +### Improved +- Included `itemprop`, `itemscope` and `itemtype` as valid HTML5 attributes in the core schema. #TINY-9932 +- Notification accessibility improvements: added tooltips, keyboard navigation and shortcut to focus on notifications. #TINY-6925 +- Removed `aria-pressed` from the `More` button in sliding toolbar mode and replaced it with `aria-expanded`. #TINY-10795 +- The editor UI now renders correctly in Windows High Contrast Mode. #TINY-10781 + +### Fixed +- Backspacing in certain html setups resulted in data moving around unexpectedly. #TINY-10590 +- Dialog title markup changed to use an `h1` element instead of `div`. #TINY-10800 +- Dialog title was not announced in macOS VoiceOver, dialogs now use `aria-label` instead of `aria-labelledby` on macOS. #TINY-10808 +- Theme loader did not respect the suffix when it was loading skin CSS files. #TINY-10602 +- Custom block elements with colon characters would throw errors. #TINY-10813 +- Tab navigation in views didn't work. #TINY-10780 +- Video and audio elements could not be played on Safari. #TINY-10774 +- `ToggleToolbarDrawer` command did not toggle the toolbar in `sliding` mode when `{skipFocus: true}` parameter was passed. #TINY-10726 +- The buttons in the custom view header were clipped on when overflowing. #TINY-10741 +- In the custom view, the scrollbar of the container was not visible if its height was greater than the editor. #TINY-10741 +- Fixed accessibility issue by removing duplicate `role="menu"` attribute from color swatches. #TINY-10806 +- Fullscreen mode now prevents focus from leaving the editor. #TINY-10597 +- Open link context menu action did not work with selection surrounding a link. #TINY-10391 +- Styles were not retained when toggling a list on and off. #TINY-10837 +- Caret and placeholder text were invisible in Windows High Contrast Mode. #TINY-9811 +- Firefox did not announce the iframe title when `iframe_aria_text` was set. #TINY-10718 +- Notification width was not constrained to the width of the editor. #TINY-10886 +- Open link context menu action was not enabled for links on images. #TINY-10391 + +## 7.0.1 - 2024-04-10 + +### Fixed +- Toggle list behavior generated wrong html when the `forced_root_block` option was set to `div`. #TINY-10488 +- Tapping inside a composed text on Firefox Android would not close the autocompleter. #TINY-10715 +- An inline editor toolbar now behaves correctly in horizontally scrolled containers. #TINY-10684 +- Tooltips unintended shrinking and incorrectly positioned when shown in horizontally scrollable container. #TINY-10797 +- The status bar was invisible when the editor's height is short. #TINY-10705 + +## 7.0.0 - 2024-03-20 + +### Added +- New `license_key` option that must be set to `gpl` or a valid license key. #TINY-10681 +- New custom tooltip functionality, tooltip will be shown when hovering with a mouse or with keyboard focus. #TINY-9275 +- New `sandbox_iframes_exclusions` option that holds a list of URL host names to be excluded from iframe sandboxing when `sandbox_iframes` is set to `true`. #TINY-10350 +- Added 'getAllEmojis' api function to the emoticons plugin. #TINY-10572 +- Element preset support for the `valid_children` option and Schema.addValidChildren API. #TINY-9979 +- A new `trigger` property for block text pattern configurations, allowing pattern activation with either Space or Enter keys. #TINY-10324 +- onFocus callback for CustomEditor dialog component. #TINY-10596 +- icons for the import from Word, export to Word and export to PDF premium plugins. #TINY-10612 +- `data` is now a valid element in the Schema. #TINY-10611 +- More advanced schema config for custom elements. #TINY-9980 +- Custom tooltip for autocompleter, now visible on both mouse hover and keyboard focus, except single column cases. #TINY-9638 + +### Improved +- Included keyboard shortcut in custom tooltip for `ToolbarButton` and `ToolbarToggleButton`. #TINY-10487 +- Improved showing which element has focus for keyboard navigation. #TINY-9176 +- Custom tooltips will now show for items in `collection` which is rendered inside a dialog, on mouse hover and keyboard focus. #TINY-9637 +- Autocompleter will now work with IMEs. #TINY-10637 +- Make table ghost element better reflect height changes when resizing. #TINY-10658 + +### Changed +- TinyMCE is now licensed GPL Version 2 or later. #TINY-10578 +- `convert_unsafe_embeds` editor option is now defaulted to `true`. #TINY-10351 +- `sandbox_iframes` editor option is now defaulted to `true`. #TINY-10350 +- The DOMUtils.isEmpty API function has been modified to consider nodes containing only comments as empty. #TINY-10459 +- The `highlight_on_focus` option now defaults to true, adding a focus outline to every editor. #TINY-10574 +- Delay before the tooltip to show up, from 800ms to 300ms. #TINY-10475 +- Now `tox-view__pane` has `position: relative` instead of `static`. #TINY-10561 +- Update outbound link for statusbar Tiny logo #TINY-10494 +- Remove the height field from the `table` plugin cell dialog. The `table` plugin row dialog now controls the row height by setting the height on the `tr` element, not the `td` elements. #TINY-10617 +- Change table height resizing handling to remove heights from `td`/`th` elements and only apply to `tr` elements. #TINY-10589 +- Removed incorrect `aria-placeholder` attribute from editor body when `placeholder` option is set. #TINY-10452 +- The `tooltip` property for dialog's footer `togglebutton` is now optional. #TINY-10672 +- Changed the `media_url_resolver` option to use promises. #TINY-9154 +- `Styles` bespoke toolbar button fallback changed to `Formats` if `Paragraph` is not configured in `style_formats` option. #TINY-10603 +- Updated deprecation/removed console message. #TINY-10694 + +### Removed +- Deprecated `force_hex_color` option, with the default now being all colors are forced to hex format as lower case. #TINY-10436 +- Deprecated `remove_trailing_brs` option from DomParser. #TINY-10454 +- `title` attribute on buttons with visible label. #TINY-10453 +- `InsertOrderedList` and `InsertUnorderedList` commands from core, these now only exist in the `lists` plugin. #TINY-10644 +- `closeButton` from the notification API, close buttons in notifications are now required. #TINY-10646 +- The autocompleter `ch` configuration property has been removed. Use the `trigger` property instead. #TINY-8929 +- Deprecated `template` plugin. #TINY-10654 + +### Fixed +- When deleting the last row in a table, the cursor would jump to the first cell (top left), instead of moving to the next adjacent cell in some cases. #TINY-6309 +- Heading formatting would be partially applied to the content within the `summary` element when the caret was positioned between words. #TINY-10312 +- Moving focus to the outside of the editor after having clicked a menu would not fire a `blur` event as expected. #TINY-10310 +- Autocomplete would sometimes cause corrupt data when starting during text composition. #TINY-10317 +- Inline mode with persisted toolbar would show regardless of the skin being loaded, causing css issues. #TINY-10482 +- Table classes couldn't be removed via setting an empty value in `table_class_list`. Also fixed being forced to pick the first class option. #TINY-6653 +- Directly right clicking on a ol's li in FireFox didn't enable the button `List Properties...` in the context menu. #TINY-10490 +- The `link_default_target` option wasn't considered when inserting a link via `quicklink` toolbar. #TINY-10439 +- When inline editor toolbar wrapped to multiple lines the top wasn't always calculated correctly. #TINY-10580 +- Removed manually dispatching dragend event on drop in Firefox. #TINY-10389 +- Slovenian help dialog content had a dot in the wrong place. #TINY-10601 +- Pressing Backspace at the start of an empty `summary` element within a `details` element nested in a list item no longer removes the `summary` element. #TINY-10303 +- The toolbar width was miscalculated for the inline editor positioned inside a scrollable container. #TINY-10581 +- Fixed incorrect object processor for `event_root` option. #TINY-10433 +- Adding newline after using `selection.setContent` to insert a block element would throw an unhandled exception. #TINY-10560 +- Floating toolbar buttons in inline editor incorrectly wrapped into multiple rows on window resizing or zooming. #TINY-10570 +- When setting table border width and `table_style_by_css` is true, only the border attribute is set to 0 and border-width styling is no longer used. #TINY-10308 +- Clicking to the left or right of a non-editable div in Firefox would show two cursors. #TINY-10314 + +## 6.8.3 - 2024-02-08 + +### Changed +- Update outbound TinyMCE website links. #TINY-10491 + +### Fixed +- The floating toolbar would not be fully visible when the editor was placed inside a scrollable container. #TINY-10335 +- ShadowDOM skin was not loaded properly when used with js bundling feature. #TINY-10451 + +## 6.8.2 - 2023-12-11 + +### Fixed +- Bespoke select toolbar buttons including `fontfamily`, `fontsize`, `blocks`, and `styles` incorrectly used plural words in their accessible names. #TINY-10426 +- The `align` bespoke select toolbar button had an accessible name that was misleading and grammatically incorrect in certain cases. #TINY-10435 +- Accessible names of bespoke select toolbar buttons including `align`, `fontfamily`, `fontsize`, `blocks`, and `styles` were incorrectly translated. #TINY-10426 #TINY-10435 +- Clicking inside table cells with heavily nested content could cause the browser to hang. #TINY-10380 +- Toggling a list that contains an LI element having another list as its first child would remove the remaining content within that LI element. #TINY-10414 + +## 6.8.1 - 2023-11-29 + +### Improved +- Colorpicker now includes the Brightness/Saturation selector and hue slider in the keyboard navigable items. #TINY-9287 + +### Fixed +- Translation syntax for announcement text in the table grid was incorrectly formatted. #TINY-10141 +- The functions `schema.isWrapper` and `schema.isInline` did not exclude node names that started with `#` which should not be considered as elements. #TINY-10385 + +## 6.8.0 - 2023-11-22 + +### Added +- CSS files are now also generated as separate JS files to improve bundling of all resources. #TINY-10352 +- Added new `StylesheetLoader.loadRawCss` API that can be used to load CSS into a style element. #TINY-10352 +- Added new `StylesheetLoader.unloadRawCss` API that can be used to unload CSS that was loaded into a style element. #TINY-10352 +- Added `force_hex_color` editor option. Option `'always'` converts all RGB & RGBA colours to hex, `'rgb_only'` will only convert RGB and *not* RGBA colours to hex, `'off'` won't convert any colours to hex. #TINY-9819 +- Added `default_font_stack` editor option that makes it possible to define what is considered a system font stack. #TINY-10290 +- New `sandbox_iframes` option that controls whether iframe elements will be added a `sandbox=""` attribute to mitigate malicious intent. #TINY-10348 +- New `convert_unsafe_embeds` option that controls whether `` and `` elements will be converted to more restrictive alternatives, namely `` for image MIME types, `