mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
1
.gitignore
vendored
1
.gitignore
vendored
@@ -47,3 +47,4 @@ e2e-ui-report/
|
|||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
blob-report/
|
blob-report/
|
||||||
|
RUNNING_LOCALLY.md
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
|
|||||||
REEFER: 'Reefer (refrigerated) surcharge',
|
REEFER: 'Reefer (refrigerated) surcharge',
|
||||||
WITH_RETURN: 'Empty-container return service',
|
WITH_RETURN: 'Empty-container return service',
|
||||||
SHIPPING_LINE: 'Shipping line handling',
|
SHIPPING_LINE: 'Shipping line handling',
|
||||||
CONSOLIDATION: 'Container consolidation (extra document)',
|
CONSOLIDATION: 'Penalty (container consolidation)',
|
||||||
LASHING: 'Cargo lashing and securing',
|
LASHING: 'Cargo lashing and securing',
|
||||||
CANCELLATION: 'Booking cancellation fee',
|
CANCELLATION: 'Booking cancellation fee',
|
||||||
DEMURRAGE: 'Demurrage / wagon detention',
|
DEMURRAGE: 'Demurrage / wagon detention',
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named service items for KM-based maintenance ("oil change", "tires", …).
|
||||||
|
* The coarse maintenance_type enum (PREVENTIVE/…) allowed only one interval
|
||||||
|
* per type per vehicle, so oil and tire intervals could not coexist. Interval
|
||||||
|
* identity becomes (vehicle, maintenance_type, service_item); schedules carry
|
||||||
|
* the item so completion re-finds the right interval for auto-scheduling.
|
||||||
|
*/
|
||||||
|
export class AddMaintenanceServiceItem2810000000000 implements MigrationInterface {
|
||||||
|
name = 'AddMaintenanceServiceItem2810000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.maintenance_intervals ADD COLUMN IF NOT EXISTS service_item varchar(120);`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.maintenance_schedules ADD COLUMN IF NOT EXISTS service_item varchar(120);`,
|
||||||
|
);
|
||||||
|
// Re-key interval uniqueness on (vehicle, type, item). COALESCE folds the
|
||||||
|
// item-less legacy rows into one slot; soft-deleted rows are ignored.
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type";`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type_item"
|
||||||
|
ON freight.maintenance_intervals (vehicle_id, maintenance_type, COALESCE(service_item, ''))
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type_item";`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type"
|
||||||
|
ON freight.maintenance_intervals (vehicle_id, maintenance_type);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS service_item;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.maintenance_intervals DROP COLUMN IF EXISTS service_item;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope the customs clearance service fee to a direction + route.
|
||||||
|
*
|
||||||
|
* The fee was a single global flat rate; the business sells it per lane —
|
||||||
|
* "import clearance, Djibouti → Adama, 300 USD". CUSTOMS_CLEARANCE rates now
|
||||||
|
* carry trade_direction + the yard pair, and contract pricing matches on them
|
||||||
|
* strictly (no route-less fallback).
|
||||||
|
*
|
||||||
|
* Existing route-less clearance rates cannot be backfilled (no way to know
|
||||||
|
* which lane each was meant for) — retired exactly like the base-freight
|
||||||
|
* retirement in AddRateYardScope: SUPERSEDED + soft-deleted, kept for
|
||||||
|
* snapshot history.
|
||||||
|
*/
|
||||||
|
export class CustomsClearanceRouteScope2820000000000 implements MigrationInterface {
|
||||||
|
name = 'CustomsClearanceRouteScope2820000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.rates
|
||||||
|
SET status = 'SUPERSEDED',
|
||||||
|
deleted_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND rate_type = 'CUSTOMS_CLEARANCE'
|
||||||
|
AND (origin_yard_id IS NULL OR destination_yard_id IS NULL);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.rates
|
||||||
|
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||||
|
deleted_at IS NOT NULL
|
||||||
|
OR status = 'SUPERSEDED'
|
||||||
|
OR CASE
|
||||||
|
WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
|
||||||
|
OR "trigger" = 'CUSTOMS_CLEARANCE'
|
||||||
|
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||||
|
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||||
|
END
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Retired rates stay retired (their lanes were never recorded); down only
|
||||||
|
// restores the pre-customs constraint shape.
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.rates
|
||||||
|
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||||
|
deleted_at IS NOT NULL
|
||||||
|
OR status = 'SUPERSEDED'
|
||||||
|
OR CASE
|
||||||
|
WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')
|
||||||
|
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||||
|
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||||
|
END
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope the empty-container return surcharge to a direction + route +
|
||||||
|
* container type, like base freight (import-only for now — the box only goes
|
||||||
|
* back to the port on imports).
|
||||||
|
*
|
||||||
|
* Existing route-less RETURN_SURCHARGE rates cannot be backfilled — retired
|
||||||
|
* (SUPERSEDED + soft-deleted) exactly like base freight and customs clearance
|
||||||
|
* were, kept readable for snapshot history. Route-scoped replacements must be
|
||||||
|
* re-entered; a booking that asks for return with no matching rate hard-blocks.
|
||||||
|
*/
|
||||||
|
export class ReturnSurchargeRouteScope2830000000000 implements MigrationInterface {
|
||||||
|
name = 'ReturnSurchargeRouteScope2830000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.rates
|
||||||
|
SET status = 'SUPERSEDED',
|
||||||
|
deleted_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND rate_type = 'RETURN_SURCHARGE'
|
||||||
|
AND (origin_yard_id IS NULL OR destination_yard_id IS NULL);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.rates
|
||||||
|
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||||
|
deleted_at IS NOT NULL
|
||||||
|
OR status = 'SUPERSEDED'
|
||||||
|
OR CASE
|
||||||
|
WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
|
||||||
|
OR "trigger" IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN')
|
||||||
|
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||||
|
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||||
|
END
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Retired rates stay retired; down only restores the customs-era shape.
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.rates
|
||||||
|
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||||
|
deleted_at IS NOT NULL
|
||||||
|
OR status = 'SUPERSEDED'
|
||||||
|
OR CASE
|
||||||
|
WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
|
||||||
|
OR "trigger" = 'CUSTOMS_CLEARANCE'
|
||||||
|
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||||
|
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||||
|
END
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customs clearance service fee is no longer prepaid via its own
|
||||||
|
* `clearance`-source invoice — it is billed as a CUSTOMS_CLEARANCE line on the
|
||||||
|
* booking invoice, together with the freight (see BookingPricingService).
|
||||||
|
*
|
||||||
|
* - Contracts/bookings parked at the payment gate move straight to the
|
||||||
|
* document step (the gate no longer exists — nothing could ever pay them).
|
||||||
|
* - Open (unpaid) clearance invoices are expired; PAID ones stay as history.
|
||||||
|
* NOTE: a ONE_TIME customs contract that already PAID its prepaid fee but
|
||||||
|
* has not booked yet will be billed the fee again on its booking invoice —
|
||||||
|
* accepted for dev data; reverses the old AddClearanceFeePayment migration.
|
||||||
|
* - clearance_fee_paid_at columns are dropped from contracts and bookings.
|
||||||
|
*/
|
||||||
|
export class DropClearanceFeePrepay2860000000000 implements MigrationInterface {
|
||||||
|
name = 'DropClearanceFeePrepay2860000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.contracts
|
||||||
|
SET status = 'AWAITING_CLEARANCE_DOCUMENTS', updated_at = now()
|
||||||
|
WHERE status = 'AWAITING_CLEARANCE_PAYMENT';
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.contracts
|
||||||
|
SET clearance_status = 'AWAITING_DOCUMENTS', updated_at = now()
|
||||||
|
WHERE clearance_status = 'AWAITING_PAYMENT';
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings
|
||||||
|
SET status = 'AWAITING_DOCUMENTS', updated_at = now()
|
||||||
|
WHERE status = 'AWAITING_CLEARANCE_PAYMENT';
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.invoices
|
||||||
|
SET status = 'EXPIRED', updated_at = now()
|
||||||
|
WHERE source = 'clearance'
|
||||||
|
AND status IN ('DRAFT', 'ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE');
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS clearance_fee_paid_at;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS clearance_fee_paid_at;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Moved rows and expired invoices stay — only the columns come back.
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS clearance_fee_paid_at timestamptz;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS clearance_fee_paid_at timestamptz;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customs clearance fees are now sold per cargo kind: container fees name a
|
||||||
|
* container type (billed PER_CONTAINER / PER_WAGON), bulk fees carry no type
|
||||||
|
* (billed PER_TON / PER_WAGON). The old one-FLAT-fee-per-route shape cannot be
|
||||||
|
* mapped to a kind — retired (SUPERSEDED + soft-deleted) exactly like the
|
||||||
|
* base-freight and return-surcharge reshapes, kept readable for snapshot
|
||||||
|
* history. Per-kind replacements must be re-entered; a customs contract or
|
||||||
|
* booking without a matching fee hard-blocks. Contracts that already froze a
|
||||||
|
* FLAT snapshot keep billing it (legacy honoured at booking pricing).
|
||||||
|
*/
|
||||||
|
export class CustomsClearancePerKind2870000000000 implements MigrationInterface {
|
||||||
|
name = 'CustomsClearancePerKind2870000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.rates
|
||||||
|
SET status = 'SUPERSEDED',
|
||||||
|
deleted_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND rate_type = 'CUSTOMS_CLEARANCE'
|
||||||
|
AND rate_unit = 'FLAT';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Retired rates stay retired — re-enter per-kind rates instead.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lashing is now sold per cargo kind, like the customs clearance fee:
|
||||||
|
* container rates name a container type (PER_CONTAINER / PER_WAGON), bulk
|
||||||
|
* rates carry no type (PER_TON / PER_WAGON). The old flat-per-booking shape
|
||||||
|
* cannot be mapped to a kind — retired (SUPERSEDED + soft-deleted), kept
|
||||||
|
* readable for snapshot history. Per-kind replacements must be re-entered;
|
||||||
|
* an unconfigured lashing rate simply bills nothing (lenient, like
|
||||||
|
* hazard/reefer). Matched on trigger, not rate_type — CONSOLIDATION rates
|
||||||
|
* share the LASHING rate_type and must survive.
|
||||||
|
*/
|
||||||
|
export class LashingPerKind2880000000000 implements MigrationInterface {
|
||||||
|
name = 'LashingPerKind2880000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.rates
|
||||||
|
SET status = 'SUPERSEDED',
|
||||||
|
deleted_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND "trigger" = 'LASHING'
|
||||||
|
AND rate_unit = 'FLAT';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Retired rates stay retired — re-enter per-kind rates instead.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lashing is now BULK-only and sold per trade direction (IMPORT / EXPORT),
|
||||||
|
* optionally narrowed to one leaf commodity. Rates that no longer fit —
|
||||||
|
* container-scoped, or carrying no direction — cannot be mapped and are
|
||||||
|
* retired (SUPERSEDED + soft-deleted), kept readable for snapshot history.
|
||||||
|
* Matched on trigger, not rate_type (CONSOLIDATION shares rate_type LASHING).
|
||||||
|
*/
|
||||||
|
export class LashingBulkOnlyPerDirection2890000000000 implements MigrationInterface {
|
||||||
|
name = 'LashingBulkOnlyPerDirection2890000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.rates
|
||||||
|
SET status = 'SUPERSEDED',
|
||||||
|
deleted_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND "trigger" = 'LASHING'
|
||||||
|
AND (container_type_id IS NOT NULL
|
||||||
|
OR trade_direction IS NULL
|
||||||
|
OR trade_direction NOT IN ('IMPORT', 'EXPORT'));
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Retired rates stay retired — re-enter per-direction bulk rates instead.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator";
|
import { Type } from "class-transformer";
|
||||||
|
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
class CreateOrganizationUserNameDto {
|
class CreateOrganizationUserNameDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -29,7 +30,8 @@ export class CreateOrganizationUserDto {
|
|||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
|
|
||||||
@ApiProperty({ type: CreateOrganizationUserNameDto })
|
@ApiProperty({ type: CreateOrganizationUserNameDto })
|
||||||
@IsObject()
|
@ValidateNested()
|
||||||
|
@Type(() => CreateOrganizationUserNameDto)
|
||||||
name!: CreateOrganizationUserNameDto;
|
name!: CreateOrganizationUserNameDto;
|
||||||
|
|
||||||
@ApiProperty({ required: false, default: false })
|
@ApiProperty({ required: false, default: false })
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
InvoiceLineInput,
|
InvoiceLineInput,
|
||||||
} from "../billing/billing.service";
|
} from "../billing/billing.service";
|
||||||
import { Invoice } from "../billing/entities/invoice.entity";
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
import { CLEARANCE_BOOKING_INVOICE_TYPE } from "../contracts/clearance-fee.service";
|
|
||||||
import { FirstMileService } from "../first-mile/first-mile.service";
|
import { FirstMileService } from "../first-mile/first-mile.service";
|
||||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||||
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
||||||
@@ -121,8 +120,7 @@ export class BookingInvoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expire the booking's currently-open invoices (freight PREPAID and the
|
* Expire the booking's currently-open freight (PREPAID) invoice when the booking is
|
||||||
* per-shipment clearance fee) when the booking is
|
|
||||||
* cancelled or rejected — the counterpart to the pay-window-expiry path
|
* cancelled or rejected — the counterpart to the pay-window-expiry path
|
||||||
* (which also calls {@link BillingService.expirePayable}). Stops a terminated
|
* (which also calls {@link BillingService.expirePayable}). Stops a terminated
|
||||||
* booking from leaving a payable invoice open. No-op when the booking has no
|
* booking from leaving a payable invoice open. No-op when the booking has no
|
||||||
@@ -133,15 +131,6 @@ export class BookingInvoiceService {
|
|||||||
bookingId: string,
|
bookingId: string,
|
||||||
manager?: EntityManager,
|
manager?: EntityManager,
|
||||||
): Promise<Invoice | null> {
|
): Promise<Invoice | null> {
|
||||||
// The per-shipment clearance fee (GENERAL contracts) bills this same booking
|
|
||||||
// id under its own source/type — retire it alongside the freight invoice, or
|
|
||||||
// a cancelled shipment keeps a payable clearance invoice open.
|
|
||||||
await this.billing.expirePayable(
|
|
||||||
Freight.InvoiceSource.Clearance,
|
|
||||||
bookingId,
|
|
||||||
CLEARANCE_BOOKING_INVOICE_TYPE,
|
|
||||||
manager,
|
|
||||||
);
|
|
||||||
return this.billing.expirePayable(
|
return this.billing.expirePayable(
|
||||||
Freight.InvoiceSource.Booking,
|
Freight.InvoiceSource.Booking,
|
||||||
bookingId,
|
bookingId,
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
ratesService as never,
|
ratesService as never,
|
||||||
exchangeService as never,
|
exchangeService as never,
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -240,3 +241,235 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
expect(result.blocked[0]).toContain('rate is configured');
|
expect(result.blocked[0]).toContain('rate is configured');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('BookingPricingService — customs clearance fee billed on the booking price', () => {
|
||||||
|
const DJ = 'yard-dj';
|
||||||
|
|
||||||
|
const containerFee20: Rate = {
|
||||||
|
id: 'rate-cc-20',
|
||||||
|
rateType: 'CUSTOMS_CLEARANCE',
|
||||||
|
trigger: 'CUSTOMS_CLEARANCE',
|
||||||
|
currency: 'USD',
|
||||||
|
rateValue: 100,
|
||||||
|
rateUnit: 'PER_CONTAINER',
|
||||||
|
status: 'LIVE',
|
||||||
|
containerTypeId: 'ct-20',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
originYardId: DJ,
|
||||||
|
destinationYardId: DIRE,
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
const bulkFeePerTon: Rate = {
|
||||||
|
...containerFee20,
|
||||||
|
id: 'rate-cc-bulk',
|
||||||
|
rateValue: 5,
|
||||||
|
rateUnit: 'PER_TON',
|
||||||
|
containerTypeId: null,
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
const emptyEval = {
|
||||||
|
priorityScore: 0,
|
||||||
|
appliedModifiers: [],
|
||||||
|
containerWeightResults: [],
|
||||||
|
warnings: [],
|
||||||
|
hardBlocked: [],
|
||||||
|
requiresDirectorApproval: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeService = (opts: {
|
||||||
|
snapshots?: unknown[];
|
||||||
|
liveRates?: Rate[];
|
||||||
|
wagonCapacity?: number;
|
||||||
|
}) =>
|
||||||
|
new BookingPricingService(
|
||||||
|
{
|
||||||
|
calculateWagonCount: jest.fn().mockResolvedValue(0),
|
||||||
|
findContractRateSnapshots: jest.fn().mockResolvedValue(opts.snapshots ?? []),
|
||||||
|
} as never,
|
||||||
|
{ evaluate: jest.fn().mockResolvedValue(emptyEval) } as never,
|
||||||
|
{
|
||||||
|
findById: jest.fn(async (id: string) => ({
|
||||||
|
id,
|
||||||
|
sizeFt: id === 'ct-40' ? 40 : 20,
|
||||||
|
isReefer: false,
|
||||||
|
code: id === 'ct-40' ? 'C40' : 'C20',
|
||||||
|
})),
|
||||||
|
} as never,
|
||||||
|
{ findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never,
|
||||||
|
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
||||||
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{
|
||||||
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
wagonTypes:
|
||||||
|
opts.wagonCapacity !== undefined
|
||||||
|
? [{ capacityTons: opts.wagonCapacity }]
|
||||||
|
: [],
|
||||||
|
}),
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const containerBooking = (overrides: Record<string, unknown> = {}) =>
|
||||||
|
({
|
||||||
|
id: 'b-cc',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
originYardId: DJ,
|
||||||
|
destinationYardId: DIRE,
|
||||||
|
bookingContainers: [
|
||||||
|
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, wagonsRequired: 2 },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
}) as unknown as Booking;
|
||||||
|
|
||||||
|
const bulkBooking = (overrides: Record<string, unknown> = {}) =>
|
||||||
|
({
|
||||||
|
id: 'b-cc-bulk',
|
||||||
|
freightType: 'BULK',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
cargoTypeId: 'cargo-1',
|
||||||
|
cargoTotalWeightVgm: 120,
|
||||||
|
originYardId: DJ,
|
||||||
|
destinationYardId: DIRE,
|
||||||
|
bookingContainers: [],
|
||||||
|
...overrides,
|
||||||
|
}) as unknown as Booking;
|
||||||
|
|
||||||
|
it('bills a container booking per box at its own container type fee', async () => {
|
||||||
|
const service = makeService({ liveRates: [containerFee20] });
|
||||||
|
const result = await service.computePriceForBooking(containerBooking());
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
|
||||||
|
expect(line).toBeDefined();
|
||||||
|
expect(line!.unit).toBe('PER_CONTAINER');
|
||||||
|
expect(line!.quantity).toBe(4);
|
||||||
|
expect(line!.amount).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bills a PER_WAGON container fee on the wagons the boxes occupy (two 20ft share one)', async () => {
|
||||||
|
const service = makeService({
|
||||||
|
liveRates: [{ ...containerFee20, rateUnit: 'PER_WAGON' } as Rate],
|
||||||
|
});
|
||||||
|
const result = await service.computePriceForBooking(containerBooking());
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
|
||||||
|
expect(line!.unit).toBe('PER_WAGON');
|
||||||
|
expect(line!.quantity).toBe(2);
|
||||||
|
expect(line!.amount).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hard-blocks a container type with no fee configured (never free clearance)', async () => {
|
||||||
|
const service = makeService({ liveRates: [bulkFeePerTon] });
|
||||||
|
const result = await service.computePriceForBooking(containerBooking());
|
||||||
|
|
||||||
|
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
|
||||||
|
expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bills a bulk booking per ton at the route bulk fee', async () => {
|
||||||
|
const service = makeService({ liveRates: [bulkFeePerTon] });
|
||||||
|
const result = await service.computePriceForBooking(bulkBooking());
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||||
|
expect(line!.unit).toBe('PER_TON');
|
||||||
|
expect(line!.quantity).toBe(120);
|
||||||
|
expect(line!.amount).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the fee scoped to the booking commodity wins over the catch-all', async () => {
|
||||||
|
const service = makeService({
|
||||||
|
liveRates: [
|
||||||
|
{ ...bulkFeePerTon, id: 'rate-cc-catchall', rateValue: 5 } as Rate,
|
||||||
|
{
|
||||||
|
...bulkFeePerTon,
|
||||||
|
id: 'rate-cc-sugar',
|
||||||
|
rateValue: 9,
|
||||||
|
cargoTypeId: 'cargo-1',
|
||||||
|
} as Rate,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = await service.computePriceForBooking(bulkBooking());
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||||
|
expect(line!.unitAmount).toBe(9); // commodity rate, not the 5 USD catch-all
|
||||||
|
expect(line!.amount).toBe(1080);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bills a PER_WAGON bulk fee on ceil(tons ÷ wagon capacity)', async () => {
|
||||||
|
const service = makeService({
|
||||||
|
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON', rateValue: 50 } as Rate],
|
||||||
|
wagonCapacity: 60,
|
||||||
|
});
|
||||||
|
const result = await service.computePriceForBooking(bulkBooking());
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||||
|
expect(line!.unit).toBe('PER_WAGON');
|
||||||
|
expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon
|
||||||
|
expect(line!.amount).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks a PER_WAGON bulk fee when no wagon capacity is configured', async () => {
|
||||||
|
const service = makeService({
|
||||||
|
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON' } as Rate],
|
||||||
|
});
|
||||||
|
const result = await service.computePriceForBooking(bulkBooking());
|
||||||
|
|
||||||
|
expect(result.hardBlocked.some((m) => m.includes('wagon'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the contract frozen per-size snapshot over the live rate', async () => {
|
||||||
|
const service = makeService({
|
||||||
|
liveRates: [containerFee20],
|
||||||
|
snapshots: [
|
||||||
|
{
|
||||||
|
rateCode: 'CUSTOMS_CLEARANCE_20FT',
|
||||||
|
unitPrice: 80,
|
||||||
|
currency: 'USD',
|
||||||
|
unitOfMeasure: 'per_container',
|
||||||
|
isClearance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = await service.computePriceForBooking(
|
||||||
|
containerBooking({ contractId: 'c-1' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
|
||||||
|
expect(line!.amount).toBe(320); // 4 × frozen 80, not live 100
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours a legacy FLAT snapshot once for the whole container booking', async () => {
|
||||||
|
const service = makeService({
|
||||||
|
liveRates: [],
|
||||||
|
snapshots: [
|
||||||
|
{
|
||||||
|
rateCode: 'CUSTOMS_CLEARANCE',
|
||||||
|
unitPrice: 500,
|
||||||
|
currency: 'USD',
|
||||||
|
unitOfMeasure: 'flat',
|
||||||
|
isClearance: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = await service.computePriceForBooking(
|
||||||
|
containerBooking({ contractId: 'c-legacy' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||||
|
expect(line!.unit).toBe('FLAT');
|
||||||
|
expect(line!.amount).toBe(500);
|
||||||
|
expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds no fee line when customs clearing is disabled', async () => {
|
||||||
|
const service = makeService({ liveRates: [containerFee20] });
|
||||||
|
const result = await service.computePriceForBooking(
|
||||||
|
containerBooking({ customsClearingEnabled: false }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
import { RatesService } from '../rule-engine/services/rates.service';
|
import { RatesService } from '../rule-engine/services/rates.service';
|
||||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||||
@@ -10,7 +11,10 @@ import {
|
|||||||
BookingEvaluationInput,
|
BookingEvaluationInput,
|
||||||
RuleEngineService,
|
RuleEngineService,
|
||||||
} from '../rule-engine/rule-engine.service';
|
} from '../rule-engine/rule-engine.service';
|
||||||
import { containersPerWagonForSize } from '../rule-engine/container-type.util';
|
import {
|
||||||
|
containersPerWagonForSize,
|
||||||
|
wagonsPerUnitForSize,
|
||||||
|
} from '../rule-engine/container-type.util';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { wagonRemainder } from './consolidation.service';
|
import { wagonRemainder } from './consolidation.service';
|
||||||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
@@ -76,6 +80,7 @@ export class BookingPricingService {
|
|||||||
private readonly ratesService: RatesService,
|
private readonly ratesService: RatesService,
|
||||||
private readonly exchangeService: ExchangeService,
|
private readonly exchangeService: ExchangeService,
|
||||||
private readonly containerValidationService: ContainerValidationService,
|
private readonly containerValidationService: ContainerValidationService,
|
||||||
|
private readonly cargoTypesService: CargoTypesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||||
@@ -165,8 +170,16 @@ export class BookingPricingService {
|
|||||||
const usdAmount = mod.calculatedAmount;
|
const usdAmount = mod.calculatedAmount;
|
||||||
|
|
||||||
const rate = rateById.get(mod.rateId);
|
const rate = rateById.get(mod.rateId);
|
||||||
const unit = rate?.rateUnit ?? 'FLAT';
|
// Derived/route-matched charges (import overweight, empty-container
|
||||||
const unitUsd = rate ? Number(rate.rateValue) : usdAmount;
|
// return) carry their own unit price + billing unit — bill and display
|
||||||
|
// those, not whatever the referenced rate row says.
|
||||||
|
const isDerived = mod.unitPriceUsd != null;
|
||||||
|
const unit = mod.billingUnit ?? rate?.rateUnit ?? 'FLAT';
|
||||||
|
const unitUsd = isDerived
|
||||||
|
? Number(mod.unitPriceUsd)
|
||||||
|
: rate
|
||||||
|
? Number(rate.rateValue)
|
||||||
|
: usdAmount;
|
||||||
// Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an
|
// Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an
|
||||||
// explicit trigger (e.g. overweight tons) wins when present; otherwise
|
// explicit trigger (e.g. overweight tons) wins when present; otherwise
|
||||||
// derive from total ÷ unit price (the live unit price — a count, not a
|
// derive from total ÷ unit price (the live unit price — a count, not a
|
||||||
@@ -182,11 +195,11 @@ export class BookingPricingService {
|
|||||||
|
|
||||||
// H15: bill the frozen contract surcharge rate (already in the booking
|
// H15: bill the frozen contract surcharge rate (already in the booking
|
||||||
// currency) when this code has a snapshot; else keep the live amount.
|
// currency) when this code has a snapshot; else keep the live amount.
|
||||||
const frozen = this.frozenRateByCode(
|
// Derived charges skip the snapshot — import overweight prices off the
|
||||||
frozenRates,
|
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
|
||||||
mod.surchargeCode,
|
const frozen = isDerived
|
||||||
paymentCurrency,
|
? null
|
||||||
);
|
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency);
|
||||||
const unitAmount = frozen
|
const unitAmount = frozen
|
||||||
? Number(frozen.unitPrice)
|
? Number(frozen.unitPrice)
|
||||||
: isEtbBooking
|
: isEtbBooking
|
||||||
@@ -215,6 +228,23 @@ export class BookingPricingService {
|
|||||||
if (rate) usedRatesMap.set(rate.id, rate);
|
if (rate) usedRatesMap.set(rate.id, rate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Customs clearance service fee (Path B) — billed HERE, on the booking
|
||||||
|
// invoice with the freight; no separate prepaid clearance invoice. Sold per
|
||||||
|
// cargo kind: container bookings bill each container type's own fee (per
|
||||||
|
// box or per wagon), bulk bookings the route's bulk fee (per ton or per
|
||||||
|
// wagon). Frozen contract snapshots win over live rates; a customs booking
|
||||||
|
// with nothing configured hard-blocks — clearance never ships for free.
|
||||||
|
const clearanceBlocked: string[] = [];
|
||||||
|
if (booking.customsClearingEnabled) {
|
||||||
|
const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates);
|
||||||
|
for (const line of clearance.lineItems) {
|
||||||
|
lineItems.push(line);
|
||||||
|
total += line.amount;
|
||||||
|
}
|
||||||
|
for (const rate of clearance.usedRates) usedRatesMap.set(rate.id, rate);
|
||||||
|
clearanceBlocked.push(...clearance.blocked);
|
||||||
|
}
|
||||||
|
|
||||||
// Overweight detail for the customer: map the engine's per-line results back
|
// Overweight detail for the customer: map the engine's per-line results back
|
||||||
// to the booking's container lines (same order) for code + weights. maxAllowed
|
// to the booking's container lines (same order) for code + weights. maxAllowed
|
||||||
// is derived from the line total minus the excess the engine computed.
|
// is derived from the line total minus the excess the engine computed.
|
||||||
@@ -252,7 +282,7 @@ export class BookingPricingService {
|
|||||||
appliedModifiers: ruleResult.appliedModifiers,
|
appliedModifiers: ruleResult.appliedModifiers,
|
||||||
priorityScore: ruleResult.priorityScore,
|
priorityScore: ruleResult.priorityScore,
|
||||||
warnings: [...ruleResult.warnings, ...baseWarnings],
|
warnings: [...ruleResult.warnings, ...baseWarnings],
|
||||||
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked],
|
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked, ...clearanceBlocked],
|
||||||
overweightLines,
|
overweightLines,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -313,6 +343,8 @@ export class BookingPricingService {
|
|||||||
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
|
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
|
||||||
reeferQuantity: Number(bc.reeferQuantity ?? 0),
|
reeferQuantity: Number(bc.reeferQuantity ?? 0),
|
||||||
returnQuantity: Number(bc.returnQuantity ?? 0),
|
returnQuantity: Number(bc.returnQuantity ?? 0),
|
||||||
|
// Wagon share per box — a PER_WAGON empty-return rate bills on it.
|
||||||
|
wagonsPerUnit: wagonsPerUnitForSize(ct.sizeFt),
|
||||||
},
|
},
|
||||||
perWagon: containersPerWagonForSize(ct.sizeFt),
|
perWagon: containersPerWagonForSize(ct.sizeFt),
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
@@ -330,6 +362,13 @@ export class BookingPricingService {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
|
// Bulk wagon estimate for PER_WAGON kind-scoped surcharges (lashing).
|
||||||
|
// Deliberately NOT totalWagons — that would shift wagon-count priority
|
||||||
|
// scoring for bulk bookings.
|
||||||
|
const bulkWagons =
|
||||||
|
booking.freightType === 'BULK'
|
||||||
|
? ((await this.bulkWagonCount(booking)) ?? 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
// Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever
|
// Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever
|
||||||
// a container type leaves a wagon partially filled. Aggregate by type first —
|
// a container type leaves a wagon partially filled. Aggregate by type first —
|
||||||
@@ -369,6 +408,8 @@ export class BookingPricingService {
|
|||||||
isGovernment: booking.isGovernment,
|
isGovernment: booking.isGovernment,
|
||||||
allowConsolidation,
|
allowConsolidation,
|
||||||
shippingLineId: booking.shippingLineId,
|
shippingLineId: booking.shippingLineId,
|
||||||
|
originYardId: booking.originYardId,
|
||||||
|
destinationYardId: booking.destinationYardId,
|
||||||
totalWagons,
|
totalWagons,
|
||||||
// Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge).
|
// Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge).
|
||||||
// Container freight carries 0 here — its surcharges scale by container count.
|
// Container freight carries 0 here — its surcharges scale by container count.
|
||||||
@@ -376,6 +417,7 @@ export class BookingPricingService {
|
|||||||
booking.freightType === 'BULK'
|
booking.freightType === 'BULK'
|
||||||
? Number(booking.cargoTotalWeightVgm ?? 0)
|
? Number(booking.cargoTotalWeightVgm ?? 0)
|
||||||
: 0,
|
: 0,
|
||||||
|
bulkWagons,
|
||||||
containers,
|
containers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -884,6 +926,191 @@ export class BookingPricingService {
|
|||||||
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
|
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customs clearance service fee lines for a customs booking (Path B), billed
|
||||||
|
* with the freight. Container bookings bill each container line at its own
|
||||||
|
* container type's fee — PER_CONTAINER × boxes or PER_WAGON × the wagons the
|
||||||
|
* line occupies (two 20ft share one). Bulk bookings bill the route's type-less
|
||||||
|
* fee — PER_TON × tonnage or PER_WAGON × wagons the bulk occupies. Frozen
|
||||||
|
* contract snapshots (CUSTOMS_CLEARANCE_20FT / _40FT / CUSTOMS_CLEARANCE)
|
||||||
|
* win over live rates; contracts frozen before the per-kind model carry one
|
||||||
|
* FLAT CUSTOMS_CLEARANCE snapshot, honoured once for the whole booking.
|
||||||
|
*/
|
||||||
|
private async customsClearanceLines(
|
||||||
|
booking: Booking,
|
||||||
|
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||||
|
liveRates: Rate[],
|
||||||
|
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; blocked: string[] }> {
|
||||||
|
const lineItems: PriceLineItemDto[] = [];
|
||||||
|
const usedRates: Rate[] = [];
|
||||||
|
const blocked: string[] = [];
|
||||||
|
const currency = booking.paymentCurrency;
|
||||||
|
const isEtb = currency === 'ETB';
|
||||||
|
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||||
|
const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd);
|
||||||
|
|
||||||
|
const onLeg = liveRates.filter(
|
||||||
|
(r) =>
|
||||||
|
r.rateType === 'CUSTOMS_CLEARANCE' &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
r.tradeDirection === booking.tradeDirection &&
|
||||||
|
r.originYardId === booking.originYardId &&
|
||||||
|
r.destinationYardId === booking.destinationYardId,
|
||||||
|
);
|
||||||
|
const missingRateMessage = (scope: string): string =>
|
||||||
|
`No customs clearance service fee is configured for ${scope} on this ` +
|
||||||
|
'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.';
|
||||||
|
|
||||||
|
if (booking.freightType === 'CONTAINER') {
|
||||||
|
// Legacy short-circuit: an old contract froze one flat fee — bill it once.
|
||||||
|
const hasPerSizeSnapshot =
|
||||||
|
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
|
||||||
|
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
|
||||||
|
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
|
||||||
|
if (legacyFlat && !hasPerSizeSnapshot) {
|
||||||
|
const amount = Number(legacyFlat.unitPrice);
|
||||||
|
if (amount > 0) {
|
||||||
|
lineItems.push({
|
||||||
|
code: 'CUSTOMS_CLEARANCE',
|
||||||
|
description: 'Customs clearance service',
|
||||||
|
amount,
|
||||||
|
unitAmount: amount,
|
||||||
|
unit: 'FLAT',
|
||||||
|
quantity: 1,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { lineItems, usedRates, blocked };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bc of booking.bookingContainers ?? []) {
|
||||||
|
if (!bc.containerTypeId) continue;
|
||||||
|
const qty = Number(bc.quantity || 0);
|
||||||
|
if (!(qty > 0)) continue;
|
||||||
|
let sizeFt = 0;
|
||||||
|
try {
|
||||||
|
sizeFt =
|
||||||
|
Number((await this.containerTypesService.findById(bc.containerTypeId)).sizeFt) || 0;
|
||||||
|
} catch {
|
||||||
|
// unknown type — falls through to the live per-type lookup below
|
||||||
|
}
|
||||||
|
const frozen = sizeFt
|
||||||
|
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency)
|
||||||
|
: null;
|
||||||
|
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
||||||
|
if (!frozen && !live) {
|
||||||
|
blocked.push(missingRateMessage(`${sizeFt || '?'}ft containers`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const unit = frozen
|
||||||
|
? this.rateUnitFromSnapshot(frozen.unitOfMeasure)
|
||||||
|
: live!.rateUnit;
|
||||||
|
const unitAmount = frozen
|
||||||
|
? Number(frozen.unitPrice)
|
||||||
|
: convert(Number(live!.rateValue));
|
||||||
|
const billedQty =
|
||||||
|
unit === 'PER_WAGON' ? Math.ceil(qty * wagonsPerUnitForSize(sizeFt)) : qty;
|
||||||
|
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
|
||||||
|
if (!(amount > 0)) continue;
|
||||||
|
lineItems.push({
|
||||||
|
code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE',
|
||||||
|
description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`,
|
||||||
|
amount,
|
||||||
|
unitAmount,
|
||||||
|
unit,
|
||||||
|
quantity: unit === 'FLAT' ? 1 : billedQty,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
if (live && !frozen) usedRates.push(live);
|
||||||
|
}
|
||||||
|
return { lineItems, usedRates, blocked };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk — one fee for the whole booking. The bulk snapshot and the legacy
|
||||||
|
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
||||||
|
// Live lookup: the rate scoped to the booking's commodity wins; a
|
||||||
|
// commodity-less rate (legacy) is the catch-all fallback.
|
||||||
|
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
|
||||||
|
const live =
|
||||||
|
(booking.cargoTypeId
|
||||||
|
? onLeg.find(
|
||||||
|
(r) => !r.containerTypeId && r.cargoTypeId === booking.cargoTypeId,
|
||||||
|
)
|
||||||
|
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
|
||||||
|
if (!frozen && !live) {
|
||||||
|
blocked.push(missingRateMessage('bulk cargo'));
|
||||||
|
return { lineItems, usedRates, blocked };
|
||||||
|
}
|
||||||
|
const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit;
|
||||||
|
const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue));
|
||||||
|
let billedQty = 1;
|
||||||
|
if (unit === 'PER_TON') {
|
||||||
|
billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0));
|
||||||
|
} else if (unit === 'PER_WAGON') {
|
||||||
|
const wagons = await this.bulkWagonCount(booking);
|
||||||
|
if (wagons == null) {
|
||||||
|
blocked.push(
|
||||||
|
'The bulk customs clearance fee is per wagon, but this cargo type has ' +
|
||||||
|
'no wagon type with a capacity configured — the wagon count cannot ' +
|
||||||
|
'be derived. Ask EDR to configure the cargo type’s wagon types.',
|
||||||
|
);
|
||||||
|
return { lineItems, usedRates, blocked };
|
||||||
|
}
|
||||||
|
billedQty = wagons;
|
||||||
|
}
|
||||||
|
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
|
||||||
|
if (amount > 0) {
|
||||||
|
lineItems.push({
|
||||||
|
code: 'CUSTOMS_CLEARANCE',
|
||||||
|
description: 'Customs clearance service (bulk)',
|
||||||
|
amount,
|
||||||
|
unitAmount,
|
||||||
|
unit,
|
||||||
|
quantity: unit === 'FLAT' ? 1 : billedQty,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
if (live && !frozen) usedRates.push(live);
|
||||||
|
}
|
||||||
|
return { lineItems, usedRates, blocked };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot unit-of-measure → the rate unit the billing math applies. */
|
||||||
|
private rateUnitFromSnapshot(unitOfMeasure: string): string {
|
||||||
|
switch (unitOfMeasure) {
|
||||||
|
case 'per_wagon':
|
||||||
|
return 'PER_WAGON';
|
||||||
|
case 'per_ton':
|
||||||
|
return 'PER_TON';
|
||||||
|
case 'per_container':
|
||||||
|
return 'PER_CONTAINER';
|
||||||
|
default:
|
||||||
|
return 'FLAT';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wagons a bulk booking occupies — ceil(tons ÷ rated capacity), using the
|
||||||
|
* largest-capacity wagon type its cargo type allows. Null when the chain is
|
||||||
|
* unconfigured (no cargo type, no wagon types, no capacity).
|
||||||
|
* ponytail: pricing-time estimate off the biggest allowed wagon; scheduling
|
||||||
|
* may stock a smaller type and use more wagons.
|
||||||
|
*/
|
||||||
|
private async bulkWagonCount(booking: Booking): Promise<number | null> {
|
||||||
|
const tons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||||
|
if (!(tons > 0) || !booking.cargoTypeId) return null;
|
||||||
|
try {
|
||||||
|
const cargo = await this.cargoTypesService.findById(booking.cargoTypeId);
|
||||||
|
const capacity = Math.max(
|
||||||
|
0,
|
||||||
|
...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0),
|
||||||
|
);
|
||||||
|
if (!(capacity > 0)) return null;
|
||||||
|
return Math.max(1, Math.ceil(tons / capacity));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private lineItemsSignature(items: PriceLineItemDto[]): string {
|
private lineItemsSignature(items: PriceLineItemDto[]): string {
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
[...items]
|
[...items]
|
||||||
|
|||||||
@@ -605,11 +605,6 @@ export class BookingTransitionService {
|
|||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
if (booking.status === "AWAITING_CLEARANCE_PAYMENT") {
|
|
||||||
throw new ConflictException(
|
|
||||||
"The customs clearance service fee for this shipment has not been paid yet — pay it from the portal to unlock document upload.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assertBookingStatus(booking, [
|
assertBookingStatus(booking, [
|
||||||
"AWAITING_DOCUMENTS",
|
"AWAITING_DOCUMENTS",
|
||||||
"DOCUMENTS_UNDER_REVIEW",
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
|
|||||||
@@ -252,8 +252,11 @@ export class BookingsController {
|
|||||||
return this.bookingsService.findAll(filter, companyId);
|
return this.bookingsService.findAll(filter, companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Powers the customer-detail bookings tab, so `customers:view` reaches it too
|
||||||
|
// — otherwise a staffer granted only the customer permission gets a page whose
|
||||||
|
// tabs 403 individually.
|
||||||
@Get("by-company/:companyId/customer-view")
|
@Get("by-company/:companyId/customer-view")
|
||||||
@BookingView()
|
@BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.bookings.view])
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List bookings for a company (customer-view shape, backoffice)",
|
summary: "List bookings for a company (customer-view shape, backoffice)",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -442,6 +442,8 @@ export class BookingsService {
|
|||||||
isReefer?: boolean;
|
isReefer?: boolean;
|
||||||
isGovernment?: boolean;
|
isGovernment?: boolean;
|
||||||
shippingLineId?: string | null;
|
shippingLineId?: string | null;
|
||||||
|
originYardId?: string | null;
|
||||||
|
destinationYardId?: string | null;
|
||||||
bulkTons?: number;
|
bulkTons?: number;
|
||||||
containers: CreateBookingContainerDto[];
|
containers: CreateBookingContainerDto[];
|
||||||
}): Promise<BookingEvaluationInput> {
|
}): Promise<BookingEvaluationInput> {
|
||||||
@@ -487,6 +489,8 @@ export class BookingsService {
|
|||||||
isGovernment: dto.isGovernment ?? false,
|
isGovernment: dto.isGovernment ?? false,
|
||||||
allowConsolidation,
|
allowConsolidation,
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
|
originYardId: dto.originYardId ?? null,
|
||||||
|
destinationYardId: dto.destinationYardId ?? null,
|
||||||
totalWagons,
|
totalWagons,
|
||||||
bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0,
|
bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0,
|
||||||
containers,
|
containers,
|
||||||
@@ -808,6 +812,8 @@ export class BookingsService {
|
|||||||
isReefer: dto.isReefer,
|
isReefer: dto.isReefer,
|
||||||
isGovernment,
|
isGovernment,
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
|
originYardId: dto.originYardId,
|
||||||
|
destinationYardId: dto.destinationYardId,
|
||||||
bulkTons: dto.cargoTotalWeightVgm,
|
bulkTons: dto.cargoTotalWeightVgm,
|
||||||
containers,
|
containers,
|
||||||
});
|
});
|
||||||
@@ -1018,6 +1024,8 @@ export class BookingsService {
|
|||||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||||
isReefer: dto.isReefer ?? existing.isReefer,
|
isReefer: dto.isReefer ?? existing.isReefer,
|
||||||
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
||||||
|
originYardId: dto.originYardId ?? existing.originYardId,
|
||||||
|
destinationYardId: dto.destinationYardId ?? existing.destinationYardId,
|
||||||
bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0),
|
bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0),
|
||||||
containers,
|
containers,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export const BOOKING_STATUSES = [
|
|||||||
'CONTRACT_ACTIVE',
|
'CONTRACT_ACTIVE',
|
||||||
'CONTRACT_CLOSED',
|
'CONTRACT_CLOSED',
|
||||||
// Post counter-sign document-clearance gate (GL workflow).
|
// Post counter-sign document-clearance gate (GL workflow).
|
||||||
'AWAITING_CLEARANCE_PAYMENT', // clearance fee invoiced, unpaid — docs locked
|
|
||||||
'AWAITING_DOCUMENTS',
|
'AWAITING_DOCUMENTS',
|
||||||
'DOCUMENTS_UNDER_REVIEW',
|
'DOCUMENTS_UNDER_REVIEW',
|
||||||
'CLEARANCE_READY',
|
'CLEARANCE_READY',
|
||||||
@@ -521,10 +520,6 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
|
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
|
||||||
clearanceCurrentPhase?: string | null;
|
clearanceCurrentPhase?: string | null;
|
||||||
|
|
||||||
/** When the prepaid customs clearance service fee settled (GENERAL + customs). */
|
|
||||||
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
|
|
||||||
clearanceFeePaidAt?: Date | null;
|
|
||||||
|
|
||||||
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
||||||
dutyRequired?: boolean | null;
|
dutyRequired?: boolean | null;
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,22 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
|
UseGuards,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||||
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
||||||
import { CurrentUser } from "@edr/api-common";
|
import { CurrentUser } from "@edr/api-common";
|
||||||
import { FreightAdmin } from "../../common/booking-guards";
|
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||||
|
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
|
||||||
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
|
import {
|
||||||
|
assertFreightPermission,
|
||||||
|
hasFreightPermission,
|
||||||
|
} from "../../common/freight-permission.util";
|
||||||
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { FilesService } from "../files/files.service";
|
import { FilesService } from "../files/files.service";
|
||||||
import { CompaniesService } from "./companies.service";
|
import { CompaniesService } from "./companies.service";
|
||||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||||
@@ -59,6 +68,23 @@ interface CurrentIamUser {
|
|||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which permission a status write needs. Approving/reactivating is a different
|
||||||
|
* authority from suspending, but both arrive on the same route with the target
|
||||||
|
* in the BODY — a route-level guard can't tell them apart, so the handlers
|
||||||
|
* assert against this map instead.
|
||||||
|
*
|
||||||
|
* Keyed by string so it serves both `CompanyStatus` and `ProfileStatus`
|
||||||
|
* (a superset: it adds `rejected`).
|
||||||
|
*/
|
||||||
|
const STATUS_PERM: Record<string, string> = {
|
||||||
|
active: FREIGHT_PERMS.customers.verify,
|
||||||
|
pending: FREIGHT_PERMS.customers.verify,
|
||||||
|
rejected: FREIGHT_PERMS.customers.verify,
|
||||||
|
suspended: FREIGHT_PERMS.customers.deactivate,
|
||||||
|
blacklisted: FREIGHT_PERMS.customers.deactivate,
|
||||||
|
};
|
||||||
|
|
||||||
@ApiTags("Companies")
|
@ApiTags("Companies")
|
||||||
@Controller("companies")
|
@Controller("companies")
|
||||||
export class CompaniesController {
|
export class CompaniesController {
|
||||||
@@ -410,7 +436,7 @@ export class CompaniesController {
|
|||||||
|
|
||||||
// Used by backoffice
|
// Used by backoffice
|
||||||
@Post()
|
@Post()
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.create)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)",
|
"Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)",
|
||||||
@@ -421,12 +447,14 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("stats")
|
@Get("stats")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
@ApiOperation({ summary: "Company counts by status (KPI strip)" })
|
@ApiOperation({ summary: "Company counts by status (KPI strip)" })
|
||||||
async getStats(): Promise<CompanyStatsResponseDto> {
|
async getStats(): Promise<CompanyStatsResponseDto> {
|
||||||
return this.companiesService.getCompanyStats();
|
return this.companiesService.getCompanyStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
@ApiOperation({ summary: "List companies (paginated, filterable)" })
|
@ApiOperation({ summary: "List companies (paginated, filterable)" })
|
||||||
async findAll(
|
async findAll(
|
||||||
@Query() query: ListCompaniesQueryDto,
|
@Query() query: ListCompaniesQueryDto,
|
||||||
@@ -436,6 +464,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
@ApiOperation({ summary: "Get company by ID" })
|
@ApiOperation({ summary: "Get company by ID" })
|
||||||
async findById(
|
async findById(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -446,30 +475,77 @@ export class CompaniesController {
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits fields AND carries `status`, so it spans two authorities. The route
|
||||||
|
* guard is one-of (a status-only caller must get in); the asserts below are
|
||||||
|
* what actually authorize: touching `status` needs the permission
|
||||||
|
* {@link STATUS_PERM} maps it to, touching anything else needs
|
||||||
|
* `customers:update`. Both checks are required — without the second, a
|
||||||
|
* caller holding only `customers:deactivate` could rename the company.
|
||||||
|
*/
|
||||||
@Patch(":id")
|
@Patch(":id")
|
||||||
@FreightAdmin()
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.customers.update,
|
||||||
|
FREIGHT_PERMS.customers.verify,
|
||||||
|
FREIGHT_PERMS.customers.deactivate,
|
||||||
|
])
|
||||||
@ApiOperation({ summary: "Update a company" })
|
@ApiOperation({ summary: "Update a company" })
|
||||||
async update(
|
async update(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: UpdateCompanyDto,
|
@Body() dto: UpdateCompanyDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
): Promise<ResponseCompanyDto> {
|
): Promise<ResponseCompanyDto> {
|
||||||
|
const { status, ...fields } = dto;
|
||||||
|
if (status) assertFreightPermission(user, STATUS_PERM[status]);
|
||||||
|
if (Object.keys(fields).length > 0) {
|
||||||
|
assertFreightPermission(user, FREIGHT_PERMS.customers.update);
|
||||||
|
}
|
||||||
const company = await this.companiesService.updateCompany(id, dto);
|
const company = await this.companiesService.updateCompany(id, dto);
|
||||||
return new ResponseCompanyDto(company);
|
return new ResponseCompanyDto(company);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.deactivate)
|
||||||
@ApiOperation({ summary: "Soft-delete a company" })
|
@ApiOperation({ summary: "Soft-delete a company" })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
|
async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
|
||||||
await this.companiesService.deleteCompany(id);
|
await this.companiesService.deleteCompany(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dual-audience: staff read any customer's documents, and the portal reads
|
||||||
|
* its OWN during onboarding (`companiesService.getDocuments`). So the route
|
||||||
|
* is authenticated-only and the split happens here — same shape as
|
||||||
|
* `GET /contracts/:id`. Gating it on a staff permission alone would 403 every
|
||||||
|
* customer on their own documents.
|
||||||
|
*
|
||||||
|
* The staff arm is one-of because two pages consume it: the customer detail
|
||||||
|
* page (`customers:view`) and the contract-request detail page, whose route
|
||||||
|
* is gated on `contracts:view` — a contract reviewer without the customer
|
||||||
|
* permission still needs the applicant's documents.
|
||||||
|
*/
|
||||||
@Get(":companyId/documents")
|
@Get(":companyId/documents")
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
@ApiOperation({ summary: "List documents uploaded for a company" })
|
@ApiOperation({ summary: "List documents uploaded for a company" })
|
||||||
async listDocuments(
|
async listDocuments(
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
|
const isStaff = [
|
||||||
|
FREIGHT_PERMS.customers.view,
|
||||||
|
FREIGHT_PERMS.contracts.view,
|
||||||
|
FREIGHT_PERMS.bookings.view,
|
||||||
|
].some((p) => hasFreightPermission(user, p));
|
||||||
|
|
||||||
|
if (!isStaff) {
|
||||||
|
const { company } = await this.companiesService.getCompanyInfoByUserId(
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
// Hidden as NotFound rather than Forbidden so company ids can't be probed.
|
||||||
|
if (company.id !== companyId) {
|
||||||
|
throw new NotFoundException(`Company ${companyId} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
const files = await this.filesService.findByResource(companyId, "companies");
|
const files = await this.filesService.findByResource(companyId, "companies");
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
files.map(async (f) => ({
|
files.map(async (f) => ({
|
||||||
@@ -490,7 +566,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("documents/:fileId/request-change")
|
@Post("documents/:fileId/request-change")
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Ask the customer to correct one uploaded document",
|
summary: "Ask the customer to correct one uploaded document",
|
||||||
description:
|
description:
|
||||||
@@ -532,14 +608,23 @@ export class CompaniesController {
|
|||||||
return this.companiesService.uploadCompanyDocuments(companyId, files, user.id);
|
return this.companiesService.uploadCompanyDocuments(companyId, files, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve / reject / suspend / blacklist all arrive here with the target in
|
||||||
|
* the body, so authorization is per-status via {@link STATUS_PERM} rather
|
||||||
|
* than on the route (the guard is only the one-of gate).
|
||||||
|
*/
|
||||||
@Patch("company-profiles/:profileId/status")
|
@Patch("company-profiles/:profileId/status")
|
||||||
@FreightAdmin()
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.customers.verify,
|
||||||
|
FREIGHT_PERMS.customers.deactivate,
|
||||||
|
])
|
||||||
@ApiOperation({ summary: "Update a company profile's approval status" })
|
@ApiOperation({ summary: "Update a company profile's approval status" })
|
||||||
async updateCompanyProfileStatus(
|
async updateCompanyProfileStatus(
|
||||||
@CurrentUser() user: CurrentIamUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||||
@Body() dto: UpdateCompanyProfileStatusDto,
|
@Body() dto: UpdateCompanyProfileStatusDto,
|
||||||
): Promise<ResponseCompanyProfileDto> {
|
): Promise<ResponseCompanyProfileDto> {
|
||||||
|
assertFreightPermission(user, STATUS_PERM[dto.status]);
|
||||||
const profile = await this.companiesService.setCompanyProfileStatus(
|
const profile = await this.companiesService.setCompanyProfileStatus(
|
||||||
profileId,
|
profileId,
|
||||||
dto.status,
|
dto.status,
|
||||||
@@ -550,7 +635,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(":companyId/change-requests")
|
@Get(":companyId/change-requests")
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
@ApiOperation({ summary: "List a company's profile change requests" })
|
@ApiOperation({ summary: "List a company's profile change requests" })
|
||||||
async listChangeRequests(
|
async listChangeRequests(
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
@@ -560,7 +645,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("change-requests/:id/approve")
|
@Post("change-requests/:id/approve")
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Approve a pending profile change request (applies the changes)",
|
summary: "Approve a pending profile change request (applies the changes)",
|
||||||
})
|
})
|
||||||
@@ -576,7 +661,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("change-requests/:id/reject")
|
@Post("change-requests/:id/reject")
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Reject a pending profile change request with a note",
|
summary: "Reject a pending profile change request with a note",
|
||||||
})
|
})
|
||||||
@@ -594,7 +679,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(":companyId/profiles")
|
@Post(":companyId/profiles")
|
||||||
@FreightAdmin()
|
@BookingStaff(FREIGHT_PERMS.customers.update)
|
||||||
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||||
async createProfile(
|
async createProfile(
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
@@ -608,6 +693,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(":companyId/profiles")
|
@Get(":companyId/profiles")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
@ApiOperation({ summary: "List profiles for a company" })
|
@ApiOperation({ summary: "List profiles for a company" })
|
||||||
async listProfiles(
|
async listProfiles(
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
@@ -618,6 +704,7 @@ export class CompaniesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("profile/user/:userId")
|
@Get("profile/user/:userId")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||||
@ApiOperation({ summary: "Get profile by IAM user ID" })
|
@ApiOperation({ summary: "Get profile by IAM user ID" })
|
||||||
async findProfileByUser(
|
async findProfileByUser(
|
||||||
@Param("userId", ParseUUIDPipe) userId: string,
|
@Param("userId", ParseUUIDPipe) userId: string,
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
import { Injectable, Logger, UnprocessableEntityException } from '@nestjs/common';
|
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
|
||||||
import { Freight } from '@edr/types';
|
|
||||||
|
|
||||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
|
||||||
import { Invoice } from '../billing/entities/invoice.entity';
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
|
||||||
import { ContractPricingBreakdown } from './contract-pricing.service';
|
|
||||||
import { ContractNotifierService } from './contract-notifier.service';
|
|
||||||
import { ContractsRepository } from './contracts.repository';
|
|
||||||
import { Contract } from './entities/contract.entity';
|
|
||||||
|
|
||||||
/** Invoice `type` for the contract-level fee (Path B ONE_TIME, after counter-sign). */
|
|
||||||
export const CLEARANCE_CONTRACT_INVOICE_TYPE = 'CLEARANCE_CONTRACT';
|
|
||||||
/** Invoice `type` for the per-shipment fee (Path B GENERAL, at shipment request). */
|
|
||||||
export const CLEARANCE_BOOKING_INVOICE_TYPE = 'CLEARANCE_BOOKING';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The prepaid customs clearance service fee (Path B) — the GL service charge,
|
|
||||||
* separate from both freight (booking invoice) and duty/tax (paid offline).
|
|
||||||
* Issued as its own `clearance`-source invoice and paid BEFORE the clearance
|
|
||||||
* document step opens and before GL touches the file:
|
|
||||||
* - ONE_TIME: once per contract, at staff counter-sign
|
|
||||||
* (AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_CLEARANCE_DOCUMENTS);
|
|
||||||
* - GENERAL: once per shipment request, on the initiated booking instance
|
|
||||||
* (booking AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_DOCUMENTS).
|
|
||||||
* The fee amount is the frozen CUSTOMS_CLEARANCE contract rate snapshot, so
|
|
||||||
* customers pay what their contract shows, not the live rate of the day.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class ClearanceFeeService {
|
|
||||||
private readonly logger = new Logger(ClearanceFeeService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly billing: BillingService,
|
|
||||||
private readonly contractsRepository: ContractsRepository,
|
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
|
||||||
private readonly notifier: ContractNotifierService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** The frozen flat fee for a contract; falls back to the pricing breakdown. */
|
|
||||||
private async feeAmountOrNull(
|
|
||||||
contract: Contract,
|
|
||||||
): Promise<{ amount: number; currency: string } | null> {
|
|
||||||
const snapshots = await this.contractsRepository.findRateSnapshots(contract.id);
|
|
||||||
const snapshot = snapshots.find(
|
|
||||||
(s) => s.isClearance || s.rateCode === 'CUSTOMS_CLEARANCE',
|
|
||||||
);
|
|
||||||
if (snapshot && Number(snapshot.unitPrice) > 0) {
|
|
||||||
return { amount: Number(snapshot.unitPrice), currency: snapshot.currency };
|
|
||||||
}
|
|
||||||
const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null;
|
|
||||||
const line = breakdown?.lineItems?.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
|
||||||
if (line && Number(line.unitPrice) > 0) {
|
|
||||||
return { amount: Number(line.unitPrice), currency: breakdown!.currency };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async feeAmount(
|
|
||||||
contract: Contract,
|
|
||||||
): Promise<{ amount: number; currency: string }> {
|
|
||||||
const fee = await this.feeAmountOrNull(contract);
|
|
||||||
if (!fee) {
|
|
||||||
throw new UnprocessableEntityException(
|
|
||||||
`Contract ${contract.reference} has no frozen customs clearance fee — regenerate its price with a live CUSTOMS_CLEARANCE rate.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fee;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the payment gate applies. Skipped for government/unlinked
|
|
||||||
* contracts (no company to bill — invoices require one, same rule the
|
|
||||||
* booking invoice applies) and for legacy customs contracts frozen before
|
|
||||||
* the fee existed (no CUSTOMS_CLEARANCE snapshot to bill from) — both keep
|
|
||||||
* the pre-fee flow instead of dead-ending.
|
|
||||||
*/
|
|
||||||
async gateApplies(contract: Contract): Promise<boolean> {
|
|
||||||
// Customs disabled → the prepay gate genuinely does not apply.
|
|
||||||
if (!contract.customsClearingEnabled) return false;
|
|
||||||
// No company to bill (government / unlinked) → the gate cannot raise an
|
|
||||||
// invoice, so it stays out of the flow (same rule the booking invoice uses).
|
|
||||||
if (!contract.companyId) return false;
|
|
||||||
// M26: customs IS enabled and billable. A missing frozen fee line must NOT
|
|
||||||
// silently waive the gate — that ships clearance for free. Hard-fail exactly
|
|
||||||
// as price generation does when no CUSTOMS_CLEARANCE rate is configured, so a
|
|
||||||
// missing fee blocks counter-sign / shipment instead of bypassing payment.
|
|
||||||
if ((await this.feeAmountOrNull(contract)) === null) {
|
|
||||||
throw new UnprocessableEntityException(
|
|
||||||
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Issue (idempotently) the ONE_TIME contract-level fee invoice. */
|
|
||||||
async issueForContract(contract: Contract): Promise<Invoice> {
|
|
||||||
const existing = await this.billing.findPayable(
|
|
||||||
Freight.InvoiceSource.Clearance,
|
|
||||||
contract.id,
|
|
||||||
CLEARANCE_CONTRACT_INVOICE_TYPE,
|
|
||||||
);
|
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
const { amount, currency } = await this.feeAmount(contract);
|
|
||||||
const invoice = await this.billing.generateInvoice({
|
|
||||||
source: Freight.InvoiceSource.Clearance,
|
|
||||||
sourceId: contract.id,
|
|
||||||
type: CLEARANCE_CONTRACT_INVOICE_TYPE,
|
|
||||||
companyId: contract.companyId!,
|
|
||||||
companyProfileId: contract.companyProfileId!,
|
|
||||||
currency,
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
chargeType: 'CUSTOMS_CLEARANCE',
|
|
||||||
description: `Customs clearance service fee — contract ${contract.reference}`,
|
|
||||||
quantity: 1,
|
|
||||||
unitRate: amount,
|
|
||||||
amount,
|
|
||||||
currency,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
status: Freight.InvoiceStatus.Pending,
|
|
||||||
});
|
|
||||||
this.notifier.clearanceFeeDue(contract, amount, currency);
|
|
||||||
return invoice;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Issue (idempotently) the GENERAL per-shipment fee invoice on the booking. */
|
|
||||||
async issueForBooking(booking: Booking, contract: Contract): Promise<Invoice> {
|
|
||||||
const existing = await this.billing.findPayable(
|
|
||||||
Freight.InvoiceSource.Clearance,
|
|
||||||
booking.id,
|
|
||||||
CLEARANCE_BOOKING_INVOICE_TYPE,
|
|
||||||
);
|
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
const { amount, currency } = await this.feeAmount(contract);
|
|
||||||
const invoice = await this.billing.generateInvoice({
|
|
||||||
source: Freight.InvoiceSource.Clearance,
|
|
||||||
sourceId: booking.id,
|
|
||||||
type: CLEARANCE_BOOKING_INVOICE_TYPE,
|
|
||||||
companyId: booking.companyId ?? contract.companyId!,
|
|
||||||
companyProfileId: booking.companyProfileId ?? contract.companyProfileId!,
|
|
||||||
currency,
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
chargeType: 'CUSTOMS_CLEARANCE',
|
|
||||||
description: `Customs clearance service fee — shipment ${booking.reference}`,
|
|
||||||
quantity: 1,
|
|
||||||
unitRate: amount,
|
|
||||||
amount,
|
|
||||||
currency,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
status: Freight.InvoiceStatus.Pending,
|
|
||||||
});
|
|
||||||
this.notifier.clearanceFeeDue(contract, amount, currency, booking.reference);
|
|
||||||
return invoice;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retire (idempotently) the unpaid contract-level fee invoice when the
|
|
||||||
* contract reaches a terminal state — a dead contract must not leave a
|
|
||||||
* payable clearance invoice open for the customer to settle. No-op when the
|
|
||||||
* fee was already paid or never invoiced (mirrors the booking cancel path,
|
|
||||||
* {@link BillingService.expirePayable}).
|
|
||||||
*/
|
|
||||||
async expireForContract(contractId: string): Promise<Invoice | null> {
|
|
||||||
return this.billing.expirePayable(
|
|
||||||
Freight.InvoiceSource.Clearance,
|
|
||||||
contractId,
|
|
||||||
CLEARANCE_CONTRACT_INVOICE_TYPE,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Settlement branch point for `clearance`-source invoices: unlock the
|
|
||||||
* document-upload step the fee was gating. Idempotent — a replayed event on
|
|
||||||
* an already-advanced contract/booking is a no-op.
|
|
||||||
*/
|
|
||||||
@OnEvent('clearance.invoice.paid')
|
|
||||||
async onClearanceInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
|
||||||
this.logger.log(
|
|
||||||
`clearance.invoice.paid (${payload.type}) for ${payload.sourceId} from ${payload.invoiceId}`,
|
|
||||||
);
|
|
||||||
switch (payload.type) {
|
|
||||||
case CLEARANCE_CONTRACT_INVOICE_TYPE:
|
|
||||||
await this.advanceContract(payload.sourceId);
|
|
||||||
break;
|
|
||||||
case CLEARANCE_BOOKING_INVOICE_TYPE:
|
|
||||||
await this.advanceBooking(payload.sourceId);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
this.logger.warn(
|
|
||||||
`Unhandled clearance invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async advanceContract(contractId: string): Promise<void> {
|
|
||||||
const contract = await this.contractsRepository.findById(contractId);
|
|
||||||
if (!contract) {
|
|
||||||
this.logger.warn(`Cannot advance unknown contract ${contractId} on clearance fee payment.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (contract.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
|
|
||||||
|
|
||||||
await this.contractsRepository.update(contractId, {
|
|
||||||
status: 'AWAITING_CLEARANCE_DOCUMENTS',
|
|
||||||
clearanceStatus: 'AWAITING_DOCUMENTS',
|
|
||||||
clearanceFeePaidAt: new Date(),
|
|
||||||
} as never);
|
|
||||||
const updated = await this.contractsRepository.findByIdWithRelations(contractId);
|
|
||||||
if (updated) this.notifier.clearanceFeePaid(updated);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async advanceBooking(bookingId: string): Promise<void> {
|
|
||||||
const booking = await this.bookingsRepository.findById(bookingId);
|
|
||||||
if (!booking) {
|
|
||||||
this.logger.warn(`Cannot advance unknown booking ${bookingId} on clearance fee payment.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (booking.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
|
|
||||||
|
|
||||||
await this.bookingsRepository.update(bookingId, {
|
|
||||||
status: 'AWAITING_DOCUMENTS',
|
|
||||||
clearanceFeePaidAt: new Date(),
|
|
||||||
} as never);
|
|
||||||
if (booking.contractId) {
|
|
||||||
const contract = await this.contractsRepository.findByIdWithRelations(
|
|
||||||
booking.contractId,
|
|
||||||
);
|
|
||||||
if (contract) this.notifier.clearanceFeePaid(contract, booking.reference);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,6 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
|||||||
{} as never, // milestoneService
|
{} as never, // milestoneService
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{} as never, // clearanceFeeService
|
|
||||||
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
|||||||
milestoneService as never,
|
milestoneService as never,
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
invoiceService as never,
|
invoiceService as never,
|
||||||
{} as never, // clearanceFeeService
|
|
||||||
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import { hasFreightPermission } from '../../common/freight-permission.util';
|
|||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { ContractRoute } from './entities/contract-route.entity';
|
import { ContractRoute } from './entities/contract-route.entity';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ClearanceFeeService } from './clearance-fee.service';
|
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
import {
|
import {
|
||||||
@@ -97,7 +96,6 @@ export class ContractBookingService {
|
|||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
private readonly workflowService: ClearanceWorkflowService,
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly clearanceFeeService: ClearanceFeeService,
|
|
||||||
private readonly bookingNotifier: BookingLifecycleNotifierService,
|
private readonly bookingNotifier: BookingLifecycleNotifierService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
@Inject(forwardRef(() => TrainSchedulingService))
|
@Inject(forwardRef(() => TrainSchedulingService))
|
||||||
@@ -537,11 +535,8 @@ export class ContractBookingService {
|
|||||||
|
|
||||||
const route = await this.resolveRoute(contract, opts.contractRouteId);
|
const route = await this.resolveRoute(contract, opts.contractRouteId);
|
||||||
|
|
||||||
// Prepay gate: each shipment request owes its own flat clearance service
|
// No prepay gate: the clearance service fee is billed on the booking
|
||||||
// fee before the document step opens (the paid event advances the booking
|
// invoice at completion, so the document step opens immediately.
|
||||||
// to AWAITING_DOCUMENTS). Government/unlinked contracts skip the gate.
|
|
||||||
const feeGate = await this.clearanceFeeService.gateApplies(contract);
|
|
||||||
|
|
||||||
const booking = await insertWithGeneratedReference(
|
const booking = await insertWithGeneratedReference(
|
||||||
() => this.generateReference(),
|
() => this.generateReference(),
|
||||||
(reference) =>
|
(reference) =>
|
||||||
@@ -551,7 +546,7 @@ export class ContractBookingService {
|
|||||||
companyProfileId: contract.companyProfileId ?? null,
|
companyProfileId: contract.companyProfileId ?? null,
|
||||||
isGovernment: contract.isGovernment,
|
isGovernment: contract.isGovernment,
|
||||||
governmentInstitution: contract.governmentInstitution ?? null,
|
governmentInstitution: contract.governmentInstitution ?? null,
|
||||||
status: feeGate ? 'AWAITING_CLEARANCE_PAYMENT' : 'AWAITING_DOCUMENTS',
|
status: 'AWAITING_DOCUMENTS',
|
||||||
bookingType: 'ONE_TIME',
|
bookingType: 'ONE_TIME',
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
contractRouteId: route?.id ?? null,
|
contractRouteId: route?.id ?? null,
|
||||||
@@ -590,10 +585,6 @@ export class ContractBookingService {
|
|||||||
contract.tradeDirection,
|
contract.tradeDirection,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (feeGate) {
|
|
||||||
await this.clearanceFeeService.issueForBooking(booking, contract);
|
|
||||||
}
|
|
||||||
|
|
||||||
const created =
|
const created =
|
||||||
(await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
(await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
||||||
this.bookingNotifier.createdToStaff(created);
|
this.bookingNotifier.createdToStaff(created);
|
||||||
|
|||||||
@@ -499,11 +499,6 @@ export class ContractClearanceService {
|
|||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
if (contract.status === 'AWAITING_CLEARANCE_PAYMENT') {
|
|
||||||
throw new ConflictException(
|
|
||||||
'The customs clearance service fee has not been paid yet — pay it from the portal to unlock document upload.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
|
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
|
||||||
contract.status !== 'CLEARANCE_UNDER_REVIEW'
|
contract.status !== 'CLEARANCE_UNDER_REVIEW'
|
||||||
|
|||||||
@@ -178,26 +178,6 @@ export class ContractNotifierService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clearance service fee invoiced — customer must pay before document upload. */
|
|
||||||
clearanceFeeDue(c: Contract, amount: number, currency: string, shipmentRef?: string): void {
|
|
||||||
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
|
|
||||||
const msg =
|
|
||||||
`A customs clearance service fee of ${amount} ${currency} is due for ${scope}. ` +
|
|
||||||
`Please pay from the portal to unlock the clearance document upload.`;
|
|
||||||
void this.notifyContact(c, msg, 'CLEARANCE FEE DUE');
|
|
||||||
this.inApp(c, 'Clearance fee due', msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clearance service fee settled — document upload is now open. */
|
|
||||||
clearanceFeePaid(c: Contract, shipmentRef?: string): void {
|
|
||||||
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
|
|
||||||
const msg =
|
|
||||||
`Your customs clearance service fee for ${scope} has been received. ` +
|
|
||||||
`You can now upload the clearance documents from the portal.`;
|
|
||||||
void this.notifyContact(c, msg, 'CLEARANCE FEE PAID');
|
|
||||||
this.inApp(c, 'Clearance fee paid', msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Clearance milestones needing customer action ──────────────────────────
|
// ── Clearance milestones needing customer action ──────────────────────────
|
||||||
|
|
||||||
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Contract } from './entities/contract.entity';
|
|||||||
export interface ContractUnitRateLineItem {
|
export interface ContractUnitRateLineItem {
|
||||||
code: string;
|
code: string;
|
||||||
label: string;
|
label: string;
|
||||||
unit: 'per_container' | 'per_ton' | 'per_item' | 'per_km' | 'flat';
|
unit: 'per_container' | 'per_wagon' | 'per_ton' | 'per_item' | 'per_km' | 'flat';
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
containerSize?: string | null;
|
containerSize?: string | null;
|
||||||
conditionalOn?: string | null;
|
conditionalOn?: string | null;
|
||||||
@@ -37,8 +37,9 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
|
|||||||
return 'per_ton';
|
return 'per_ton';
|
||||||
case 'PER_KM':
|
case 'PER_KM':
|
||||||
return 'per_km';
|
return 'per_km';
|
||||||
case 'PER_CONTAINER':
|
|
||||||
case 'PER_WAGON':
|
case 'PER_WAGON':
|
||||||
|
return 'per_wagon';
|
||||||
|
case 'PER_CONTAINER':
|
||||||
return 'per_container';
|
return 'per_container';
|
||||||
default:
|
default:
|
||||||
return 'flat';
|
return 'flat';
|
||||||
@@ -188,49 +189,165 @@ export class ContractPricingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Lashing / cargo securing — BULK only, shown when the contract's commodity
|
||||||
|
// needs lashing (cargoType.hasLashing). The commodity-scoped rate for the
|
||||||
|
// contract's direction wins over the commodity-wide catch-all; billed at
|
||||||
|
// booking on the live rate (per ton / per wagon), this line is display.
|
||||||
|
if (contract.freightType === 'BULK') {
|
||||||
|
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||||
|
if (scope?.cargoType?.hasLashing) {
|
||||||
|
const onDirection = liveRates.filter(
|
||||||
|
(r) =>
|
||||||
|
r.trigger === 'LASHING' &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
!r.containerTypeId &&
|
||||||
|
r.tradeDirection === contract.tradeDirection,
|
||||||
|
);
|
||||||
|
const lashing =
|
||||||
|
onDirection.find((r) => r.cargoTypeId === scope.cargoTypeId) ??
|
||||||
|
onDirection.find((r) => !r.cargoTypeId);
|
||||||
|
if (lashing && Number(lashing.rateValue) > 0) {
|
||||||
|
lineItems.push({
|
||||||
|
code: 'LASHING',
|
||||||
|
label: `Lashing / cargo securing (${scope.cargoType.cargoTypeName})`,
|
||||||
|
unit: toContractUnit(lashing.rateUnit),
|
||||||
|
unitPrice: convert(Number(lashing.rateValue)),
|
||||||
|
cargoTypeCode: scope.cargoType.code ?? null,
|
||||||
|
conditionalOn: 'has_lashing',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Empty-container return service — container contracts only, toggled on the
|
// Empty-container return service — container contracts only, toggled on the
|
||||||
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
|
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
|
||||||
if (
|
if (
|
||||||
contract.freightType === 'CONTAINER' &&
|
contract.freightType === 'CONTAINER' &&
|
||||||
contract.equipmentReturn === 'WITH_RETURN'
|
contract.equipmentReturn === 'WITH_RETURN'
|
||||||
) {
|
) {
|
||||||
const withReturn = liveRates.find(
|
// Return is sold per direction + route + container type (import-only) —
|
||||||
(r) => r.rateType === 'RETURN_SURCHARGE' && r.currency === 'USD',
|
// one display line per contract size that has a configured rate. A size
|
||||||
);
|
// with no rate shows nothing here and hard-blocks at booking time.
|
||||||
if (withReturn && Number(withReturn.rateValue) > 0) {
|
// ponytail: bookings bill the live route rate, not a frozen snapshot.
|
||||||
lineItems.push({
|
const route = [...(contract.routes ?? [])].sort(
|
||||||
code: 'RETURN_SURCHARGE',
|
(a, b) => a.sortOrder - b.sortOrder,
|
||||||
label: 'Empty container return',
|
)[0];
|
||||||
unit: toContractUnit(withReturn.rateUnit),
|
const onLeg = route
|
||||||
unitPrice: convert(Number(withReturn.rateValue)),
|
? liveRates.filter(
|
||||||
conditionalOn: 'with_return',
|
(r) =>
|
||||||
|
r.rateType === 'RETURN_SURCHARGE' &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
r.tradeDirection === contract.tradeDirection &&
|
||||||
|
r.originYardId === route.originYardId &&
|
||||||
|
r.destinationYardId === route.destinationYardId,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
if (onLeg.length > 0) {
|
||||||
|
const sizes = (contract.cargoScope ?? [])
|
||||||
|
.map((c) => c.containerSize)
|
||||||
|
.filter((s): s is string => !!s);
|
||||||
|
const { items: containerTypes } = await this.containerTypesService.findAll({
|
||||||
|
isActive: true,
|
||||||
|
pageSize: 100,
|
||||||
});
|
});
|
||||||
|
for (const size of sizes) {
|
||||||
|
const sizeFt = size === '40ft' ? 40 : 20;
|
||||||
|
const matchedIds = new Set(
|
||||||
|
containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id),
|
||||||
|
);
|
||||||
|
const rate =
|
||||||
|
onLeg.find((r) => r.containerTypeId && matchedIds.has(r.containerTypeId)) ??
|
||||||
|
onLeg.find((r) => !r.containerTypeId);
|
||||||
|
if (!rate || Number(rate.rateValue) <= 0) continue;
|
||||||
|
lineItems.push({
|
||||||
|
code: 'RETURN_SURCHARGE',
|
||||||
|
label: `Empty container return (${size})`,
|
||||||
|
unit: toContractUnit(rate.rateUnit),
|
||||||
|
unitPrice: convert(Number(rate.rateValue)),
|
||||||
|
containerSize: size,
|
||||||
|
conditionalOn: 'with_return',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Customs clearance service fee (Path B) — a FLAT prepaid fee, shown on the
|
// Customs clearance service fee (Path B) — billed on the booking invoice
|
||||||
// contract and billed via its own clearance invoice: after counter-sign for
|
// together with the freight. Sold per direction + route + cargo kind:
|
||||||
// ONE_TIME, per shipment request for GENERAL. Excluded from booking totals.
|
// container contracts freeze one fee line per contract size (each size's
|
||||||
// A customs contract may not proceed without a configured live rate.
|
// own container-type rate), bulk contracts freeze the route's bulk fee.
|
||||||
|
// A customs contract may not proceed without the fee(s) configured.
|
||||||
if (contract.customsClearingEnabled) {
|
if (contract.customsClearingEnabled) {
|
||||||
const clearance = liveRates.find(
|
// Strict, no route-less fallback.
|
||||||
(r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD',
|
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
|
||||||
);
|
const route = [...(contract.routes ?? [])].sort(
|
||||||
if (!clearance || Number(clearance.rateValue) <= 0) {
|
(a, b) => a.sortOrder - b.sortOrder,
|
||||||
throw new UnprocessableEntityException(
|
)[0];
|
||||||
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
|
const onLeg = route
|
||||||
);
|
? liveRates.filter(
|
||||||
|
(r) =>
|
||||||
|
r.rateType === 'CUSTOMS_CLEARANCE' &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
r.tradeDirection === contract.tradeDirection &&
|
||||||
|
r.originYardId === route.originYardId &&
|
||||||
|
r.destinationYardId === route.destinationYardId,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
if (contract.freightType === 'CONTAINER') {
|
||||||
|
const sizes = (contract.cargoScope ?? [])
|
||||||
|
.map((c) => c.containerSize)
|
||||||
|
.filter((s): s is string => !!s);
|
||||||
|
const { items: containerTypes } = await this.containerTypesService.findAll({
|
||||||
|
isActive: true,
|
||||||
|
pageSize: 100,
|
||||||
|
});
|
||||||
|
for (const size of sizes) {
|
||||||
|
const sizeFt = size === '40ft' ? 40 : 20;
|
||||||
|
const matchedIds = new Set(
|
||||||
|
containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id),
|
||||||
|
);
|
||||||
|
const rate = onLeg.find(
|
||||||
|
(r) => r.containerTypeId && matchedIds.has(r.containerTypeId),
|
||||||
|
);
|
||||||
|
if (!rate || Number(rate.rateValue) <= 0) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this container type and origin → destination.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lineItems.push({
|
||||||
|
// Distinct code per size so the frozen snapshots don't collide —
|
||||||
|
// booking pricing looks each size up by CUSTOMS_CLEARANCE_<FT>FT.
|
||||||
|
code: `CUSTOMS_CLEARANCE_${sizeFt}FT`,
|
||||||
|
label: `Customs clearance service (${size})`,
|
||||||
|
unit: toContractUnit(rate.rateUnit),
|
||||||
|
unitPrice: convert(Number(rate.rateValue)),
|
||||||
|
containerSize: size,
|
||||||
|
isClearance: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Bulk fee — the rate scoped to the contract's commodity wins; a
|
||||||
|
// commodity-less rate (legacy) is the catch-all fallback.
|
||||||
|
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||||
|
const rate =
|
||||||
|
(scope?.cargoTypeId
|
||||||
|
? onLeg.find(
|
||||||
|
(r) => !r.containerTypeId && r.cargoTypeId === scope.cargoTypeId,
|
||||||
|
)
|
||||||
|
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
|
||||||
|
if (!rate || Number(rate.rateValue) <= 0) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
'No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this commodity and origin → destination.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lineItems.push({
|
||||||
|
code: 'CUSTOMS_CLEARANCE',
|
||||||
|
label: `Customs clearance service (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
|
||||||
|
unit: toContractUnit(rate.rateUnit),
|
||||||
|
unitPrice: convert(Number(rate.rateValue)),
|
||||||
|
cargoTypeCode: scope?.cargoType?.code ?? null,
|
||||||
|
isClearance: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
lineItems.push({
|
|
||||||
code: 'CUSTOMS_CLEARANCE',
|
|
||||||
label:
|
|
||||||
contract.contractKind === 'GENERAL'
|
|
||||||
? 'Customs clearance service fee (per shipment request, prepaid)'
|
|
||||||
: 'Customs clearance service fee (prepaid)',
|
|
||||||
unit: toContractUnit(clearance.rateUnit),
|
|
||||||
unitPrice: convert(Number(clearance.rateValue)),
|
|
||||||
isClearance: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -20,8 +20,13 @@ import { MinioService } from '../minio/minio.service';
|
|||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
import {
|
import {
|
||||||
assertCanApproveContractStep,
|
assertCanApproveContractStep,
|
||||||
|
assertFreightPermission,
|
||||||
canEditContractStep,
|
canEditContractStep,
|
||||||
} from '../../common/freight-permission.util';
|
} from '../../common/freight-permission.util';
|
||||||
|
import {
|
||||||
|
FREIGHT_PERMS,
|
||||||
|
forFreightType,
|
||||||
|
} from '../../seed/freight-permissions.registry';
|
||||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||||
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
||||||
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||||
@@ -31,7 +36,6 @@ import { SignaturesService } from '../signatures/signatures.service';
|
|||||||
import { OtpService } from '../otp/otp.service';
|
import { OtpService } from '../otp/otp.service';
|
||||||
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
|
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
|
||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
import { ClearanceFeeService } from './clearance-fee.service';
|
|
||||||
import { ContractNotifierService } from './contract-notifier.service';
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
@@ -157,7 +161,6 @@ export class ContractTransitionService {
|
|||||||
private readonly otpService: OtpService,
|
private readonly otpService: OtpService,
|
||||||
private readonly notifier: ContractNotifierService,
|
private readonly notifier: ContractNotifierService,
|
||||||
private readonly contractTemplates: ContractTemplatesService,
|
private readonly contractTemplates: ContractTemplatesService,
|
||||||
private readonly clearanceFeeService: ClearanceFeeService,
|
|
||||||
@InjectDataSource()
|
@InjectDataSource()
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
@@ -239,8 +242,15 @@ export class ContractTransitionService {
|
|||||||
actorId: string,
|
actorId: string,
|
||||||
validityDays: number,
|
validityDays: number,
|
||||||
documentSnapshot?: ContractDocumentSnapshotInput | null,
|
documentSnapshot?: ContractDocumentSnapshotInput | null,
|
||||||
|
user?: TCurrentUser | null,
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
// The route guard passes on either arm; the contract's freight type decides
|
||||||
|
// which one is actually required (accept bulk ≠ accept container).
|
||||||
|
assertFreightPermission(
|
||||||
|
user,
|
||||||
|
forFreightType(FREIGHT_PERMS.contracts.staffAccept, contract.freightType),
|
||||||
|
);
|
||||||
assertContractStatus(contract, ['SUBMITTED']);
|
assertContractStatus(contract, ['SUBMITTED']);
|
||||||
|
|
||||||
if (!Number.isInteger(validityDays) || validityDays < 1) {
|
if (!Number.isInteger(validityDays) || validityDays < 1) {
|
||||||
@@ -535,8 +545,13 @@ export class ContractTransitionService {
|
|||||||
contractId: string,
|
contractId: string,
|
||||||
note: string,
|
note: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
|
user?: TCurrentUser | null,
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertFreightPermission(
|
||||||
|
user,
|
||||||
|
forFreightType(FREIGHT_PERMS.contracts.requestChanges, contract.freightType),
|
||||||
|
);
|
||||||
assertContractStatus(contract, ['SUBMITTED']);
|
assertContractStatus(contract, ['SUBMITTED']);
|
||||||
|
|
||||||
await this.contractsRepository.createReviewNote(
|
await this.contractsRepository.createReviewNote(
|
||||||
@@ -554,8 +569,17 @@ export class ContractTransitionService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
|
async reject(
|
||||||
|
contractId: string,
|
||||||
|
reason: string,
|
||||||
|
actorId: string,
|
||||||
|
user?: TCurrentUser | null,
|
||||||
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertFreightPermission(
|
||||||
|
user,
|
||||||
|
forFreightType(FREIGHT_PERMS.contracts.reject, contract.freightType),
|
||||||
|
);
|
||||||
assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);
|
assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);
|
||||||
|
|
||||||
await this.contractsRepository.createReviewNote(
|
await this.contractsRepository.createReviewNote(
|
||||||
@@ -565,10 +589,6 @@ export class ContractTransitionService {
|
|||||||
actorId,
|
actorId,
|
||||||
'STAFF',
|
'STAFF',
|
||||||
);
|
);
|
||||||
// Stop the open-invoice leak: a rejected contract must not leave a payable
|
|
||||||
// clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable).
|
|
||||||
await this.clearanceFeeService.expireForContract(contractId);
|
|
||||||
|
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'REJECTED',
|
status: 'REJECTED',
|
||||||
} as never);
|
} as never);
|
||||||
@@ -628,10 +648,6 @@ export class ContractTransitionService {
|
|||||||
'STAFF',
|
'STAFF',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stop the open-invoice leak: a rejected contract must not leave a payable
|
|
||||||
// clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable).
|
|
||||||
await this.clearanceFeeService.expireForContract(contractId);
|
|
||||||
|
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'REJECTED',
|
status: 'REJECTED',
|
||||||
} as never);
|
} as never);
|
||||||
@@ -643,9 +659,9 @@ export class ContractTransitionService {
|
|||||||
/**
|
/**
|
||||||
* Internal send-back branch of rejectStep: return the contract to an earlier,
|
* Internal send-back branch of rejectStep: return the contract to an earlier,
|
||||||
* already-approved stage of the chain instead of rejecting it outright.
|
* already-approved stage of the chain instead of rejecting it outright.
|
||||||
* Deliberately NOT the terminal path: no clearance-fee expiry (the contract
|
* Deliberately NOT the terminal path: the contract is still alive and there
|
||||||
* is still alive) and no customer-facing REJECTION note — the trail is a
|
* is no customer-facing REJECTION note — the trail is a staff note plus a
|
||||||
* staff note plus a backoffice inbox ping.
|
* backoffice inbox ping.
|
||||||
*/
|
*/
|
||||||
private async sendBackToStep(
|
private async sendBackToStep(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
@@ -1143,17 +1159,11 @@ export class ContractTransitionService {
|
|||||||
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
||||||
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
||||||
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
||||||
// Path B prepay gate: the customs clearance service fee is invoiced here
|
// No prepay gate: the customs clearance service fee (Path B) is billed on
|
||||||
// and must settle before the document step opens (the paid event advances
|
// the booking invoice together with the freight, so the document step
|
||||||
// to AWAITING_CLEARANCE_DOCUMENTS). Path A (self-clearance) has no GL fee.
|
// opens immediately.
|
||||||
if (await this.clearanceFeeService.gateApplies(contract)) {
|
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
||||||
await this.clearanceFeeService.issueForContract(contract);
|
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||||
updates.status = 'AWAITING_CLEARANCE_PAYMENT';
|
|
||||||
updates.clearanceStatus = 'AWAITING_PAYMENT';
|
|
||||||
} else {
|
|
||||||
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
|
||||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
|
||||||
}
|
|
||||||
updates.clearanceCycleNumber = cycleNumber;
|
updates.clearanceCycleNumber = cycleNumber;
|
||||||
} else {
|
} else {
|
||||||
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ import {
|
|||||||
import { actorLabel } from '../warehouses/current-actor.util';
|
import { actorLabel } from '../warehouses/current-actor.util';
|
||||||
import { BookingStaff } from '../../common/booking-guards';
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import {
|
||||||
|
FREIGHT_PERMS,
|
||||||
|
bothFreightTypes,
|
||||||
|
forFreightType,
|
||||||
|
} from '../../seed/freight-permissions.registry';
|
||||||
import {
|
import {
|
||||||
assertFreightPermission,
|
assertFreightPermission,
|
||||||
hasFreightPermission,
|
hasFreightPermission,
|
||||||
@@ -184,7 +188,10 @@ export class ContractsController {
|
|||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
if (dto.isGovernment) {
|
if (dto.isGovernment) {
|
||||||
assertFreightPermission(user, FREIGHT_PERMS.contracts.staffAccept);
|
assertFreightPermission(
|
||||||
|
user,
|
||||||
|
forFreightType(FREIGHT_PERMS.contracts.staffAccept, dto.freightType),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return this.contractsService.create(dto, files ?? [], user?.id);
|
return this.contractsService.create(dto, files ?? [], user?.id);
|
||||||
}
|
}
|
||||||
@@ -337,23 +344,32 @@ export class ContractsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/staff/accept')
|
@Post(':id/staff/accept')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
// One-of guard; the service then requires the arm matching the contract's freight type.
|
||||||
|
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept))
|
||||||
@ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' })
|
@ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' })
|
||||||
staffAccept(
|
staffAccept(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: AcceptContractDto,
|
@Body() dto: AcceptContractDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
return this.transitionService.staffAccept(
|
return this.transitionService.staffAccept(
|
||||||
id,
|
id,
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
dto.validityDays,
|
dto.validityDays,
|
||||||
dto.documentSnapshot,
|
dto.documentSnapshot,
|
||||||
|
user,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Readable by anyone who may view the contract: the draft carries
|
||||||
|
// `editableByMe`, and the approval chain's approvers (identified by position
|
||||||
|
// type, not by staff_accept) must be able to fetch it to learn it is their
|
||||||
|
// turn. Gating this on staff_accept hid the edit dialog from every approver.
|
||||||
@Get(':id/document/draft')
|
@Get(':id/document/draft')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.contracts.view,
|
||||||
|
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
||||||
|
])
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
|
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
|
||||||
@@ -376,8 +392,15 @@ export class ContractsController {
|
|||||||
return this.documentHistory.list(id);
|
return this.documentHistory.list(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Coarse gate only. WHO may actually edit is turn-based, not a static
|
||||||
|
// permission, so `updateContractDocument` -> `assertDocumentEditable` is the
|
||||||
|
// real boundary: it admits only the approver whose step is currently pending
|
||||||
|
// (edit rights hand off down the chain on each approval).
|
||||||
@Put(':id/document/articles')
|
@Put(':id/document/articles')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.contracts.view,
|
||||||
|
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
||||||
|
])
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
|
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
|
||||||
@@ -396,29 +419,35 @@ export class ContractsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/staff/request-changes')
|
@Post(':id/staff/request-changes')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
|
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges))
|
||||||
@ApiOperation({ summary: 'Staff return contract for customer updates' })
|
@ApiOperation({ summary: 'Staff return contract for customer updates' })
|
||||||
requestChanges(
|
requestChanges(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: RequestChangesDto,
|
@Body() dto: RequestChangesDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
return this.transitionService.requestChanges(
|
return this.transitionService.requestChanges(
|
||||||
id,
|
id,
|
||||||
dto.note,
|
dto.note,
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
|
user,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/staff/reject')
|
@Post(':id/staff/reject')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.reject)
|
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.reject))
|
||||||
@ApiOperation({ summary: 'Staff reject contract' })
|
@ApiOperation({ summary: 'Staff reject contract' })
|
||||||
reject(
|
reject(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: RejectContractDto,
|
@Body() dto: RejectContractDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
return this.transitionService.reject(id, dto.reason, resolveAuthUserId(user));
|
return this.transitionService.reject(
|
||||||
|
id,
|
||||||
|
dto.reason,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
user,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/approval-steps/:stepId/approve')
|
@Post(':id/approval-steps/:stepId/approve')
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { ContractsController } from './contracts.controller';
|
|||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
import { ClearanceFeeService } from './clearance-fee.service';
|
|
||||||
import { ContractNotifierService } from './contract-notifier.service';
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { ContractTransitionService } from './contract-transition.service';
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
import { ContractClearanceService } from './contract-clearance.service';
|
import { ContractClearanceService } from './contract-clearance.service';
|
||||||
@@ -107,7 +106,6 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
ContractsService,
|
ContractsService,
|
||||||
ContractsRepository,
|
ContractsRepository,
|
||||||
ContractPricingService,
|
ContractPricingService,
|
||||||
ClearanceFeeService,
|
|
||||||
ContractNotifierService,
|
ContractNotifierService,
|
||||||
ContractTransitionService,
|
ContractTransitionService,
|
||||||
ContractDocumentHistoryService,
|
ContractDocumentHistoryService,
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ export class ContractRateSnapshot extends BaseEntity {
|
|||||||
conditionalOn?: string | null;
|
conditionalOn?: string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Customs clearance service fee line — billed up front via a clearance
|
* Customs clearance service fee line — billed on the booking invoice
|
||||||
* invoice, excluded from shipment booking totals.
|
* together with the freight (no separate prepaid clearance invoice).
|
||||||
*/
|
*/
|
||||||
@Column({ name: 'is_clearance', type: 'boolean', default: false })
|
@Column({ name: 'is_clearance', type: 'boolean', default: false })
|
||||||
isClearance!: boolean;
|
isClearance!: boolean;
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export const CONTRACT_STATUSES = [
|
|||||||
'SIGNED_CUSTOMER',
|
'SIGNED_CUSTOMER',
|
||||||
'FULLY_EXECUTED',
|
'FULLY_EXECUTED',
|
||||||
'CONTRACT_ACTIVE',
|
'CONTRACT_ACTIVE',
|
||||||
'AWAITING_CLEARANCE_PAYMENT', // Path B — clearance fee invoiced, unpaid
|
|
||||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||||
'CLEARANCE_UNDER_REVIEW',
|
'CLEARANCE_UNDER_REVIEW',
|
||||||
'CLEARANCE_READY_FOR_BOOKING',
|
'CLEARANCE_READY_FOR_BOOKING',
|
||||||
@@ -85,7 +84,6 @@ export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
|
|||||||
|
|
||||||
export const CONTRACT_CLEARANCE_STATUSES = [
|
export const CONTRACT_CLEARANCE_STATUSES = [
|
||||||
'NOT_APPLICABLE',
|
'NOT_APPLICABLE',
|
||||||
'AWAITING_PAYMENT', // Path B — clearance service fee must be paid first
|
|
||||||
'AWAITING_DOCUMENTS',
|
'AWAITING_DOCUMENTS',
|
||||||
'DOCUMENTS_UNDER_REVIEW',
|
'DOCUMENTS_UNDER_REVIEW',
|
||||||
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
|
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
|
||||||
@@ -217,10 +215,6 @@ export class Contract extends BaseEntity {
|
|||||||
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
|
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
|
||||||
clearanceCycleNumber!: number;
|
clearanceCycleNumber!: number;
|
||||||
|
|
||||||
/** When the prepaid customs clearance service fee settled (Path B ONE_TIME). */
|
|
||||||
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
|
|
||||||
clearanceFeePaidAt?: Date | null;
|
|
||||||
|
|
||||||
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
|
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
|
||||||
pricingBreakdown?: Record<string, unknown> | null;
|
pricingBreakdown?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
|||||||
@@ -54,11 +54,4 @@ export class InterchangeDocumentsController {
|
|||||||
dispute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DisputeInterchangeDocumentDto) {
|
dispute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DisputeInterchangeDocumentDto) {
|
||||||
return this.service.dispute(id, dto);
|
return this.service.dispute(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/cancel')
|
|
||||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.cancel)
|
|
||||||
@ApiOperation({ summary: 'Cancel a draft/generated interchange document' })
|
|
||||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
|
||||||
return this.service.cancel(id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,11 +203,12 @@ export class InterchangeDocumentsService {
|
|||||||
|
|
||||||
async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise<InterchangeDocument> {
|
async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise<InterchangeDocument> {
|
||||||
const document = await this.findOne(id);
|
const document = await this.findOne(id);
|
||||||
// A dispute can only be raised on a live handover — a GENERATED or already
|
// A dispute can only be raised BEFORE the handover is acknowledged — an
|
||||||
// ACKNOWLEDGED document. CANCELLED and already-DISPUTED are terminal here.
|
// acknowledged document is settled. DISPUTED itself is terminal and
|
||||||
if (!['GENERATED', 'ACKNOWLEDGED'].includes(document.status)) {
|
// read-only: the registered dispute cannot be re-raised or overwritten.
|
||||||
|
if (document.status !== 'GENERATED') {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Interchange document in ${document.status} status cannot be disputed (must be GENERATED or ACKNOWLEDGED)`,
|
`Interchange document in ${document.status} status cannot be disputed (must be GENERATED — an acknowledged handover is settled, a registered dispute is read-only)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.dataSource.getRepository(InterchangeDocument).update(id, {
|
await this.dataSource.getRepository(InterchangeDocument).update(id, {
|
||||||
@@ -217,15 +218,6 @@ export class InterchangeDocumentsService {
|
|||||||
return this.findOne(id);
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancel(id: string): Promise<InterchangeDocument> {
|
|
||||||
const document = await this.findOne(id);
|
|
||||||
if (!['DRAFT', 'GENERATED'].includes(document.status)) {
|
|
||||||
throw new BadRequestException(`Interchange document ${document.status} cannot be cancelled`);
|
|
||||||
}
|
|
||||||
await this.dataSource.getRepository(InterchangeDocument).update(id, { status: 'CANCELLED' });
|
|
||||||
return this.findOne(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getScheduleSnapshot(scheduleId: string): Promise<ScheduleSnapshot> {
|
private async getScheduleSnapshot(scheduleId: string): Promise<ScheduleSnapshot> {
|
||||||
const [schedule] = await this.dataSource.query(
|
const [schedule] = await this.dataSource.query(
|
||||||
`SELECT ts.id,
|
`SELECT ts.id,
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export class CreateMaintenanceScheduleDto {
|
|||||||
@IsEnum(MaintenanceType)
|
@IsEnum(MaintenanceType)
|
||||||
maintenanceType!: MaintenanceType;
|
maintenanceType!: MaintenanceType;
|
||||||
|
|
||||||
|
/** What is serviced — matched against the interval for auto-scheduling. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
serviceItem?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
description!: string;
|
description!: string;
|
||||||
|
|
||||||
@@ -81,7 +86,37 @@ export class UpdateMaintenanceScheduleDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
actualCost?: number;
|
actualCost?: number;
|
||||||
|
|
||||||
|
/** Odometer at completion — drives KM-based auto-scheduling of the next service. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
odometerReading?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpsertMaintenanceIntervalDto {
|
||||||
|
@IsUUID()
|
||||||
|
vehicleId!: string;
|
||||||
|
|
||||||
|
@IsEnum(MaintenanceType)
|
||||||
|
maintenanceType!: MaintenanceType;
|
||||||
|
|
||||||
|
/** What is serviced — "oil change", "tires", … Distinguishes intervals of the same type. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
serviceItem?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
intervalKm?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
intervalDays?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Entity, Column, ManyToOne, JoinColumn, Index, Unique } from 'typeorm';
|
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||||
import { MaintenanceType } from './maintenance-schedule.entity';
|
import { MaintenanceType } from './maintenance-schedule.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maintenance interval configuration. Defines how often a vehicle/type needs maintenance.
|
* Maintenance interval configuration. Defines how often a vehicle needs a
|
||||||
* Each vehicle can have different intervals for different maintenance types (e.g., oil every 10k km, tires every 50k km).
|
* given service. Identity is (vehicle, maintenanceType, serviceItem) — a
|
||||||
|
* vehicle carries several intervals of the same coarse type with different
|
||||||
|
* items (oil every 10k km, tires every 50k km, both PREVENTIVE). Uniqueness
|
||||||
|
* is enforced by a COALESCE expression index in the migration (nullable
|
||||||
|
* service_item), not a TypeORM @Unique.
|
||||||
*/
|
*/
|
||||||
@Entity({ name: 'maintenance_intervals', schema: 'freight' })
|
@Entity({ name: 'maintenance_intervals', schema: 'freight' })
|
||||||
@Index(['vehicleId', 'maintenanceType'])
|
@Index(['vehicleId', 'maintenanceType'])
|
||||||
@Unique(['vehicleId', 'maintenanceType'])
|
|
||||||
export class MaintenanceInterval extends BaseEntity {
|
export class MaintenanceInterval extends BaseEntity {
|
||||||
@Column({ name: 'vehicle_id', type: 'uuid' })
|
@Column({ name: 'vehicle_id', type: 'uuid' })
|
||||||
vehicleId!: string;
|
vehicleId!: string;
|
||||||
@@ -21,6 +24,10 @@ export class MaintenanceInterval extends BaseEntity {
|
|||||||
@Column({ name: 'maintenance_type', type: 'varchar' })
|
@Column({ name: 'maintenance_type', type: 'varchar' })
|
||||||
maintenanceType!: MaintenanceType;
|
maintenanceType!: MaintenanceType;
|
||||||
|
|
||||||
|
/** What is serviced — "oil change", "tires", … Null = generic for the type. */
|
||||||
|
@Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true })
|
||||||
|
serviceItem?: string | null;
|
||||||
|
|
||||||
/** Maintenance interval in kilometers. E.g., 10000 for oil changes every 10k km. */
|
/** Maintenance interval in kilometers. E.g., 10000 for oil changes every 10k km. */
|
||||||
@Column({ name: 'interval_km', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
@Column({ name: 'interval_km', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
||||||
intervalKm?: number | null;
|
intervalKm?: number | null;
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export class MaintenanceSchedule extends BaseEntity {
|
|||||||
@Column({ name: 'maintenance_type', type: 'varchar' })
|
@Column({ name: 'maintenance_type', type: 'varchar' })
|
||||||
maintenanceType!: MaintenanceType;
|
maintenanceType!: MaintenanceType;
|
||||||
|
|
||||||
|
/** What is serviced — matches the interval's service_item for auto-scheduling. */
|
||||||
|
@Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true })
|
||||||
|
serviceItem?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'description' })
|
@Column({ name: 'description' })
|
||||||
description!: string;
|
description!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { MaintenanceService } from './maintenance.service';
|
||||||
|
import { MaintenanceStatus } from './entities/maintenance-schedule.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KM-based auto-scheduling: completing a maintenance with an odometer reading
|
||||||
|
* creates the next SCHEDULED item at completedKm + intervalKm, matched on the
|
||||||
|
* schedule's (type, serviceItem) interval. Re-completing must not duplicate.
|
||||||
|
*/
|
||||||
|
function makeService(opts: {
|
||||||
|
before: Record<string, unknown> | null;
|
||||||
|
after: Record<string, unknown> | null;
|
||||||
|
interval: Record<string, unknown> | null;
|
||||||
|
}) {
|
||||||
|
const saved: Array<Record<string, unknown>> = [];
|
||||||
|
const service = Object.create(MaintenanceService.prototype) as Record<string, unknown>;
|
||||||
|
service.scheduleRepository = {
|
||||||
|
findOneBy: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(opts.before)
|
||||||
|
.mockResolvedValueOnce(opts.after),
|
||||||
|
update: jest.fn(),
|
||||||
|
create: jest.fn((v: Record<string, unknown>) => v),
|
||||||
|
save: jest.fn(async (v: Record<string, unknown>) => {
|
||||||
|
saved.push(v);
|
||||||
|
return v;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
service.intervalRepository = {
|
||||||
|
getByVehicleAndType: jest.fn().mockResolvedValue(opts.interval),
|
||||||
|
};
|
||||||
|
service.dataSource = {
|
||||||
|
getRepository: jest.fn().mockReturnValue({ update: jest.fn() }),
|
||||||
|
};
|
||||||
|
service.logger = { error: jest.fn() };
|
||||||
|
return { service: service as unknown as MaintenanceService, saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
id: 's-1',
|
||||||
|
vehicleId: 'v-1',
|
||||||
|
maintenanceType: 'PREVENTIVE',
|
||||||
|
serviceItem: 'oil change',
|
||||||
|
description: 'Oil and filter',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('MaintenanceService auto-next scheduling', () => {
|
||||||
|
it('completing at 50,000 km with a 10,000 km interval schedules the next at 60,000', async () => {
|
||||||
|
const { service, saved } = makeService({
|
||||||
|
before: { ...base, status: MaintenanceStatus.SCHEDULED },
|
||||||
|
after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 },
|
||||||
|
interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: null, description: 'Oil and filter' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.updateMaintenanceSchedule('s-1', {
|
||||||
|
status: MaintenanceStatus.COMPLETED,
|
||||||
|
odometerReading: 50000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saved).toHaveLength(1);
|
||||||
|
expect(saved[0]).toMatchObject({
|
||||||
|
vehicleId: 'v-1',
|
||||||
|
serviceItem: 'oil change',
|
||||||
|
nextDueKm: 60000,
|
||||||
|
status: MaintenanceStatus.SCHEDULED,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-completing an already COMPLETED schedule does not duplicate the next one', async () => {
|
||||||
|
const { service, saved } = makeService({
|
||||||
|
before: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 },
|
||||||
|
after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 },
|
||||||
|
interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.updateMaintenanceSchedule('s-1', {
|
||||||
|
status: MaintenanceStatus.COMPLETED,
|
||||||
|
odometerReading: 50000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saved).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a km + days interval produces ONE next schedule carrying both thresholds', async () => {
|
||||||
|
const { service, saved } = makeService({
|
||||||
|
before: { ...base, status: MaintenanceStatus.IN_PROGRESS },
|
||||||
|
after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 20000 },
|
||||||
|
interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: 180 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.updateMaintenanceSchedule('s-1', {
|
||||||
|
status: MaintenanceStatus.COMPLETED,
|
||||||
|
odometerReading: 20000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saved).toHaveLength(1);
|
||||||
|
expect(saved[0].nextDueKm).toBe(30000);
|
||||||
|
expect(saved[0].nextDueDate).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository } from '@edr/api-common';
|
||||||
import { Repository } from 'typeorm';
|
import { IsNull, Repository } from 'typeorm';
|
||||||
import { MaintenanceInterval } from './entities/maintenance-interval.entity';
|
import { MaintenanceInterval } from './entities/maintenance-interval.entity';
|
||||||
import { MaintenanceType } from './entities/maintenance-schedule.entity';
|
import { MaintenanceType } from './entities/maintenance-schedule.entity';
|
||||||
|
|
||||||
@@ -14,27 +14,44 @@ export class MaintenanceIntervalRepository extends BaseRepository<MaintenanceInt
|
|||||||
super(intervalRepository);
|
super(intervalRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByVehicleAndType(vehicleId: string, maintenanceType: MaintenanceType): Promise<MaintenanceInterval | null> {
|
/**
|
||||||
|
* Resolve the interval for a completed service. Prefers the exact
|
||||||
|
* (type, serviceItem) match; a completion without an item falls back to the
|
||||||
|
* type's item-less interval only, so "oil" completions never consume the
|
||||||
|
* "tires" interval.
|
||||||
|
*/
|
||||||
|
async getByVehicleAndType(
|
||||||
|
vehicleId: string,
|
||||||
|
maintenanceType: MaintenanceType,
|
||||||
|
serviceItem?: string | null,
|
||||||
|
): Promise<MaintenanceInterval | null> {
|
||||||
return this.intervalRepository.findOne({
|
return this.intervalRepository.findOne({
|
||||||
where: { vehicleId, maintenanceType, isActive: true },
|
where: {
|
||||||
|
vehicleId,
|
||||||
|
maintenanceType,
|
||||||
|
isActive: true,
|
||||||
|
serviceItem: serviceItem?.trim() ? serviceItem.trim() : IsNull(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getActiveIntervals(vehicleId: string): Promise<MaintenanceInterval[]> {
|
async getActiveIntervals(vehicleId: string): Promise<MaintenanceInterval[]> {
|
||||||
return this.intervalRepository.find({
|
return this.intervalRepository.find({
|
||||||
where: { vehicleId, isActive: true },
|
where: { vehicleId, isActive: true },
|
||||||
order: { maintenanceType: 'ASC' },
|
order: { maintenanceType: 'ASC', serviceItem: 'ASC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertInterval(
|
async upsertInterval(
|
||||||
vehicleId: string,
|
vehicleId: string,
|
||||||
maintenanceType: MaintenanceType,
|
maintenanceType: MaintenanceType,
|
||||||
|
serviceItem?: string | null,
|
||||||
intervalKm?: number | null,
|
intervalKm?: number | null,
|
||||||
intervalDays?: number | null,
|
intervalDays?: number | null,
|
||||||
description?: string | null,
|
description?: string | null,
|
||||||
): Promise<MaintenanceInterval> {
|
): Promise<MaintenanceInterval> {
|
||||||
const existing = await this.getByVehicleAndType(vehicleId, maintenanceType);
|
const item = serviceItem?.trim() || null;
|
||||||
|
const existing = await this.getByVehicleAndType(vehicleId, maintenanceType, item);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await this.intervalRepository.update(existing.id, {
|
await this.intervalRepository.update(existing.id, {
|
||||||
@@ -50,6 +67,7 @@ export class MaintenanceIntervalRepository extends BaseRepository<MaintenanceInt
|
|||||||
this.intervalRepository.create({
|
this.intervalRepository.create({
|
||||||
vehicleId,
|
vehicleId,
|
||||||
maintenanceType,
|
maintenanceType,
|
||||||
|
serviceItem: item,
|
||||||
intervalKm,
|
intervalKm,
|
||||||
intervalDays,
|
intervalDays,
|
||||||
description,
|
description,
|
||||||
@@ -57,4 +75,9 @@ export class MaintenanceIntervalRepository extends BaseRepository<MaintenanceInt
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Soft-disable: history keeps pointing at it, auto-scheduling stops. */
|
||||||
|
async deactivate(id: string): Promise<void> {
|
||||||
|
await this.intervalRepository.update(id, { isActive: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { BookingStaff } from '../../common/booking-guards';
|
|||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { MaintenanceService } from './maintenance.service';
|
import { MaintenanceService } from './maintenance.service';
|
||||||
import { MaintenanceDepthService } from './maintenance-depth.service';
|
import { MaintenanceDepthService } from './maintenance-depth.service';
|
||||||
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
|
import {
|
||||||
|
CreateMaintenanceScheduleDto,
|
||||||
|
CreateMaintenanceCostDto,
|
||||||
|
UpdateMaintenanceScheduleDto,
|
||||||
|
UpsertMaintenanceIntervalDto,
|
||||||
|
} from './dto/create-maintenance.dto';
|
||||||
import {
|
import {
|
||||||
CreateWorkOrderDto,
|
CreateWorkOrderDto,
|
||||||
UpdateWorkOrderDto,
|
UpdateWorkOrderDto,
|
||||||
@@ -51,6 +56,27 @@ export class MaintenanceController {
|
|||||||
return this.maintenanceService.getDueBoard();
|
return this.maintenanceService.getDueBoard();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('intervals')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.maintenance.create)
|
||||||
|
@ApiOperation({ summary: 'Define/adjust a service interval (e.g. oil change every 10,000 km)' })
|
||||||
|
async upsertInterval(@Body() dto: UpsertMaintenanceIntervalDto) {
|
||||||
|
return this.maintenanceService.upsertInterval(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('intervals/:vehicleId')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
||||||
|
@ApiOperation({ summary: "A vehicle's active service intervals" })
|
||||||
|
async getIntervals(@Param('vehicleId') vehicleId: string) {
|
||||||
|
return this.maintenanceService.getIntervals(vehicleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('intervals/:id')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.maintenance.delete)
|
||||||
|
@ApiOperation({ summary: 'Deactivate a service interval (stops auto-scheduling)' })
|
||||||
|
async deactivateInterval(@Param('id') id: string) {
|
||||||
|
return this.maintenanceService.deactivateInterval(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('upcoming/:vehicleId')
|
@Get('upcoming/:vehicleId')
|
||||||
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
||||||
@ApiOperation({ summary: 'Get upcoming maintenance' })
|
@ApiOperation({ summary: 'Get upcoming maintenance' })
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export class MaintenanceRepository extends BaseRepository<MaintenanceSchedule> {
|
|||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
plateNumber: string;
|
plateNumber: string;
|
||||||
maintenanceType: string;
|
maintenanceType: string;
|
||||||
|
serviceItem: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
scheduledDate: Date;
|
scheduledDate: Date;
|
||||||
nextDueDate: Date | null;
|
nextDueDate: Date | null;
|
||||||
@@ -71,12 +72,15 @@ export class MaintenanceRepository extends BaseRepository<MaintenanceSchedule> {
|
|||||||
overdue: boolean;
|
overdue: boolean;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
|
// Every SCHEDULED item, not one per vehicle — a truck legitimately holds
|
||||||
|
// several (oil vs tires intervals differ).
|
||||||
return this.scheduleRepository.manager.query(`
|
return this.scheduleRepository.manager.query(`
|
||||||
SELECT DISTINCT ON (s.vehicle_id)
|
SELECT
|
||||||
s.id AS "scheduleId",
|
s.id AS "scheduleId",
|
||||||
s.vehicle_id AS "vehicleId",
|
s.vehicle_id AS "vehicleId",
|
||||||
v.plate_number AS "plateNumber",
|
v.plate_number AS "plateNumber",
|
||||||
s.maintenance_type AS "maintenanceType",
|
s.maintenance_type AS "maintenanceType",
|
||||||
|
s.service_item AS "serviceItem",
|
||||||
s.description,
|
s.description,
|
||||||
s.scheduled_date AS "scheduledDate",
|
s.scheduled_date AS "scheduledDate",
|
||||||
s.next_due_date AS "nextDueDate",
|
s.next_due_date AS "nextDueDate",
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import { MaintenanceIntervalRepository } from './maintenance-interval.repository
|
|||||||
import { MaintenanceSchedule, MaintenanceStatus, MaintenanceType } from './entities/maintenance-schedule.entity';
|
import { MaintenanceSchedule, MaintenanceStatus, MaintenanceType } from './entities/maintenance-schedule.entity';
|
||||||
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
||||||
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
|
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
|
||||||
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
|
import {
|
||||||
|
CreateMaintenanceScheduleDto,
|
||||||
|
CreateMaintenanceCostDto,
|
||||||
|
UpdateMaintenanceScheduleDto,
|
||||||
|
UpsertMaintenanceIntervalDto,
|
||||||
|
} from './dto/create-maintenance.dto';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -107,6 +112,10 @@ export class MaintenanceService {
|
|||||||
id: string,
|
id: string,
|
||||||
dto: UpdateMaintenanceScheduleDto,
|
dto: UpdateMaintenanceScheduleDto,
|
||||||
): Promise<MaintenanceSchedule> {
|
): Promise<MaintenanceSchedule> {
|
||||||
|
// Status BEFORE the write: completing an already-COMPLETED schedule again
|
||||||
|
// must not auto-create a second "next" schedule.
|
||||||
|
const before = await this.scheduleRepository.findOneBy({ id });
|
||||||
|
|
||||||
await this.scheduleRepository.update(id, {
|
await this.scheduleRepository.update(id, {
|
||||||
...dto,
|
...dto,
|
||||||
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
|
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
|
||||||
@@ -122,8 +131,12 @@ export class MaintenanceService {
|
|||||||
// Maintenance finished/aborted → vehicle back in service.
|
// Maintenance finished/aborted → vehicle back in service.
|
||||||
await this.setVehicleMaintenanceState(updated.vehicleId, false);
|
await this.setVehicleMaintenanceState(updated.vehicleId, false);
|
||||||
|
|
||||||
// If completed, schedule the next maintenance based on interval
|
// First transition into COMPLETED with an odometer → auto-schedule next.
|
||||||
if (dto.status === MaintenanceStatus.COMPLETED && updated.odometerReading != null) {
|
if (
|
||||||
|
dto.status === MaintenanceStatus.COMPLETED &&
|
||||||
|
before?.status !== MaintenanceStatus.COMPLETED &&
|
||||||
|
updated.odometerReading != null
|
||||||
|
) {
|
||||||
await this.scheduleNextMaintenance(updated);
|
await this.scheduleNextMaintenance(updated);
|
||||||
}
|
}
|
||||||
} else if (dto.status === MaintenanceStatus.IN_PROGRESS) {
|
} else if (dto.status === MaintenanceStatus.IN_PROGRESS) {
|
||||||
@@ -135,52 +148,75 @@ export class MaintenanceService {
|
|||||||
return updated!;
|
return updated!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Define/adjust how often a vehicle needs a service ("oil change every 10,000 km"). */
|
||||||
|
async upsertInterval(dto: UpsertMaintenanceIntervalDto) {
|
||||||
|
return this.intervalRepository.upsertInterval(
|
||||||
|
dto.vehicleId,
|
||||||
|
dto.maintenanceType,
|
||||||
|
dto.serviceItem ?? null,
|
||||||
|
dto.intervalKm ?? null,
|
||||||
|
dto.intervalDays ?? null,
|
||||||
|
dto.description ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIntervals(vehicleId: string) {
|
||||||
|
return this.intervalRepository.getActiveIntervals(vehicleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivateInterval(id: string): Promise<{ id: string; deactivated: boolean }> {
|
||||||
|
await this.intervalRepository.deactivate(id);
|
||||||
|
return { id, deactivated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-schedule the next service after a completion: matched on the
|
||||||
|
* completed schedule's (type, serviceItem) interval; one SCHEDULED row
|
||||||
|
* carrying BOTH thresholds when the interval defines km and days —
|
||||||
|
* whichever is crossed first makes it due.
|
||||||
|
*/
|
||||||
private async scheduleNextMaintenance(completed: MaintenanceSchedule): Promise<void> {
|
private async scheduleNextMaintenance(completed: MaintenanceSchedule): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Get maintenance interval for this type
|
|
||||||
const interval = await this.intervalRepository.getByVehicleAndType(
|
const interval = await this.intervalRepository.getByVehicleAndType(
|
||||||
completed.vehicleId,
|
completed.vehicleId,
|
||||||
completed.maintenanceType as MaintenanceType,
|
completed.maintenanceType as MaintenanceType,
|
||||||
|
completed.serviceItem,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!interval) return; // No interval defined, skip auto-scheduling
|
if (!interval) return; // No interval defined, skip auto-scheduling
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const completedKm = Number(completed.odometerReading ?? 0);
|
const completedKm = Number(completed.odometerReading ?? 0);
|
||||||
|
const intervalKm = Number(interval.intervalKm ?? 0);
|
||||||
|
const intervalDays = Number(interval.intervalDays ?? 0);
|
||||||
|
if (intervalKm <= 0 && intervalDays <= 0) return;
|
||||||
|
|
||||||
// Calculate next due based on KM interval
|
const nextDueKm = intervalKm > 0 ? completedKm + intervalKm : undefined;
|
||||||
if (interval.intervalKm && interval.intervalKm > 0) {
|
const nextDueDate =
|
||||||
const nextDueKm = completedKm + Number(interval.intervalKm);
|
intervalDays > 0
|
||||||
|
? new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Create next scheduled maintenance
|
const label = interval.serviceItem ? `${interval.serviceItem}: ` : '';
|
||||||
const nextSchedule = this.scheduleRepository.create({
|
const due = [
|
||||||
|
nextDueKm != null ? `${nextDueKm} km` : null,
|
||||||
|
nextDueDate != null ? nextDueDate.toISOString().slice(0, 10) : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' / ');
|
||||||
|
|
||||||
|
await this.scheduleRepository.save(
|
||||||
|
this.scheduleRepository.create({
|
||||||
vehicleId: completed.vehicleId,
|
vehicleId: completed.vehicleId,
|
||||||
maintenanceType: completed.maintenanceType,
|
maintenanceType: completed.maintenanceType,
|
||||||
description: `${interval.description || completed.description} (Next interval: ${nextDueKm} km)`,
|
serviceItem: completed.serviceItem ?? interval.serviceItem ?? null,
|
||||||
|
description: `${label}${interval.description || completed.description} (next due: ${due})`,
|
||||||
scheduledDate: now,
|
scheduledDate: now,
|
||||||
nextDueKm,
|
nextDueKm,
|
||||||
|
nextDueDate,
|
||||||
status: MaintenanceStatus.SCHEDULED,
|
status: MaintenanceStatus.SCHEDULED,
|
||||||
});
|
}),
|
||||||
await this.scheduleRepository.save(nextSchedule);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate next due based on date interval
|
|
||||||
if (interval.intervalDays && interval.intervalDays > 0) {
|
|
||||||
const nextDueDate = new Date(now.getTime() + interval.intervalDays * 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
// If no KM-based next maintenance was created, use date-based
|
|
||||||
if (!interval.intervalKm) {
|
|
||||||
const nextSchedule = this.scheduleRepository.create({
|
|
||||||
vehicleId: completed.vehicleId,
|
|
||||||
maintenanceType: completed.maintenanceType,
|
|
||||||
description: completed.description,
|
|
||||||
scheduledDate: now,
|
|
||||||
nextDueDate,
|
|
||||||
status: MaintenanceStatus.SCHEDULED,
|
|
||||||
});
|
|
||||||
await this.scheduleRepository.save(nextSchedule);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to schedule next maintenance for vehicle ${completed.vehicleId}: ${(err as Error).message}`,
|
`Failed to schedule next maintenance for vehicle ${completed.vehicleId}: ${(err as Error).message}`,
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { Public } from "@edr/api-common";
|
import { Public } from "@edr/api-common";
|
||||||
import { BookingView } from "../../common/booking-guards";
|
import { BookingStaff, BookingView } from "../../common/booking-guards";
|
||||||
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { PaymentService } from "./payment.service";
|
import { PaymentService } from "./payment.service";
|
||||||
import { IntentStatusDto } from "./payments.dto";
|
import { IntentStatusDto } from "./payments.dto";
|
||||||
|
|
||||||
@@ -25,7 +26,9 @@ import { IntentStatusDto } from "./payments.dto";
|
|||||||
export class PaymentController {
|
export class PaymentController {
|
||||||
constructor(private readonly paymentService: PaymentService) { }
|
constructor(private readonly paymentService: PaymentService) { }
|
||||||
|
|
||||||
|
// Customer-detail payments tab — same one-of rule as the bookings tab.
|
||||||
@Get("by-company/:companyId/customer-view")
|
@Get("by-company/:companyId/customer-view")
|
||||||
|
@BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.payments.view])
|
||||||
@ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" })
|
@ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" })
|
||||||
findByCompanyCustomerView(
|
findByCompanyCustomerView(
|
||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||||
const CURRENCIES = ['USD'] as const;
|
const CURRENCIES = ['USD'] as const;
|
||||||
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
|
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||||
|
export const CARGO_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||||
|
|
||||||
export class CreateRateDto {
|
export class CreateRateDto {
|
||||||
@ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' })
|
@ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' })
|
||||||
@@ -47,6 +48,15 @@ export class CreateRateDto {
|
|||||||
@IsIn([...INTERCITY_KINDS])
|
@IsIn([...INTERCITY_KINDS])
|
||||||
intercityKind?: string;
|
intercityKind?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: CARGO_KINDS,
|
||||||
|
description:
|
||||||
|
'Whether a customs clearance rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE. Not stored — container fees carry a containerTypeId, bulk fees none.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...CARGO_KINDS])
|
||||||
|
cargoKind?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',
|
'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',
|
||||||
|
|||||||
@@ -125,6 +125,22 @@ export class ListRatesQueryDto extends PaginationQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
rateType?: string;
|
rateType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Filter by rate category — comma-separated appliesTo values (e.g. "CONTAINER" or "FIRST_MILE,LAST_MILE").',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
appliesTo?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Filter by surcharge trigger — comma-separated trigger values (e.g. "CUSTOMS_CLEARANCE" or "HAZARDOUS,REEFER").',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
trigger?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {
|
export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
|
|||||||
export function allowedRateUnits(input: {
|
export function allowedRateUnits(input: {
|
||||||
appliesTo: RateAppliesTo;
|
appliesTo: RateAppliesTo;
|
||||||
trigger: RateTrigger;
|
trigger: RateTrigger;
|
||||||
|
/** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */
|
||||||
|
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||||
}): RateUnit[] {
|
}): RateUnit[] {
|
||||||
const { appliesTo, trigger } = input;
|
const { appliesTo, trigger } = input;
|
||||||
|
|
||||||
@@ -29,16 +31,20 @@ export function allowedRateUnits(input: {
|
|||||||
case 'DEMURRAGE':
|
case 'DEMURRAGE':
|
||||||
return ['PER_CONTAINER', 'PER_TON'];
|
return ['PER_CONTAINER', 'PER_TON'];
|
||||||
case 'WITH_RETURN':
|
case 'WITH_RETURN':
|
||||||
// Container-only empty-return service — bills per returned container.
|
// Container-only empty-return service — per returned container, per
|
||||||
return ['PER_CONTAINER', 'FLAT'];
|
// wagon the empties ride back on, or a flat fee.
|
||||||
|
return ['PER_CONTAINER', 'PER_WAGON', 'FLAT'];
|
||||||
case 'CANCELLATION':
|
case 'CANCELLATION':
|
||||||
return ['FLAT', 'PER_INVOICE'];
|
return ['FLAT', 'PER_INVOICE'];
|
||||||
case 'CUSTOMS_CLEARANCE':
|
case 'CUSTOMS_CLEARANCE':
|
||||||
// Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL).
|
// Sold per cargo kind: container fees bill per box or per wagon, bulk
|
||||||
return ['FLAT'];
|
// fees per ton or per wagon. Billed on the booking invoice.
|
||||||
|
return input.cargoKind === 'BULK'
|
||||||
|
? ['PER_TON', 'PER_WAGON']
|
||||||
|
: ['PER_CONTAINER', 'PER_WAGON'];
|
||||||
case 'LASHING':
|
case 'LASHING':
|
||||||
// Flat cargo-securing fee, billed once per booking.
|
// Bulk-only cargo securing — per ton or per wagon.
|
||||||
return ['FLAT'];
|
return ['PER_TON', 'PER_WAGON'];
|
||||||
case 'CONSOLIDATION':
|
case 'CONSOLIDATION':
|
||||||
return ['PER_CONTAINER', 'FLAT'];
|
return ['PER_CONTAINER', 'FLAT'];
|
||||||
case 'SHIPPING_LINE':
|
case 'SHIPPING_LINE':
|
||||||
@@ -74,6 +80,7 @@ export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: Rate
|
|||||||
export function isRateUnitAllowed(input: {
|
export function isRateUnitAllowed(input: {
|
||||||
appliesTo: RateAppliesTo;
|
appliesTo: RateAppliesTo;
|
||||||
trigger: RateTrigger;
|
trigger: RateTrigger;
|
||||||
|
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||||
unit: RateUnit;
|
unit: RateUnit;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return allowedRateUnits(input).includes(input.unit);
|
return allowedRateUnits(input).includes(input.unit);
|
||||||
|
|||||||
@@ -117,6 +117,21 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
if (query.rateType) {
|
if (query.rateType) {
|
||||||
qb.andWhere('rate.rateType = :rateType', { rateType: query.rateType });
|
qb.andWhere('rate.rateType = :rateType', { rateType: query.rateType });
|
||||||
}
|
}
|
||||||
|
// Category tabs on the admin page: comma-separated appliesTo / trigger
|
||||||
|
// lists, ANDed together (e.g. appliesTo=OTHER + trigger=CUSTOMS_CLEARANCE).
|
||||||
|
const csv = (v?: string) =>
|
||||||
|
(v ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const appliesTo = csv(query.appliesTo);
|
||||||
|
if (appliesTo.length > 0) {
|
||||||
|
qb.andWhere('rate.appliesTo IN (:...appliesTo)', { appliesTo });
|
||||||
|
}
|
||||||
|
const triggers = csv(query.trigger);
|
||||||
|
if (triggers.length > 0) {
|
||||||
|
qb.andWhere('rate.trigger IN (:...triggers)', { triggers });
|
||||||
|
}
|
||||||
if (query.search) {
|
if (query.search) {
|
||||||
qb.andWhere(
|
qb.andWhere(
|
||||||
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)',
|
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)',
|
||||||
|
|||||||
@@ -76,3 +76,349 @@ describe('RuleEngineService — requested service without a configured surcharge
|
|||||||
expect(result.hardBlocked[0]).toContain('reefer');
|
expect(result.hardBlocked[0]).toContain('reefer');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('RuleEngineService — overweight surcharge by trade direction', () => {
|
||||||
|
const baseImportRate: Rate = {
|
||||||
|
id: 'rate-import-20',
|
||||||
|
rateType: 'CONTAINER_IMPORT',
|
||||||
|
trigger: 'ALWAYS',
|
||||||
|
rateValue: 1000,
|
||||||
|
rateUnit: 'PER_CONTAINER',
|
||||||
|
currency: 'USD',
|
||||||
|
status: 'LIVE',
|
||||||
|
containerTypeId: 'ct-20',
|
||||||
|
cargoTypeId: null,
|
||||||
|
originYardId: 'yard-dj',
|
||||||
|
destinationYardId: 'yard-adama',
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
const configuredOverweight: Rate = {
|
||||||
|
id: 'rate-ow',
|
||||||
|
rateType: 'OVERWEIGHT_PER_TON',
|
||||||
|
trigger: 'OVERWEIGHT',
|
||||||
|
rateValue: 10,
|
||||||
|
rateUnit: 'PER_TON',
|
||||||
|
currency: 'USD',
|
||||||
|
status: 'LIVE',
|
||||||
|
containerTypeId: null,
|
||||||
|
cargoTypeId: null,
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
let service: RuleEngineService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new RuleEngineService(
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{
|
||||||
|
findActiveByContainerTypeId: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
|
||||||
|
} as never,
|
||||||
|
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{
|
||||||
|
findLiveRates: jest.fn().mockResolvedValue([baseImportRate, configuredOverweight]),
|
||||||
|
} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// One 20ft at 25 t against a 20 t limit → 5 t excess.
|
||||||
|
const overweightInput = (tradeDirection: string): BookingEvaluationInput => ({
|
||||||
|
serviceTypeId: 'svc-1',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
tradeDirection,
|
||||||
|
isHazardous: false,
|
||||||
|
totalWagons: 1,
|
||||||
|
originYardId: 'yard-dj',
|
||||||
|
destinationYardId: 'yard-adama',
|
||||||
|
containers: [
|
||||||
|
{ containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it('IMPORT derives the per-ton price from base freight ÷ (2 × limit), not the configured rate', async () => {
|
||||||
|
const result = await service.evaluate(overweightInput('IMPORT'));
|
||||||
|
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
|
||||||
|
expect(ow).toHaveLength(1);
|
||||||
|
// 1000 / (2 × 20) = 25 USD/t on 5 excess tons.
|
||||||
|
expect(ow[0].unitPriceUsd).toBe(25);
|
||||||
|
expect(ow[0].calculatedAmount).toBe(125);
|
||||||
|
expect(ow[0].triggerValue).toBe(5);
|
||||||
|
expect(ow[0].rateId).toBe(baseImportRate.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('EXPORT keeps billing the configured OVERWEIGHT rate', async () => {
|
||||||
|
const result = await service.evaluate(overweightInput('EXPORT'));
|
||||||
|
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
|
||||||
|
expect(ow).toHaveLength(1);
|
||||||
|
expect(ow[0].rateId).toBe(configuredOverweight.id);
|
||||||
|
// 5 excess tons × the configured 10 USD/t.
|
||||||
|
expect(ow[0].calculatedAmount).toBe(50);
|
||||||
|
expect(ow[0].unitPriceUsd).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('IMPORT without a route-matching base rate bills no overweight (base freight blocks anyway)', async () => {
|
||||||
|
const result = await service.evaluate({
|
||||||
|
...overweightInput('IMPORT'),
|
||||||
|
destinationYardId: 'yard-elsewhere',
|
||||||
|
});
|
||||||
|
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
|
||||||
|
expect(ow).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RuleEngineService — empty-container return per route + container type', () => {
|
||||||
|
const returnRate20: Rate = {
|
||||||
|
id: 'rate-return-20',
|
||||||
|
rateType: 'RETURN_SURCHARGE',
|
||||||
|
trigger: 'WITH_RETURN',
|
||||||
|
rateValue: 20,
|
||||||
|
rateUnit: 'PER_CONTAINER',
|
||||||
|
currency: 'USD',
|
||||||
|
status: 'LIVE',
|
||||||
|
containerTypeId: 'ct-20',
|
||||||
|
cargoTypeId: null,
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
originYardId: 'yard-dj',
|
||||||
|
destinationYardId: 'yard-adama',
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
let service: RuleEngineService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new RuleEngineService(
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{
|
||||||
|
findActiveByContainerTypeId: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
|
||||||
|
} as never,
|
||||||
|
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ findLiveRates: jest.fn().mockResolvedValue([returnRate20]) } as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const returnInput = (overrides: Partial<BookingEvaluationInput>): BookingEvaluationInput => ({
|
||||||
|
serviceTypeId: 'svc-1',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
isHazardous: false,
|
||||||
|
totalWagons: 1,
|
||||||
|
originYardId: 'yard-dj',
|
||||||
|
destinationYardId: 'yard-adama',
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
containerTypeId: 'ct-20',
|
||||||
|
quantity: 4,
|
||||||
|
vgmPerUnitTons: 10,
|
||||||
|
totalVgmTons: 40,
|
||||||
|
returnQuantity: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bills the route + type matched rate on the opted-in count', async () => {
|
||||||
|
const result = await service.evaluate(returnInput({}));
|
||||||
|
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
|
||||||
|
expect(result.hardBlocked).toHaveLength(0);
|
||||||
|
expect(ret).toHaveLength(1);
|
||||||
|
expect(ret[0].rateId).toBe(returnRate20.id);
|
||||||
|
expect(ret[0].triggerValue).toBe(2);
|
||||||
|
expect(ret[0].calculatedAmount).toBe(40);
|
||||||
|
expect(ret[0].billingUnit).toBe('PER_CONTAINER');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hard-blocks when the booking route has no matching return rate', async () => {
|
||||||
|
const result = await service.evaluate(
|
||||||
|
returnInput({ destinationYardId: 'yard-elsewhere' }),
|
||||||
|
);
|
||||||
|
expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
|
||||||
|
expect(
|
||||||
|
result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'),
|
||||||
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hard-blocks an EXPORT booking asking for return (rates are import-only)', async () => {
|
||||||
|
const result = await service.evaluate(returnInput({ tradeDirection: 'EXPORT' }));
|
||||||
|
expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PER_WAGON bills the wagons the empties ride back on, not the boxes', async () => {
|
||||||
|
// Same service, but the return rate is sold per wagon: 4× 20ft return =
|
||||||
|
// 2 wagons (two 20ft share a wagon) × 20 USD, not 4 × 20.
|
||||||
|
service = new RuleEngineService(
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{
|
||||||
|
findActiveByContainerTypeId: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
|
||||||
|
} as never,
|
||||||
|
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{
|
||||||
|
findLiveRates: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ ...returnRate20, rateUnit: 'PER_WAGON' } as Rate]),
|
||||||
|
} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.evaluate(
|
||||||
|
returnInput({
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
containerTypeId: 'ct-20',
|
||||||
|
quantity: 4,
|
||||||
|
vgmPerUnitTons: 10,
|
||||||
|
totalVgmTons: 40,
|
||||||
|
returnQuantity: 4,
|
||||||
|
wagonsPerUnit: 0.5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
|
||||||
|
expect(ret).toHaveLength(1);
|
||||||
|
expect(ret[0].triggerValue).toBe(2);
|
||||||
|
expect(ret[0].calculatedAmount).toBe(40);
|
||||||
|
expect(ret[0].billingUnit).toBe('PER_WAGON');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legacy booking-level flag bills every container at its type rate', async () => {
|
||||||
|
const result = await service.evaluate(
|
||||||
|
returnInput({
|
||||||
|
withReturn: true,
|
||||||
|
containers: [
|
||||||
|
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
|
||||||
|
expect(ret).toHaveLength(1);
|
||||||
|
expect(ret[0].triggerValue).toBe(4);
|
||||||
|
expect(ret[0].calculatedAmount).toBe(80);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RuleEngineService — lashing (bulk-only, per direction + commodity)', () => {
|
||||||
|
const lashingBulkImport: Rate = {
|
||||||
|
id: 'rate-lash-bulk',
|
||||||
|
rateType: 'LASHING',
|
||||||
|
trigger: 'LASHING',
|
||||||
|
rateValue: 2,
|
||||||
|
rateUnit: 'PER_TON',
|
||||||
|
currency: 'USD',
|
||||||
|
status: 'LIVE',
|
||||||
|
containerTypeId: null,
|
||||||
|
cargoTypeId: null,
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
originYardId: null,
|
||||||
|
destinationYardId: null,
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
const buildService = (rates: Rate[]): RuleEngineService =>
|
||||||
|
new RuleEngineService(
|
||||||
|
{
|
||||||
|
findById: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ hasLashing: true, requiresDirectorApproval: false }),
|
||||||
|
} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const bulkInput = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
|
||||||
|
serviceTypeId: 'svc-1',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
isHazardous: false,
|
||||||
|
cargoTypeId: 'cargo-sugar',
|
||||||
|
totalWagons: 0,
|
||||||
|
bulkTons: 100,
|
||||||
|
bulkWagons: 3,
|
||||||
|
containers: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lashingMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
|
||||||
|
result.appliedModifiers.filter((m) => m.surchargeCode === 'LASHING');
|
||||||
|
|
||||||
|
it('bulk lashing bills per ton on the direction-matched rate', async () => {
|
||||||
|
const result = await buildService([lashingBulkImport]).evaluate(bulkInput());
|
||||||
|
const mods = lashingMods(result);
|
||||||
|
expect(mods).toHaveLength(1);
|
||||||
|
expect(mods[0].triggerValue).toBe(100);
|
||||||
|
expect(mods[0].calculatedAmount).toBe(200);
|
||||||
|
expect(mods[0].billingUnit).toBe('PER_TON');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a rate for the other direction never bills', async () => {
|
||||||
|
const result = await buildService([
|
||||||
|
{ ...lashingBulkImport, tradeDirection: 'EXPORT' } as Rate,
|
||||||
|
]).evaluate(bulkInput());
|
||||||
|
expect(lashingMods(result)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PER_WAGON bulk lashing bills the wagons the bulk occupies', async () => {
|
||||||
|
const result = await buildService([
|
||||||
|
{ ...lashingBulkImport, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate,
|
||||||
|
]).evaluate(bulkInput());
|
||||||
|
const mods = lashingMods(result);
|
||||||
|
expect(mods[0].triggerValue).toBe(3);
|
||||||
|
expect(mods[0].calculatedAmount).toBe(75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the commodity-scoped rate wins over the commodity-wide catch-all', async () => {
|
||||||
|
const result = await buildService([
|
||||||
|
lashingBulkImport,
|
||||||
|
{ ...lashingBulkImport, id: 'rate-lash-sugar', rateValue: 7, cargoTypeId: 'cargo-sugar' } as Rate,
|
||||||
|
]).evaluate(bulkInput());
|
||||||
|
const mods = lashingMods(result);
|
||||||
|
expect(mods).toHaveLength(1);
|
||||||
|
expect(mods[0].unitPriceUsd).toBe(7);
|
||||||
|
expect(mods[0].calculatedAmount).toBe(700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('container bookings never incur lashing (bulk-only service)', async () => {
|
||||||
|
const result = await buildService([lashingBulkImport]).evaluate(
|
||||||
|
bulkInput({
|
||||||
|
cargoTypeId: null,
|
||||||
|
hasLashing: true,
|
||||||
|
containers: [
|
||||||
|
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(lashingMods(result)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no lashing charge when the cargo does not need lashing', async () => {
|
||||||
|
const service = new RuleEngineService(
|
||||||
|
{
|
||||||
|
findById: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ hasLashing: false, requiresDirectorApproval: false }),
|
||||||
|
} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ findLiveRates: jest.fn().mockResolvedValue([lashingBulkImport]) } as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
const result = await service.evaluate(bulkInput());
|
||||||
|
expect(lashingMods(result)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ export interface BookingContainerEvalInput {
|
|||||||
hazardousQuantity?: number;
|
hazardousQuantity?: number;
|
||||||
reeferQuantity?: number;
|
reeferQuantity?: number;
|
||||||
returnQuantity?: number;
|
returnQuantity?: number;
|
||||||
|
/**
|
||||||
|
* Wagon fraction one container of this line occupies (40ft = 1, 20ft = 0.5).
|
||||||
|
* Lets a PER_WAGON empty-return rate bill the wagons the returned empties
|
||||||
|
* ride back on. Missing ⇒ one wagon per container.
|
||||||
|
*/
|
||||||
|
wagonsPerUnit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingEvaluationInput {
|
export interface BookingEvaluationInput {
|
||||||
@@ -67,6 +73,12 @@ export interface BookingEvaluationInput {
|
|||||||
isGovernment?: boolean;
|
isGovernment?: boolean;
|
||||||
allowConsolidation?: boolean;
|
allowConsolidation?: boolean;
|
||||||
shippingLineId?: string | null;
|
shippingLineId?: string | null;
|
||||||
|
/**
|
||||||
|
* The booking's rail leg. Import overweight derives its per-ton price from
|
||||||
|
* this route's own container freight rate, so the engine needs the yards.
|
||||||
|
*/
|
||||||
|
originYardId?: string | null;
|
||||||
|
destinationYardId?: string | null;
|
||||||
/**
|
/**
|
||||||
* Booking's cargo type needs EDR-provided lashing/securing (cargoType
|
* Booking's cargo type needs EDR-provided lashing/securing (cargoType
|
||||||
* hasLashing = true). Fires the flat LASHING surcharge. Resolved by the
|
* hasLashing = true). Fires the flat LASHING surcharge. Resolved by the
|
||||||
@@ -80,6 +92,12 @@ export interface BookingEvaluationInput {
|
|||||||
* container freight, which is scaled by container count instead.
|
* container freight, which is scaled by container count instead.
|
||||||
*/
|
*/
|
||||||
bulkTons?: number;
|
bulkTons?: number;
|
||||||
|
/**
|
||||||
|
* Wagons a BULK booking occupies (ceil(tons ÷ wagon capacity)), resolved by
|
||||||
|
* the pricing service. Scales PER_WAGON kind-scoped surcharges (lashing);
|
||||||
|
* 0/undefined when unknown — those charges then bill nothing.
|
||||||
|
*/
|
||||||
|
bulkWagons?: number;
|
||||||
containers: BookingContainerEvalInput[];
|
containers: BookingContainerEvalInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +109,15 @@ export interface AppliedCargoModifier {
|
|||||||
triggerValue: number | null;
|
triggerValue: number | null;
|
||||||
calculatedAmount: number;
|
calculatedAmount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
/**
|
||||||
|
* Effective per-unit USD price when it differs from the rate row's own value
|
||||||
|
* — set by derived charges (import overweight: base freight ÷ 2×limit) so
|
||||||
|
* the breakdown shows the real per-ton figure, not the base container price.
|
||||||
|
* Any modifier carrying it also bypasses frozen contract snapshots.
|
||||||
|
*/
|
||||||
|
unitPriceUsd?: number | null;
|
||||||
|
/** Display unit for a unitPriceUsd modifier (e.g. PER_TON for overweight). */
|
||||||
|
billingUnit?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContainerWeightResult {
|
export interface ContainerWeightResult {
|
||||||
@@ -165,12 +192,16 @@ export class RuleEngineService {
|
|||||||
...(await this.capacityViolations(input.containers, input.tradeDirection)),
|
...(await this.capacityViolations(input.containers, input.tradeDirection)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Per-container-line weight limit (maxVgmTons), index-aligned with
|
||||||
|
// containerWeightResults — the derived import overweight divides by it.
|
||||||
|
const lineMaxVgmTons: Array<number | null> = [];
|
||||||
for (const container of input.containers) {
|
for (const container of input.containers) {
|
||||||
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
|
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
|
||||||
container.containerTypeId,
|
container.containerTypeId,
|
||||||
input.tradeDirection,
|
input.tradeDirection,
|
||||||
);
|
);
|
||||||
const rule = rules[0];
|
const rule = rules[0];
|
||||||
|
lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null);
|
||||||
let isOverweight = container.isOverweight ?? false;
|
let isOverweight = container.isOverweight ?? false;
|
||||||
let excess = container.overweightExcessTons ?? null;
|
let excess = container.overweightExcessTons ?? null;
|
||||||
|
|
||||||
@@ -273,13 +304,6 @@ export class RuleEngineService {
|
|||||||
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
|
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
|
||||||
label: 'refrigerated (reefer) cargo',
|
label: 'refrigerated (reefer) cargo',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
trigger: 'WITH_RETURN',
|
|
||||||
wanted:
|
|
||||||
truthy(input.withReturn) ||
|
|
||||||
input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0),
|
|
||||||
label: 'empty-container return',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
for (const svc of requestedServices) {
|
for (const svc of requestedServices) {
|
||||||
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
|
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
|
||||||
@@ -292,6 +316,17 @@ export class RuleEngineService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const rate of surchargeRates) {
|
for (const rate of surchargeRates) {
|
||||||
|
// Import overweight never bills the configured rate — its per-ton price
|
||||||
|
// derives from the route's base container freight (see below).
|
||||||
|
if (rate.trigger === 'OVERWEIGHT' && input.tradeDirection === 'IMPORT') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Empty-container return is sold per route + container type — billed by
|
||||||
|
// the route-matched block below, never by this route-agnostic loop.
|
||||||
|
if (rate.trigger === 'WITH_RETURN') continue;
|
||||||
|
// Lashing is sold per cargo kind + container type — billed by the
|
||||||
|
// kind-aware block below, never by this generic loop.
|
||||||
|
if (rate.trigger === 'LASHING') continue;
|
||||||
const triggered = this.matchesTrigger(rate.trigger, {
|
const triggered = this.matchesTrigger(rate.trigger, {
|
||||||
isHazardous: input.isHazardous,
|
isHazardous: input.isHazardous,
|
||||||
hasReefer,
|
hasReefer,
|
||||||
@@ -384,6 +419,25 @@ export class RuleEngineService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.tradeDirection === 'IMPORT') {
|
||||||
|
appliedModifiers.push(
|
||||||
|
...this.derivedImportOverweight(
|
||||||
|
input,
|
||||||
|
containerWeightResults,
|
||||||
|
lineMaxVgmTons,
|
||||||
|
liveRates,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const withReturn = this.withReturnCharges(input, liveRates);
|
||||||
|
appliedModifiers.push(...withReturn.modifiers);
|
||||||
|
hardBlocked.push(...withReturn.blocked);
|
||||||
|
|
||||||
|
if (hasLashing) {
|
||||||
|
appliedModifiers.push(...this.lashingCharges(input, liveRates));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
priorityScore,
|
priorityScore,
|
||||||
appliedModifiers,
|
appliedModifiers,
|
||||||
@@ -394,6 +448,186 @@ export class RuleEngineService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import overweight — derived, never configured. Each overweight container
|
||||||
|
* line bills its excess tons at (its own base import freight on the booking's
|
||||||
|
* route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit →
|
||||||
|
* 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate.
|
||||||
|
* Note: derives from the LIVE route rate even for frozen-rate contract
|
||||||
|
* bookings — the frozen snapshot has no route-scoped container price to
|
||||||
|
* divide.
|
||||||
|
*/
|
||||||
|
private derivedImportOverweight(
|
||||||
|
input: BookingEvaluationInput,
|
||||||
|
weightResults: ContainerWeightResult[],
|
||||||
|
lineMaxVgmTons: Array<number | null>,
|
||||||
|
liveRates: Rate[],
|
||||||
|
): AppliedCargoModifier[] {
|
||||||
|
const modifiers: AppliedCargoModifier[] = [];
|
||||||
|
if (!input.originYardId || !input.destinationYardId) return modifiers;
|
||||||
|
|
||||||
|
for (let i = 0; i < weightResults.length; i++) {
|
||||||
|
const wr = weightResults[i];
|
||||||
|
const excess = Number(wr?.overweightExcessTons ?? 0);
|
||||||
|
const maxVgm = Number(lineMaxVgmTons[i] ?? 0);
|
||||||
|
if (!wr?.isOverweight || !(excess > 0) || !(maxVgm > 0)) continue;
|
||||||
|
|
||||||
|
// Same precedence as base freight pricing: the rate scoped to this
|
||||||
|
// container type wins over the route's catch-all rate.
|
||||||
|
const onLeg = liveRates.filter(
|
||||||
|
(r) =>
|
||||||
|
r.rateType === 'CONTAINER_IMPORT' &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
r.originYardId === input.originYardId &&
|
||||||
|
r.destinationYardId === input.destinationYardId,
|
||||||
|
);
|
||||||
|
const base =
|
||||||
|
onLeg.find((r) => r.containerTypeId === wr.containerTypeId) ??
|
||||||
|
onLeg.find((r) => !r.containerTypeId);
|
||||||
|
// No base rate → the base-freight line hard-blocks this booking anyway.
|
||||||
|
if (!base) continue;
|
||||||
|
|
||||||
|
const perTon = Number(base.rateValue) / (2 * maxVgm);
|
||||||
|
const amount = excess * perTon;
|
||||||
|
if (!(amount > 0)) continue;
|
||||||
|
|
||||||
|
modifiers.push({
|
||||||
|
rateId: base.id,
|
||||||
|
surchargeCode: 'OVERWEIGHT_PER_TON',
|
||||||
|
triggerValue: excess,
|
||||||
|
calculatedAmount: amount,
|
||||||
|
currency: base.currency,
|
||||||
|
unitPriceUsd: perTon,
|
||||||
|
billingUnit: 'PER_TON',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return modifiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty-container return — sold per direction + route + container type, like
|
||||||
|
* base freight. Each container line that opted in (returnQuantity, or every
|
||||||
|
* container when only the legacy booking-level flag is set) bills the
|
||||||
|
* route-matched WITH_RETURN rate for its own container type; a line with no
|
||||||
|
* matching rate hard-blocks the booking instead of shipping the service for
|
||||||
|
* free. Rates are import-only for now, so an export booking that asks for
|
||||||
|
* return blocks too.
|
||||||
|
* ponytail: bills the LIVE route rate, not a frozen contract snapshot — one
|
||||||
|
* RETURN_SURCHARGE snapshot code can't hold per-size route prices.
|
||||||
|
*/
|
||||||
|
private withReturnCharges(
|
||||||
|
input: BookingEvaluationInput,
|
||||||
|
liveRates: Rate[],
|
||||||
|
): { modifiers: AppliedCargoModifier[]; blocked: string[] } {
|
||||||
|
const modifiers: AppliedCargoModifier[] = [];
|
||||||
|
const blocked: string[] = [];
|
||||||
|
const bookingLevel = truthy(input.withReturn);
|
||||||
|
const wanted =
|
||||||
|
bookingLevel || input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0);
|
||||||
|
if (!wanted) return { modifiers, blocked };
|
||||||
|
|
||||||
|
const onLeg = liveRates.filter(
|
||||||
|
(r) =>
|
||||||
|
r.trigger === 'WITH_RETURN' &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
r.tradeDirection === input.tradeDirection &&
|
||||||
|
r.originYardId === input.originYardId &&
|
||||||
|
r.destinationYardId === input.destinationYardId,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const container of input.containers) {
|
||||||
|
const qty =
|
||||||
|
Number(container.returnQuantity ?? 0) > 0
|
||||||
|
? Number(container.returnQuantity)
|
||||||
|
: bookingLevel
|
||||||
|
? Number(container.quantity || 0)
|
||||||
|
: 0;
|
||||||
|
if (!(qty > 0)) continue;
|
||||||
|
|
||||||
|
const rate =
|
||||||
|
onLeg.find((r) => r.containerTypeId === container.containerTypeId) ??
|
||||||
|
onLeg.find((r) => !r.containerTypeId);
|
||||||
|
if (!rate) {
|
||||||
|
blocked.push(
|
||||||
|
'No empty-container return rate is configured for this container ' +
|
||||||
|
'type on this route (return is import-only) — remove the return ' +
|
||||||
|
'option or ask EDR to configure its rate for this origin → destination.',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateValue = Number(rate.rateValue);
|
||||||
|
// PER_WAGON bills the wagons the returned empties occupy (two 20ft share
|
||||||
|
// one wagon), PER_CONTAINER the boxes themselves, FLAT once per line.
|
||||||
|
const billed =
|
||||||
|
rate.rateUnit === 'PER_WAGON'
|
||||||
|
? Math.ceil(qty * (container.wagonsPerUnit ?? 1))
|
||||||
|
: qty;
|
||||||
|
const amount = rate.rateUnit === 'FLAT' ? rateValue : billed * rateValue;
|
||||||
|
if (!(amount > 0)) continue;
|
||||||
|
modifiers.push({
|
||||||
|
rateId: rate.id,
|
||||||
|
surchargeCode: this.surchargeCode(rate),
|
||||||
|
triggerValue: rate.rateUnit === 'FLAT' ? qty : billed,
|
||||||
|
calculatedAmount: amount,
|
||||||
|
currency: rate.currency,
|
||||||
|
unitPriceUsd: rateValue,
|
||||||
|
billingUnit: rate.rateUnit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same block deduplicated — several lines missing the rate is one problem.
|
||||||
|
return { modifiers, blocked: [...new Set(blocked)] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cargo securing / lashing — BULK only, sold per trade direction, optionally
|
||||||
|
* narrowed to one leaf commodity (the commodity-scoped rate wins over the
|
||||||
|
* commodity-wide catch-all). Bills PER_TON × tonnage or PER_WAGON × the
|
||||||
|
* wagons the bulk occupies. Container bookings never incur lashing, and an
|
||||||
|
* unconfigured rate simply bills nothing — same leniency as hazard/reefer.
|
||||||
|
*/
|
||||||
|
private lashingCharges(
|
||||||
|
input: BookingEvaluationInput,
|
||||||
|
liveRates: Rate[],
|
||||||
|
): AppliedCargoModifier[] {
|
||||||
|
const modifiers: AppliedCargoModifier[] = [];
|
||||||
|
if (input.containers.length > 0) return modifiers; // bulk-only service
|
||||||
|
|
||||||
|
const onDirection = liveRates.filter(
|
||||||
|
(r) =>
|
||||||
|
r.trigger === 'LASHING' &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
!r.containerTypeId &&
|
||||||
|
r.tradeDirection === input.tradeDirection,
|
||||||
|
);
|
||||||
|
const rate =
|
||||||
|
(input.cargoTypeId
|
||||||
|
? onDirection.find((r) => r.cargoTypeId === input.cargoTypeId)
|
||||||
|
: undefined) ?? onDirection.find((r) => !r.cargoTypeId);
|
||||||
|
if (!rate) return modifiers;
|
||||||
|
|
||||||
|
const billedQty =
|
||||||
|
rate.rateUnit === 'PER_TON'
|
||||||
|
? Math.max(0, Number(input.bulkTons ?? 0))
|
||||||
|
: rate.rateUnit === 'PER_WAGON'
|
||||||
|
? Math.max(0, Number(input.bulkWagons ?? 0))
|
||||||
|
: 1;
|
||||||
|
const rateValue = Number(rate.rateValue);
|
||||||
|
const amount = rate.rateUnit === 'FLAT' ? rateValue : billedQty * rateValue;
|
||||||
|
if (!(amount > 0)) return modifiers;
|
||||||
|
modifiers.push({
|
||||||
|
rateId: rate.id,
|
||||||
|
surchargeCode: this.surchargeCode(rate),
|
||||||
|
triggerValue: rate.rateUnit === 'FLAT' ? 1 : billedQty,
|
||||||
|
calculatedAmount: amount,
|
||||||
|
currency: rate.currency,
|
||||||
|
unitPriceUsd: rateValue,
|
||||||
|
billingUnit: rate.rateUnit,
|
||||||
|
});
|
||||||
|
return modifiers;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Messages for container lines whose total weight exceeds the hard capacity
|
* Messages for container lines whose total weight exceeds the hard capacity
|
||||||
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
|
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
|
||||||
|
|||||||
@@ -101,6 +101,23 @@ describe('RateChangeRequestsService', () => {
|
|||||||
expect(request.payload).toEqual({ rateValue: 200 });
|
expect(request.payload).toEqual({ rateValue: 200 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('carries a re-routed leg — a yard-only edit is a real change', async () => {
|
||||||
|
const { service } = build({
|
||||||
|
rate: liveRate({ originYardId: 'yard-a', destinationYardId: 'yard-b' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = await service.submit({
|
||||||
|
rateId: 'rate-1',
|
||||||
|
update: {
|
||||||
|
rateValue: 100,
|
||||||
|
originYardId: 'yard-a',
|
||||||
|
destinationYardId: 'yard-c',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.payload).toEqual({ destinationYardId: 'yard-c' });
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
|
it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
|
||||||
const { service } = build();
|
const { service } = build();
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ const DIFFABLE_FIELDS = [
|
|||||||
'tradeDirection',
|
'tradeDirection',
|
||||||
'containerTypeId',
|
'containerTypeId',
|
||||||
'cargoTypeId',
|
'cargoTypeId',
|
||||||
|
// The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate
|
||||||
|
// diffed to nothing and the submit was refused as "nothing changed".
|
||||||
|
'originYardId',
|
||||||
|
'destinationYardId',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -69,18 +69,19 @@ export class RatesService {
|
|||||||
appliesTo: Rate['appliesTo'],
|
appliesTo: Rate['appliesTo'],
|
||||||
trigger: Rate['trigger'],
|
trigger: Rate['trigger'],
|
||||||
requestedUnit: Rate['rateUnit'] | undefined,
|
requestedUnit: Rate['rateUnit'] | undefined,
|
||||||
|
cargoKind?: 'CONTAINER' | 'BULK' | null,
|
||||||
): Rate['rateUnit'] {
|
): Rate['rateUnit'] {
|
||||||
// Overweight is per-ton, full stop — the admin form hides the unit field
|
// Overweight is per-ton, full stop — the admin form hides the unit field
|
||||||
// for it and omits rateUnit from the payload entirely.
|
// for it and omits rateUnit from the payload entirely.
|
||||||
if (trigger === 'OVERWEIGHT') return 'PER_TON';
|
if (trigger === 'OVERWEIGHT') return 'PER_TON';
|
||||||
|
|
||||||
const allowed = allowedRateUnits({ appliesTo, trigger });
|
const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind });
|
||||||
if (!requestedUnit) {
|
if (!requestedUnit) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
|
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
|
if (!isRateUnitAllowed({ appliesTo, trigger, cargoKind, unit: requestedUnit })) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
|
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
|
||||||
);
|
);
|
||||||
@@ -93,6 +94,19 @@ export class RatesService {
|
|||||||
return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo);
|
return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rates sold per direction + route. Base freight always; customs clearance
|
||||||
|
* and empty-container return are the surcharges that are too — their fee
|
||||||
|
* depends on the lane (and, for returns, the container type).
|
||||||
|
*/
|
||||||
|
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
||||||
|
return (
|
||||||
|
this.isBaseFreight(appliesTo, trigger) ||
|
||||||
|
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||||
|
trigger === 'WITH_RETURN'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which country each end of the leg must sit in, given what the rate is for.
|
* Which country each end of the leg must sit in, given what the rate is for.
|
||||||
* The railway only sells three shapes: import lands at the Djibouti ports and
|
* The railway only sells three shapes: import lands at the Djibouti ports and
|
||||||
@@ -126,7 +140,7 @@ export class RatesService {
|
|||||||
destinationYardId?: string | null;
|
destinationYardId?: string | null;
|
||||||
}): Promise<YardScope> {
|
}): Promise<YardScope> {
|
||||||
const { appliesTo, trigger, tradeDirection } = input;
|
const { appliesTo, trigger, tradeDirection } = input;
|
||||||
if (!this.isBaseFreight(appliesTo, trigger)) {
|
if (!this.isRouteScoped(appliesTo, trigger)) {
|
||||||
return { originYardId: null, destinationYardId: null };
|
return { originYardId: null, destinationYardId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +148,7 @@ export class RatesService {
|
|||||||
const destinationYardId = input.destinationYardId ?? null;
|
const destinationYardId = input.destinationYardId ?? null;
|
||||||
if (!originYardId || !destinationYardId) {
|
if (!originYardId || !destinationYardId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Base freight rates are priced per leg — pick both an origin and a destination yard.',
|
'This rate is priced per leg — pick both an origin and a destination yard.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (originYardId === destinationYardId) {
|
if (originYardId === destinationYardId) {
|
||||||
@@ -174,11 +188,76 @@ export class RatesService {
|
|||||||
trigger: Rate['trigger'];
|
trigger: Rate['trigger'];
|
||||||
tradeDirection: string | null;
|
tradeDirection: string | null;
|
||||||
intercityKind: string | null;
|
intercityKind: string | null;
|
||||||
|
cargoKind: string | null;
|
||||||
containerTypeId: string | null;
|
containerTypeId: string | null;
|
||||||
cargoTypeId: string | null;
|
cargoTypeId: string | null;
|
||||||
}): void {
|
}): void {
|
||||||
const { appliesTo, trigger, tradeDirection, intercityKind } = input;
|
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
|
||||||
const { containerTypeId, cargoTypeId } = input;
|
const { containerTypeId, cargoTypeId } = input;
|
||||||
|
if (trigger === 'CUSTOMS_CLEARANCE') {
|
||||||
|
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Sold per cargo kind: a container fee names the container type it covers
|
||||||
|
// (20ft and 40ft price differently); a bulk fee carries no type at all —
|
||||||
|
// that absence is what marks it as the bulk fee.
|
||||||
|
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A customs clearance rate must say whether it covers containers or bulk.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cargoKind === 'CONTAINER' && !containerTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A container customs clearance rate must name the container type it covers.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cargoKind === 'BULK' && containerTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A bulk customs clearance rate cannot be scoped to a container type.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The bulk customs fee names the commodity it covers (sugar and
|
||||||
|
// fertilizer clear differently).
|
||||||
|
if (cargoKind === 'BULK' && !cargoTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A bulk customs clearance rate must name the bulk cargo type it covers.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cargoKind === 'CONTAINER' && cargoTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A container customs clearance rate cannot be scoped to a bulk cargo type.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trigger === 'LASHING') {
|
||||||
|
// Bulk-only cargo securing, sold per direction. May narrow to one leaf
|
||||||
|
// commodity (specific wins over the commodity-wide catch-all).
|
||||||
|
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A lashing rate must say whether it covers IMPORT or EXPORT.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (containerTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Lashing is bulk-only — it cannot be scoped to a container type.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trigger === 'WITH_RETURN') {
|
||||||
|
// Returning the empty box only exists on imports (the box goes back to
|
||||||
|
// the port) — export return rates are rejected until the business sells
|
||||||
|
// that.
|
||||||
|
if (tradeDirection !== 'IMPORT') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'An empty container return rate is import-only for now.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!this.isBaseFreight(appliesTo, trigger)) return;
|
if (!this.isBaseFreight(appliesTo, trigger)) return;
|
||||||
|
|
||||||
if (appliesTo === 'INTERCITY') {
|
if (appliesTo === 'INTERCITY') {
|
||||||
@@ -247,13 +326,35 @@ export class RatesService {
|
|||||||
const trigger = dto.trigger as Rate['trigger'];
|
const trigger = dto.trigger as Rate['trigger'];
|
||||||
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
||||||
// the engine never accidentally narrows a surcharge by container/direction.
|
// the engine never accidentally narrows a surcharge by container/direction.
|
||||||
|
// Exceptions: customs clearance and empty-container return keep direction +
|
||||||
|
// container type — both are sold per lane (and per container type).
|
||||||
const isSurcharge = trigger !== 'ALWAYS';
|
const isSurcharge = trigger !== 'ALWAYS';
|
||||||
const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null);
|
const cargoKind =
|
||||||
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null);
|
trigger === 'CUSTOMS_CLEARANCE'
|
||||||
|
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
|
||||||
|
: null;
|
||||||
|
const containerTypeId =
|
||||||
|
trigger === 'WITH_RETURN' ||
|
||||||
|
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER')
|
||||||
|
? (dto.containerTypeId ?? null)
|
||||||
|
: isSurcharge
|
||||||
|
? null
|
||||||
|
: (dto.containerTypeId ?? null);
|
||||||
|
const cargoTypeId =
|
||||||
|
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||||||
|
trigger === 'LASHING'
|
||||||
|
? (dto.cargoTypeId ?? null)
|
||||||
|
: isSurcharge
|
||||||
|
? null
|
||||||
|
: (dto.cargoTypeId ?? null);
|
||||||
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
||||||
// its yard pair already says where it runs.
|
// its yard pair already says where it runs.
|
||||||
const tradeDirection =
|
const tradeDirection =
|
||||||
isSurcharge || appliesTo === 'INTERCITY' ? null : (dto.tradeDirection ?? null);
|
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
|
||||||
|
? (dto.tradeDirection ?? null)
|
||||||
|
: isSurcharge || appliesTo === 'INTERCITY'
|
||||||
|
? null
|
||||||
|
: (dto.tradeDirection ?? null);
|
||||||
|
|
||||||
const intercityKind = dto.intercityKind ?? null;
|
const intercityKind = dto.intercityKind ?? null;
|
||||||
this.assertScopeCoherent({
|
this.assertScopeCoherent({
|
||||||
@@ -261,6 +362,7 @@ export class RatesService {
|
|||||||
trigger,
|
trigger,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
intercityKind,
|
intercityKind,
|
||||||
|
cargoKind,
|
||||||
containerTypeId,
|
containerTypeId,
|
||||||
cargoTypeId,
|
cargoTypeId,
|
||||||
});
|
});
|
||||||
@@ -282,6 +384,7 @@ export class RatesService {
|
|||||||
appliesTo,
|
appliesTo,
|
||||||
trigger,
|
trigger,
|
||||||
dto.rateUnit as Rate['rateUnit'] | undefined,
|
dto.rateUnit as Rate['rateUnit'] | undefined,
|
||||||
|
cargoKind,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.assertNoDuplicatePattern({
|
await this.assertNoDuplicatePattern({
|
||||||
@@ -376,22 +479,42 @@ export class RatesService {
|
|||||||
if (dto.appliesTo) updates.appliesTo = appliesTo;
|
if (dto.appliesTo) updates.appliesTo = appliesTo;
|
||||||
if (dto.trigger) updates.trigger = trigger;
|
if (dto.trigger) updates.trigger = trigger;
|
||||||
|
|
||||||
const containerTypeId = isSurcharge
|
// A patch that leaves the cargo kind unsaid keeps the one the rate already
|
||||||
|
// has — read back off its container scope (container fees carry the type).
|
||||||
|
const cargoKind =
|
||||||
|
trigger !== 'CUSTOMS_CLEARANCE'
|
||||||
|
? null
|
||||||
|
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
|
||||||
|
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
|
||||||
|
|
||||||
|
const keepsContainerType =
|
||||||
|
!isSurcharge ||
|
||||||
|
trigger === 'WITH_RETURN' ||
|
||||||
|
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER');
|
||||||
|
const containerTypeId = !keepsContainerType
|
||||||
? null
|
? null
|
||||||
: dto.containerTypeId !== undefined
|
: dto.containerTypeId !== undefined
|
||||||
? dto.containerTypeId
|
? dto.containerTypeId
|
||||||
: existing.containerTypeId;
|
: existing.containerTypeId;
|
||||||
const cargoTypeId = isSurcharge
|
const keepsCargoType =
|
||||||
|
!isSurcharge ||
|
||||||
|
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||||||
|
trigger === 'LASHING';
|
||||||
|
const cargoTypeId = !keepsCargoType
|
||||||
? null
|
? null
|
||||||
: dto.cargoTypeId !== undefined
|
: dto.cargoTypeId !== undefined
|
||||||
? dto.cargoTypeId
|
? dto.cargoTypeId
|
||||||
: existing.cargoTypeId;
|
: existing.cargoTypeId;
|
||||||
const tradeDirection =
|
const tradeDirection =
|
||||||
isSurcharge || appliesTo === 'INTERCITY'
|
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
|
||||||
? null
|
? dto.tradeDirection !== undefined
|
||||||
: dto.tradeDirection !== undefined
|
|
||||||
? dto.tradeDirection
|
? dto.tradeDirection
|
||||||
: existing.tradeDirection;
|
: existing.tradeDirection
|
||||||
|
: isSurcharge || appliesTo === 'INTERCITY'
|
||||||
|
? null
|
||||||
|
: dto.tradeDirection !== undefined
|
||||||
|
? dto.tradeDirection
|
||||||
|
: existing.tradeDirection;
|
||||||
|
|
||||||
updates.containerTypeId = containerTypeId ?? null;
|
updates.containerTypeId = containerTypeId ?? null;
|
||||||
updates.cargoTypeId = cargoTypeId ?? null;
|
updates.cargoTypeId = cargoTypeId ?? null;
|
||||||
@@ -407,6 +530,7 @@ export class RatesService {
|
|||||||
trigger,
|
trigger,
|
||||||
tradeDirection: updates.tradeDirection,
|
tradeDirection: updates.tradeDirection,
|
||||||
intercityKind,
|
intercityKind,
|
||||||
|
cargoKind,
|
||||||
containerTypeId: updates.containerTypeId,
|
containerTypeId: updates.containerTypeId,
|
||||||
cargoTypeId: updates.cargoTypeId,
|
cargoTypeId: updates.cargoTypeId,
|
||||||
});
|
});
|
||||||
@@ -438,7 +562,7 @@ export class RatesService {
|
|||||||
// Re-validate the unit against the (possibly changed) shape; overweight is
|
// Re-validate the unit against the (possibly changed) shape; overweight is
|
||||||
// forced to PER_TON.
|
// forced to PER_TON.
|
||||||
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
|
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
|
||||||
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit);
|
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit, cargoKind);
|
||||||
|
|
||||||
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
||||||
await this.assertNoDuplicatePattern({
|
await this.assertNoDuplicatePattern({
|
||||||
|
|||||||
@@ -2504,27 +2504,37 @@ export class WarehouseInventoryService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.unloadedCount > 0) {
|
if (result.unloadedCount > 0) {
|
||||||
let document = await this.interchangeDocuments.generateFromSchedule({
|
// Best-effort: the unload is already committed — a paperwork failure must
|
||||||
scheduleId,
|
// not fail the response (it did once: items unloaded, request 500'd, and
|
||||||
direction: 'EXPORT',
|
// the document only appeared after a manual retry days later). The doc
|
||||||
handoverLocation: schedule.destinationName ?? 'Djibouti Port',
|
// backfills on any retry since already-unloaded items count as unloaded.
|
||||||
handoverFrom: 'EDR',
|
try {
|
||||||
handoverTo: 'Djibouti Port Operator',
|
let document = await this.interchangeDocuments.generateFromSchedule({
|
||||||
portOperatorName: 'Doraleh Multipurpose Port',
|
scheduleId,
|
||||||
generatedBy: performedBy ?? 'EDR Operations',
|
direction: 'EXPORT',
|
||||||
remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
|
handoverLocation: schedule.destinationName ?? 'Djibouti Port',
|
||||||
});
|
handoverFrom: 'EDR',
|
||||||
if (document.status !== 'ACKNOWLEDGED') {
|
handoverTo: 'Djibouti Port Operator',
|
||||||
document = await this.interchangeDocuments.acknowledge(document.id, {
|
portOperatorName: 'Doraleh Multipurpose Port',
|
||||||
acknowledgedBy: 'Djibouti Port Operator',
|
generatedBy: performedBy ?? 'EDR Operations',
|
||||||
remarks: 'Auto acknowledged after Djibouti export unloading.',
|
remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
|
||||||
});
|
});
|
||||||
|
if (document.status !== 'ACKNOWLEDGED') {
|
||||||
|
document = await this.interchangeDocuments.acknowledge(document.id, {
|
||||||
|
acknowledgedBy: 'Djibouti Port Operator',
|
||||||
|
remarks: 'Auto acknowledged after Djibouti export unloading.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
result.interchangeDocument = {
|
||||||
|
id: document.id,
|
||||||
|
documentNo: document.documentNo,
|
||||||
|
status: document.status,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Export interchange document generation failed for schedule ${scheduleId}: ${(err as Error).message} — rerun the Djibouti unloading to regenerate it`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
result.interchangeDocument = {
|
|
||||||
id: document.id,
|
|
||||||
documentNo: document.documentNo,
|
|
||||||
status: document.status,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -70,9 +70,15 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
*/
|
*/
|
||||||
export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
perm('a3000001-0001-4000-8000-000000000001', 'edr_freight_app:contracts:view', 'View contracts'),
|
perm('a3000001-0001-4000-8000-000000000001', 'edr_freight_app:contracts:view', 'View contracts'),
|
||||||
perm('a3000001-0001-4000-8000-000000000002', 'edr_freight_app:contracts:staff_accept', 'Accept contract intake'),
|
// Intake actions are split per freight type (bulk vs container) — fresh ids
|
||||||
perm('a3000001-0001-4000-8000-000000000003', 'edr_freight_app:contracts:request_changes', 'Request contract changes'),
|
// because the seeder upserts ON CONFLICT (key); reusing the old ids with new
|
||||||
perm('a3000001-0001-4000-8000-000000000004', 'edr_freight_app:contracts:reject', 'Reject contract'),
|
// keys would PK-collide with the legacy staff_accept/request_changes/reject rows.
|
||||||
|
perm('a3000001-0001-4000-8000-000000000011', 'edr_freight_app:contracts:staff_accept:bulk', 'Accept bulk contract intake'),
|
||||||
|
perm('a3000001-0001-4000-8000-000000000012', 'edr_freight_app:contracts:staff_accept:container', 'Accept container contract intake'),
|
||||||
|
perm('a3000001-0001-4000-8000-000000000013', 'edr_freight_app:contracts:request_changes:bulk', 'Request bulk contract changes'),
|
||||||
|
perm('a3000001-0001-4000-8000-000000000014', 'edr_freight_app:contracts:request_changes:container', 'Request container contract changes'),
|
||||||
|
perm('a3000001-0001-4000-8000-000000000015', 'edr_freight_app:contracts:reject:bulk', 'Reject bulk contract'),
|
||||||
|
perm('a3000001-0001-4000-8000-000000000016', 'edr_freight_app:contracts:reject:container', 'Reject container contract'),
|
||||||
perm('a3000001-0001-4000-8000-000000000005', 'edr_freight_app:contracts:approve_line_staff', 'Approve contract as line staff'),
|
perm('a3000001-0001-4000-8000-000000000005', 'edr_freight_app:contracts:approve_line_staff', 'Approve contract as line staff'),
|
||||||
perm('a3000001-0001-4000-8000-000000000006', 'edr_freight_app:contracts:approve_director', 'Approve contract as director'),
|
perm('a3000001-0001-4000-8000-000000000006', 'edr_freight_app:contracts:approve_director', 'Approve contract as director'),
|
||||||
perm('a3000001-0001-4000-8000-000000000007', 'edr_freight_app:contracts:approve_ceo', 'Approve contract as CEO'),
|
perm('a3000001-0001-4000-8000-000000000007', 'edr_freight_app:contracts:approve_ceo', 'Approve contract as CEO'),
|
||||||
@@ -373,9 +379,18 @@ export const FREIGHT_PERMS = {
|
|||||||
},
|
},
|
||||||
contracts: {
|
contracts: {
|
||||||
view: 'edr_freight_app:contracts:view',
|
view: 'edr_freight_app:contracts:view',
|
||||||
staffAccept: 'edr_freight_app:contracts:staff_accept',
|
staffAccept: {
|
||||||
requestChanges: 'edr_freight_app:contracts:request_changes',
|
bulk: 'edr_freight_app:contracts:staff_accept:bulk',
|
||||||
reject: 'edr_freight_app:contracts:reject',
|
container: 'edr_freight_app:contracts:staff_accept:container',
|
||||||
|
},
|
||||||
|
requestChanges: {
|
||||||
|
bulk: 'edr_freight_app:contracts:request_changes:bulk',
|
||||||
|
container: 'edr_freight_app:contracts:request_changes:container',
|
||||||
|
},
|
||||||
|
reject: {
|
||||||
|
bulk: 'edr_freight_app:contracts:reject:bulk',
|
||||||
|
container: 'edr_freight_app:contracts:reject:container',
|
||||||
|
},
|
||||||
approveLineStaff: 'edr_freight_app:contracts:approve_line_staff',
|
approveLineStaff: 'edr_freight_app:contracts:approve_line_staff',
|
||||||
approveDirector: 'edr_freight_app:contracts:approve_director',
|
approveDirector: 'edr_freight_app:contracts:approve_director',
|
||||||
approveCeo: 'edr_freight_app:contracts:approve_ceo',
|
approveCeo: 'edr_freight_app:contracts:approve_ceo',
|
||||||
@@ -661,6 +676,18 @@ export const FREIGHT_PERMS = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** Both arms of a freight-type-split permission (for one-of route guards). */
|
||||||
|
export const bothFreightTypes = (p: { bulk: string; container: string }): string[] => [
|
||||||
|
p.bulk,
|
||||||
|
p.container,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The arm of a freight-type-split permission matching a contract's freightType. */
|
||||||
|
export const forFreightType = (
|
||||||
|
p: { bulk: string; container: string },
|
||||||
|
freightType: string,
|
||||||
|
): string => (freightType === 'BULK' ? p.bulk : p.container);
|
||||||
|
|
||||||
const allRuleEngineViewKeys = () =>
|
const allRuleEngineViewKeys = () =>
|
||||||
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
|
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
|
||||||
|
|
||||||
@@ -712,9 +739,9 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.bookings.rejectApproval,
|
FREIGHT_PERMS.bookings.rejectApproval,
|
||||||
FREIGHT_PERMS.bookings.cancel,
|
FREIGHT_PERMS.bookings.cancel,
|
||||||
FREIGHT_PERMS.contracts.view,
|
FREIGHT_PERMS.contracts.view,
|
||||||
FREIGHT_PERMS.contracts.staffAccept,
|
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
||||||
FREIGHT_PERMS.contracts.requestChanges,
|
...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges),
|
||||||
FREIGHT_PERMS.contracts.reject,
|
...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
|
||||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||||
...allRuleEngineViewKeys(),
|
...allRuleEngineViewKeys(),
|
||||||
],
|
],
|
||||||
@@ -804,9 +831,9 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.bookings.reviewDocuments,
|
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||||
FREIGHT_PERMS.bookings.finalizeClearance,
|
FREIGHT_PERMS.bookings.finalizeClearance,
|
||||||
FREIGHT_PERMS.contracts.view,
|
FREIGHT_PERMS.contracts.view,
|
||||||
FREIGHT_PERMS.contracts.staffAccept,
|
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
||||||
FREIGHT_PERMS.contracts.requestChanges,
|
...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges),
|
||||||
FREIGHT_PERMS.contracts.reject,
|
...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
|
||||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||||
FREIGHT_PERMS.contracts.generateContract,
|
FREIGHT_PERMS.contracts.generateContract,
|
||||||
FREIGHT_PERMS.contracts.signStaff,
|
FREIGHT_PERMS.contracts.signStaff,
|
||||||
|
|||||||
@@ -416,9 +416,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
|||||||
{ appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" },
|
{ appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" },
|
||||||
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
|
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
|
||||||
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
|
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
|
||||||
// Cargo-securing / lashing — flat fee, billed once per booking whose
|
// Cargo-securing / lashing — bulk-only, fires when the cargo type has
|
||||||
// cargo type has hasLashing = true.
|
// hasLashing. Sold per direction; commodity-wide catch-alls seeded here,
|
||||||
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", rateValue: 40, rateUnit: "FLAT" },
|
// commodity-specific rates are configured by the rates team.
|
||||||
|
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", tradeDirection: "IMPORT", rateValue: 40, rateUnit: "PER_TON" },
|
||||||
|
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", tradeDirection: "EXPORT", rateValue: 40, rateUnit: "PER_TON" },
|
||||||
// ── First/last-mile road haulage (per km) — drives the mile invoices ──
|
// ── First/last-mile road haulage (per km) — drives the mile invoices ──
|
||||||
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" },
|
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" },
|
||||||
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" },
|
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" },
|
||||||
|
|||||||
@@ -802,8 +802,22 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="support" element={<SupportInboxPage />} />
|
<Route path="support" element={<SupportInboxPage />} />
|
||||||
<Route path="customers" element={<CustomersPage />} />
|
<Route
|
||||||
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
path="customers"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.customers.view}>
|
||||||
|
<CustomersPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="customers/:id"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.customers.view}>
|
||||||
|
<CustomerDetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="invoices"
|
path="invoices"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
@@ -48,8 +50,19 @@ export function ContractActionsToolbar({
|
|||||||
onReviewClearance,
|
onReviewClearance,
|
||||||
}: ContractActionsToolbarProps) {
|
}: ContractActionsToolbarProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
const { status } = contract;
|
const { status } = contract;
|
||||||
|
|
||||||
|
// Intake permissions are split per freight type: an accept:bulk holder must
|
||||||
|
// not see the accept button on a container contract (API enforces the same).
|
||||||
|
const arm = contract.freightType === "BULK" ? "bulk" : "container";
|
||||||
|
const mayAccept = hasPermission(user, FREIGHT_PERMS.contracts.staffAccept[arm]);
|
||||||
|
const mayRequestChanges = hasPermission(
|
||||||
|
user,
|
||||||
|
FREIGHT_PERMS.contracts.requestChanges[arm],
|
||||||
|
);
|
||||||
|
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
|
||||||
|
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
@@ -97,7 +110,8 @@ export function ContractActionsToolbar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const canAccept = status === "SUBMITTED";
|
const canAccept =
|
||||||
|
status === "SUBMITTED" && (mayAccept || mayRequestChanges || mayReject);
|
||||||
// The document stays editable for the whole approval chain, but only by the
|
// The document stays editable for the whole approval chain, but only by the
|
||||||
// approver whose turn it is. The server resolves that against the caller's
|
// approver whose turn it is. The server resolves that against the caller's
|
||||||
// position type; the client cannot derive it.
|
// position type; the client cannot derive it.
|
||||||
@@ -126,35 +140,41 @@ export function ContractActionsToolbar({
|
|||||||
|
|
||||||
{canAccept && (
|
{canAccept && (
|
||||||
<>
|
<>
|
||||||
<Button
|
{mayAccept && (
|
||||||
fullWidth
|
<Button
|
||||||
color="edr-green"
|
fullWidth
|
||||||
leftSection={<Check size={16} />}
|
color="edr-green"
|
||||||
onClick={() => {
|
leftSection={<Check size={16} />}
|
||||||
setEditorMode("accept");
|
onClick={() => {
|
||||||
setEditorOpen(true);
|
setEditorMode("accept");
|
||||||
}}
|
setEditorOpen(true);
|
||||||
>
|
}}
|
||||||
Accept for approval
|
>
|
||||||
</Button>
|
Accept for approval
|
||||||
<Button
|
</Button>
|
||||||
fullWidth
|
)}
|
||||||
variant="light"
|
{mayRequestChanges && (
|
||||||
color="orange"
|
<Button
|
||||||
leftSection={<MessageSquareWarning size={16} />}
|
fullWidth
|
||||||
onClick={() => setChangesOpen(true)}
|
variant="light"
|
||||||
>
|
color="orange"
|
||||||
Request changes
|
leftSection={<MessageSquareWarning size={16} />}
|
||||||
</Button>
|
onClick={() => setChangesOpen(true)}
|
||||||
<Button
|
>
|
||||||
fullWidth
|
Request changes
|
||||||
variant="light"
|
</Button>
|
||||||
color="red"
|
)}
|
||||||
leftSection={<XCircle size={16} />}
|
{mayReject && (
|
||||||
onClick={() => setRejectOpen(true)}
|
<Button
|
||||||
>
|
fullWidth
|
||||||
Reject contract
|
variant="light"
|
||||||
</Button>
|
color="red"
|
||||||
|
leftSection={<XCircle size={16} />}
|
||||||
|
onClick={() => setRejectOpen(true)}
|
||||||
|
>
|
||||||
|
Reject contract
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -65,8 +65,12 @@ export function computeGlShipmentTotal(
|
|||||||
(i) =>
|
(i) =>
|
||||||
i.containerSize === line.containerSize &&
|
i.containerSize === line.containerSize &&
|
||||||
i.unit === "per_container" &&
|
i.unit === "per_container" &&
|
||||||
!i.conditionalOn,
|
!i.conditionalOn &&
|
||||||
) ?? rateFor((i) => i.containerSize === line.containerSize);
|
!i.isClearance,
|
||||||
|
) ??
|
||||||
|
rateFor(
|
||||||
|
(i) => i.containerSize === line.containerSize && !i.isClearance,
|
||||||
|
);
|
||||||
if (rate) {
|
if (rate) {
|
||||||
lines.push({
|
lines.push({
|
||||||
label: rate.label,
|
label: rate.label,
|
||||||
@@ -123,7 +127,12 @@ export function computeGlShipmentTotal(
|
|||||||
} else {
|
} else {
|
||||||
const qty = q.bulkQuantity;
|
const qty = q.bulkQuantity;
|
||||||
const rate =
|
const rate =
|
||||||
rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0];
|
rateFor(
|
||||||
|
(i) =>
|
||||||
|
(i.unit === "per_ton" || i.unit === "per_item") &&
|
||||||
|
!i.isClearance &&
|
||||||
|
!i.conditionalOn,
|
||||||
|
) ?? items[0];
|
||||||
if (rate && qty > 0) {
|
if (rate && qty > 0) {
|
||||||
lines.push({
|
lines.push({
|
||||||
label: rate.label,
|
label: rate.label,
|
||||||
@@ -159,6 +168,53 @@ export function computeGlShipmentTotal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lashing / cargo securing — bulk-only, applies whenever the contract shows
|
||||||
|
// it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon
|
||||||
|
// depends on the wagon capacity the train stocks — shown at real pricing.
|
||||||
|
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
|
||||||
|
if (lashing && lashing.unit === "per_ton") {
|
||||||
|
const tons = q.bulkQuantity;
|
||||||
|
if (tons > 0) {
|
||||||
|
lines.push({
|
||||||
|
label: lashing.label,
|
||||||
|
unitPrice: lashing.unitPrice,
|
||||||
|
unit: lashing.unit,
|
||||||
|
quantity: tons,
|
||||||
|
amount: lashing.unitPrice * tons,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customs clearance service fee — billed on the booking invoice with the
|
||||||
|
// freight. Container fees estimate per size (per box, or per wagon: two 20ft
|
||||||
|
// share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on
|
||||||
|
// the wagon capacity the train stocks — shown at real pricing, not estimated.
|
||||||
|
for (const cl of items.filter((i) => i.isClearance)) {
|
||||||
|
let qty = 0;
|
||||||
|
if (q.isContainer) {
|
||||||
|
const boxes = q.containers
|
||||||
|
.filter((c) => c.containerSize === cl.containerSize)
|
||||||
|
.reduce((s, c) => s + Number(c.quantity || 0), 0);
|
||||||
|
qty =
|
||||||
|
cl.unit === "per_wagon"
|
||||||
|
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
|
||||||
|
: boxes;
|
||||||
|
} else if (cl.unit === "per_ton") {
|
||||||
|
qty = q.bulkQuantity;
|
||||||
|
} else if (cl.unit === "flat") {
|
||||||
|
qty = 1;
|
||||||
|
}
|
||||||
|
if (qty > 0) {
|
||||||
|
lines.push({
|
||||||
|
label: cl.label,
|
||||||
|
unitPrice: cl.unitPrice,
|
||||||
|
unit: cl.unit,
|
||||||
|
quantity: qty,
|
||||||
|
amount: cl.unitPrice * qty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const total = lines.reduce((s, l) => s + l.amount, 0);
|
const total = lines.reduce((s, l) => s + l.amount, 0);
|
||||||
return { currency, lines, total };
|
return { currency, lines, total };
|
||||||
}
|
}
|
||||||
@@ -167,6 +223,7 @@ export function computeGlShipmentTotal(
|
|||||||
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
|
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
per_container: "container",
|
per_container: "container",
|
||||||
|
per_wagon: "wagon",
|
||||||
per_ton: "ton",
|
per_ton: "ton",
|
||||||
per_item: "item",
|
per_item: "item",
|
||||||
per_km: "km",
|
per_km: "km",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useFileViewer } from "@edr/ui-common";
|
import { useFileViewer } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { fetchViewableFile } from "@/services/files.service";
|
import { fetchViewableFile } from "@/services/files.service";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
||||||
@@ -128,6 +130,8 @@ function DiffRow({
|
|||||||
* (with note) actions, plus a short history of past decisions.
|
* (with note) actions, plus a short history of past decisions.
|
||||||
*/
|
*/
|
||||||
export function ChangeRequestReview({ company }: { company: Company }) {
|
export function ChangeRequestReview({ company }: { company: Company }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canReview = hasPermission(user, FREIGHT_PERMS.customers.verify);
|
||||||
const query = useQuery(
|
const query = useQuery(
|
||||||
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
|
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
|
||||||
);
|
);
|
||||||
@@ -323,25 +327,30 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group justify="flex-end" gap="sm">
|
{/* Reviewing the diff is `customers:view`; deciding on it is
|
||||||
<Button
|
`customers:verify`. Without it the request stays readable but
|
||||||
variant="light"
|
un-actionable. */}
|
||||||
color="red"
|
{canReview && (
|
||||||
onClick={() => {
|
<Group justify="flex-end" gap="sm">
|
||||||
setRejectId(pending.id);
|
<Button
|
||||||
setNote("");
|
variant="light"
|
||||||
}}
|
color="red"
|
||||||
>
|
onClick={() => {
|
||||||
Reject
|
setRejectId(pending.id);
|
||||||
</Button>
|
setNote("");
|
||||||
<Button
|
}}
|
||||||
color="edr-green"
|
>
|
||||||
loading={approve.isPending}
|
Reject
|
||||||
onClick={() => approve.mutate({ id: pending.id })}
|
</Button>
|
||||||
>
|
<Button
|
||||||
Approve changes
|
color="edr-green"
|
||||||
</Button>
|
loading={approve.isPending}
|
||||||
</Group>
|
onClick={() => approve.mutate({ id: pending.id })}
|
||||||
|
>
|
||||||
|
Approve changes
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -286,6 +288,19 @@ export function InvoiceStatusBadge({
|
|||||||
* regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so
|
* regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so
|
||||||
* an already-active profile is still managable.
|
* an already-active profile is still managable.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Which permission each status write needs. Mirrors `STATUS_PERM` in the API's
|
||||||
|
* `companies.controller.ts` — approving is a different authority from
|
||||||
|
* suspending, and both go through the same endpoint. Keep the two in step.
|
||||||
|
*/
|
||||||
|
const STATUS_PERM: Record<ProfileStatus, string> = {
|
||||||
|
active: FREIGHT_PERMS.customers.verify,
|
||||||
|
pending: FREIGHT_PERMS.customers.verify,
|
||||||
|
rejected: FREIGHT_PERMS.customers.verify,
|
||||||
|
suspended: FREIGHT_PERMS.customers.deactivate,
|
||||||
|
blacklisted: FREIGHT_PERMS.customers.deactivate,
|
||||||
|
};
|
||||||
|
|
||||||
export function ProfileApprovalActions({
|
export function ProfileApprovalActions({
|
||||||
profileId,
|
profileId,
|
||||||
status,
|
status,
|
||||||
@@ -295,6 +310,10 @@ export function ProfileApprovalActions({
|
|||||||
status: ProfileStatus;
|
status: ProfileStatus;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
/** The API rejects these anyway — hide rather than offer a button that 403s. */
|
||||||
|
const canSet = (next: ProfileStatus) =>
|
||||||
|
hasPermission(user, STATUS_PERM[next]);
|
||||||
const { mutate, isPending } = useMutation(
|
const { mutate, isPending } = useMutation(
|
||||||
api.customers.setProfileStatus.mutationOptions(),
|
api.customers.setProfileStatus.mutationOptions(),
|
||||||
);
|
);
|
||||||
@@ -414,35 +433,41 @@ export function ProfileApprovalActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === "pending") {
|
if (status === "pending") {
|
||||||
|
if (!canSet("active") && !canSet("rejected")) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{decisionModal}
|
{decisionModal}
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<Button
|
{canSet("active") && (
|
||||||
size="xs"
|
<Button
|
||||||
variant="light"
|
size="xs"
|
||||||
color="edr-green"
|
variant="light"
|
||||||
radius="md"
|
color="edr-green"
|
||||||
loading={isPending}
|
radius="md"
|
||||||
onClick={() => act("active")}
|
loading={isPending}
|
||||||
>
|
onClick={() => act("active")}
|
||||||
Approve
|
>
|
||||||
</Button>
|
Approve
|
||||||
<Button
|
</Button>
|
||||||
size="xs"
|
)}
|
||||||
variant="light"
|
{canSet("rejected") && (
|
||||||
color="red"
|
<Button
|
||||||
radius="md"
|
size="xs"
|
||||||
onClick={() => openDecision("reject")}
|
variant="light"
|
||||||
>
|
color="red"
|
||||||
Reject
|
radius="md"
|
||||||
</Button>
|
onClick={() => openDecision("reject")}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "rejected") {
|
if (status === "rejected") {
|
||||||
|
if (!canSet("active")) return null;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
@@ -458,6 +483,7 @@ export function ProfileApprovalActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === "active") {
|
if (status === "active") {
|
||||||
|
if (!canSet("suspended")) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{decisionModal}
|
{decisionModal}
|
||||||
@@ -476,34 +502,40 @@ export function ProfileApprovalActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === "suspended") {
|
if (status === "suspended") {
|
||||||
|
if (!canSet("active") && !canSet("blacklisted")) return null;
|
||||||
return (
|
return (
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
{decisionModal}
|
{decisionModal}
|
||||||
<Button
|
{canSet("active") && (
|
||||||
size="xs"
|
<Button
|
||||||
variant="light"
|
size="xs"
|
||||||
color="edr-green"
|
variant="light"
|
||||||
radius="md"
|
color="edr-green"
|
||||||
loading={isPending}
|
radius="md"
|
||||||
onClick={() => openDecision("reactivate")}
|
loading={isPending}
|
||||||
>
|
onClick={() => openDecision("reactivate")}
|
||||||
Reactivate
|
>
|
||||||
</Button>
|
Reactivate
|
||||||
<Button
|
</Button>
|
||||||
size="xs"
|
)}
|
||||||
variant="light"
|
{canSet("blacklisted") && (
|
||||||
color="red"
|
<Button
|
||||||
radius="md"
|
size="xs"
|
||||||
loading={isPending}
|
variant="light"
|
||||||
onClick={() => act("blacklisted")}
|
color="red"
|
||||||
>
|
radius="md"
|
||||||
Blacklist
|
loading={isPending}
|
||||||
</Button>
|
onClick={() => act("blacklisted")}
|
||||||
|
>
|
||||||
|
Blacklist
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "blacklisted") {
|
if (status === "blacklisted") {
|
||||||
|
if (!canSet("pending")) return null;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|||||||
@@ -209,6 +209,13 @@ const RuleEngineFormDialog = ({
|
|||||||
next.containerTypeId = "";
|
next.containerTypeId = "";
|
||||||
next.cargoTypeId = "";
|
next.cargoTypeId = "";
|
||||||
}
|
}
|
||||||
|
// Cargo kind (customs / lashing) decides both the container-type scope
|
||||||
|
// and the legal units (container → per box/wagon, bulk → per ton/wagon).
|
||||||
|
if (name === "cargoKind") {
|
||||||
|
next.containerTypeId = "";
|
||||||
|
next.cargoTypeId = "";
|
||||||
|
next.rateUnit = "";
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -333,6 +340,10 @@ const RuleEngineFormDialog = ({
|
|||||||
value={resolveSelectValue(field, values)}
|
value={resolveSelectValue(field, values)}
|
||||||
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
||||||
disabled={selectOptionsLoading}
|
disabled={selectOptionsLoading}
|
||||||
|
// Native required blocks submit while a mandatory select is empty —
|
||||||
|
// without it the form posts and the API 400s (e.g. a container
|
||||||
|
// customs/lashing rate with no container type picked).
|
||||||
|
required={field.required}
|
||||||
data={options
|
data={options
|
||||||
.filter((opt) => opt.value !== "")
|
.filter((opt) => opt.value !== "")
|
||||||
.map((opt) => ({
|
.map((opt) => ({
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ export const QUERY_KEYS = {
|
|||||||
["maintenance", "history", vehicleId ?? "all"] as const,
|
["maintenance", "history", vehicleId ?? "all"] as const,
|
||||||
stats: (vehicleId?: string) =>
|
stats: (vehicleId?: string) =>
|
||||||
["maintenance", "stats", vehicleId ?? "all"] as const,
|
["maintenance", "stats", vehicleId ?? "all"] as const,
|
||||||
|
intervals: (vehicleId?: string) =>
|
||||||
|
["maintenance", "intervals", vehicleId ?? "all"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
FINANCIAL_REPORTS: {
|
FINANCIAL_REPORTS: {
|
||||||
|
|||||||
@@ -286,7 +286,6 @@ export const BOOKING_LIST_TABS = [
|
|||||||
key: "clearance",
|
key: "clearance",
|
||||||
label: "Clearance",
|
label: "Clearance",
|
||||||
statuses: [
|
statuses: [
|
||||||
"AWAITING_CLEARANCE_PAYMENT",
|
|
||||||
"AWAITING_DOCUMENTS",
|
"AWAITING_DOCUMENTS",
|
||||||
"DOCUMENTS_UNDER_REVIEW",
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
"CLEARANCE_READY",
|
"CLEARANCE_READY",
|
||||||
|
|||||||
@@ -51,10 +51,6 @@ export const CONTRACT_STATUS_STYLES: Record<string, StatusStyle> = {
|
|||||||
label: "Active",
|
label: "Active",
|
||||||
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||||
},
|
},
|
||||||
AWAITING_CLEARANCE_PAYMENT: {
|
|
||||||
label: "Clearance Fee Due",
|
|
||||||
color: "bg-orange-50 text-orange-700 border-orange-200",
|
|
||||||
},
|
|
||||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||||
label: "Awaiting Documents",
|
label: "Awaiting Documents",
|
||||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||||
@@ -122,7 +118,6 @@ export const CONTRACT_STATUS_COLOR: Record<string, string> = {
|
|||||||
SIGNED_CUSTOMER: "cyan",
|
SIGNED_CUSTOMER: "cyan",
|
||||||
FULLY_EXECUTED: "indigo",
|
FULLY_EXECUTED: "indigo",
|
||||||
CONTRACT_ACTIVE: "edr-green",
|
CONTRACT_ACTIVE: "edr-green",
|
||||||
AWAITING_CLEARANCE_PAYMENT: "orange",
|
|
||||||
AWAITING_CLEARANCE_DOCUMENTS: "yellow",
|
AWAITING_CLEARANCE_DOCUMENTS: "yellow",
|
||||||
CLEARANCE_UNDER_REVIEW: "yellow",
|
CLEARANCE_UNDER_REVIEW: "yellow",
|
||||||
CLEARANCE_READY_FOR_BOOKING: "edr-green",
|
CLEARANCE_READY_FOR_BOOKING: "edr-green",
|
||||||
@@ -212,13 +207,6 @@ export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
|
|||||||
color: "text-[color:var(--freight-brand)]",
|
color: "text-[color:var(--freight-brand)]",
|
||||||
stage: 3,
|
stage: 3,
|
||||||
},
|
},
|
||||||
AWAITING_CLEARANCE_PAYMENT: {
|
|
||||||
title: "Clearance Fee Due",
|
|
||||||
description:
|
|
||||||
"Customer must pay the prepaid clearance service fee before uploading documents.",
|
|
||||||
color: "text-orange-600",
|
|
||||||
stage: 3,
|
|
||||||
},
|
|
||||||
AWAITING_CLEARANCE_DOCUMENTS: {
|
AWAITING_CLEARANCE_DOCUMENTS: {
|
||||||
title: "Awaiting Documents",
|
title: "Awaiting Documents",
|
||||||
description: "Customer is uploading pre-booking clearance documents.",
|
description: "Customer is uploading pre-booking clearance documents.",
|
||||||
|
|||||||
@@ -67,10 +67,3 @@ export function useDisputeInterchangeDocument() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCancelInterchangeDocument() {
|
|
||||||
const onSuccess = useInterchangeInvalidation();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) => interchangeDocumentsService.cancel(id),
|
|
||||||
onSuccess,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,9 +29,19 @@ export const FREIGHT_PERMS = {
|
|||||||
},
|
},
|
||||||
contracts: {
|
contracts: {
|
||||||
view: "edr_freight_app:contracts:view",
|
view: "edr_freight_app:contracts:view",
|
||||||
staffAccept: "edr_freight_app:contracts:staff_accept",
|
// Intake actions are split per freight type — mirror of the API registry.
|
||||||
requestChanges: "edr_freight_app:contracts:request_changes",
|
staffAccept: {
|
||||||
reject: "edr_freight_app:contracts:reject",
|
bulk: "edr_freight_app:contracts:staff_accept:bulk",
|
||||||
|
container: "edr_freight_app:contracts:staff_accept:container",
|
||||||
|
},
|
||||||
|
requestChanges: {
|
||||||
|
bulk: "edr_freight_app:contracts:request_changes:bulk",
|
||||||
|
container: "edr_freight_app:contracts:request_changes:container",
|
||||||
|
},
|
||||||
|
reject: {
|
||||||
|
bulk: "edr_freight_app:contracts:reject:bulk",
|
||||||
|
container: "edr_freight_app:contracts:reject:container",
|
||||||
|
},
|
||||||
approveLineStaff: "edr_freight_app:contracts:approve_line_staff",
|
approveLineStaff: "edr_freight_app:contracts:approve_line_staff",
|
||||||
approveDirector: "edr_freight_app:contracts:approve_director",
|
approveDirector: "edr_freight_app:contracts:approve_director",
|
||||||
approveCeo: "edr_freight_app:contracts:approve_ceo",
|
approveCeo: "edr_freight_app:contracts:approve_ceo",
|
||||||
|
|||||||
@@ -2039,6 +2039,7 @@
|
|||||||
"setting": "ቅንብሮች",
|
"setting": "ቅንብሮች",
|
||||||
"loadingAdmins": "አስተዳዳሪዎችን በመጫን ላይ...",
|
"loadingAdmins": "አስተዳዳሪዎችን በመጫን ላይ...",
|
||||||
"errorLoadingAdmins": "የአስተዳዳሪ መረጃን ማጫን ላይ ስህተት ተፈጥሯል",
|
"errorLoadingAdmins": "የአስተዳዳሪ መረጃን ማጫን ላይ ስህተት ተፈጥሯል",
|
||||||
|
"errorLoadingUnits": "ክፍሎችን ማጫን ላይ ስህተት ተፈጥሯል",
|
||||||
"retry": "ደግመው ይሞክሩ",
|
"retry": "ደግመው ይሞክሩ",
|
||||||
"assignAdmin": "አስተዳዳሪ መመደብ",
|
"assignAdmin": "አስተዳዳሪ መመደብ",
|
||||||
"addAdmin": "አስተዳዳሪ ያክሉ",
|
"addAdmin": "አስተዳዳሪ ያክሉ",
|
||||||
@@ -2585,7 +2586,6 @@
|
|||||||
"archiveDepartment": "የስራ መደብ መጠርያ አርክብ አድርግ",
|
"archiveDepartment": "የስራ መደብ መጠርያ አርክብ አድርግ",
|
||||||
"deleteDepartment": "የስራ መደብ መጠርያ ሰርዝ",
|
"deleteDepartment": "የስራ መደብ መጠርያ ሰርዝ",
|
||||||
"deleteConfirm": "የስራ መደብ መጠርያ ይሰርዝ?",
|
"deleteConfirm": "የስራ መደብ መጠርያ ይሰርዝ?",
|
||||||
"delete": "ሰርዝ",
|
|
||||||
"deleteFailed": "የስራ መደብ መጠርያ ሰረዝ ወደ ተሳክቶ",
|
"deleteFailed": "የስራ መደብ መጠርያ ሰረዝ ወደ ተሳክቶ",
|
||||||
"cannotDeleteWithEmployees": "ተመድቦ ካለበት ሰራተኞች ጋር የስራ መደብ መጠርያ መሰረዝ አይቻልም",
|
"cannotDeleteWithEmployees": "ተመድቦ ካለበት ሰራተኞች ጋር የስራ መደብ መጠርያ መሰረዝ አይቻልም",
|
||||||
"reassignEmployeesFirst": "እባክዎ ሁሉንም ሰራተኞች በዚህ ክፍል ውስጥ ዳግም ይሰጧቸው ወይም ያስወግዱ።",
|
"reassignEmployeesFirst": "እባክዎ ሁሉንም ሰራተኞች በዚህ ክፍል ውስጥ ዳግም ይሰጧቸው ወይም ያስወግዱ።",
|
||||||
@@ -2652,6 +2652,18 @@
|
|||||||
"selectApplicationToLoadPermissions": "ፍቃዶቹን ለማስገንዘብ አፕሊኬሽኑን ይምረጡ",
|
"selectApplicationToLoadPermissions": "ፍቃዶቹን ለማስገንዘብ አፕሊኬሽኑን ይምረጡ",
|
||||||
"copyPermissionsHint": "የነበረ የቦታ አይነት ይምረጡ፤ ፍቃዶቹ አስቀድመው ይሞላሉ፣ ከታች ማስተካከል ይችላሉ።",
|
"copyPermissionsHint": "የነበረ የቦታ አይነት ይምረጡ፤ ፍቃዶቹ አስቀድመው ይሞላሉ፣ ከታች ማስተካከል ይችላሉ።",
|
||||||
"copyPermissionsFailed": "ፍቃዶችን መቅዳት አልተቻለም",
|
"copyPermissionsFailed": "ፍቃዶችን መቅዳት አልተቻለም",
|
||||||
|
"selectOrganizationToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ድርጅት ይምረጡ",
|
||||||
|
"cannotClearAllPermissions": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — ይህ የቦታ ዓይነት ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።",
|
||||||
|
"permissionsSelected": "{{count}} ተመርጠዋል",
|
||||||
|
"positionTypeCreated": "የቦታ ዓይነት ተፈጥሯል",
|
||||||
|
"positionTypeUpdated": "የቦታ ዓይነት ተሻሽሏል",
|
||||||
|
"positionTypeDeleted": "የቦታ ዓይነት ተሰርዟል",
|
||||||
|
"positionTypeMigrated": "የቦታ ዓይነት ዝውውር ተሻሽሏል",
|
||||||
|
"positionTypeNotFound": "የቦታ ዓይነት አልተገኘም",
|
||||||
|
"permissionsAssignFailed": "የቦታ ዓይነቱ ተቀምጧል፣ ነገር ግን ፍቃዶቹን መመደብ አልተቻለም። እንደገና ለመሞከር ደግመው ይክፈቱት።",
|
||||||
|
"failedToLoadPermissions": "ፍቃዶችን መጫን አልተቻለም",
|
||||||
|
"failedToLoadPositionTypes": "የቦታ ዓይነቶችን መጫን አልተቻለም",
|
||||||
|
"exportFailed": "የቦታ ዓይነት ቁልፎችን መላክ አልተቻለም",
|
||||||
"perFailed": "ፍቃድ መፍጠር አልተቻለም",
|
"perFailed": "ፍቃድ መፍጠር አልተቻለም",
|
||||||
"perSuccess": "የፍቃድ አይነት ተፈጠረና ፍቃዶች ተመደቡ",
|
"perSuccess": "የፍቃድ አይነት ተፈጠረና ፍቃዶች ተመደቡ",
|
||||||
"updatePerSuccess": "ፍቃድ በትክክል ተዘምኗል",
|
"updatePerSuccess": "ፍቃድ በትክክል ተዘምኗል",
|
||||||
@@ -3117,7 +3129,7 @@
|
|||||||
"referenceNumberStyles": "የማጣቀሻ ቁጥር ስታይሎች",
|
"referenceNumberStyles": "የማጣቀሻ ቁጥር ስታይሎች",
|
||||||
"other": "ሌላ",
|
"other": "ሌላ",
|
||||||
"styleCategories": "የስታይል ምድቦች",
|
"styleCategories": "የስታይል ምድቦች",
|
||||||
"styleEditor": "ስታይል አርታኢ",
|
"styleEditor": "የስታይል አርታዒ",
|
||||||
"livePreview": "የቀጥታ ቅድመ-እይታ",
|
"livePreview": "የቀጥታ ቅድመ-እይታ",
|
||||||
"fontsHint": "አማራጭ የቅርጸ-ቁምፊ ሪሶርስ ይምረጡ።",
|
"fontsHint": "አማራጭ የቅርጸ-ቁምፊ ሪሶርስ ይምረጡ።",
|
||||||
"fontResource": "የቅርጸ-ቁምፊ ፋይል",
|
"fontResource": "የቅርጸ-ቁምፊ ፋይል",
|
||||||
@@ -3209,11 +3221,9 @@
|
|||||||
"previewLanguage": "የቅድመ-እይታ ቋንቋ",
|
"previewLanguage": "የቅድመ-እይታ ቋንቋ",
|
||||||
"amharic": "አማርኛ",
|
"amharic": "አማርኛ",
|
||||||
"english": "እንግሊዝኛ",
|
"english": "እንግሊዝኛ",
|
||||||
"styleCategories": "የስታይል ምድቦች",
|
|
||||||
"categoryHint": "ለማርትዕ ክፍል ይምረጡ",
|
"categoryHint": "ለማርትዕ ክፍል ይምረጡ",
|
||||||
"expandSidebar": "የጎን ማውጫን ዘርጋ",
|
"expandSidebar": "የጎን ማውጫን ዘርጋ",
|
||||||
"collapseSidebar": "የጎን ማውጫን ጠቅልል",
|
"collapseSidebar": "የጎን ማውጫን ጠቅልል",
|
||||||
"styleEditor": "የስታይል አርታዒ",
|
|
||||||
"headerFooterSelection": "ራስጌ እና ግርጌ",
|
"headerFooterSelection": "ራስጌ እና ግርጌ",
|
||||||
"noSettings": "ምንም የስታይል ቅንብሮች የሉም",
|
"noSettings": "ምንም የስታይል ቅንብሮች የሉም",
|
||||||
"visible": "የሚታይ",
|
"visible": "የሚታይ",
|
||||||
@@ -7393,5 +7403,100 @@
|
|||||||
"department": "ዲፓርትመንት",
|
"department": "ዲፓርትመንት",
|
||||||
"unit": "ክፍል",
|
"unit": "ክፍል",
|
||||||
"notAvailable": "ያልዋቀረ"
|
"notAvailable": "ያልዋቀረ"
|
||||||
|
},
|
||||||
|
"orgAdmins": {
|
||||||
|
"title": "የድርጅት አስተዳዳሪዎች",
|
||||||
|
"subtitle": "አስተዳዳሪዎቹን ለማየት እና ለማስተዳደር ድርጅት ይምረጡ።",
|
||||||
|
"tableName": "የድርጅት አስተዳዳሪዎች",
|
||||||
|
"selectOrg": "ድርጅት ይምረጡ",
|
||||||
|
"searchOrgs": "ድርጅቶችን ይፈልጉ...",
|
||||||
|
"noOrgsFound": "ምንም ድርጅት አልተገኘም።",
|
||||||
|
"adminsCount": "{{count}} አስተዳዳሪ",
|
||||||
|
"adminsCount_other": "{{count}} አስተዳዳሪዎች",
|
||||||
|
"noAdmins": "አስተዳዳሪ የለም",
|
||||||
|
"activeEmployees": "{{count}} ንቁ ሰራተኞች",
|
||||||
|
"selectOrgPrompt": "አስተዳዳሪዎቹን ለማስተዳደር ድርጅት ይምረጡ",
|
||||||
|
"selectOrgPromptHint": "ከላይ ያለውን መምረጫ ተጠቅመው ድርጅት ይፈልጉ እና ይምረጡ።",
|
||||||
|
"noAdminsHint": "{{name}} እስካሁን አስተዳዳሪ የለውም። አዲስ አስተዳዳሪ ይጋብዙ ወይም ነባር ሰራተኛ ይመድቡ።",
|
||||||
|
"assignExisting": "ነባር ሰራተኛ ይመድቡ",
|
||||||
|
"roleOrgAdmin": "የድርጅት አስተዳዳሪ",
|
||||||
|
"roleUnitAdmin": "የክፍል አስተዳዳሪ",
|
||||||
|
"statusInvited": "የተጋበዘ",
|
||||||
|
"statusActive": "ንቁ",
|
||||||
|
"statusInactive": "ንቁ ያልሆነ",
|
||||||
|
"columns": {
|
||||||
|
"name": "ስም",
|
||||||
|
"email": "ኢሜይል",
|
||||||
|
"phone": "ስልክ",
|
||||||
|
"role": "ሚና",
|
||||||
|
"status": "ሁኔታ",
|
||||||
|
"addedOn": "የተጨመረበት ቀን",
|
||||||
|
"actions": "እርምጃዎች"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"edit": "መገለጫ ያስተካክሉ",
|
||||||
|
"resend": "ግብዣ እንደገና ይላኩ",
|
||||||
|
"activate": "መለያ ያንቁ",
|
||||||
|
"deactivate": "መለያ ያቦዝኑ",
|
||||||
|
"remove": "አስተዳዳሪ ያስወግዱ"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"nameEn": "ስም (እንግሊዝኛ)",
|
||||||
|
"nameAm": "ስም (አማርኛ)",
|
||||||
|
"username": "የተጠቃሚ ስም",
|
||||||
|
"email": "ኢሜይል",
|
||||||
|
"phoneNumber": "ስልክ ቁጥር",
|
||||||
|
"unit": "ክፍል",
|
||||||
|
"selectUnit": "ክፍል ይምረጡ",
|
||||||
|
"loadingUnits": "ክፍሎች በመጫን ላይ...",
|
||||||
|
"noUnit": "ምንም — የድርጅት አስተዳዳሪ",
|
||||||
|
"unitRequired": "ክፍል ያስፈልጋል"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "የአስተዳዳሪ መገለጫ ያስተካክሉ",
|
||||||
|
"description": "የዚህን አስተዳዳሪ የመገለጫ ዝርዝሮች ያዘምኑ።",
|
||||||
|
"submit": "ለውጦችን ያስቀምጡ"
|
||||||
|
},
|
||||||
|
"assign": {
|
||||||
|
"title": "ነባር ሰራተኛ ይመድቡ",
|
||||||
|
"description": "የዚህን ድርጅት ሰራተኛ ወደ አስተዳዳሪነት ያሳድጉ።",
|
||||||
|
"searchUsers": "ሰራተኞችን በስም ወይም በኢሜይል ይፈልጉ...",
|
||||||
|
"noUsersFound": "ምንም ሰራተኛ አልተገኘም።",
|
||||||
|
"alreadyAdmin": "አስቀድሞ አስተዳዳሪ ነው",
|
||||||
|
"submit": "እንደ አስተዳዳሪ ይመድቡ",
|
||||||
|
"loadError": "ሰራተኞችን መጫን አልተሳካም።",
|
||||||
|
"users": "ተጠቃሚዎች"
|
||||||
|
},
|
||||||
|
"confirmRemove": {
|
||||||
|
"title": "አስተዳዳሪ ይወገድ?",
|
||||||
|
"description": "ይህ የ{{name}}ን የአስተዳዳሪነት ሚና ከ{{org}} ያስወግዳል። የተጠቃሚው መለያ ራሱ ይቀራል።",
|
||||||
|
"removing": "በማስወገድ ላይ..."
|
||||||
|
},
|
||||||
|
"confirmToggle": {
|
||||||
|
"activateTitle": "መለያ ይንቃ?",
|
||||||
|
"deactivateTitle": "መለያ ይቦዝን?",
|
||||||
|
"description": "ይህ የ{{name}}ን የመለያ ሁኔታ በመላው ስርዓቱ ላይ ይቀይራል፣ ለዚህ ድርጅት ብቻ አይደለም።"
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"assigned": "አስተዳዳሪ በተሳካ ሁኔታ ተመድቧል!",
|
||||||
|
"removed": "አስተዳዳሪ በተሳካ ሁኔታ ተወግዷል!",
|
||||||
|
"resent": "ግብዣው እንደገና ተልኳል!",
|
||||||
|
"activated": "መለያው ነቅቷል!",
|
||||||
|
"deactivated": "መለያው ቦዝኗል!",
|
||||||
|
"profileUpdated": "መገለጫው ተዘምኗል!",
|
||||||
|
"resending": "ግብዣ በመላክ ላይ...",
|
||||||
|
"missingContact": "ይህ አስተዳዳሪ ኢሜይል ወይም ስልክ ቁጥር የለውም።",
|
||||||
|
"added": "አስተዳዳሪ በተሳካ ሁኔታ ተጨምሯል!"
|
||||||
|
},
|
||||||
|
"loadError": "አስተዳዳሪዎችን መጫን አልተሳካም።",
|
||||||
|
"pickerError": "ድርጅቶችን መጫን አልተሳካም።",
|
||||||
|
"addAdmin": "አስተዳዳሪ ጨምር",
|
||||||
|
"add": {
|
||||||
|
"title": "አስተዳዳሪ ጨምር",
|
||||||
|
"description": "የተጠቃሚ መለያ ይፍጠሩ እና በዚህ ድርጅት ውስጥ የአስተዳዳሪ መዳረሻ ይስጡ።",
|
||||||
|
"submit": "አስተዳዳሪ ጨምር",
|
||||||
|
"inviteNote": "ተጠቃሚው ይፈጠራል እና የይለፍ ቃሉን እንዲያዘጋጅ የኤስኤምኤስ ግብዣ ይደርሰዋል።",
|
||||||
|
"noUnitsOrgAdmin": "ይህ ድርጅት ክፍሎች የሉትም — አስተዳዳሪው እንደ የድርጅት አስተዳዳሪ ይጨመራል።"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2057,6 +2057,7 @@
|
|||||||
"setting": "Setting",
|
"setting": "Setting",
|
||||||
"loadingAdmins": "Loading admins...",
|
"loadingAdmins": "Loading admins...",
|
||||||
"errorLoadingAdmins": "Error loading admin data",
|
"errorLoadingAdmins": "Error loading admin data",
|
||||||
|
"errorLoadingUnits": "Error loading units",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"assignAdmin": "Assign Admin",
|
"assignAdmin": "Assign Admin",
|
||||||
"addAdmin": "Add Admin",
|
"addAdmin": "Add Admin",
|
||||||
@@ -2760,6 +2761,18 @@
|
|||||||
"selectApplicationToLoadPermissions": "Select an application to load its permissions",
|
"selectApplicationToLoadPermissions": "Select an application to load its permissions",
|
||||||
"copyPermissionsHint": "Pick an existing position type to pre-fill its permissions, then edit below.",
|
"copyPermissionsHint": "Pick an existing position type to pre-fill its permissions, then edit below.",
|
||||||
"copyPermissionsFailed": "Failed to copy permissions",
|
"copyPermissionsFailed": "Failed to copy permissions",
|
||||||
|
"selectOrganizationToCopy": "Select an organization to see the position types you can copy from",
|
||||||
|
"cannotClearAllPermissions": "Saved. Permissions were left unchanged — this position type must keep at least one permission.",
|
||||||
|
"permissionsSelected": "{{count}} selected",
|
||||||
|
"positionTypeCreated": "Position type created",
|
||||||
|
"positionTypeUpdated": "Position type updated",
|
||||||
|
"positionTypeDeleted": "Position type deleted",
|
||||||
|
"positionTypeMigrated": "Position type migration updated",
|
||||||
|
"positionTypeNotFound": "Position type not found",
|
||||||
|
"permissionsAssignFailed": "Position type saved, but assigning its permissions failed. Reopen it to try again.",
|
||||||
|
"failedToLoadPermissions": "Failed to load permissions",
|
||||||
|
"failedToLoadPositionTypes": "Failed to load position types",
|
||||||
|
"exportFailed": "Failed to export position type keys",
|
||||||
"perFailed": "Failed To Create Permission",
|
"perFailed": "Failed To Create Permission",
|
||||||
"perSuccess": "Permission type created and permissions assigned",
|
"perSuccess": "Permission type created and permissions assigned",
|
||||||
"updatePerSuccess": "Permission updated successfully",
|
"updatePerSuccess": "Permission updated successfully",
|
||||||
@@ -7391,5 +7404,100 @@
|
|||||||
"department": "Department",
|
"department": "Department",
|
||||||
"unit": "Unit",
|
"unit": "Unit",
|
||||||
"notAvailable": "Not Available"
|
"notAvailable": "Not Available"
|
||||||
|
},
|
||||||
|
"orgAdmins": {
|
||||||
|
"title": "Organization Admins",
|
||||||
|
"subtitle": "Pick an organization to view and manage its administrators.",
|
||||||
|
"tableName": "Organization Admins",
|
||||||
|
"selectOrg": "Select an organization",
|
||||||
|
"searchOrgs": "Search organizations...",
|
||||||
|
"noOrgsFound": "No organizations found.",
|
||||||
|
"adminsCount": "{{count}} admin",
|
||||||
|
"adminsCount_other": "{{count}} admins",
|
||||||
|
"noAdmins": "No admins",
|
||||||
|
"activeEmployees": "{{count}} active employees",
|
||||||
|
"selectOrgPrompt": "Select an organization to manage its admins",
|
||||||
|
"selectOrgPromptHint": "Use the selector above to search and pick an organization.",
|
||||||
|
"noAdminsHint": "{{name}} has no administrators yet. Invite a new admin or assign an existing employee.",
|
||||||
|
"assignExisting": "Assign Existing",
|
||||||
|
"roleOrgAdmin": "Org Admin",
|
||||||
|
"roleUnitAdmin": "Unit Admin",
|
||||||
|
"statusInvited": "Invited",
|
||||||
|
"statusActive": "Active",
|
||||||
|
"statusInactive": "Inactive",
|
||||||
|
"columns": {
|
||||||
|
"name": "Name",
|
||||||
|
"email": "Email",
|
||||||
|
"phone": "Phone",
|
||||||
|
"role": "Role",
|
||||||
|
"status": "Status",
|
||||||
|
"addedOn": "Added On",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"edit": "Edit Profile",
|
||||||
|
"resend": "Resend Invite",
|
||||||
|
"activate": "Activate Account",
|
||||||
|
"deactivate": "Deactivate Account",
|
||||||
|
"remove": "Remove Admin"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"nameEn": "Name (English)",
|
||||||
|
"nameAm": "Name (Amharic)",
|
||||||
|
"username": "Username",
|
||||||
|
"email": "Email",
|
||||||
|
"phoneNumber": "Phone Number",
|
||||||
|
"unit": "Unit",
|
||||||
|
"selectUnit": "Select a unit",
|
||||||
|
"loadingUnits": "Loading units...",
|
||||||
|
"noUnit": "None — organization admin",
|
||||||
|
"unitRequired": "Unit is required"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Edit Admin Profile",
|
||||||
|
"description": "Update this administrator's profile details.",
|
||||||
|
"submit": "Save Changes"
|
||||||
|
},
|
||||||
|
"assign": {
|
||||||
|
"title": "Assign Existing Employee",
|
||||||
|
"description": "Promote an employee of this organization to administrator.",
|
||||||
|
"searchUsers": "Search employees by name or email...",
|
||||||
|
"noUsersFound": "No employees found.",
|
||||||
|
"alreadyAdmin": "Already admin",
|
||||||
|
"submit": "Assign as Admin",
|
||||||
|
"loadError": "Failed to load employees.",
|
||||||
|
"users": "Users"
|
||||||
|
},
|
||||||
|
"confirmRemove": {
|
||||||
|
"title": "Remove Admin?",
|
||||||
|
"description": "This removes the admin role of {{name}} for {{org}}. The user account itself is kept.",
|
||||||
|
"removing": "Removing..."
|
||||||
|
},
|
||||||
|
"confirmToggle": {
|
||||||
|
"activateTitle": "Activate Account?",
|
||||||
|
"deactivateTitle": "Deactivate Account?",
|
||||||
|
"description": "This changes the account status of {{name}} across the whole platform, not just for this organization."
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"assigned": "Admin assigned successfully!",
|
||||||
|
"removed": "Admin removed successfully!",
|
||||||
|
"resent": "Invitation re-sent successfully!",
|
||||||
|
"activated": "Account activated successfully!",
|
||||||
|
"deactivated": "Account deactivated successfully!",
|
||||||
|
"profileUpdated": "Profile updated successfully!",
|
||||||
|
"resending": "Sending invite...",
|
||||||
|
"missingContact": "This admin has no email or phone number on file.",
|
||||||
|
"added": "Admin added successfully!"
|
||||||
|
},
|
||||||
|
"loadError": "Failed to load admins.",
|
||||||
|
"pickerError": "Failed to load organizations.",
|
||||||
|
"addAdmin": "Add Admin",
|
||||||
|
"add": {
|
||||||
|
"title": "Add Admin",
|
||||||
|
"description": "Create a user account and grant admin access in this organization.",
|
||||||
|
"submit": "Add Admin",
|
||||||
|
"inviteNote": "The user is created and receives an SMS invitation to set their password.",
|
||||||
|
"noUnitsOrgAdmin": "This organization has no units — the admin will be added as an organization admin."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1525,6 +1525,7 @@
|
|||||||
"setting": "Paramètre",
|
"setting": "Paramètre",
|
||||||
"loadingAdmins": "Chargement des administrateurs...",
|
"loadingAdmins": "Chargement des administrateurs...",
|
||||||
"errorLoadingAdmins": "Erreur lors du chargement des données administrateur",
|
"errorLoadingAdmins": "Erreur lors du chargement des données administrateur",
|
||||||
|
"errorLoadingUnits": "Erreur lors du chargement des unités",
|
||||||
"retry": "Réessayer",
|
"retry": "Réessayer",
|
||||||
"assignAdmin": "Assigner un administrateur",
|
"assignAdmin": "Assigner un administrateur",
|
||||||
"addAdmin": "Ajouter un administrateur",
|
"addAdmin": "Ajouter un administrateur",
|
||||||
@@ -1886,6 +1887,18 @@
|
|||||||
"selectApplicationToLoadPermissions": "Sélectionner une application pour charger ses autorisations",
|
"selectApplicationToLoadPermissions": "Sélectionner une application pour charger ses autorisations",
|
||||||
"copyPermissionsHint": "Choisissez un type de poste existant pour préremplir ses autorisations, puis modifiez ci-dessous.",
|
"copyPermissionsHint": "Choisissez un type de poste existant pour préremplir ses autorisations, puis modifiez ci-dessous.",
|
||||||
"copyPermissionsFailed": "Échec de la copie des autorisations",
|
"copyPermissionsFailed": "Échec de la copie des autorisations",
|
||||||
|
"selectOrganizationToCopy": "Sélectionnez une organisation pour voir les types de poste que vous pouvez copier",
|
||||||
|
"cannotClearAllPermissions": "Enregistré. Les autorisations n'ont pas été modifiées — ce type de poste doit conserver au moins une autorisation.",
|
||||||
|
"permissionsSelected": "{{count}} sélectionné(s)",
|
||||||
|
"positionTypeCreated": "Type de poste créé",
|
||||||
|
"positionTypeUpdated": "Type de poste mis à jour",
|
||||||
|
"positionTypeDeleted": "Type de poste supprimé",
|
||||||
|
"positionTypeMigrated": "Migration du type de poste mise à jour",
|
||||||
|
"positionTypeNotFound": "Type de poste introuvable",
|
||||||
|
"permissionsAssignFailed": "Type de poste enregistré, mais l'attribution de ses autorisations a échoué. Rouvrez-le pour réessayer.",
|
||||||
|
"failedToLoadPermissions": "Échec du chargement des autorisations",
|
||||||
|
"failedToLoadPositionTypes": "Échec du chargement des types de poste",
|
||||||
|
"exportFailed": "Échec de l'exportation des clés de type de poste",
|
||||||
"perFailed": "Échec de la création de l’autorisation",
|
"perFailed": "Échec de la création de l’autorisation",
|
||||||
"perSuccess": "Type d’autorisation créé et autorisations assignées",
|
"perSuccess": "Type d’autorisation créé et autorisations assignées",
|
||||||
"updatePerSuccess": "Autorisation mise à jour avec succès",
|
"updatePerSuccess": "Autorisation mise à jour avec succès",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import "../index.css";
|
|||||||
import "@edr/ui-common/theme.css";
|
import "@edr/ui-common/theme.css";
|
||||||
|
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
|
import { Toaster as SonnerToaster } from "./shared/common/ui/sonner";
|
||||||
|
|
||||||
// Initialize i18next before first paint so the detected/persisted language
|
// Initialize i18next before first paint so the detected/persisted language
|
||||||
// applies immediately (the vendored IAM UI also imports this via @/i18n).
|
// applies immediately (the vendored IAM UI also imports this via @/i18n).
|
||||||
@@ -70,6 +71,9 @@ createRoot(rootElement).render(
|
|||||||
message (suppressed on warehouse / mile / onboarding pages). */}
|
message (suppressed on warehouse / mile / onboarding pages). */}
|
||||||
<ApiErrorModal />
|
<ApiErrorModal />
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
|
{/* sonner toasts (used across super-admin & user-management)
|
||||||
|
rendered nowhere without this mount */}
|
||||||
|
<SonnerToaster position="top-right" richColors />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ const DashboardPage = () => {
|
|||||||
refetch();
|
refetch();
|
||||||
refetchAdmins();
|
refetchAdmins();
|
||||||
}}
|
}}
|
||||||
className="bg-primary hover:bg-primary/90 text-primary-foreground">
|
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||||
|
>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
{t("organization.retry")}
|
{t("organization.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -140,7 +141,8 @@ const DashboardPage = () => {
|
|||||||
<Link to="/organizations">
|
<Link to="/organizations">
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
className="text-primary dark:text-primary-400 text-sm px-0">
|
className="text-primary dark:text-primary-400 text-sm px-0"
|
||||||
|
>
|
||||||
{t("organization.viewMore")}
|
{t("organization.viewMore")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import OrganizationsAdmins from "@/super-admin/components/organizationAdmins/OrganizationAdmins";
|
import OrgAdminsPage from "@/super-admin/components/org-admins/OrgAdminsPage";
|
||||||
|
|
||||||
const OrganizationAdminsPage = () => {
|
const OrganizationAdminsPage = () => {
|
||||||
return <OrganizationsAdmins />;
|
return <OrgAdminsPage />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default OrganizationAdminsPage;
|
export default OrganizationAdminsPage;
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ import {
|
|||||||
humanize,
|
humanize,
|
||||||
} from "@/components/customers";
|
} from "@/components/customers";
|
||||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import {
|
import {
|
||||||
downloadBookingFile,
|
downloadBookingFile,
|
||||||
fetchViewableFile,
|
fetchViewableFile,
|
||||||
@@ -108,6 +110,7 @@ export default function CustomerDetailPage() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { view, viewer } = useFileViewer();
|
const { view, viewer } = useFileViewer();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const { data: company, isLoading } = useQuery(
|
const { data: company, isLoading } = useQuery(
|
||||||
api.customers.getById.queryOptions({
|
api.customers.getById.queryOptions({
|
||||||
@@ -180,6 +183,11 @@ export default function CustomerDetailPage() {
|
|||||||
// API's rule exactly, so no button is offered that the server would reject.
|
// API's rule exactly, so no button is offered that the server would reject.
|
||||||
const stillOnboarding = company ? isOnboardingDraft(company) : false;
|
const stillOnboarding = company ? isOnboardingDraft(company) : false;
|
||||||
const canReview = company ? hasSubmittedOnboarding(company) : true;
|
const canReview = company ? hasSubmittedOnboarding(company) : true;
|
||||||
|
// Workflow gate (above) AND authority: asking the customer to correct a
|
||||||
|
// document is a `customers:verify` action, so a view-only reviewer reads the
|
||||||
|
// documents but is not offered the request-change control.
|
||||||
|
const canRequestDocChange =
|
||||||
|
canReview && hasPermission(user, FREIGHT_PERMS.customers.verify);
|
||||||
|
|
||||||
/** Document the reviewer is asking the customer to correct; null = closed. */
|
/** Document the reviewer is asking the customer to correct; null = closed. */
|
||||||
const [changeRequestDoc, setChangeRequestDoc] =
|
const [changeRequestDoc, setChangeRequestDoc] =
|
||||||
@@ -446,7 +454,7 @@ export default function CustomerDetailPage() {
|
|||||||
>
|
>
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
{canReview && (
|
{canRequestDocChange && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component="button"
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -468,7 +476,7 @@ export default function CustomerDetailPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[view, canReview],
|
[view, canRequestDocChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
|
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
Container,
|
Container,
|
||||||
|
ActionIcon,
|
||||||
|
Tooltip,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Plus } from 'lucide-react';
|
import { CheckCircle2, Plus, Trash2 } from 'lucide-react';
|
||||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||||
@@ -26,6 +28,7 @@ interface MaintenanceSchedule {
|
|||||||
id: string;
|
id: string;
|
||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
maintenanceType: string;
|
maintenanceType: string;
|
||||||
|
serviceItem?: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
scheduledDate: string;
|
scheduledDate: string;
|
||||||
completedDate?: string;
|
completedDate?: string;
|
||||||
@@ -40,6 +43,7 @@ interface DueBoardRow {
|
|||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
plateNumber: string;
|
plateNumber: string;
|
||||||
maintenanceType: string;
|
maintenanceType: string;
|
||||||
|
serviceItem: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
scheduledDate: string;
|
scheduledDate: string;
|
||||||
nextDueDate: string | null;
|
nextDueDate: string | null;
|
||||||
@@ -50,8 +54,20 @@ interface DueBoardRow {
|
|||||||
overdue: boolean;
|
overdue: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MaintenanceInterval {
|
||||||
|
id: string;
|
||||||
|
vehicleId: string;
|
||||||
|
maintenanceType: string;
|
||||||
|
serviceItem: string | null;
|
||||||
|
intervalKm: number | null;
|
||||||
|
intervalDays: number | null;
|
||||||
|
description: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
maintenanceType: 'PREVENTIVE',
|
maintenanceType: 'PREVENTIVE',
|
||||||
|
serviceItem: '',
|
||||||
description: '',
|
description: '',
|
||||||
scheduledDate: new Date().toISOString().split('T')[0],
|
scheduledDate: new Date().toISOString().split('T')[0],
|
||||||
estimatedCost: 0,
|
estimatedCost: 0,
|
||||||
@@ -59,12 +75,24 @@ const emptyForm = {
|
|||||||
notes: '',
|
notes: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emptyIntervalForm = {
|
||||||
|
maintenanceType: 'PREVENTIVE',
|
||||||
|
serviceItem: '',
|
||||||
|
intervalKm: '' as number | '',
|
||||||
|
intervalDays: '' as number | '',
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
export function MaintenancePage() {
|
export function MaintenancePage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
|
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
|
||||||
const [openScheduleModal, setOpenScheduleModal] = useState(false);
|
const [openScheduleModal, setOpenScheduleModal] = useState(false);
|
||||||
const [formData, setFormData] = useState(emptyForm);
|
const [formData, setFormData] = useState(emptyForm);
|
||||||
|
const [intervalForm, setIntervalForm] = useState(emptyIntervalForm);
|
||||||
|
const [completeTarget, setCompleteTarget] = useState<MaintenanceSchedule | null>(null);
|
||||||
|
const [completeOdometer, setCompleteOdometer] = useState<number | ''>('');
|
||||||
|
const [completeCost, setCompleteCost] = useState<number | ''>('');
|
||||||
|
|
||||||
// Maintenance is driven by time AND km, not a picked-then-scheduled action —
|
// Maintenance is driven by time AND km, not a picked-then-scheduled action —
|
||||||
// this is the fleet-wide board of what's actually due, by date or mileage.
|
// this is the fleet-wide board of what's actually due, by date or mileage.
|
||||||
@@ -94,7 +122,32 @@ export function MaintenancePage() {
|
|||||||
enabled: !!selectedVehicle,
|
enabled: !!selectedVehicle,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: intervals } = useQuery({
|
||||||
|
queryKey: QUERY_KEYS.MAINTENANCE.intervals(selectedVehicle || ''),
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!selectedVehicle) return [];
|
||||||
|
const res = await api.get(`/maintenance/intervals/${selectedVehicle}`);
|
||||||
|
return (res.data || []) as MaintenanceInterval[];
|
||||||
|
},
|
||||||
|
enabled: !!selectedVehicle,
|
||||||
|
});
|
||||||
|
|
||||||
const upcomingList: MaintenanceSchedule[] = Array.isArray(upcoming) ? upcoming : [];
|
const upcomingList: MaintenanceSchedule[] = Array.isArray(upcoming) ? upcoming : [];
|
||||||
|
const intervalList: MaintenanceInterval[] = Array.isArray(intervals) ? intervals : [];
|
||||||
|
|
||||||
|
const invalidateVehicle = () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.ROOT });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (err: unknown) => {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description:
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||||
|
'Failed',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const scheduleMutation = useMutation({
|
const scheduleMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -102,24 +155,79 @@ export function MaintenancePage() {
|
|||||||
const res = await api.post('/maintenance/schedules', {
|
const res = await api.post('/maintenance/schedules', {
|
||||||
vehicleId: selectedVehicle,
|
vehicleId: selectedVehicle,
|
||||||
...formData,
|
...formData,
|
||||||
|
serviceItem: formData.serviceItem.trim() || undefined,
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ title: 'Maintenance scheduled' });
|
toast({ title: 'Maintenance scheduled' });
|
||||||
queryClient.invalidateQueries({
|
invalidateVehicle();
|
||||||
queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''),
|
|
||||||
});
|
|
||||||
setOpenScheduleModal(false);
|
setOpenScheduleModal(false);
|
||||||
setFormData(emptyForm);
|
setFormData(emptyForm);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError,
|
||||||
toast({
|
});
|
||||||
title: 'Error',
|
|
||||||
description: err?.response?.data?.message ?? 'Failed',
|
// Interval upsert: "oil change every 10,000 km" — drives the auto-scheduling
|
||||||
variant: 'destructive',
|
// of the next service when a maintenance completes with an odometer reading.
|
||||||
|
const intervalMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!selectedVehicle) return;
|
||||||
|
const res = await api.post('/maintenance/intervals', {
|
||||||
|
vehicleId: selectedVehicle,
|
||||||
|
maintenanceType: intervalForm.maintenanceType,
|
||||||
|
serviceItem: intervalForm.serviceItem.trim() || undefined,
|
||||||
|
intervalKm: intervalForm.intervalKm === '' ? undefined : Number(intervalForm.intervalKm),
|
||||||
|
intervalDays:
|
||||||
|
intervalForm.intervalDays === '' ? undefined : Number(intervalForm.intervalDays),
|
||||||
|
description: intervalForm.description.trim() || undefined,
|
||||||
});
|
});
|
||||||
|
return res.data;
|
||||||
},
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: 'Interval saved' });
|
||||||
|
invalidateVehicle();
|
||||||
|
setIntervalForm(emptyIntervalForm);
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deactivateIntervalMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => api.delete(`/maintenance/intervals/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: 'Interval deactivated' });
|
||||||
|
invalidateVehicle();
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Completion with odometer: the reading is what advances KM-based
|
||||||
|
// scheduling — the API auto-creates the next SCHEDULED item from it.
|
||||||
|
const completeMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!completeTarget) return;
|
||||||
|
const res = await api.patch(`/maintenance/schedules/${completeTarget.id}`, {
|
||||||
|
status: 'COMPLETED',
|
||||||
|
completedDate: new Date().toISOString(),
|
||||||
|
odometerReading: completeOdometer === '' ? undefined : Number(completeOdometer),
|
||||||
|
actualCost: completeCost === '' ? undefined : Number(completeCost),
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Maintenance completed',
|
||||||
|
description:
|
||||||
|
completeOdometer === ''
|
||||||
|
? 'No odometer recorded — next service was NOT auto-scheduled.'
|
||||||
|
: 'Next service auto-scheduled from the recorded odometer.',
|
||||||
|
});
|
||||||
|
invalidateVehicle();
|
||||||
|
setCompleteTarget(null);
|
||||||
|
setCompleteOdometer('');
|
||||||
|
setCompleteCost('');
|
||||||
|
},
|
||||||
|
onError,
|
||||||
});
|
});
|
||||||
|
|
||||||
const vehicleOptions =
|
const vehicleOptions =
|
||||||
@@ -170,6 +278,7 @@ export function MaintenancePage() {
|
|||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Vehicle</Table.Th>
|
<Table.Th>Vehicle</Table.Th>
|
||||||
<Table.Th>Type</Table.Th>
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Service Item</Table.Th>
|
||||||
<Table.Th>Next Due Date</Table.Th>
|
<Table.Th>Next Due Date</Table.Th>
|
||||||
<Table.Th>Next Due Km</Table.Th>
|
<Table.Th>Next Due Km</Table.Th>
|
||||||
<Table.Th>Current Km</Table.Th>
|
<Table.Th>Current Km</Table.Th>
|
||||||
@@ -186,6 +295,7 @@ export function MaintenancePage() {
|
|||||||
>
|
>
|
||||||
<Table.Td>{row.plateNumber}</Table.Td>
|
<Table.Td>{row.plateNumber}</Table.Td>
|
||||||
<Table.Td>{row.maintenanceType}</Table.Td>
|
<Table.Td>{row.maintenanceType}</Table.Td>
|
||||||
|
<Table.Td>{row.serviceItem ?? '—'}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{row.nextDueDate ? new Date(row.nextDueDate).toLocaleDateString() : '—'}
|
{row.nextDueDate ? new Date(row.nextDueDate).toLocaleDateString() : '—'}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@@ -231,50 +341,179 @@ export function MaintenancePage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card withBorder>
|
<>
|
||||||
<Card.Section p="md" withBorder>
|
<Card withBorder>
|
||||||
<Text fw={500}>Upcoming Maintenance</Text>
|
<Card.Section p="md" withBorder>
|
||||||
</Card.Section>
|
<Text fw={500}>Service Intervals — drives auto-scheduling</Text>
|
||||||
<Card.Section p="md">
|
<Text size="xs" c="dimmed">
|
||||||
{isLoading ? (
|
e.g. oil change every 10,000 km. On completion with an odometer reading, the
|
||||||
<Text>Loading...</Text>
|
next service is scheduled automatically at reading + interval.
|
||||||
) : upcomingList.length > 0 ? (
|
</Text>
|
||||||
<Table striped highlightOnHover>
|
</Card.Section>
|
||||||
<Table.Thead>
|
<Card.Section p="md">
|
||||||
<Table.Tr>
|
<Stack gap="sm">
|
||||||
<Table.Th>Type</Table.Th>
|
{intervalList.length > 0 && (
|
||||||
<Table.Th>Description</Table.Th>
|
<Table striped>
|
||||||
<Table.Th>Scheduled</Table.Th>
|
<Table.Thead>
|
||||||
<Table.Th>Est. Cost</Table.Th>
|
<Table.Tr>
|
||||||
<Table.Th>Status</Table.Th>
|
<Table.Th>Type</Table.Th>
|
||||||
</Table.Tr>
|
<Table.Th>Service Item</Table.Th>
|
||||||
</Table.Thead>
|
<Table.Th>Every (km)</Table.Th>
|
||||||
<Table.Tbody>
|
<Table.Th>Every (days)</Table.Th>
|
||||||
{upcomingList.map((m) => (
|
<Table.Th>Description</Table.Th>
|
||||||
<Table.Tr key={m.id}>
|
<Table.Th />
|
||||||
<Table.Td>{m.maintenanceType}</Table.Td>
|
</Table.Tr>
|
||||||
<Table.Td>{m.description}</Table.Td>
|
</Table.Thead>
|
||||||
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
|
<Table.Tbody>
|
||||||
<Table.Td>
|
{intervalList.map((i) => (
|
||||||
{m.estimatedCost != null
|
<Table.Tr key={i.id}>
|
||||||
? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', {
|
<Table.Td>{i.maintenanceType}</Table.Td>
|
||||||
minimumFractionDigits: 2,
|
<Table.Td>{i.serviceItem ?? '—'}</Table.Td>
|
||||||
maximumFractionDigits: 2,
|
<Table.Td>{i.intervalKm ?? '—'}</Table.Td>
|
||||||
})}`
|
<Table.Td>{i.intervalDays ?? '—'}</Table.Td>
|
||||||
: '—'}
|
<Table.Td>{i.description ?? '—'}</Table.Td>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
<Table.Td>
|
<Tooltip label="Deactivate — stops auto-scheduling">
|
||||||
<Badge color={statusColor(m.status)}>{m.status}</Badge>
|
<ActionIcon
|
||||||
</Table.Td>
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
onClick={() => deactivateIntervalMutation.mutate(i.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
<Group align="flex-end" gap="sm" wrap="wrap">
|
||||||
|
<Select
|
||||||
|
label="Type"
|
||||||
|
w={150}
|
||||||
|
data={['PREVENTIVE', 'CORRECTIVE', 'INSPECTION', 'REPAIR']}
|
||||||
|
value={intervalForm.maintenanceType}
|
||||||
|
onChange={(v) =>
|
||||||
|
setIntervalForm({ ...intervalForm, maintenanceType: v || 'PREVENTIVE' })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Service item"
|
||||||
|
placeholder="e.g. oil change"
|
||||||
|
w={170}
|
||||||
|
value={intervalForm.serviceItem}
|
||||||
|
onChange={(e) =>
|
||||||
|
setIntervalForm({ ...intervalForm, serviceItem: e.currentTarget.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Every (km)"
|
||||||
|
min={0}
|
||||||
|
w={130}
|
||||||
|
value={intervalForm.intervalKm}
|
||||||
|
onChange={(v) =>
|
||||||
|
setIntervalForm({ ...intervalForm, intervalKm: v === '' ? '' : Number(v) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Every (days)"
|
||||||
|
min={0}
|
||||||
|
w={130}
|
||||||
|
value={intervalForm.intervalDays}
|
||||||
|
onChange={(v) =>
|
||||||
|
setIntervalForm({
|
||||||
|
...intervalForm,
|
||||||
|
intervalDays: v === '' ? '' : Number(v),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Description"
|
||||||
|
placeholder="Oil and filter change"
|
||||||
|
style={{ flex: 1, minWidth: 160 }}
|
||||||
|
value={intervalForm.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setIntervalForm({ ...intervalForm, description: e.currentTarget.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Plus size={14} />}
|
||||||
|
loading={intervalMutation.isPending}
|
||||||
|
disabled={
|
||||||
|
intervalForm.intervalKm === '' && intervalForm.intervalDays === ''
|
||||||
|
}
|
||||||
|
onClick={() => intervalMutation.mutate()}
|
||||||
|
>
|
||||||
|
Save interval
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Card.Section p="md" withBorder>
|
||||||
|
<Text fw={500}>Upcoming Maintenance</Text>
|
||||||
|
</Card.Section>
|
||||||
|
<Card.Section p="md">
|
||||||
|
{isLoading ? (
|
||||||
|
<Text>Loading...</Text>
|
||||||
|
) : upcomingList.length > 0 ? (
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Service Item</Table.Th>
|
||||||
|
<Table.Th>Description</Table.Th>
|
||||||
|
<Table.Th>Scheduled</Table.Th>
|
||||||
|
<Table.Th>Est. Cost</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th />
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
</Table.Thead>
|
||||||
</Table.Tbody>
|
<Table.Tbody>
|
||||||
</Table>
|
{upcomingList.map((m) => (
|
||||||
) : (
|
<Table.Tr key={m.id}>
|
||||||
<Text c="dimmed">No upcoming maintenance</Text>
|
<Table.Td>{m.maintenanceType}</Table.Td>
|
||||||
)}
|
<Table.Td>{m.serviceItem ?? '—'}</Table.Td>
|
||||||
</Card.Section>
|
<Table.Td>{m.description}</Table.Td>
|
||||||
</Card>
|
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{m.estimatedCost != null
|
||||||
|
? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`
|
||||||
|
: '—'}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={statusColor(m.status)}>{m.status}</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{(m.status === 'SCHEDULED' || m.status === 'IN_PROGRESS') && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<CheckCircle2 size={13} />}
|
||||||
|
onClick={() => setCompleteTarget(m)}
|
||||||
|
>
|
||||||
|
Complete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<Text c="dimmed">No upcoming maintenance</Text>
|
||||||
|
)}
|
||||||
|
</Card.Section>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -291,6 +530,12 @@ export function MaintenancePage() {
|
|||||||
value={formData.maintenanceType}
|
value={formData.maintenanceType}
|
||||||
onChange={(v) => setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })}
|
onChange={(v) => setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })}
|
||||||
/>
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Service item"
|
||||||
|
placeholder="e.g. oil change — links this schedule to its interval"
|
||||||
|
value={formData.serviceItem}
|
||||||
|
onChange={(e) => setFormData({ ...formData, serviceItem: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Description"
|
label="Description"
|
||||||
placeholder="What needs to be done?"
|
placeholder="What needs to be done?"
|
||||||
@@ -335,6 +580,49 @@ export function MaintenancePage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={completeTarget != null}
|
||||||
|
onClose={() => setCompleteTarget(null)}
|
||||||
|
title={`Complete maintenance${completeTarget?.serviceItem ? ` — ${completeTarget.serviceItem}` : ''}`}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Record the odometer at completion — the next service is auto-scheduled at reading +
|
||||||
|
interval (e.g. completed at 50,000 km with a 10,000 km interval ⇒ next due at 60,000
|
||||||
|
km).
|
||||||
|
</Text>
|
||||||
|
<NumberInput
|
||||||
|
label="Odometer reading (km)"
|
||||||
|
placeholder="e.g. 50000"
|
||||||
|
min={0}
|
||||||
|
required
|
||||||
|
value={completeOdometer}
|
||||||
|
onChange={(v) => setCompleteOdometer(v === '' ? '' : Number(v))}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Actual cost (ETB)"
|
||||||
|
min={0}
|
||||||
|
value={completeCost}
|
||||||
|
onChange={(v) => setCompleteCost(v === '' ? '' : Number(v))}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="light" onClick={() => setCompleteTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<CheckCircle2 size={15} />}
|
||||||
|
loading={completeMutation.isPending}
|
||||||
|
disabled={completeOdometer === ''}
|
||||||
|
onClick={() => completeMutation.mutate()}
|
||||||
|
>
|
||||||
|
Complete & schedule next
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
tradeDirection: "Direction",
|
tradeDirection: "Direction",
|
||||||
containerTypeId: "Container type",
|
containerTypeId: "Container type",
|
||||||
cargoTypeId: "Cargo type",
|
cargoTypeId: "Cargo type",
|
||||||
|
originYardId: "Origin yard",
|
||||||
|
destinationYardId: "Destination yard",
|
||||||
};
|
};
|
||||||
|
|
||||||
const fmtDateTime = (iso: string) =>
|
const fmtDateTime = (iso: string) =>
|
||||||
@@ -36,12 +38,20 @@ const fmtDateTime = (iso: string) =>
|
|||||||
hour12: false,
|
hour12: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fmtValue = (field: string, value: unknown): string => {
|
const fmtValue = (
|
||||||
|
field: string,
|
||||||
|
value: unknown,
|
||||||
|
labels?: Record<string, string>,
|
||||||
|
): string => {
|
||||||
if (value === null || value === undefined || value === "") return "—";
|
if (value === null || value === undefined || value === "") return "—";
|
||||||
if (field === "rateValue") {
|
if (field === "rateValue") {
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
return Number.isNaN(num) ? String(value) : num.toLocaleString();
|
return Number.isNaN(num) ? String(value) : num.toLocaleString();
|
||||||
}
|
}
|
||||||
|
// Yard ids are unreadable — an approver decides on the route, not a UUID.
|
||||||
|
if (field === "originYardId" || field === "destinationYardId") {
|
||||||
|
return labels?.[String(value)] ?? String(value);
|
||||||
|
}
|
||||||
return String(value).replace(/_/g, " ");
|
return String(value).replace(/_/g, " ");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,6 +87,8 @@ interface RateApprovalsSectionProps {
|
|||||||
canDecide: boolean;
|
canDecide: boolean;
|
||||||
approve: Decide;
|
approve: Decide;
|
||||||
reject: Decide;
|
reject: Decide;
|
||||||
|
/** yardId → label, so a re-routed rate reads as yards, not UUIDs. */
|
||||||
|
yardLabels?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,6 +101,7 @@ const RateApprovalsSection = ({
|
|||||||
canDecide,
|
canDecide,
|
||||||
approve,
|
approve,
|
||||||
reject,
|
reject,
|
||||||
|
yardLabels,
|
||||||
}: RateApprovalsSectionProps) => {
|
}: RateApprovalsSectionProps) => {
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||||
@@ -212,11 +225,11 @@ const RateApprovalsSection = ({
|
|||||||
{FIELD_LABELS[field] ?? field}
|
{FIELD_LABELS[field] ?? field}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed" td="line-through">
|
<Text size="sm" c="dimmed" td="line-through">
|
||||||
{fmtValue(field, r.previousValues[field])}
|
{fmtValue(field, r.previousValues[field], yardLabels)}
|
||||||
</Text>
|
</Text>
|
||||||
<ArrowRight size={13} />
|
<ArrowRight size={13} />
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{fmtValue(field, r.payload[field])}
|
{fmtValue(field, r.payload[field], yardLabels)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
Stack,
|
Stack,
|
||||||
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
@@ -94,7 +95,14 @@ const yardOptionsForLegEnd = (
|
|||||||
let country: string | undefined;
|
let country: string | undefined;
|
||||||
if (appliesTo === "INTERCITY") {
|
if (appliesTo === "INTERCITY") {
|
||||||
country = "Ethiopia";
|
country = "Ethiopia";
|
||||||
} else if (appliesTo === "CONTAINER" || appliesTo === "BULK") {
|
} else if (
|
||||||
|
appliesTo === "CONTAINER" ||
|
||||||
|
appliesTo === "BULK" ||
|
||||||
|
// Customs clearance + empty-container return are sold per direction +
|
||||||
|
// route, so their yard dropdowns narrow exactly like base freight.
|
||||||
|
(appliesTo === "OTHER" &&
|
||||||
|
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")))
|
||||||
|
) {
|
||||||
const direction = String(values.tradeDirection ?? "");
|
const direction = String(values.tradeDirection ?? "");
|
||||||
// Direction is what decides the countries, so offer nothing until it is set
|
// Direction is what decides the countries, so offer nothing until it is set
|
||||||
// rather than defaulting to one and letting it read as a real choice.
|
// rather than defaulting to one and letting it read as a real choice.
|
||||||
@@ -124,6 +132,10 @@ const RuleEngineResourcePage = () => {
|
|||||||
|
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
// Category tabs (rates page): the active tab's filters go to the backend.
|
||||||
|
const [activeTab, setActiveTab] = useState<string>(
|
||||||
|
config?.listTabs?.[0]?.key ?? "",
|
||||||
|
);
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
|
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(
|
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(
|
||||||
@@ -153,10 +165,13 @@ const RuleEngineResourcePage = () => {
|
|||||||
sortOrder: "ASC" as const,
|
sortOrder: "ASC" as const,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(config?.listTabs?.find((t) => t.key === activeTab)?.filters ?? {}),
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
config?.orderConfig,
|
config?.orderConfig,
|
||||||
config?.supportsSearch,
|
config?.supportsSearch,
|
||||||
|
config?.listTabs,
|
||||||
|
activeTab,
|
||||||
search,
|
search,
|
||||||
pagination.pageIndex,
|
pagination.pageIndex,
|
||||||
pagination.pageSize,
|
pagination.pageSize,
|
||||||
@@ -166,7 +181,8 @@ const RuleEngineResourcePage = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||||
setSearch("");
|
setSearch("");
|
||||||
}, [config?.slug, setPagination]);
|
setActiveTab(config?.listTabs?.[0]?.key ?? "");
|
||||||
|
}, [config?.slug, config?.listTabs, setPagination]);
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useRuleEngineList(
|
const { data, isLoading, isError, error } = useRuleEngineList(
|
||||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||||
@@ -253,6 +269,10 @@ const RuleEngineResourcePage = () => {
|
|||||||
);
|
);
|
||||||
const { data: yardOptions, isLoading: yardOptionsLoading } =
|
const { data: yardOptions, isLoading: yardOptionsLoading } =
|
||||||
useYardOptions(usesYardField);
|
useYardOptions(usesYardField);
|
||||||
|
const yardLabelById = useMemo(
|
||||||
|
() => Object.fromEntries((yardOptions ?? []).map((y) => [y.value, y.label])),
|
||||||
|
[yardOptions],
|
||||||
|
);
|
||||||
const usesApprovalRoleField = Boolean(
|
const usesApprovalRoleField = Boolean(
|
||||||
config?.formFields.some(
|
config?.formFields.some(
|
||||||
(f) => f.name === "requiredRole" || f.name === "blocksRole",
|
(f) => f.name === "requiredRole" || f.name === "blocksRole",
|
||||||
@@ -645,6 +665,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
canDecide={canApproveRates}
|
canDecide={canApproveRates}
|
||||||
approve={rateChangeWorkflow.approve}
|
approve={rateChangeWorkflow.approve}
|
||||||
reject={rateChangeWorkflow.reject}
|
reject={rateChangeWorkflow.reject}
|
||||||
|
yardLabels={yardLabelById}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -685,6 +706,25 @@ const RuleEngineResourcePage = () => {
|
|||||||
|
|
||||||
<Card p={0}>
|
<Card p={0}>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
|
{config.listTabs && (
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(v) => {
|
||||||
|
setActiveTab(v ?? config.listTabs![0].key);
|
||||||
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
|
}}
|
||||||
|
px="md"
|
||||||
|
pt="sm"
|
||||||
|
>
|
||||||
|
<Tabs.List>
|
||||||
|
{config.listTabs.map((tab) => (
|
||||||
|
<Tabs.Tab key={tab.key} value={tab.key}>
|
||||||
|
{tab.label}
|
||||||
|
</Tabs.Tab>
|
||||||
|
))}
|
||||||
|
</Tabs.List>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
<Box px="md" pt="md" pb="sm" w="100%">
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
<RuleEngineToolbar
|
<RuleEngineToolbar
|
||||||
search={search}
|
search={search}
|
||||||
|
|||||||
@@ -84,6 +84,17 @@ export interface RuleEngineOrderConfig {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A category tab above a resource list. The active tab's `filters` are sent to
|
||||||
|
* the list endpoint verbatim, so filtering happens server-side (values may be
|
||||||
|
* comma-separated lists, e.g. appliesTo: "FIRST_MILE,LAST_MILE").
|
||||||
|
*/
|
||||||
|
export interface RuleEngineListTab {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
filters: { appliesTo?: string; trigger?: string };
|
||||||
|
}
|
||||||
|
|
||||||
export interface RuleEngineResourceConfig {
|
export interface RuleEngineResourceConfig {
|
||||||
slug: RuleEngineResourceSlug;
|
slug: RuleEngineResourceSlug;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -94,6 +105,8 @@ export interface RuleEngineResourceConfig {
|
|||||||
formFields: FormFieldDef[];
|
formFields: FormFieldDef[];
|
||||||
supportsSearch?: boolean;
|
supportsSearch?: boolean;
|
||||||
orderConfig?: RuleEngineOrderConfig;
|
orderConfig?: RuleEngineOrderConfig;
|
||||||
|
/** Server-filtered category tabs rendered above the list (rates page). */
|
||||||
|
listTabs?: RuleEngineListTab[];
|
||||||
/** 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). */
|
||||||
@@ -148,16 +161,21 @@ const RATE_APPLIES_TO = [
|
|||||||
/** Surcharge triggers — only relevant when Applies to = Other. */
|
/** Surcharge triggers — only relevant when Applies to = Other. */
|
||||||
const RATE_TRIGGERS = [
|
const RATE_TRIGGERS = [
|
||||||
{ label: "Hazardous cargo", value: "HAZARDOUS" },
|
{ label: "Hazardous cargo", value: "HAZARDOUS" },
|
||||||
{ label: "Overweight (per excess ton)", value: "OVERWEIGHT" },
|
{
|
||||||
|
label: "Overweight (export only — import derives from container price)",
|
||||||
|
value: "OVERWEIGHT",
|
||||||
|
},
|
||||||
{ label: "Reefer cargo", value: "REEFER" },
|
{ label: "Reefer cargo", value: "REEFER" },
|
||||||
{ label: "Empty container return", value: "WITH_RETURN" },
|
{
|
||||||
|
label: "Empty container return (import, per route + container type)",
|
||||||
|
value: "WITH_RETURN",
|
||||||
|
},
|
||||||
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
||||||
{ label: "Consolidation", value: "CONSOLIDATION" },
|
{ label: "Penalty", value: "CONSOLIDATION" },
|
||||||
{ label: "Lashing (flat, per booking)", value: "LASHING" },
|
{ label: "Lashing (bulk, per cargo type)", value: "LASHING" },
|
||||||
{ label: "Cancellation", value: "CANCELLATION" },
|
{ label: "Cancellation", value: "CANCELLATION" },
|
||||||
{ label: "Demurrage", value: "DEMURRAGE" },
|
|
||||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||||
{ label: "Customs clearance service fee (prepaid)", value: "CUSTOMS_CLEARANCE" },
|
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -175,6 +193,15 @@ const INTERCITY_KINDS = [
|
|||||||
const isBaseFreightRate = (values: Record<string, unknown>) =>
|
const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||||
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
|
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rates priced per leg: base rail freight, plus the customs clearance fee and
|
||||||
|
* the empty-container return surcharge (sold per route + container type).
|
||||||
|
*/
|
||||||
|
const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||||
|
isBaseFreightRate(values) ||
|
||||||
|
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||||
|
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")));
|
||||||
|
|
||||||
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,7 +210,11 @@ const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value
|
|||||||
* bill per container, bulk per ton, overweight always per excess ton, etc. Kept
|
* bill per container, bulk per ton, overweight always per excess ton, etc. Kept
|
||||||
* in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts.
|
* in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts.
|
||||||
*/
|
*/
|
||||||
const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
|
const allowedRateUnits = (
|
||||||
|
appliesTo: string,
|
||||||
|
trigger: string,
|
||||||
|
cargoKind = "",
|
||||||
|
): string[] => {
|
||||||
if (appliesTo === "OTHER") {
|
if (appliesTo === "OTHER") {
|
||||||
switch (trigger) {
|
switch (trigger) {
|
||||||
case "OVERWEIGHT":
|
case "OVERWEIGHT":
|
||||||
@@ -193,16 +224,18 @@ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
|
|||||||
case "DEMURRAGE":
|
case "DEMURRAGE":
|
||||||
return ["PER_CONTAINER", "PER_TON"];
|
return ["PER_CONTAINER", "PER_TON"];
|
||||||
case "WITH_RETURN":
|
case "WITH_RETURN":
|
||||||
// Container-only service — bills per returned container.
|
// Container-only service — per returned container, per wagon, or flat.
|
||||||
return ["PER_CONTAINER", "FLAT"];
|
return ["PER_CONTAINER", "PER_WAGON", "FLAT"];
|
||||||
case "CANCELLATION":
|
case "CANCELLATION":
|
||||||
return ["FLAT", "PER_INVOICE"];
|
return ["FLAT", "PER_INVOICE"];
|
||||||
case "CUSTOMS_CLEARANCE":
|
case "CUSTOMS_CLEARANCE":
|
||||||
// Flat per clearance (ONE_TIME) / per shipment request (GENERAL).
|
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
|
||||||
return ["FLAT"];
|
return cargoKind === "BULK"
|
||||||
|
? ["PER_TON", "PER_WAGON"]
|
||||||
|
: ["PER_CONTAINER", "PER_WAGON"];
|
||||||
case "LASHING":
|
case "LASHING":
|
||||||
// Flat cargo-securing fee, billed once per booking.
|
// Bulk-only cargo securing — per ton or per wagon.
|
||||||
return ["FLAT"];
|
return ["PER_TON", "PER_WAGON"];
|
||||||
case "CONSOLIDATION":
|
case "CONSOLIDATION":
|
||||||
case "SHIPPING_LINE":
|
case "SHIPPING_LINE":
|
||||||
case "PIL_EXTRA_FEE":
|
case "PIL_EXTRA_FEE":
|
||||||
@@ -230,7 +263,11 @@ const rateUnitOptions = (values: Record<string, unknown>) => {
|
|||||||
const appliesTo = String(values.appliesTo ?? "");
|
const appliesTo = String(values.appliesTo ?? "");
|
||||||
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
|
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
|
||||||
if (!appliesTo) return [];
|
if (!appliesTo) return [];
|
||||||
return allowedRateUnits(appliesTo, trigger).map(unitOption);
|
return allowedRateUnits(
|
||||||
|
appliesTo,
|
||||||
|
trigger,
|
||||||
|
String(values.cargoKind ?? ""),
|
||||||
|
).map(unitOption);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CURRENCIES = [
|
const CURRENCIES = [
|
||||||
@@ -604,6 +641,37 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
subtitle: "Freight rates and approval workflow",
|
subtitle: "Freight rates and approval workflow",
|
||||||
searchPlaceholder: "Search rates by type or status...",
|
searchPlaceholder: "Search rates by type or status...",
|
||||||
supportsSearch: true,
|
supportsSearch: true,
|
||||||
|
// Category tabs — each filters server-side by appliesTo / trigger.
|
||||||
|
listTabs: [
|
||||||
|
{ key: "all", label: "All", filters: {} },
|
||||||
|
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER" } },
|
||||||
|
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK" } },
|
||||||
|
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY" } },
|
||||||
|
{
|
||||||
|
key: "trucking",
|
||||||
|
label: "First / Last mile",
|
||||||
|
filters: { appliesTo: "FIRST_MILE,LAST_MILE" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "customs",
|
||||||
|
label: "Customs clearance",
|
||||||
|
filters: { trigger: "CUSTOMS_CLEARANCE" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "return",
|
||||||
|
label: "Container return",
|
||||||
|
filters: { trigger: "WITH_RETURN" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "surcharges",
|
||||||
|
label: "Surcharges",
|
||||||
|
filters: {
|
||||||
|
appliesTo: "OTHER",
|
||||||
|
trigger:
|
||||||
|
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
columns: [
|
columns: [
|
||||||
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
|
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
|
||||||
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
|
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
|
||||||
@@ -640,14 +708,80 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
placeholder: "What makes this surcharge apply?",
|
placeholder: "What makes this surcharge apply?",
|
||||||
showWhen: { field: "appliesTo", equals: ["OTHER"] },
|
showWhen: { field: "appliesTo", equals: ["OTHER"] },
|
||||||
},
|
},
|
||||||
// ── Trade direction — Bulk & Container only (intercity is domestic) ───
|
// ── Trade direction — Bulk & Container base freight, plus the route-
|
||||||
|
// scoped surcharges (customs clearance; empty-container return, which is
|
||||||
|
// import-only for now so export is not offered) ────────────────────────
|
||||||
{
|
{
|
||||||
name: "tradeDirection",
|
name: "tradeDirection",
|
||||||
label: "Trade direction",
|
label: "Trade direction",
|
||||||
type: "select",
|
type: "select",
|
||||||
required: true,
|
required: true,
|
||||||
options: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
optionsFromValues: (v: Record<string, unknown>) =>
|
||||||
showWhen: { field: "appliesTo", equals: ["BULK", "CONTAINER"] },
|
String(v.trigger ?? "") === "WITH_RETURN" &&
|
||||||
|
String(v.appliesTo ?? "") === "OTHER"
|
||||||
|
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
|
||||||
|
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||||
|
showIf: (v) =>
|
||||||
|
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||||
|
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||||
|
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING"].includes(
|
||||||
|
String(v.trigger ?? ""),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
// ── Cargo kind — customs clearance is priced separately for containers
|
||||||
|
// (one rate per container type) and bulk ───────────────────────────────
|
||||||
|
{
|
||||||
|
name: "cargoKind",
|
||||||
|
label: "Cargo kind",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
options: INTERCITY_KINDS,
|
||||||
|
placeholder: "Is this fee for containers or bulk?",
|
||||||
|
description:
|
||||||
|
"Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.",
|
||||||
|
showIf: (v) =>
|
||||||
|
v.appliesTo === "OTHER" && v.trigger === "CUSTOMS_CLEARANCE",
|
||||||
|
// Not a stored column: a container fee carries its containerTypeId, a
|
||||||
|
// bulk fee carries none.
|
||||||
|
getInitialValue: (record) =>
|
||||||
|
record.containerTypeId ? "CONTAINER" : "BULK",
|
||||||
|
},
|
||||||
|
// ── Container type — a container fee names the type it covers ─────────
|
||||||
|
{
|
||||||
|
name: "containerTypeId",
|
||||||
|
label: "Container type",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
placeholder: "Which container type this fee covers",
|
||||||
|
showIf: (v) =>
|
||||||
|
v.appliesTo === "OTHER" &&
|
||||||
|
v.trigger === "CUSTOMS_CLEARANCE" &&
|
||||||
|
v.cargoKind === "CONTAINER",
|
||||||
|
},
|
||||||
|
// ── Bulk cargo type — the bulk customs fee names its commodity ────────
|
||||||
|
{
|
||||||
|
name: "cargoTypeId",
|
||||||
|
label: "Bulk cargo type",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
placeholder: "Which bulk commodity this fee covers",
|
||||||
|
showIf: (v) =>
|
||||||
|
v.appliesTo === "OTHER" &&
|
||||||
|
v.trigger === "CUSTOMS_CLEARANCE" &&
|
||||||
|
v.cargoKind === "BULK",
|
||||||
|
},
|
||||||
|
// ── Bulk cargo type — lashing is bulk-only; may narrow to one leaf
|
||||||
|
// commodity (specific wins over the commodity-wide catch-all) ──────────
|
||||||
|
{
|
||||||
|
name: "cargoTypeId",
|
||||||
|
label: "Bulk cargo type",
|
||||||
|
type: "select",
|
||||||
|
optional: true,
|
||||||
|
placeholder: "All lashing commodities (optional)",
|
||||||
|
description:
|
||||||
|
"Leave empty to cover every lashing commodity; a commodity-specific rate wins over the catch-all.",
|
||||||
|
showIf: (v) =>
|
||||||
|
v.appliesTo === "OTHER" && v.trigger === "LASHING",
|
||||||
},
|
},
|
||||||
// ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
|
// ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
|
||||||
{
|
{
|
||||||
@@ -664,7 +798,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
getInitialValue: (record) =>
|
getInitialValue: (record) =>
|
||||||
record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER",
|
record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER",
|
||||||
},
|
},
|
||||||
// ── Container type — Container freight, and container-kind intercity ──
|
// ── Container type — Container freight, container-kind intercity, and
|
||||||
|
// the empty-container return surcharge (20ft vs 40ft price differently) ─
|
||||||
{
|
{
|
||||||
name: "containerTypeId",
|
name: "containerTypeId",
|
||||||
label: "Container type",
|
label: "Container type",
|
||||||
@@ -673,7 +808,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
placeholder: "Select container type (optional)",
|
placeholder: "Select container type (optional)",
|
||||||
showIf: (v) =>
|
showIf: (v) =>
|
||||||
v.appliesTo === "CONTAINER" ||
|
v.appliesTo === "CONTAINER" ||
|
||||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER"),
|
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
|
||||||
|
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN"),
|
||||||
},
|
},
|
||||||
// ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─
|
// ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─
|
||||||
{
|
{
|
||||||
@@ -696,7 +832,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
type: "select",
|
type: "select",
|
||||||
required: true,
|
required: true,
|
||||||
placeholder: "Where the leg starts",
|
placeholder: "Where the leg starts",
|
||||||
showIf: isBaseFreightRate,
|
showIf: isRouteScopedRate,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "destinationYardId",
|
name: "destinationYardId",
|
||||||
@@ -704,7 +840,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
type: "select",
|
type: "select",
|
||||||
required: true,
|
required: true,
|
||||||
placeholder: "Where the leg ends",
|
placeholder: "Where the leg ends",
|
||||||
showIf: isBaseFreightRate,
|
showIf: isRouteScopedRate,
|
||||||
},
|
},
|
||||||
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
|
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
|
||||||
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
|
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { CheckCircle2, Download, Eye, FileText, Printer, Search, XCircle } from 'lucide-react';
|
import { CheckCircle2, Download, Eye, FileText, Printer, Search } from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||||
@@ -21,7 +21,6 @@ import { PageContainer, PageHeader } from '@/components/page';
|
|||||||
import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
|
import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
|
||||||
import {
|
import {
|
||||||
useAcknowledgeInterchangeDocument,
|
useAcknowledgeInterchangeDocument,
|
||||||
useCancelInterchangeDocument,
|
|
||||||
useDisputeInterchangeDocument,
|
useDisputeInterchangeDocument,
|
||||||
useInterchangeDocument,
|
useInterchangeDocument,
|
||||||
useInterchangeDocuments,
|
useInterchangeDocuments,
|
||||||
@@ -66,7 +65,7 @@ const buildPrintableInterchangeHtml = (document: InterchangeDocument) => {
|
|||||||
(item, index) => `
|
(item, index) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${index + 1}</td>
|
<td>${index + 1}</td>
|
||||||
<td>${escapeHtml(item.bookingReference ?? item.bookingId?.slice(0, 8))}</td>
|
<td>${escapeHtml(item.bookingReference)}</td>
|
||||||
<td>${escapeHtml(item.itemType)}</td>
|
<td>${escapeHtml(item.itemType)}</td>
|
||||||
<td>${escapeHtml(item.containerNumber)}</td>
|
<td>${escapeHtml(item.containerNumber)}</td>
|
||||||
<td>${escapeHtml(item.sealNumber)}</td>
|
<td>${escapeHtml(item.sealNumber)}</td>
|
||||||
@@ -118,7 +117,6 @@ const buildPrintableInterchangeHtml = (document: InterchangeDocument) => {
|
|||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="field"><div class="label">Direction</div><div class="value">${escapeHtml(document.direction)}</div></div>
|
<div class="field"><div class="label">Direction</div><div class="value">${escapeHtml(document.direction)}</div></div>
|
||||||
<div class="field"><div class="label">Train No</div><div class="value">${escapeHtml(document.trainNo)}</div></div>
|
<div class="field"><div class="label">Train No</div><div class="value">${escapeHtml(document.trainNo)}</div></div>
|
||||||
<div class="field"><div class="label">Schedule</div><div class="value">${escapeHtml(document.scheduleId)}</div></div>
|
|
||||||
<div class="field"><div class="label">Handover Location</div><div class="value">${escapeHtml(document.handoverLocation)}</div></div>
|
<div class="field"><div class="label">Handover Location</div><div class="value">${escapeHtml(document.handoverLocation)}</div></div>
|
||||||
<div class="field"><div class="label">Handover From</div><div class="value">${escapeHtml(document.handoverFrom)}</div></div>
|
<div class="field"><div class="label">Handover From</div><div class="value">${escapeHtml(document.handoverFrom)}</div></div>
|
||||||
<div class="field"><div class="label">Handover To</div><div class="value">${escapeHtml(document.handoverTo)}</div></div>
|
<div class="field"><div class="label">Handover To</div><div class="value">${escapeHtml(document.handoverTo)}</div></div>
|
||||||
@@ -191,7 +189,6 @@ function InterchangeDocumentDetail({ id }: { id: string }) {
|
|||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||||
<DetailField label="Document No" value={document.documentNo} />
|
<DetailField label="Document No" value={document.documentNo} />
|
||||||
<DetailField label="Direction" value={document.direction} />
|
<DetailField label="Direction" value={document.direction} />
|
||||||
<DetailField label="Schedule" value={document.scheduleId?.slice(0, 8)} />
|
|
||||||
<DetailField label="Train No" value={document.trainNo} />
|
<DetailField label="Train No" value={document.trainNo} />
|
||||||
<DetailField label="Handover Location" value={document.handoverLocation} />
|
<DetailField label="Handover Location" value={document.handoverLocation} />
|
||||||
<DetailField label="Handover From" value={document.handoverFrom} />
|
<DetailField label="Handover From" value={document.handoverFrom} />
|
||||||
@@ -232,7 +229,7 @@ function InterchangeDocumentDetail({ id }: { id: string }) {
|
|||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Table.Tr key={item.id}>
|
<Table.Tr key={item.id}>
|
||||||
<Table.Td>{item.bookingReference ?? item.bookingId?.slice(0, 8) ?? '-'}</Table.Td>
|
<Table.Td>{item.bookingReference ?? '-'}</Table.Td>
|
||||||
<Table.Td>{item.itemType}</Table.Td>
|
<Table.Td>{item.itemType}</Table.Td>
|
||||||
<Table.Td>{item.containerNumber ?? '-'}</Table.Td>
|
<Table.Td>{item.containerNumber ?? '-'}</Table.Td>
|
||||||
<Table.Td>{item.sealNumber ?? '-'}</Table.Td>
|
<Table.Td>{item.sealNumber ?? '-'}</Table.Td>
|
||||||
@@ -268,7 +265,6 @@ export default function InterchangeDocumentsPage() {
|
|||||||
const { data: documents = [], isLoading } = useInterchangeDocuments(filter);
|
const { data: documents = [], isLoading } = useInterchangeDocuments(filter);
|
||||||
const acknowledge = useAcknowledgeInterchangeDocument();
|
const acknowledge = useAcknowledgeInterchangeDocument();
|
||||||
const dispute = useDisputeInterchangeDocument();
|
const dispute = useDisputeInterchangeDocument();
|
||||||
const cancel = useCancelInterchangeDocument();
|
|
||||||
|
|
||||||
const getPrintableDocument = async (interchangeDocument: InterchangeDocument) => {
|
const getPrintableDocument = async (interchangeDocument: InterchangeDocument) => {
|
||||||
if (interchangeDocument.items?.length) return interchangeDocument;
|
if (interchangeDocument.items?.length) return interchangeDocument;
|
||||||
@@ -336,19 +332,7 @@ export default function InterchangeDocumentsPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ id: 'direction', header: 'Direction', cell: ({ row }) => row.original.direction },
|
{ id: 'direction', header: 'Direction', cell: ({ row }) => row.original.direction },
|
||||||
{
|
{ id: 'train', header: 'Train No', cell: ({ row }) => row.original.trainNo ?? '-' },
|
||||||
id: 'train',
|
|
||||||
header: 'Train No / Schedule',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Text size="sm">{row.original.trainNo ?? '-'}</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{row.original.scheduleId?.slice(0, 8) ?? '-'}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ id: 'route', header: 'Route', cell: ({ row }) => row.original.routeId?.slice(0, 8) ?? '-' },
|
|
||||||
{ id: 'handoverLocation', header: 'Handover Location', cell: ({ row }) => row.original.handoverLocation },
|
{ id: 'handoverLocation', header: 'Handover Location', cell: ({ row }) => row.original.handoverLocation },
|
||||||
{ id: 'handoverFrom', header: 'Handover From', cell: ({ row }) => row.original.handoverFrom },
|
{ id: 'handoverFrom', header: 'Handover From', cell: ({ row }) => row.original.handoverFrom },
|
||||||
{ id: 'handoverTo', header: 'Handover To', cell: ({ row }) => row.original.handoverTo },
|
{ id: 'handoverTo', header: 'Handover To', cell: ({ row }) => row.original.handoverTo },
|
||||||
@@ -390,7 +374,7 @@ export default function InterchangeDocumentsPage() {
|
|||||||
>
|
>
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
{doc.status !== 'ACKNOWLEDGED' && doc.status !== 'CANCELLED' ? (
|
{doc.status === 'GENERATED' ? (
|
||||||
<Button
|
<Button
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
color="green"
|
color="green"
|
||||||
@@ -423,7 +407,9 @@ export default function InterchangeDocumentsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{doc.status !== 'CANCELLED' ? (
|
{/* Disputes are raised BEFORE acknowledgement; a registered dispute
|
||||||
|
(DISPUTED) is read-only — its row offers View only. */}
|
||||||
|
{doc.status === 'GENERATED' ? (
|
||||||
<Button
|
<Button
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
color="orange"
|
color="orange"
|
||||||
@@ -434,17 +420,6 @@ export default function InterchangeDocumentsPage() {
|
|||||||
Dispute
|
Dispute
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{doc.status === 'DRAFT' || doc.status === 'GENERATED' ? (
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
color="red"
|
|
||||||
variant="light"
|
|
||||||
leftSection={<XCircle size={14} />}
|
|
||||||
onClick={() => run(() => cancel.mutateAsync(doc.id), 'Interchange document cancelled')}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,4 @@ export const interchangeDocumentsService = {
|
|||||||
apiClient.patch<InterchangeDocument>(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.ACKNOWLEDGE(id), payload),
|
apiClient.patch<InterchangeDocument>(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.ACKNOWLEDGE(id), payload),
|
||||||
dispute: (id: string, payload: { remarks: string }) =>
|
dispute: (id: string, payload: { remarks: string }) =>
|
||||||
apiClient.patch<InterchangeDocument>(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.DISPUTE(id), payload),
|
apiClient.patch<InterchangeDocument>(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.DISPUTE(id), payload),
|
||||||
cancel: (id: string) =>
|
|
||||||
apiClient.patch<InterchangeDocument>(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.CANCEL(id), {}),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export interface RuleEngineListParams {
|
|||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: "ASC" | "DESC";
|
sortOrder?: "ASC" | "DESC";
|
||||||
requiresDirectorApproval?: boolean;
|
requiresDirectorApproval?: boolean;
|
||||||
|
/** Rates category tabs — comma-separated appliesTo / trigger filters. */
|
||||||
|
appliesTo?: string;
|
||||||
|
trigger?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuleEngineReorderPayload {
|
export interface RuleEngineReorderPayload {
|
||||||
@@ -205,6 +208,8 @@ export const ruleEngineService = {
|
|||||||
sortBy: params?.sortBy,
|
sortBy: params?.sortBy,
|
||||||
sortOrder: params?.sortOrder,
|
sortOrder: params?.sortOrder,
|
||||||
requiresDirectorApproval: params?.requiresDirectorApproval,
|
requiresDirectorApproval: params?.requiresDirectorApproval,
|
||||||
|
appliesTo: params?.appliesTo,
|
||||||
|
trigger: params?.trigger,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return normalizeList<T>(response.data, page, pageSize);
|
return normalizeList<T>(response.data, page, pageSize);
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export enum FilterEnum {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getOrganizations = async (
|
export const getOrganizations = async (
|
||||||
params?: OrgQueryParams
|
params?: OrgQueryParams,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
return axiosInstance.get("/organizations/filter", {
|
return axiosInstance.get("/organizations/filter", {
|
||||||
...{ headers: withHeaders() },
|
...{ headers: withHeaders() },
|
||||||
@@ -45,29 +45,27 @@ export const getOrganizations = async (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMyAdminOrganizations = async (): Promise<AxiosResponse> => {
|
|
||||||
//my-admin-organizations
|
|
||||||
return axiosInstance.get("/organizations/with-admin-flag", {
|
|
||||||
headers: withHeaders(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
export const getOrganizationsWithAdminFlag = async (
|
export const getOrganizationsWithAdminFlag = async (
|
||||||
params?: OrgQueryParams
|
params?: OrgQueryParams,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
return axiosInstance.get("/organizations/with-admin-flag", {
|
return axiosInstance.get("/organizations/with-admin-flag", {
|
||||||
...{ headers: withHeaders() },
|
...{ headers: withHeaders() },
|
||||||
params,
|
params: {
|
||||||
|
take: 100,
|
||||||
|
...params,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
export const getMyAdminOrganizations = getOrganizationsWithAdminFlag;
|
||||||
|
|
||||||
export const getOrganizationById = async (
|
export const getOrganizationById = async (
|
||||||
id: string | number
|
id: string | number,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
return axiosInstance.get(`/organizations/${id}`, { headers: withHeaders() });
|
return axiosInstance.get(`/organizations/${id}`, { headers: withHeaders() });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getChildren = async (
|
export const getChildren = async (
|
||||||
id: string | number
|
id: string | number,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
return axiosInstance.get(`/organizations/${id}/children`, {
|
return axiosInstance.get(`/organizations/${id}/children`, {
|
||||||
headers: withHeaders(),
|
headers: withHeaders(),
|
||||||
@@ -76,7 +74,7 @@ export const getChildren = async (
|
|||||||
|
|
||||||
export const getEmployeesUnderOrg = async (
|
export const getEmployeesUnderOrg = async (
|
||||||
id: string | number,
|
id: string | number,
|
||||||
params?: OrgQueryParams
|
params?: OrgQueryParams,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
return axiosInstance.get(`/organizations/current/${id}/employees`, {
|
return axiosInstance.get(`/organizations/current/${id}/employees`, {
|
||||||
headers: withHeaders(),
|
headers: withHeaders(),
|
||||||
@@ -89,7 +87,7 @@ export const getEmployeesUnderOrg = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getEmployeeCountByOrgId = async (
|
export const getEmployeeCountByOrgId = async (
|
||||||
id: string | number
|
id: string | number,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
// Try the endpoint from your curl example
|
// Try the endpoint from your curl example
|
||||||
return axiosInstance.get(`/organizations/${id}/employees/count`, {
|
return axiosInstance.get(`/organizations/${id}/employees/count`, {
|
||||||
@@ -98,13 +96,13 @@ export const getEmployeeCountByOrgId = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createOrganization = async (
|
export const createOrganization = async (
|
||||||
data: OrganizationPayload
|
data: OrganizationPayload,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
return axiosInstance.post("/organizations", data, { headers: withHeaders() });
|
return axiosInstance.post("/organizations", data, { headers: withHeaders() });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const activateOrganization = async (
|
export const activateOrganization = async (
|
||||||
id: string | number
|
id: string | number,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
return axiosInstance.patch(`/organizations/${id}/activate`, null, {
|
return axiosInstance.patch(`/organizations/${id}/activate`, null, {
|
||||||
headers: withHeaders(),
|
headers: withHeaders(),
|
||||||
@@ -112,7 +110,7 @@ export const activateOrganization = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const deActivateOrganization = async (
|
export const deActivateOrganization = async (
|
||||||
id: string | number
|
id: string | number,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
return axiosInstance.patch(`/organizations/${id}/debar`, null, {
|
return axiosInstance.patch(`/organizations/${id}/debar`, null, {
|
||||||
headers: withHeaders(),
|
headers: withHeaders(),
|
||||||
@@ -121,7 +119,7 @@ export const deActivateOrganization = async (
|
|||||||
|
|
||||||
export const updateOrganization = async (
|
export const updateOrganization = async (
|
||||||
id: string | number,
|
id: string | number,
|
||||||
data: OrganizationPayload
|
data: OrganizationPayload,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
return axiosInstance.put(`/organizations/${id}`, data, {
|
return axiosInstance.put(`/organizations/${id}`, data, {
|
||||||
headers: withHeaders(),
|
headers: withHeaders(),
|
||||||
@@ -129,7 +127,7 @@ export const updateOrganization = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const deleteOrganization = async (
|
export const deleteOrganization = async (
|
||||||
id: string | number
|
id: string | number,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
return axiosInstance.delete(`/organizations/${id}`, {
|
return axiosInstance.delete(`/organizations/${id}`, {
|
||||||
headers: withHeaders(),
|
headers: withHeaders(),
|
||||||
@@ -137,7 +135,7 @@ export const deleteOrganization = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const softDeleteOrganization = async (
|
export const softDeleteOrganization = async (
|
||||||
id: string | number
|
id: string | number,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
return axiosInstance.delete(`/organizations/${id}/soft`, {
|
return axiosInstance.delete(`/organizations/${id}/soft`, {
|
||||||
headers: withHeaders(),
|
headers: withHeaders(),
|
||||||
@@ -149,7 +147,7 @@ export const softDeleteOrganization = async (
|
|||||||
// GET /organizations/archived
|
// GET /organizations/archived
|
||||||
// PATCH /organizations/{id}/restore
|
// PATCH /organizations/{id}/restore
|
||||||
export const getArchivedOrganizations = async (
|
export const getArchivedOrganizations = async (
|
||||||
params?: OrgQueryParams
|
params?: OrgQueryParams,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
return axiosInstance.get(`/organizations/archived`, {
|
return axiosInstance.get(`/organizations/archived`, {
|
||||||
headers: withHeaders(),
|
headers: withHeaders(),
|
||||||
@@ -158,7 +156,7 @@ export const getArchivedOrganizations = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const restoreOrganization = async (
|
export const restoreOrganization = async (
|
||||||
id: string | number
|
id: string | number,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
return axiosInstance.patch(`/organizations/${id}/restore`, null, {
|
return axiosInstance.patch(`/organizations/${id}/restore`, null, {
|
||||||
headers: withHeaders(),
|
headers: withHeaders(),
|
||||||
@@ -171,20 +169,20 @@ export const getDocumentRequirements = async (): Promise<AxiosResponse> => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
export const getDocumentRequirementsById = async (
|
export const getDocumentRequirementsById = async (
|
||||||
id: string
|
id: string,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
return axiosInstance.get(`/documentary-requirements/${id}`, {
|
return axiosInstance.get(`/documentary-requirements/${id}`, {
|
||||||
headers: withHeaders(),
|
headers: withHeaders(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
export const getDocumentRequirementsByFilter = async (
|
export const getDocumentRequirementsByFilter = async (
|
||||||
filter: FilterEnum
|
filter: FilterEnum,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
return axiosInstance.get(`/documentary-requirements/${filter}/type`);
|
return axiosInstance.get(`/documentary-requirements/${filter}/type`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const postDocumentRequirements = async (
|
export const postDocumentRequirements = async (
|
||||||
data: DocumentRequirementDto
|
data: DocumentRequirementDto,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
return axiosInstance.post(`/documentary-requirements`, data, {
|
return axiosInstance.post(`/documentary-requirements`, data, {
|
||||||
headers: withHeaders(),
|
headers: withHeaders(),
|
||||||
@@ -193,7 +191,7 @@ export const postDocumentRequirements = async (
|
|||||||
|
|
||||||
export const giveResponse = async (
|
export const giveResponse = async (
|
||||||
id: string,
|
id: string,
|
||||||
data: ResponseActionDto
|
data: ResponseActionDto,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
return axiosInstance.post(`user-documents/${id}/response`, data, {
|
return axiosInstance.post(`user-documents/${id}/response`, data, {
|
||||||
headers: withHeaders(),
|
headers: withHeaders(),
|
||||||
@@ -202,7 +200,7 @@ export const giveResponse = async (
|
|||||||
|
|
||||||
export const getArchivedUserId = async (
|
export const getArchivedUserId = async (
|
||||||
unitId: string,
|
unitId: string,
|
||||||
params?: OrgQueryParams
|
params?: OrgQueryParams,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
return axiosInstance.get(`/employees/archived/${unitId}/with-unit`, {
|
return axiosInstance.get(`/employees/archived/${unitId}/with-unit`, {
|
||||||
headers: withHeaders(),
|
headers: withHeaders(),
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/common/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/common/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/common/ui/select";
|
||||||
|
import { Input } from "@/shared/common/ui/input";
|
||||||
|
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||||
|
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||||
|
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||||
|
import { OrgAdminUser } from "@/super-admin/hooks/useOrgAdmins";
|
||||||
|
|
||||||
|
const adminSchema = z.object({
|
||||||
|
name: z.object({
|
||||||
|
en: z.string().min(1, t("organization.englishNameRequired")),
|
||||||
|
am: z.string().min(1, t("organization.amharicNameRequired")),
|
||||||
|
}),
|
||||||
|
username: z.string().min(3, t("organization.usernameMinLength")),
|
||||||
|
email: z.string().email(t("organization.invalidEmail")),
|
||||||
|
phoneNumber: z
|
||||||
|
.string()
|
||||||
|
.regex(/^(\+251|0)?9\d{8}$/, t("organization.invalidPhoneNumber")),
|
||||||
|
organizationId: z.string().min(1, t("organization.organizationRequired")),
|
||||||
|
/** required when the org has units (unit admin); empty only when the org
|
||||||
|
* has no units → org admin. Enforced at submit, not in the schema. */
|
||||||
|
unitId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AdminFormValues = z.infer<typeof adminSchema>;
|
||||||
|
|
||||||
|
const EMPTY_VALUES: AdminFormValues = {
|
||||||
|
name: { en: "", am: "" },
|
||||||
|
username: "",
|
||||||
|
email: "",
|
||||||
|
phoneNumber: "",
|
||||||
|
organizationId: "",
|
||||||
|
unitId: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const RequiredMark = () => <span className="text-red-500"> *</span>;
|
||||||
|
|
||||||
|
interface AdminFormModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** null → add a new admin; set → edit this admin's profile */
|
||||||
|
admin: OrgAdminUser | null;
|
||||||
|
/** org whose units feed the unit-admin scope picker */
|
||||||
|
organizationId?: string;
|
||||||
|
/** server-side error from the last submit, shown inline */
|
||||||
|
apiError: string | null;
|
||||||
|
onSubmit: (values: AdminFormValues) => void;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add-admin (org or unit scope) / edit-admin-profile modal (one form). */
|
||||||
|
export default function AdminFormModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
admin,
|
||||||
|
organizationId,
|
||||||
|
apiError,
|
||||||
|
onSubmit,
|
||||||
|
isSubmitting,
|
||||||
|
}: AdminFormModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const localizedName = useLocalizedName();
|
||||||
|
const isEdit = !!admin;
|
||||||
|
|
||||||
|
const form = useForm<AdminFormValues>({
|
||||||
|
resolver: zodResolver(adminSchema),
|
||||||
|
defaultValues: EMPTY_VALUES,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { organizationsResponse } = useOrganizations("Org", { take: 3000 });
|
||||||
|
const activeOrgs = (organizationsResponse?.items ?? []).filter(
|
||||||
|
(org) => org.status === "Active",
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedOrgId = form.watch("organizationId");
|
||||||
|
const { data: unitsResponse, isLoading: isLoadingUnits } =
|
||||||
|
useUnit().getList(
|
||||||
|
selectedOrgId || "",
|
||||||
|
{ take: 300, skip: 0 },
|
||||||
|
isOpen && !isEdit,
|
||||||
|
);
|
||||||
|
const units = unitsResponse?.data?.items ?? [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
form.reset(
|
||||||
|
admin
|
||||||
|
? {
|
||||||
|
...EMPTY_VALUES,
|
||||||
|
name: {
|
||||||
|
en: admin.name?.en ?? "",
|
||||||
|
am: admin.name?.am ?? "",
|
||||||
|
},
|
||||||
|
username: admin.username ?? "",
|
||||||
|
email: admin.email ?? "",
|
||||||
|
phoneNumber: admin.phoneNumber ?? "",
|
||||||
|
}
|
||||||
|
: { ...EMPTY_VALUES, organizationId: organizationId ?? "" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [isOpen, admin, organizationId, form]);
|
||||||
|
|
||||||
|
// an org with units gets a unit admin — unit is mandatory then; only a
|
||||||
|
// unit-less org falls through to an org admin
|
||||||
|
const submit = form.handleSubmit((values) => {
|
||||||
|
if (!isEdit && units.length > 0 && !values.unitId) {
|
||||||
|
form.setError("unitId", {
|
||||||
|
message: t("orgAdmins.form.unitRequired"),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit(values);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (isSubmitting) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||||
|
<DialogContent className="sm:max-w-[520px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{isEdit ? t("orgAdmins.edit.title") : t("orgAdmins.add.title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isEdit
|
||||||
|
? t("orgAdmins.edit.description")
|
||||||
|
: t("orgAdmins.add.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name.en"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("orgAdmins.form.nameEn")}
|
||||||
|
<RequiredMark />
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={t("organization.enterEnglishName")}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name.am"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("orgAdmins.form.nameAm")}
|
||||||
|
<RequiredMark />
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={t("organization.enterAmharicName")}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("orgAdmins.form.username")}
|
||||||
|
<RequiredMark />
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={t("organization.enterUsername")}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("orgAdmins.form.email")}
|
||||||
|
<RequiredMark />
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder={t("organization.emailExample")}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="phoneNumber"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("orgAdmins.form.phoneNumber")}
|
||||||
|
<RequiredMark />
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="tel"
|
||||||
|
placeholder={t("organization.phoneNumberExample")}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isEdit && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="organizationId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("organization.organization")}
|
||||||
|
<RequiredMark />
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
form.setValue("unitId", ""); // reset unit when org changes
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
"organization.selectOrganization",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{activeOrgs.map((org) => (
|
||||||
|
<SelectItem key={org.id} value={org.id}>
|
||||||
|
{localizedName(org.name)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{(isLoadingUnits || units.length > 0 || !selectedOrgId) && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="unitId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("orgAdmins.form.unit")}
|
||||||
|
<RequiredMark />
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
disabled={!selectedOrgId || isLoadingUnits}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
isLoadingUnits
|
||||||
|
? t("orgAdmins.form.loadingUnits")
|
||||||
|
: t("orgAdmins.form.selectUnit")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{units.map((unit: any) => (
|
||||||
|
<SelectItem key={unit.id} value={unit.id}>
|
||||||
|
{localizedName(unit.name)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{!isLoadingUnits && selectedOrgId && units.length === 0
|
||||||
|
? t("orgAdmins.add.noUnitsOrgAdmin")
|
||||||
|
: t("orgAdmins.add.inviteNote")}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{apiError && (
|
||||||
|
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
|
||||||
|
{apiError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || (!isEdit && isLoadingUnits)}
|
||||||
|
>
|
||||||
|
{isSubmitting && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{isEdit
|
||||||
|
? t("orgAdmins.edit.submit")
|
||||||
|
: t("orgAdmins.add.submit")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Check, Loader2, UserPlus } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/common/ui/dialog";
|
||||||
|
import { Input } from "@/shared/common/ui/input";
|
||||||
|
import { Label } from "@/shared/common/ui/label";
|
||||||
|
import { ScrollArea } from "@/shared/common/ui/scroll-area";
|
||||||
|
import { Badge } from "@/shared/common/ui/badge";
|
||||||
|
import { cn } from "@/super-admin/lib/utils";
|
||||||
|
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||||
|
import { useEmployees } from "@/user-management/hooks/useEmployees";
|
||||||
|
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||||
|
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||||
|
|
||||||
|
interface AssignExistingAdminModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** org preselected in the panel (the page's current org) */
|
||||||
|
organizationId: string;
|
||||||
|
/** user ids that are already admins of the page's org — shown disabled */
|
||||||
|
existingAdminIds: string[];
|
||||||
|
/** server-side error from the last assign attempt, shown inline */
|
||||||
|
apiError: string | null;
|
||||||
|
/** unitId set → grant unit-admin of that unit instead of org-admin */
|
||||||
|
onAssign: (userId: string, organizationId: string, unitId?: string) => void;
|
||||||
|
isAssigning: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promote an existing employee to admin. Three panels like the old
|
||||||
|
* AssignAdminDialog: pick an org (page org preselected), pick a unit (or
|
||||||
|
* none → org admin), then pick a user — unit selection also filters the
|
||||||
|
* employee list to that unit.
|
||||||
|
*/
|
||||||
|
export default function AssignExistingAdminModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
organizationId,
|
||||||
|
existingAdminIds,
|
||||||
|
apiError,
|
||||||
|
onAssign,
|
||||||
|
isAssigning,
|
||||||
|
}: AssignExistingAdminModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const localizedName = useLocalizedName();
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState("");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [orgId, setOrgId] = useState(organizationId);
|
||||||
|
// "" → org admin (all org users listed); set → unit admin of that unit
|
||||||
|
const [unitId, setUnitId] = useState("");
|
||||||
|
|
||||||
|
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
|
||||||
|
"Org",
|
||||||
|
{ take: 300 },
|
||||||
|
);
|
||||||
|
const orgs = organizationsResponse?.items ?? [];
|
||||||
|
|
||||||
|
const { data: unitsResponse, isLoading: isLoadingUnits } =
|
||||||
|
useUnit().getList(orgId, { take: 300, skip: 0 }, isOpen);
|
||||||
|
const units = unitsResponse?.data?.items ?? [];
|
||||||
|
|
||||||
|
const {
|
||||||
|
employeesResponseByOrg,
|
||||||
|
isLoadingEmployeesByOrg,
|
||||||
|
isErrorEmployeesByOrg,
|
||||||
|
refetchEmployeesByOrg,
|
||||||
|
} = useEmployees({
|
||||||
|
organizationId: isOpen ? orgId : undefined,
|
||||||
|
unitId: unitId || undefined,
|
||||||
|
params: { take: 3000, skip: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredEmployees = useMemo(() => {
|
||||||
|
const query = search.trim().toLowerCase();
|
||||||
|
const employees = employeesResponseByOrg?.items ?? [];
|
||||||
|
if (!query) return employees;
|
||||||
|
return employees.filter((employee: any) => {
|
||||||
|
const name = localizedName(employee.user?.name).toLowerCase();
|
||||||
|
const email = employee.user?.email?.toLowerCase() ?? "";
|
||||||
|
return name.includes(query) || email.includes(query);
|
||||||
|
});
|
||||||
|
}, [employeesResponseByOrg, localizedName, search]);
|
||||||
|
|
||||||
|
const selectOrg = (id: string) => {
|
||||||
|
setOrgId(id);
|
||||||
|
setUnitId("");
|
||||||
|
setSelectedUserId("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectUnit = (id: string) => {
|
||||||
|
setUnitId(id);
|
||||||
|
setSelectedUserId("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (isAssigning) return;
|
||||||
|
setSelectedUserId("");
|
||||||
|
setSearch("");
|
||||||
|
setOrgId(organizationId);
|
||||||
|
setUnitId("");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// already-admin info only covers the page's org
|
||||||
|
const knownAdminIds = orgId === organizationId ? existingAdminIds : [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setOrgId(organizationId);
|
||||||
|
setUnitId("");
|
||||||
|
setSelectedUserId("");
|
||||||
|
setSearch("");
|
||||||
|
}
|
||||||
|
}, [isOpen, organizationId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||||
|
<DialogContent className="sm:max-w-[960px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("orgAdmins.assign.title")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("orgAdmins.assign.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
{/* Step 1: organization (page org preselected) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">
|
||||||
|
{t("organization.organizations")}
|
||||||
|
</Label>
|
||||||
|
{isLoadingOrgs ? (
|
||||||
|
<div className="flex h-[320px] items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-[320px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{orgs.map((org) => (
|
||||||
|
<button
|
||||||
|
key={org.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectOrg(org.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
|
||||||
|
orgId === org.id
|
||||||
|
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
|
||||||
|
: "text-gray-700 hover:bg-emerald-50 dark:text-gray-300 dark:hover:bg-emerald-900/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{localizedName(org.name)}
|
||||||
|
</span>
|
||||||
|
{orgId === org.id && (
|
||||||
|
<Check className="ml-2 h-4 w-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 2: unit (or none → org admin) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">
|
||||||
|
{t("orgAdmins.form.unit")}
|
||||||
|
</Label>
|
||||||
|
{isLoadingUnits ? (
|
||||||
|
<div className="flex h-[320px] items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-[320px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectUnit("")}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
|
||||||
|
!unitId
|
||||||
|
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
|
||||||
|
: "text-gray-700 hover:bg-emerald-50 dark:text-gray-300 dark:hover:bg-emerald-900/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{t("orgAdmins.form.noUnit")}</span>
|
||||||
|
{!unitId && <Check className="ml-2 h-4 w-4 shrink-0" />}
|
||||||
|
</button>
|
||||||
|
{units.map((unit: any) => (
|
||||||
|
<button
|
||||||
|
key={unit.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectUnit(unit.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
|
||||||
|
unitId === unit.id
|
||||||
|
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
|
||||||
|
: "text-gray-700 hover:bg-emerald-50 dark:text-gray-300 dark:hover:bg-emerald-900/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{localizedName(unit.name)}
|
||||||
|
</span>
|
||||||
|
{unitId === unit.id && (
|
||||||
|
<Check className="ml-2 h-4 w-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 3: user */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">
|
||||||
|
{t("orgAdmins.assign.users")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder={t("orgAdmins.assign.searchUsers")}
|
||||||
|
/>
|
||||||
|
{isLoadingEmployeesByOrg ? (
|
||||||
|
<div className="flex h-[272px] items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : isErrorEmployeesByOrg ? (
|
||||||
|
<div className="flex h-[272px] flex-col items-center justify-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||||
|
{t("orgAdmins.assign.loadError")}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refetchEmployeesByOrg()}
|
||||||
|
>
|
||||||
|
{t("common.retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-[272px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filteredEmployees.map((employee: any) => {
|
||||||
|
const userId = employee.user?.id;
|
||||||
|
if (!userId) return null;
|
||||||
|
const isAlreadyAdmin = knownAdminIds.includes(userId);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={userId}
|
||||||
|
type="button"
|
||||||
|
disabled={isAlreadyAdmin}
|
||||||
|
onClick={() => setSelectedUserId(userId)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
|
||||||
|
isAlreadyAdmin
|
||||||
|
? "cursor-not-allowed opacity-50"
|
||||||
|
: selectedUserId === userId
|
||||||
|
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
|
||||||
|
: "text-gray-700 hover:bg-emerald-50 dark:text-gray-300 dark:hover:bg-emerald-900/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">
|
||||||
|
{localizedName(employee.user?.name) ||
|
||||||
|
employee.user?.email}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{employee.user?.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isAlreadyAdmin ? (
|
||||||
|
<Badge variant="outline" className="ml-2 shrink-0">
|
||||||
|
{t("orgAdmins.assign.alreadyAdmin")}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
selectedUserId === userId && (
|
||||||
|
<Check className="ml-2 h-4 w-4 shrink-0" />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filteredEmployees.length === 0 && (
|
||||||
|
<p className="mt-2 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t("orgAdmins.assign.noUsersFound")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{apiError && (
|
||||||
|
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
|
||||||
|
{apiError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isAssigning}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={isAssigning || !selectedUserId || !orgId}
|
||||||
|
onClick={() =>
|
||||||
|
onAssign(selectedUserId, orgId, unitId || undefined)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isAssigning ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t("orgAdmins.assign.submit")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
ArrowUpDown,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
Send,
|
||||||
|
Trash2,
|
||||||
|
UserCheck,
|
||||||
|
UserX,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import { Badge } from "@/shared/common/ui/badge";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/common/ui/dropdown-menu";
|
||||||
|
import { OrgAdminUser } from "@/super-admin/hooks/useOrgAdmins";
|
||||||
|
|
||||||
|
export const ORG_ADMIN_ROLE_KEY = "organization_admin";
|
||||||
|
export const UNIT_ADMIN_ROLE_KEY = "unit_admin";
|
||||||
|
|
||||||
|
export interface AdminRoleInfo {
|
||||||
|
isOrgAdmin: boolean;
|
||||||
|
isUnitAdmin: boolean;
|
||||||
|
unitId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* all-admins/:id returns users who are org admins of the org OR unit admins of
|
||||||
|
* one of its units; userRoles carries every role of the user, so match the org
|
||||||
|
* explicitly for the org-admin grant.
|
||||||
|
*/
|
||||||
|
// ponytail: unit relation isn't loaded, so a unit_admin grant from another org
|
||||||
|
// can't be told apart — acceptable, the server only returns admins of this org.
|
||||||
|
export function getAdminRoleInfo(
|
||||||
|
admin: OrgAdminUser,
|
||||||
|
selectedOrgId: string,
|
||||||
|
): AdminRoleInfo {
|
||||||
|
const roles = admin.userRoles ?? [];
|
||||||
|
const isOrgAdmin = roles.some(
|
||||||
|
(r) =>
|
||||||
|
r.role?.key === ORG_ADMIN_ROLE_KEY &&
|
||||||
|
r.organizationId === selectedOrgId,
|
||||||
|
);
|
||||||
|
const unitRole = roles.find(
|
||||||
|
(r) => r.role?.key === UNIT_ADMIN_ROLE_KEY && r.unitId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
isOrgAdmin,
|
||||||
|
isUnitAdmin: !!unitRole,
|
||||||
|
unitId: unitRole?.unitId ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnCallbacks {
|
||||||
|
selectedOrgId: string;
|
||||||
|
localizedName: (name?: { am?: string; en?: string }) => string;
|
||||||
|
onEdit: (admin: OrgAdminUser) => void;
|
||||||
|
onResend: (admin: OrgAdminUser) => void;
|
||||||
|
onToggleActive: (admin: OrgAdminUser) => void;
|
||||||
|
onRemove: (admin: OrgAdminUser, roleInfo: AdminRoleInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrgAdminsColumnDefn({
|
||||||
|
selectedOrgId,
|
||||||
|
localizedName,
|
||||||
|
onEdit,
|
||||||
|
onResend,
|
||||||
|
onToggleActive,
|
||||||
|
onRemove,
|
||||||
|
}: ColumnCallbacks): ColumnDef<OrgAdminUser>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
accessorFn: (row) => localizedName(row.name),
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-0 hover:bg-transparent"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
{t("orgAdmins.columns.name")}
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-800 dark:text-slate-100">
|
||||||
|
{localizedName(row.original.name) || "—"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{row.original.username}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "email",
|
||||||
|
accessorFn: (row) => row.email ?? "",
|
||||||
|
header: () => t("orgAdmins.columns.email"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm">{row.original.email || "—"}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "phoneNumber",
|
||||||
|
accessorFn: (row) => row.phoneNumber ?? "",
|
||||||
|
header: () => t("orgAdmins.columns.phone"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm">{row.original.phoneNumber || "—"}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "role",
|
||||||
|
header: () => t("orgAdmins.columns.role"),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const info = getAdminRoleInfo(row.original, selectedOrgId);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{info.isOrgAdmin && (
|
||||||
|
<Badge className="bg-indigo-100 text-indigo-700 hover:bg-indigo-100 dark:bg-indigo-950 dark:text-indigo-300">
|
||||||
|
{t("orgAdmins.roleOrgAdmin")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{info.isUnitAdmin && (
|
||||||
|
<Badge className="bg-sky-100 text-sky-700 hover:bg-sky-100 dark:bg-sky-950 dark:text-sky-300">
|
||||||
|
{t("orgAdmins.roleUnitAdmin")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
accessorFn: (row) =>
|
||||||
|
!row.hasSetPassword
|
||||||
|
? "invited"
|
||||||
|
: row.isActive
|
||||||
|
? "active"
|
||||||
|
: "inactive",
|
||||||
|
header: () => t("orgAdmins.columns.status"),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const admin = row.original;
|
||||||
|
if (!admin.hasSetPassword) {
|
||||||
|
return (
|
||||||
|
<Badge className="bg-amber-100 text-amber-700 hover:bg-amber-100 dark:bg-amber-950 dark:text-amber-300">
|
||||||
|
{t("orgAdmins.statusInvited")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return admin.isActive ? (
|
||||||
|
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 dark:bg-emerald-950 dark:text-emerald-300">
|
||||||
|
{t("orgAdmins.statusActive")}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300">
|
||||||
|
{t("orgAdmins.statusInactive")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "createdAt",
|
||||||
|
accessorFn: (row) => row.createdAt ?? "",
|
||||||
|
header: () => t("orgAdmins.columns.addedOn"),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.createdAt
|
||||||
|
? new Date(row.original.createdAt).toLocaleDateString()
|
||||||
|
: "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => t("orgAdmins.columns.actions"),
|
||||||
|
enableHiding: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const admin = row.original;
|
||||||
|
const roleInfo = getAdminRoleInfo(admin, selectedOrgId);
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(admin)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
{t("orgAdmins.actions.edit")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{!admin.hasSetPassword && (
|
||||||
|
<DropdownMenuItem onClick={() => onResend(admin)}>
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
{t("orgAdmins.actions.resend")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem onClick={() => onToggleActive(admin)}>
|
||||||
|
{admin.isActive ? (
|
||||||
|
<>
|
||||||
|
<UserX className="mr-2 h-4 w-4" />
|
||||||
|
{t("orgAdmins.actions.deactivate")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserCheck className="mr-2 h-4 w-4" />
|
||||||
|
{t("orgAdmins.actions.activate")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-red-600 focus:text-red-600"
|
||||||
|
onClick={() => onRemove(admin, roleInfo)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{t("orgAdmins.actions.remove")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Building2, Loader2, Plus, UserPlus, Users2 } from "lucide-react";
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/common/ui/card";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/common/ui/alert-dialog";
|
||||||
|
import { Badge } from "@/shared/common/ui/badge";
|
||||||
|
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||||
|
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||||
|
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
||||||
|
import {
|
||||||
|
OrgAdminUser,
|
||||||
|
useOrgAdmins,
|
||||||
|
} from "@/super-admin/hooks/useOrgAdmins";
|
||||||
|
import { OrgPicker } from "./OrgPicker";
|
||||||
|
import {
|
||||||
|
AdminRoleInfo,
|
||||||
|
getOrgAdminsColumnDefn,
|
||||||
|
} from "./OrgAdminsColumnDefn";
|
||||||
|
import AdminFormModal, { AdminFormValues } from "./AdminFormModal";
|
||||||
|
import AssignExistingAdminModal from "./AssignExistingAdminModal";
|
||||||
|
|
||||||
|
interface RemoveTarget {
|
||||||
|
admin: OrgAdminUser;
|
||||||
|
roleInfo: AdminRoleInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrgAdminsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const localizedName = useLocalizedName();
|
||||||
|
|
||||||
|
const [selectedOrg, setSelectedOrg] = useState<OrganizationDto | null>(null);
|
||||||
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
|
||||||
|
// modals & confirms
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [editAdmin, setEditAdmin] = useState<OrgAdminUser | null>(null);
|
||||||
|
const [assignOpen, setAssignOpen] = useState(false);
|
||||||
|
const [removeTarget, setRemoveTarget] = useState<RemoveTarget | null>(null);
|
||||||
|
const [toggleTarget, setToggleTarget] = useState<OrgAdminUser | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
adminsResponse,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
addAdmin,
|
||||||
|
isAdding,
|
||||||
|
assignAdmin,
|
||||||
|
isAssigning,
|
||||||
|
removeAdmin,
|
||||||
|
isRemoving,
|
||||||
|
resendInvite,
|
||||||
|
toggleActive,
|
||||||
|
isToggling,
|
||||||
|
updateAdminProfile,
|
||||||
|
isUpdatingProfile,
|
||||||
|
formError,
|
||||||
|
assignError,
|
||||||
|
removeError,
|
||||||
|
toggleError,
|
||||||
|
clearErrors,
|
||||||
|
} = useOrgAdmins(selectedOrg?.id, {
|
||||||
|
take: pageSize,
|
||||||
|
skip: pageIndex * pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPageIndex(0);
|
||||||
|
}, [selectedOrg?.id, pageSize]);
|
||||||
|
|
||||||
|
const admins = adminsResponse?.items ?? [];
|
||||||
|
const adminCount = adminsResponse?.count ?? 0;
|
||||||
|
const existingAdminIds = useMemo(
|
||||||
|
() => admins.map((admin) => admin.id),
|
||||||
|
[admins],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFormSubmit = (values: AdminFormValues) => {
|
||||||
|
if (!selectedOrg) return;
|
||||||
|
const person = {
|
||||||
|
name: values.name,
|
||||||
|
username: values.username,
|
||||||
|
email: values.email,
|
||||||
|
phoneNumber: values.phoneNumber,
|
||||||
|
};
|
||||||
|
if (editAdmin) {
|
||||||
|
updateAdminProfile(
|
||||||
|
{ id: editAdmin.id, payload: person },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setFormOpen(false);
|
||||||
|
setEditAdmin(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
addAdmin(
|
||||||
|
{
|
||||||
|
organizationId: values.organizationId,
|
||||||
|
unitId: values.unitId || undefined,
|
||||||
|
...person,
|
||||||
|
},
|
||||||
|
{ onSuccess: () => setFormOpen(false) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssign = (
|
||||||
|
userId: string,
|
||||||
|
organizationId: string,
|
||||||
|
unitId?: string,
|
||||||
|
) => {
|
||||||
|
assignAdmin(
|
||||||
|
{ organizationId, userId, unitId },
|
||||||
|
{ onSuccess: () => setAssignOpen(false) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveConfirm = () => {
|
||||||
|
if (!removeTarget || !selectedOrg) return;
|
||||||
|
const { admin, roleInfo } = removeTarget;
|
||||||
|
removeAdmin(
|
||||||
|
// unit-admin-only rows go through the unit endpoint; everything else
|
||||||
|
// defaults to org removal so a role anomaly never sends unitId: undefined
|
||||||
|
!roleInfo.isOrgAdmin && roleInfo.unitId
|
||||||
|
? { userId: admin.id, unitId: roleInfo.unitId }
|
||||||
|
: { userId: admin.id, organizationId: selectedOrg.id },
|
||||||
|
{ onSuccess: () => setRemoveTarget(null) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResend = (admin: OrgAdminUser) => {
|
||||||
|
if (!admin.email || !admin.phoneNumber) {
|
||||||
|
toast.error(t("orgAdmins.toasts.missingContact"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toastId = toast.loading(t("orgAdmins.toasts.resending"));
|
||||||
|
resendInvite(
|
||||||
|
{ email: admin.email, phoneNumber: admin.phoneNumber },
|
||||||
|
{ onSettled: () => toast.dismiss(toastId) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleConfirm = () => {
|
||||||
|
if (!toggleTarget) return;
|
||||||
|
toggleActive(
|
||||||
|
{ id: toggleTarget.id, activate: !toggleTarget.isActive },
|
||||||
|
{ onSuccess: () => setToggleTarget(null) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() =>
|
||||||
|
getOrgAdminsColumnDefn({
|
||||||
|
selectedOrgId: selectedOrg?.id ?? "",
|
||||||
|
localizedName: localizedName as (name?: {
|
||||||
|
am?: string;
|
||||||
|
en?: string;
|
||||||
|
}) => string,
|
||||||
|
onEdit: (admin) => {
|
||||||
|
setEditAdmin(admin);
|
||||||
|
setFormOpen(true);
|
||||||
|
},
|
||||||
|
onResend: handleResend,
|
||||||
|
onToggleActive: setToggleTarget,
|
||||||
|
onRemove: (admin, roleInfo) => setRemoveTarget({ admin, roleInfo }),
|
||||||
|
}),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[selectedOrg?.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<Card className="shadow-none border-none bg-transparent px-0">
|
||||||
|
<CardHeader className="px-0 space-y-1">
|
||||||
|
<CardTitle className="text-2xl font-bold text-slate-800 dark:text-slate-100">
|
||||||
|
{t("orgAdmins.title")}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("orgAdmins.subtitle")}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-0 space-y-4">
|
||||||
|
{/* Org selector + summary */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<OrgPicker value={selectedOrg} onChange={setSelectedOrg} />
|
||||||
|
{selectedOrg && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1 border-indigo-300 bg-indigo-50 text-indigo-700 dark:border-indigo-800 dark:bg-indigo-950 dark:text-indigo-300"
|
||||||
|
>
|
||||||
|
<Users2 className="h-3.5 w-3.5" />
|
||||||
|
{t("orgAdmins.adminsCount", { count: adminCount })}
|
||||||
|
</Badge>
|
||||||
|
{selectedOrg.activeEmployeeCount !== undefined && (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
{t("orgAdmins.activeEmployees", {
|
||||||
|
count: selectedOrg.activeEmployeeCount,
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
selectedOrg.status === "Active"
|
||||||
|
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
|
||||||
|
: "border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedOrg.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selectedOrg ? (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-gray-300 dark:border-gray-700">
|
||||||
|
<Building2 className="h-10 w-10 text-muted-foreground" />
|
||||||
|
<p className="font-medium text-slate-700 dark:text-slate-200">
|
||||||
|
{t("orgAdmins.selectOrgPrompt")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("orgAdmins.selectOrgPromptHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isError && (
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-md border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
|
||||||
|
{t("orgAdmins.loadError")}
|
||||||
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||||
|
{t("common.retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && !isError && adminCount === 0 && (
|
||||||
|
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300">
|
||||||
|
{t("orgAdmins.noAdminsHint", {
|
||||||
|
name: localizedName(selectedOrg.name),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AdvancedTable
|
||||||
|
columns={columns}
|
||||||
|
data={admins}
|
||||||
|
tableName={t("orgAdmins.tableName")}
|
||||||
|
toolBarPosition="right"
|
||||||
|
itemCount={adminCount}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageChange={setPageIndex}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
|
nextFunction={() => setPageIndex(pageIndex + 1)}
|
||||||
|
prevFunction={() => setPageIndex(Math.max(pageIndex - 1, 0))}
|
||||||
|
refresh={refetch}
|
||||||
|
isLoading={isLoading}
|
||||||
|
extraToolbar={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setAssignOpen(true)}
|
||||||
|
>
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
{t("orgAdmins.assignExisting")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setEditAdmin(null);
|
||||||
|
setFormOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{t("orgAdmins.addAdmin")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Add / Edit modal */}
|
||||||
|
<AdminFormModal
|
||||||
|
isOpen={formOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setFormOpen(false);
|
||||||
|
setEditAdmin(null);
|
||||||
|
clearErrors();
|
||||||
|
}}
|
||||||
|
admin={editAdmin}
|
||||||
|
organizationId={selectedOrg?.id}
|
||||||
|
apiError={formError}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
isSubmitting={isAdding || isUpdatingProfile}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Assign existing employee modal */}
|
||||||
|
{selectedOrg && (
|
||||||
|
<AssignExistingAdminModal
|
||||||
|
isOpen={assignOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setAssignOpen(false);
|
||||||
|
clearErrors();
|
||||||
|
}}
|
||||||
|
organizationId={selectedOrg.id}
|
||||||
|
existingAdminIds={existingAdminIds}
|
||||||
|
apiError={assignError}
|
||||||
|
onAssign={handleAssign}
|
||||||
|
isAssigning={isAssigning}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Remove admin confirm */}
|
||||||
|
<AlertDialog
|
||||||
|
open={!!removeTarget}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setRemoveTarget(null);
|
||||||
|
clearErrors();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-lg font-bold text-red-600">
|
||||||
|
{t("orgAdmins.confirmRemove.title")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("orgAdmins.confirmRemove.description", {
|
||||||
|
name:
|
||||||
|
localizedName(removeTarget?.admin.name) ||
|
||||||
|
removeTarget?.admin.email,
|
||||||
|
org: localizedName(selectedOrg?.name),
|
||||||
|
})}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
{removeError && (
|
||||||
|
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
|
||||||
|
{removeError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isRemoving}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={isRemoving}
|
||||||
|
onClick={handleRemoveConfirm}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
>
|
||||||
|
{isRemoving
|
||||||
|
? t("orgAdmins.confirmRemove.removing")
|
||||||
|
: t("orgAdmins.actions.remove")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Activate / Deactivate confirm */}
|
||||||
|
<AlertDialog
|
||||||
|
open={!!toggleTarget}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setToggleTarget(null);
|
||||||
|
clearErrors();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-lg font-bold">
|
||||||
|
{toggleTarget?.isActive
|
||||||
|
? t("orgAdmins.confirmToggle.deactivateTitle")
|
||||||
|
: t("orgAdmins.confirmToggle.activateTitle")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("orgAdmins.confirmToggle.description", {
|
||||||
|
name:
|
||||||
|
localizedName(toggleTarget?.name) || toggleTarget?.email,
|
||||||
|
})}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
{toggleError && (
|
||||||
|
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
|
||||||
|
{toggleError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isToggling}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={isToggling}
|
||||||
|
onClick={handleToggleConfirm}
|
||||||
|
className={
|
||||||
|
toggleTarget?.isActive
|
||||||
|
? "bg-amber-600 hover:bg-amber-700 text-white"
|
||||||
|
: "bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isToggling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{toggleTarget?.isActive
|
||||||
|
? t("orgAdmins.actions.deactivate")
|
||||||
|
: t("orgAdmins.actions.activate")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { Building2, Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import { Badge } from "@/shared/common/ui/badge";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/shared/common/ui/popover";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/shared/common/ui/command";
|
||||||
|
import { cn } from "@/super-admin/lib/utils";
|
||||||
|
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||||
|
import { getOrganizationsWithAdminFlag } from "@/shared/services/organizationsService";
|
||||||
|
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
||||||
|
import { ORG_PICKER_KEY } from "@/super-admin/hooks/useOrgAdmins";
|
||||||
|
|
||||||
|
interface OrgPickerProps {
|
||||||
|
value: OrganizationDto | null;
|
||||||
|
onChange: (org: OrganizationDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Searchable organization combobox — server-side name search, shows admin counts. */
|
||||||
|
export function OrgPicker({ value, onChange }: OrgPickerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const localizedName = useLocalizedName();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: orgsResponse,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [ORG_PICKER_KEY, debouncedSearch],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getOrganizationsWithAdminFlag({
|
||||||
|
take: 50,
|
||||||
|
skip: 0,
|
||||||
|
name: debouncedSearch || undefined,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
count: (data?.count ?? 0) as number,
|
||||||
|
items: (data?.items ?? []) as OrganizationDto[],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const orgs = orgsResponse?.items ?? [];
|
||||||
|
|
||||||
|
// default to the first org that already has admins (initial load only,
|
||||||
|
// never while the user is searching)
|
||||||
|
useEffect(() => {
|
||||||
|
if (value || debouncedSearch || !orgs.length) return;
|
||||||
|
const firstWithAdmins = orgs.find((org) => (org.adminsCount ?? 0) > 0);
|
||||||
|
if (firstWithAdmins) onChange(firstWithAdmins);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [orgsResponse]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-full sm:w-[360px] justify-between font-normal"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 truncate">
|
||||||
|
<Building2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
{value ? (
|
||||||
|
<span className="truncate">{localizedName(value.name)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("orgAdmins.selectOrg")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[360px] p-0" align="start">
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t("orgAdmins.searchOrgs")}
|
||||||
|
value={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-6 text-sm text-red-600 dark:text-red-400">
|
||||||
|
{t("orgAdmins.pickerError")}
|
||||||
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||||
|
{t("common.retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CommandEmpty>{t("orgAdmins.noOrgsFound")}</CommandEmpty>
|
||||||
|
{orgs.map((org) => (
|
||||||
|
<CommandItem
|
||||||
|
key={org.id}
|
||||||
|
value={org.id}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(org);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-between gap-2"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 truncate">
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 shrink-0",
|
||||||
|
value?.id === org.id ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">
|
||||||
|
{localizedName(org.name)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{(org.adminsCount ?? 0) > 0 ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0 border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
|
||||||
|
>
|
||||||
|
{t("orgAdmins.adminsCount", {
|
||||||
|
count: org.adminsCount ?? 0,
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0 border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
{t("orgAdmins.noAdmins")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
import { FormEvent, useMemo, useState } from "react";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { Check, Loader2 } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { Button } from "@/shared/common/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/shared/common/ui/dialog";
|
|
||||||
import { Input } from "@/shared/common/ui/input";
|
|
||||||
import { Label } from "@/shared/common/ui/label";
|
|
||||||
import { ScrollArea } from "@/shared/common/ui/scroll-area";
|
|
||||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
|
||||||
import { cn } from "@/super-admin/lib/utils";
|
|
||||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
|
||||||
import {
|
|
||||||
assignOrgAdminRole,
|
|
||||||
RemoveOrAssignOrgAdminPayload,
|
|
||||||
} from "@/super-admin/services/api/userRoleService";
|
|
||||||
import { useEmployees } from "@/user-management/hooks/useEmployees";
|
|
||||||
|
|
||||||
interface AssignOrgAdminDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AssignOrgAdminDialog({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onSuccess,
|
|
||||||
}: AssignOrgAdminDialogProps) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const localizedName = useLocalizedName();
|
|
||||||
const [selectedOrg, setSelectedOrg] = useState("");
|
|
||||||
const [selectedUser, setSelectedUser] = useState("");
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [isAssigning, setIsAssigning] = useState(false);
|
|
||||||
|
|
||||||
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
|
|
||||||
"Org",
|
|
||||||
{ take: 300 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
employeesResponseByOrg,
|
|
||||||
isLoadingEmployeesByOrg: isLoadingEmployees,
|
|
||||||
} = useEmployees({
|
|
||||||
organizationId: selectedOrg || undefined,
|
|
||||||
params: { take: 3000, skip: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredEmployees = useMemo(() => {
|
|
||||||
const query = search.trim().toLowerCase();
|
|
||||||
const employees = employeesResponseByOrg?.items ?? [];
|
|
||||||
|
|
||||||
if (!query) return employees;
|
|
||||||
|
|
||||||
return employees.filter((employee) => {
|
|
||||||
const name = localizedName(employee.user.name).toLowerCase();
|
|
||||||
const email = employee.user.email?.toLowerCase() ?? "";
|
|
||||||
|
|
||||||
return name.includes(query) || email.includes(query);
|
|
||||||
});
|
|
||||||
}, [employeesResponseByOrg, localizedName, search]);
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setSelectedOrg("");
|
|
||||||
setSelectedUser("");
|
|
||||||
setSearch("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (isAssigning) return;
|
|
||||||
resetForm();
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOrganizationChange = (organizationId: string) => {
|
|
||||||
setSelectedOrg(organizationId);
|
|
||||||
setSelectedUser("");
|
|
||||||
setSearch("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (!selectedOrg || !selectedUser) {
|
|
||||||
toast.error(t("organization.allFieldsRequired", "All fields are required"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: RemoveOrAssignOrgAdminPayload = {
|
|
||||||
organizationId: selectedOrg,
|
|
||||||
userId: selectedUser,
|
|
||||||
};
|
|
||||||
|
|
||||||
setIsAssigning(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await assignOrgAdminRole(payload);
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: ["organizationAdmins"],
|
|
||||||
});
|
|
||||||
resetForm();
|
|
||||||
onClose();
|
|
||||||
onSuccess();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(t("organization.userAssignFailed"), {
|
|
||||||
description: error?.response?.data?.message,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsAssigning(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) handleClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="sm:max-w-[750px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{t(
|
|
||||||
"organization.assignAdminToOrganization",
|
|
||||||
"Assign admin to organization"
|
|
||||||
)}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{t(
|
|
||||||
"organization.assignOrgAdminInstructions",
|
|
||||||
"Select an organization and a user to assign as its administrator."
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-semibold">
|
|
||||||
{t("organization.organizations")}{" "}
|
|
||||||
<span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
{isLoadingOrgs ? (
|
|
||||||
<div className="flex h-[350px] items-center justify-center">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ScrollArea className="h-[350px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{organizationsResponse?.items?.map((organization) => (
|
|
||||||
<button
|
|
||||||
key={organization.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
handleOrganizationChange(organization.id)
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
|
|
||||||
selectedOrg === organization.id
|
|
||||||
? "bg-green-200 text-green-800 dark:bg-green-900/40 dark:text-green-300"
|
|
||||||
: "text-gray-700 hover:bg-green-100 dark:text-gray-300 dark:hover:bg-green-900/30"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span>{localizedName(organization.name)}</span>
|
|
||||||
{selectedOrg === organization.id && (
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-semibold">
|
|
||||||
{t("organization.users")}{" "}
|
|
||||||
<span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
{!selectedOrg ? (
|
|
||||||
<div className="flex h-[350px] items-center justify-center rounded-md border border-gray-200 p-4 text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
|
||||||
<p className="text-center text-sm">
|
|
||||||
{t("organization.selectOrganizationFirst")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : isLoadingEmployees ? (
|
|
||||||
<div className="flex h-[350px] items-center justify-center">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Input
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
|
||||||
placeholder={t(
|
|
||||||
"organization.searchOrganizationUsers",
|
|
||||||
"Search organization users"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<ScrollArea className="h-[308px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{filteredEmployees.map((employee) => (
|
|
||||||
<button
|
|
||||||
key={employee.user.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedUser(employee.user.id)}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
|
|
||||||
selectedUser === employee.user.id
|
|
||||||
? "bg-purple-200 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300"
|
|
||||||
: "text-gray-700 hover:bg-purple-100 dark:text-gray-300 dark:hover:bg-purple-900/30"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate font-medium">
|
|
||||||
{localizedName(employee.user.name) ||
|
|
||||||
employee.user.email}
|
|
||||||
</p>
|
|
||||||
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{employee.user.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{selectedUser === employee.user.id && (
|
|
||||||
<Check className="ml-2 h-4 w-4 shrink-0" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{filteredEmployees.length === 0 && (
|
|
||||||
<p className="mt-2 text-center text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{t("organization.noUsersFound")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={isAssigning}
|
|
||||||
>
|
|
||||||
{t("common.Cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isAssigning || !selectedOrg || !selectedUser}
|
|
||||||
className="bg-green-600 hover:bg-green-700"
|
|
||||||
>
|
|
||||||
{isAssigning && (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
)}
|
|
||||||
{isAssigning
|
|
||||||
? t("organization.assigning")
|
|
||||||
: t("organization.assignUser")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/shared/common/ui/button";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { Plus, Loader2, UserPlus } from "lucide-react";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "../../../shared/common/ui/card";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { AdvancedTable } from "../../../shared/common/ui/table/AdvancedTable";
|
|
||||||
import { OrganizationAdminsColumnDefn } from "./OrganizationAdminsColumnDefn";
|
|
||||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
|
||||||
import { AssignOrgAdminDialog } from "./AssignOrgAdminDialog";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
|
||||||
|
|
||||||
export default function OrganizationAdmins() {
|
|
||||||
const [pageIndex, setPageIndex] = useState(0); // starts at 0
|
|
||||||
const pageSize = 10;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const localizedName = useLocalizedName();
|
|
||||||
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
const { organizationsAdminsResponse, isLoading, isError, refetch } =
|
|
||||||
useOrganizations("Admin", {
|
|
||||||
take: pageSize,
|
|
||||||
skip: pageIndex * pageSize,
|
|
||||||
orderBy: "createdAt",
|
|
||||||
order: "createdAt:Desc",
|
|
||||||
name: searchTerm || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
|
||||||
setPageIndex(newPage);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchChange = (term: string) => {
|
|
||||||
setSearchTerm(term);
|
|
||||||
setPageIndex(0); // Reset to first page when search term changes
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
|
||||||
refetch();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="p-6 flex items-center justify-center h-64">
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{t("organization.loadingAdmins")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<div className="p-6 flex items-center justify-center h-64">
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<div className="text-red-500 font-medium">
|
|
||||||
{t("organization.errorLoadingAdmins")}
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={() => refetch()}>
|
|
||||||
{t("organization.retry")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="p-6 space-y-6 dark:bg-gray-900">
|
|
||||||
<Card className="col-span-2 shadow-none border-none bg-transparent dark:bg-transparent px-0">
|
|
||||||
<CardHeader className="flex flex-row justify-between items-center px-0">
|
|
||||||
<CardTitle className="text-xl font-semibold dark:text-white">
|
|
||||||
{t("organization.organizationAdmins")}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-0 dark:bg-transparent">
|
|
||||||
<AdvancedTable
|
|
||||||
columns={OrganizationAdminsColumnDefn(
|
|
||||||
localizedName as (name?: { am?: string; en?: string }) => string
|
|
||||||
)}
|
|
||||||
data={organizationsAdminsResponse?.items || []}
|
|
||||||
tableName="Organization Admins"
|
|
||||||
toolBarPosition="right"
|
|
||||||
refresh={handleRefresh}
|
|
||||||
onGlobalFilterChange={handleSearchChange}
|
|
||||||
disableClientFiltering={true}
|
|
||||||
extraToolbar={
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => setIsAssignDialogOpen(true)}
|
|
||||||
className="bg-[#4A6CF7] hover:bg-[#3a5ad4] text-white px-5 py-2 rounded-md text-sm font-medium shadow-md"
|
|
||||||
>
|
|
||||||
<UserPlus className="w-4 h-4 mr-2" />
|
|
||||||
{t("organization.assignAdmin")}
|
|
||||||
</Button>
|
|
||||||
<Link to="/user-management/add_admin">
|
|
||||||
<Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
{t("organization.addAdmin")}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
itemCount={organizationsAdminsResponse?.count || 0}
|
|
||||||
pageIndex={pageIndex}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
nextFunction={() => handlePageChange(pageIndex + 1)}
|
|
||||||
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Assign Admin Dialog */}
|
|
||||||
<AssignOrgAdminDialog
|
|
||||||
isOpen={isAssignDialogOpen}
|
|
||||||
onClose={() => setIsAssignDialogOpen(false)}
|
|
||||||
onSuccess={() => {
|
|
||||||
refetch();
|
|
||||||
toast.success(t("organization.adminAssignedSuccess"));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "../../../shared/common/ui/dropdown-menu";
|
|
||||||
import { Edit, MoreHorizontal, Trash, UserCheck, UserX } from "lucide-react";
|
|
||||||
import { Button } from "../../../shared/common/ui/button";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { OrganizationAdmin } from "@/super-admin/services/api/organizationAdminService";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface OrganizationAdminsActionsProps {
|
|
||||||
rowData: OrganizationAdmin;
|
|
||||||
}
|
|
||||||
const OrganizationAdminsActions: React.FC<OrganizationAdminsActionsProps> = ({
|
|
||||||
rowData,
|
|
||||||
}) => {
|
|
||||||
const handleDeleteClick = () => {
|
|
||||||
toast.warning("Delete functionality not implemented yet");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStatusChange = (status: string) => {
|
|
||||||
toast.info(`Admin status change to ${status} not implemented yet`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
||||||
<span className="sr-only">Open menu</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link
|
|
||||||
to={`/add_admin?id=${rowData.id}`}
|
|
||||||
className="flex items-center w-full"
|
|
||||||
>
|
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{rowData.status !== "active" && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleStatusChange("active")}
|
|
||||||
className="flex items-center"
|
|
||||||
>
|
|
||||||
<UserCheck className="mr-2 h-4 w-4" />
|
|
||||||
Activate
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{rowData.status === "active" && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleStatusChange("inactive")}
|
|
||||||
className="flex items-center"
|
|
||||||
>
|
|
||||||
<UserX className="mr-2 h-4 w-4" />
|
|
||||||
Deactivate
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleDeleteClick()}
|
|
||||||
className="flex items-center"
|
|
||||||
>
|
|
||||||
<Trash className="mr-2 h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OrganizationAdminsActions;
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { ColumnDef } from "@tanstack/react-table";
|
|
||||||
import { Button } from "../../../shared/common/ui/button";
|
|
||||||
import { ArrowUpDown, Eye } from "lucide-react";
|
|
||||||
import { OrganizationAdminsDto } from "@/shared/dto/organization/organizationDto";
|
|
||||||
import { StatusCell } from "./StatusCell";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import OrganizationAdminsActions from "./OrganizationAdminsActions";
|
|
||||||
|
|
||||||
export const OrganizationAdminsColumnDefn = (
|
|
||||||
localizedName: (name?: { am?: string; en?: string }) => string,
|
|
||||||
): ColumnDef<OrganizationAdminsDto>[] => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
accessorKey: "name",
|
|
||||||
header: ({ column }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
className="p-0 hover:bg-transparent text-gray-700 dark:text-gray-300"
|
|
||||||
style={{
|
|
||||||
padding: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("organization.name")}
|
|
||||||
<ArrowUpDown className="p-0 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="font-medium">{localizedName(row.original.name)}</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
header: ({ column }) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
className="p-0 hover:bg-transparent text-gray-700 dark:text-gray-300"
|
|
||||||
style={{
|
|
||||||
padding: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("dashboard.Status")}
|
|
||||||
<ArrowUpDown className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const id = row.original.id;
|
|
||||||
return (
|
|
||||||
<StatusCell
|
|
||||||
id={id}
|
|
||||||
// isAssigned={row.original.isAssigned}
|
|
||||||
adminsCount={row.original.adminsCount}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "actions",
|
|
||||||
header: t("organization.actions") || "Actions",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Link to={`/super-admin/organizations/${row.original.id}`}>
|
|
||||||
<Button variant="ghost" size="sm">
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<OrganizationAdminsActions rowData={row.original as any} />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||||
|
import {
|
||||||
|
assignOrganizationAdmin,
|
||||||
|
assignUnitAdmin,
|
||||||
|
fetchAllUnitAdminById,
|
||||||
|
} from "@/super-admin/services/api/organizationAdminService";
|
||||||
|
import {
|
||||||
|
assignOrgAdminRole,
|
||||||
|
assignUnitAdminRole,
|
||||||
|
removeOrgAdminRole,
|
||||||
|
removeUnitAdminRole,
|
||||||
|
} from "@/super-admin/services/api/userRoleService";
|
||||||
|
import {
|
||||||
|
activateUser,
|
||||||
|
deactivateUser,
|
||||||
|
} from "@/super-admin/services/api/userService";
|
||||||
|
import { resendVerificationCode } from "@/shared/services/authService";
|
||||||
|
import {
|
||||||
|
updateProfile,
|
||||||
|
UpdateProfilePayload,
|
||||||
|
} from "@/user-management/services/api/employeePositionsService";
|
||||||
|
|
||||||
|
export interface OrgAdminUserRole {
|
||||||
|
id: string;
|
||||||
|
organizationId?: string | null;
|
||||||
|
unitId?: string | null;
|
||||||
|
role?: { key?: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User row returned by GET /organizations/all-admins/:id (userRoles.role relation included). */
|
||||||
|
export interface OrgAdminUser {
|
||||||
|
id: string;
|
||||||
|
name?: { am?: string; en?: string };
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
hasSetPassword: boolean;
|
||||||
|
status?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
userRoles?: OrgAdminUserRole[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ORG_ADMINS_KEY = "orgAdmins";
|
||||||
|
export const ORG_PICKER_KEY = "orgAdminsOrgPicker";
|
||||||
|
|
||||||
|
export interface RemoveAdminInput {
|
||||||
|
userId: string;
|
||||||
|
/** set for org-admin removal */
|
||||||
|
organizationId?: string;
|
||||||
|
/** set for unit-admin removal (wins over organizationId) */
|
||||||
|
unitId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddAdminInput {
|
||||||
|
organizationId: string;
|
||||||
|
/** set → create as unit admin of this unit instead of org admin */
|
||||||
|
unitId?: string;
|
||||||
|
name: { am: string; en: string };
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOrgAdmins = (
|
||||||
|
orgId?: string,
|
||||||
|
params?: { take?: number; skip?: number },
|
||||||
|
) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { handleError, getErrorMessage } = useErrorHandler(t);
|
||||||
|
|
||||||
|
// dialog mutations surface their API errors inline, not via toast
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [assignError, setAssignError] = useState<string | null>(null);
|
||||||
|
const [removeError, setRemoveError] = useState<string | null>(null);
|
||||||
|
const [toggleError, setToggleError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const inlineError =
|
||||||
|
(set: (message: string | null) => void) => async (err: unknown) => {
|
||||||
|
console.error(err);
|
||||||
|
set(await getErrorMessage(err));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearErrors = () => {
|
||||||
|
setFormError(null);
|
||||||
|
setAssignError(null);
|
||||||
|
setRemoveError(null);
|
||||||
|
setToggleError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: adminsResponse,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [ORG_ADMINS_KEY, orgId, params],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await fetchAllUnitAdminById(orgId as string, params);
|
||||||
|
return {
|
||||||
|
count: (data?.count ?? 0) as number,
|
||||||
|
items: (data?.items ?? []) as OrgAdminUser[],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: !!orgId,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [ORG_ADMINS_KEY] });
|
||||||
|
// picker + org tables show adminsCount — keep them fresh
|
||||||
|
queryClient.invalidateQueries({ queryKey: [ORG_PICKER_KEY] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["organizations"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutate: addAdmin, isPending: isAdding } = useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
organizationId,
|
||||||
|
unitId,
|
||||||
|
...person
|
||||||
|
}: AddAdminInput) => {
|
||||||
|
// both iam invite endpoints create the user (employee + role +
|
||||||
|
// SET_PASSWORD OTP in one tx); unitId decides the admin scope
|
||||||
|
const { data } = unitId
|
||||||
|
? await assignUnitAdmin({ unitId, ...person })
|
||||||
|
: await assignOrganizationAdmin({ organizationId, ...person });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onMutate: () => setFormError(null),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("orgAdmins.toasts.added"));
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: inlineError(setFormError),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: assignAdmin, isPending: isAssigning } = useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
unitId,
|
||||||
|
}: {
|
||||||
|
organizationId: string;
|
||||||
|
userId: string;
|
||||||
|
/** set → grant unit-admin of this unit instead of org-admin */
|
||||||
|
unitId?: string;
|
||||||
|
}) => {
|
||||||
|
const { data } = unitId
|
||||||
|
? await assignUnitAdminRole({ unitId, userId })
|
||||||
|
: await assignOrgAdminRole({ organizationId, userId });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onMutate: () => setAssignError(null),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("orgAdmins.toasts.assigned"));
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: inlineError(setAssignError),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: removeAdmin, isPending: isRemoving } = useMutation({
|
||||||
|
mutationFn: async ({ userId, organizationId, unitId }: RemoveAdminInput) => {
|
||||||
|
const { data } = unitId
|
||||||
|
? await removeUnitAdminRole({ unitId, userId })
|
||||||
|
: await removeOrgAdminRole({
|
||||||
|
organizationId: organizationId as string,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onMutate: () => setRemoveError(null),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("orgAdmins.toasts.removed"));
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: inlineError(setRemoveError),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: resendInvite, isPending: isResending } = useMutation({
|
||||||
|
mutationFn: async (payload: { email: string; phoneNumber: string }) => {
|
||||||
|
const { data } = await resendVerificationCode(payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("orgAdmins.toasts.resent"));
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: toggleActive, isPending: isToggling } = useMutation({
|
||||||
|
mutationFn: async ({ id, activate }: { id: string; activate: boolean }) => {
|
||||||
|
const { data } = activate
|
||||||
|
? await activateUser(id)
|
||||||
|
: await deactivateUser(id);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onMutate: () => setToggleError(null),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
toast.success(
|
||||||
|
variables.activate
|
||||||
|
? t("orgAdmins.toasts.activated")
|
||||||
|
: t("orgAdmins.toasts.deactivated"),
|
||||||
|
);
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: inlineError(setToggleError),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: updateAdminProfile, isPending: isUpdatingProfile } =
|
||||||
|
useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
id,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
payload: UpdateProfilePayload;
|
||||||
|
}) => {
|
||||||
|
const { data } = await updateProfile(payload, id);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onMutate: () => setFormError(null),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("orgAdmins.toasts.profileUpdated"));
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: inlineError(setFormError),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
adminsResponse,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
addAdmin,
|
||||||
|
isAdding,
|
||||||
|
assignAdmin,
|
||||||
|
isAssigning,
|
||||||
|
removeAdmin,
|
||||||
|
isRemoving,
|
||||||
|
resendInvite,
|
||||||
|
isResending,
|
||||||
|
toggleActive,
|
||||||
|
isToggling,
|
||||||
|
updateAdminProfile,
|
||||||
|
isUpdatingProfile,
|
||||||
|
formError,
|
||||||
|
assignError,
|
||||||
|
removeError,
|
||||||
|
toggleError,
|
||||||
|
clearErrors,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -28,7 +28,6 @@ export const BOOKING_STATUSES = [
|
|||||||
"CONTRACT_ACTIVE",
|
"CONTRACT_ACTIVE",
|
||||||
"CONTRACT_CLOSED",
|
"CONTRACT_CLOSED",
|
||||||
// Post counter-sign document-clearance gate.
|
// Post counter-sign document-clearance gate.
|
||||||
"AWAITING_CLEARANCE_PAYMENT",
|
|
||||||
"AWAITING_DOCUMENTS",
|
"AWAITING_DOCUMENTS",
|
||||||
"DOCUMENTS_UNDER_REVIEW",
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
"CLEARANCE_READY",
|
"CLEARANCE_READY",
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { Button } from "@/shared/common/ui/button";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogAction,
|
|
||||||
} from "@/shared/common/ui/alert-dialog";
|
|
||||||
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
|
|
||||||
import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
|
|
||||||
import { t } from "i18next";
|
|
||||||
|
|
||||||
type ActionsColumnProps = {
|
|
||||||
row: PositionTypeDto;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ActionsColumn: React.FC<ActionsColumnProps> = ({ row }) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [openDialog, setOpenDialog] = useState(false);
|
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { deletePositionType } = usePositionTypes({ id: "" });
|
|
||||||
|
|
||||||
const handleDeleteClick = (id: string) => {
|
|
||||||
setDeletingId(id);
|
|
||||||
setOpenDialog(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteConfirm = async () => {
|
|
||||||
if (!deletingId) return;
|
|
||||||
await deletePositionType.mutateAsync(deletingId);
|
|
||||||
setOpenDialog(false);
|
|
||||||
setDeletingId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(`/user-management/position-management/edit/${row.id}`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("common.Edit")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<AlertDialog open={openDialog} onOpenChange={setOpenDialog}>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => handleDeleteClick(row.id)}
|
|
||||||
>
|
|
||||||
{t("userRecord.Delete")}
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{t("contentManagement.delMsg")}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{t("contentManagement.delMsg2")}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel
|
|
||||||
onClick={() => {
|
|
||||||
setOpenDialog(false);
|
|
||||||
setDeletingId(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("common.Cancel")}
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={handleDeleteConfirm}
|
|
||||||
disabled={deletePositionType.isPending}
|
|
||||||
>
|
|
||||||
{deletePositionType.isPending
|
|
||||||
? t("organization.deleting")
|
|
||||||
: t("organization.delete")}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ActionsColumn;
|
|
||||||
@@ -12,10 +12,13 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/shared/common/ui/form";
|
} from "@/shared/common/ui/form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
|
import {
|
||||||
|
invalidatePositionTypeQueries,
|
||||||
|
usePositionTypes,
|
||||||
|
} from "@/user-management/hooks/usePositionTypes";
|
||||||
import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService";
|
import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { t } from "i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "@/shared/context/AuthContext";
|
import { useAuth } from "@/shared/context/AuthContext";
|
||||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -32,19 +35,11 @@ import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
|
|||||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||||
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
||||||
import i18n from "@/i18n";
|
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||||
import { PermissionSearch } from "./PermissionSearch";
|
import { PermissionSearch } from "./PermissionSearch";
|
||||||
import { useApplications } from "@/user-management/hooks/useApplications";
|
import { useApplications } from "@/user-management/hooks/useApplications";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
nameAm: z.string().min(2),
|
|
||||||
nameEn: z.string().min(2),
|
|
||||||
permissions: z.array(z.string()),
|
|
||||||
});
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
|
||||||
|
|
||||||
export interface CreatePositionFormProps {
|
export interface CreatePositionFormProps {
|
||||||
mode?: "create" | "edit";
|
mode?: "create" | "edit";
|
||||||
positionTypeId?: string;
|
positionTypeId?: string;
|
||||||
@@ -66,11 +61,14 @@ export const CreatePositionForm = ({
|
|||||||
onCancel,
|
onCancel,
|
||||||
}: CreatePositionFormProps = {}) => {
|
}: CreatePositionFormProps = {}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { handleError } = useErrorHandler(t);
|
||||||
const {
|
const {
|
||||||
createPositionType,
|
createPositionType,
|
||||||
updatePositionType,
|
updatePositionType,
|
||||||
positionTypes,
|
positionTypes,
|
||||||
isLoading: isLoadingPositionTypes,
|
isLoading: isLoadingPositionTypes,
|
||||||
|
isError: isErrorPositionTypes,
|
||||||
} = usePositionTypes();
|
} = usePositionTypes();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { getList, getById } = useUnit();
|
const { getList, getById } = useUnit();
|
||||||
@@ -79,22 +77,45 @@ export const CreatePositionForm = ({
|
|||||||
user?.employee && user.employee.length > 0
|
user?.employee && user.employee.length > 0
|
||||||
? user.employee[0].organizationId
|
? user.employee[0].organizationId
|
||||||
: undefined;
|
: undefined;
|
||||||
const [selectedOrganizationId, setSelectedOrganizationId] = useState<string>(
|
|
||||||
userOrganizationId ?? "",
|
|
||||||
);
|
|
||||||
const [selectedUnitId, setSelectedUnitId] = useState<string>(
|
|
||||||
initialValues?.unitId ?? "",
|
|
||||||
);
|
|
||||||
const [selectedApplicationId, setSelectedApplicationId] =
|
const [selectedApplicationId, setSelectedApplicationId] =
|
||||||
useState<string>("");
|
useState<string>("");
|
||||||
const [copyFromPositionId, setCopyFromPositionId] = useState<string>("");
|
const [copyFromPositionId, setCopyFromPositionId] = useState<string>("");
|
||||||
const [isCopying, setIsCopying] = useState(false);
|
const [isCopying, setIsCopying] = useState(false);
|
||||||
const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit");
|
const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit");
|
||||||
const hasLoadedEditData = useRef(false);
|
const hasLoadedEditData = useRef(false);
|
||||||
const lang = i18n.language;
|
// Permissions the position type had when the form opened. Needed because the
|
||||||
|
// API cannot represent "no permissions" (see onSubmit).
|
||||||
|
const loadedPermissionCount = useRef(0);
|
||||||
const { applications, isLoading: isLoadingApplications } = useApplications();
|
const { applications, isLoading: isLoadingApplications } = useApplications();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const formSchema = useMemo(
|
||||||
|
() =>
|
||||||
|
z.object({
|
||||||
|
nameEn: z.string().trim().min(2, t("organization.englishNameRequired")),
|
||||||
|
nameAm: z.string().trim().min(2, t("organization.amharicNameRequired")),
|
||||||
|
organizationId: z.string().min(1, t("organization.organizationRequired")),
|
||||||
|
unitId: z.string().min(1, t("contentManagement.unitRequired")),
|
||||||
|
permissions: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
nameAm: initialValues?.nameAm ?? "",
|
||||||
|
nameEn: initialValues?.nameEn ?? "",
|
||||||
|
organizationId: userOrganizationId ?? "",
|
||||||
|
unitId: initialValues?.unitId ?? "",
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedOrganizationId = form.watch("organizationId");
|
||||||
|
|
||||||
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
|
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
|
||||||
"Org",
|
"Org",
|
||||||
{ take: 3000 },
|
{ take: 3000 },
|
||||||
@@ -141,21 +162,36 @@ export const CreatePositionForm = ({
|
|||||||
),
|
),
|
||||||
enabled: mode === "edit" && !!positionTypeId,
|
enabled: mode === "edit" && !!positionTypeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A position type belongs to a unit, and a unit to an organization — IAM has
|
||||||
|
// no organizationId on the type itself and no organization-scoped route, so
|
||||||
|
// the picked org narrows the list through its units. isSystem types are the
|
||||||
|
// shared "commons" and stay available to every organization.
|
||||||
|
const orgUnitIds = useMemo(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
(unitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id),
|
||||||
|
),
|
||||||
|
[unitsResponse],
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyFromOptions = useMemo(() => {
|
||||||
|
if (!selectedOrganizationId) return [];
|
||||||
|
return positionTypes.filter(
|
||||||
|
(type: PositionTypeDto) =>
|
||||||
|
type.id !== positionTypeId &&
|
||||||
|
(type.isSystem || (!!type.unitId && orgUnitIds.has(type.unitId))),
|
||||||
|
);
|
||||||
|
}, [positionTypes, orgUnitIds, selectedOrganizationId, positionTypeId]);
|
||||||
|
|
||||||
// Reset the selected unit when the organization changes so a unit from a
|
// Reset the selected unit when the organization changes so a unit from a
|
||||||
// different org can't be submitted by mistake.
|
// different org can't be submitted by mistake. The copy source is cleared
|
||||||
|
// too — it is scoped to the old organization.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode === "edit") return;
|
if (mode === "edit") return;
|
||||||
setSelectedUnitId("");
|
form.setValue("unitId", "");
|
||||||
}, [selectedOrganizationId, mode]);
|
setCopyFromPositionId("");
|
||||||
|
}, [selectedOrganizationId, mode, form]);
|
||||||
const form = useForm<FormValues>({
|
|
||||||
resolver: zodResolver(formSchema),
|
|
||||||
defaultValues: {
|
|
||||||
nameAm: initialValues?.nameAm ?? "",
|
|
||||||
nameEn: initialValues?.nameEn ?? "",
|
|
||||||
permissions: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "edit" || !initialValues || !positionTypeId) return;
|
if (mode !== "edit" || !initialValues || !positionTypeId) return;
|
||||||
@@ -168,17 +204,14 @@ export const CreatePositionForm = ({
|
|||||||
hasLoadedEditData.current = true;
|
hasLoadedEditData.current = true;
|
||||||
|
|
||||||
const unit = editUnitResponse?.data;
|
const unit = editUnitResponse?.data;
|
||||||
if (unit) {
|
|
||||||
setSelectedOrganizationId(unit.organizationId);
|
|
||||||
setSelectedUnitId(unit.id);
|
|
||||||
} else if (initialValues.unitId) {
|
|
||||||
setSelectedUnitId(initialValues.unitId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? [];
|
const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? [];
|
||||||
|
loadedPermissionCount.current = ids.length;
|
||||||
|
|
||||||
form.reset({
|
form.reset({
|
||||||
nameAm: initialValues.nameAm,
|
nameAm: initialValues.nameAm,
|
||||||
nameEn: initialValues.nameEn,
|
nameEn: initialValues.nameEn,
|
||||||
|
organizationId: unit?.organizationId ?? userOrganizationId ?? "",
|
||||||
|
unitId: unit?.id ?? initialValues.unitId ?? "",
|
||||||
permissions: ids,
|
permissions: ids,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,36 +227,32 @@ export const CreatePositionForm = ({
|
|||||||
isPermissionsError,
|
isPermissionsError,
|
||||||
editUnitResponse,
|
editUnitResponse,
|
||||||
permissionsResponse,
|
permissionsResponse,
|
||||||
|
userOrganizationId,
|
||||||
form,
|
form,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handlePermissionChange = (permissionId: string, checked: boolean) => {
|
const handlePermissionChange = (permissionId: string, checked: boolean) => {
|
||||||
const currentPermissions = form.getValues("permissions");
|
const currentPermissions = form.getValues("permissions");
|
||||||
if (checked) {
|
form.setValue(
|
||||||
form.setValue("permissions", [...currentPermissions, permissionId]);
|
"permissions",
|
||||||
} else {
|
checked
|
||||||
form.setValue(
|
? [...currentPermissions, permissionId]
|
||||||
"permissions",
|
: currentPermissions.filter((id) => id !== permissionId),
|
||||||
currentPermissions.filter((id) => id !== permissionId),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyFrom = async (positionTypeId: string) => {
|
const handleCopyFrom = async (sourcePositionTypeId: string) => {
|
||||||
setCopyFromPositionId(positionTypeId);
|
setCopyFromPositionId(sourcePositionTypeId);
|
||||||
if (!positionTypeId) {
|
|
||||||
form.setValue("permissions", []);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsCopying(true);
|
setIsCopying(true);
|
||||||
try {
|
try {
|
||||||
const response =
|
const response =
|
||||||
await positionTypePermissionService.getPermissionsByPositionTypeId(
|
await positionTypePermissionService.getPermissionsByPositionTypeId(
|
||||||
positionTypeId,
|
sourcePositionTypeId,
|
||||||
);
|
);
|
||||||
const ids = response.data.items?.map((p) => p.id) ?? [];
|
const ids = response.data.items?.map((p) => p.id) ?? [];
|
||||||
form.setValue("permissions", ids);
|
form.setValue("permissions", ids);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
toast.error(t("contentManagement.copyPermissionsFailed"));
|
toast.error(t("contentManagement.copyPermissionsFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsCopying(false);
|
setIsCopying(false);
|
||||||
@@ -231,23 +260,17 @@ export const CreatePositionForm = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (values: FormValues) => {
|
const onSubmit = async (values: FormValues) => {
|
||||||
|
const payload = {
|
||||||
|
name: { am: values.nameAm, en: values.nameEn },
|
||||||
|
key: values.nameEn.toLowerCase().replace(/\s+/g, "-"),
|
||||||
|
unitId: values.unitId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save the position type first. If this fails nothing else runs, and the
|
||||||
|
// mutation's own onError surfaces the reason (403 for built-in types,
|
||||||
|
// conflict on the globally-unique key, ...).
|
||||||
|
let targetId = positionTypeId;
|
||||||
try {
|
try {
|
||||||
if (!selectedUnitId) {
|
|
||||||
toast.error(t("organization.selectUnit"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
name: {
|
|
||||||
am: values.nameAm,
|
|
||||||
en: values.nameEn,
|
|
||||||
},
|
|
||||||
key: values.nameEn.toLowerCase().replace(/\s+/g, "-"),
|
|
||||||
unitId: selectedUnitId,
|
|
||||||
};
|
|
||||||
|
|
||||||
let targetId = positionTypeId;
|
|
||||||
|
|
||||||
if (mode === "edit" && positionTypeId) {
|
if (mode === "edit" && positionTypeId) {
|
||||||
await updatePositionType.mutateAsync({
|
await updatePositionType.mutateAsync({
|
||||||
id: positionTypeId,
|
id: positionTypeId,
|
||||||
@@ -257,30 +280,43 @@ export const CreatePositionForm = ({
|
|||||||
const response = await createPositionType.mutateAsync(payload);
|
const response = await createPositionType.mutateAsync(payload);
|
||||||
targetId = response.data.id;
|
targetId = response.data.id;
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
return; // already reported by the mutation's onError
|
||||||
|
}
|
||||||
|
|
||||||
if (targetId && values.permissions.length > 0) {
|
// assign-seconds-for-first replaces the whole set, but an empty secondIds
|
||||||
|
// fails server-side — so "unassign everything" is not expressible. Keep the
|
||||||
|
// save and tell the user their permissions were left alone.
|
||||||
|
const mustClearAll =
|
||||||
|
values.permissions.length === 0 && loadedPermissionCount.current > 0;
|
||||||
|
|
||||||
|
if (targetId && values.permissions.length > 0) {
|
||||||
|
try {
|
||||||
await positionTypePermissionService.assignPermissionsToPositionType({
|
await positionTypePermissionService.assignPermissionsToPositionType({
|
||||||
firstId: targetId,
|
firstId: targetId,
|
||||||
secondIds: values.permissions,
|
secondIds: values.permissions,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
invalidatePositionTypeQueries(queryClient);
|
||||||
|
toast.error(t("contentManagement.permissionsAssignFailed"));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
invalidatePositionTypeQueries(queryClient);
|
||||||
queryKey: ["position-type"],
|
queryClient.invalidateQueries({ queryKey: ["position-type-permissions"] });
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["position-types"] });
|
if (mustClearAll) {
|
||||||
queryClient.invalidateQueries({
|
toast.warning(t("contentManagement.cannotClearAllPermissions"));
|
||||||
queryKey: ["position-type-permissions"],
|
} else {
|
||||||
});
|
|
||||||
toast.success(t("contentManagement.permissionSuccess"));
|
toast.success(t("contentManagement.permissionSuccess"));
|
||||||
|
}
|
||||||
|
|
||||||
if (onSuccess) {
|
if (onSuccess) {
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} else {
|
} else {
|
||||||
navigate("/user-management/position-management");
|
navigate("/user-management/position-management");
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error(t("contentManagement.permissionSuccess"));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -292,6 +328,19 @@ export const CreatePositionForm = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedPermissionCount = form.watch("permissions").length;
|
||||||
|
// form.formState.isSubmitting stays true for the whole async handler, so it
|
||||||
|
// also covers the permission-assignment call that follows the save.
|
||||||
|
const isBusy = form.formState.isSubmitting || isCopying;
|
||||||
|
|
||||||
|
const copyFromPlaceholder = !selectedOrganizationId
|
||||||
|
? t("contentManagement.selectOrganizationToCopy")
|
||||||
|
: isCopying || isLoadingPositionTypes || isLoadingUnits
|
||||||
|
? t("common.loading")
|
||||||
|
: isErrorPositionTypes
|
||||||
|
? t("contentManagement.failedToLoadPositionTypes")
|
||||||
|
: t("contentManagement.selectPositionToCopy");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
@@ -323,43 +372,51 @@ export const CreatePositionForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ✅ Organization (searchable, all orgs) */}
|
{/* Organization (searchable, all orgs) */}
|
||||||
<div className="mb-4 w-full">
|
<FormField
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
control={form.control}
|
||||||
{t("organization.organization") || "Organization"}
|
name="organizationId"
|
||||||
</label>
|
render={({ field }) => (
|
||||||
<SingleSelect
|
<FormItem>
|
||||||
options={organizationOptions}
|
<FormLabel>{t("organization.organization")}</FormLabel>
|
||||||
value={selectedOrganizationId}
|
<SingleSelect
|
||||||
onValueChange={setSelectedOrganizationId}
|
options={organizationOptions}
|
||||||
placeholder={
|
value={field.value}
|
||||||
isLoadingOrgs
|
onValueChange={field.onChange}
|
||||||
? t("common.loading")
|
placeholder={
|
||||||
: t("organization.selectOrganization") ||
|
isLoadingOrgs
|
||||||
"Select an organization"
|
? t("common.loading")
|
||||||
}
|
: t("organization.selectOrganization")
|
||||||
/>
|
}
|
||||||
</div>
|
/>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ✅ Unit Selector — searchable, scoped to picked org */}
|
{/* Unit Selector — searchable, scoped to picked org */}
|
||||||
<div className="mb-4 w-full">
|
<FormField
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
control={form.control}
|
||||||
{t("organization.selectUnit")}
|
name="unitId"
|
||||||
</label>
|
render={({ field }) => (
|
||||||
<SingleSelect
|
<FormItem>
|
||||||
options={unitOptions}
|
<FormLabel>{t("organization.selectUnit")}</FormLabel>
|
||||||
value={selectedUnitId}
|
<SingleSelect
|
||||||
onValueChange={setSelectedUnitId}
|
options={unitOptions}
|
||||||
placeholder={
|
value={field.value}
|
||||||
!selectedOrganizationId
|
onValueChange={field.onChange}
|
||||||
? t("organization.selectOrganizationFirst") ||
|
placeholder={
|
||||||
"Select an organization first"
|
!selectedOrganizationId
|
||||||
: isLoadingUnits
|
? t("organization.selectOrganizationFirst")
|
||||||
? t("common.loading")
|
: isLoadingUnits
|
||||||
: t("organization.selectUnit")
|
? t("common.loading")
|
||||||
}
|
: t("organization.selectUnit")
|
||||||
/>
|
}
|
||||||
</div>
|
/>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mb-4 w-1/2">
|
<div className="mb-4 w-1/2">
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
@@ -367,15 +424,21 @@ export const CreatePositionForm = ({
|
|||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedApplicationId}
|
value={selectedApplicationId}
|
||||||
onValueChange={(value) => setSelectedApplicationId(value)}
|
onValueChange={setSelectedApplicationId}
|
||||||
disabled={isLoadingApplications}>
|
disabled={isLoadingApplications}>
|
||||||
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
|
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
|
||||||
<SelectValue placeholder="Select an Application" />
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
isLoadingApplications
|
||||||
|
? t("common.loading")
|
||||||
|
: t("contentManagement.selectApplication")
|
||||||
|
}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="max-h-60 overflow-y-auto">
|
<SelectContent className="max-h-60 overflow-y-auto">
|
||||||
{applications?.map((app) => (
|
{applications?.map((app) => (
|
||||||
<SelectItem key={app.id} value={app.id}>
|
<SelectItem key={app.id} value={app.id}>
|
||||||
{lang === "en" ? app.name.en : app.name.am}
|
{localizedName(app.name)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -389,18 +452,17 @@ export const CreatePositionForm = ({
|
|||||||
<Select
|
<Select
|
||||||
value={copyFromPositionId}
|
value={copyFromPositionId}
|
||||||
onValueChange={handleCopyFrom}
|
onValueChange={handleCopyFrom}
|
||||||
disabled={isLoadingPositionTypes || isCopying}>
|
disabled={
|
||||||
|
!selectedOrganizationId ||
|
||||||
|
isLoadingPositionTypes ||
|
||||||
|
isLoadingUnits ||
|
||||||
|
isCopying
|
||||||
|
}>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue
|
<SelectValue placeholder={copyFromPlaceholder} />
|
||||||
placeholder={
|
|
||||||
isCopying
|
|
||||||
? t("common.loading")
|
|
||||||
: t("contentManagement.selectPositionToCopy")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="max-h-60 overflow-y-auto">
|
<SelectContent className="max-h-60 overflow-y-auto">
|
||||||
{positionTypes.map((p: PositionTypeDto) => (
|
{copyFromOptions.map((p: PositionTypeDto) => (
|
||||||
<SelectItem key={p.id} value={p.id}>
|
<SelectItem key={p.id} value={p.id}>
|
||||||
{localizedName(p.name)}
|
{localizedName(p.name)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -417,7 +479,18 @@ export const CreatePositionForm = ({
|
|||||||
name="permissions"
|
name="permissions"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("contentManagement.permission")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("contentManagement.permission")}
|
||||||
|
{selectedPermissionCount > 0 && (
|
||||||
|
<span className="ml-2 font-normal text-muted-foreground">
|
||||||
|
(
|
||||||
|
{t("contentManagement.permissionsSelected", {
|
||||||
|
count: selectedPermissionCount,
|
||||||
|
})}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
<PermissionSearch
|
<PermissionSearch
|
||||||
selectedPermissions={field.value}
|
selectedPermissions={field.value}
|
||||||
onPermissionChange={handlePermissionChange}
|
onPermissionChange={handlePermissionChange}
|
||||||
@@ -432,6 +505,7 @@ export const CreatePositionForm = ({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
disabled={isBusy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (onCancel) {
|
if (onCancel) {
|
||||||
onCancel();
|
onCancel();
|
||||||
@@ -441,12 +515,12 @@ export const CreatePositionForm = ({
|
|||||||
}}>
|
}}>
|
||||||
{t("common.Cancel")}
|
{t("common.Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="submit" disabled={isBusy}>
|
||||||
type="submit"
|
{form.formState.isSubmitting
|
||||||
disabled={
|
? t("common.saving")
|
||||||
createPositionType.isPending || updatePositionType.isPending
|
: mode === "edit"
|
||||||
}>
|
? t("delegation.update")
|
||||||
{mode === "edit" ? t("delegation.update") : t("delegation.save")}
|
: t("delegation.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/shared/common/ui/button";
|
import { Button } from "@/shared/common/ui/button";
|
||||||
import { Input } from "@/shared/common/ui/input";
|
import { Input } from "@/shared/common/ui/input";
|
||||||
import { Label } from "@/shared/common/ui/label";
|
import { Label } from "@/shared/common/ui/label";
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/common/ui/select";
|
} from "@/shared/common/ui/select";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||||
import { useAuth } from "@/shared/context/AuthContext";
|
import { useAuth } from "@/shared/context/AuthContext";
|
||||||
@@ -17,7 +18,6 @@ import { positionTypePermissionService } from "@/user-management/services/api/po
|
|||||||
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
|
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
|
||||||
import { useApplications } from "@/user-management/hooks/useApplications";
|
import { useApplications } from "@/user-management/hooks/useApplications";
|
||||||
import { PermissionSearch } from "./PermissionSearch";
|
import { PermissionSearch } from "./PermissionSearch";
|
||||||
import { PermissionDto } from "@/user-management/dto/permissions/permissonDto";
|
|
||||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||||
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
@@ -28,7 +28,9 @@ export const EditPositionForm = ({ id }: { id: string }) => {
|
|||||||
const { getList } = useUnit();
|
const { getList } = useUnit();
|
||||||
const localizedName = useLocalizedName();
|
const localizedName = useLocalizedName();
|
||||||
|
|
||||||
const { positionType, isLoadingSingle } = usePositionTypes({ id });
|
const { positionType, isLoadingSingle, isErrorSingle } = usePositionTypes({
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
const organizationId =
|
const organizationId =
|
||||||
user?.employee && user.employee.length > 0
|
user?.employee && user.employee.length > 0
|
||||||
@@ -44,30 +46,47 @@ export const EditPositionForm = ({ id }: { id: string }) => {
|
|||||||
|
|
||||||
const [selectedApplicationId, setSelectedApplicationId] =
|
const [selectedApplicationId, setSelectedApplicationId] =
|
||||||
useState<string>("");
|
useState<string>("");
|
||||||
const [assignedPermissions, setAssignedPermissions] = useState<
|
|
||||||
PermissionDto[]
|
|
||||||
>([]);
|
|
||||||
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Shares the cache key CreatePositionForm writes under, so editing a position
|
||||||
const load = async () => {
|
// type's permissions refreshes this view too.
|
||||||
if (!positionType) return;
|
const {
|
||||||
setIsLoadingPermissions(true);
|
data: assignedResponse,
|
||||||
try {
|
isLoading: isLoadingPermissions,
|
||||||
const assigned =
|
isError: isErrorPermissions,
|
||||||
await positionTypePermissionService.getPermissionsByPositionTypeId(
|
} = useQuery({
|
||||||
positionType.id,
|
queryKey: ["position-type-permissions", id],
|
||||||
);
|
queryFn: () =>
|
||||||
setAssignedPermissions(assigned.data.items ?? []);
|
positionTypePermissionService.getPermissionsByPositionTypeId(id),
|
||||||
} finally {
|
enabled: !!id,
|
||||||
setIsLoadingPermissions(false);
|
});
|
||||||
}
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
}, [positionType]);
|
|
||||||
|
|
||||||
if (isLoadingSingle) return <p>Loading...</p>;
|
const assignedPermissions = assignedResponse?.data?.items ?? [];
|
||||||
if (!positionType) return null;
|
|
||||||
|
if (isLoadingSingle) {
|
||||||
|
return (
|
||||||
|
<p className="py-8 text-center text-muted-foreground">
|
||||||
|
{t("common.loading")}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isErrorSingle || !positionType) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="py-8 text-center text-red-500">
|
||||||
|
{t("contentManagement.positionTypeNotFound")}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate("/user-management/position-management")}>
|
||||||
|
{t("common.Back")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const unit = unitsResponse?.data?.items?.find(
|
const unit = unitsResponse?.data?.items?.find(
|
||||||
(u: UnitDto) => u.id === positionType.unitId,
|
(u: UnitDto) => u.id === positionType.unitId,
|
||||||
@@ -87,22 +106,32 @@ export const EditPositionForm = ({ id }: { id: string }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Key</Label>
|
<Label>{t("contentManagement.key")}</Label>
|
||||||
<Input value={positionType.key} disabled readOnly />
|
<Input value={positionType.key} disabled readOnly />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("organization.selectUnit")}</Label>
|
||||||
|
<Input value={unitName ?? ""} disabled readOnly />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("contentManagement.selectApplication")}</Label>
|
<Label>{t("contentManagement.selectApplication")}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedApplicationId}
|
value={selectedApplicationId}
|
||||||
onValueChange={(value) => setSelectedApplicationId(value)}
|
onValueChange={setSelectedApplicationId}
|
||||||
disabled={isLoadingApplications}
|
disabled={isLoadingApplications}>
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
|
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
|
||||||
<SelectValue placeholder="Select an Application" />
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
isLoadingApplications
|
||||||
|
? t("common.loading")
|
||||||
|
: t("contentManagement.selectApplication")
|
||||||
|
}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="max-h-60 overflow-y-auto">
|
<SelectContent className="max-h-60 overflow-y-auto">
|
||||||
{applications?.map((app: any) => (
|
{applications?.map((app) => (
|
||||||
<SelectItem key={app.id} value={app.id}>
|
<SelectItem key={app.id} value={app.id}>
|
||||||
{localizedName(app.name)}
|
{localizedName(app.name)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -125,7 +154,13 @@ export const EditPositionForm = ({ id }: { id: string }) => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="border rounded-md p-4 bg-background max-h-96 overflow-y-auto">
|
<div className="border rounded-md p-4 bg-background max-h-96 overflow-y-auto">
|
||||||
{isLoadingPermissions ? (
|
{isLoadingPermissions ? (
|
||||||
<div className="text-center py-4 text-gray-500">Loading...</div>
|
<div className="text-center py-4 text-gray-500">
|
||||||
|
{t("common.loading")}
|
||||||
|
</div>
|
||||||
|
) : isErrorPermissions ? (
|
||||||
|
<div className="text-center py-4 text-red-500">
|
||||||
|
{t("contentManagement.failedToLoadPermissions")}
|
||||||
|
</div>
|
||||||
) : assignedPermissions.length === 0 ? (
|
) : assignedPermissions.length === 0 ? (
|
||||||
<div className="text-center py-4 text-gray-500">
|
<div className="text-center py-4 text-gray-500">
|
||||||
{t("contentManagement.noPermissionsAvailable")}
|
{t("contentManagement.noPermissionsAvailable")}
|
||||||
@@ -135,8 +170,7 @@ export const EditPositionForm = ({ id }: { id: string }) => {
|
|||||||
{assignedPermissions.map((perm) => (
|
{assignedPermissions.map((perm) => (
|
||||||
<li
|
<li
|
||||||
key={perm.id}
|
key={perm.id}
|
||||||
className="capitalize text-sm py-1 px-2 rounded bg-muted/40"
|
className="capitalize text-sm py-1 px-2 rounded bg-muted/40">
|
||||||
>
|
|
||||||
{localizedName(perm.name)}
|
{localizedName(perm.name)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -150,8 +184,7 @@ export const EditPositionForm = ({ id }: { id: string }) => {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => navigate("/user-management/position-management")}
|
onClick={() => navigate("/user-management/position-management")}>
|
||||||
>
|
|
||||||
{t("common.Back")}
|
{t("common.Back")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useMemo, useRef } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Input } from "@/shared/common/ui/input";
|
import { Input } from "@/shared/common/ui/input";
|
||||||
import { Checkbox } from "@/shared/common/ui/checkbox";
|
import { Checkbox } from "@/shared/common/ui/checkbox";
|
||||||
import { usePermissionManager } from "@/user-management/hooks/usePermissionManager";
|
import { usePermissionManager } from "@/user-management/hooks/usePermissionManager";
|
||||||
@@ -14,7 +14,10 @@ interface PermissionSearchProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_TAKE = 50; // Initial number of items to fetch
|
// One request per application. This used to fetch 50, read `count` off the
|
||||||
|
// response and immediately refetch with take = count — two round trips on every
|
||||||
|
// mount for the same list.
|
||||||
|
const TAKE = 1000;
|
||||||
|
|
||||||
export const PermissionSearch: React.FC<PermissionSearchProps> = ({
|
export const PermissionSearch: React.FC<PermissionSearchProps> = ({
|
||||||
selectedPermissions,
|
selectedPermissions,
|
||||||
@@ -24,60 +27,30 @@ export const PermissionSearch: React.FC<PermissionSearchProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||||
const [take, setTake] = useState(INITIAL_TAKE); // Start with 50
|
|
||||||
const hasSetTotalCount = useRef(false); // Track if we've set the total count
|
|
||||||
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const localizedName = useLocalizedName();
|
const localizedName = useLocalizedName();
|
||||||
|
|
||||||
/** ------------------ 1. Debounce Search ------------------ */
|
/** ------------------ 1. Debounce Search ------------------ */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => setDebouncedSearchTerm(searchTerm), 300);
|
||||||
setDebouncedSearchTerm(searchTerm);
|
|
||||||
setTake(INITIAL_TAKE); // Reset to 50
|
|
||||||
hasSetTotalCount.current = false; // Reset the flag
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchTerm]);
|
}, [searchTerm]);
|
||||||
|
|
||||||
/** ------------------ 2. Fetch Permissions ------------------ */
|
/** ------------------ 2. Fetch Permissions ------------------ */
|
||||||
|
// The API does the filtering. Filtering the result again on the *undebounced*
|
||||||
|
// term used to blank the list for 300ms on every keystroke.
|
||||||
const { permissions, isPermissionsLoading } = usePermissionManager({
|
const { permissions, isPermissionsLoading } = usePermissionManager({
|
||||||
params: applicationId
|
params: applicationId
|
||||||
? {
|
? {
|
||||||
take,
|
take: TAKE,
|
||||||
skip: 0, // Always skip 0, we fetch everything at once
|
skip: 0,
|
||||||
search: debouncedSearchTerm || undefined,
|
search: debouncedSearchTerm || undefined,
|
||||||
applicationId,
|
applicationId,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** ------------------ 3. Update take to total count after first fetch ------------------ */
|
const items = permissions?.items ?? [];
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
permissions?.count &&
|
|
||||||
!hasSetTotalCount.current &&
|
|
||||||
take !== permissions.count
|
|
||||||
) {
|
|
||||||
hasSetTotalCount.current = true;
|
|
||||||
setTake(permissions.count); // Fetch all items
|
|
||||||
}
|
|
||||||
}, [permissions?.count, take]);
|
|
||||||
|
|
||||||
/** ------------------ 4. Client-side Filtering (Optional) ------------------ */
|
|
||||||
const filteredPermissions = useMemo(() => {
|
|
||||||
if (!permissions?.items?.length) return [];
|
|
||||||
if (!searchTerm.trim()) return permissions.items;
|
|
||||||
|
|
||||||
return permissions.items.filter((perm: PermissionDto) => {
|
|
||||||
const name = localizedName(perm.name).toLowerCase();
|
|
||||||
const key = perm.key.toLowerCase();
|
|
||||||
const search = searchTerm.toLowerCase();
|
|
||||||
return name.includes(search) || key.includes(search);
|
|
||||||
});
|
|
||||||
}, [permissions?.items, searchTerm, localizedName]);
|
|
||||||
|
|
||||||
/** ------------------ Render ------------------ */
|
/** ------------------ Render ------------------ */
|
||||||
return (
|
return (
|
||||||
@@ -96,31 +69,26 @@ export const PermissionSearch: React.FC<PermissionSearchProps> = ({
|
|||||||
{/* Permission List Container */}
|
{/* Permission List Container */}
|
||||||
{!applicationId ? (
|
{!applicationId ? (
|
||||||
<div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background text-center text-gray-500">
|
<div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background text-center text-gray-500">
|
||||||
{t("contentManagement.selectApplicationToLoadPermissions") ||
|
{t("contentManagement.selectApplicationToLoadPermissions")}
|
||||||
"Select an application to load permissions."}
|
|
||||||
</div>
|
</div>
|
||||||
) : isPermissionsLoading ? (
|
) : isPermissionsLoading ? (
|
||||||
<div className="flex justify-center py-10">
|
<div className="flex justify-center py-10">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background">
|
||||||
ref={scrollContainerRef}
|
{items.length === 0 ? (
|
||||||
className="max-h-96 overflow-y-auto border rounded-md p-4 bg-background"
|
|
||||||
>
|
|
||||||
{filteredPermissions.length === 0 ? (
|
|
||||||
<div className="text-center py-4 text-gray-500">
|
<div className="text-center py-4 text-gray-500">
|
||||||
{searchTerm
|
{debouncedSearchTerm
|
||||||
? t("contentManagement.noPermissionsFound")
|
? t("contentManagement.noPermissionsFound")
|
||||||
: t("contentManagement.noPermissionsAvailable")}
|
: t("contentManagement.noPermissionsAvailable")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{filteredPermissions.map((perm: PermissionDto) => (
|
{items.map((perm: PermissionDto) => (
|
||||||
<div
|
<div
|
||||||
key={perm.id}
|
key={perm.id}
|
||||||
className="flex flex-row items-start space-x-3 space-y-0"
|
className="flex flex-row items-start space-x-3 space-y-0">
|
||||||
>
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedPermissions.includes(perm.id)}
|
checked={selectedPermissions.includes(perm.id)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -139,10 +107,10 @@ export const PermissionSearch: React.FC<PermissionSearchProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer Info */}
|
{/* Footer Info */}
|
||||||
{filteredPermissions.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div className="text-xs text-muted-foreground px-1">
|
<div className="text-xs text-muted-foreground px-1">
|
||||||
{t("contentManagement.showingPermissions", {
|
{t("contentManagement.showingPermissions", {
|
||||||
count: filteredPermissions.length,
|
count: items.length,
|
||||||
total: permissions?.count || 0,
|
total: permissions?.count || 0,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||||
import { Button } from "@/shared/common/ui/button";
|
import { Button } from "@/shared/common/ui/button";
|
||||||
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
|
|||||||
import { createPositionTypeColumns } from "./PositionTypeColumnDefn";
|
import { createPositionTypeColumns } from "./PositionTypeColumnDefn";
|
||||||
import { positionTypeService } from "@/user-management/services/api/positionTypesService";
|
import { positionTypeService } from "@/user-management/services/api/positionTypesService";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||||
import { useAuth } from "@/shared/context/AuthContext";
|
import { useAuth } from "@/shared/context/AuthContext";
|
||||||
import {
|
import {
|
||||||
@@ -24,14 +25,13 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/common/ui/select";
|
} from "@/shared/common/ui/select";
|
||||||
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
||||||
import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType";
|
import { PositionTypeDto } from "@/user-management/dto/positions/positionType";
|
||||||
|
|
||||||
export default function PositionManagement() {
|
export default function PositionManagement() {
|
||||||
const [pageIndex, setPageIndex] = useState(0);
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const { createConfiguration } = usePositionTypeConfiguration();
|
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
@@ -39,10 +39,13 @@ export default function PositionManagement() {
|
|||||||
|
|
||||||
const organizationId = user?.employee?.[0]?.organizationId;
|
const organizationId = user?.employee?.[0]?.organizationId;
|
||||||
|
|
||||||
const { data: unitsResponse } = getAccessibleList(organizationId ?? "", {
|
const { data: unitsResponse, isError: isUnitsError } = getAccessibleList(
|
||||||
take: 300,
|
organizationId ?? "",
|
||||||
skip: 0,
|
{
|
||||||
});
|
take: 300,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Add state for selected unitId
|
// Add state for selected unitId
|
||||||
// Default: if super_admin => "All", otherwise wait for units
|
// Default: if super_admin => "All", otherwise wait for units
|
||||||
@@ -68,10 +71,16 @@ export default function PositionManagement() {
|
|||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setPageIndex(newPage);
|
setPageIndex(newPage);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showingAllUnits = selectedUnitId === "All";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
positionTypeResponse,
|
positionTypeResponse,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isError,
|
||||||
positionTypeByUnitId,
|
positionTypeByUnitId,
|
||||||
|
isLoadingPosition,
|
||||||
|
isErrorPosition,
|
||||||
refetch,
|
refetch,
|
||||||
refetchPosition,
|
refetchPosition,
|
||||||
} = usePositionTypes({
|
} = usePositionTypes({
|
||||||
@@ -80,78 +89,39 @@ export default function PositionManagement() {
|
|||||||
skip: 0,
|
skip: 0,
|
||||||
orderBy: "updatedAt:DESC",
|
orderBy: "updatedAt:DESC",
|
||||||
},
|
},
|
||||||
unitId: selectedUnitId === "All" ? undefined : selectedUnitId,
|
unitId: showingAllUnits ? undefined : selectedUnitId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch position types without unitId for migration options
|
// Refresh whichever list is on screen. `positionTypeResponse` is the
|
||||||
const {
|
// unscoped fetch, so it doubles as the migration-target source — no second
|
||||||
positionTypeResponse: globalPositionTypes,
|
// usePositionTypes() call needed (its cache key ignores unitId, so a second
|
||||||
refetch: refetchGlobalPositionTypes,
|
// call returned the very same query).
|
||||||
} = usePositionTypes({
|
const handlePositionTypeChanged = useCallback(async () => {
|
||||||
params: {
|
await (showingAllUnits ? refetch() : refetchPosition());
|
||||||
take: 1000, // Get all global position types
|
}, [showingAllUnits, refetch, refetchPosition]);
|
||||||
skip: 0,
|
|
||||||
orderBy: "updatedAt:DESC",
|
|
||||||
},
|
|
||||||
unitId: undefined, // Explicitly fetch position types without unitId
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a combined refetch function for the onDelete callback
|
|
||||||
const handlePositionTypeDeleted = async () => {
|
|
||||||
await Promise.all([
|
|
||||||
selectedUnitId === "All" ? refetch() : refetchPosition(),
|
|
||||||
refetchGlobalPositionTypes(),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
const handleToggle = async (
|
|
||||||
positionTypeId: string,
|
|
||||||
checked: boolean,
|
|
||||||
field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord",
|
|
||||||
) => {
|
|
||||||
if (!selectedUnitId || selectedUnitId === "All") return;
|
|
||||||
|
|
||||||
await createConfiguration({
|
|
||||||
positionTypeId,
|
|
||||||
timeframe: "yearly",
|
|
||||||
organizationId: organizationId!,
|
|
||||||
canReceiveRecord: field === "canReceiveRecord" ? checked : false,
|
|
||||||
canAssignRecord: field === "canAssignRecord" ? checked : false,
|
|
||||||
canCreateBankRecord: field === "canCreateBankRecord" ? checked : false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await handlePositionTypeDeleted();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create columns with positionTypeResponse
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createPositionTypeColumns(
|
createPositionTypeColumns(
|
||||||
selectedUnitId === "All" ? positionTypeResponse : positionTypeByUnitId,
|
positionTypeResponse,
|
||||||
globalPositionTypes,
|
handlePositionTypeChanged,
|
||||||
handlePositionTypeDeleted,
|
handlePositionTypeChanged,
|
||||||
handlePositionTypeDeleted,
|
|
||||||
handleToggle, // ← pass toggle handler
|
|
||||||
selectedUnitId === "All", // ← isGlobal: hide toggle when "All"
|
|
||||||
),
|
),
|
||||||
[
|
[positionTypeResponse, handlePositionTypeChanged],
|
||||||
selectedUnitId,
|
|
||||||
positionTypeResponse,
|
|
||||||
positionTypeByUnitId,
|
|
||||||
globalPositionTypes,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const allItems = useMemo(
|
const allItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(selectedUnitId === "All"
|
(showingAllUnits
|
||||||
? positionTypeResponse?.items
|
? positionTypeResponse?.items
|
||||||
: positionTypeByUnitId?.items) || [],
|
: positionTypeByUnitId?.items) || [],
|
||||||
[selectedUnitId, positionTypeResponse?.items, positionTypeByUnitId?.items],
|
[showingAllUnits, positionTypeResponse?.items, positionTypeByUnitId?.items],
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
const trimmed = searchTerm.trim().toLowerCase();
|
const trimmed = searchTerm.trim().toLowerCase();
|
||||||
if (!trimmed) return allItems;
|
if (!trimmed) return allItems;
|
||||||
return allItems.filter((item: any) => {
|
return allItems.filter((item: PositionTypeDto) => {
|
||||||
const en = (item?.name?.en || "").toLowerCase();
|
const en = (item?.name?.en || "").toLowerCase();
|
||||||
const am = (item?.name?.am || "").toLowerCase();
|
const am = (item?.name?.am || "").toLowerCase();
|
||||||
const key = (item?.key || "").toLowerCase();
|
const key = (item?.key || "").toLowerCase();
|
||||||
@@ -166,44 +136,37 @@ export default function PositionManagement() {
|
|||||||
return filteredItems.slice(start, start + pageSize);
|
return filteredItems.slice(start, start + pageSize);
|
||||||
}, [filteredItems, pageIndex, pageSize]);
|
}, [filteredItems, pageIndex, pageSize]);
|
||||||
|
|
||||||
if (isLoading) {
|
// Track whichever query is actually feeding the table — picking a unit used
|
||||||
return <div>{t("contentManagement.addUser")}</div>;
|
// to leave the previous unit's rows on screen with no loading state.
|
||||||
}
|
const isLoadingList = showingAllUnits ? isLoading : isLoadingPosition;
|
||||||
|
const isErrorList = showingAllUnits ? isError : isErrorPosition;
|
||||||
|
|
||||||
const exportTypes = () => {
|
const exportTypes = () => {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
positionTypeService
|
positionTypeService
|
||||||
.getAll({
|
.getAll({ take: 3000 })
|
||||||
take: 3000,
|
|
||||||
})
|
|
||||||
.then((allPositionKeys) => {
|
.then((allPositionKeys) => {
|
||||||
// Get the position type keys
|
|
||||||
const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key);
|
const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key);
|
||||||
|
|
||||||
if (positionTypeKeys && positionTypeKeys.length > 0) {
|
if (!positionTypeKeys?.length) {
|
||||||
// Convert the array of keys into a string, with each key on a new line
|
toast.error(t("contentManagement.exportFailed"));
|
||||||
const fileContent = positionTypeKeys.join("\n");
|
return;
|
||||||
|
|
||||||
// Create a Blob from the string content
|
|
||||||
const blob = new Blob([fileContent], { type: "text/plain" });
|
|
||||||
|
|
||||||
// Create a link element to trigger the download
|
|
||||||
const link = document.createElement("a");
|
|
||||||
|
|
||||||
// Create an object URL for the Blob
|
|
||||||
link.href = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
// Set the download attribute with a file name
|
|
||||||
link.download = "position_keys.txt";
|
|
||||||
|
|
||||||
// Programmatically trigger a click on the link to start the download
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
// Clean up by revoking the object URL
|
|
||||||
URL.revokeObjectURL(link.href);
|
|
||||||
} else {
|
|
||||||
console.error("No position type keys found.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One key per line, downloaded as a plain text file.
|
||||||
|
const blob = new Blob([positionTypeKeys.join("\n")], {
|
||||||
|
type: "text/plain",
|
||||||
|
});
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = "position_keys.txt";
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(link.href);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(t("contentManagement.exportFailed"));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
setIsExporting(false);
|
setIsExporting(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -215,29 +178,28 @@ export default function PositionManagement() {
|
|||||||
<CardTitle className="text-xl font-semibold ">
|
<CardTitle className="text-xl font-semibold ">
|
||||||
{t("contentManagement.permissionType")}
|
{t("contentManagement.permissionType")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button onClick={exportTypes}>
|
<Button onClick={exportTypes} disabled={isExporting}>
|
||||||
{isExporting
|
{isExporting
|
||||||
? t("contentManagement.exporting")
|
? t("contentManagement.exporting")
|
||||||
: t("contentManagement.exportTypes")}
|
: t("contentManagement.exportTypes")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{unitsResponse?.data?.items?.length > 0 && (
|
{!!unitsResponse?.data?.items?.length && (
|
||||||
<div className="mb-4 w-1/2">
|
<div className="mb-4 w-1/2">
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
Select Unit
|
{t("organization.selectUnit")}
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedUnitId}
|
value={selectedUnitId}
|
||||||
onValueChange={(value) => setSelectedUnitId(value)}
|
onValueChange={(value) => setSelectedUnitId(value)}>
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm [&>span]:truncate">
|
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm [&>span]:truncate">
|
||||||
<SelectValue placeholder="Select a Unit" />
|
<SelectValue placeholder={t("organization.selectUnit")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem key="all" value="All">
|
<SelectItem key="all" value="All">
|
||||||
All
|
{t("common.all")}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
{unitsResponse?.data.items.map((unit: UnitDto) => (
|
{unitsResponse.data.items.map((unit: UnitDto) => (
|
||||||
<SelectItem key={unit.id} value={unit.id}>
|
<SelectItem key={unit.id} value={unit.id}>
|
||||||
<span className="block truncate max-w-70">
|
<span className="block truncate max-w-70">
|
||||||
{unit.name.en || unit.name.am}
|
{unit.name.en || unit.name.am}
|
||||||
@@ -248,27 +210,42 @@ export default function PositionManagement() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isUnitsError && (
|
||||||
|
<p className="mb-4 text-sm text-red-500">
|
||||||
|
{t("organization.errorLoadingUnits")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<CardContent className="px-0">
|
<CardContent className="px-0">
|
||||||
<AdvancedTable
|
{isLoadingList ? (
|
||||||
columns={columns}
|
<div className="py-10 text-center text-muted-foreground">
|
||||||
data={paginatedItems}
|
{t("common.loading")}
|
||||||
tableName="Positions"
|
</div>
|
||||||
toolBarPosition="right"
|
) : isErrorList ? (
|
||||||
itemCount={filteredItems.length}
|
<div className="py-10 text-center text-red-500">
|
||||||
onGlobalFilterChange={setSearchTerm}
|
{t("contentManagement.failedToLoadPositionTypes")}
|
||||||
extraToolbar={
|
</div>
|
||||||
<Link to="/user-management/position-management/new">
|
) : (
|
||||||
<Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
|
<AdvancedTable
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
columns={columns}
|
||||||
{t("contentManagement.newPermission")}
|
data={paginatedItems}
|
||||||
</Button>
|
tableName="Positions"
|
||||||
</Link>
|
toolBarPosition="right"
|
||||||
}
|
itemCount={filteredItems.length}
|
||||||
pageIndex={pageIndex}
|
onGlobalFilterChange={setSearchTerm}
|
||||||
onPageChange={handlePageChange}
|
extraToolbar={
|
||||||
nextFunction={() => handlePageChange(pageIndex + 1)}
|
<Link to="/user-management/position-management/new">
|
||||||
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
|
<Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
|
||||||
/>
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
{t("contentManagement.newPermission")}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
nextFunction={() => handlePageChange(pageIndex + 1)}
|
||||||
|
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,14 +26,8 @@ import { MoreVertical, Edit, Eye, Trash2, Pencil } from "lucide-react";
|
|||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import PositionTypeMigrationModal from "./PostionTypeMigration";
|
import PositionTypeMigrationModal from "./PostionTypeMigration";
|
||||||
import { CreatePositionForm } from "./CreatePositionForm";
|
import { CreatePositionForm } from "./CreatePositionForm";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||||
import { positionTypeService } from "@/user-management/services/api/positionTypesService";
|
import { usePositionTypes } from "@/user-management/hooks/usePositionTypes";
|
||||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
|
||||||
import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType";
|
|
||||||
import { Switch } from "@/shared/common/ui/switch";
|
|
||||||
import { PositionTypeConfigurationDto } from "@/user-management/services/api/positionTypeConfigurationService";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -47,59 +41,42 @@ interface PositionTypeResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ActionsCellProps = {
|
type ActionsCellProps = {
|
||||||
row: PositionTypeDto | PositionTypeConfigurationDto;
|
row: PositionTypeDto;
|
||||||
globalPositionTypes?: PositionTypeResponse;
|
globalPositionTypes?: PositionTypeResponse;
|
||||||
onDelete?: () => void | Promise<void>;
|
onDelete?: () => void | Promise<void>;
|
||||||
onEdit?: () => void | Promise<void>;
|
onEdit?: () => void | Promise<void>;
|
||||||
onToggle?: (
|
|
||||||
positionTypeId: string,
|
|
||||||
checked: boolean,
|
|
||||||
field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord",
|
|
||||||
) => void | Promise<void>;
|
|
||||||
isGlobal?: boolean; // true when viewing "All" units — hide toggle
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* TODO(record-toggles): this menu used to carry CanReceiveRecord /
|
||||||
|
* CanAssignRecord / CanCreateBankRecord switches. They never worked. IAM's
|
||||||
|
* PositionTypeConfiguration entity only has { id, organizationId,
|
||||||
|
* positionTypeId, timeframe } — verified against every local build (0.7.4
|
||||||
|
* through 0.7.12) and the live swagger. canAssignRecord and
|
||||||
|
* canCreateBankRecord do not exist anywhere in the IAM package, and the global
|
||||||
|
* ValidationPipe runs with forbidNonWhitelisted, so every write 400'd. The
|
||||||
|
* reads were broken too: the list route filters on organizationId (the repo is
|
||||||
|
* built as TExtraCrudRepository(repo, "organizationId")) while the UI passed a
|
||||||
|
* positionTypeId, so it always came back empty.
|
||||||
|
*
|
||||||
|
* The flag that does exist is PositionConfiguration.canReceiveRecord, keyed by
|
||||||
|
* positionId — a per-position setting served by /api/position-configurations,
|
||||||
|
* not a per-position-type one. Restoring this needs either that endpoint and a
|
||||||
|
* position-level UI, or new columns on PositionTypeConfiguration in IAM.
|
||||||
|
*/
|
||||||
const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
||||||
row,
|
row,
|
||||||
globalPositionTypes,
|
globalPositionTypes,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
onEdit,
|
||||||
onToggle,
|
|
||||||
isGlobal = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const [showMigrateDialog, setShowMigrateDialog] = useState(false);
|
const [showMigrateDialog, setShowMigrateDialog] = useState(false);
|
||||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
const localizedName = useLocalizedName();
|
const localizedName = useLocalizedName();
|
||||||
const { handleError } = useErrorHandler(t);
|
const { deletePositionType } = usePositionTypes();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
// Use row.id as the positionTypeId for the configuration lookup
|
|
||||||
|
|
||||||
const {
|
|
||||||
configurations,
|
|
||||||
isLoadingConfigurations,
|
|
||||||
updateConfiguration,
|
|
||||||
isUpdatingConfiguration,
|
|
||||||
} = usePositionTypeConfiguration(
|
|
||||||
row?.id ?? null, // 👈 pass row.id as unitId
|
|
||||||
);
|
|
||||||
|
|
||||||
const configItem = configurations[0];
|
|
||||||
const isCanReceiveRecord = configItem?.canReceiveRecord ?? false;
|
|
||||||
const isCanAssignRecord = configItem?.canAssignRecord ?? false;
|
|
||||||
const isCanCreateBankRecord = configItem?.canCreateBankRecord ?? false;
|
|
||||||
|
|
||||||
const invalidateConfig = () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["positionTypeConfigurations", row.id],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["positionTypeConfiguration", row.id],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create position type options from globalPositionTypes - only those WITHOUT unitId
|
// Create position type options from globalPositionTypes - only those WITHOUT unitId
|
||||||
const positionTypeOptions =
|
const positionTypeOptions =
|
||||||
@@ -129,97 +106,18 @@ const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
|||||||
setShowEditDialog(true);
|
setShowEditDialog(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Goes through the mutation rather than the service directly, so the cache is
|
||||||
|
// invalidated and IAM's 403 for built-in types reaches the user.
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
setIsDeleting(true);
|
await deletePositionType.mutateAsync(row.id);
|
||||||
await positionTypeService.delete(row.id);
|
|
||||||
toast.success(t("common.DeletedSuccessfully"));
|
|
||||||
setShowDeleteDialog(false);
|
setShowDeleteDialog(false);
|
||||||
if (onDelete) {
|
await onDelete?.();
|
||||||
await onDelete();
|
} catch {
|
||||||
}
|
// reported by the mutation's onError
|
||||||
} catch (error) {
|
|
||||||
handleError(error);
|
|
||||||
toast.error(t("common.FailedToDelete"));
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleChange = async (checked: boolean) => {
|
|
||||||
if (isGlobal) return;
|
|
||||||
try {
|
|
||||||
if (configItem?.id) {
|
|
||||||
await updateConfiguration({
|
|
||||||
id: configItem.id,
|
|
||||||
payload: {
|
|
||||||
organizationId: configItem.organizationId,
|
|
||||||
positionTypeId: configItem.positionTypeId,
|
|
||||||
timeframe: configItem.timeframe,
|
|
||||||
canReceiveRecord: checked,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await onToggle?.(row.id, checked, "canReceiveRecord");
|
|
||||||
}
|
|
||||||
toast.success(t("incomingRecord.UpdatedSuccessfully"));
|
|
||||||
invalidateConfig();
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error);
|
|
||||||
toast.error(t("incomingRecord.FailedToUpdate"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAssignToggleChange = async (checked: boolean) => {
|
|
||||||
if (isGlobal) return;
|
|
||||||
try {
|
|
||||||
if (configItem?.id) {
|
|
||||||
await updateConfiguration({
|
|
||||||
id: configItem.id,
|
|
||||||
payload: {
|
|
||||||
organizationId: configItem.organizationId,
|
|
||||||
positionTypeId: configItem.positionTypeId,
|
|
||||||
timeframe: configItem.timeframe,
|
|
||||||
canAssignRecord: checked,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await onToggle?.(row.id, checked, "canAssignRecord");
|
|
||||||
}
|
|
||||||
toast.success(t("incomingRecord.UpdatedSuccessfully"));
|
|
||||||
invalidateConfig();
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error);
|
|
||||||
toast.error(t("incomingRecord.FailedToUpdate"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateBankRecordToggleChange = async (checked: boolean) => {
|
|
||||||
if (isGlobal) return;
|
|
||||||
try {
|
|
||||||
if (configItem?.id) {
|
|
||||||
await updateConfiguration({
|
|
||||||
id: configItem.id,
|
|
||||||
payload: {
|
|
||||||
organizationId: configItem.organizationId,
|
|
||||||
positionTypeId: configItem.positionTypeId,
|
|
||||||
timeframe: configItem.timeframe,
|
|
||||||
canCreateBankRecord: checked,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await onToggle?.(row.id, checked, "canCreateBankRecord");
|
|
||||||
}
|
|
||||||
toast.success(t("incomingRecord.UpdatedSuccessfully"));
|
|
||||||
invalidateConfig();
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error);
|
|
||||||
toast.error(t("incomingRecord.FailedToUpdate"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const rowName = "name" in row ? row.name : { am: "", en: "" };
|
|
||||||
const isPositionType = "name" in row && "key" in row;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||||
@@ -238,7 +136,7 @@ const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
|||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<DropdownMenuLabel>{t("userRecord.Actions")}</DropdownMenuLabel>
|
||||||
|
|
||||||
{canBeModified && (
|
{canBeModified && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -249,7 +147,7 @@ const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canBeModified && isPositionType && (
|
{canBeModified && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={handleEdit}
|
onSelect={handleEdit}
|
||||||
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
|
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
|
||||||
@@ -276,50 +174,6 @@ const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
|||||||
<span>{t("common.Delete")}</span>
|
<span>{t("common.Delete")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Toggle moved here from ToggleCell */}
|
|
||||||
{!isGlobal && (
|
|
||||||
<div className="px-2 py-2 border-t mt-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm">
|
|
||||||
{t("contentManagement.CanReceiveRecord")}
|
|
||||||
</span>
|
|
||||||
<Switch
|
|
||||||
checked={isCanReceiveRecord}
|
|
||||||
disabled={isLoadingConfigurations || isUpdatingConfiguration}
|
|
||||||
onCheckedChange={handleToggleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isGlobal && (
|
|
||||||
<div className="px-2 py-2 border-t mt-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm">
|
|
||||||
{t("contentManagement.CanAssignRecord")}
|
|
||||||
</span>
|
|
||||||
<Switch
|
|
||||||
checked={isCanAssignRecord}
|
|
||||||
disabled={isLoadingConfigurations || isUpdatingConfiguration}
|
|
||||||
onCheckedChange={handleAssignToggleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isGlobal && (
|
|
||||||
<div className="px-2 py-2 border-t mt-1">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<span className="text-sm">
|
|
||||||
{t("contentManagement.CanCreateBankRecord")}
|
|
||||||
</span>
|
|
||||||
<Switch
|
|
||||||
checked={isCanCreateBankRecord}
|
|
||||||
disabled={isLoadingConfigurations || isUpdatingConfiguration}
|
|
||||||
onCheckedChange={handleCreateBankRecordToggleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
@@ -330,7 +184,7 @@ const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
|||||||
setShowMigrateDialog(false);
|
setShowMigrateDialog(false);
|
||||||
}}
|
}}
|
||||||
toId={row.id}
|
toId={row.id}
|
||||||
toName={localizedName(rowName)}
|
toName={localizedName(row.name)}
|
||||||
positionTypeOptions={positionTypeOptions}
|
positionTypeOptions={positionTypeOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -341,7 +195,7 @@ const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
|||||||
<DialogTitle>{t("common.Edit")}</DialogTitle>
|
<DialogTitle>{t("common.Edit")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex-1 overflow-y-auto pr-2 min-h-0">
|
<div className="flex-1 overflow-y-auto pr-2 min-h-0">
|
||||||
{isPositionType && showEditDialog && (
|
{showEditDialog && (
|
||||||
<CreatePositionForm
|
<CreatePositionForm
|
||||||
key={row.id + "-edit"}
|
key={row.id + "-edit"}
|
||||||
mode="edit"
|
mode="edit"
|
||||||
@@ -349,7 +203,7 @@ const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
|||||||
initialValues={{
|
initialValues={{
|
||||||
nameAm: row.name.am,
|
nameAm: row.name.am,
|
||||||
nameEn: row.name.en,
|
nameEn: row.name.en,
|
||||||
unitId: row.unitId,
|
unitId: row.unitId ?? "",
|
||||||
key: row.key,
|
key: row.key,
|
||||||
}}
|
}}
|
||||||
onSuccess={async () => {
|
onSuccess={async () => {
|
||||||
@@ -371,17 +225,21 @@ const PositionTypeActionsCell: React.FC<ActionsCellProps> = ({
|
|||||||
<AlertDialogTitle>{t("common.ConfirmDelete")}</AlertDialogTitle>
|
<AlertDialogTitle>{t("common.ConfirmDelete")}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
{t("common.DeleteConfirmationMessage", {
|
{t("common.DeleteConfirmationMessage", {
|
||||||
defaultValue: `Are you sure you want to delete "${localizedName(rowName)}"? This action cannot be undone.`,
|
name: localizedName(row.name),
|
||||||
})}
|
})}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>{t("common.Cancel")}</AlertDialogCancel>
|
<AlertDialogCancel disabled={deletePositionType.isPending}>
|
||||||
|
{t("common.Cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={isDeleting}
|
disabled={deletePositionType.isPending}
|
||||||
className="bg-red-500 hover:bg-red-600">
|
className="bg-red-500 hover:bg-red-600">
|
||||||
{isDeleting ? t("common.Deleting") : t("common.Delete")}
|
{deletePositionType.isPending
|
||||||
|
? t("common.Deleting")
|
||||||
|
: t("common.Delete")}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -16,16 +16,9 @@ const NameCell = ({ name }: { name: PositionTypeDto["name"] }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createPositionTypeColumns = (
|
export const createPositionTypeColumns = (
|
||||||
_positionTypeResponse?: PositionTypeResponse,
|
|
||||||
globalPositionTypes?: PositionTypeResponse,
|
globalPositionTypes?: PositionTypeResponse,
|
||||||
onDelete?: () => void | Promise<void>,
|
onDelete?: () => void | Promise<void>,
|
||||||
onEdit?: () => void | Promise<void>,
|
onEdit?: () => void | Promise<void>,
|
||||||
onToggle?: (
|
|
||||||
positionTypeId: string,
|
|
||||||
checked: boolean,
|
|
||||||
field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord",
|
|
||||||
) => void | Promise<void>,
|
|
||||||
isGlobal?: boolean,
|
|
||||||
): ColumnDef<PositionTypeDto>[] => [
|
): ColumnDef<PositionTypeDto>[] => [
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
@@ -64,8 +57,6 @@ export const createPositionTypeColumns = (
|
|||||||
globalPositionTypes={globalPositionTypes}
|
globalPositionTypes={globalPositionTypes}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onToggle={onToggle}
|
|
||||||
isGlobal={isGlobal}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ export interface PositionTypeDto {
|
|||||||
en: string;
|
en: string;
|
||||||
};
|
};
|
||||||
key: string;
|
key: string;
|
||||||
unitId: string;
|
/**
|
||||||
canReceiveRecord: boolean;
|
* Null for the built-in ("common") types, which `isSystem` marks and which
|
||||||
canCreateBankRecord?: boolean;
|
* every unit can use. IAM has no organizationId on a position type — the
|
||||||
canAssignRecord: boolean;
|
* owning organization is only reachable via unit -> organizationId.
|
||||||
|
*/
|
||||||
|
unitId: string | null;
|
||||||
|
/** Built-in type. IAM rejects update/delete on these with a 403. */
|
||||||
|
isSystem?: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import {
|
||||||
|
QueryClient,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
CreatePositionTypePayload,
|
CreatePositionTypePayload,
|
||||||
PositionRequest,
|
PositionRequest,
|
||||||
@@ -23,10 +28,22 @@ interface positionParams {
|
|||||||
interface UsePositionTypeManagerProps {
|
interface UsePositionTypeManagerProps {
|
||||||
id?: string;
|
id?: string;
|
||||||
unitId?: string;
|
unitId?: string;
|
||||||
organizationId?: string;
|
|
||||||
params?: positionParams; // 👈 we expected query params to be passed like this
|
params?: positionParams; // 👈 we expected query params to be passed like this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every cache key this hook writes under. React Query matches key prefixes
|
||||||
|
* element by element, so `["position-type"]` does NOT reach
|
||||||
|
* `["position-types-common", ...]` — each root has to be listed. Anything that
|
||||||
|
* mutates a position type should call this rather than hand-picking keys, or
|
||||||
|
* the department pickers (which read the "-common" queries) go stale.
|
||||||
|
*/
|
||||||
|
export const invalidatePositionTypeQueries = (queryClient: QueryClient) => {
|
||||||
|
["position-types", "position-type", "position-types-common"].forEach(
|
||||||
|
(root) => queryClient.invalidateQueries({ queryKey: [root] }),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const usePositionTypes = ({
|
export const usePositionTypes = ({
|
||||||
id,
|
id,
|
||||||
params = {
|
params = {
|
||||||
@@ -35,11 +52,11 @@ export const usePositionTypes = ({
|
|||||||
orderBy: "createdAt:Desc",
|
orderBy: "createdAt:Desc",
|
||||||
},
|
},
|
||||||
unitId,
|
unitId,
|
||||||
organizationId,
|
|
||||||
}: UsePositionTypeManagerProps = {}) => {
|
}: UsePositionTypeManagerProps = {}) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { handleError } = useErrorHandler(t);
|
const { handleError } = useErrorHandler(t);
|
||||||
|
const invalidateAll = () => invalidatePositionTypeQueries(queryClient);
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
queryKey: ["position-types", params],
|
queryKey: ["position-types", params],
|
||||||
queryFn: () => positionTypeService.getAll(params).then((res) => res.data),
|
queryFn: () => positionTypeService.getAll(params).then((res) => res.data),
|
||||||
@@ -70,38 +87,6 @@ export const usePositionTypes = ({
|
|||||||
enabled: !!unitId,
|
enabled: !!unitId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Position types by organization ID
|
|
||||||
const {
|
|
||||||
data: positionTypeByOrgId,
|
|
||||||
isLoading: isLoadingOrgPosition,
|
|
||||||
isError: isErrorOrgPosition,
|
|
||||||
refetch: refetchOrgPosition,
|
|
||||||
} = useQuery<PositionTypesListResponse | undefined>({
|
|
||||||
queryKey: ["position-type-org", organizationId, params],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!organizationId) return undefined;
|
|
||||||
const res = await positionTypeService.getByOrganizationId(organizationId, params);
|
|
||||||
return res.data as PositionTypesListResponse | undefined;
|
|
||||||
},
|
|
||||||
enabled: !!organizationId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Common types with organization ID (includes both org-specific and common types)
|
|
||||||
const {
|
|
||||||
data: commonPositionTypesByOrgId,
|
|
||||||
isLoading: isLoadingCommonOrgTypes,
|
|
||||||
isError: isErrorCommonOrgTypes,
|
|
||||||
refetch: refetchCommonOrgTypes,
|
|
||||||
} = useQuery<PositionTypesListResponse | undefined>({
|
|
||||||
queryKey: ["position-types-common-org", organizationId, params],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!organizationId) return undefined;
|
|
||||||
const res = await positionTypeService.getCommonTypesByOrganizationId(organizationId, params);
|
|
||||||
return res.data as PositionTypesListResponse | undefined;
|
|
||||||
},
|
|
||||||
enabled: !!organizationId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Common types with unit ID (includes both unit-specific and common types)
|
// Common types with unit ID (includes both unit-specific and common types)
|
||||||
const {
|
const {
|
||||||
data: commonPositionTypes,
|
data: commonPositionTypes,
|
||||||
@@ -123,15 +108,16 @@ export const usePositionTypes = ({
|
|||||||
mutationFn: (payload: CreatePositionTypePayload) =>
|
mutationFn: (payload: CreatePositionTypePayload) =>
|
||||||
positionTypeService.create(payload),
|
positionTypeService.create(payload),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Position type created");
|
toast.success(t("contentManagement.positionTypeCreated"));
|
||||||
queryClient.invalidateQueries({ queryKey: ["position-types"] });
|
invalidateAll();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
handleError(error);
|
handleError(error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update
|
// Update. IAM answers 403 `position_type_not_allowed_to_update` for built-in
|
||||||
|
// (isSystem) types, so the error has to reach the user.
|
||||||
const updatePositionType = useMutation({
|
const updatePositionType = useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
id,
|
id,
|
||||||
@@ -141,11 +127,12 @@ export const usePositionTypes = ({
|
|||||||
data: UpdatePositionTypePayload;
|
data: UpdatePositionTypePayload;
|
||||||
}) => positionTypeService.update(id, data),
|
}) => positionTypeService.update(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Position type updated");
|
toast.success(t("contentManagement.positionTypeUpdated"));
|
||||||
queryClient.invalidateQueries({ queryKey: ["position-types"] });
|
invalidateAll();
|
||||||
queryClient.invalidateQueries({ queryKey: ["position-type", id] });
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
handleError(error);
|
||||||
},
|
},
|
||||||
onError: () => {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
//update positon from to
|
//update positon from to
|
||||||
@@ -153,30 +140,32 @@ export const usePositionTypes = ({
|
|||||||
mutationFn: ({ toId, fromId }: { toId: string; fromId: string }) =>
|
mutationFn: ({ toId, fromId }: { toId: string; fromId: string }) =>
|
||||||
positionTypeService.updateFromto(toId, fromId),
|
positionTypeService.updateFromto(toId, fromId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Position type migration updated");
|
toast.success(t("contentManagement.positionTypeMigrated"));
|
||||||
queryClient.invalidateQueries({ queryKey: ["position-types-to"] });
|
invalidateAll();
|
||||||
queryClient.invalidateQueries({ queryKey: ["position-type", id] });
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
handleError(error);
|
||||||
},
|
},
|
||||||
onError: () => {},
|
|
||||||
});
|
});
|
||||||
//update all postions
|
//update all postions
|
||||||
const migratePositionsByPositions = useMutation({
|
const migratePositionsByPositions = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: string; data: PositionRequest }) =>
|
mutationFn: ({ id, data }: { id: string; data: PositionRequest }) =>
|
||||||
positionTypeService.updateByPostion(id, data),
|
positionTypeService.updateByPostion(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Position type migration updated");
|
toast.success(t("contentManagement.positionTypeMigrated"));
|
||||||
queryClient.invalidateQueries({ queryKey: ["position-types-migration"] });
|
invalidateAll();
|
||||||
queryClient.invalidateQueries({ queryKey: ["position-type", id] });
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
handleError(error);
|
||||||
},
|
},
|
||||||
onError: () => {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete
|
// Delete. Also 403s for built-in types.
|
||||||
const deletePositionType = useMutation({
|
const deletePositionType = useMutation({
|
||||||
mutationFn: (id: string) => positionTypeService.delete(id),
|
mutationFn: (id: string) => positionTypeService.delete(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Position type deleted");
|
toast.success(t("contentManagement.positionTypeDeleted"));
|
||||||
queryClient.invalidateQueries({ queryKey: ["position-types"] });
|
invalidateAll();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
handleError(error);
|
handleError(error);
|
||||||
@@ -205,16 +194,6 @@ export const usePositionTypes = ({
|
|||||||
refetchPosition,
|
refetchPosition,
|
||||||
isErrorPosition,
|
isErrorPosition,
|
||||||
isLoadingPosition,
|
isLoadingPosition,
|
||||||
// organization-based position types
|
|
||||||
positionTypeByOrgId,
|
|
||||||
refetchOrgPosition,
|
|
||||||
isErrorOrgPosition,
|
|
||||||
isLoadingOrgPosition,
|
|
||||||
// common types with organization ID
|
|
||||||
commonPositionTypesByOrgId,
|
|
||||||
refetchCommonOrgTypes,
|
|
||||||
isErrorCommonOrgTypes,
|
|
||||||
isLoadingCommonOrgTypes,
|
|
||||||
// common types with unit ID
|
// common types with unit ID
|
||||||
commonPositionTypes: commonPositionTypes?.items ?? [],
|
commonPositionTypes: commonPositionTypes?.items ?? [],
|
||||||
isLoadingCommonTypes,
|
isLoadingCommonTypes,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface PositionPayload {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
parentPositionId?: string;
|
parentPositionId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
positionTypeId: string;
|
positionTypeId?: string;
|
||||||
}
|
}
|
||||||
export interface PositionQueryParams {
|
export interface PositionQueryParams {
|
||||||
orderBy?: string;
|
orderBy?: string;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user