mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 20:05:41 +00:00
change price logic on the ,rule engine ui, auto generate the contrat
This commit is contained in:
@@ -48,6 +48,7 @@ import { WagonsModule } from './modules/wagons/wagons.module';
|
|||||||
import { ContainersModule } from './modules/container-management/containers.module';
|
import { ContainersModule } from './modules/container-management/containers.module';
|
||||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||||
import { RoutesModule } from './modules/routes/routes.module';
|
import { RoutesModule } from './modules/routes/routes.module';
|
||||||
|
import { OverviewModule } from './modules/overview/overview.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -100,6 +101,7 @@ import { RoutesModule } from './modules/routes/routes.module';
|
|||||||
ContainersModule,
|
ContainersModule,
|
||||||
CargoesModule,
|
CargoesModule,
|
||||||
RoutesModule,
|
RoutesModule,
|
||||||
|
OverviewModule,
|
||||||
],
|
],
|
||||||
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
|
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ export function computeNextStep(
|
|||||||
const { status } = booking;
|
const { status } = booking;
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
case 'PRICE_CHANGED_PENDING_CONFIRM':
|
||||||
|
return {
|
||||||
|
action: 'CONFIRM_SUBMIT',
|
||||||
|
description: 'Price has changed since preview; confirm to submit booking',
|
||||||
|
};
|
||||||
case 'SUBMITTED':
|
case 'SUBMITTED':
|
||||||
return {
|
return {
|
||||||
action: 'ACCEPT_INTAKE',
|
action: 'ACCEPT_INTAKE',
|
||||||
|
|||||||
@@ -14,6 +14,24 @@ import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price
|
|||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
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()
|
@Injectable()
|
||||||
export class BookingPricingService {
|
export class BookingPricingService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -26,22 +44,56 @@ export class BookingPricingService {
|
|||||||
|
|
||||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||||
const booking = await this.requireBooking(bookingId);
|
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<ComputedPriceResult> {
|
||||||
const evalInput = await this.buildEvalInputForBooking(booking);
|
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||||
console.log('evalInput----', evalInput);
|
|
||||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||||
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
|
||||||
|
|
||||||
const lineItems: PriceLineItemDto[] = [];
|
const lineItems: PriceLineItemDto[] = [];
|
||||||
let total = 0;
|
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) {
|
for (const line of baseLines) {
|
||||||
lineItems.push(line);
|
lineItems.push(line);
|
||||||
total += line.amount;
|
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) {
|
for (const mod of ruleResult.appliedModifiers) {
|
||||||
const item: PriceLineItemDto = {
|
const item: PriceLineItemDto = {
|
||||||
code: mod.surchargeTypeCode,
|
code: mod.surchargeTypeCode,
|
||||||
@@ -51,30 +103,60 @@ export class BookingPricingService {
|
|||||||
};
|
};
|
||||||
lineItems.push(item);
|
lineItems.push(item);
|
||||||
total += mod.calculatedAmount;
|
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 {
|
return {
|
||||||
bookingId,
|
lineItems,
|
||||||
totalAmount: total,
|
totalAmount: total,
|
||||||
currency: booking.paymentCurrency,
|
currency: booking.paymentCurrency,
|
||||||
lineItems,
|
usedRates: [...usedRatesMap.values()],
|
||||||
|
appliedModifiers: ruleResult.appliedModifiers,
|
||||||
|
priorityScore: ruleResult.priorityScore,
|
||||||
warnings: ruleResult.warnings,
|
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<void> {
|
||||||
|
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<typeof r> => r !== null);
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await this.bookingsRepository.createCargoModifiers(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
|
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
|
||||||
const containers = await Promise.all(
|
const containers = await Promise.all(
|
||||||
(booking.bookingContainers ?? []).map(async (bc) => {
|
(booking.bookingContainers ?? []).map(async (bc) => {
|
||||||
@@ -115,11 +197,7 @@ export class BookingPricingService {
|
|||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
}> {
|
}> {
|
||||||
const stored = booking.pricingBreakdown as {
|
const stored = booking.pricingBreakdown as StoredPricingBreakdown;
|
||||||
lineItems?: PriceLineItemDto[];
|
|
||||||
totalAmount?: number;
|
|
||||||
currency?: string;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
if (stored?.lineItems?.length) {
|
if (stored?.lineItems?.length) {
|
||||||
return {
|
return {
|
||||||
@@ -129,41 +207,28 @@ export class BookingPricingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const evalInput = await this.buildEvalInputForBooking(booking);
|
const computed = await this.computePriceForBooking(booking);
|
||||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
|
||||||
const lineItems: PriceLineItemDto[] = [];
|
|
||||||
let total = 0;
|
|
||||||
|
|
||||||
const baseLines = await this.computeBaseRailLines(booking, evalInput);
|
if (computed.lineItems.length === 0) {
|
||||||
for (const line of baseLines) {
|
const total = Number(booking.totalAmount);
|
||||||
lineItems.push(line);
|
return {
|
||||||
total += line.amount;
|
lineItems: [
|
||||||
}
|
{
|
||||||
|
code: 'TOTAL',
|
||||||
for (const mod of ruleResult.appliedModifiers) {
|
description: 'Contract total',
|
||||||
lineItems.push({
|
amount: total,
|
||||||
code: mod.surchargeTypeCode,
|
currency: booking.paymentCurrency,
|
||||||
description: `Surcharge: ${mod.surchargeTypeCode}`,
|
},
|
||||||
amount: mod.calculatedAmount,
|
],
|
||||||
currency: mod.currency,
|
totalAmount: total,
|
||||||
});
|
|
||||||
total += mod.calculatedAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lineItems.length === 0) {
|
|
||||||
total = Number(booking.totalAmount);
|
|
||||||
lineItems.push({
|
|
||||||
code: 'TOTAL',
|
|
||||||
description: 'Contract total',
|
|
||||||
amount: total,
|
|
||||||
currency: booking.paymentCurrency,
|
currency: booking.paymentCurrency,
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lineItems,
|
lineItems: computed.lineItems,
|
||||||
totalAmount: total || Number(booking.totalAmount),
|
totalAmount: computed.totalAmount || Number(booking.totalAmount),
|
||||||
currency: booking.paymentCurrency,
|
currency: computed.currency,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,14 +255,14 @@ export class BookingPricingService {
|
|||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async computeBaseRailLines(
|
private async computeBaseRailLinesWithRates(
|
||||||
booking: Booking,
|
booking: Booking,
|
||||||
evalInput: BookingEvaluationInput,
|
evalInput: BookingEvaluationInput,
|
||||||
): Promise<PriceLineItemDto[]> {
|
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
||||||
const liveRates = await this.ratesService.findLiveRates();
|
const liveRates = await this.ratesService.findLiveRates();
|
||||||
const currency = booking.paymentCurrency;
|
const currency = booking.paymentCurrency;
|
||||||
const isBulk = booking.freightType === 'BULK';
|
const isBulk = booking.freightType === 'BULK';
|
||||||
console.log('liveRates----', liveRates);
|
|
||||||
const rateType =
|
const rateType =
|
||||||
booking.tradeDirection === 'IMPORT'
|
booking.tradeDirection === 'IMPORT'
|
||||||
? isBulk
|
? isBulk
|
||||||
@@ -209,18 +274,15 @@ console.log('liveRates----', liveRates);
|
|||||||
: 'CONTAINER_EXPORT'
|
: 'CONTAINER_EXPORT'
|
||||||
: 'INTERCITY_CONTAINER';
|
: 'INTERCITY_CONTAINER';
|
||||||
|
|
||||||
|
|
||||||
console.log('rateType----', rateType);
|
|
||||||
|
|
||||||
const lines: PriceLineItemDto[] = [];
|
const lines: PriceLineItemDto[] = [];
|
||||||
|
const usedRatesMap = new Map<string, Rate>();
|
||||||
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
||||||
|
|
||||||
for (const container of evalInput.containers) {
|
for (const container of evalInput.containers) {
|
||||||
console.log('container----', container);
|
|
||||||
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
|
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
|
||||||
console.log('rate----', rate);
|
|
||||||
if (!rate) continue;
|
if (!rate) continue;
|
||||||
|
|
||||||
|
usedRatesMap.set(rate.id, rate);
|
||||||
const amount = this.amountForRate(rate, container.quantity, wagonCount);
|
const amount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||||
lines.push({
|
lines.push({
|
||||||
code: rateType,
|
code: rateType,
|
||||||
@@ -235,6 +297,7 @@ console.log('liveRates----', liveRates);
|
|||||||
(r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
|
(r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
|
||||||
);
|
);
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
|
usedRatesMap.set(fallback.id, fallback);
|
||||||
const amount = this.amountForRate(fallback, 1, wagonCount);
|
const amount = this.amountForRate(fallback, 1, wagonCount);
|
||||||
lines.push({
|
lines.push({
|
||||||
code: rateType,
|
code: rateType,
|
||||||
@@ -245,7 +308,7 @@ console.log('liveRates----', liveRates);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines;
|
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
||||||
}
|
}
|
||||||
|
|
||||||
private pickRate(
|
private pickRate(
|
||||||
@@ -281,31 +344,15 @@ console.log('liveRates----', liveRates);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistPriceRun(
|
private lineItemsSignature(items: PriceLineItemDto[]): string {
|
||||||
bookingId: string,
|
return JSON.stringify(
|
||||||
modifiers: AppliedCargoModifier[],
|
[...items]
|
||||||
_total: number,
|
.map((item) => ({
|
||||||
): Promise<void> {
|
code: item.code,
|
||||||
await this.bookingsRepository.clearPricingArtifacts(bookingId);
|
amount: item.amount,
|
||||||
const snapshots = await this.ruleEngineService.snapshotLiveRates(bookingId);
|
currency: item.currency,
|
||||||
|
}))
|
||||||
const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
|
.sort((a, b) => a.code.localeCompare(b.code)),
|
||||||
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<typeof r> => r !== null);
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
|
||||||
await this.bookingsRepository.createCargoModifiers(rows);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { BookingPricingService } from './booking-pricing.service';
|
|||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
import { computeNextStep, type BookingNextStep } from './booking-next-step.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 { Booking } from './entities/booking.entity';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ export class BookingTransitionService {
|
|||||||
private readonly bookingsService: BookingsService,
|
private readonly bookingsService: BookingsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async submit(bookingId: string): Promise<Booking> {
|
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
|
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
|
||||||
|
|
||||||
@@ -32,14 +34,119 @@ export class BookingTransitionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
|
const computed = await this.pricingService.computePriceForBooking(booking);
|
||||||
await this.ruleEngineService.snapshotLiveRates(bookingId);
|
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<SubmitBookingResponseDto> {
|
||||||
|
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, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'SUBMITTED',
|
status: 'SUBMITTED',
|
||||||
priorityScore,
|
priorityScore,
|
||||||
|
totalAmount: computed.totalAmount,
|
||||||
|
pricingBreakdown: {
|
||||||
|
lineItems: computed.lineItems,
|
||||||
|
totalAmount: computed.totalAmount,
|
||||||
|
currency: computed.currency,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
} as never);
|
} 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(
|
async requestChanges(
|
||||||
@@ -279,6 +386,7 @@ export class BookingTransitionService {
|
|||||||
assertBookingStatus(booking, [
|
assertBookingStatus(booking, [
|
||||||
'DRAFT',
|
'DRAFT',
|
||||||
'SUBMITTED',
|
'SUBMITTED',
|
||||||
|
'PRICE_CHANGED_PENDING_CONFIRM',
|
||||||
'CHANGES_REQUESTED',
|
'CHANGES_REQUESTED',
|
||||||
'PENDING_APPROVAL',
|
'PENDING_APPROVAL',
|
||||||
'CONTRACT_READY',
|
'CONTRACT_READY',
|
||||||
@@ -321,4 +429,4 @@ export class BookingTransitionService {
|
|||||||
nextStep,
|
nextStep,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,6 +39,7 @@ import { CreateBookingDto } from './dto/create-booking.dto';
|
|||||||
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
|
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
|
||||||
import { FilterBookingDto } from './dto/filter-booking.dto';
|
import { FilterBookingDto } from './dto/filter-booking.dto';
|
||||||
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||||||
|
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||||
import {
|
import {
|
||||||
ApproveStepDto,
|
ApproveStepDto,
|
||||||
CancelBookingDto,
|
CancelBookingDto,
|
||||||
@@ -165,17 +166,36 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/generate-price')
|
@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 })
|
@ApiOkResponse({ type: GeneratePriceResponseDto })
|
||||||
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
|
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.pricingService.generatePrice(id);
|
return this.pricingService.generatePrice(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/submit')
|
@Post(':id/submit')
|
||||||
@ApiOperation({ summary: 'Customer submit booking' })
|
@ApiOperation({
|
||||||
async submit(@Param('id', ParseUUIDPipe) id: string) {
|
summary: 'Customer submit booking',
|
||||||
const booking = await this.transitionService.submit(id);
|
description:
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
'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')
|
@Post(':id/staff/request-changes')
|
||||||
|
|||||||
@@ -345,6 +345,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
|
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async hasPricingArtifacts(bookingId: string): Promise<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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. */
|
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
|
||||||
async findQueue(options: {
|
async findQueue(options: {
|
||||||
status: string | string[];
|
status: string | string[];
|
||||||
|
|||||||
@@ -348,6 +348,15 @@ export class BookingsService {
|
|||||||
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
||||||
warnings.push(...ruleResult.warnings);
|
warnings.push(...ruleResult.warnings);
|
||||||
|
|
||||||
|
const pricingFieldsChanged = this.pricingRelevantFieldsChanged(
|
||||||
|
existing,
|
||||||
|
dto,
|
||||||
|
freightType,
|
||||||
|
cargoTypeId,
|
||||||
|
allowConsolidation,
|
||||||
|
containers,
|
||||||
|
);
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {
|
const updates: Record<string, unknown> = {
|
||||||
...dto,
|
...dto,
|
||||||
freightType,
|
freightType,
|
||||||
@@ -375,6 +384,10 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pricingFieldsChanged) {
|
||||||
|
await this.bookingsRepository.invalidatePricingPreview(id);
|
||||||
|
}
|
||||||
|
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
await this.filesService.uploadMany(id, 'bookings', files);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { BookingReviewNote } from './booking-review-note.entity';
|
|||||||
export const BOOKING_STATUSES = [
|
export const BOOKING_STATUSES = [
|
||||||
'DRAFT',
|
'DRAFT',
|
||||||
'SUBMITTED',
|
'SUBMITTED',
|
||||||
|
'PRICE_CHANGED_PENDING_CONFIRM',
|
||||||
'CHANGES_REQUESTED',
|
'CHANGES_REQUESTED',
|
||||||
'PENDING_APPROVAL',
|
'PENDING_APPROVAL',
|
||||||
'APPROVED_PENDING_SIGNATURE',
|
'APPROVED_PENDING_SIGNATURE',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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<OverviewResponseDto> {
|
||||||
|
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<OverviewBookingsTabDto> {
|
||||||
|
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<OverviewBillingTabDto> {
|
||||||
|
return this.overviewService.getBillingTab(query.range ?? '30d');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('operations')
|
||||||
|
@BookingView()
|
||||||
|
@ApiOperation({ summary: 'Operations tab metrics and charts' })
|
||||||
|
@ApiOkResponse({ type: OverviewOperationsTabDto })
|
||||||
|
getOperationsTab(): Promise<OverviewOperationsTabDto> {
|
||||||
|
return this.overviewService.getOperationsTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('customers')
|
||||||
|
@BookingView()
|
||||||
|
@ApiOperation({ summary: 'Customers tab metrics and charts' })
|
||||||
|
@ApiOkResponse({ type: OverviewCustomersTabDto })
|
||||||
|
getCustomersTab(@Query() query: OverviewQueryDto): Promise<OverviewCustomersTabDto> {
|
||||||
|
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<OverviewStaffTabDto> {
|
||||||
|
return this.overviewService.getStaffTab(query.range ?? '30d');
|
||||||
|
}
|
||||||
|
}
|
||||||
34
apps/edr-freight-api/src/modules/overview/overview.module.ts
Normal file
34
apps/edr-freight-api/src/modules/overview/overview.module.ts
Normal file
@@ -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 {}
|
||||||
553
apps/edr-freight-api/src/modules/overview/overview.repository.ts
Normal file
553
apps/edr-freight-api/src/modules/overview/overview.repository.ts
Normal file
@@ -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<Booking>,
|
||||||
|
@InjectRepository(PaymentEntity)
|
||||||
|
private readonly paymentRepository: Repository<PaymentEntity>,
|
||||||
|
@InjectRepository(Customer)
|
||||||
|
private readonly customerRepository: Repository<Customer>,
|
||||||
|
@InjectRepository(Train)
|
||||||
|
private readonly trainRepository: Repository<Train>,
|
||||||
|
@InjectRepository(Wagon)
|
||||||
|
private readonly wagonRepository: Repository<Wagon>,
|
||||||
|
@InjectRepository(Container)
|
||||||
|
private readonly containerRepository: Repository<Container>,
|
||||||
|
@InjectRepository(Cargo)
|
||||||
|
private readonly cargoRepository: Repository<Cargo>,
|
||||||
|
@InjectRepository(Employee)
|
||||||
|
private readonly employeeRepository: Repository<Employee>,
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepository: Repository<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
|
||||||
|
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<Record<string, string>>();
|
||||||
|
|
||||||
|
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<Record<string, string>>();
|
||||||
|
|
||||||
|
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<Record<string, string>>();
|
||||||
|
|
||||||
|
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<Record<string, number>> {
|
||||||
|
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<OverviewRecentBookingRow[]> {
|
||||||
|
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<ObjectLiteral>,
|
||||||
|
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 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
210
apps/edr-freight-api/src/modules/overview/overview.service.ts
Normal file
210
apps/edr-freight-api/src/modules/overview/overview.service.ts
Normal file
@@ -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<string, number>) {
|
||||||
|
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<OverviewResponseDto> {
|
||||||
|
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<OverviewBookingsTabDto> {
|
||||||
|
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<OverviewBillingTabDto> {
|
||||||
|
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<OverviewOperationsTabDto> {
|
||||||
|
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<OverviewCustomersTabDto> {
|
||||||
|
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<OverviewStaffTabDto> {
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
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 { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
||||||
import { ApprovalRulesService } from '../services/approval-rules.service';
|
import { ApprovalRulesService } from '../services/approval-rules.service';
|
||||||
|
|
||||||
@@ -35,6 +37,22 @@ export class ApprovalRulesController {
|
|||||||
return this.service.findChain(flag === 'true');
|
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')
|
@Get(':id')
|
||||||
@RuleEngineView('approval-rules')
|
@RuleEngineView('approval-rules')
|
||||||
@ApiOperation({ summary: 'Get an approval rule by ID' })
|
@ApiOperation({ summary: 'Get an approval rule by ID' })
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
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 { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
||||||
import { CargoTypesService } from '../services/cargo-types.service';
|
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')
|
@Get(':id')
|
||||||
@RuleEngineView('cargo-types')
|
@RuleEngineView('cargo-types')
|
||||||
@ApiOperation({ summary: 'Get a cargo type by ID' })
|
@ApiOperation({ summary: 'Get a cargo type by ID' })
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
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 { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
||||||
import { ContainerTypesService } from '../services/container-types.service';
|
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')
|
@Get(':id')
|
||||||
@RuleEngineView('container-types')
|
@RuleEngineView('container-types')
|
||||||
@ApiOperation({ summary: 'Get a container type by ID' })
|
@ApiOperation({ summary: 'Get a container type by ID' })
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
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 { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
|
||||||
import { ServiceTypesService } from '../services/service-types.service';
|
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')
|
@Get(':id')
|
||||||
@RuleEngineView('service-types')
|
@RuleEngineView('service-types')
|
||||||
@ApiOperation({ summary: 'Get a service type by ID' })
|
@ApiOperation({ summary: 'Get a service type by ID' })
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateYardDto } from '../dto/create-yard.dto';
|
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 { UpdateYardDto } from '../dto/update-yard.dto';
|
||||||
import { YardsService } from '../services/yards.service';
|
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')
|
@Get(':id')
|
||||||
@RuleEngineView('yards')
|
@RuleEngineView('yards')
|
||||||
@ApiOperation({ summary: 'Get a yard by ID' })
|
@ApiOperation({ summary: 'Get a yard by ID' })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
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;
|
const ROLES = ['LINE_STAFF', 'DIRECTOR', 'CEO'] as const;
|
||||||
|
|
||||||
@@ -8,10 +8,16 @@ export class CreateApprovalRuleDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
requiresDirectorApproval!: boolean;
|
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()
|
@IsInt()
|
||||||
@Min(1)
|
@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' })
|
@ApiProperty({ enum: ROLES, description: 'Role required to action this step' })
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -32,4 +32,9 @@ export class CreateCargoTypeDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
displayOrder?: number;
|
displayOrder?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Insert after this record ID' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
insertAfterId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
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 {
|
export class CreateContainerTypeDto {
|
||||||
@ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 })
|
@ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 })
|
||||||
@@ -40,4 +40,9 @@ export class CreateContainerTypeDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
displayOrder?: number;
|
displayOrder?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Insert after this record ID' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
insertAfterId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
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 {
|
export class CreateServiceTypeDto {
|
||||||
@ApiProperty({ description: 'Service type display name', maxLength: 255 })
|
@ApiProperty({ description: 'Service type display name', maxLength: 255 })
|
||||||
@@ -48,4 +48,9 @@ export class CreateServiceTypeDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
displayOrder?: number;
|
displayOrder?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Insert after this record ID' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
insertAfterId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
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 {
|
export class CreateYardDto {
|
||||||
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
|
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
|
||||||
@@ -22,4 +22,9 @@ export class CreateYardDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
displayOrder?: number;
|
displayOrder?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Insert after this record ID' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
insertAfterId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.re
|
|||||||
import { YardsRepository } from './repositories/yards.repository';
|
import { YardsRepository } from './repositories/yards.repository';
|
||||||
|
|
||||||
import { ApprovalRulesService } from './services/approval-rules.service';
|
import { ApprovalRulesService } from './services/approval-rules.service';
|
||||||
|
import { DisplayOrderService } from './services/display-order.service';
|
||||||
import { CargoTypesService } from './services/cargo-types.service';
|
import { CargoTypesService } from './services/cargo-types.service';
|
||||||
import { ContainerTypesService } from './services/container-types.service';
|
import { ContainerTypesService } from './services/container-types.service';
|
||||||
import { PriorityRulesService } from './services/priority-rules.service';
|
import { PriorityRulesService } from './services/priority-rules.service';
|
||||||
@@ -126,6 +127,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
ShippingLinesService,
|
ShippingLinesService,
|
||||||
RatesService,
|
RatesService,
|
||||||
ApprovalRulesService,
|
ApprovalRulesService,
|
||||||
|
DisplayOrderService,
|
||||||
RuleEngineService,
|
RuleEngineService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
|
|||||||
@@ -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<BookingRateSnapshot[]> {
|
async snapshotRates(
|
||||||
const liveRates = await this.ratesRepo.findLiveRates();
|
bookingId: string,
|
||||||
|
rates: Array<{
|
||||||
|
id: string;
|
||||||
|
rateType: string;
|
||||||
|
rateValue: number;
|
||||||
|
rateUnit: string;
|
||||||
|
currency: string;
|
||||||
|
}>,
|
||||||
|
): Promise<BookingRateSnapshot[]> {
|
||||||
const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot);
|
const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const seen = new Set<string>();
|
||||||
const snapshots: BookingRateSnapshot[] = [];
|
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({
|
const snapshot = snapshotRepo.create({
|
||||||
bookingId,
|
bookingId,
|
||||||
rateId: rate.id,
|
rateId: rate.id,
|
||||||
|
|||||||
@@ -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 { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
||||||
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
||||||
import { ApprovalRule } from '../entities/approval-rule.entity';
|
import { ApprovalRule } from '../entities/approval-rule.entity';
|
||||||
import {
|
import {
|
||||||
APPROVAL_RULES_REPOSITORY,
|
APPROVAL_RULES_REPOSITORY,
|
||||||
IApprovalRulesRepository,
|
IApprovalRulesRepository,
|
||||||
} from '../interfaces/approval-rules.repository.interface';
|
} from '../interfaces/approval-rules.repository.interface';
|
||||||
|
import { DisplayOrderService } from './display-order.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApprovalRulesService {
|
export class ApprovalRulesService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(APPROVAL_RULES_REPOSITORY)
|
@Inject(APPROVAL_RULES_REPOSITORY)
|
||||||
private readonly repository: IApprovalRulesRepository,
|
private readonly repository: IApprovalRulesRepository,
|
||||||
|
private readonly displayOrder: DisplayOrderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List approval rules. */
|
/** List approval rules. */
|
||||||
@@ -21,7 +24,7 @@ export class ApprovalRulesService {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}): Promise<{ data: ApprovalRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
}): Promise<{ data: ApprovalRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 20;
|
const pageSize = filter.pageSize ?? 10;
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (filter.requiresDirectorApproval !== undefined) {
|
if (filter.requiresDirectorApproval !== undefined) {
|
||||||
where.requiresDirectorApproval = filter.requiresDirectorApproval;
|
where.requiresDirectorApproval = filter.requiresDirectorApproval;
|
||||||
@@ -50,9 +53,20 @@ export class ApprovalRulesService {
|
|||||||
|
|
||||||
/** Create an approval rule step. */
|
/** Create an approval rule step. */
|
||||||
async create(dto: CreateApprovalRuleDto): Promise<ApprovalRule> {
|
async create(dto: CreateApprovalRuleDto): Promise<ApprovalRule> {
|
||||||
|
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({
|
return this.repository.create({
|
||||||
requiresDirectorApproval: dto.requiresDirectorApproval,
|
requiresDirectorApproval: dto.requiresDirectorApproval,
|
||||||
stepOrder: dto.stepOrder,
|
stepOrder,
|
||||||
requiredRole: dto.requiredRole,
|
requiredRole: dto.requiredRole,
|
||||||
actionLabel: dto.actionLabel,
|
actionLabel: dto.actionLabel,
|
||||||
blocksRole: dto.blocksRole,
|
blocksRole: dto.blocksRole,
|
||||||
@@ -72,4 +86,20 @@ export class ApprovalRulesService {
|
|||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.repository.softDelete(id);
|
await this.repository.softDelete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reorder(dto: ReorderItemsDto): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
const rule = await this.findById(id);
|
||||||
|
await this.displayOrder.moveOne(ApprovalRule, 'stepOrder', id, direction, {
|
||||||
|
requiresDirectorApproval: rule.requiresDirectorApproval,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,21 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestj
|
|||||||
import { ILike } from 'typeorm';
|
import { ILike } from 'typeorm';
|
||||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||||
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
||||||
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
||||||
import { CargoType } from '../entities/cargo-type.entity';
|
import { CargoType } from '../entities/cargo-type.entity';
|
||||||
import {
|
import {
|
||||||
CARGO_TYPES_REPOSITORY,
|
CARGO_TYPES_REPOSITORY,
|
||||||
ICargoTypesRepository,
|
ICargoTypesRepository,
|
||||||
} from '../interfaces/cargo-types.repository.interface';
|
} from '../interfaces/cargo-types.repository.interface';
|
||||||
|
import { DisplayOrderService } from './display-order.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CargoTypesService {
|
export class CargoTypesService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(CARGO_TYPES_REPOSITORY)
|
@Inject(CARGO_TYPES_REPOSITORY)
|
||||||
private readonly repository: ICargoTypesRepository,
|
private readonly repository: ICargoTypesRepository,
|
||||||
|
private readonly displayOrder: DisplayOrderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List cargo types with pagination and optional filtering. */
|
/** List cargo types with pagination and optional filtering. */
|
||||||
@@ -28,7 +31,7 @@ export class CargoTypesService {
|
|||||||
sortOrder?: 'ASC' | 'DESC';
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
}): Promise<{ data: CargoType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
}): Promise<{ data: CargoType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 20;
|
const pageSize = filter.pageSize ?? 10;
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
||||||
if (filter.requiresDirectorApproval !== undefined) where.requiresDirectorApproval = filter.requiresDirectorApproval;
|
if (filter.requiresDirectorApproval !== undefined) where.requiresDirectorApproval = filter.requiresDirectorApproval;
|
||||||
@@ -66,6 +69,12 @@ export class CargoTypesService {
|
|||||||
const parent = await this.repository.findById(dto.parentGroupId);
|
const parent = await this.repository.findById(dto.parentGroupId);
|
||||||
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
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({
|
return this.repository.create({
|
||||||
code,
|
code,
|
||||||
cargoTypeName: dto.cargoTypeName,
|
cargoTypeName: dto.cargoTypeName,
|
||||||
@@ -73,7 +82,7 @@ export class CargoTypesService {
|
|||||||
showFreeTextBox: dto.showFreeTextBox ?? false,
|
showFreeTextBox: dto.showFreeTextBox ?? false,
|
||||||
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
displayOrder: dto.displayOrder ?? 1,
|
displayOrder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,4 +104,13 @@ export class CargoTypesService {
|
|||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.repository.softDelete(id);
|
await this.repository.softDelete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reorder(dto: ReorderItemsDto): Promise<void> {
|
||||||
|
await this.displayOrder.reorderByIds(CargoType, 'displayOrder', dto.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
|
||||||
|
await this.findById(id);
|
||||||
|
await this.displayOrder.moveOne(CargoType, 'displayOrder', id, direction);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||||
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
||||||
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
||||||
import { ContainerType } from '../entities/container-type.entity';
|
import { ContainerType } from '../entities/container-type.entity';
|
||||||
import {
|
import {
|
||||||
CONTAINER_TYPES_REPOSITORY,
|
CONTAINER_TYPES_REPOSITORY,
|
||||||
IContainerTypesRepository,
|
IContainerTypesRepository,
|
||||||
} from '../interfaces/container-types.repository.interface';
|
} from '../interfaces/container-types.repository.interface';
|
||||||
|
import { DisplayOrderService } from './display-order.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ContainerTypesService {
|
export class ContainerTypesService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(CONTAINER_TYPES_REPOSITORY)
|
@Inject(CONTAINER_TYPES_REPOSITORY)
|
||||||
private readonly repository: IContainerTypesRepository,
|
private readonly repository: IContainerTypesRepository,
|
||||||
|
private readonly displayOrder: DisplayOrderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List container types with pagination. */
|
/** List container types with pagination. */
|
||||||
@@ -22,7 +25,7 @@ export class ContainerTypesService {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}): Promise<{ data: ContainerType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
}): Promise<{ data: ContainerType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 20;
|
const pageSize = filter.pageSize ?? 10;
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
||||||
|
|
||||||
@@ -47,6 +50,12 @@ export class ContainerTypesService {
|
|||||||
const code = generateCode(dto.label);
|
const code = generateCode(dto.label);
|
||||||
const existing = await this.repository.findByCode(code);
|
const existing = await this.repository.findByCode(code);
|
||||||
if (existing) throw new ConflictException(`Container type with label "${dto.label}" conflicts with existing code "${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({
|
return this.repository.create({
|
||||||
code,
|
code,
|
||||||
label: dto.label,
|
label: dto.label,
|
||||||
@@ -55,7 +64,7 @@ export class ContainerTypesService {
|
|||||||
isReefer: dto.isReefer ?? false,
|
isReefer: dto.isReefer ?? false,
|
||||||
isOpenTop: dto.isOpenTop ?? false,
|
isOpenTop: dto.isOpenTop ?? false,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
displayOrder: dto.displayOrder ?? 1,
|
displayOrder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,4 +81,13 @@ export class ContainerTypesService {
|
|||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.repository.softDelete(id);
|
await this.repository.softDelete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reorder(dto: ReorderItemsDto): Promise<void> {
|
||||||
|
await this.displayOrder.reorderByIds(ContainerType, 'displayOrder', dto.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
|
||||||
|
await this.findById(id);
|
||||||
|
await this.displayOrder.moveOne(ContainerType, 'displayOrder', id, direction);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<T extends ObjectLiteral>(
|
||||||
|
entity: EntityTarget<T>,
|
||||||
|
field: OrderField,
|
||||||
|
where?: FindOptionsWhere<T>,
|
||||||
|
): Promise<number> {
|
||||||
|
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<T extends ObjectLiteral>(
|
||||||
|
entity: EntityTarget<T>,
|
||||||
|
field: OrderField,
|
||||||
|
options: {
|
||||||
|
explicitOrder?: number;
|
||||||
|
insertAfterId?: string;
|
||||||
|
scopeWhere?: FindOptionsWhere<T>;
|
||||||
|
},
|
||||||
|
): Promise<number> {
|
||||||
|
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<T>,
|
||||||
|
});
|
||||||
|
if (!after) {
|
||||||
|
throw new NotFoundException(`Record ${insertAfterId} not found in scope`);
|
||||||
|
}
|
||||||
|
const afterOrder = Number((after as Record<string, unknown>)[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<T extends ObjectLiteral>(
|
||||||
|
entity: EntityTarget<T>,
|
||||||
|
field: OrderField,
|
||||||
|
ids: string[],
|
||||||
|
scopeWhere?: FindOptionsWhere<T>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<T extends ObjectLiteral>(
|
||||||
|
entity: EntityTarget<T>,
|
||||||
|
field: OrderField,
|
||||||
|
id: string,
|
||||||
|
direction: 'up' | 'down',
|
||||||
|
scopeWhere?: FindOptionsWhere<T>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, unknown>;
|
||||||
|
const neighbor = items[targetIndex] as Record<string, unknown>;
|
||||||
|
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<T extends ObjectLiteral>(
|
||||||
|
entity: EntityTarget<T>,
|
||||||
|
field: OrderField,
|
||||||
|
fromOrder: number,
|
||||||
|
delta: number,
|
||||||
|
scopeWhere?: FindOptionsWhere<T>,
|
||||||
|
): Promise<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ export class RatesService {
|
|||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: 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. */
|
/** Return all currently LIVE rates. */
|
||||||
|
|||||||
@@ -2,18 +2,21 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestj
|
|||||||
import { ILike } from 'typeorm';
|
import { ILike } from 'typeorm';
|
||||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||||
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
||||||
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
|
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
|
||||||
import { ServiceType } from '../entities/service-type.entity';
|
import { ServiceType } from '../entities/service-type.entity';
|
||||||
import {
|
import {
|
||||||
IServiceTypesRepository,
|
IServiceTypesRepository,
|
||||||
SERVICE_TYPES_REPOSITORY,
|
SERVICE_TYPES_REPOSITORY,
|
||||||
} from '../interfaces/service-types.repository.interface';
|
} from '../interfaces/service-types.repository.interface';
|
||||||
|
import { DisplayOrderService } from './display-order.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ServiceTypesService {
|
export class ServiceTypesService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(SERVICE_TYPES_REPOSITORY)
|
@Inject(SERVICE_TYPES_REPOSITORY)
|
||||||
private readonly repository: IServiceTypesRepository,
|
private readonly repository: IServiceTypesRepository,
|
||||||
|
private readonly displayOrder: DisplayOrderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List service types with pagination and optional filtering. */
|
/** List service types with pagination and optional filtering. */
|
||||||
@@ -27,7 +30,7 @@ export class ServiceTypesService {
|
|||||||
sortOrder?: 'ASC' | 'DESC';
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
}): Promise<{ data: ServiceType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
}): Promise<{ data: ServiceType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 20;
|
const pageSize = filter.pageSize ?? 10;
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
||||||
if (filter.canBeBookedAlone !== undefined) where.canBeBookedAlone = filter.canBeBookedAlone;
|
if (filter.canBeBookedAlone !== undefined) where.canBeBookedAlone = filter.canBeBookedAlone;
|
||||||
@@ -59,6 +62,12 @@ export class ServiceTypesService {
|
|||||||
const code = generateCode(dto.serviceName);
|
const code = generateCode(dto.serviceName);
|
||||||
const existing = await this.repository.findByCode(code);
|
const existing = await this.repository.findByCode(code);
|
||||||
if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${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({
|
return this.repository.create({
|
||||||
code,
|
code,
|
||||||
serviceName: dto.serviceName,
|
serviceName: dto.serviceName,
|
||||||
@@ -69,7 +78,7 @@ export class ServiceTypesService {
|
|||||||
includesCustoms: dto.includesCustoms ?? false,
|
includesCustoms: dto.includesCustoms ?? false,
|
||||||
priorityBonusPoints: dto.priorityBonusPoints ?? 0,
|
priorityBonusPoints: dto.priorityBonusPoints ?? 0,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
displayOrder: dto.displayOrder ?? 1,
|
displayOrder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,4 +96,13 @@ export class ServiceTypesService {
|
|||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.repository.softDelete(id);
|
await this.repository.softDelete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reorder(dto: ReorderItemsDto): Promise<void> {
|
||||||
|
await this.displayOrder.reorderByIds(ServiceType, 'displayOrder', dto.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
|
||||||
|
await this.findById(id);
|
||||||
|
await this.displayOrder.moveOne(ServiceType, 'displayOrder', id, direction);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||||
import { CreateYardDto } from '../dto/create-yard.dto';
|
import { CreateYardDto } from '../dto/create-yard.dto';
|
||||||
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateYardDto } from '../dto/update-yard.dto';
|
import { UpdateYardDto } from '../dto/update-yard.dto';
|
||||||
import { Yard } from '../entities/yard.entity';
|
import { Yard } from '../entities/yard.entity';
|
||||||
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
||||||
|
import { DisplayOrderService } from './display-order.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class YardsService {
|
export class YardsService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(YARDS_REPOSITORY)
|
@Inject(YARDS_REPOSITORY)
|
||||||
private readonly repository: IYardsRepository,
|
private readonly repository: IYardsRepository,
|
||||||
|
private readonly displayOrder: DisplayOrderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List yards with pagination. */
|
/** List yards with pagination. */
|
||||||
@@ -20,7 +23,7 @@ export class YardsService {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}): Promise<{ data: Yard[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
}): Promise<{ data: Yard[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 20;
|
const pageSize = filter.pageSize ?? 10;
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
||||||
if (filter.country) where.country = filter.country;
|
if (filter.country) where.country = filter.country;
|
||||||
@@ -46,12 +49,18 @@ export class YardsService {
|
|||||||
const code = generateCode(dto.label);
|
const code = generateCode(dto.label);
|
||||||
const existing = await this.repository.findByCode(code);
|
const existing = await this.repository.findByCode(code);
|
||||||
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${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({
|
return this.repository.create({
|
||||||
code,
|
code,
|
||||||
label: dto.label,
|
label: dto.label,
|
||||||
country: dto.country,
|
country: dto.country,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
displayOrder: dto.displayOrder ?? 1,
|
displayOrder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,4 +77,13 @@ export class YardsService {
|
|||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.repository.softDelete(id);
|
await this.repository.softDelete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reorder(dto: ReorderItemsDto): Promise<void> {
|
||||||
|
await this.displayOrder.reorderByIds(Yard, 'displayOrder', dto.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
|
||||||
|
await this.findById(id);
|
||||||
|
await this.displayOrder.moveOne(Yard, 'displayOrder', id, direction);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
|
||||||
|
<Stack gap="md" h="100%">
|
||||||
|
<Text fw={600}>Booking trend</Text>
|
||||||
|
{!hasData ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
No bookings in this period
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bookingTrendFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={overviewChartColors.primary} stopOpacity={0.35} />
|
||||||
|
<stop offset="95%" stopColor={overviewChartColors.primary} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={formatDateLabel}
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
stroke="#94a3b8"
|
||||||
|
/>
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||||
|
<Tooltip
|
||||||
|
labelFormatter={(value) => formatDateLabel(String(value))}
|
||||||
|
formatter={(value) => [value, "Bookings"]}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="count"
|
||||||
|
stroke={overviewChartColors.primary}
|
||||||
|
fill="url(#bookingTrendFill)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 300 }}>
|
||||||
|
<Stack gap="md" h="100%">
|
||||||
|
<Text fw={600}>{title}</Text>
|
||||||
|
{!hasData ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
{emptyMessage}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={filtered}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={55}
|
||||||
|
outerRadius={90}
|
||||||
|
paddingAngle={2}
|
||||||
|
>
|
||||||
|
{filtered.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.name}
|
||||||
|
fill={
|
||||||
|
overviewChartColors.pipeline[
|
||||||
|
index % overviewChartColors.pipeline.length
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip formatter={(value) => [value, "Count"]} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 300 }}>
|
||||||
|
<Stack gap="md" h="100%">
|
||||||
|
<Text fw={600}>{title}</Text>
|
||||||
|
{!hasData ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
{emptyMessage}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={Math.max(240, chartData.length * 36)}>
|
||||||
|
<BarChart
|
||||||
|
data={chartData}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 4, right: 16, left: 8, bottom: 4 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" horizontal={false} />
|
||||||
|
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 12 }} />
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
width={120}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
stroke="#94a3b8"
|
||||||
|
/>
|
||||||
|
<Tooltip formatter={(value) => [value, valueLabel]} />
|
||||||
|
<Bar dataKey="value" radius={[0, 6, 6, 0]} barSize={18}>
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.name}
|
||||||
|
fill={
|
||||||
|
overviewChartColors.pipeline[
|
||||||
|
index % overviewChartColors.pipeline.length
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Card
|
||||||
|
p="lg"
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
background: "white",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
minWidth: "240px",
|
||||||
|
width: "240px",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start">
|
||||||
|
<Stack gap="xs" style={{ flex: 1 }}>
|
||||||
|
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
<Text size="28px" fw={700} style={{ lineHeight: 1, letterSpacing: "-0.02em" }}>
|
||||||
|
{item.value}
|
||||||
|
</Text>
|
||||||
|
{item.hint && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{item.hint}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "40px",
|
||||||
|
height: "40px",
|
||||||
|
borderRadius: "10px",
|
||||||
|
background: accentStyle.bg,
|
||||||
|
color: accentStyle.color,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={20} strokeWidth={1.75} />
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Stack gap="md">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<Paper
|
||||||
|
key={section.title}
|
||||||
|
p="md"
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
background: "white",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
overflowX: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={600} mb="sm" c="dimmed">
|
||||||
|
{section.title}
|
||||||
|
</Text>
|
||||||
|
<Group gap="md" style={{ flexWrap: "nowrap", minWidth: "min-content" }}>
|
||||||
|
{section.items.map((item) => (
|
||||||
|
<OverviewKpiCard key={item.label} item={item} />
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Paper
|
||||||
|
p="md"
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(180deg, #f0fdf4 0%, #ffffff 100%)",
|
||||||
|
border: "1px solid var(--freight-brand-border, #bbf7d0)",
|
||||||
|
overflowX: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<Text size="sm" fw={600} mb="sm" c="dimmed">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Group gap="md" style={{ flexWrap: "nowrap", minWidth: "min-content" }}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<OverviewKpiCard key={item.label} item={item} />
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Title order={2} style={{ letterSpacing: "-0.02em" }}>
|
||||||
|
Operations overview
|
||||||
|
</Title>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Updated {formatRelativeTime(generatedAt)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Group gap="sm">
|
||||||
|
<SegmentedControl
|
||||||
|
value={range}
|
||||||
|
onChange={(value) => onRangeChange(value as OverviewRange)}
|
||||||
|
data={RANGE_OPTIONS}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
size="lg"
|
||||||
|
aria-label="Refresh dashboard"
|
||||||
|
onClick={onRefresh}
|
||||||
|
loading={isRefreshing}
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
|
||||||
|
<Stack gap="md" h="100%">
|
||||||
|
<Text fw={600}>Payment trend</Text>
|
||||||
|
{!hasData ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
No successful payments in this period
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={formatDateLabel}
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
stroke="#94a3b8"
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||||
|
<Tooltip
|
||||||
|
labelFormatter={(value) => formatDateLabel(String(value))}
|
||||||
|
formatter={(value, name) => [
|
||||||
|
formatAmount(Number(value), name === "amountUsd" ? "USD" : "ETB"),
|
||||||
|
name === "amountUsd" ? "USD" : "ETB",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
<Bar
|
||||||
|
dataKey="amountEtb"
|
||||||
|
name="ETB"
|
||||||
|
stackId="payments"
|
||||||
|
fill={overviewChartColors.etb}
|
||||||
|
radius={[0, 0, 0, 0]}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="amountUsd"
|
||||||
|
name="USD"
|
||||||
|
stackId="payments"
|
||||||
|
fill={overviewChartColors.usd}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Stack gap="md" h="100%">
|
||||||
|
<Text fw={600}>Quick links</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||||
|
{links.map((link) => {
|
||||||
|
const Icon = link.icon;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={link.href}
|
||||||
|
p="md"
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(link.href)}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
|
<Group align="flex-start" gap="sm" wrap="nowrap">
|
||||||
|
<ThemeIcon variant="light" color="green" size="lg" radius="md">
|
||||||
|
<Icon size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{link.title}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{link.description}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<ArrowRight size={16} color="var(--mantine-color-gray-5)" />
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Paper p="lg" radius="lg" withBorder>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text fw={600}>Recent bookings</Text>
|
||||||
|
{bookings.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||||
|
No recent bookings
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Table highlightOnHover verticalSpacing="sm">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Reference</Table.Th>
|
||||||
|
<Table.Th>Customer</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>Priority</Table.Th>
|
||||||
|
<Table.Th>Amount</Table.Th>
|
||||||
|
<Table.Th>Created</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{bookings.map((booking) => (
|
||||||
|
<Table.Tr
|
||||||
|
key={booking.id}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/dashboard/booking-requests/${booking.id}`)}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{booking.reference}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{booking.customerLabel}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<BookingStatusBadge status={booking.status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<BookingPriorityBadge score={booking.priorityScore} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{formatAmount(booking.totalAmount, booking.paymentCurrency)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{new Date(booking.createdAt).toLocaleDateString()}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
|
||||||
|
<Stack gap="md" h="100%">
|
||||||
|
<Text fw={600}>Pipeline by stage</Text>
|
||||||
|
{!hasData ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
No bookings in pipeline
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 24 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
interval={0}
|
||||||
|
angle={-20}
|
||||||
|
textAnchor="end"
|
||||||
|
height={60}
|
||||||
|
stroke="#94a3b8"
|
||||||
|
/>
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||||
|
<Tooltip formatter={(value) => [value, "Bookings"]} />
|
||||||
|
<Bar dataKey="count" radius={[6, 6, 0, 0]}>
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.stage}
|
||||||
|
fill={
|
||||||
|
overviewChartColors.pipeline[
|
||||||
|
index % overviewChartColors.pipeline.length
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Skeleton height={120} radius="lg" />
|
||||||
|
<Skeleton height={320} radius="lg" />
|
||||||
|
<Skeleton height={320} radius="lg" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <TabSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !query.data) {
|
||||||
|
return (
|
||||||
|
<Paper p="xl" radius="lg" withBorder>
|
||||||
|
<Alert
|
||||||
|
icon={<AlertCircle size={16} />}
|
||||||
|
color="red"
|
||||||
|
title="Failed to load tab data"
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
<Stack gap="sm" align="flex-start">
|
||||||
|
<span>Could not load {tab} metrics. Please try again.</span>
|
||||||
|
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md" pos="relative">
|
||||||
|
{isFetching && (
|
||||||
|
<Center style={{ position: "absolute", top: 8, right: 8, zIndex: 2 }}>
|
||||||
|
<Loader size="sm" color="green" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "bookings" && bookings.data && (
|
||||||
|
<OverviewBookingsTabPanel data={bookings.data} />
|
||||||
|
)}
|
||||||
|
{tab === "billing" && billing.data && (
|
||||||
|
<OverviewBillingTabPanel data={billing.data} />
|
||||||
|
)}
|
||||||
|
{tab === "operations" && operations.data && (
|
||||||
|
<OverviewOperationsTabPanel data={operations.data} />
|
||||||
|
)}
|
||||||
|
{tab === "customers" && customers.data && (
|
||||||
|
<OverviewCustomersTabPanel data={customers.data} />
|
||||||
|
)}
|
||||||
|
{tab === "staff" && staff.data && (
|
||||||
|
<OverviewStaffTabPanel data={staff.data} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<OverviewKpiStrip
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "Revenue MTD (ETB)",
|
||||||
|
value: formatCurrency(data.kpis.revenueMtdEtb, "ETB"),
|
||||||
|
icon: Banknote,
|
||||||
|
accent: "emerald",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Revenue MTD (USD)",
|
||||||
|
value: formatCurrency(data.kpis.revenueMtdUsd, "USD"),
|
||||||
|
icon: Wallet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Pending payments",
|
||||||
|
value: data.kpis.pendingPayments,
|
||||||
|
icon: CreditCard,
|
||||||
|
accent: "amber",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Successful MTD",
|
||||||
|
value: data.kpis.successfulPaymentsMtd,
|
||||||
|
icon: Banknote,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Grid gap="md">
|
||||||
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
|
<OverviewPaymentChart data={data.paymentTrend} />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
|
<OverviewDonutChart
|
||||||
|
title="Revenue by currency (MTD)"
|
||||||
|
data={data.revenueByCurrency.map((item) => ({
|
||||||
|
name: item.currency,
|
||||||
|
value: item.amount,
|
||||||
|
}))}
|
||||||
|
emptyMessage="No revenue this month"
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid gap="md">
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<OverviewDonutChart
|
||||||
|
title="Payments by status"
|
||||||
|
data={data.paymentsByStatus.map((item) => ({
|
||||||
|
name: item.status.replace(/-/g, " "),
|
||||||
|
value: item.count,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 300 }}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text fw={600}>Payments by method</Text>
|
||||||
|
{methodChartData.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
No payment methods recorded
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart data={methodChartData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||||
|
<XAxis dataKey="name" tick={{ fontSize: 11 }} stroke="#94a3b8" />
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="count" name="Transactions" radius={[6, 6, 0, 0]}>
|
||||||
|
{methodChartData.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.name}
|
||||||
|
fill={
|
||||||
|
overviewChartColors.pipeline[
|
||||||
|
index % overviewChartColors.pipeline.length
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<OverviewKpiStrip
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "Active bookings",
|
||||||
|
value: data.kpis.totalActive,
|
||||||
|
icon: FileText,
|
||||||
|
accent: "emerald",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Needs action",
|
||||||
|
value: data.kpis.needsAction,
|
||||||
|
icon: AlertCircle,
|
||||||
|
accent: "amber",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Urgent",
|
||||||
|
value: data.kpis.urgent,
|
||||||
|
icon: Clock,
|
||||||
|
accent: "rose",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "In approval",
|
||||||
|
value: data.kpis.inApproval,
|
||||||
|
icon: UserCheck,
|
||||||
|
accent: "sky",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Submitted today",
|
||||||
|
value: data.kpis.submittedToday,
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Grid gap="md">
|
||||||
|
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||||
|
<OverviewBookingTrendChart data={data.bookingTrend} />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||||
|
<OverviewStatusChart data={data.bookingsByPipeline} />
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid gap="md">
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<OverviewDonutChart
|
||||||
|
title="By status"
|
||||||
|
data={data.bookingsByStatus.map((item) => ({
|
||||||
|
name: BOOKING_STATUS_META[item.status]?.title ?? item.status,
|
||||||
|
value: item.count,
|
||||||
|
}))}
|
||||||
|
emptyMessage="No bookings yet"
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<OverviewDonutChart
|
||||||
|
title="By freight type"
|
||||||
|
data={data.bookingsByFreightType.map((item) => ({
|
||||||
|
name: item.label,
|
||||||
|
value: item.count,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<OverviewHorizontalBarChart
|
||||||
|
title="By payment currency"
|
||||||
|
data={data.bookingsByCurrency.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
value: item.count,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OverviewRecentBookingsTable bookings={data.recentBookings} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<OverviewKpiStrip
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "Total customers",
|
||||||
|
value: data.kpis.totalCustomers,
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "New this month",
|
||||||
|
value: data.kpis.newCustomersThisMonth,
|
||||||
|
icon: Users,
|
||||||
|
accent: "emerald",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Grid gap="md">
|
||||||
|
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||||
|
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text fw={600}>Customer growth</Text>
|
||||||
|
{!hasGrowth ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
No new customers in this period
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<AreaChart data={data.customerGrowthTrend}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="customerGrowthFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor={overviewChartColors.primary}
|
||||||
|
stopOpacity={0.35}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor={overviewChartColors.primary}
|
||||||
|
stopOpacity={0}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={formatDateLabel}
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
labelFormatter={(value) => formatDateLabel(String(value))}
|
||||||
|
formatter={(value) => [value, "New customers"]}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="count"
|
||||||
|
stroke={overviewChartColors.primary}
|
||||||
|
fill="url(#customerGrowthFill)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||||
|
<OverviewDonutChart
|
||||||
|
title="Customers by type"
|
||||||
|
data={data.customersByType.map((item) => ({
|
||||||
|
name: item.label,
|
||||||
|
value: item.count,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<OverviewHorizontalBarChart
|
||||||
|
title="Top customers by bookings"
|
||||||
|
data={data.topCustomersByBookings.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
value: item.count,
|
||||||
|
}))}
|
||||||
|
valueLabel="Bookings"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<OverviewKpiStrip
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "Active trains",
|
||||||
|
value: data.kpis.trainsActive,
|
||||||
|
icon: Train,
|
||||||
|
accent: "emerald",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Wagons available",
|
||||||
|
value: data.kpis.wagonsAvailable,
|
||||||
|
icon: Truck,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Containers in transit",
|
||||||
|
value: data.kpis.containersInTransit,
|
||||||
|
icon: ContainerIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Cargoes loaded",
|
||||||
|
value: data.kpis.cargoesLoaded,
|
||||||
|
icon: Box,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Grid gap="md">
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<OverviewDonutChart
|
||||||
|
title="Train status"
|
||||||
|
data={data.trainStatusBreakdown.map((item) => ({
|
||||||
|
name: formatStatusLabel(item.status),
|
||||||
|
value: item.count,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<OverviewDonutChart
|
||||||
|
title="Wagon status"
|
||||||
|
data={data.wagonStatusBreakdown.map((item) => ({
|
||||||
|
name: formatStatusLabel(item.status),
|
||||||
|
value: item.count,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<OverviewDonutChart
|
||||||
|
title="Container status"
|
||||||
|
data={data.containerStatusBreakdown.map((item) => ({
|
||||||
|
name: formatStatusLabel(item.status),
|
||||||
|
value: item.count,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<OverviewDonutChart
|
||||||
|
title="Cargo status"
|
||||||
|
data={data.cargoStatusBreakdown.map((item) => ({
|
||||||
|
name: formatStatusLabel(item.status),
|
||||||
|
value: item.count,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<OverviewKpiStrip
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "Active employees",
|
||||||
|
value: data.kpis.activeEmployees,
|
||||||
|
icon: UserCheck,
|
||||||
|
accent: "emerald",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Active users",
|
||||||
|
value: data.kpis.activeUsers,
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Grid gap="md">
|
||||||
|
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||||
|
<Paper p="lg" radius="lg" withBorder h="100%" style={{ minHeight: 320 }}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text fw={600}>Employee onboarding trend</Text>
|
||||||
|
{!hasGrowth ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
No new employees in this period
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<AreaChart data={data.employeeGrowthTrend}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="employeeGrowthFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor={overviewChartColors.primaryDark}
|
||||||
|
stopOpacity={0.35}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor={overviewChartColors.primaryDark}
|
||||||
|
stopOpacity={0}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={formatDateLabel}
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
labelFormatter={(value) => formatDateLabel(String(value))}
|
||||||
|
formatter={(value) => [value, "New employees"]}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="count"
|
||||||
|
stroke={overviewChartColors.primaryDark}
|
||||||
|
fill="url(#employeeGrowthFill)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||||
|
<OverviewDonutChart
|
||||||
|
title="Active vs inactive users"
|
||||||
|
data={data.activeUsersBreakdown.map((item) => ({
|
||||||
|
name: item.label,
|
||||||
|
value: item.count,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<OverviewDonutChart
|
||||||
|
title="Users by account status"
|
||||||
|
data={data.usersByStatus.map((item) => ({
|
||||||
|
name: item.status.replace(/_/g, " "),
|
||||||
|
value: item.count,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}) => (
|
||||||
|
<PortalAwareRow snapshot={snapshot}>
|
||||||
|
<Group
|
||||||
|
ref={dragProvided.innerRef}
|
||||||
|
{...dragProvided.draggableProps}
|
||||||
|
{...dragProvided.dragHandleProps}
|
||||||
|
gap="sm"
|
||||||
|
wrap="nowrap"
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
...dragProvided.draggableProps.style,
|
||||||
|
border: "1px solid var(--mantine-color-gray-3)",
|
||||||
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
|
background: snapshot.isDragging ? "var(--mantine-color-gray-0)" : "white",
|
||||||
|
boxShadow: snapshot.isDragging ? "0 8px 24px rgba(0, 0, 0, 0.12)" : undefined,
|
||||||
|
cursor: snapshot.isDragging ? "grabbing" : "grab",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box c="dimmed" style={{ display: "flex", alignItems: "center" }}>
|
||||||
|
<GripVertical size={18} />
|
||||||
|
</Box>
|
||||||
|
<Badge variant="light" color="gray" size="sm">
|
||||||
|
{index + 1}
|
||||||
|
</Badge>
|
||||||
|
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={500} truncate>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
{item.code ? (
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{item.code}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</PortalAwareRow>
|
||||||
|
);
|
||||||
|
|
||||||
|
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<OrderDraftItem[]>([]);
|
||||||
|
const [directorItems, setDirectorItems] = useState<OrderDraftItem[]>([]);
|
||||||
|
|
||||||
|
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[]) => (
|
||||||
|
<Droppable droppableId={droppableId}>
|
||||||
|
{(provided) => (
|
||||||
|
<Stack
|
||||||
|
gap="xs"
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
style={{ minHeight: 120 }}
|
||||||
|
>
|
||||||
|
{listItems.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
No items to reorder.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
listItems.map((item, index) => (
|
||||||
|
<Draggable
|
||||||
|
key={item.id}
|
||||||
|
draggableId={item.id}
|
||||||
|
index={index}
|
||||||
|
isDragDisabled={Boolean(filter.trim())}
|
||||||
|
>
|
||||||
|
{(dragProvided, snapshot) => (
|
||||||
|
<OrderRow
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
dragProvided={dragProvided}
|
||||||
|
snapshot={snapshot}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{provided.placeholder}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={open}
|
||||||
|
onClose={() => 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",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Drag items anywhere in the list to set display order. Changes apply when you save.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader2 size={28} style={{ animation: "spin 1s linear infinite" }} />
|
||||||
|
</Group>
|
||||||
|
) : isScoped ? (
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onChange={(value) => setTab((value as "standard" | "director") ?? "standard")}
|
||||||
|
>
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Tab value="standard">Standard chain ({standardItems.length})</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="director">Director chain ({directorItems.length})</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
<Tabs.Panel value="standard" pt="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Filter items…"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Box style={{ maxHeight: "50vh", overflowY: "auto", paddingRight: 4 }}>
|
||||||
|
{renderList(filteredItems)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Tabs.Panel>
|
||||||
|
<Tabs.Panel value="director" pt="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Filter items…"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Box style={{ maxHeight: "50vh", overflowY: "auto", paddingRight: 4 }}>
|
||||||
|
{renderList(filteredItems)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Filter items…"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Box style={{ maxHeight: "50vh", overflowY: "auto", paddingRight: 4 }}>
|
||||||
|
{renderList(filteredItems)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filter.trim() ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Clear the filter to drag and reorder items.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
leftSection={
|
||||||
|
isSaving ? (
|
||||||
|
<Loader2 size={16} style={{ animation: "spin 1s linear infinite" }} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving…" : "Save order"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</DragDropContext>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ManageRuleEngineOrderDialog;
|
||||||
@@ -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 { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
|
||||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||||
|
|
||||||
|
import RuleEngineListFooter from "./RuleEngineListFooter";
|
||||||
import RuleEngineRecordActions from "./RuleEngineRecordActions";
|
import RuleEngineRecordActions from "./RuleEngineRecordActions";
|
||||||
import { cardInitials, resolveCardPresentation } from "./ruleEngineCardMeta";
|
import { cardInitials, resolveCardPresentation } from "./ruleEngineCardMeta";
|
||||||
import { formatCell } from "./ruleEngineFormat";
|
import { formatCell } from "./ruleEngineFormat";
|
||||||
@@ -13,12 +15,10 @@ export interface RuleEngineCardGridProps {
|
|||||||
status: "loading" | "error" | "success";
|
status: "loading" | "error" | "success";
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
itemLabel: string;
|
itemLabel: string;
|
||||||
pagination: {
|
pagination: PaginationState;
|
||||||
pageIndex: number;
|
pageCount: number;
|
||||||
pageSize: number;
|
totalCount: number;
|
||||||
pageCount: number;
|
onPaginationChange: OnChangeFn<PaginationState>;
|
||||||
totalCount: number;
|
|
||||||
};
|
|
||||||
onEdit?: (record: RuleEngineRecord) => void;
|
onEdit?: (record: RuleEngineRecord) => void;
|
||||||
onDelete?: (record: RuleEngineRecord) => void;
|
onDelete?: (record: RuleEngineRecord) => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@@ -71,6 +71,9 @@ const RuleEngineCardGrid = ({
|
|||||||
emptyMessage,
|
emptyMessage,
|
||||||
itemLabel,
|
itemLabel,
|
||||||
pagination,
|
pagination,
|
||||||
|
pageCount,
|
||||||
|
totalCount,
|
||||||
|
onPaginationChange,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onViewChain,
|
onViewChain,
|
||||||
@@ -249,19 +252,13 @@ const RuleEngineCardGrid = ({
|
|||||||
})}
|
})}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
{pagination.pageCount > 1 && (
|
<RuleEngineListFooter
|
||||||
<Group justify="space-between" align="center" p="md" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
|
pagination={pagination}
|
||||||
<Text size="sm" c="dimmed">
|
pageCount={pageCount}
|
||||||
Showing {Math.min(rows.length, pagination.pageSize)} of {pagination.totalCount} {itemLabel}
|
totalCount={totalCount}
|
||||||
</Text>
|
itemLabel={itemLabel}
|
||||||
<Pagination
|
onPaginationChange={onPaginationChange}
|
||||||
value={pagination.pageIndex + 1}
|
/>
|
||||||
total={pagination.pageCount}
|
|
||||||
size="sm"
|
|
||||||
radius="md"
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
type FormFieldDef,
|
type FormFieldDef,
|
||||||
} from "@/pages/ruleEngine/config/resources";
|
} from "@/pages/ruleEngine/config/resources";
|
||||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||||
|
import { RULE_ENGINE_POSITION_END } from "./ruleEngineOrder.utils";
|
||||||
|
|
||||||
export interface RuleEngineFormDialogProps {
|
export interface RuleEngineFormDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -30,6 +31,8 @@ export interface RuleEngineFormDialogProps {
|
|||||||
initialRecord?: RuleEngineRecord | null;
|
initialRecord?: RuleEngineRecord | null;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
selectOptionsLoading?: boolean;
|
selectOptionsLoading?: boolean;
|
||||||
|
positionOptions?: { label: string; value: string }[];
|
||||||
|
positionLoading?: boolean;
|
||||||
onSubmit: (values: Record<string, unknown>) => void;
|
onSubmit: (values: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,15 +149,19 @@ const RuleEngineFormDialog = ({
|
|||||||
initialRecord,
|
initialRecord,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
selectOptionsLoading = false,
|
selectOptionsLoading = false,
|
||||||
|
positionOptions,
|
||||||
|
positionLoading = false,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: RuleEngineFormDialogProps) => {
|
}: RuleEngineFormDialogProps) => {
|
||||||
const [values, setValues] = useState<Record<string, unknown>>(() =>
|
const [values, setValues] = useState<Record<string, unknown>>(() =>
|
||||||
buildInitialValues(fields, initialRecord),
|
buildInitialValues(fields, initialRecord),
|
||||||
);
|
);
|
||||||
|
const [position, setPosition] = useState(RULE_ENGINE_POSITION_END);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setValues(buildInitialValues(fields, initialRecord));
|
setValues(buildInitialValues(fields, initialRecord));
|
||||||
|
setPosition(RULE_ENGINE_POSITION_END);
|
||||||
}
|
}
|
||||||
}, [open, fields, initialRecord]);
|
}, [open, fields, initialRecord]);
|
||||||
|
|
||||||
@@ -192,6 +199,10 @@ const RuleEngineFormDialog = ({
|
|||||||
payload.code = String(payload.code).toUpperCase();
|
payload.code = String(payload.code).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) {
|
||||||
|
payload.insertAfterId = position;
|
||||||
|
}
|
||||||
|
|
||||||
onSubmit(payload);
|
onSubmit(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -315,6 +326,23 @@ const RuleEngineFormDialog = ({
|
|||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Box style={{ maxHeight: "calc(65vh - 120px)", overflowY: "auto", paddingRight: 4 }}>
|
<Box style={{ maxHeight: "calc(65vh - 120px)", overflowY: "auto", paddingRight: 4 }}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
{!initialRecord && positionOptions ? (
|
||||||
|
<Select
|
||||||
|
label="Position"
|
||||||
|
description="New items are appended to the end by default."
|
||||||
|
value={position}
|
||||||
|
onChange={(value) => setPosition(value ?? RULE_ENGINE_POSITION_END)}
|
||||||
|
data={[
|
||||||
|
{ label: "At end (default)", value: RULE_ENGINE_POSITION_END },
|
||||||
|
...positionOptions,
|
||||||
|
]}
|
||||||
|
searchable
|
||||||
|
disabled={positionLoading}
|
||||||
|
size="md"
|
||||||
|
radius="md"
|
||||||
|
styles={inputStyles}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{formRows.map((row) =>
|
{formRows.map((row) =>
|
||||||
row.kind === "pair" ? (
|
row.kind === "pair" ? (
|
||||||
<SimpleGrid key={`${row.fields[0].name}-${row.fields[1].name}`} cols={2} spacing="md">
|
<SimpleGrid key={`${row.fields[0].name}-${row.fields[1].name}`} cols={2} spacing="md">
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { OnChangeFn, PaginationState } from "@tanstack/react-table";
|
||||||
|
import { Group, Pagination, Select, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
export interface RuleEngineListFooterProps {
|
||||||
|
pagination: PaginationState;
|
||||||
|
pageCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
itemLabel: string;
|
||||||
|
onPaginationChange: OnChangeFn<PaginationState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = ["5", "10", "25", "50"];
|
||||||
|
|
||||||
|
const RuleEngineListFooter = ({
|
||||||
|
pagination,
|
||||||
|
pageCount,
|
||||||
|
totalCount,
|
||||||
|
itemLabel,
|
||||||
|
onPaginationChange,
|
||||||
|
}: RuleEngineListFooterProps) => {
|
||||||
|
const { pageIndex, pageSize } = pagination;
|
||||||
|
const start = totalCount === 0 ? 0 : pageIndex * pageSize + 1;
|
||||||
|
const end = Math.min((pageIndex + 1) * pageSize, totalCount);
|
||||||
|
|
||||||
|
const setPageIndex = (nextIndex: number) => {
|
||||||
|
onPaginationChange({ pageIndex: nextIndex, pageSize });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPageSize = (nextSize: number) => {
|
||||||
|
onPaginationChange({ pageIndex: 0, pageSize: nextSize });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
wrap="wrap"
|
||||||
|
p="md"
|
||||||
|
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
|
||||||
|
>
|
||||||
|
<Group gap="md" align="center">
|
||||||
|
<Group gap="xs" align="center">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Rows per page
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
value={String(pageSize)}
|
||||||
|
onChange={(value) => value && setPageSize(Number(value))}
|
||||||
|
data={PAGE_SIZE_OPTIONS}
|
||||||
|
size="xs"
|
||||||
|
w={70}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Showing {start}–{end} of {totalCount} {itemLabel}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{pageCount > 1 && (
|
||||||
|
<Pagination
|
||||||
|
value={pageIndex + 1}
|
||||||
|
total={pageCount}
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
onChange={(page) => setPageIndex(page - 1)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RuleEngineListFooter;
|
||||||
@@ -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 (
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<Tooltip label="Move up">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled || !canMoveUp}
|
||||||
|
onClick={() => onMove(id, "up")}
|
||||||
|
aria-label="Move up"
|
||||||
|
>
|
||||||
|
<ChevronUp size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Move down">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled || !canMoveDown}
|
||||||
|
onClick={() => onMove(id, "down")}
|
||||||
|
aria-label="Move down"
|
||||||
|
>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RuleEngineOrderControls;
|
||||||
@@ -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 { Button, TextInput, Group, SegmentedControl } from "@mantine/core";
|
||||||
|
|
||||||
import type { RuleEngineViewMode } from "./useRuleEngineViewMode";
|
import type { RuleEngineViewMode } from "./useRuleEngineViewMode";
|
||||||
|
|
||||||
export interface RuleEngineToolbarProps {
|
export interface RuleEngineToolbarProps {
|
||||||
search: string;
|
search?: string;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange?: (value: string) => void;
|
||||||
searchPlaceholder: string;
|
searchPlaceholder?: string;
|
||||||
|
showSearch?: boolean;
|
||||||
onAdd?: () => void;
|
onAdd?: () => void;
|
||||||
addLabel?: string;
|
addLabel?: string;
|
||||||
|
onManageOrder?: () => void;
|
||||||
viewMode: RuleEngineViewMode;
|
viewMode: RuleEngineViewMode;
|
||||||
onViewModeChange: (mode: RuleEngineViewMode) => void;
|
onViewModeChange: (mode: RuleEngineViewMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RuleEngineToolbar = ({
|
const RuleEngineToolbar = ({
|
||||||
search,
|
search = "",
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
searchPlaceholder,
|
searchPlaceholder = "Search…",
|
||||||
|
showSearch = true,
|
||||||
onAdd,
|
onAdd,
|
||||||
addLabel = "Add",
|
addLabel = "Add",
|
||||||
|
onManageOrder,
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
}: RuleEngineToolbarProps) => (
|
}: RuleEngineToolbarProps) => (
|
||||||
<Group gap="md" justify="space-between" align="center" wrap="nowrap">
|
<Group gap="md" justify="space-between" align="center" wrap="nowrap">
|
||||||
<TextInput
|
{showSearch && onSearchChange ? (
|
||||||
placeholder={searchPlaceholder}
|
<TextInput
|
||||||
value={search}
|
placeholder={searchPlaceholder}
|
||||||
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
value={search}
|
||||||
leftSection={<Search size={18} />}
|
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
||||||
size="md"
|
leftSection={<Search size={18} />}
|
||||||
radius="lg"
|
size="md"
|
||||||
style={{ flex: 1, minWidth: 0 }}
|
radius="lg"
|
||||||
styles={{
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
input: {
|
styles={{
|
||||||
borderColor: "var(--mantine-color-gray-3)",
|
input: {
|
||||||
},
|
borderColor: "var(--mantine-color-gray-3)",
|
||||||
}}
|
},
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Group gap="md" align="center" justify="flex-end" wrap="nowrap">
|
<Group gap="md" align="center" justify="flex-end" wrap="nowrap">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
@@ -72,6 +80,21 @@ const RuleEngineToolbar = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{onManageOrder ? (
|
||||||
|
<Button
|
||||||
|
onClick={onManageOrder}
|
||||||
|
leftSection={<ListOrdered size={18} />}
|
||||||
|
size="sm"
|
||||||
|
radius="lg"
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
fw={600}
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
Manage order
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{onAdd ? (
|
{onAdd ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={onAdd}
|
onClick={onAdd}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { RuleEngineRecord, RuleEngineResourceSlug } from "@/types/rule-engine";
|
||||||
|
|
||||||
|
export const RULE_ENGINE_POSITION_END = "__end__";
|
||||||
|
|
||||||
|
export function getOrderItemLabel(
|
||||||
|
record: RuleEngineRecord,
|
||||||
|
slug: RuleEngineResourceSlug,
|
||||||
|
): string {
|
||||||
|
const code = String(record.code ?? "").trim();
|
||||||
|
switch (slug) {
|
||||||
|
case "cargo-types":
|
||||||
|
return String(record.cargoTypeName ?? (code || record.id));
|
||||||
|
case "container-types":
|
||||||
|
case "yards":
|
||||||
|
case "shipping-lines":
|
||||||
|
return String(record.label ?? (code || record.id));
|
||||||
|
case "service-types":
|
||||||
|
return String(record.serviceName ?? (code || record.id));
|
||||||
|
case "approval-rules":
|
||||||
|
return String(record.actionLabel ?? record.requiredRole ?? record.id);
|
||||||
|
default:
|
||||||
|
return String(record.label ?? record.code ?? record.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrderValue(
|
||||||
|
record: RuleEngineRecord,
|
||||||
|
field: "displayOrder" | "stepOrder",
|
||||||
|
): number {
|
||||||
|
const raw = record[field];
|
||||||
|
return typeof raw === "number" ? raw : Number(raw ?? 0);
|
||||||
|
}
|
||||||
@@ -59,5 +59,17 @@ export const QUERY_KEYS = {
|
|||||||
resource: RuleEngineResourceSlug | string,
|
resource: RuleEngineResourceSlug | string,
|
||||||
params?: Record<string, unknown>,
|
params?: Record<string, unknown>,
|
||||||
) => ["rule-engine", "select-options", resource, params ?? {}] as const,
|
) => ["rule-engine", "select-options", resource, params ?? {}] as const,
|
||||||
|
orderList: (resource: RuleEngineResourceSlug | string) =>
|
||||||
|
["rule-engine", "order-list", resource] as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
OVERVIEW: {
|
||||||
|
ROOT: ["overview"] as const,
|
||||||
|
dashboard: (range?: string) => ["overview", "dashboard", range ?? "30d"] as const,
|
||||||
|
bookingsTab: (range?: string) => ["overview", "bookings", range ?? "30d"] as const,
|
||||||
|
billingTab: (range?: string) => ["overview", "billing", range ?? "30d"] as const,
|
||||||
|
operationsTab: () => ["overview", "operations"] as const,
|
||||||
|
customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const,
|
||||||
|
staffTab: (range?: string) => ["overview", "staff", range ?? "30d"] as const,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -68,11 +68,20 @@ export const URL_CONSTANTS = {
|
|||||||
BY_ID: (id: string | number) => `/customers/${id}`,
|
BY_ID: (id: string | number) => `/customers/${id}`,
|
||||||
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
|
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
|
||||||
},
|
},
|
||||||
|
|
||||||
CUSTOMERS_API: {
|
CUSTOMERS_API: {
|
||||||
BASE: "/api/customers",
|
BASE: "/api/customers",
|
||||||
BY_ID: (id: string) => `/api/customers/${id}`,
|
BY_ID: (id: string) => `/api/customers/${id}`,
|
||||||
BY_USER_ID: (id: string) => `/api/customers/user/${id}`
|
BY_USER_ID: (id: string) => `/api/customers/user/${id}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
OVERVIEW: {
|
||||||
|
BASE: "/overview",
|
||||||
|
BOOKINGS: "/overview/bookings",
|
||||||
|
BILLING: "/overview/billing",
|
||||||
|
OPERATIONS: "/overview/operations",
|
||||||
|
CUSTOMERS: "/overview/customers",
|
||||||
|
STAFF: "/overview/staff",
|
||||||
},
|
},
|
||||||
|
|
||||||
BOOKINGS: {
|
BOOKINGS: {
|
||||||
|
|||||||
@@ -26,6 +26,52 @@ export const useRuleEngineList = (
|
|||||||
queryFn: () => ruleEngineService.list(resource, params),
|
queryFn: () => ruleEngineService.list(resource, params),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ORDER_LIST_PAGE_SIZE = 500;
|
||||||
|
|
||||||
|
export const useRuleEngineOrderList = (
|
||||||
|
resource: RuleEngineResourceSlug,
|
||||||
|
enabled: boolean,
|
||||||
|
sortBy?: string,
|
||||||
|
) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: QUERY_KEYS.RULE_ENGINE.orderList(resource),
|
||||||
|
queryFn: () =>
|
||||||
|
ruleEngineService.list(resource, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: ORDER_LIST_PAGE_SIZE,
|
||||||
|
sortBy,
|
||||||
|
sortOrder: "ASC",
|
||||||
|
}),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useRuleEngineOrderMutations = (resource: RuleEngineResourceSlug) => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const reorder = useMutation({
|
||||||
|
mutationFn: (payload: { ids: string[]; requiresDirectorApproval?: boolean }) =>
|
||||||
|
ruleEngineService.reorder(resource, payload),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("Order updated");
|
||||||
|
await invalidateRuleEngineList(qc, resource);
|
||||||
|
await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.orderList(resource) });
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to update order"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveOrder = useMutation({
|
||||||
|
mutationFn: ({ id, direction }: { id: string; direction: "up" | "down" }) =>
|
||||||
|
ruleEngineService.moveOrder(resource, id, direction),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidateRuleEngineList(qc, resource);
|
||||||
|
await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.orderList(resource) });
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Cannot move item further in that direction"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { reorder, moveOrder };
|
||||||
|
};
|
||||||
|
|
||||||
export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
|
export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("cargo-types"),
|
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("cargo-types"),
|
||||||
|
|||||||
52
apps/edr-freight-web/backoffice/src/hooks/useOverview.ts
Normal file
52
apps/edr-freight-web/backoffice/src/hooks/useOverview.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
|
import { overviewService } from "@/services/overview.service";
|
||||||
|
import type { OverviewRange } from "@/types/overview";
|
||||||
|
|
||||||
|
export function useOverview(range: OverviewRange = "30d") {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEYS.OVERVIEW.dashboard(range),
|
||||||
|
queryFn: () => overviewService.getDashboard(range),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOverviewBookingsTab(range: OverviewRange, enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEYS.OVERVIEW.bookingsTab(range),
|
||||||
|
queryFn: () => overviewService.getBookingsTab(range),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOverviewBillingTab(range: OverviewRange, enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEYS.OVERVIEW.billingTab(range),
|
||||||
|
queryFn: () => overviewService.getBillingTab(range),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOverviewOperationsTab(enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEYS.OVERVIEW.operationsTab(),
|
||||||
|
queryFn: () => overviewService.getOperationsTab(),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOverviewCustomersTab(range: OverviewRange, enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEYS.OVERVIEW.customersTab(range),
|
||||||
|
queryFn: () => overviewService.getCustomersTab(range),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOverviewStaffTab(range: OverviewRange, enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEYS.OVERVIEW.staffTab(range),
|
||||||
|
queryFn: () => overviewService.getStaffTab(range),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,11 +1,192 @@
|
|||||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Banknote,
|
||||||
|
FileText,
|
||||||
|
Train,
|
||||||
|
UserCheck,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Tabs,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader";
|
||||||
|
import { OverviewQuickLinks } from "@/components/overview/OverviewQuickLinks";
|
||||||
|
import { OverviewTabContent } from "@/components/overview/OverviewTabContent";
|
||||||
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
|
import { useOverview } from "@/hooks/useOverview";
|
||||||
|
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
|
||||||
|
|
||||||
|
const TAB_ITEMS: Array<{
|
||||||
|
value: OverviewTabKey;
|
||||||
|
label: string;
|
||||||
|
icon: typeof FileText;
|
||||||
|
kpiKey: "bookings" | "billing" | "operations" | "customers" | "staff";
|
||||||
|
metricKey: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: "bookings",
|
||||||
|
label: "Bookings",
|
||||||
|
icon: FileText,
|
||||||
|
kpiKey: "bookings",
|
||||||
|
metricKey: "totalActive",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "billing",
|
||||||
|
label: "Billing",
|
||||||
|
icon: Banknote,
|
||||||
|
kpiKey: "billing",
|
||||||
|
metricKey: "successfulPaymentsMtd",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "operations",
|
||||||
|
label: "Operations",
|
||||||
|
icon: Train,
|
||||||
|
kpiKey: "operations",
|
||||||
|
metricKey: "trainsActive",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "customers",
|
||||||
|
label: "Customers",
|
||||||
|
icon: Users,
|
||||||
|
kpiKey: "customers",
|
||||||
|
metricKey: "totalCustomers",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "staff",
|
||||||
|
label: "Staff",
|
||||||
|
icon: UserCheck,
|
||||||
|
kpiKey: "staff",
|
||||||
|
metricKey: "activeEmployees",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function HeaderSkeleton() {
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Skeleton height={48} radius="md" />
|
||||||
|
<Skeleton height={52} radius="lg" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const OverviewPage = () => {
|
const OverviewPage = () => {
|
||||||
|
const [range, setRange] = useState<OverviewRange>("30d");
|
||||||
|
const [activeTab, setActiveTab] = useState<OverviewTabKey>("bookings");
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: summary, isLoading, isError, refetch, isFetching } = useOverview(range);
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
void refetch();
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.OVERVIEW.ROOT });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTabBadge = (tab: (typeof TAB_ITEMS)[number]) => {
|
||||||
|
if (!summary?.kpis) return 0;
|
||||||
|
const group = summary.kpis[tab.kpiKey] as Record<string, number>;
|
||||||
|
return group[tab.metricKey] ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FeaturePlaceholder
|
<Container fluid px="md" py="md">
|
||||||
title="Overview"
|
<Stack gap="lg">
|
||||||
description="Track internal freight operations, monitor account administration, and review the latest backoffice activity from a single operational dashboard."
|
{isLoading && !summary ? (
|
||||||
/>
|
<HeaderSkeleton />
|
||||||
|
) : (
|
||||||
|
<OverviewPageHeader
|
||||||
|
range={range}
|
||||||
|
onRangeChange={setRange}
|
||||||
|
generatedAt={summary?.generatedAt}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
isRefreshing={isFetching && !isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<Alert
|
||||||
|
icon={<AlertCircle size={16} />}
|
||||||
|
color="red"
|
||||||
|
title="Unable to load dashboard summary"
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
<Stack gap="sm" align="flex-start">
|
||||||
|
<span>Check your connection and try again.</span>
|
||||||
|
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
background: "white",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(value) => setActiveTab((value as OverviewTabKey) ?? "bookings")}
|
||||||
|
variant="pills"
|
||||||
|
color="green"
|
||||||
|
keepMounted={false}
|
||||||
|
>
|
||||||
|
<Tabs.List
|
||||||
|
style={{
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 8,
|
||||||
|
background: "var(--freight-brand-muted, #f0fdf4)",
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TAB_ITEMS.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
|
<Tabs.Tab
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
leftSection={<Icon size={16} />}
|
||||||
|
rightSection={
|
||||||
|
summary ? (
|
||||||
|
<Badge size="sm" variant="light" color="green">
|
||||||
|
{getTabBadge(tab)}
|
||||||
|
</Badge>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
style={{ fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Tabs.Tab>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
{TAB_ITEMS.map((tab) => (
|
||||||
|
<Tabs.Panel key={tab.value} value={tab.value} pt="lg">
|
||||||
|
<OverviewTabContent tab={tab.value} range={range} />
|
||||||
|
</Tabs.Panel>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper p="lg" radius="lg" withBorder>
|
||||||
|
<OverviewQuickLinks />
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 { Navigate, useLocation, useParams } from "react-router-dom";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { canAccessRuleEngineResource } from "@/lib/permissions";
|
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 RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
||||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
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 RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordActions";
|
||||||
|
import { getOrderItemLabel } from "@/components/ruleEngine/ruleEngineOrder.utils";
|
||||||
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
|
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
|
||||||
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
|
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
@@ -29,6 +32,8 @@ import {
|
|||||||
useRateWorkflow,
|
useRateWorkflow,
|
||||||
useRuleEngineList,
|
useRuleEngineList,
|
||||||
useRuleEngineMutations,
|
useRuleEngineMutations,
|
||||||
|
useRuleEngineOrderList,
|
||||||
|
useRuleEngineOrderMutations,
|
||||||
} from "@/hooks/rule-engine/useRuleEngine";
|
} from "@/hooks/rule-engine/useRuleEngine";
|
||||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||||
import {
|
import {
|
||||||
@@ -65,6 +70,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
|
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(null);
|
||||||
const [chainOpen, setChainOpen] = useState(false);
|
const [chainOpen, setChainOpen] = useState(false);
|
||||||
|
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
|
||||||
const { viewMode, setViewMode } = useRuleEngineViewMode(
|
const { viewMode, setViewMode } = useRuleEngineViewMode(
|
||||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||||
);
|
);
|
||||||
@@ -81,10 +87,27 @@ const RuleEngineResourcePage = () => {
|
|||||||
search: config?.supportsSearch ? search.trim() || undefined : undefined,
|
search: config?.supportsSearch ? search.trim() || undefined : undefined,
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
pageSize: pagination.pageSize,
|
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(
|
const { data, isLoading, isError, error } = useRuleEngineList(
|
||||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||||
listParams,
|
listParams,
|
||||||
@@ -93,6 +116,14 @@ const RuleEngineResourcePage = () => {
|
|||||||
const { create, update, remove } = useRuleEngineMutations(
|
const { create, update, remove } = useRuleEngineMutations(
|
||||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
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 { submit, approve } = useRateWorkflow();
|
||||||
const { data: chainData, isLoading: chainLoading } = useApprovalChain(
|
const { data: chainData, isLoading: chainLoading } = useApprovalChain(
|
||||||
chainOpen && config?.slug === "approval-rules",
|
chainOpen && config?.slug === "approval-rules",
|
||||||
@@ -149,25 +180,24 @@ const RuleEngineResourcePage = () => {
|
|||||||
const rows = data?.data ?? [];
|
const rows = data?.data ?? [];
|
||||||
const meta = data?.meta;
|
const meta = data?.meta;
|
||||||
const pageCount = meta?.totalPages ?? 1;
|
const pageCount = meta?.totalPages ?? 1;
|
||||||
|
const totalCount = meta?.total ?? rows.length;
|
||||||
|
|
||||||
const filteredRows = useMemo(() => {
|
const { data: createPositionList, isLoading: createPositionLoading } = useRuleEngineOrderList(
|
||||||
if (config?.supportsSearch || !search.trim()) return rows;
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||||
const q = search.trim().toLowerCase();
|
Boolean(formOpen && !editing && config?.orderConfig),
|
||||||
return rows.filter((row) =>
|
config?.orderConfig?.field,
|
||||||
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 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(
|
const handleApproveRate = useCallback(
|
||||||
(record: RuleEngineRecord) => {
|
(record: RuleEngineRecord) => {
|
||||||
@@ -176,6 +206,13 @@ const RuleEngineResourcePage = () => {
|
|||||||
[approve],
|
[approve],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleMoveOrder = useCallback(
|
||||||
|
(id: string, direction: "up" | "down") => {
|
||||||
|
moveOrder.mutate({ id, direction });
|
||||||
|
},
|
||||||
|
[moveOrder],
|
||||||
|
);
|
||||||
|
|
||||||
const columns = useMemo((): ColumnDef<RuleEngineRecord>[] => {
|
const columns = useMemo((): ColumnDef<RuleEngineRecord>[] => {
|
||||||
if (!config) return [];
|
if (!config) return [];
|
||||||
|
|
||||||
@@ -192,36 +229,47 @@ const RuleEngineResourcePage = () => {
|
|||||||
base.push({
|
base.push({
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Actions",
|
header: "Actions",
|
||||||
size: 140,
|
size: config.orderConfig ? 200 : 140,
|
||||||
minSize: 120,
|
minSize: config.orderConfig ? 180 : 120,
|
||||||
meta: {
|
meta: {
|
||||||
headerClassName,
|
headerClassName,
|
||||||
cellClassName: `${cellClassName} whitespace-nowrap`,
|
cellClassName: `${cellClassName} whitespace-nowrap`,
|
||||||
},
|
},
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
||||||
<RuleEngineRecordActions
|
<Group gap="xs" wrap="nowrap" justify="flex-end">
|
||||||
record={row.original}
|
{config.orderConfig && canManage ? (
|
||||||
config={config}
|
<RuleEngineOrderControls
|
||||||
layout="row"
|
record={row.original}
|
||||||
readOnly={!canManage}
|
orderConfig={config.orderConfig}
|
||||||
onEdit={(record) => {
|
totalCount={totalCount}
|
||||||
setEditing(record);
|
disabled={moveOrder.isPending}
|
||||||
setFormOpen(true);
|
onMove={handleMoveOrder}
|
||||||
}}
|
/>
|
||||||
onDelete={setDeleteTarget}
|
) : null}
|
||||||
onViewChain={
|
<RuleEngineRecordActions
|
||||||
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
record={row.original}
|
||||||
}
|
config={config}
|
||||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
layout="row"
|
||||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
readOnly={!canManage}
|
||||||
/>
|
onEdit={(record) => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
}, [canManage, config, submit, handleApproveRate]);
|
}, [canManage, config, submit, handleApproveRate, handleMoveOrder, moveOrder.isPending, totalCount]);
|
||||||
|
|
||||||
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||||
|
|
||||||
@@ -276,13 +324,21 @@ const RuleEngineResourcePage = () => {
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<RuleEngineToolbar
|
<RuleEngineToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={(v) => {
|
onSearchChange={
|
||||||
setSearch(v);
|
config.supportsSearch
|
||||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
? (v) => {
|
||||||
}}
|
setSearch(v);
|
||||||
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
showSearch={Boolean(config.supportsSearch)}
|
||||||
searchPlaceholder={config.searchPlaceholder}
|
searchPlaceholder={config.searchPlaceholder}
|
||||||
onAdd={canManage ? openCreate : undefined}
|
onAdd={canManage ? openCreate : undefined}
|
||||||
addLabel={`Add ${config.label.replace(/s$/, "")}`}
|
addLabel={`Add ${config.label.replace(/s$/, "")}`}
|
||||||
|
onManageOrder={
|
||||||
|
canManage && config.orderConfig ? () => setOrderDialogOpen(true) : undefined
|
||||||
|
}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
/>
|
/>
|
||||||
@@ -290,7 +346,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
{viewMode === "table" ? (
|
{viewMode === "table" ? (
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={filteredRows}
|
data={rows}
|
||||||
status={tableStatus}
|
status={tableStatus}
|
||||||
error={
|
error={
|
||||||
isError
|
isError
|
||||||
@@ -302,7 +358,12 @@ const RuleEngineResourcePage = () => {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
emptyMessage={`No ${itemLabel} found.`}
|
emptyMessage={`No ${itemLabel} found.`}
|
||||||
pagination={paginationState}
|
pagination={{
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount,
|
||||||
|
totalCount,
|
||||||
|
}}
|
||||||
tableOptions={{
|
tableOptions={{
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
pageCount,
|
pageCount,
|
||||||
@@ -328,11 +389,14 @@ const RuleEngineResourcePage = () => {
|
|||||||
) : (
|
) : (
|
||||||
<RuleEngineCardGrid
|
<RuleEngineCardGrid
|
||||||
config={config}
|
config={config}
|
||||||
rows={filteredRows}
|
rows={rows}
|
||||||
status={tableStatus}
|
status={tableStatus}
|
||||||
emptyMessage={`No ${itemLabel} found.`}
|
emptyMessage={`No ${itemLabel} found.`}
|
||||||
itemLabel={itemLabel}
|
itemLabel={itemLabel}
|
||||||
pagination={paginationState}
|
pagination={pagination}
|
||||||
|
pageCount={pageCount}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPaginationChange={setPagination}
|
||||||
readOnly={!canManage}
|
readOnly={!canManage}
|
||||||
onEdit={canManage ? openEdit : undefined}
|
onEdit={canManage ? openEdit : undefined}
|
||||||
onDelete={canManage ? setDeleteTarget : undefined}
|
onDelete={canManage ? setDeleteTarget : undefined}
|
||||||
@@ -363,9 +427,27 @@ const RuleEngineResourcePage = () => {
|
|||||||
(usesContainerTypeField && containerTypeOptionsLoading) ||
|
(usesContainerTypeField && containerTypeOptionsLoading) ||
|
||||||
(usesLiveRateField && liveRateOptionsLoading)
|
(usesLiveRateField && liveRateOptionsLoading)
|
||||||
}
|
}
|
||||||
|
positionOptions={!editing ? createPositionOptions : undefined}
|
||||||
|
positionLoading={createPositionLoading}
|
||||||
onSubmit={handleFormSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{config.orderConfig ? (
|
||||||
|
<ManageRuleEngineOrderDialog
|
||||||
|
open={orderDialogOpen}
|
||||||
|
onOpenChange={setOrderDialogOpen}
|
||||||
|
config={config}
|
||||||
|
items={orderListData?.data ?? []}
|
||||||
|
isLoading={orderListLoading}
|
||||||
|
isSaving={reorder.isPending}
|
||||||
|
onSave={(payload) => {
|
||||||
|
reorder.mutate(payload, {
|
||||||
|
onSuccess: () => setOrderDialogOpen(false),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={Boolean(deleteTarget)}
|
opened={Boolean(deleteTarget)}
|
||||||
onClose={() => setDeleteTarget(null)}
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ export interface FormFieldDef {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RuleEngineOrderConfig {
|
||||||
|
field: "displayOrder" | "stepOrder";
|
||||||
|
scopeField?: "requiresDirectorApproval";
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RuleEngineResourceConfig {
|
export interface RuleEngineResourceConfig {
|
||||||
slug: RuleEngineResourceSlug;
|
slug: RuleEngineResourceSlug;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -45,6 +51,7 @@ export interface RuleEngineResourceConfig {
|
|||||||
columns: ResourceColumn[];
|
columns: ResourceColumn[];
|
||||||
formFields: FormFieldDef[];
|
formFields: FormFieldDef[];
|
||||||
supportsSearch?: boolean;
|
supportsSearch?: boolean;
|
||||||
|
orderConfig?: RuleEngineOrderConfig;
|
||||||
/** Primary line on card view (inferred from columns when omitted). */
|
/** Primary line on card view (inferred from columns when omitted). */
|
||||||
cardTitleKey?: string;
|
cardTitleKey?: string;
|
||||||
/** Secondary line under title on card view (inferred when omitted). */
|
/** 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",
|
subtitle: "Manage freight cargo classification and approval rules",
|
||||||
searchPlaceholder: "Search cargo types by name or code...",
|
searchPlaceholder: "Search cargo types by name or code...",
|
||||||
supportsSearch: true,
|
supportsSearch: true,
|
||||||
|
orderConfig: { field: "displayOrder", label: "Display order" },
|
||||||
columns: [
|
columns: [
|
||||||
codeColumn("code"),
|
codeColumn("code"),
|
||||||
{ id: "cargoTypeName", header: "Name", accessorKey: "cargoTypeName" },
|
{ 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: "showFreeTextBox", label: "Show free text box", type: "boolean" },
|
||||||
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||||
{ name: "isActive", label: "Active", 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",
|
category: "configuration",
|
||||||
subtitle: "Configure container sizes and wagon capacity",
|
subtitle: "Configure container sizes and wagon capacity",
|
||||||
searchPlaceholder: "Search container types...",
|
searchPlaceholder: "Search container types...",
|
||||||
|
orderConfig: { field: "displayOrder", label: "Display order" },
|
||||||
columns: [
|
columns: [
|
||||||
codeColumn("code"),
|
codeColumn("code"),
|
||||||
{ id: "label", header: "Label", accessorKey: "label" },
|
{ id: "label", header: "Label", accessorKey: "label" },
|
||||||
|
{ id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" },
|
||||||
{ id: "sizeFt", header: "Size (ft)", accessorKey: "sizeFt", format: "number" },
|
{ id: "sizeFt", header: "Size (ft)", accessorKey: "sizeFt", format: "number" },
|
||||||
{ id: "wagonsPerUnit", header: "Wagons / unit", accessorKey: "wagonsPerUnit", format: "number" },
|
{ id: "wagonsPerUnit", header: "Wagons / unit", accessorKey: "wagonsPerUnit", format: "number" },
|
||||||
activeColumn,
|
activeColumn,
|
||||||
@@ -177,7 +186,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
{ name: "isReefer", label: "Reefer", type: "boolean" },
|
{ name: "isReefer", label: "Reefer", type: "boolean" },
|
||||||
{ name: "isOpenTop", label: "Open top", type: "boolean" },
|
{ name: "isOpenTop", label: "Open top", type: "boolean" },
|
||||||
{ name: "isActive", label: "Active", 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",
|
subtitle: "Freight service offerings and booking options",
|
||||||
searchPlaceholder: "Search service types...",
|
searchPlaceholder: "Search service types...",
|
||||||
supportsSearch: true,
|
supportsSearch: true,
|
||||||
|
orderConfig: { field: "displayOrder", label: "Display order" },
|
||||||
columns: [
|
columns: [
|
||||||
codeColumn("code"),
|
codeColumn("code"),
|
||||||
{ id: "serviceName", header: "Service name", accessorKey: "serviceName" },
|
{ id: "serviceName", header: "Service name", accessorKey: "serviceName" },
|
||||||
|
{ id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" },
|
||||||
{ id: "priorityBonusPoints", header: "Bonus pts", accessorKey: "priorityBonusPoints", format: "number" },
|
{ id: "priorityBonusPoints", header: "Bonus pts", accessorKey: "priorityBonusPoints", format: "number" },
|
||||||
activeColumn,
|
activeColumn,
|
||||||
],
|
],
|
||||||
@@ -269,7 +279,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
|
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
|
||||||
{ name: "priorityBonusPoints", label: "Priority bonus points", type: "number" },
|
{ name: "priorityBonusPoints", label: "Priority bonus points", type: "number" },
|
||||||
{ name: "isActive", label: "Active", type: "boolean" },
|
{ 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",
|
category: "configuration",
|
||||||
subtitle: "Terminal and yard locations",
|
subtitle: "Terminal and yard locations",
|
||||||
searchPlaceholder: "Search yards...",
|
searchPlaceholder: "Search yards...",
|
||||||
|
orderConfig: { field: "displayOrder", label: "Display order" },
|
||||||
columns: [
|
columns: [
|
||||||
codeColumn("code"),
|
codeColumn("code"),
|
||||||
{ id: "label", header: "Label", accessorKey: "label" },
|
{ 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: "label", label: "Label", type: "text", required: true },
|
||||||
{ name: "country", label: "Country", type: "text", required: true },
|
{ name: "country", label: "Country", type: "text", required: true },
|
||||||
{ name: "isActive", label: "Active", type: "boolean" },
|
{ 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",
|
cardSubtitleKey: "requiredRole",
|
||||||
subtitle: "Multi-step booking approval chain",
|
subtitle: "Multi-step booking approval chain",
|
||||||
searchPlaceholder: "Search approval rules...",
|
searchPlaceholder: "Search approval rules...",
|
||||||
|
orderConfig: {
|
||||||
|
field: "stepOrder",
|
||||||
|
scopeField: "requiresDirectorApproval",
|
||||||
|
label: "Step order",
|
||||||
|
},
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
id: "requiresDirectorApproval",
|
id: "requiresDirectorApproval",
|
||||||
@@ -450,7 +464,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
{ name: "requiresDirectorApproval", label: "Requires director approval chain", type: "boolean" },
|
{ name: "requiresDirectorApproval", label: "Requires director approval chain", type: "boolean" },
|
||||||
{ name: "stepOrder", label: "Step order", type: "number", required: true },
|
|
||||||
{
|
{
|
||||||
name: "requiredRole",
|
name: "requiredRole",
|
||||||
label: "Required role",
|
label: "Required role",
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import {
|
|||||||
type RejectStepPayload,
|
type RejectStepPayload,
|
||||||
} from "./bookings.service";
|
} from "./bookings.service";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
||||||
|
import { overviewService } from "./overview.service";
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
fileUploadSettings: {
|
fileUploadSettings: {
|
||||||
@@ -233,6 +235,20 @@ export const api = {
|
|||||||
() => ruleEngineService.getApprovalChain(),
|
() => ruleEngineService.getApprovalChain(),
|
||||||
() => QUERY_KEYS.RULE_ENGINE.chain,
|
() => 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: {
|
bookings: {
|
||||||
@@ -329,4 +345,12 @@ export const api = {
|
|||||||
({ id, reason }) => bookingsService.cancel(id, reason),
|
({ id, reason }) => bookingsService.cancel(id, reason),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
overview: {
|
||||||
|
get: endpoint<{ range?: OverviewRange }, IOverviewDashboard>(
|
||||||
|
"overview",
|
||||||
|
"get",
|
||||||
|
({ range }) => overviewService.getDashboard(range),
|
||||||
|
),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<IOverviewDashboard> => {
|
||||||
|
const response = await client.get<IOverviewDashboard>(O.BASE, {
|
||||||
|
params: range ? { range } : undefined,
|
||||||
|
});
|
||||||
|
return unwrap(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
getBookingsTab: async (range?: OverviewRange): Promise<IOverviewBookingsTab> => {
|
||||||
|
const response = await client.get<IOverviewBookingsTab>(O.BOOKINGS, {
|
||||||
|
params: range ? { range } : undefined,
|
||||||
|
});
|
||||||
|
return unwrap(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
getBillingTab: async (range?: OverviewRange): Promise<IOverviewBillingTab> => {
|
||||||
|
const response = await client.get<IOverviewBillingTab>(O.BILLING, {
|
||||||
|
params: range ? { range } : undefined,
|
||||||
|
});
|
||||||
|
return unwrap(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
getOperationsTab: async (): Promise<IOverviewOperationsTab> => {
|
||||||
|
const response = await client.get<IOverviewOperationsTab>(O.OPERATIONS);
|
||||||
|
return unwrap(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
getCustomersTab: async (range?: OverviewRange): Promise<IOverviewCustomersTab> => {
|
||||||
|
const response = await client.get<IOverviewCustomersTab>(O.CUSTOMERS, {
|
||||||
|
params: range ? { range } : undefined,
|
||||||
|
});
|
||||||
|
return unwrap(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
getStaffTab: async (range?: OverviewRange): Promise<IOverviewStaffTab> => {
|
||||||
|
const response = await client.get<IOverviewStaffTab>(O.STAFF, {
|
||||||
|
params: range ? { range } : undefined,
|
||||||
|
});
|
||||||
|
return unwrap(response);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -14,6 +14,14 @@ export interface RuleEngineListParams {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: "ASC" | "DESC";
|
||||||
|
requiresDirectorApproval?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleEngineReorderPayload {
|
||||||
|
ids: string[];
|
||||||
|
requiresDirectorApproval?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
||||||
@@ -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,
|
total: dataLength,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
totalPages: Math.max(1, Math.ceil(dataLength / pageSize)),
|
totalPages: Math.max(1, Math.ceil(dataLength / pageSize)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isPaginatedListResult = <T extends RuleEngineRecord>(
|
||||||
|
value: unknown,
|
||||||
|
): value is RuleEngineListResult<T> =>
|
||||||
|
Boolean(value) &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
"data" in value &&
|
||||||
|
Array.isArray((value as RuleEngineListResult<T>).data);
|
||||||
|
|
||||||
const normalizeList = <T extends RuleEngineRecord>(
|
const normalizeList = <T extends RuleEngineRecord>(
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 20,
|
pageSize = 10,
|
||||||
): RuleEngineListResult<T> => {
|
): RuleEngineListResult<T> => {
|
||||||
|
if (isPaginatedListResult<T>(payload)) {
|
||||||
|
return {
|
||||||
|
data: payload.data,
|
||||||
|
meta: payload.meta ?? defaultMeta(payload.data.length, page, pageSize),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const body = unwrap(payload as { data: unknown }) as unknown;
|
const body = unwrap(payload as { data: unknown }) as unknown;
|
||||||
|
|
||||||
if (body && typeof body === "object" && "data" in body && Array.isArray((body as RuleEngineListResult<T>).data)) {
|
if (isPaginatedListResult<T>(body)) {
|
||||||
const typed = body as RuleEngineListResult<T>;
|
|
||||||
return {
|
return {
|
||||||
data: typed.data,
|
data: body.data,
|
||||||
meta: typed.meta ?? defaultMeta(typed.data.length, page, pageSize),
|
meta: body.meta ?? defaultMeta(body.data.length, page, pageSize),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +120,7 @@ export const ruleEngineService = {
|
|||||||
params?: RuleEngineListParams,
|
params?: RuleEngineListParams,
|
||||||
): Promise<RuleEngineListResult<T>> => {
|
): Promise<RuleEngineListResult<T>> => {
|
||||||
const page = params?.page ?? 1;
|
const page = params?.page ?? 1;
|
||||||
const pageSize = params?.pageSize ?? 20;
|
const pageSize = params?.pageSize ?? 10;
|
||||||
const response = await client.get(RESOURCE_BASE[resource], {
|
const response = await client.get(RESOURCE_BASE[resource], {
|
||||||
params: {
|
params: {
|
||||||
page,
|
page,
|
||||||
@@ -106,6 +128,9 @@ export const ruleEngineService = {
|
|||||||
search: params?.search,
|
search: params?.search,
|
||||||
isActive: params?.isActive,
|
isActive: params?.isActive,
|
||||||
status: params?.status,
|
status: params?.status,
|
||||||
|
sortBy: params?.sortBy,
|
||||||
|
sortOrder: params?.sortOrder,
|
||||||
|
requiresDirectorApproval: params?.requiresDirectorApproval,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return normalizeList<T>(response.data, page, pageSize);
|
return normalizeList<T>(response.data, page, pageSize);
|
||||||
@@ -140,6 +165,21 @@ export const ruleEngineService = {
|
|||||||
await client.delete(byIdPath(resource, id));
|
await client.delete(byIdPath(resource, id));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
reorder: async (
|
||||||
|
resource: RuleEngineResourceSlug,
|
||||||
|
payload: RuleEngineReorderPayload,
|
||||||
|
): Promise<void> => {
|
||||||
|
await client.post(`${RESOURCE_BASE[resource]}/reorder`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
moveOrder: async (
|
||||||
|
resource: RuleEngineResourceSlug,
|
||||||
|
id: string,
|
||||||
|
direction: "up" | "down",
|
||||||
|
): Promise<void> => {
|
||||||
|
await client.post(`${byIdPath(resource, id)}/move-order`, { direction });
|
||||||
|
},
|
||||||
|
|
||||||
submitRate: async <T extends RuleEngineRecord>(id: string): Promise<T> => {
|
submitRate: async <T extends RuleEngineRecord>(id: string): Promise<T> => {
|
||||||
const response = await client.post(URL_CONSTANTS.RULE_ENGINE.RATE_SUBMIT(id));
|
const response = await client.post(URL_CONSTANTS.RULE_ENGINE.RATE_SUBMIT(id));
|
||||||
return normalizeEntity<T>(response.data);
|
return normalizeEntity<T>(response.data);
|
||||||
|
|||||||
24
apps/edr-freight-web/backoffice/src/types/overview.ts
Normal file
24
apps/edr-freight-web/backoffice/src/types/overview.ts
Normal file
@@ -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";
|
||||||
@@ -2,6 +2,7 @@ import type { BaseEntity } from "../common";
|
|||||||
|
|
||||||
export * from "./file_upload_settings";
|
export * from "./file_upload_settings";
|
||||||
export * from "./dropdown_settings";
|
export * from "./dropdown_settings";
|
||||||
|
export * from "./overview";
|
||||||
|
|
||||||
export enum TradeDirection {
|
export enum TradeDirection {
|
||||||
IMPORT = 'IMPORT',
|
IMPORT = 'IMPORT',
|
||||||
|
|||||||
152
packages/types/src/freight/overview.ts
Normal file
152
packages/types/src/freight/overview.ts
Normal file
@@ -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';
|
||||||
Reference in New Issue
Block a user