From 88df20be6b38766cade3ec22b7fc6a43a6e8bbbc Mon Sep 17 00:00:00 2001 From: marshal Date: Mon, 8 Jun 2026 10:04:23 +0300 Subject: [PATCH] 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';