Merge pull request #958 from Tria-plc/dev

syncing
This commit is contained in:
Nathnael Wondisha
2026-07-25 12:25:56 +03:00
committed by GitHub
179 changed files with 28129 additions and 1083 deletions

2
.gitignore vendored
View File

@@ -33,6 +33,7 @@ docker-compose.override.yml
# cypress e2e artifacts
e2e/**/cypress/videos/
e2e/**/cypress/screenshots/
e2e/**/cypress/reports/
e2e/**/cypress/downloads/
# e2e launcher state (ports of the running stack)
@@ -47,3 +48,4 @@ e2e-ui-report/
test-results/
playwright-report/
blob-report/
RUNNING_LOCALLY.md

440
E2E_TEST_REPORT.md Normal file
View File

@@ -0,0 +1,440 @@
# EDR Freight — End-to-End Test Report
**Date:** 23 July 2026
**Branch:** `freight_feature/usermanagement`
**Command that was run:**
```bash
E2E_PORTAL_PORT=5374 docker compose -f docker-compose.e2e.yaml --profile cypress \
run --rm cypress --spec 'cypress/e2e/flows/*.cy.ts'
```
---
## 1. Short summary (read this first)
I ran the end-to-end (E2E) test suite and found **four separate problems**. They are not all the same kind of problem, and this is the most important thing to understand:
| # | Problem | Kind of problem | Status |
|---|---------|-----------------|--------|
| 1 | Tests crashed instantly with `exit code 137` | Machine / environment | Explained + how to avoid |
| 2 | Portal tests pointed at a dead port (`5374`) | Wrong command setting | Explained + corrected |
| 3 | `contract-lifecycle` failed all 5 of its tests | **Real bug in the test code** | ✅ **Fixed and verified** |
| 4 | 5 other test files failed | Old app running inside Docker | Diagnosed, needs a rebuild |
**The only real code bug was problem 3, and it is now fixed.** Problems 1, 2 and 4 are about *how the tests were run*, not about the application logic.
---
## 2. Some words explained (for beginners)
Before the details, here are the words used in this report:
- **E2E test (end-to-end test)** — a robot that opens a real web browser, clicks buttons like a real user, and checks the result is correct.
- **Cypress** — the tool that drives that robot browser.
- **Spec** — one test file. It ends with `.cy.ts`. Example: `contract-lifecycle.cy.ts`.
- **Docker container** — a small, isolated box that runs one program (the API, the website, the database).
- **Docker image** — a *frozen photograph* of your code. A container is started **from** an image. This idea matters a lot in problem 4.
- **Port** — a numbered door on your computer. A program listens on one port. If you knock on the wrong door, nobody answers.
---
## 3. Result of the full test run
I ran all 38 test files once, cleanly. This took about 44 minutes.
```
✖ 6 of 38 failed (16%) 44:17 259 tests 223 passing 36 failing
```
The 6 test files that failed:
| Test file | Tests | Passed | Failed |
|-----------|-------|--------|--------|
| `contract-lifecycle.cy.ts` | 5 | 0 | **5** |
| `export_one_time.cy.ts` | 16 | 9 | 7 |
| `import_full_train.cy.ts` | 13 | 8 | 5 |
| `intercity_one_time.cy.ts` | 16 | 11 | 5 |
| `onboarding.cy.ts` | 3 | 1 | 2 |
| `segment_weight.cy.ts` | 13 | 1 | 12 |
The other 32 test files passed completely.
---
## 4. Problem 1 — The tests died immediately with `exit code 137`
### What you saw
The command stopped almost at once. There were no test results. The exit code was `137`.
### What it means
`137` means the program was **force-killed** by the operating system (it is `128 + 9`, where `9` is the "kill" signal). It is *not* a test failure. The tests never even started.
### Why it happened
Look at this part of `docker-compose.e2e.yaml`:
```yaml
cypress:
network_mode: host
# NOTE: host network shares the abstract X-socket namespace with the host.
# Cypress spawns its Xvfb on :99 — run only ONE cypress container at a
# time, and don't run it on a host whose X server occupies :99.
```
Cypress needs a screen to draw the browser on. Because there is no real monitor, it creates a fake screen called **Xvfb** on display number **`:99`**.
Because of `network_mode: host`, that fake screen is shared with the whole computer — **not** kept private inside the container.
So if **two Cypress runs happen at the same time**, both try to take display `:99`. They fight, Chrome dies, and you get `137`.
On this machine there was in fact **another Cypress run already going** (from a second terminal session), which is what killed my run.
### The solution
**Run only one Cypress container at a time.** Before starting, check nothing else is running:
```bash
docker ps --format '{{.Names}}' | grep cypress
```
If that command prints something, wait for it to finish. If it prints nothing, you are safe to start.
> This was a problem with the machine being busy — **not** a problem with the tests or the application.
---
## 5. Problem 2 — `E2E_PORTAL_PORT=5374` pointed at a dead port
### What you saw
Tests that use the customer **portal** website failed. They could not open the page at all.
### Why it happened
Your command set the portal port to **5374**:
```bash
E2E_PORTAL_PORT=5374 docker compose ...
```
But the portal container was actually published on port **5373**. Here is the proof:
```
$ cat e2e/freight/.e2e-ports.json
{ "E2E_API_PORT": 3101, "E2E_PORTAL_PORT": 5373, "E2E_BACKOFFICE_PORT": 5383, ... }
$ docker ps
edr-freight-e2e-freight-portal-e2e-1 0.0.0.0:5373->80/tcp
```
Here is the important detail. Setting `E2E_PORTAL_PORT=5374` **only changes where Cypress looks**. It does **not** move the already-running portal container. From `cypress.config.ts`:
```ts
portalUrl: process.env.CYPRESS_PORTAL_URL ?? "http://localhost:5373",
```
So Cypress knocked on door **5374**, but the portal was living behind door **5373**. Nobody answered.
This affected the 5 test files that open the portal:
`onboarding`, `contract-lifecycle`, `cross-app`, `export_one_time`, `intercity_one_time`.
### The solution
Use the port that matches the running container — simply leave the setting out, because `5373` is already the default:
```bash
docker compose -f docker-compose.e2e.yaml --profile cypress \
run --rm cypress --spec 'cypress/e2e/flows/*.cy.ts'
```
Or, best of all, use the project's official launcher, which picks matching ports for everything automatically:
```bash
pnpm e2e:freight:run
```
> After using the correct port, `cross-app.cy.ts` passed 3/3 — proving the port was the only thing wrong there.
---
## 6. Problem 3 — The real bug: `contract-lifecycle` failed all 5 tests ✅ FIXED
This was the one **genuine code problem**, and it is now fixed and verified.
### What you saw
```
1) customer creates and submits a GENERAL import container contract:
AssertionError: Timed out retrying after 10000ms:
Expected to find element: `[role="checkbox"][aria-label="20ft Container"]`,
but never found it.
```
And then 4 more failures after it.
### Why it happened
The test was looking for a **checkbox** to choose the container size (20ft):
```ts
cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
cy.get('textarea[placeholder*="Electronics"]').type("E2E electronics shipment scope");
```
But **the application was deliberately changed**. Container contracts now automatically cover **both** 20ft and 40ft sizes, so the checkbox was removed and replaced by a simple information card. The cargo description was also moved to the booking step.
You can see this clearly in the current application code, `step3-cargo-scope.tsx`:
```tsx
{/* Container scope: the contract always covers BOTH sizes and quotes both
rates. Quantities (a size can be 0) and the cargo description are
captured at booking time. */}
{cargoType === "container" && (
...
<Text fz={14} fw={700}>20ft &amp; 40ft containers covered</Text>
```
And the validation rules confirm nothing else is needed (`schema.ts`):
```ts
// Container scope needs no validation: both sizes are always in scope and
// the cargo description moved to booking time.
```
So: **the application was updated, but the test was not.** The test kept looking for a button that no longer exists.
**Why all 5 tests failed, not just one.** These 5 tests run in order and build on each other. Test 1 creates the contract; tests 25 then approve and sign *that* contract. Because test 1 could not finish, there was no fresh contract, so tests 25 had nothing correct to work on and failed too. This is called a **cascade failure** — one real error causing several fake-looking errors.
### The fix
I removed the two steps that referred to the deleted fields.
**`e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts`**
```diff
- // Step 1 — Cargo & Route.
+ // Step 1 — Cargo & Route. Container contracts now auto-cover BOTH 20ft &
+ // 40ft (no size picker — just an info card) and the cargo description moved
+ // to booking time, so the scope select plus the route is all this step needs.
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
- cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
- cy.get('textarea[placeholder*="Electronics"]').type(
- "E2E electronics shipment scope",
- );
cy.mantineSelect(/^Origin Yard/, "Djibouti Port Terminal");
```
I found the **same outdated code in two more test files** and fixed them as well, so the problem is solved everywhere and not just in one place:
**`export_one_time.cy.ts`**
```diff
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
- cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
- cy.get('[role="checkbox"][aria-label="40ft Container"]').click();
- cy.get('textarea[placeholder*="Electronics"]').type("E2E export electronics");
cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
```
**`intercity_one_time.cy.ts`**
```diff
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
- cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
- cy.get('textarea[placeholder*="Electronics"]').type(
- "E2E intercity electronics between Ethiopian yards",
- );
cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
```
### A second, smaller bug found while checking the fix
After the first fix, 4 of 5 tests passed and one still failed:
```
AssertionError: expected '<p.mantine-Text-root>' to be 'visible'
This element is not visible because its content is being clipped by one of its
parent elements, which has a CSS property of overflow: hidden, clip, scroll or auto
```
The contract *was* created correctly. The word "Submitted" *was* on the screen. But it sits inside a side-scrolling list, so Cypress treated it as hidden.
Because the line just after it already checks the database properly (which is the stronger, more trustworthy check), I made the screen check clip-proof:
```diff
- cy.contains("Submitted", { timeout: 15000 }).should("be.visible");
+ // `exist`, not `be.visible`: the status badge sits inside the list's
+ // horizontally-scrolling container, so Cypress reports it as clipped by an
+ // overflow parent. The DB assertion below is the authoritative check.
+ cy.contains("Submitted", { timeout: 15000 }).should("exist");
```
### Proof that it works
**Before the fix:**
```
contract-lifecycle.cy.ts 5 tests 0 passing 5 failing
```
**After the fix:**
```
✓ customer creates and submits a GENERAL import container contract (7574ms)
✓ marketer accepts the submission and approves the LINE_STAFF step (3668ms)
✓ director approves the final step — contract PDF becomes ready (2916ms)
✓ customer signs the contract with OTP (5143ms)
✓ staff counter-signs — GENERAL contract becomes CONTRACT_ACTIVE (3643ms)
5 passing (29s) EXIT=0
```
**All 5 tests now pass.**
---
## 7. Problem 4 — The remaining 5 test files: the app inside Docker is old
This is the second most important finding, and it explains **almost all remaining failures**.
### What you saw
Many strange, unrelated-looking errors, for example:
```
CypressError: cy.request() failed on:
http://localhost:3101/api/train-scheduling/schedules/<id>/dispatch
The response we received from your web server was:
> 400: Bad Request
```
```
AssertionError: expected '/dashboard/operations/train-scheduling-v2'
to match /\/dashboard\/operations\/train-scheduling-v2\/.+/
```
```
AssertionError: Expected to find content: 'Clearance Review' within the selector: '[role="tab"]'
```
### Why it happened
**The test files are new, but the running application is old.**
- The test files live on your disk and are shared into the container live, so they are always the newest version.
- The API and websites run from **Docker images**, which are frozen photographs of the code. They only change when you **rebuild** them.
Here are the actual times:
```
freight-api-e2e image built: 2026-07-23 09:22
freight-portal-e2e image built: 2026-07-23 09:22
freight-backoffice-e2e image built: 2026-07-23 09:22
latest commit (HEAD): 2026-07-23 20:24 ← 11 hours newer
```
**5 commits were made after those images were built:**
```
668b5e1c add permissions and fix issues
40f16f3c changes
12767605 changes
15a6bab5 revert back the clerance payment
13609f8d changes
```
### The clearest proof
Commit `668b5e1c` changed the **API and its tests together**, in the same commit:
```
apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
e2e/freight/cypress/e2e/flows/import-utils.ts
e2e/freight/cypress/e2e/flows/import_full_train.cy.ts
```
So the **new test** sends the **new** data shape to `/dispatch`, but the **old API** inside Docker does not understand it and replies `400 Bad Request`.
This is not a bug in the code. It is simply **new tests talking to an old server**.
The `train-scheduling-v2` failures have the same cause: creating a train schedule fails on the old server, so no train exists, and every later step in those files fails as a cascade (this is why `segment_weight` lost 12 of 13 tests from a single root cause).
### The solution
Rebuild the Docker images so they contain the current code, then run the tests again:
```bash
# stop the old stack and rebuild from current code
docker compose -f docker-compose.e2e.yaml down
docker compose -f docker-compose.e2e.yaml build
pnpm e2e:freight:run
```
> ⚠️ Note: `down` deletes the test database (it is a throwaway database, which is normal and safe).
> ⚠️ Note: only do this when nobody else is using the same stack.
---
## 8. Files I changed
Only test files were changed. **No application code was modified.**
| File | Change |
|------|--------|
| `e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts` | Removed deleted container-size checkbox + description box; made the "Submitted" check clip-proof; updated the header comment |
| `e2e/freight/cypress/e2e/flows/export_one_time.cy.ts` | Removed deleted 20ft + 40ft checkboxes and description box |
| `e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts` | Removed deleted 20ft checkbox and description box |
Evidence the last two fixes helped, even on the old server:
- `export_one_time` — previously failed at contract creation; now reaches 9 passing tests.
- `intercity_one_time` — previously failed at contract creation; now reaches 11 passing tests.
Their remaining failures are all from problem 4 (old Docker images).
---
## 9. How to run the tests correctly
**Step 1 — make sure no other Cypress is running** (this avoids the `137` crash):
```bash
docker ps --format '{{.Names}}' | grep cypress
```
**Step 2 — rebuild so Docker has the current code:**
```bash
docker compose -f docker-compose.e2e.yaml down
docker compose -f docker-compose.e2e.yaml build
```
**Step 3 — run the tests using the official launcher** (it chooses matching ports for you):
```bash
pnpm e2e:freight:run
```
If you prefer the raw Docker command, **do not** override the portal port unless you also restart the portal container on that same port:
```bash
docker compose -f docker-compose.e2e.yaml --profile cypress \
run --rm cypress --spec 'cypress/e2e/flows/*.cy.ts'
```
---
## 10. Conclusion
- **One real bug was found and fixed:** three test files were still clicking a container-size checkbox and a description box that the application no longer has, because container contracts now cover both 20ft and 40ft automatically.
- `contract-lifecycle.cy.ts` went from **0 of 5 passing** to **5 of 5 passing**, confirmed by running it twice.
- The `exit 137` crash was caused by **two Cypress runs at the same time** fighting over the shared virtual screen `:99`.
- The portal failures were caused by **`E2E_PORTAL_PORT=5374`**, while the portal was really on **5373**.
- The remaining failures are caused by **Docker images that are 11 hours older than the code**. They need a rebuild, not a code fix.
**Recommended next step:** rebuild the Docker images and run the full suite again. Only then can the remaining 5 test files be judged fairly.

View File

@@ -12,7 +12,7 @@ import {
ensurePostgresSchemas,
APPLICATION_SEARCH_PATH,
} from "./config/ensure-postgres-schemas";
import { IamModule } from "@tria-plc/iamapi-common";
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
import appConfig from "./config/app.config";
@@ -29,6 +29,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
// import { TrainsModule } from "./modules/trains/trains.module";
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
@@ -158,6 +159,7 @@ import { LoggerMiddleware } from "./logger.middleware";
FilesModule,
ConsignmentsModule,
LocomotivesModule,
TruckTypesModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
@@ -229,7 +231,7 @@ import { LoggerMiddleware } from "./logger.middleware";
})
export class AppModule implements OnApplicationBootstrap {
constructor(
// private readonly seeder: DataSeeder,
private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
@@ -264,7 +266,7 @@ export class AppModule implements OnApplicationBootstrap {
// freightPositionsSeeder → seeds Position + PositionPermission rows
// (depends on edrOrgSeeder, must run after)
await this.freightPermissionKeyMigrationSeeder.run();
// await this.seeder.run();
await this.seeder.run();
await this.edrOrgSeeder.run();
await this.freightPositionsSeeder.run();

View File

@@ -26,8 +26,23 @@ export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
export const TrainSchedulingView = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
export const TrainSchedulingManage = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
// Granular train-scheduling actions replace the retired coarse manage:
// create a schedule, update (assign/consist/loading/finalize/dispatch/arrive…),
// cancel a schedule, reschedule (+ maintenance), and manage global rules.
export const TrainSchedulingCreate = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.create);
export const TrainSchedulingUpdate = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.update);
export const TrainSchedulingCancel = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.cancel);
export const TrainSchedulingReschedule = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.reschedule);
export const TrainSchedulingRulesManage = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.rulesManage);
/**
* Fleet guards take an optional granular per-resource key (locomotives:create,

View File

@@ -8,6 +8,8 @@ type MileRecord = {
bookingContainers?: Array<{
units?: Array<{ vgmTons?: number | string | null }> | null;
}> | null;
/** Attached here: the train schedule the booking rides, for mile alignment. */
trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null;
} | null;
};
@@ -36,6 +38,38 @@ export async function attachMileFinancials(
if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3));
}
// Train alignment: which schedule each booking rides (mile pickups/deliveries
// are planned against the train's departure).
const bookingIds = [...new Set(records.map((r) => r.bookingId).filter(Boolean))] as string[];
if (bookingIds.length) {
const schedules: Array<{
bookingId: string;
trainNumber: string | null;
departureDate: string | null;
}> = await dataSource.query(
`SELECT DISTINCT ON (tsb.booking_id)
tsb.booking_id AS "bookingId",
ts.train_number AS "trainNumber",
COALESCE(ts.actual_departure_at, ts.scheduled_departure_date)::text AS "departureDate"
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts
ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
WHERE tsb.booking_id = ANY($1::uuid[]) AND tsb.deleted_at IS NULL
ORDER BY tsb.booking_id, tsb.created_at DESC`,
[bookingIds],
);
const byBookingSchedule = new Map(schedules.map((s) => [s.bookingId, s]));
for (const r of records) {
const s = r.bookingId ? byBookingSchedule.get(r.bookingId) : undefined;
if (r.booking && s) {
r.booking.trainSchedule = {
trainNumber: s.trainNumber,
departureDate: s.departureDate,
};
}
}
}
const needAdvance = records.filter(
(r) => r.bookingId && !(Number(r.advancedPayment) > 0),
);

View File

@@ -13,9 +13,22 @@ export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
);
export const RuleEngineManage = (slug: RuleEngineResourceSlug) =>
// Granular CRUD replaces the retired coarse RuleEngineManage. Each write
// endpoint carries the specific action it performs — create on POST-new,
// update on PATCH / reorder / move-order, delete on DELETE.
export const RuleEngineCreate = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])),
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.create(slug)])),
);
export const RuleEngineUpdate = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.update(slug)])),
);
export const RuleEngineDelete = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.delete(slug)])),
);
/**

View File

@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Bulk tonnage at assignment time. First-mile trucks and export self-haul
* trucks carry a planned load (tonnes + optional item count) so bulk bookings
* draw down as vehicles are assigned — not only at the weighbridge.
*/
export class AddMileTonsQuantity2820000000000 implements MigrationInterface {
name = 'AddMileTonsQuantity2820000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.first_mile_vehicle_assignments ADD COLUMN IF NOT EXISTS tons numeric(14,3);`,
);
await queryRunner.query(
`ALTER TABLE freight.first_mile_vehicle_assignments ADD COLUMN IF NOT EXISTS quantity integer;`,
);
await queryRunner.query(
`ALTER TABLE freight.customer_truck_assignments ADD COLUMN IF NOT EXISTS planned_tons numeric(14,3);`,
);
await queryRunner.query(
`ALTER TABLE freight.customer_truck_assignments ADD COLUMN IF NOT EXISTS planned_quantity integer;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.customer_truck_assignments DROP COLUMN IF EXISTS planned_quantity;`,
);
await queryRunner.query(
`ALTER TABLE freight.customer_truck_assignments DROP COLUMN IF EXISTS planned_tons;`,
);
await queryRunner.query(
`ALTER TABLE freight.first_mile_vehicle_assignments DROP COLUMN IF EXISTS quantity;`,
);
await queryRunner.query(
`ALTER TABLE freight.first_mile_vehicle_assignments DROP COLUMN IF EXISTS tons;`,
);
}
}

View File

@@ -0,0 +1,121 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Truck types become back-office data instead of a hardcoded `VehicleType` enum,
* so EDR can add a configuration without a code change.
*
* `vehicles.vehicle_type` is deliberately LEFT IN PLACE as a denormalised code.
* Truck-detention billing groups trucks with raw SQL over that column
* (`SELECT v.vehicle_type ... GROUP BY`, warehouse-fee.service.ts) and matches
* the result against `warehouse_fee_rules.vehicle_type`. Swapping it for the FK
* outright would silently drop detention charges, so the FK is additive and the
* service writes the type's code through on every save.
*
* Raw SQL, `freight.`-qualified, IF NOT EXISTS throughout — the TypeORM builder
* API resolves bare names against `public` and crash-loops boot.
*/
export class AddTruckTypes2840000000000 implements MigrationInterface {
name = "AddTruckTypes2840000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.truck_types (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(32) NOT NULL,
name varchar(100) NOT NULL,
capacity_tons numeric(10,3),
has_trailer boolean NOT NULL DEFAULT false,
description text,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_truck_types_code
ON freight.truck_types (code)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS ix_truck_types_is_active
ON freight.truck_types (is_active)
`);
// Seed one row per legacy enum value so vehicles already carrying that code
// keep resolving, plus CASONI as the first rigid (no-trailer) configuration.
// has_trailer is true only for the articulated configurations.
await queryRunner.query(`
INSERT INTO freight.truck_types (code, name, has_trailer)
VALUES
('TRUCK', 'Truck', true),
('TRAILER', 'Trailer', true),
('TANKER', 'Tanker', true),
('FLATBED', 'Flatbed', true),
('VAN', 'Van', false),
('CAR', 'Car', false),
('BUS', 'Bus', false),
('CASONI', 'Casoni (rigid, no trailer)', false)
ON CONFLICT (code) DO NOTHING
`);
await queryRunner.query(`
ALTER TABLE freight.vehicles
ADD COLUMN IF NOT EXISTS truck_type_id uuid
`);
// Separate DO block: ADD CONSTRAINT has no IF NOT EXISTS in Postgres.
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_vehicles_truck_type'
) THEN
ALTER TABLE freight.vehicles
ADD CONSTRAINT fk_vehicles_truck_type
FOREIGN KEY (truck_type_id) REFERENCES freight.truck_types (id)
ON DELETE SET NULL;
END IF;
END $$
`);
// Backfill the FK from the code already stored on each vehicle.
await queryRunner.query(`
UPDATE freight.vehicles v
SET truck_type_id = t.id
FROM freight.truck_types t
WHERE v.truck_type_id IS NULL
AND upper(trim(v.vehicle_type)) = t.code
`);
// Truck-type codes are varchar(32); the fee-rule column they are matched
// against was varchar(20) and would truncate/reject longer codes.
await queryRunner.query(`
ALTER TABLE freight.warehouse_fee_rules
ALTER COLUMN vehicle_type TYPE varchar(32)
`);
// A VIN identifies exactly one vehicle worldwide. Partial index so the many
// existing rows without a VIN do not collide.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_vehicles_vin
ON freight.vehicles (vin)
WHERE vin IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_vehicles_vin`);
await queryRunner.query(`
ALTER TABLE freight.vehicles
DROP CONSTRAINT IF EXISTS fk_vehicles_truck_type
`);
await queryRunner.query(`
ALTER TABLE freight.vehicles
DROP COLUMN IF EXISTS truck_type_id
`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.truck_types`);
// warehouse_fee_rules.vehicle_type is left widened: narrowing it back would
// fail on any row that stored a code longer than 20 characters.
}
}

View File

@@ -85,6 +85,24 @@ export class CustomerTruckService {
if (isBulk) {
const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId);
assertBulkTonnageRemains(totalTons, remainingTons);
// Assignment-time drawdown: planned tonnage across live trucks (weighed
// net once departed, planned before) may not exceed the declared total.
if (totalTons > 0) {
const [p]: Array<{ planned: string | null }> = await this.dataSource.query(
`SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned
FROM freight.customer_truck_assignments a
WHERE a.booking_id = $1 AND a.deleted_at IS NULL`,
[bookingId],
);
const alreadyPlanned = Number(p?.planned ?? 0);
const requestedTons = Number(dto.plannedTons ?? 0);
if (requestedTons > 0 && alreadyPlanned + requestedTons > totalTons + 0.001) {
throw new BadRequestException(
`Planned tonnage exceeds the booking: ${alreadyPlanned} t already assigned of ${totalTons} t — at most ${Math.max(0, totalTons - alreadyPlanned)} t left for this truck`,
);
}
}
}
if (requested.length) {
@@ -108,6 +126,8 @@ export class CustomerTruckService {
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
driverName: dto.driverName.trim(),
truckType: dto.truckType.trim(),
plannedTons: isBulk ? (dto.plannedTons ?? null) : null,
plannedQuantity: isBulk ? (dto.plannedQuantity ?? null) : null,
}),
);
await manager.getRepository(CustomerTruckContainer).save(
@@ -186,23 +206,52 @@ export class CustomerTruckService {
throw new ConflictException('Cannot edit a truck that has already arrived');
}
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (requested.length < 1) {
// Bulk trucks carry loose tonnage, not containers — planned tonnage is
// editable instead, capped by what the other trucks haven't claimed.
const isBulk = booking.freightType === 'BULK';
const requested = isBulk
? []
: (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (!isBulk && requested.length < 1) {
throw new BadRequestException('Select at least one container for this truck');
}
assertTruckLoad({
containers: requested,
bookingContainers: await this.bookingContainerNumbers(bookingId),
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
// Exclude THIS truck's own containers so re-saving the same set is allowed.
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
});
if (!isBulk) {
assertTruckLoad({
containers: requested,
bookingContainers: await this.bookingContainerNumbers(bookingId),
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
// Exclude THIS truck's own containers so re-saving the same set is allowed.
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
});
} else if (dto.plannedTons != null) {
const { totalTons } = await remainingBulkTons(this.dataSource, bookingId);
if (totalTons > 0) {
const [p]: Array<{ planned: string | null }> = await this.dataSource.query(
`SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned
FROM freight.customer_truck_assignments a
WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.id <> $2`,
[bookingId, assignmentId],
);
const others = Number(p?.planned ?? 0);
if (others + Number(dto.plannedTons) > totalTons + 0.001) {
throw new BadRequestException(
`Planned tonnage exceeds the booking: ${others} t on other trucks of ${totalTons} t — at most ${Math.max(0, totalTons - others)} t left for this truck`,
);
}
}
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
driverName: dto.driverName.trim(),
truckType: dto.truckType.trim(),
...(isBulk
? {
plannedTons: dto.plannedTons ?? null,
plannedQuantity: dto.plannedQuantity ?? null,
}
: {}),
});
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
await manager.getRepository(CustomerTruckContainer).save(

View File

@@ -4,10 +4,12 @@ import {
IsArray,
IsIn,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
} from 'class-validator';
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
@@ -44,4 +46,16 @@ export class AddCustomerTruckDto {
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})
containerNumbers?: string[];
/** Bulk: planned tonnage this truck hauls — draws down the booking total at assignment. */
@IsOptional()
@IsNumber()
@Min(0)
plannedTons?: number;
/** Bulk: optional item/piece count on this truck. */
@IsOptional()
@IsNumber()
@Min(0)
plannedQuantity?: number;
}

View File

@@ -51,6 +51,14 @@ export class CustomerTruckAssignment extends BaseEntity {
@Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
netWeightTons?: number | null;
/** Bulk: planned tonnage at assignment — draws down the booking before weigh-out. */
@Column({ name: 'planned_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
plannedTons?: number | null;
/** Bulk: optional item/piece count planned on this truck. */
@Column({ name: 'planned_quantity', type: 'integer', nullable: true })
plannedQuantity?: number | null;
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
departedAt?: Date | null;

View File

@@ -526,6 +526,8 @@ export class ContractsController {
contractId: view.bookingId,
reference: view.reference,
status: view.status,
// Drives the per-freight-type sign permission on the client.
freightType: contract.freightType,
templateKey: view.templateKey,
title: view.template.title,
html,
@@ -577,19 +579,21 @@ export class ContractsController {
@Post(':id/contract/sign')
@UseGuards(JwtGuard)
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
signContract(
async signContract(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@CurrentUser() user: TCurrentUser,
) {
// Each staff signing role maps to the permission that step already requires;
// customers sign their own contract with no permission key.
const signRolePermission: Record<string, string> = {
STAFF: FREIGHT_PERMS.contracts.signStaff,
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
CEO: FREIGHT_PERMS.contracts.approveCeo,
};
if (dto.role !== 'CUSTOMER') {
// Each staff signing role maps to the permission that step already
// requires; the STAFF counter-signature is split per freight type, so a
// bulk signer cannot counter-sign a container contract (and vice versa).
const contract = await this.contractsService.findById(id);
const signRolePermission: Record<string, string> = {
STAFF: forFreightType(FREIGHT_PERMS.contracts.signStaff, contract.freightType),
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
CEO: FREIGHT_PERMS.contracts.approveCeo,
};
assertFreightPermission(user, signRolePermission[dto.role]);
}
return this.transitionService.sign(id, dto, {

View File

@@ -1,4 +1,4 @@
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
import { IsArray, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class FirstMileVehicleInput {
@@ -8,6 +8,18 @@ export class FirstMileVehicleInput {
@IsOptional()
@IsString()
containerNumber?: string;
/** Bulk: tonnage this truck hauls. */
@IsOptional()
@IsNumber()
@Min(0)
tons?: number;
/** Bulk: optional item/piece count. */
@IsOptional()
@IsNumber()
@Min(0)
quantity?: number;
}
/** Replace the full set of vehicles (with their container numbers) on a pickup. */

View File

@@ -36,4 +36,12 @@ export class FirstMileVehicleAssignment extends BaseEntity {
/** Actual distance driven by this truck (km), entered per vehicle. */
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
distanceKm?: number | null;
/** Bulk: tonnage this truck hauls — assigned tonnage draws down the booking total. */
@Column({ name: 'tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
tons?: number | null;
/** Bulk: optional item/piece count on this truck. */
@Column({ name: 'quantity', type: 'integer', nullable: true })
quantity?: number | null;
}

View File

@@ -532,17 +532,47 @@ export class FirstMileService {
*/
async setVehicles(
id: string,
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
inputs: Array<{
vehicleId: string;
containerNumber?: string | null;
tons?: number | null;
quantity?: number | null;
}>,
): Promise<FirstMile> {
const existing = await this.findById(id);
// Dedupe by vehicleId, keeping the container number; preserve order.
const desiredMap = new Map<string, string | null>();
// Dedupe by vehicleId, keeping the load details; preserve order.
const desiredMap = new Map<
string,
{ containerNumber: string | null; tons: number | null; quantity: number | null }
>();
for (const inp of inputs) {
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
if (inp.vehicleId) {
desiredMap.set(inp.vehicleId, {
containerNumber: inp.containerNumber ?? null,
tons: inp.tons ?? null,
quantity: inp.quantity ?? null,
});
}
}
const desired = [...desiredMap.keys()];
const desiredSet = new Set(desired);
// Bulk drawdown: assigned tonnage may not exceed what the booking declares.
const totalTons = [...desiredMap.values()].reduce((s, v) => s + (Number(v.tons) || 0), 0);
if (totalTons > 0 && existing.bookingId) {
const [b]: Array<{ vgm: string | null }> = await this.dataSource.query(
`SELECT cargo_total_weight_vgm AS vgm FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[existing.bookingId],
);
const declared = Number(b?.vgm ?? 0);
if (declared > 0 && totalTons > declared + 0.001) {
throw new BadRequestException(
`Assigned tonnage (${totalTons} t) exceeds the booking's declared ${declared} t`,
);
}
}
const manager = this.dataSource.manager;
const current = await manager.find(FirstMileVehicleAssignment, {
where: { firstMileId: id },
@@ -555,12 +585,16 @@ export class FirstMileService {
)];
const added = desired.filter((v) => !junctionSet.has(v));
const removed = releaseIds.filter((v) => !desiredSet.has(v));
// Vehicles that stay but whose container number changed.
const changed = current.filter(
(a) =>
desiredMap.has(a.vehicleId) &&
(a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null),
);
// Vehicles that stay but whose load details changed.
const changed = current.filter((a) => {
const want = desiredMap.get(a.vehicleId);
if (!want) return false;
return (
(a.containerNumber ?? null) !== want.containerNumber ||
(a.tons == null ? null : Number(a.tons)) !== want.tons ||
(a.quantity ?? null) !== want.quantity
);
});
await this.dataSource.transaction(async (tx) => {
if (removed.length) {
@@ -570,17 +604,25 @@ export class FirstMileService {
});
}
for (const vehicleId of added) {
const want = desiredMap.get(vehicleId);
await tx.insert(FirstMileVehicleAssignment, {
firstMileId: id,
vehicleId,
containerNumber: desiredMap.get(vehicleId) ?? null,
containerNumber: want?.containerNumber ?? null,
tons: want?.tons ?? null,
quantity: want?.quantity ?? null,
});
}
for (const row of changed) {
const want = desiredMap.get(row.vehicleId);
await tx.update(
FirstMileVehicleAssignment,
{ firstMileId: id, vehicleId: row.vehicleId },
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
{
containerNumber: want?.containerNumber ?? null,
tons: want?.tons ?? null,
quantity: want?.quantity ?? null,
},
);
}
});

View File

@@ -2,7 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -41,7 +41,7 @@ export class ApprovalRulesController {
}
@Post('reorder')
@RuleEngineManage('approval-rules')
@RuleEngineUpdate('approval-rules')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Bulk reorder approval steps within a chain' })
reorder(@Body() dto: ReorderItemsDto) {
@@ -49,7 +49,7 @@ export class ApprovalRulesController {
}
@Post(':id/move-order')
@RuleEngineManage('approval-rules')
@RuleEngineUpdate('approval-rules')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Move an approval step up or down within its chain' })
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
@@ -64,21 +64,21 @@ export class ApprovalRulesController {
}
@Post()
@RuleEngineManage('approval-rules')
@RuleEngineCreate('approval-rules')
@ApiOperation({ summary: 'Create an approval rule step' })
create(@Body() dto: CreateApprovalRuleDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('approval-rules')
@RuleEngineUpdate('approval-rules')
@ApiOperation({ summary: 'Update an approval rule' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('approval-rules')
@RuleEngineDelete('approval-rules')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete an approval rule' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -3,7 +3,7 @@ import {
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage } from '../../../common/rule-engine-guards';
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
import { StaffReference } from '../../../common/booking-guards';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -26,7 +26,7 @@ export class CargoTypesController {
}
@Post('reorder')
@RuleEngineManage('cargo-types')
@RuleEngineUpdate('cargo-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Bulk reorder cargo types by ID list' })
reorder(@Body() dto: ReorderItemsDto) {
@@ -34,7 +34,7 @@ export class CargoTypesController {
}
@Post(':id/move-order')
@RuleEngineManage('cargo-types')
@RuleEngineUpdate('cargo-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Move a cargo type up or down in display order' })
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
@@ -49,21 +49,21 @@ export class CargoTypesController {
}
@Post()
@RuleEngineManage('cargo-types')
@RuleEngineCreate('cargo-types')
@ApiOperation({ summary: 'Create a cargo type' })
create(@Body() dto: CreateCargoTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('cargo-types')
@RuleEngineUpdate('cargo-types')
@ApiOperation({ summary: 'Update a cargo type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('cargo-types')
@RuleEngineDelete('cargo-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a cargo type' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -3,7 +3,7 @@ import {
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage } from '../../../common/rule-engine-guards';
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
import { StaffReference } from '../../../common/booking-guards';
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -26,7 +26,7 @@ export class ContainerTypesController {
}
@Post('reorder')
@RuleEngineManage('container-types')
@RuleEngineUpdate('container-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Bulk reorder container types by ID list' })
reorder(@Body() dto: ReorderItemsDto) {
@@ -34,7 +34,7 @@ export class ContainerTypesController {
}
@Post(':id/move-order')
@RuleEngineManage('container-types')
@RuleEngineUpdate('container-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Move a container type up or down in display order' })
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
@@ -49,21 +49,21 @@ export class ContainerTypesController {
}
@Post()
@RuleEngineManage('container-types')
@RuleEngineCreate('container-types')
@ApiOperation({ summary: 'Create a container type' })
create(@Body() dto: CreateContainerTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('container-types')
@RuleEngineUpdate('container-types')
@ApiOperation({ summary: 'Update a container type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('container-types')
@RuleEngineDelete('container-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a container type' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -3,7 +3,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -49,14 +49,14 @@ export class PriorityConfigsController {
}
@Post()
@RuleEngineManage('priority-configs')
@RuleEngineCreate('priority-configs')
@ApiOperation({ summary: 'Create a priority config' })
create(@Body() dto: CreatePriorityConfigDto) {
return this.service.create(dto);
}
@Post('reorder')
@RuleEngineManage('priority-configs')
@RuleEngineUpdate('priority-configs')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Bulk reorder priority configs by ID list' })
reorder(@Body() dto: ReorderItemsDto) {
@@ -64,7 +64,7 @@ export class PriorityConfigsController {
}
@Post(':id/move-order')
@RuleEngineManage('priority-configs')
@RuleEngineUpdate('priority-configs')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Move a priority config up or down in display order' })
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
@@ -72,14 +72,14 @@ export class PriorityConfigsController {
}
@Patch(':id')
@RuleEngineManage('priority-configs')
@RuleEngineUpdate('priority-configs')
@ApiOperation({ summary: 'Update a priority config' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityConfigDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('priority-configs')
@RuleEngineDelete('priority-configs')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a priority config' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -11,7 +11,7 @@ import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { RuleEngineCreate, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
import { isSuperAdmin } from '../../../common/freight-permission.util';
import {
DecidePriorityRuleChangeDto,
@@ -32,7 +32,7 @@ export class PriorityRuleChangeRequestsController {
constructor(private readonly service: PriorityRuleChangeRequestsService) {}
@Post()
@RuleEngineManage('priority-configs')
@RuleEngineCreate('priority-configs')
@ApiOperation({ summary: 'Submit a priority-rule change for approval' })
submit(
@Body() dto: SubmitPriorityRuleChangeDto,
@@ -50,7 +50,7 @@ export class PriorityRuleChangeRequestsController {
}
@Post(':id/approve')
@RuleEngineManage('priority-configs')
@RuleEngineUpdate('priority-configs')
@ApiOperation({ summary: 'Approve and apply a pending change' })
approve(
@Param('id', ParseUUIDPipe) id: string,
@@ -63,7 +63,7 @@ export class PriorityRuleChangeRequestsController {
}
@Post(':id/reject')
@RuleEngineManage('priority-configs')
@RuleEngineUpdate('priority-configs')
@ApiOperation({ summary: 'Reject a pending change' })
reject(
@Param('id', ParseUUIDPipe) id: string,

View File

@@ -4,7 +4,7 @@ import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { isSuperAdmin } from '../../../common/freight-permission.util';
import { RuleEngineApprove, RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { RuleEngineApprove, RuleEngineCreate, RuleEngineView } from '../../../common/rule-engine-guards';
import { DecideRateChangeDto, SubmitRateChangeDto } from '../dto/rate-change-request.dto';
import { RateChangeStatus } from '../entities/rate-change-request.entity';
import { RateChangeRequestsService } from '../services/rate-change-requests.service';
@@ -21,7 +21,7 @@ export class RateChangeRequestsController {
constructor(private readonly service: RateChangeRequestsService) {}
@Post()
@RuleEngineManage('rates')
@RuleEngineCreate('rates')
@ApiOperation({ summary: 'Propose a change to a LIVE rate' })
submit(@Body() dto: SubmitRateChangeDto, @CurrentUser() user: TCurrentUser) {
return this.service.submit(dto, user?.id);

View File

@@ -5,7 +5,7 @@ import {
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
import { isSuperAdmin } from '../../../common/freight-permission.util';
import { CreateRateDto } from '../dto/create-rate.dto';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -44,7 +44,7 @@ export class RatesController {
}
@Post()
@RuleEngineManage('rates')
@RuleEngineCreate('rates')
@ApiOperation({ summary: 'Create a rate (DRAFT)' })
create(
@Body() dto: CreateRateDto,
@@ -54,21 +54,21 @@ export class RatesController {
}
@Patch(':id')
@RuleEngineManage('rates')
@RuleEngineUpdate('rates')
@ApiOperation({ summary: 'Update a DRAFT rate' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRateDto) {
return this.service.update(id, dto);
}
@Post(':id/submit')
@RuleEngineManage('rates')
@RuleEngineUpdate('rates')
@ApiOperation({ summary: 'Submit rate for CEO approval' })
submit(@Param('id', ParseUUIDPipe) id: string) {
return this.service.submitForApproval(id);
}
@Post(':id/approve')
@RuleEngineManage('rates')
@RuleEngineUpdate('rates')
@ApiOperation({ summary: 'CEO approves a rate' })
approve(
@Param('id', ParseUUIDPipe) id: string,
@@ -80,7 +80,7 @@ export class RatesController {
}
@Delete(':id')
@RuleEngineManage('rates')
@RuleEngineDelete('rates')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a rate' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -2,7 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage } from '../../../common/rule-engine-guards';
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
import { StaffReference } from '../../../common/booking-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
@@ -26,7 +26,7 @@ export class ServiceTypesController {
}
@Post('reorder')
@RuleEngineManage('service-types')
@RuleEngineUpdate('service-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Bulk reorder service types by ID list' })
reorder(@Body() dto: ReorderItemsDto) {
@@ -34,7 +34,7 @@ export class ServiceTypesController {
}
@Post(':id/move-order')
@RuleEngineManage('service-types')
@RuleEngineUpdate('service-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Move a service type up or down in display order' })
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
@@ -49,21 +49,21 @@ export class ServiceTypesController {
}
@Post()
@RuleEngineManage('service-types')
@RuleEngineCreate('service-types')
@ApiOperation({ summary: 'Create a service type' })
create(@Body() dto: CreateServiceTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('service-types')
@RuleEngineUpdate('service-types')
@ApiOperation({ summary: 'Update a service type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateServiceTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('service-types')
@RuleEngineDelete('service-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a service type' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -2,7 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage } from '../../../common/rule-engine-guards';
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
import { StaffReference } from '../../../common/booking-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
@@ -31,21 +31,21 @@ export class ShippingLinesController {
}
@Post()
@RuleEngineManage('shipping-lines')
@RuleEngineCreate('shipping-lines')
@ApiOperation({ summary: 'Create a shipping line' })
create(@Body() dto: CreateShippingLineDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('shipping-lines')
@RuleEngineUpdate('shipping-lines')
@ApiOperation({ summary: 'Update a shipping line' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateShippingLineDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('shipping-lines')
@RuleEngineDelete('shipping-lines')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a shipping line' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -2,7 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -30,21 +30,21 @@ export class WeightLimitRulesController {
}
@Post()
@RuleEngineManage('weight-limit-rules')
@RuleEngineCreate('weight-limit-rules')
@ApiOperation({ summary: 'Create a weight limit rule' })
create(@Body() dto: CreateWeightLimitRuleDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('weight-limit-rules')
@RuleEngineUpdate('weight-limit-rules')
@ApiOperation({ summary: 'Update a weight limit rule' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWeightLimitRuleDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('weight-limit-rules')
@RuleEngineDelete('weight-limit-rules')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a weight limit rule' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -12,7 +12,7 @@ import {
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage } from '../../../common/rule-engine-guards';
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
import { StaffReference } from '../../../common/booking-guards';
import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto';
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -40,21 +40,21 @@ export class YardDistancesController {
}
@Post()
@RuleEngineManage('yard-distances')
@RuleEngineCreate('yard-distances')
@ApiOperation({ summary: 'Create a yard distance' })
create(@Body() dto: CreateYardDistanceDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('yard-distances')
@RuleEngineUpdate('yard-distances')
@ApiOperation({ summary: 'Update a yard distance' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDistanceDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('yard-distances')
@RuleEngineDelete('yard-distances')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a yard distance' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -2,7 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage } from '../../../common/rule-engine-guards';
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
import { StaffReference } from '../../../common/booking-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateYardDto } from '../dto/create-yard.dto';
@@ -28,7 +28,7 @@ export class YardsController {
}
@Post('reorder')
@RuleEngineManage('yards')
@RuleEngineUpdate('yards')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Bulk reorder yards by ID list' })
reorder(@Body() dto: ReorderItemsDto) {
@@ -36,7 +36,7 @@ export class YardsController {
}
@Post(':id/move-order')
@RuleEngineManage('yards')
@RuleEngineUpdate('yards')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Move a yard up or down in display order' })
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
@@ -51,21 +51,21 @@ export class YardsController {
}
@Post()
@RuleEngineManage('yards')
@RuleEngineCreate('yards')
@ApiOperation({ summary: 'Create a yard' })
create(@Body() dto: CreateYardDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('yards')
@RuleEngineUpdate('yards')
@ApiOperation({ summary: 'Update a yard' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('yards')
@RuleEngineDelete('yards')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a yard' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -2,7 +2,7 @@ import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { TrainSchedulingManage } from '../../common/booking-guards';
import { TrainSchedulingReschedule } from '../../common/booking-guards';
import {
type AuthUserPayload,
resolveAuthUserId,
@@ -18,7 +18,7 @@ export class SchedulingRescheduleController {
constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {}
@Post('preview')
@TrainSchedulingManage()
@TrainSchedulingReschedule()
@ApiOperation({ summary: 'Preview reschedule / government preempt plan' })
preview(
@Param('id', ParseUUIDPipe) id: string,
@@ -28,7 +28,7 @@ export class SchedulingRescheduleController {
}
@Post('execute')
@TrainSchedulingManage()
@TrainSchedulingReschedule()
@ApiOperation({ summary: 'Execute a confirmed reschedule plan' })
execute(
@Param('id', ParseUUIDPipe) id: string,
@@ -50,7 +50,7 @@ export class SchedulingMaintenanceController {
constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {}
@Post('maintenance')
@TrainSchedulingManage()
@TrainSchedulingReschedule()
@ApiOperation({ summary: 'Reschedule train for maintenance (new departure + rebalance)' })
maintenance(
@Param('id', ParseUUIDPipe) id: string,

View File

@@ -1,23 +1,18 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
Res,
} from "@nestjs/common";
import { CurrentUser } from "@edr/api-common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
import {
TrainSchedulingManage,
Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res,
} from "@nestjs/common";
import { CurrentUser } from "@edr/api-common";
import {
TrainSchedulingCancel,
TrainSchedulingCreate,
TrainSchedulingReschedule,
TrainSchedulingRulesManage,
TrainSchedulingUpdate,
TrainSchedulingView,
} from "../../common/booking-guards";
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
@@ -114,7 +109,7 @@ export class TrainSchedulingController {
}
@Patch("global-rules")
@TrainSchedulingManage()
@TrainSchedulingRulesManage()
@ApiOperation({ summary: "Update global train scheduling rules (singleton)" })
updateGlobalRules(@Body() dto: UpdateTrainSchedulingGlobalRulesDto) {
return this.trainSchedulingService.updateTrainSchedulingGlobalRules(dto);
@@ -181,7 +176,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/adjust-consist")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)",
@@ -283,21 +278,21 @@ export class TrainSchedulingController {
}
@Post("container/schedules")
@TrainSchedulingManage()
@TrainSchedulingCreate()
@ApiOperation({ summary: "Create a container train schedule" })
createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
return this.trainSchedulingService.createContainerTrainSchedule(dto);
}
@Post("bulk/schedules")
@TrainSchedulingManage()
@TrainSchedulingCreate()
@ApiOperation({ summary: "Create a bulk train schedule" })
createBulkTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
return this.trainSchedulingService.createContainerTrainSchedule(dto);
}
@Post("schedules/:id/assign-bookings")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary: "Assign bookings to a train schedule (mixed-capable)",
})
@@ -309,7 +304,7 @@ export class TrainSchedulingController {
}
@Post("container/schedules/:id/assign-bookings")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({ summary: "Assign container bookings to a train schedule" })
assignContainerBookings(
@Param("id", ParseUUIDPipe) id: string,
@@ -323,7 +318,7 @@ export class TrainSchedulingController {
}
@Post("bulk/schedules/:id/assign-bookings")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({ summary: "Assign bulk bookings to a train schedule" })
assignBulkBookings(
@Param("id", ParseUUIDPipe) id: string,
@@ -337,7 +332,7 @@ export class TrainSchedulingController {
}
@Delete("schedules/:id/bookings/:bookingId")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({ summary: "Unassign a booking from a train schedule" })
unassignBooking(
@Param("id", ParseUUIDPipe) id: string,
@@ -352,7 +347,7 @@ export class TrainSchedulingController {
}
@Delete("schedules/:id/wagons/:trainSetWagonId")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({ summary: "Remove an empty wagon slot from a train" })
removeWagonSlot(
@Param("id", ParseUUIDPipe) id: string,
@@ -365,7 +360,7 @@ export class TrainSchedulingController {
}
@Patch("schedules/:id/container-items/:itemId")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({ summary: "Update a container number on a wagon slot" })
updateContainerItem(
@Param("id", ParseUUIDPipe) id: string,
@@ -376,7 +371,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/wagons/:wagonId/move-load")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)",
@@ -397,7 +392,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/assign-unassigned-booking")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Assign one linked unallocated booking to wagons (preserves existing assignments)",
@@ -429,7 +424,7 @@ export class TrainSchedulingController {
}
@Patch("schedules/:id/import-loading-status")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)",
@@ -442,7 +437,7 @@ export class TrainSchedulingController {
}
@Patch("schedules/:id/loading-status")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)",
@@ -455,21 +450,21 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/pin-wagons")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({ summary: "Pin physical wagons to train set slots" })
pinWagons(@Param("id", ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) {
return this.trainSchedulingService.pinWagons(id, dto);
}
@Post("schedules/:id/finalize")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({ summary: "Finalize a draft train schedule" })
finalizeSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.finalizeSchedule(id);
}
@Post("schedules/:id/dispatch")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({ summary: "Dispatch a scheduled train" })
dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.dispatchSchedule(id);
@@ -496,7 +491,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/intercity/accept")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)",
@@ -519,7 +514,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/bookings/:bookingId/load")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)",
@@ -532,7 +527,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/bookings/:bookingId/unload")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival",
@@ -545,7 +540,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/intercity/:bookingId/load")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)",
})
@@ -557,7 +552,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/intercity/:bookingId/unload")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)",
@@ -577,7 +572,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/import-djibouti/documents")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({ summary: "Upload/check an import Djibouti-side document" })
uploadImportDjiboutiDocument(
@Param("id", ParseUUIDPipe) id: string,
@@ -587,7 +582,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/import-djibouti/gatepass-granted")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({ summary: "Mark import Djibouti gatepass permission granted" })
grantImportDjiboutiGatepass(
@Param("id", ParseUUIDPipe) id: string,
@@ -597,7 +592,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/import-djibouti/ready-for-loading")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({ summary: "Mark import train ready for loading at Djibouti" })
markImportReadyForLoading(
@Param("id", ParseUUIDPipe) id: string,
@@ -607,7 +602,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/import-djibouti/loaded-on-train")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({ summary: "Confirm import cargo loaded on train at Djibouti" })
confirmImportLoadedOnTrain(
@Param("id", ParseUUIDPipe) id: string,
@@ -617,7 +612,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/confirm-loading")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)",
@@ -630,7 +625,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/import-djibouti/depart")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({ summary: "Depart loaded import train from Djibouti" })
departImportFromDjibouti(
@Param("id", ParseUUIDPipe) id: string,
@@ -640,7 +635,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/import-djibouti/load-list")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({ summary: "Generate import load list / marshalling document summary" })
generateImportLoadList(
@Param("id", ParseUUIDPipe) id: string,
@@ -680,7 +675,7 @@ export class TrainSchedulingController {
// ---- batch / booking-window staff actions ----
@Post("schedules/:id/run-batch")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({ summary: "Manually run the batch fill for a schedule" })
async runBatch(@Param("id", ParseUUIDPipe) id: string) {
await this.bookingBatchService.fillSchedule(id);
@@ -688,7 +683,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/run-allocation")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary: "Run wagon-level allocation for all eligible linked bookings",
})
@@ -697,7 +692,7 @@ export class TrainSchedulingController {
}
@Patch("schedules/:id/booking-window")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({ summary: "Open or close a schedule booking window" })
async setBookingWindow(
@Param("id", ParseUUIDPipe) id: string,
@@ -711,7 +706,7 @@ export class TrainSchedulingController {
}
@Patch("schedules/:id/window-rule")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens",
@@ -725,7 +720,7 @@ export class TrainSchedulingController {
}
@Patch("schedules/:id/schedule-date")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window",
@@ -739,7 +734,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/maintenance")
@TrainSchedulingManage()
@TrainSchedulingReschedule()
@ApiOperation({
summary:
"Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged",
@@ -753,7 +748,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/doc-review-complete")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)",
@@ -764,7 +759,7 @@ export class TrainSchedulingController {
}
@Post("bookings/:bookingId/mark-paid")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary: "Staff: mark a reserved booking paid and allocate it now",
})
@@ -774,7 +769,7 @@ export class TrainSchedulingController {
}
@Post("bookings/:bookingId/expire")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary: "Staff: expire a reservation and free its capacity",
})
@@ -784,7 +779,7 @@ export class TrainSchedulingController {
}
@Post("bookings/:bookingId/move-schedule")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary: "Re-point a booking to another OPEN same-route schedule",
})
@@ -806,7 +801,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/checkpoints")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary: "Log the train passing a station (final station triggers arrival)",
})
@@ -818,7 +813,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/arrive")
@TrainSchedulingManage()
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Mark a dispatched train arrived (move assets to destination yard, free assets)",
@@ -856,14 +851,14 @@ export class TrainSchedulingController {
}
@Post("container/schedules/:id/cancel")
@TrainSchedulingManage()
@TrainSchedulingCancel()
@ApiOperation({ summary: "Cancel container train schedule" })
cancelTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.cancelTrainSchedule(id);
}
@Post('bulk/schedules/:id/cancel')
@TrainSchedulingManage()
@TrainSchedulingCancel()
@ApiOperation({ summary: "Cancel bulk train schedule" })
cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.cancelTrainSchedule(id);

View File

@@ -2303,14 +2303,19 @@ export class TrainSchedulingService {
);
if (direction !== 'EXPORT') return;
// Only bookings boarding at the schedule's ORIGIN station gate dispatch —
// a mid-corridor boarder (origin B on an A→B→C→D run) is loaded when the
// train reaches its yard, so its warehouse state says nothing at departure.
const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query(
`WITH ${SCHEDULE_BOOKINGS_CTE}
SELECT DISTINCT b.reference AS "reference", inv.status AS "status"
FROM sched_bookings sb
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
JOIN freight.train_schedules ts ON ts.id = sb.schedule_id
JOIN freight.warehouse_inventory inv
ON inv.booking_id = b.id AND inv.deleted_at IS NULL
WHERE sb.schedule_id = $1
AND b.origin_yard_id = ts.origin_station_id
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`,
[scheduleId],
);
@@ -5625,9 +5630,12 @@ export class TrainSchedulingService {
// --- validate additions: AVAILABLE, loose, standing in the train's yard ---
const added: Wagon[] = [];
for (const wagonId of addWagonIds) {
// No `relations` on this query: Postgres refuses FOR UPDATE through the
// nullable side of the wagonType LEFT JOIN ("FOR UPDATE cannot be
// applied to the nullable side of an outer join"). Lock the row alone,
// then attach its type with a separate unlocked lookup.
const wagon = await manager.getRepository(Wagon).findOne({
where: { id: wagonId },
relations: { wagonType: true },
lock: { mode: 'pessimistic_write' },
});
if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
@@ -5644,6 +5652,10 @@ export class TrainSchedulingService {
`Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`,
);
}
wagon.wagonType =
(await manager
.getRepository(WagonType)
.findOne({ where: { id: wagon.wagonTypeId } })) ?? undefined;
added.push(wagon);
}

View File

@@ -0,0 +1,55 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
const toNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? value : Number(value);
const toBoolean = ({ value }: { value: unknown }) => {
if (typeof value === 'boolean') return value;
if (value === 'true') return true;
if (value === 'false') return false;
return value;
};
export class CreateTruckTypeDto {
@ApiProperty({ maxLength: 32, example: 'CASONI' })
@IsString()
@MaxLength(32)
code!: string;
@ApiProperty({ maxLength: 100, example: 'Casoni (rigid, no trailer)' })
@IsString()
@MaxLength(100)
name!: string;
@ApiPropertyOptional({
description: 'Payload capacity in metric tons — pre-fills a vehicle registered against this type',
example: 30,
})
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
capacityTons?: number;
@ApiPropertyOptional({
description: 'Whether this configuration pulls a trailer. False (e.g. Casoni) forbids a trailer plate.',
default: false,
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
hasTrailer?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTruckTypeDto } from './create-truck-type.dto';
export class UpdateTruckTypeDto extends PartialType(CreateTruckTypeDto) {}

View File

@@ -0,0 +1,40 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
/**
* A truck configuration EDR registers vehicles against — back-office managed so
* new configurations arrive without a code change.
*
* Two fields drive vehicle registration:
* - `capacityTons` pre-fills a vehicle's capacity (capacity belongs to the type,
* not to each individual truck).
* - `hasTrailer` decides whether a trailer plate applies at all. A rigid truck
* (e.g. Casoni) has none, and registering one with a trailer plate is rejected.
*/
@Entity({ schema: 'freight', name: 'truck_types' })
@Index(['code'])
@Index(['isActive'])
export class TruckType extends BaseEntity {
/**
* Matching key, upper-case. Denormalised onto `vehicles.vehicle_type`, which
* truck-detention billing groups and matches fee rules by — so a code change
* here is a billing-visible change.
*/
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
code!: string;
@Column({ name: 'name', type: 'varchar', length: 100 })
name!: string;
@Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
capacityTons?: number | null;
@Column({ name: 'has_trailer', type: 'boolean', default: false })
hasTrailer!: boolean;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,79 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
RuleEngineCreate,
RuleEngineDelete,
RuleEngineUpdate,
RuleEngineView,
} from '../../common/rule-engine-guards';
import { CreateTruckTypeDto } from './dto/create-truck-type.dto';
import { UpdateTruckTypeDto } from './dto/update-truck-type.dto';
import { TruckTypesService } from './truck-types.service';
@ApiTags('truck-types')
@Controller('truck-types')
@ApiBearerAuth()
export class TruckTypesController {
constructor(private readonly truckTypesService: TruckTypesService) {}
@Get()
@RuleEngineView('truck-types')
@ApiOperation({ summary: 'List truck types' })
findAll(@Query() query: Record<string, string | undefined>) {
return this.truckTypesService.findAll({
isActive:
query.isActive === 'all'
? undefined
: query.isActive !== undefined
? query.isActive === 'true'
: true,
page: query.page ? parseInt(query.page, 10) : undefined,
pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
sortBy: query.sortBy,
sortOrder: query.sortOrder,
});
}
@Get(':id')
@RuleEngineView('truck-types')
@ApiOperation({ summary: 'Get a truck type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.truckTypesService.findById(id);
}
@Post()
@RuleEngineCreate('truck-types')
@ApiOperation({ summary: 'Create a truck type' })
create(@Body() dto: CreateTruckTypeDto) {
return this.truckTypesService.create(dto);
}
@Patch(':id')
@RuleEngineUpdate('truck-types')
@ApiOperation({ summary: 'Update a truck type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTruckTypeDto) {
return this.truckTypesService.update(id, dto);
}
@Delete(':id')
@RuleEngineDelete('truck-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a truck type' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.truckTypesService.remove(id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TruckType } from './entities/truck-type.entity';
import { TruckTypesController } from './truck-types.controller';
import { TruckTypesRepository } from './truck-types.repository';
import { TruckTypesService } from './truck-types.service';
@Module({
imports: [TypeOrmModule.forFeature([TruckType])],
controllers: [TruckTypesController],
providers: [TruckTypesRepository, TruckTypesService],
exports: [TruckTypesRepository, TruckTypesService],
})
export class TruckTypesModule {}

View File

@@ -0,0 +1,20 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TruckType } from './entities/truck-type.entity';
@Injectable()
export class TruckTypesRepository extends BaseRepository<TruckType> {
constructor(
@InjectRepository(TruckType)
repository: Repository<TruckType>,
) {
super(repository);
}
findByCode(code: string): Promise<TruckType | null> {
return this.repository.findOne({ where: { code } });
}
}

View File

@@ -0,0 +1,116 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsOrder } from 'typeorm';
import { CreateTruckTypeDto } from './dto/create-truck-type.dto';
import { UpdateTruckTypeDto } from './dto/update-truck-type.dto';
import { TruckType } from './entities/truck-type.entity';
import { TruckTypesRepository } from './truck-types.repository';
type TruckTypeListFilter = {
isActive?: boolean;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: string;
};
@Injectable()
export class TruckTypesService {
constructor(private readonly truckTypesRepository: TruckTypesRepository) {}
async findAll(filter: TruckTypeListFilter = {}): Promise<{
data: TruckType[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 500;
const sortBy = ['code', 'name', 'capacityTons', 'hasTrailer', 'isActive'].includes(
filter.sortBy ?? '',
)
? (filter.sortBy as keyof TruckType)
: 'code';
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const [data, total] = await this.truckTypesRepository.findAndCount({
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
order: { [sortBy]: sortOrder } as FindOptionsOrder<TruckType>,
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
data,
meta: {
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
};
}
async findById(id: string): Promise<TruckType> {
const truckType = await this.truckTypesRepository.findById(id);
if (!truckType) {
throw new NotFoundException(`Truck type ${id} not found`);
}
return truckType;
}
async findByCode(code: string): Promise<TruckType> {
const truckType = await this.truckTypesRepository.findByCode(code);
if (!truckType) {
throw new NotFoundException(`Truck type ${code} not found`);
}
return truckType;
}
async create(dto: CreateTruckTypeDto): Promise<TruckType> {
const code = dto.code.trim().toUpperCase();
const existing = await this.truckTypesRepository.findByCode(code);
if (existing) {
throw new ConflictException(`Truck type code "${code}" already exists`);
}
return this.truckTypesRepository.create({
code,
name: dto.name.trim(),
capacityTons: dto.capacityTons ?? null,
hasTrailer: dto.hasTrailer ?? false,
description: dto.description?.trim() ?? null,
isActive: dto.isActive ?? true,
});
}
async update(id: string, dto: UpdateTruckTypeDto): Promise<TruckType> {
const truckType = await this.findById(id);
const nextCode = dto.code?.trim().toUpperCase();
if (nextCode && nextCode !== truckType.code) {
const existing = await this.truckTypesRepository.findByCode(nextCode);
if (existing) {
throw new ConflictException(`Truck type code "${nextCode}" already exists`);
}
}
const updated = await this.truckTypesRepository.update(id, {
...dto,
...(nextCode ? { code: nextCode } : {}),
...(dto.name ? { name: dto.name.trim() } : {}),
});
if (!updated) {
throw new NotFoundException(`Truck type ${id} not found`);
}
return updated;
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.truckTypesRepository.softDelete(id);
}
}

View File

@@ -1,6 +1,11 @@
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID, Matches } from 'class-validator';
import { Transform } from 'class-transformer';
import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity';
import {
FuelType,
VehicleAvailability,
VehicleOwnership,
VehicleStatus,
} from '../entities/vehicle.entity';
/**
* A vehicle plate is two or three letters, a hyphen, then two to six digits —
@@ -28,8 +33,9 @@ export class CreateVehicleDto {
@IsString()
plateNumber!: string;
@IsEnum(VehicleType)
vehicleType!: VehicleType;
/** Truck configuration from `freight.truck_types` — drives capacity and whether a trailer plate applies. */
@IsUUID()
truckTypeId!: string;
@IsString()
manufacturer!: string;
@@ -43,8 +49,18 @@ export class CreateVehicleDto {
@IsEnum(FuelType)
fuelType!: FuelType;
/** Defaults to the truck type's capacity when omitted. */
@IsOptional()
@IsNumber()
capacity!: number;
capacity?: number;
@IsOptional()
@IsString()
vin?: string;
@IsOptional()
@IsEnum(VehicleOwnership)
ownership?: VehicleOwnership;
@IsEnum(VehicleStatus)
status!: VehicleStatus;

View File

@@ -1,6 +1,17 @@
import { Entity, Column } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
/**
* Legacy classification. Truck configurations are now back-office data in
* `freight.truck_types` — register a vehicle with `truckTypeId`, not this.
*
* The `vehicle_type` COLUMN survives as a denormalised copy of the truck type's
* code because truck-detention billing groups by it in raw SQL and matches it
* against `warehouse_fee_rules.vehicle_type`. The service writes it through on
* every save; nothing should set it by hand.
*
* @deprecated use `truckTypeId` / `freight.truck_types`
*/
export enum VehicleType {
TRUCK = 'TRUCK',
VAN = 'VAN',
@@ -11,6 +22,12 @@ export enum VehicleType {
FLATBED = 'FLATBED',
}
/** Who supplies the truck. Supplier selection is deferred until EDR commits to outsourcing. */
export enum VehicleOwnership {
OWNED = 'OWNED',
OUTSOURCED = 'OUTSOURCED',
}
export enum FuelType {
PETROL = 'PETROL',
DIESEL = 'DIESEL',
@@ -47,8 +64,12 @@ export class Vehicle extends BaseEntity {
@Column({ name: 'registration_number', unique: true, nullable: true })
registrationNumber?: string;
/** Denormalised `truck_types.code` — written through by the service, never set by hand. */
@Column({ name: 'vehicle_type', type: 'varchar', nullable: true })
vehicleType?: VehicleType;
vehicleType?: string;
@Column({ name: 'truck_type_id', type: 'uuid', nullable: true })
truckTypeId?: string | null;
@Column({ nullable: true })
manufacturer?: string;
@@ -101,7 +122,7 @@ export class Vehicle extends BaseEntity {
@Column({ name: 'vin', type: 'varchar', nullable: true })
vin?: string;
/** Owned | Leased | Rented */
/** OWNED | OUTSOURCED — see {@link VehicleOwnership}. */
@Column({ name: 'ownership', type: 'varchar', nullable: true })
ownership?: string;

View File

@@ -11,6 +11,7 @@ describe('VehiclesService driver assignment guard', () => {
new VehiclesService(
{ findOne, create: jest.fn((x) => x), save: jest.fn(async (x) => x) } as any,
{ record: jest.fn() } as any,
{ findById: jest.fn(async () => ({ code: 'TRUCK', name: 'Truck', hasTrailer: true })) } as any,
);
it('rejects create when the driver is on another truck', async () => {
@@ -18,7 +19,7 @@ describe('VehiclesService driver assignment guard', () => {
const findOne = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(otherTruck);
const svc = makeService(findOne);
await expect(
svc.create({ plateNumber: '3-22222', vehicleType: 'TRUCK', assignedDriverId: 'd1' } as any),
svc.create({ plateNumber: '3-22222', truckTypeId: 'tt1', assignedDriverId: 'd1' } as any),
).rejects.toThrow(ConflictException);
});

View File

@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Vehicle } from './entities/vehicle.entity';
import { VehiclesService } from './vehicles.service';
import { VehiclesController } from './vehicles.controller';
import { TruckTypesModule } from '../truck-types/truck-types.module';
@Module({
imports: [TypeOrmModule.forFeature([Vehicle])],
imports: [TypeOrmModule.forFeature([Vehicle]), TruckTypesModule],
providers: [VehiclesService],
controllers: [VehiclesController],
exports: [VehiclesService],

View File

@@ -1,9 +1,16 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity';
import { TruckType } from '../truck-types/entities/truck-type.entity';
import { TruckTypesService } from '../truck-types/truck-types.service';
import { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.entity';
import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity';
import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity';
@@ -18,8 +25,26 @@ export class VehiclesService {
@InjectRepository(Vehicle)
private readonly vehicleRepo: Repository<Vehicle>,
private readonly history: FleetHistoryService,
private readonly truckTypes: TruckTypesService,
) {}
/**
* A trailer plate only exists on a configuration that pulls a trailer — a
* rigid truck (Casoni) has none. Checked against the RESULTING record, not
* just the patch, so switching an articulated truck to a rigid type cannot
* leave its old trailer plate stranded on the row.
*/
private assertTrailerPlateAllowed(
truckType: TruckType,
trailerPlateNo?: string | null,
): void {
if (!truckType.hasTrailer && trailerPlateNo) {
throw new BadRequestException(
`${truckType.name} has no trailer — remove the trailer plate number`,
);
}
}
/**
* A driver holds one truck at a time — reassignment requires detaching them
* from their current truck first.
@@ -54,10 +79,17 @@ export class VehiclesService {
await this.assertDriverUnassigned(dto.assignedDriverId);
}
const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
const truckType = await this.truckTypes.findById(dto.truckTypeId);
this.assertTrailerPlateAllowed(truckType, dto.trailerPlateNo);
const registrationNumber = `REG-${truckType.code}-${Date.now()}`;
const vehicle = this.vehicleRepo.create({
...dto,
registrationNumber,
// Denormalised for truck-detention billing, which groups on this column.
vehicleType: truckType.code,
// Capacity belongs to the type; an explicit value still wins for one-offs.
capacity: dto.capacity ?? truckType.capacityTons ?? undefined,
});
const saved = await this.vehicleRepo.save(vehicle);
@@ -148,6 +180,17 @@ export class VehiclesService {
await this.assertDriverUnassigned(dto.assignedDriverId, id);
}
// Re-resolve the truck type whenever the type OR the trailer plate moves —
// either edit can produce a rigid truck holding a trailer plate.
const nextTruckTypeId = dto.truckTypeId ?? vehicle.truckTypeId;
let nextTruckType: TruckType | null = null;
if (nextTruckTypeId && (dto.truckTypeId !== undefined || dto.trailerPlateNo !== undefined)) {
nextTruckType = await this.truckTypes.findById(nextTruckTypeId);
const nextTrailerPlate =
dto.trailerPlateNo !== undefined ? dto.trailerPlateNo : vehicle.trailerPlateNo;
this.assertTrailerPlateAllowed(nextTruckType, nextTrailerPlate);
}
const prev = {
assignedDriverId: vehicle.assignedDriverId,
assignedDriverName: vehicle.assignedDriverName,
@@ -156,6 +199,11 @@ export class VehiclesService {
};
Object.assign(vehicle, dto);
// After the patch is applied, so the denormalised billing code always
// reflects the type the vehicle actually ends up on.
if (nextTruckType) {
vehicle.vehicleType = nextTruckType.code;
}
const saved = await this.vehicleRepo.save(vehicle);
// Driver (re)assignment — emit an unassign for the old driver and/or an

View File

@@ -0,0 +1,81 @@
import { BadRequestException } from '@nestjs/common';
import { VehiclesService } from './vehicles.service';
// A trailer plate only exists on a configuration that pulls a trailer. A rigid
// truck (Casoni) has none, so registering or editing one into a trailer plate
// must be refused server-side — the form hiding the field is not enforcement.
describe('VehiclesService trailer plate guard', () => {
const CASONI = { code: 'CASONI', name: 'Casoni (rigid, no trailer)', hasTrailer: false, capacityTons: 30 };
const ARTIC = { code: 'TRUCK', name: 'Truck', hasTrailer: true, capacityTons: 40 };
const makeService = (findOne: jest.Mock, truckType: unknown) => {
const save = jest.fn(async (x) => x);
const svc = new VehiclesService(
{ findOne, create: jest.fn((x) => x), save } as any,
{ record: jest.fn() } as any,
{ findById: jest.fn(async () => truckType) } as any,
);
return { svc, save };
};
it('rejects creating a rigid truck that carries a trailer plate', async () => {
const findOne = jest.fn().mockResolvedValueOnce(null); // plate is free
const { svc } = makeService(findOne, CASONI);
await expect(
svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-casoni', trailerPlateNo: 'ET-1234' } as any),
).rejects.toThrow(BadRequestException);
});
it('accepts a rigid truck with no trailer plate, and takes capacity from the type', async () => {
const findOne = jest.fn().mockResolvedValueOnce(null);
const { svc } = makeService(findOne, CASONI);
const saved = await svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-casoni' } as any);
expect(saved.capacity).toBe(30);
// Denormalised code is what truck-detention billing groups on.
expect(saved.vehicleType).toBe('CASONI');
});
it('keeps an explicit capacity over the type default', async () => {
const findOne = jest.fn().mockResolvedValueOnce(null);
const { svc } = makeService(findOne, CASONI);
const saved = await svc.create({
plateNumber: 'ET-9875',
truckTypeId: 'tt-casoni',
capacity: 25,
} as any);
expect(saved.capacity).toBe(25);
});
it('allows a trailer plate on an articulated type', async () => {
const findOne = jest.fn().mockResolvedValueOnce(null);
const { svc } = makeService(findOne, ARTIC);
await expect(
svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-truck', trailerPlateNo: 'ET-1234' } as any),
).resolves.toBeDefined();
});
// The regression that motivated validating the RESULT rather than the patch:
// switching type alone leaves the stored trailer plate behind.
it('rejects switching an existing truck to a rigid type while its trailer plate stands', async () => {
const findOne = jest
.fn()
.mockResolvedValueOnce({ id: 'v1', plateNumber: 'ET-9875', trailerPlateNo: 'ET-1234' });
const { svc } = makeService(findOne, CASONI);
await expect(svc.update('v1', { truckTypeId: 'tt-casoni' } as any)).rejects.toThrow(
BadRequestException,
);
});
it('allows the switch when the trailer plate is cleared in the same edit', async () => {
const findOne = jest
.fn()
.mockResolvedValueOnce({ id: 'v1', plateNumber: 'ET-9875', trailerPlateNo: 'ET-1234' });
const { svc } = makeService(findOne, CASONI);
const saved = await svc.update('v1', {
truckTypeId: 'tt-casoni',
trailerPlateNo: null,
} as any);
expect(saved.vehicleType).toBe('CASONI');
});
});

View File

@@ -13,7 +13,7 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../common/rule-engine-guards';
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../common/rule-engine-guards';
import { CreateWagonTypeDto } from './dto/create-wagon-type.dto';
import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
@@ -51,21 +51,21 @@ export class WagonTypesController {
}
@Post()
@RuleEngineManage('wagon-types')
@RuleEngineCreate('wagon-types')
@ApiOperation({ summary: 'Create a wagon type' })
create(@Body() dto: CreateWagonTypeDto) {
return this.wagonTypesService.create(dto);
}
@Patch(':id')
@RuleEngineManage('wagon-types')
@RuleEngineUpdate('wagon-types')
@ApiOperation({ summary: 'Update a wagon type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonTypeDto) {
return this.wagonTypesService.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('wagon-types')
@RuleEngineDelete('wagon-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a wagon type' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -791,15 +791,22 @@ export class WarehouseFeeService {
};
}
// Group the leg's vehicles by type so each truck type is billed by its own
// matching rule (rates differ by truck type). Falls back to one untyped group.
// Group the leg's vehicles by CANONICAL truck type so each type is billed
// by its own matching rule (rates differ by truck type). The FK to
// truck_types is the source of truth — renaming a type's label no longer
// silently unmatches its rule; the normalized legacy vehicle_type code is
// only a fallback for vehicles without the FK (LEFT JOIN keeps them billed
// instead of dropping them). Falls back to one untyped group.
const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> =
await this.dataSource.query(
`SELECT v.vehicle_type AS "vehicleType", count(*)::int AS "truckCount"
`SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType",
count(*)::int AS "truckCount"
FROM freight.last_mile_vehicle_assignments va
JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL
LEFT JOIN freight.truck_types t
ON t.id = v.truck_type_id AND t.deleted_at IS NULL
WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL
GROUP BY v.vehicle_type`,
GROUP BY 1`,
[lastMileId],
);
const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }];

View File

@@ -20,8 +20,13 @@ import { WarehouseInspectionService } from './warehouse-inspection.service';
@ApiTags('warehouse-inspection')
@ApiBearerAuth()
// Baseline read: inspection reports are opened from inventory screens too —
// either view permission grants reads; writes stack their own per route.
@Controller()
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view)
@BookingStaff([
FREIGHT_PERMS.warehouseInspectionReports.view,
FREIGHT_PERMS.warehouseInventory.view,
])
export class WarehouseInspectionController {
constructor(private readonly inspectionService: WarehouseInspectionService) {}

View File

@@ -5,7 +5,7 @@ import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { actorLabel } from './current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { BookingStaff, StaffReference } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
@@ -456,6 +456,7 @@ export class WarehouseInventoryController {
}
@Get(':id/handover-document')
@StaffReference()
@ApiOperation({ summary: 'View import goods handover document PDF' })
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.handoverDocument(id);
@@ -466,6 +467,7 @@ export class WarehouseInventoryController {
}
@Post('bookings/:bookingId/approve-delivery')
@StaffReference()
@ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" })
approveDeliveryForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -481,12 +483,14 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/handovers')
@StaffReference()
@ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' })
bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.handoverService.list(bookingId);
}
@Post('handovers/:handoverId/sign')
@StaffReference()
@ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' })
signHandover(
@Param('handoverId', ParseUUIDPipe) handoverId: string,
@@ -502,12 +506,14 @@ export class WarehouseInventoryController {
}
@Post('bookings/:bookingId/request-handover-signature')
@StaffReference()
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.handoverService.requestSignature(bookingId);
}
@Get('bookings/:bookingId/grn-document')
@StaffReference()
@ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' })
async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId);
@@ -518,6 +524,7 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/release-document')
@StaffReference()
@ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' })
async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId);
@@ -528,6 +535,7 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/handover-document')
@StaffReference()
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' })
async bookingHandoverDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -545,18 +553,21 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/container-items')
@StaffReference()
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.containerItems(bookingId);
}
@Get('bookings/:bookingId/container-weights')
@StaffReference()
@ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" })
containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.bookingContainerWeights(bookingId);
}
@Get('bookings/:bookingId/location')
@StaffReference()
@ApiOperation({ summary: "Warehouse location of a booking's inventory (customer portal)" })
bookingLocation(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.bookingLocation(bookingId);

View File

@@ -5,7 +5,7 @@ import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { actorLabel } from './current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { BookingStaff, StaffReference } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
@@ -43,6 +43,7 @@ export class WarehouseInvoiceController {
}
@Get('bookings/:id/warehouse-fee-invoices')
@StaffReference()
@ApiOperation({ summary: 'List warehouse fee invoices for a booking' })
listForBooking(@Param('id', ParseUUIDPipe) id: string) {
return this.invoiceService.listForBooking(id);
@@ -70,12 +71,14 @@ export class WarehouseInvoiceController {
}
@Get('warehouse-fee-invoices/:id')
@StaffReference()
@ApiOperation({ summary: 'Get a warehouse fee invoice with items + payment history' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.invoiceService.findById(id);
}
@Get('warehouse-fee-invoices/:id/document')
@StaffReference()
@ApiOperation({ summary: 'Download sealed warehouse fee invoice PDF' })
async document(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.invoiceService.document(id);
@@ -86,6 +89,7 @@ export class WarehouseInvoiceController {
}
@Get('warehouse-fee-invoices/:id/receipt')
@StaffReference()
@ApiOperation({ summary: 'Download sealed warehouse fee payment receipt PDF' })
async receipt(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.invoiceService.receipt(id);
@@ -110,6 +114,7 @@ export class WarehouseInvoiceController {
}
@Post('warehouse-fee-invoices/:id/pay-online')
@StaffReference()
@ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' })
payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) {
return this.invoiceService.initiatePayment(id, dto);

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff, StaffReference } from '../../common/booking-guards';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
@@ -10,8 +10,8 @@ import { WarehouseZonesService } from './warehouse-zones.service';
@ApiTags('warehouse-yards')
@ApiBearerAuth()
// No class-level guard: the two reference GETs are open to any signed-in
// staff (StaffReference), every other route carries its own permission.
// No class-level guard: every route carries its own permission (reads accept
// yard-view OR inventory-view so inventory flows can populate yard pickers).
@Controller('warehouse-yards')
export class WarehouseYardsController {
constructor(
@@ -20,14 +20,14 @@ export class WarehouseYardsController {
) {}
@Get()
@StaffReference()
@BookingStaff([FREIGHT_PERMS.warehouseYards.view, FREIGHT_PERMS.warehouseInventory.view])
@ApiOperation({ summary: 'List all warehouse yards' })
findAll() {
return this.yardsService.findAll();
}
@Get(':id')
@StaffReference()
@BookingStaff([FREIGHT_PERMS.warehouseYards.view, FREIGHT_PERMS.warehouseInventory.view])
@ApiOperation({ summary: 'Get warehouse yard by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.yardsService.findById(id);

View File

@@ -8,8 +8,11 @@ import { WarehouseZonesService } from './warehouse-zones.service';
@ApiTags('warehouse-zones')
@ApiBearerAuth()
// Baseline read: zone reference data also serves inventory flows (allocation,
// receive/move pickers) — either view permission grants reads; writes stack
// their specific permission per route.
@Controller('warehouse-zones')
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
@BookingStaff([FREIGHT_PERMS.warehouseZones.view, FREIGHT_PERMS.warehouseInventory.view])
export class WarehouseZonesController {
constructor(private readonly zonesService: WarehouseZonesService) {}

View File

@@ -13,8 +13,15 @@ import { WarehousesService } from './warehouses.service';
@ApiTags('warehouses')
@ApiBearerAuth()
// Baseline read: warehouse reference data is consumed by inventory/dashboard
// flows too, so any of the three view permissions grants reads. Writes stack
// their specific create/update permission per route on top.
@Controller('warehouses')
@BookingStaff(FREIGHT_PERMS.warehouses.view)
@BookingStaff([
FREIGHT_PERMS.warehouses.view,
FREIGHT_PERMS.warehouseInventory.view,
FREIGHT_PERMS.warehouseDashboard.view,
])
export class WarehousesController {
constructor(
private readonly warehousesService: WarehousesService,

View File

@@ -582,6 +582,19 @@ const INTERCITY_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
},
];
// ── Hazardous cargo documents ───────────────────────────────────────────────
// Asked for in the contract wizard the moment the customer flags the cargo as
// hazardous (ONE_TIME contracts only). Fields start empty and are configured in
// the backoffice file-settings editor.
const HAZARDOUS_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
{
code: "hazardous_documents",
label: "Hazardous cargo documents",
entity: CONTRACT_INTAKE_ENTITY,
fields: [],
},
];
@Injectable()
export class FileUploadSettingsSeeder {
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
@@ -633,6 +646,11 @@ export class FileUploadSettingsSeeder {
description:
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
})),
...HAZARDOUS_DOCUMENT_SETTINGS.map((s) => ({
...s,
description:
"Documents required when a one-time contract's cargo is flagged hazardous.",
})),
...INTERCITY_DOCUMENT_SETTINGS.map((s) => ({
...s,
description:

View File

@@ -19,6 +19,9 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
'rates',
'approval-rules',
'yard-distances',
// Keep new slugs at the END: ruleEngineCrudId derives ids from list index,
// so a mid-list insert would shift ids already seeded for later slugs.
'truck-types',
] as const;
export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number];
@@ -57,7 +60,6 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
perm('a1000001-0001-4000-8000-000000000021', 'edr_freight_app:bookings:upload_clearance_output', 'Upload customs output documents'),
perm('a1000001-0001-4000-8000-000000000022', 'edr_freight_app:bookings:finalize_clearance', 'Finalize document clearance'),
perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'),
perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'),
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'),
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
@@ -83,7 +85,10 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
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-000000000008', 'edr_freight_app:contracts:generate_contract', 'Generate contract document'),
perm('a3000001-0001-4000-8000-000000000009', 'edr_freight_app:contracts:sign_staff', 'Staff contract signature'),
// Staff counter-signature is split per freight type too — fresh ids for the
// same reason as the intake keys above.
perm('a3000001-0001-4000-8000-000000000017', 'edr_freight_app:contracts:sign_staff:bulk', 'Staff contract signature: bulk'),
perm('a3000001-0001-4000-8000-000000000018', 'edr_freight_app:contracts:sign_staff:container', 'Staff contract signature: container'),
perm('a3000001-0001-4000-8000-00000000000a', 'edr_freight_app:contracts:clearance_review', 'Review pre-booking clearance docs'),
perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'),
perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'),
@@ -93,24 +98,43 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'),
];
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' },
'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' },
'wagon-types': { view: 'b2000001-0001-4000-8000-000000000015', manage: 'b2000001-0001-4000-8000-000000000016' },
'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' },
yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' },
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },
'weight-limit-rules': { view: 'b2000001-0001-4000-8000-00000000000b', manage: 'b2000001-0001-4000-8000-00000000000c' },
'priority-configs': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' },
rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' },
'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' },
'yard-distances': { view: 'b2000001-0001-4000-8000-000000000018', manage: 'b2000001-0001-4000-8000-000000000019' },
// Existing per-slug view ids are kept as-is: position-type grants reference
// them by id, so re-minting would orphan those rows.
const RULE_ENGINE_VIEW_IDS: Record<RuleEngineResourceSlug, string> = {
'cargo-types': 'b2000001-0001-4000-8000-000000000001',
'container-types': 'b2000001-0001-4000-8000-000000000003',
'wagon-types': 'b2000001-0001-4000-8000-000000000015',
'truck-types': 'b2000001-0001-4000-8000-00000000001a',
'service-types': 'b2000001-0001-4000-8000-000000000005',
yards: 'b2000001-0001-4000-8000-000000000007',
'shipping-lines': 'b2000001-0001-4000-8000-000000000009',
'weight-limit-rules': 'b2000001-0001-4000-8000-00000000000b',
'priority-configs': 'b2000001-0001-4000-8000-00000000000f',
rates: 'b2000001-0001-4000-8000-000000000011',
'approval-rules': 'b2000001-0001-4000-8000-000000000013',
'yard-distances': 'b2000001-0001-4000-8000-000000000018',
};
// CRUD replaces the retired coarse `:manage`. New ids live in a fresh block
// (b2000002-…) so a stale `:manage` grant can never silently confer a CRUD
// action — the migration re-grants create/update/delete explicitly.
const RULE_ENGINE_CRUD_ACTIONS = ['create', 'update', 'delete'] as const;
type RuleEngineCrudAction = (typeof RULE_ENGINE_CRUD_ACTIONS)[number];
const ruleEngineCrudId = (
slug: RuleEngineResourceSlug,
action: RuleEngineCrudAction,
): string => {
const n =
RULE_ENGINE_RESOURCE_SLUGS.indexOf(slug) * 3 +
RULE_ENGINE_CRUD_ACTIONS.indexOf(action) +
1; // 1..36
return `b2000002-0001-4000-8000-${n.toString(16).padStart(12, '0')}`;
};
/**
* Slugs whose changes go through a separate approver. `manage` lets a staff
* member propose a change; only `approve` lets someone put it into effect.
* Only listed slugs get the permission — the rest are manage-only.
* Slugs whose changes go through a separate approver. CRUD lets a staff member
* propose a change; only `approve` lets someone put it into effect. Only listed
* slugs get the permission.
*/
const RULE_ENGINE_APPROVE_PERMISSION_IDS: Partial<Record<RuleEngineResourceSlug, string>> = {
rates: 'b2000001-0001-4000-8000-000000000017',
@@ -121,11 +145,12 @@ export type RuleEngineApprovableSlug = 'rates';
export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap(
(slug) => {
const resource = slugToResourceKey(slug);
const ids = RULE_ENGINE_PERMISSION_IDS[slug];
const approveId = RULE_ENGINE_APPROVE_PERMISSION_IDS[slug];
return [
perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`),
perm(ids.manage, `edr_freight_app:rule_engine:${resource}:manage`, `Manage ${slug}`),
perm(RULE_ENGINE_VIEW_IDS[slug], `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`),
perm(ruleEngineCrudId(slug, 'create'), `edr_freight_app:rule_engine:${resource}:create`, `Create ${slug}`),
perm(ruleEngineCrudId(slug, 'update'), `edr_freight_app:rule_engine:${resource}:update`, `Update ${slug}`),
perm(ruleEngineCrudId(slug, 'delete'), `edr_freight_app:rule_engine:${resource}:delete`, `Delete ${slug}`),
...(approveId
? [perm(approveId, `edr_freight_app:rule_engine:${resource}:approve`, `Approve ${slug} changes`)]
: []),
@@ -395,7 +420,10 @@ export const FREIGHT_PERMS = {
approveDirector: 'edr_freight_app:contracts:approve_director',
approveCeo: 'edr_freight_app:contracts:approve_ceo',
generateContract: 'edr_freight_app:contracts:generate_contract',
signStaff: 'edr_freight_app:contracts:sign_staff',
signStaff: {
bulk: 'edr_freight_app:contracts:sign_staff:bulk',
container: 'edr_freight_app:contracts:sign_staff:container',
},
clearanceReview: 'edr_freight_app:contracts:clearance_review',
finalizeClearance: 'edr_freight_app:contracts:finalize_clearance',
createBooking: 'edr_freight_app:contracts:create_booking',
@@ -406,7 +434,6 @@ export const FREIGHT_PERMS = {
},
trainScheduling: {
view: 'edr_freight_app:train_scheduling:view',
manage: 'edr_freight_app:train_scheduling:manage',
create: 'edr_freight_app:train_scheduling:create',
update: 'edr_freight_app:train_scheduling:update',
cancel: 'edr_freight_app:train_scheduling:cancel',
@@ -421,8 +448,12 @@ export const FREIGHT_PERMS = {
ruleEngine: {
view: (slug: RuleEngineResourceSlug) =>
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`,
manage: (slug: RuleEngineResourceSlug) =>
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`,
create: (slug: RuleEngineResourceSlug) =>
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:create`,
update: (slug: RuleEngineResourceSlug) =>
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:update`,
delete: (slug: RuleEngineResourceSlug) =>
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:delete`,
approve: (slug: RuleEngineApprovableSlug) =>
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`,
},
@@ -751,7 +782,11 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.operations,
FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.trainScheduling.manage,
FREIGHT_PERMS.trainScheduling.create,
FREIGHT_PERMS.trainScheduling.update,
FREIGHT_PERMS.trainScheduling.cancel,
FREIGHT_PERMS.trainScheduling.reschedule,
FREIGHT_PERMS.trainScheduling.rulesManage,
FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.fleet.manage,
...FLEET_GRANULAR_KEYS,
@@ -836,7 +871,7 @@ export const ROLE_PERMISSION_PRESETS = {
...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
FREIGHT_PERMS.contracts.approveLineStaff,
FREIGHT_PERMS.contracts.generateContract,
FREIGHT_PERMS.contracts.signStaff,
...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff),
],
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
} as const;

View File

@@ -0,0 +1,96 @@
import { Button, Group, TextInput } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { Search, X } from "lucide-react";
import type { ReactNode } from "react";
export interface ListControlsProps {
search: string;
onSearchChange: (value: string) => void;
searchPlaceholder?: string;
/** `YYYY-MM-DD`, matching Mantine 9's date inputs. */
dateFrom: string | null;
onDateFromChange: (value: string | null) => void;
dateTo: string | null;
onDateToChange: (value: string | null) => void;
/** Label above the range, naming the date being filtered (e.g. "Arrival date"). */
dateLabel?: string;
hasFilters?: boolean;
onReset?: () => void;
/** Page-specific selects (status, warehouse…) rendered after the date range. */
children?: ReactNode;
showSearch?: boolean;
showDateRange?: boolean;
}
/**
* Search box + inclusive date range + clear, shared by every freight list so the
* controls sit in the same place and behave the same way on all of them.
* Pair with `useListControls`, which owns the state and does the filtering.
*/
const ListControls = ({
search,
onSearchChange,
searchPlaceholder = "Search…",
dateFrom,
onDateFromChange,
dateTo,
onDateToChange,
dateLabel,
hasFilters,
onReset,
children,
showSearch = true,
showDateRange = true,
}: ListControlsProps) => (
<Group gap="sm" align="flex-end" wrap="wrap">
{showSearch && (
<TextInput
placeholder={searchPlaceholder}
value={search}
onChange={(e) => onSearchChange(e.currentTarget.value)}
leftSection={<Search size={16} />}
style={{ flex: "1 1 240px", minWidth: 200 }}
/>
)}
{showDateRange && (
<>
<DatePickerInput
label={dateLabel ? `${dateLabel} from` : "From"}
placeholder="Any"
value={dateFrom}
onChange={onDateFromChange}
// Cannot start after it ends — the picker refuses the invalid range
// instead of silently returning nothing.
maxDate={dateTo ?? undefined}
clearable
w={150}
/>
<DatePickerInput
label={dateLabel ? `${dateLabel} to` : "To"}
placeholder="Any"
value={dateTo}
onChange={onDateToChange}
minDate={dateFrom ?? undefined}
clearable
w={150}
/>
</>
)}
{children}
{hasFilters && onReset && (
<Button
variant="subtle"
color="gray"
leftSection={<X size={14} />}
onClick={onReset}
>
Clear
</Button>
)}
</Group>
);
export default ListControls;

View File

@@ -126,6 +126,28 @@ const FleetFormDialog = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, recordId]);
// Seed the `_`-prefixed scratch that `onOptionSelected` derives (e.g.
// _hasTrailer) for the value already on the record. Without this, editing a
// rigid truck would show a Trailer Plate field until the type is re-picked.
// Only scratch keys are written, so a stored one-off capacity is never
// clobbered by the type's default; re-deriving from the live value is
// idempotent, so this is safe to run again when the options finally load.
useEffect(() => {
if (!open) return;
setValues((current) => {
const scratch: Record<string, unknown> = {};
fields.forEach((field) => {
if (!field.onOptionSelected) return;
const selected = field.options?.find((o) => o.value === current[field.name]);
if (!selected) return;
Object.entries(field.onOptionSelected(selected, current)).forEach(([key, value]) => {
if (key.startsWith("_")) scratch[key] = value;
});
});
return Object.keys(scratch).length ? { ...current, ...scratch } : current;
});
}, [open, fields]);
// Receive the ?code&state relayed by the /callback popup, exchange it for
// the verified identity, and prefill the matching form fields.
useEffect(() => {
@@ -202,18 +224,52 @@ const FleetFormDialog = ({
const faydaVerified = values.faydaVerified === true;
/**
* Fields the current answers actually apply to — a rigid truck type (Casoni)
* has no trailer, so its plate field disappears. Honoured in three places, not
* just here: a hidden field must also skip validation (an invisible "required"
* error blocks submit with nothing to fix) and must submit an explicit null
* (so switching to a rigid type CLEARS the stored trailer plate rather than
* stranding it on the row).
*/
const visibleFields = useMemo(
() =>
fields.filter((field) => {
if (
field.hideWhen &&
field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? ""))
) {
return false;
}
if (
field.showWhen &&
!field.showWhen.equals.includes(String(values[field.showWhen.field] ?? ""))
) {
return false;
}
if (field.showIf && !field.showIf(values)) return false;
return true;
}),
[fields, values],
);
const hiddenFieldNames = useMemo(() => {
const visible = new Set(visibleFields.map((f) => f.name));
return fields.filter((f) => !visible.has(f.name)).map((f) => f.name);
}, [fields, visibleFields]);
const shortFields = useMemo(
() => fields.filter((f) => f.type !== "textarea"),
[fields],
() => visibleFields.filter((f) => f.type !== "textarea"),
[visibleFields],
);
const longFields = useMemo(
() => fields.filter((f) => f.type === "textarea"),
[fields],
() => visibleFields.filter((f) => f.type === "textarea"),
[visibleFields],
);
const validate = () => {
const next: Record<string, string> = {};
fields.forEach((field) => {
visibleFields.forEach((field) => {
const value = values[field.name];
const stringValue =
typeof value === "string" ? value.trim() : String(value ?? "");
@@ -295,9 +351,19 @@ const FleetFormDialog = ({
fields.forEach((field) => {
if (field.derivedValue) submitted[field.name] = field.derivedValue(values);
});
// A field the answers hid no longer applies to this record — send an explicit
// null so the column is unset, instead of leaving a stale value behind.
hiddenFieldNames.forEach((name) => {
submitted[name] = null;
});
const payload = Object.fromEntries(
Object.entries(submitted)
// `_`-prefixed keys are form-local scratch written by `onOptionSelected`
// (e.g. _hasTrailer, which drives visibility). The API validates with
// forbidNonWhitelisted, so an undeclared key would 400 the whole save.
.filter(([key]) => !key.startsWith("_"))
.map(([key, value]) => {
if (hiddenFieldNames.includes(key)) return [key, null];
if (value === FLEET_SELECT_NONE || value === "" || value == null)
return [key, clearableByName[key] ? null : undefined];
if (fieldTypeByName[key] === "number") {
@@ -371,7 +437,15 @@ const FleetFormDialog = ({
: String(value)
}
onChange={(next) =>
setValues((current) => ({ ...current, [field.name]: next ?? "" }))
setValues((current) => {
const patch = field.onOptionSelected
? field.onOptionSelected(
field.options?.find((o) => o.value === next),
current,
)
: {};
return { ...current, [field.name]: next ?? "", ...patch };
})
}
error={error}
searchable

View File

@@ -0,0 +1,403 @@
import {
Alert,
Badge,
Button,
Divider,
Group,
Loader,
Modal,
Stack,
Table,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
CheckCircle2,
Flag,
MapPin,
PackageCheck,
TrainFront,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Freight } from "@edr/types";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import type { TrackStation, YardWorkBookingRow } from "@/types/trainScheduling";
const parseError = (error: unknown, fallback: string) => {
const message = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data?.message;
if (Array.isArray(message)) return message.join("; ");
return message || (error as Error)?.message || fallback;
};
const fmtDate = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
const DIRECTION_COLORS: Record<string, string> = {
IMPORT: "blue",
EXPORT: "teal",
DOMESTIC: "violet",
};
/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
const DIRECTION_LABELS: Record<string, string> = Freight.TRADE_DIRECTION_LABELS;
function DirectionChip({ direction }: { direction: string }) {
return (
<Badge size="sm" variant="light" color={DIRECTION_COLORS[direction] ?? "gray"}>
{DIRECTION_LABELS[direction] ?? direction}
</Badge>
);
}
function SectionLabel({
icon,
title,
count,
}: {
icon: React.ReactNode;
title: string;
count: number;
}) {
return (
<Group gap={8} align="center">
<ThemeIcon size={26} radius="md" variant="light" color="edr-green">
{icon}
</ThemeIcon>
<Text fw={700} size="sm">
{title}
</Text>
<Badge size="sm" variant="light" color="gray" radius="sm">
{count}
</Badge>
</Group>
);
}
/**
* Yard-work modal for the track page's "Log pass" step.
*
* A train runs A→B→C→D and bookings board/alight at any stop, so logging the
* pass at a yard is the moment its yard work happens: bookings destined here
* flip to ARRIVED (import/export) or COMPLETED (intercity) automatically the
* instant the pass is logged, and bookings boarding here become loadable —
* the server only accepts a load while the train's latest checkpoint is this
* yard. The modal therefore drives the sequence: log the pass first, then
* load anything that boards here (including cargo the operator forgot — it
* stays loadable until the next pass is logged).
*/
export function LogPassYardWorkModal({
opened,
onClose,
scheduleId,
station,
isFinal,
alreadyLogged,
}: {
opened: boolean;
onClose: () => void;
scheduleId: string;
station: TrackStation | null;
isFinal: boolean;
/** True when opened for the current station (pass already logged). */
alreadyLogged: boolean;
}) {
const { toast } = useToast();
const [justLogged, setJustLogged] = useState(false);
useEffect(() => setJustLogged(false), [station?.sequenceNo, opened]);
const logged = alreadyLogged || justLogged;
const yardWorkQuery = useQuery(
api.trainScheduling.yardWork.queryOptions({
input: { scheduleId },
enabled: opened && Boolean(scheduleId),
}),
);
const recordCheckpoint = useMutation(
api.trainScheduling.recordCheckpoint.mutationOptions(),
);
const load = useMutation(api.trainScheduling.loadScheduleBooking.mutationOptions());
const yard = yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId);
const boarders: YardWorkBookingRow[] = yard?.toLoad ?? [];
const arrivals: YardWorkBookingRow[] = yard?.toUnload ?? [];
const pendingBoarders = boarders.filter((r) => !r.loadedAt);
const doLogPass = () => {
if (!station) return;
recordCheckpoint.mutate(
{ id: scheduleId, payload: { sequenceNo: station.sequenceNo } },
{
onSuccess: () => {
setJustLogged(true);
toast({
title: isFinal
? "Train arrived — remaining bookings marked arrived, assets freed"
: `Pass logged at ${station.label}`,
description: isFinal
? undefined
: arrivals.some((r) => r.canUnload)
? "Bookings arriving here have been marked arrived."
: undefined,
});
void yardWorkQuery.refetch();
},
onError: (err) =>
toast({
title: "Could not log checkpoint",
description: parseError(err, "Please try again"),
variant: "destructive",
}),
},
);
};
const doLoad = (row: YardWorkBookingRow) => {
load.mutate(
{ scheduleId, bookingId: row.id },
{
onSuccess: () => {
toast({
title: `${row.reference ?? "Booking"} loaded`,
description: `Cargo boarded the train at ${station?.label ?? "this yard"}.`,
});
void yardWorkQuery.refetch();
},
onError: (err) =>
toast({
title: "Could not load booking",
description: parseError(err, "Please try again"),
variant: "destructive",
}),
},
);
};
const hasWork = boarders.length > 0 || arrivals.length > 0;
return (
<Modal
opened={opened}
onClose={onClose}
size="xl"
radius="lg"
title={
<Group gap={8}>
{isFinal ? <Flag size={18} /> : <MapPin size={18} />}
<Text fw={700}>
{isFinal ? "Arrival" : "Yard work"} {station?.label ?? ""}
</Text>
{logged ? (
<Badge size="sm" variant="light" color="edr-green" radius="sm">
{isFinal ? "Arrived" : "Pass logged"}
</Badge>
) : null}
</Group>
}
>
<Stack gap="md">
{yardWorkQuery.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" color="edr-green" />
</Group>
) : !hasWork ? (
<Alert color="gray" variant="light" radius="md" icon={<MapPin size={16} />}>
No bookings board or alight at this station.
</Alert>
) : (
<>
{/* ── Arriving here ─────────────────────────────────────────── */}
{arrivals.length > 0 ? (
<Stack gap="xs">
<SectionLabel
icon={<Flag size={14} />}
title="Arriving at this yard"
count={arrivals.length}
/>
{!logged ? (
<Text size="xs" c="dimmed">
Logging the pass marks the loaded bookings below as Arrived
(import/export) or Completed (intercity) automatically.
</Text>
) : null}
<Table.ScrollContainer minWidth={620}>
<Table verticalSpacing="xs" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Direction</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Arrived</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{arrivals.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Text size="sm" fw={600}>
{row.reference ?? row.id.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{row.customer}</Text>
</Table.Td>
<Table.Td>
<DirectionChip direction={row.tradeDirection} />
</Table.Td>
<Table.Td>
<BookingStatusBadge status={row.status} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{row.arrivedAt ? fmtDate(row.arrivedAt) : "—"}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Stack>
) : null}
{arrivals.length > 0 && boarders.length > 0 ? <Divider /> : null}
{/* ── Boarding here ─────────────────────────────────────────── */}
{boarders.length > 0 ? (
<Stack gap="xs">
<SectionLabel
icon={<TrainFront size={14} />}
title="Boarding at this yard"
count={boarders.length}
/>
{!logged && pendingBoarders.length > 0 ? (
<Text size="xs" c="dimmed">
Log the pass first the train must be at {station?.label} before
cargo can be loaded.
</Text>
) : null}
<Table.ScrollContainer minWidth={620}>
<Table verticalSpacing="xs" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Direction</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Loaded</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{boarders.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{row.reference ?? row.id.slice(0, 8)}
</Text>
{row.isGovernment ? (
<Badge size="xs" variant="light" color="grape">
GOV
</Badge>
) : null}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm">{row.customer}</Text>
</Table.Td>
<Table.Td>
<DirectionChip direction={row.tradeDirection} />
</Table.Td>
<Table.Td>
<BookingStatusBadge status={row.status} />
</Table.Td>
<Table.Td>
{row.loadedAt ? (
<Group gap={4} wrap="nowrap">
<CheckCircle2
size={13}
color="var(--mantine-color-edr-green-7)"
/>
<Text size="xs" c="dimmed">
{fmtDate(row.loadedAt)}
</Text>
</Group>
) : (
<Text size="xs" c="dimmed">
Not loaded
</Text>
)}
</Table.Td>
<Table.Td>
{!row.loadedAt ? (
<Tooltip
label={
!logged
? "Log the pass first — the train must be at this yard"
: !row.canLoad
? "Booking is not ready to load (payment pending)"
: "Confirm cargo loaded onto the train"
}
>
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
disabled={!logged || !row.canLoad}
loading={
load.isPending && load.variables?.bookingId === row.id
}
onClick={() => doLoad(row)}
>
Load
</Button>
</Tooltip>
) : null}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Stack>
) : null}
</>
)}
<Group justify="space-between" mt="xs">
<Text size="xs" c="dimmed">
{logged && pendingBoarders.length > 0
? `${pendingBoarders.length} booking${pendingBoarders.length === 1 ? "" : "s"} still to load before the next station.`
: ""}
</Text>
<Group gap="sm">
<Button variant="default" onClick={onClose}>
Close
</Button>
{!logged ? (
<Button
color={isFinal ? "teal" : "edr-green"}
leftSection={isFinal ? <Flag size={15} /> : <MapPin size={15} />}
loading={recordCheckpoint.isPending}
onClick={doLogPass}
>
{isFinal
? `Mark arrived at ${station?.label ?? "destination"}`
: `Log pass at ${station?.label ?? "station"}`}
</Button>
) : null}
</Group>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -166,9 +166,6 @@ export function ScheduleWorkspacePanel({
const setLoading = useMutation(
api.trainScheduling.setLoadingStatus.mutationOptions(),
);
const confirmLoading = useMutation(
api.trainScheduling.confirmLoading.mutationOptions(),
);
const moveSchedule = useMutation(
api.trainScheduling.moveBookingSchedule.mutationOptions(),
);
@@ -301,25 +298,6 @@ export function ScheduleWorkspacePanel({
);
};
const doConfirmLoading = () => {
confirmLoading
.mutateAsync({ id: schedule.id })
.then(() => {
toast({ title: "Loading confirmed", description: "The train is cleared to dispatch." });
onChanged();
})
.catch((error) =>
toast({
title: "Could not confirm loading",
description: apiErrorMessage(
error,
"Grant the Djibouti gatepass first, then confirm loading.",
),
variant: "destructive",
}),
);
};
// Point the pool booking at the chosen same-day train, then put it on wagons.
// If the wagon step fails (that train is short too) the booking stays paid &
// unassigned in the pool — nothing is lost, staff just pick another train.
@@ -460,53 +438,8 @@ export function ScheduleWorkspacePanel({
</Text>
) : null}
{/* Loading confirmation — required before dispatch for import-Djibouti
trains; shown for every direction so staff have one place to confirm. */}
{canManage ? (
<Group
gap={10}
p="sm"
wrap="nowrap"
align="center"
justify="space-between"
style={{
borderRadius: 10,
background: schedule.loadingConfirmed
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-yellow-0)",
border: `1px solid ${
schedule.loadingConfirmed
? "var(--mantine-color-edr-green-2)"
: "var(--mantine-color-yellow-3)"
}`,
}}
>
<Group gap={8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
{schedule.loadingConfirmed ? (
<CheckCircle2 size={18} color="var(--mantine-color-edr-green-7)" />
) : (
<PackageCheck size={18} color="#B7791F" />
)}
<Text size="sm" fw={600}>
{schedule.loadingConfirmed
? "Loading confirmed — cleared to dispatch"
: "Confirm loading before dispatching this train"}
</Text>
</Group>
{!schedule.loadingConfirmed ? (
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={14} />}
loading={confirmLoading.isPending}
onClick={doConfirmLoading}
>
Confirm loading
</Button>
) : null}
</Group>
) : null}
{/* Loading confirmation gate removed: bookings can board mid-corridor,
so per-yard loading happens from the track page's log-pass flow. */}
{/* Two-panel board */}
<Group align="stretch" gap="lg" grow wrap="wrap">

View File

@@ -18,6 +18,11 @@ import { LoadInventoryModal } from './LoadInventoryModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import ListControls from '@/components/common/ListControls';
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path; reused here rather than adding a second one.
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
import { useListControls } from '@/hooks/useListControls';
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import { openPdfBlob, saveBlob } from './pdf';
@@ -56,8 +61,17 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
api.warehouses.bulkMarkInspected.mutationOptions(),
);
const controls = useListControls(items, {
searchKeys: ['grnNumber', 'bookingReference', 'customerName', 'status', 'releaseOrderReference', 'notes'],
dateKey: 'arrivedAt',
});
const visible = controls.filteredRows;
const [selected, setSelected] = useState<Set<string>>(new Set());
const allSelected = items.length > 0 && selected.size === items.length;
// Select-all spans everything matching the current filters, not just the rows
// on screen — bulk "mark inspected" over one page of a filtered set would be a
// surprise. Counts compare against the filtered set for the same reason.
const allSelected = visible.length > 0 && selected.size === visible.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleSelect = (id: string) =>
setSelected((prev) => {
@@ -66,7 +80,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
return next;
});
const toggleSelectAll = () =>
setSelected(allSelected ? new Set() : new Set(items.map((i) => i.id)));
setSelected(allSelected ? new Set() : new Set(visible.map((i) => i.id)));
const markInspected = async () => {
if (selected.size === 0) {
@@ -268,8 +282,21 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
</Button>
</Group>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="GRN, container, booking, customer…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Arrived"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<WarehouseInventoryTable
items={items}
items={controls.pagedRows}
busyId={busyId}
onAdvance={advance}
onMove={setMoveItem}
@@ -287,6 +314,14 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
allSelected={allSelected}
someSelected={someSelected}
/>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="items"
onPaginationChange={controls.setPagination}
/>
</Stack>
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />

View File

@@ -417,6 +417,9 @@ export const URL_CONSTANTS = {
WAGON_TYPES: "/wagon-types",
WAGON_TYPE_BY_ID: (id: string) => `/wagon-types/${id}`,
TRUCK_TYPES: "/truck-types",
TRUCK_TYPE_BY_ID: (id: string) => `/truck-types/${id}`,
PRIORITY_CONFIGS: "/priority-configs",
PRIORITY_CONFIG_BY_ID: (id: string) => `/priority-configs/${id}`,

View File

@@ -199,7 +199,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
complete: FREIGHT_PERMS.bookings.operations,
operationAccept: FREIGHT_PERMS.bookings.operations,
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
allocateBooking: FREIGHT_PERMS.trainScheduling.update,
cancel: FREIGHT_PERMS.bookings.cancel,
};

View File

@@ -0,0 +1,164 @@
import { useEffect, useMemo, useState } from "react";
import { usePagination } from "@edr/ui-common";
/**
* Search + date-range + pagination over an already-fetched array.
*
* Client-side on purpose: the freight lists are hundreds of rows (largest table
* is ~1.1k), so filtering in the browser avoids paginating ~20 API endpoints —
* several of which sit on billing paths. If a list ever outgrows this (roughly
* 5k rows, where the per-keystroke filter starts to feel slow), move that ONE
* page to a server-side query; the component API here stays the same.
*
* Dates are `YYYY-MM-DD` strings, matching Mantine 9's date inputs. Comparing
* them lexically keeps the range on calendar days and sidesteps timezone drift
* entirely — a UTC timestamp is truncated to its date before the comparison.
*
* ponytail: linear scan per keystroke, no debounce — fine at this size; add
* a debounce (or server-side filtering) if a list gets big enough to stutter.
*/
export interface ListControlsOptions<T> {
/**
* Fields matched against the search box. Constrained to real keys of the row
* so a typo is a compile error rather than a filter that silently matches
* nothing. For nested or derived values, pass `searchValue` instead.
*/
searchKeys?: (keyof T)[];
/**
* Row's meaningful business date (arrival, invoice, dispatch…), which is what
* staff actually filter by. Falls back to `createdAt` when the row has no
* value for it, so a record is never silently invisible to a date range.
*/
dateKey?: keyof T;
/** Rows per page. */
pageSize?: number;
/** Custom search extractor when the value isn't a top-level field. */
searchValue?: (row: T) => string;
}
const readField = (row: unknown, key: string): unknown =>
row && typeof row === "object" ? (row as Record<string, unknown>)[key] : undefined;
/**
* Reduce any stored date to its `YYYY-MM-DD` calendar day. ISO strings are cut
* directly rather than parsed, so a timestamp is never shifted into the
* previous/next day by the viewer's timezone.
*/
export const toDayString = (raw: unknown): string | null => {
if (!raw) return null;
if (raw instanceof Date) {
return Number.isNaN(raw.getTime()) ? null : raw.toISOString().slice(0, 10);
}
const text = String(raw);
if (/^\d{4}-\d{2}-\d{2}/.test(text)) return text.slice(0, 10);
const parsed = new Date(text);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString().slice(0, 10);
};
/**
* Does a stored date fall inside an inclusive `YYYY-MM-DD` range? Exported for
* lists that already own their filtering (e.g. FleetResourcePage, which folds
* server-side filters and search together) so the range semantics — inclusive
* ends, undated rows excluded — stay defined in exactly one place.
*/
export const matchesDayRange = (
raw: unknown,
dateFrom: string | null,
dateTo: string | null,
): boolean => {
if (!dateFrom && !dateTo) return true;
const day = toDayString(raw);
if (!day) return false;
if (dateFrom && day < dateFrom) return false;
if (dateTo && day > dateTo) return false;
return true;
};
export const useListControls = <T,>(rows: T[], options: ListControlsOptions<T> = {}) => {
const { searchKeys = [], dateKey, pageSize = 10, searchValue } = options;
const [search, setSearch] = useState("");
const [dateFrom, setDateFrom] = useState<string | null>(null);
const [dateTo, setDateTo] = useState<string | null>(null);
const { pagination, setPagination } = usePagination({ pageSize });
const keys = searchKeys.map(String);
const keySignature = keys.join("|");
const dateKeyStr = dateKey ? String(dateKey) : undefined;
const filteredRows = useMemo(() => {
const term = search.trim().toLowerCase();
if (!term && !dateFrom && !dateTo) return rows;
return rows.filter((row) => {
if (term) {
const haystack = searchValue
? searchValue(row)
: keys.map((key) => String(readField(row, key) ?? "")).join(" ");
if (!haystack.toLowerCase().includes(term)) return false;
}
if (dateFrom || dateTo) {
const raw = dateKeyStr
? (readField(row, dateKeyStr) ?? readField(row, "createdAt"))
: null;
if (!matchesDayRange(raw, dateFrom, dateTo)) return false;
}
return true;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, search, dateFrom, dateTo, keySignature, dateKeyStr, searchValue]);
// Narrowing the result set can strand the user on a page that no longer
// exists (filter to 3 rows while on page 5 → empty table). Snap back to the
// first page whenever the filters change.
useEffect(() => {
setPagination((prev) => (prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }));
}, [search, dateFrom, dateTo, setPagination]);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
const pagedRows = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filteredRows.slice(start, start + pagination.pageSize);
}, [filteredRows, pagination.pageIndex, pagination.pageSize]);
const hasFilters = Boolean(search || dateFrom || dateTo);
const reset = () => {
setSearch("");
setDateFrom(null);
setDateTo(null);
};
return {
search,
setSearch,
dateFrom,
setDateFrom,
dateTo,
setDateTo,
hasFilters,
reset,
filteredRows,
pagedRows,
pageCount,
pagination,
setPagination,
totalCount: filteredRows.length,
/** Spread straight onto <DataTable /> so every list paginates identically. */
tableProps: {
pagination: {
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredRows.length,
},
tableOptions: {
manualPagination: true as const,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
},
},
};
};

View File

@@ -46,7 +46,10 @@ export const FREIGHT_PERMS = {
approveDirector: "edr_freight_app:contracts:approve_director",
approveCeo: "edr_freight_app:contracts:approve_ceo",
generateContract: "edr_freight_app:contracts:generate_contract",
signStaff: "edr_freight_app:contracts:sign_staff",
signStaff: {
bulk: "edr_freight_app:contracts:sign_staff:bulk",
container: "edr_freight_app:contracts:sign_staff:container",
},
clearanceReview: "edr_freight_app:contracts:clearance_review",
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
createBooking: "edr_freight_app:contracts:create_booking",
@@ -57,7 +60,6 @@ export const FREIGHT_PERMS = {
},
trainScheduling: {
view: "edr_freight_app:train_scheduling:view",
manage: "edr_freight_app:train_scheduling:manage",
create: "edr_freight_app:train_scheduling:create",
update: "edr_freight_app:train_scheduling:update",
cancel: "edr_freight_app:train_scheduling:cancel",
@@ -509,8 +511,18 @@ export function canViewScheduling(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.trainScheduling.view);
}
/** Any train-scheduling write action (create / update / cancel / reschedule). */
export function canManageScheduling(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.trainScheduling.manage);
return (
hasPermission(user, FREIGHT_PERMS.trainScheduling.create) ||
hasPermission(user, FREIGHT_PERMS.trainScheduling.update) ||
hasPermission(user, FREIGHT_PERMS.trainScheduling.cancel) ||
hasPermission(user, FREIGHT_PERMS.trainScheduling.reschedule)
);
}
export function canCreateSchedule(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.trainScheduling.create);
}
export function canViewFleet(user: AuthUser | null | undefined): boolean {
@@ -546,12 +558,17 @@ export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.admin);
}
export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`;
export type RuleEngineAction = "view" | "create" | "update" | "delete";
export function ruleEngineActionKey(
slug: RuleEngineResourceSlug,
action: RuleEngineAction,
): string {
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:${action}`;
}
export function ruleEngineManageKey(slug: RuleEngineResourceSlug): string {
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`;
export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
return ruleEngineActionKey(slug, "view");
}
/**
@@ -572,10 +589,19 @@ export function canApproveRuleEngineChange(
export function canAccessRuleEngineResource(
user: AuthUser | null | undefined,
slug: RuleEngineResourceSlug,
mode: "view" | "manage",
mode: RuleEngineAction,
): boolean {
const key = mode === "manage" ? ruleEngineManageKey(slug) : ruleEngineViewKey(slug);
return hasPermission(user, key);
return hasPermission(user, ruleEngineActionKey(slug, mode));
}
/** Holds any write action on the resource — for surfaces gated on "can edit at all". */
export function canWriteRuleEngineResource(
user: AuthUser | null | undefined,
slug: RuleEngineResourceSlug,
): boolean {
return (["create", "update", "delete"] as const).some((a) =>
canAccessRuleEngineResource(user, slug, a),
);
}
export function canAccessAnyRuleEngineView(

View File

@@ -20,6 +20,8 @@ import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuc
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { contractsService } from "@/services/contracts.service";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
/**
* Staff contract preview + sign. Staff must open and read the generated
@@ -30,6 +32,7 @@ export default function ContractViewPage() {
const navigate = useNavigate();
const qc = useQueryClient();
const iframeRef = useRef<HTMLIFrameElement>(null);
const { user } = useAuth();
const [signOpen, setSignOpen] = useState(false);
const [successOpen, setSuccessOpen] = useState(false);
@@ -116,6 +119,15 @@ export default function ContractViewPage() {
);
}
// Counter-signature permission is split per freight type — a bulk signer must
// not sign a container contract (API enforces the same on POST /contract/sign).
const maySign = hasPermission(
user,
FREIGHT_PERMS.contracts.signStaff[
data.freightType === "BULK" ? "bulk" : "container"
],
);
return (
<Box p={{ base: "md", md: "xl" }}>
<Box maw={920} mx="auto">
@@ -145,7 +157,7 @@ export default function ContractViewPage() {
>
Download PDF
</Button>
{data.canSignStaff && (
{data.canSignStaff && maySign && (
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}

View File

@@ -18,6 +18,11 @@ import {
} from "@mantine/core";
import { Plus, AlertTriangle } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import { useToast } from "@/hooks/use-toast";
import {
complianceService,
@@ -86,6 +91,11 @@ export default function CompliancePage() {
},
});
const controls = useListControls(records as ComplianceRecord[], {
searchKeys: ["type", "status", "documentNumber"],
dateKey: "expiryDate",
});
const createMutation = useMutation({
mutationFn: async (data: typeof formData) => {
const res = await complianceService.create({
@@ -210,6 +220,18 @@ export default function CompliancePage() {
Compliance Records
</Title>
<Card withBorder>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search type, status, document no…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Expiry"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
@@ -239,7 +261,7 @@ export default function CompliancePage() {
</Table.Td>
</Table.Tr>
) : null}
{(records as ComplianceRecord[]).map((record) => (
{controls.pagedRows.map((record) => (
<Table.Tr key={record.id}>
<Table.Td>{vehicleLabel(record)}</Table.Td>
<Table.Td>
@@ -259,6 +281,13 @@ export default function CompliancePage() {
))}
</Table.Tbody>
</Table>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="records"
onPaginationChange={controls.setPagination}
/>
</Card>
{/* Modal */}

View File

@@ -1,10 +1,11 @@
import type { ColumnDef } from "@edr/ui-common";
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import { canFleetAction } from "@/lib/permissions";
import { canFleetAction, hasPermission, FREIGHT_PERMS } from "@/lib/permissions";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { Inbox, Plus, Warehouse } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
@@ -15,6 +16,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog";
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { matchesDayRange } from "@/hooks/useListControls";
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
import WagonTransferRequestsModal from "@/components/wagons/WagonTransferRequestsModal";
@@ -43,10 +45,20 @@ const FleetResourcePage = () => {
const canCreate = canFleetAction(user, slug, "create");
const canUpdate = canFleetAction(user, slug, "update");
const canDelete = canFleetAction(user, slug, "delete");
// Wagon transfer workspace: shown only to holders of a transfer capability
// (raise a request, fulfill one, or see the cross-yard history).
const canTransfer =
hasPermission(user, FREIGHT_PERMS.wagons.transferRequest) ||
hasPermission(user, FREIGHT_PERMS.wagons.transferFulfill) ||
hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll);
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
// Registration date range. Server-side list filters (status/yard/train) are
// applied by the API; this narrows what comes back, alongside search.
const [dateFrom, setDateFrom] = useState<string | null>(null);
const [dateTo, setDateTo] = useState<string | null>(null);
const [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FleetRecord | null>(null);
@@ -94,6 +106,9 @@ const FleetResourcePage = () => {
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
api.wagonTypes.list.queryOptions(),
);
const { data: truckTypes = [], isLoading: truckTypesLoading } = useQuery(
api.truckTypes.list.queryOptions(),
);
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
);
@@ -122,7 +137,7 @@ const FleetResourcePage = () => {
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
}, [search, listFilterValues, setPagination]);
}, [search, listFilterValues, dateFrom, dateTo, setPagination]);
const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status"));
const usesServerListFilters = Boolean(config?.listFilters?.length);
@@ -169,17 +184,34 @@ const FleetResourcePage = () => {
(y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }),
);
// Carries capacity + trailer configuration so picking a truck type can
// pre-fill the vehicle's capacity and drop the trailer plate on a rigid type.
const truckTypeOpts = (
truckTypes as Array<{
id: string;
code: string;
name?: string;
capacityTons?: number | null;
hasTrailer?: boolean;
}>
).map((t) => ({
value: t.id,
label: t.name ? `${t.name} (${t.code})` : t.code,
meta: { capacityTons: t.capacityTons, hasTrailer: t.hasTrailer },
}));
registerFleetOptionLabels("currentYardId", yardOpts);
return {
wagonTypes: wagonTypeOpts,
containerTypes: containerTypeOpts,
cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts],
truckTypes: truckTypeOpts,
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
containers: containerOpts,
yards: yardOpts,
};
}, [wagonTypes, containerTypes, cargoTypes, wagons, containers, yards]);
}, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards]);
const listFilterSelects = useMemo(() => {
if (!config?.listFilters?.length) return null;
@@ -212,6 +244,7 @@ const FleetResourcePage = () => {
registerFleetOptionLabels("containerId", dynamicOptions.containers);
registerFleetOptionLabels("currentYardId", dynamicOptions.yards);
registerFleetOptionLabels("locationId", dynamicOptions.yards);
registerFleetOptionLabels("truckTypeId", dynamicOptions.truckTypes);
}, [dynamicOptions]);
const formFields = useMemo((): FleetFormFieldDef[] => {
@@ -227,16 +260,20 @@ const FleetResourcePage = () => {
wagonTypesLoading ||
containerTypesLoading ||
cargoTypesLoading ||
truckTypesLoading ||
wagonsLoading ||
containersLoading ||
yardsLoading;
const filteredRows = useMemo(() => {
if (!config) return allRows;
if (usesServerListFilters) return allRows;
const term = search.trim().toLowerCase();
return allRows.filter((row) => {
const record = row as unknown as Record<string, unknown>;
// The date range applies even when the API already filtered the list —
// it is not one of the server-side filters.
if (!matchesDayRange(record.createdAt, dateFrom, dateTo)) return false;
if (usesServerListFilters) return true;
if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) {
return false;
}
@@ -247,7 +284,7 @@ const FleetResourcePage = () => {
.includes(term),
);
});
}, [allRows, search, statusFilter, config, usesServerListFilters]);
}, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo]);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
const pagedRows = useMemo(() => {
@@ -411,15 +448,17 @@ const FleetResourcePage = () => {
Yard Workspace
</Button>
) : null}
<Button
variant="light"
color="grape"
leftSection={<Inbox size={16} />}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setTransferRequestsOpen(true)}
>
Transfer Requests
</Button>
{canTransfer ? (
<Button
variant="light"
color="grape"
leftSection={<Inbox size={16} />}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setTransferRequestsOpen(true)}
>
Transfer Requests
</Button>
) : null}
</>
) : null}
{canCreate ? (
@@ -444,7 +483,30 @@ const FleetResourcePage = () => {
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
listFilterSelects ? (
<Group gap="sm" wrap="wrap" align="center">
<DatePickerInput
aria-label="Created from"
placeholder="Created from"
value={dateFrom}
onChange={setDateFrom}
maxDate={dateTo ?? undefined}
clearable
size="sm"
radius="lg"
w={160}
/>
<DatePickerInput
aria-label="Created to"
placeholder="Created to"
value={dateTo}
onChange={setDateTo}
minDate={dateFrom ?? undefined}
clearable
size="sm"
radius="lg"
w={160}
/>
{listFilterSelects ? (
<Group gap="sm" wrap="wrap" align="center">
{listFilterSelects.map((filter) => (
<Select
@@ -487,7 +549,8 @@ const FleetResourcePage = () => {
))}
</Group>
</Group>
) : undefined
) : null}
</Group>
}
/>
</Box>

View File

@@ -19,6 +19,11 @@ import {
} from "@mantine/core";
import { Plus } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/auth/http";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
@@ -118,6 +123,11 @@ export default function FuelPurchasePage() {
const totalCost = formData.liters * formData.costPerLiter;
// Aggregate stats (guarded against divide-by-zero when there are no purchases)
const controls = useListControls(purchasesData as FuelPurchase[], {
searchKeys: ["fuelStation", "paymentMethod"],
dateKey: "purchaseDate",
});
const totalLiters = (purchasesData as FuelPurchase[]).reduce(
(sum, p) => sum + Number(p.liters),
0
@@ -185,6 +195,18 @@ export default function FuelPurchasePage() {
{/* Purchases Table */}
<Card withBorder>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search station or payment method…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Purchased"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
@@ -215,7 +237,7 @@ export default function FuelPurchasePage() {
</Table.Td>
</Table.Tr>
) : null}
{(purchasesData as FuelPurchase[])?.map((purchase) => (
{controls.pagedRows.map((purchase) => (
<Table.Tr key={purchase.id}>
<Table.Td>{(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId}</Table.Td>
<Table.Td>{new Date(purchase.purchaseDate).toLocaleDateString()}</Table.Td>
@@ -230,6 +252,13 @@ export default function FuelPurchasePage() {
))}
</Table.Tbody>
</Table>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="purchases"
onPaginationChange={controls.setPagination}
/>
</Card>
{/* Modal */}

View File

@@ -20,6 +20,11 @@ import {
} from "@mantine/core";
import { Plus } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import { useToast } from "@/hooks/use-toast";
import {
incidentsService,
@@ -161,6 +166,10 @@ export default function IncidentsPage() {
})) || [];
const incidents = incidentsData as Incident[];
const controls = useListControls(incidents, {
searchKeys: ["type", "severity", "status"],
dateKey: "occurredAt",
});
const totalCount = incidents.length;
const openCount = incidents.filter((i) => OPEN_STATUSES.includes(i.status)).length;
const underReviewCount = incidents.filter((i) => i.status === "UNDER_REVIEW").length;
@@ -237,6 +246,18 @@ export default function IncidentsPage() {
{/* Incidents Table */}
<Card withBorder>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search type, severity, status…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Occurred"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
@@ -267,7 +288,7 @@ export default function IncidentsPage() {
</Table.Td>
</Table.Tr>
) : null}
{incidents.map((incident) => (
{controls.pagedRows.map((incident) => (
<Table.Tr key={incident.id}>
<Table.Td>{new Date(incident.occurredAt).toLocaleDateString()}</Table.Td>
<Table.Td>
@@ -294,6 +315,13 @@ export default function IncidentsPage() {
))}
</Table.Tbody>
</Table>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="incidents"
onPaginationChange={controls.setPagination}
/>
</Card>
{/* Modal */}

View File

@@ -1,14 +1,18 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Button,
Card,
Center,
Container,
Group,
Loader,
NumberInput,
Radio,
Select,
SimpleGrid,
Stack,
Table,
@@ -20,6 +24,7 @@ import {
import {
ArrowLeft,
Fuel,
Gauge,
History,
Route,
Truck,
@@ -28,7 +33,13 @@ import {
} from "lucide-react";
import { api } from "@/auth/http";
import { vehiclesService } from "@/services/vehicles.service";
import { api as apiClient2 } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import {
vehiclesService,
type SaveVehiclePayload,
type Vehicle,
} from "@/services/vehicles.service";
import { driversService } from "@/services/drivers.service";
import { fleetHistoryService } from "@/services/fleet-history.service";
@@ -132,6 +143,7 @@ const VehicleDetailPage = () => {
<Tabs.Tab value="history" leftSection={<History size={14} />}>History</Tabs.Tab>
<Tabs.Tab value="maintenance" leftSection={<Wrench size={14} />}>Maintenance</Tabs.Tab>
<Tabs.Tab value="fuel" leftSection={<Fuel size={14} />}>Fuel</Tabs.Tab>
<Tabs.Tab value="operations" leftSection={<Gauge size={14} />}>Operations</Tabs.Tab>
<Tabs.Tab value="mile" leftSection={<Route size={14} />}>First/Last mile</Tabs.Tab>
</Tabs.List>
@@ -142,6 +154,8 @@ const VehicleDetailPage = () => {
<InfoRow label="Code" value={vehicle.code ?? "—"} />
<InfoRow label="Registration" value={vehicle.registrationNumber ?? "—"} />
<InfoRow label="Type" value={vehicle.vehicleType ?? "—"} />
<InfoRow label="VIN" value={vehicle.vin ?? "—"} />
<InfoRow label="Ownership" value={vehicle.ownership ?? "—"} />
<InfoRow label="Manufacturer" value={vehicle.manufacturer ?? "—"} />
<InfoRow label="Model" value={vehicle.model ?? "—"} />
<InfoRow label="Year" value={vehicle.year ?? "—"} />
@@ -172,6 +186,10 @@ const VehicleDetailPage = () => {
<FuelTab vehicleId={id} />
</Tabs.Panel>
<Tabs.Panel value="operations" pt="lg">
<OperationsTab vehicle={vehicle} />
</Tabs.Panel>
<Tabs.Panel value="mile" pt="lg">
<MileTab vehicleId={id} />
</Tabs.Panel>
@@ -181,6 +199,103 @@ const VehicleDetailPage = () => {
);
};
/**
* Where a truck is and what it costs to run are per-trip operational facts, not
* part of registering the vehicle — so they are edited here rather than on the
* Add Vehicle form. `pricePerKm` is live billing input: first/last-mile charges
* are `distance × pricePerKm`.
*/
const OperationsTab = ({ vehicle }: { vehicle: Vehicle }) => {
const { toast } = useToast();
const queryClient = useQueryClient();
const [form, setForm] = useState({
locationId: vehicle.locationId ?? "",
estimatedDistanceKm: vehicle.estimatedDistanceKm ?? "",
actualDistanceKm: vehicle.actualDistanceKm ?? "",
pricePerKm: vehicle.pricePerKm ?? "",
currency: vehicle.currency ?? "ETB",
});
const { data: yards = [], isLoading: yardsLoading } = useQuery(
apiClient2.routes.yards.queryOptions(),
);
const save = useMutation({
mutationFn: () =>
vehiclesService.update(vehicle.id, {
locationId: form.locationId || null,
// Empty means "not recorded" — send null so the column is unset rather
// than coerced to 0, which would read as a real measurement.
estimatedDistanceKm: form.estimatedDistanceKm === "" ? null : Number(form.estimatedDistanceKm),
actualDistanceKm: form.actualDistanceKm === "" ? null : Number(form.actualDistanceKm),
pricePerKm: form.pricePerKm === "" ? null : Number(form.pricePerKm),
currency: form.currency || null,
} as Partial<SaveVehiclePayload> & { locationId?: string | null }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["vehicle", vehicle.id] });
toast({ title: "Operational details saved" });
},
onError: () =>
toast({ title: "Could not save operational details", variant: "destructive" }),
});
return (
<Card withBorder radius="md" padding="md">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<Select
label="Location"
placeholder={yardsLoading ? "Loading yards..." : "Not set"}
data={(yards as Array<{ id: string; label?: string; code?: string }>).map((y) => ({
value: y.id,
label: y.label ?? y.code ?? y.id,
}))}
value={form.locationId || null}
onChange={(v) => setForm((f) => ({ ...f, locationId: v ?? "" }))}
disabled={yardsLoading}
searchable
clearable
/>
<NumberInput
label="Price per KM"
description="Used for first/last-mile billing"
value={form.pricePerKm}
onChange={(v) => setForm((f) => ({ ...f, pricePerKm: v as number | "" }))}
min={0}
/>
<NumberInput
label="Estimated Distance (KM)"
value={form.estimatedDistanceKm}
onChange={(v) => setForm((f) => ({ ...f, estimatedDistanceKm: v as number | "" }))}
min={0}
/>
<NumberInput
label="Actual Distance (KM)"
value={form.actualDistanceKm}
onChange={(v) => setForm((f) => ({ ...f, actualDistanceKm: v as number | "" }))}
min={0}
/>
<Radio.Group
label="Currency"
value={form.currency}
onChange={(v) => setForm((f) => ({ ...f, currency: v }))}
>
<Group gap="lg" mt={6}>
<Radio value="ETB" label="ETB" />
<Radio value="USD" label="USD" />
</Group>
</Radio.Group>
</SimpleGrid>
<Group justify="flex-end">
<Button onClick={() => save.mutate()} loading={save.isPending}>
Save
</Button>
</Group>
</Stack>
</Card>
);
};
const DriverTab = ({
vehicleId,
driverId,

View File

@@ -30,10 +30,22 @@ export type FleetDynamicOptions =
| "wagonTypes"
| "containerTypes"
| "cargoTypes"
| "truckTypes"
| "wagons"
| "containers"
| "yards";
/**
* A dynamic select option that can carry the record it came from. Picking a
* truck type has to pull its capacity and trailer configuration into the form,
* which a bare {label, value} pair cannot express.
*/
export interface FleetSelectOption {
label: string;
value: string;
meta?: Record<string, unknown>;
}
export interface FleetResourceColumn {
id: string;
header: string;
@@ -45,6 +57,17 @@ export interface FleetResourceColumn {
export interface FleetFormFieldDef extends FormFieldDef {
dynamicOptions?: FleetDynamicOptions;
noneOption?: boolean;
/** Options carrying their source record, so `onOptionSelected` can read it. */
options?: FleetSelectOption[];
/**
* Patch merged into the form when this select changes — for values that are a
* property of the chosen option rather than typed per record (a vehicle's
* capacity comes from its truck type). Returns the fields to overwrite.
*/
onOptionSelected?: (
option: FleetSelectOption | undefined,
values: Record<string, unknown>,
) => Record<string, unknown>;
/**
* Read-only field whose value is computed from the other fields rather than
* typed. Rendered non-editable and recomputed on every change, so the stored

View File

@@ -10,6 +10,16 @@ const PLATE_PATTERN = {
message: "Use letters and numbers like ET-9875 or AA-8642",
};
/**
* Legacy static list. Truck configurations now live in `freight.truck_types`
* and are edited in the back office (Rule Engine → Truck Types), so the form
* loads them through `dynamicOptions: "truckTypes"` instead.
*
* Kept only for the driver "authorized vehicle types" multiselect, which stores
* free-text categories rather than truck-type ids.
*
* @deprecated prefer the managed truck types
*/
const VEHICLE_TYPE_OPTIONS = [
{ label: "Truck", value: "TRUCK" },
{ label: "Van", value: "VAN" },
@@ -20,6 +30,11 @@ const VEHICLE_TYPE_OPTIONS = [
{ label: "Flatbed", value: "FLATBED" },
];
const OWNERSHIP_OPTIONS = [
{ label: "Owned", value: "OWNED" },
{ label: "Outsourced", value: "OUTSOURCED" },
];
const FUEL_TYPE_OPTIONS = [
{ label: "Petrol", value: "PETROL" },
{ label: "Diesel", value: "DIESEL" },
@@ -39,11 +54,6 @@ const VEHICLE_AVAILABILITY_OPTIONS = [
{ label: "Busy", value: "BUSY" },
];
const CURRENCY_OPTIONS = [
{ label: "ETB", value: "ETB" },
{ label: "USD", value: "USD" },
];
export const vehiclesConfig: FleetResourceConfig = {
slug: "vehicles",
label: "Vehicles",
@@ -72,35 +82,65 @@ export const vehiclesConfig: FleetResourceConfig = {
options: VEHICLE_AVAILABILITY_OPTIONS,
},
],
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"],
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "vin", "status"],
columns: [
{ id: "code", header: "Code", accessorKey: "code", size: 90 },
{ id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", size: 120 },
{ id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", size: 130 },
{ id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 120 },
{ id: "model", header: "Model", accessorKey: "model", size: 100 },
{ id: "vehicleType", header: "Type", accessorKey: "vehicleType", size: 75 },
{ id: "truckTypeId", header: "Truck Type", accessorKey: "truckTypeId", format: "entityLabel", size: 130 },
{ id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 100 },
{ id: "locationId", header: "Location", accessorKey: "locationId", size: 140 },
{ id: "ownership", header: "Ownership", accessorKey: "ownership", size: 100 },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
{ id: "availability", header: "Availability", accessorKey: "availability", format: "statusBadge", size: 100 },
],
// Registration captures what the vehicle IS. Location, distances and haulage
// pricing are per-trip operational data and live on the vehicle's Operations
// tab instead (VehicleDetailPage) — they are not part of registering a truck.
formFields: [
{ name: "code", label: "Code", type: "text" },
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true, pattern: PLATE_PATTERN },
// { name: "powerPlateNo", label: "Power Plate No", type: "text" },
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text", pattern: PLATE_PATTERN },
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
// The truck type decides whether a trailer exists at all: a rigid truck
// (Casoni) has none, so the field disappears and submits null. Mirrored
// server-side in VehiclesService — hiding a field is not enforcement.
{
name: "trailerPlateNo",
label: "Trailer Plate No",
type: "text",
pattern: PLATE_PATTERN,
showIf: (values) => values._hasTrailer !== false,
},
{
name: "truckTypeId",
label: "Truck Type",
type: "select",
required: true,
dynamicOptions: "truckTypes",
description: "Managed in Rule Engine → Truck Types",
// Capacity is a property of the type, not of each individual truck.
// `_hasTrailer` is form-local scratch (stripped before submit) that drives
// the trailer plate's visibility.
onOptionSelected: (option) => ({
_hasTrailer: option?.meta?.hasTrailer ?? true,
...(option?.meta?.capacityTons != null
? { capacity: option.meta.capacityTons }
: {}),
}),
},
{ name: "vin", label: "VIN", type: "text", description: "Vehicle Identification Number" },
{ name: "ownership", label: "Ownership", type: "radio", options: OWNERSHIP_OPTIONS },
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },
{ name: "model", label: "Model", type: "text", required: true },
{ name: "year", label: "Year", type: "number", required: true },
{ name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS },
{ name: "capacity", label: "Capacity", type: "number", required: true },
{ name: "locationId", label: "Location", type: "select", dynamicOptions: "yards" },
{ name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" },
{ name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" },
{ name: "pricePerKm", label: "Price per KM", type: "number" },
{ name: "currency", label: "Currency", type: "radio", options: CURRENCY_OPTIONS },
{
name: "capacity",
label: "Capacity (tons)",
type: "number",
required: true,
description: "Pre-filled from the truck type — override only for a one-off",
},
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
{ name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS },
{ name: "description", label: "Description", type: "textarea" },
@@ -108,19 +148,15 @@ export const vehiclesConfig: FleetResourceConfig = {
emptyValues: {
code: "03-ET",
plateNumber: "",
powerPlateNo: "",
trailerPlateNo: "",
vehicleType: "TRUCK",
truckTypeId: "",
vin: "",
ownership: "OWNED",
manufacturer: "",
model: "",
year: new Date().getFullYear(),
fuelType: "DIESEL",
capacity: 0,
locationId: null,
estimatedDistanceKm: "",
actualDistanceKm: "",
pricePerKm: "",
currency: "ETB",
status: "ACTIVE",
availability: "FREE",
description: "",

View File

@@ -229,6 +229,21 @@ const cargoDesc = (r: FirstMileRecord) => {
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
return parts.join(" · ") || "—";
};
const cargoTypeName = (r: FirstMileRecord) =>
r.booking?.cargoType?.cargoTypeName ??
r.booking?.cargoType?.label ??
r.booking?.cargoType?.name ??
r.booking?.cargoFreeText ??
"—";
const isBulkBooking = (r: FirstMileRecord) => r.booking?.freightType === "BULK";
const bookingTotalTons = (r: FirstMileRecord) => Number(r.booking?.cargoTotalWeightVgm) || 0;
const trainScheduleLabel = (r: FirstMileRecord) => {
const s = r.booking?.trainSchedule;
if (!s?.trainNumber && !s?.departureDate) return "—";
return [s.trainNumber, s.departureDate ? new Date(s.departureDate).toLocaleDateString() : null]
.filter(Boolean)
.join(" · ");
};
// First-mile destination is the origin yard (pickup → origin yard)
const destinationYardName = (r: FirstMileRecord) =>
r.booking?.originYard?.label ?? "—";
@@ -277,7 +292,9 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => {
<InfoRow label="Service type" value={serviceTypeName(record)} />
{hasPickupAddress && <InfoRow label="Pickup location" value={pickupLocation(record)} />}
<InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
<InfoRow label="Cargo Type" value={cargoTypeName(record)} />
<InfoRow label="Cargo" value={cargoDesc(record)} />
<InfoRow label="Train Schedule" value={trainScheduleLabel(record)} />
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment, currencyOf(record))} />
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment, currencyOf(record))} />
<InfoRow label="Contact" value={contactPersonName(record)} />
@@ -496,10 +513,11 @@ const FirstMilePage = () => {
const [tripSlipVehicleId, setTripSlipVehicleId] = useState<string | null>(null);
const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
// Multi-vehicle assign: one row per truck — vehicle + its load (container for
// container bookings; tonnes + optional item count for bulk).
const [vehicleRows, setVehicleRows] = useState<
Array<{ vehicleId: string | null; containerNumber: string }>
>([{ vehicleId: null, containerNumber: "" }]);
Array<{ vehicleId: string | null; containerNumber: string; tons: number | ""; quantity: number | "" }>
>([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
const [acceptOpen, setAcceptOpen] = useState(false);
const [acceptStep, setAcceptStep] = useState<1 | 2>(1);
@@ -925,13 +943,19 @@ const FirstMilePage = () => {
? rec.vehicleAssignments.map((a, i) => ({
vehicleId: a.vehicleId,
containerNumber: a.containerNumber ?? nums[i] ?? "",
tons: (a.tons != null ? Number(a.tons) : "") as number | "",
quantity: (a.quantity != null ? Number(a.quantity) : "") as number | "",
}))
: rec?.vehicleId
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }]
: [{ vehicleId: null, containerNumber: nums[0] ?? "" }];
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "", tons: "" as const, quantity: "" as const }]
: [{ vehicleId: null, containerNumber: nums[0] ?? "", tons: "" as const, quantity: "" as const }];
setBulkMode(false);
setActiveId(resolved);
setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]);
setVehicleRows(
rows.length
? rows
: [{ vehicleId: null, containerNumber: nums[0] ?? "", tons: "", quantity: "" }],
);
setAssignOpen(true);
};
@@ -944,7 +968,7 @@ const FirstMilePage = () => {
}
setBulkMode(true);
setActiveId(null);
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
setVehicleRows([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
setAssignOpen(true);
};
@@ -952,15 +976,41 @@ const FirstMilePage = () => {
setAssignOpen(false);
setBulkMode(false);
setActiveId(null);
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
setVehicleRows([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
};
const handleAssign = () => {
const bulkCargo = activeRecord != null && isBulkBooking(activeRecord);
if (bulkCargo && vehicleRows.some((r) => r.vehicleId && r.tons === "")) {
toast({
title: "Tonnes required",
description: "Enter the tonnage each truck hauls — bulk assignment draws down the booking total.",
variant: "destructive",
});
return;
}
if (bulkCargo) {
const total = bookingTotalTons(activeRecord);
const assigning = vehicleRows.reduce((s, r) => s + (r.vehicleId ? Number(r.tons) || 0 : 0), 0);
if (total > 0 && assigning > total + 0.001) {
toast({
title: "Over booking tonnage",
description: `Assigned ${assigning} t exceeds the booking's ${total} t.`,
variant: "destructive",
});
return;
}
}
const seen = new Set<string>();
const vehicles = vehicleRows
.filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId))
.filter((r): r is (typeof vehicleRows)[number] & { vehicleId: string } => Boolean(r.vehicleId))
.filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId)))
.map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null }));
.map((r) => ({
vehicleId: r.vehicleId,
containerNumber: r.containerNumber.trim() || null,
tons: bulkCargo && r.tons !== "" ? Number(r.tons) : null,
quantity: bulkCargo && r.quantity !== "" ? Number(r.quantity) : null,
}));
const count = vehicles.length;
const targetIds = bulkMode
? selectedIds
@@ -1432,30 +1482,63 @@ const FirstMilePage = () => {
clearable
disabled={assignVehicleOptions.length === 0}
/>
<Select
style={{ flex: 1 }}
label={i === 0 ? "Container no." : undefined}
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
data={[
...containerOptions.filter(
(n) =>
n === row.containerNumber ||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
),
// keep a manual/legacy value selectable even if not in the booking
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
? [row.containerNumber]
: []),
]}
value={row.containerNumber || null}
onChange={(value) =>
setVehicleRows((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
)
}
searchable
clearable
/>
{!bulkMode && activeRecord && isBulkBooking(activeRecord) ? (
<>
<NumberInput
style={{ flex: 0.8 }}
label={i === 0 ? "Tonnes" : undefined}
placeholder="t"
min={0}
value={row.tons}
onChange={(v) =>
setVehicleRows((prev) =>
prev.map((x, idx) =>
idx === i ? { ...x, tons: v === "" ? "" : Number(v) } : x,
),
)
}
/>
<NumberInput
style={{ flex: 0.8 }}
label={i === 0 ? "Items qty (pcs)" : undefined}
placeholder="optional"
min={0}
value={row.quantity}
onChange={(v) =>
setVehicleRows((prev) =>
prev.map((x, idx) =>
idx === i ? { ...x, quantity: v === "" ? "" : Number(v) } : x,
),
)
}
/>
</>
) : (
<Select
style={{ flex: 1 }}
label={i === 0 ? "Container no." : undefined}
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
data={[
...containerOptions.filter(
(n) =>
n === row.containerNumber ||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
),
// keep a manual/legacy value selectable even if not in the booking
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
? [row.containerNumber]
: []),
]}
value={row.containerNumber || null}
onChange={(value) =>
setVehicleRows((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
)
}
searchable
clearable
/>
)}
{vehicleRows.length > 1 && (
<ActionIcon
variant="subtle"
@@ -1479,6 +1562,8 @@ const FirstMilePage = () => {
vehicleId: null,
containerNumber:
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
tons: "" as const,
quantity: "" as const,
},
])
}
@@ -1491,6 +1576,22 @@ const FirstMilePage = () => {
>
Add vehicle
</Button>
{!bulkMode && activeRecord && isBulkBooking(activeRecord) && (() => {
const total = bookingTotalTons(activeRecord);
const assigning = vehicleRows.reduce(
(s, r) => s + (r.vehicleId ? Number(r.tons) || 0 : 0),
0,
);
const remaining = Math.round((total - assigning) * 1000) / 1000;
return (
<Text size="sm" c={remaining < 0 ? "red" : "dimmed"}>
Bulk drawdown: {assigning} t of {total} t assigned {" "}
<Text span fw={600} c={remaining < 0 ? "red" : undefined}>
{remaining} t remaining
</Text>
</Text>
);
})()}
</Stack>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeAssign}>Cancel</Button>

View File

@@ -114,7 +114,9 @@ const CargoTypesPage = () => {
const config = getRuleEngineResource(CARGO_SLUG);
const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view");
const canManage = canAccessRuleEngineResource(user, CARGO_SLUG, "manage");
const canCreate = canAccessRuleEngineResource(user, CARGO_SLUG, "create");
const canUpdate = canAccessRuleEngineResource(user, CARGO_SLUG, "update");
const canDelete = canAccessRuleEngineResource(user, CARGO_SLUG, "delete");
// One fetch of the whole (small) set — page-walked because the API caps
// pageSize at 100; the tree, ancestry and each level are derived client-side
@@ -128,7 +130,7 @@ const CargoTypesPage = () => {
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
// Wagon-type options for the "Wagon types" picker (bulk cargo → allowed list).
const { data: wagonTypeOptions } = useWagonTypeOptions(canManage);
const { data: wagonTypeOptions } = useWagonTypeOptions(canCreate || canUpdate);
const formFields = useMemo<FormFieldDef[]>(
() =>
FORM_FIELDS.map((field) =>
@@ -291,7 +293,7 @@ const CargoTypesPage = () => {
leftSection={<Search size={16} />}
w={240}
/>
{canManage && (
{canCreate && (
<Button
color="teal"
leftSection={<Plus size={16} />}
@@ -335,7 +337,7 @@ const CargoTypesPage = () => {
? "No cargo categories yet"
: `No cargo types under “${str(current?.cargoTypeName)}” yet`}
</Text>
{!term && canManage && (
{!term && canCreate && (
<Button
variant="light"
color="teal"
@@ -354,7 +356,8 @@ const CargoTypesPage = () => {
node={node}
childCount={(childrenOf.get(node.id) ?? []).length}
topBorder={i > 0}
canManage={canManage}
canUpdate={canUpdate}
canDelete={canDelete}
onOpen={() => navigate(`${BASE_PATH}/${node.id}`)}
onEdit={() => setFormMode({ kind: "edit", record: node })}
onDelete={() => setDeleteTarget(node)}
@@ -444,7 +447,8 @@ interface CargoRowProps {
node: CargoNode;
childCount: number;
topBorder: boolean;
canManage: boolean;
canUpdate: boolean;
canDelete: boolean;
onOpen: () => void;
onEdit: () => void;
onDelete: () => void;
@@ -454,7 +458,8 @@ function CargoRow({
node,
childCount,
topBorder,
canManage,
canUpdate,
canDelete,
onOpen,
onEdit,
onDelete,
@@ -529,19 +534,19 @@ function CargoRow({
</UnstyledButton>
<Group gap={4} wrap="nowrap">
{canManage && (
<>
<Tooltip label="Edit" withArrow>
<Button size="compact-sm" variant="subtle" color="gray" onClick={onEdit} px={8}>
<Pencil size={15} />
</Button>
</Tooltip>
<Tooltip label="Delete" withArrow>
<Button size="compact-sm" variant="subtle" color="red" onClick={onDelete} px={8}>
<Trash2 size={15} />
</Button>
</Tooltip>
</>
{canUpdate && (
<Tooltip label="Edit" withArrow>
<Button size="compact-sm" variant="subtle" color="gray" onClick={onEdit} px={8}>
<Pencil size={15} />
</Button>
</Tooltip>
)}
{canDelete && (
<Tooltip label="Delete" withArrow>
<Button size="compact-sm" variant="subtle" color="red" onClick={onDelete} px={8}>
<Trash2 size={15} />
</Button>
</Tooltip>
)}
<Tooltip label="Open" withArrow>
<Button size="compact-sm" variant="subtle" color="teal" onClick={onOpen} px={8}>

View File

@@ -150,9 +150,20 @@ const RuleEngineResourcePage = () => {
const canView = Boolean(
config && canAccessRuleEngineResource(user, config.slug, "view"),
);
const canManage = Boolean(
config && canAccessRuleEngineResource(user, config.slug, "manage"),
// Per-action gates replace the retired coarse "manage": Add shows only with
// create, row Edit with update, row Delete with delete.
const canCreate = Boolean(
config && canAccessRuleEngineResource(user, config.slug, "create"),
);
const canUpdate = Boolean(
config && canAccessRuleEngineResource(user, config.slug, "update"),
);
const canDelete = Boolean(
config && canAccessRuleEngineResource(user, config.slug, "delete"),
);
// Update-class controls (reorder, rate submit/approve, approval-rule decide)
// all map to the update permission — the matching endpoints now require it.
const canUpdateControls = canUpdate;
const listParams = useMemo(
() => ({
@@ -480,7 +491,7 @@ const RuleEngineResourcePage = () => {
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
<Group gap="xs" wrap="nowrap" justify="flex-end">
{config.orderConfig && canManage ? (
{config.orderConfig && canUpdateControls ? (
<RuleEngineOrderControls
record={row.original}
orderConfig={config.orderConfig}
@@ -493,7 +504,7 @@ const RuleEngineResourcePage = () => {
record={row.original}
config={config}
layout="row"
readOnly={!canManage}
readOnly={!canUpdateControls}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
@@ -504,8 +515,8 @@ const RuleEngineResourcePage = () => {
? () => setChainOpen(true)
: undefined
}
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
onApproveRate={canManage ? handleApproveRate : undefined}
onSubmitRate={canUpdateControls ? (id) => submit.mutate(id) : undefined}
onApproveRate={canUpdateControls ? handleApproveRate : undefined}
/>
</Group>
</div>
@@ -514,7 +525,7 @@ const RuleEngineResourcePage = () => {
return base;
}, [
canManage,
canUpdateControls,
config,
isRates,
pendingByRateId,
@@ -642,7 +653,7 @@ const RuleEngineResourcePage = () => {
title={config.label}
subtitle={config.subtitle}
action={
canManage && config.slug !== "container-types" ? (
canCreate && config.slug !== "container-types" ? (
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
{addLabel}
</Button>
@@ -653,7 +664,7 @@ const RuleEngineResourcePage = () => {
{isPriorityRules ? (
<PriorityRuleApprovalsSection
requests={priorityWorkflow.pending.data ?? []}
canDecide={canManage}
canDecide={canUpdateControls}
approve={priorityWorkflow.approve}
reject={priorityWorkflow.reject}
/>
@@ -742,7 +753,7 @@ const RuleEngineResourcePage = () => {
showSearch={Boolean(config.supportsSearch)}
searchPlaceholder={config.searchPlaceholder}
onManageOrder={
canManage && config.orderConfig
canUpdateControls && config.orderConfig
? () => setOrderDialogOpen(true)
: undefined
}
@@ -806,16 +817,16 @@ const RuleEngineResourcePage = () => {
pageCount={pageCount}
totalCount={totalCount}
onPaginationChange={setPagination}
readOnly={!canManage}
onEdit={canManage ? openEdit : undefined}
onDelete={canManage ? setDeleteTarget : undefined}
readOnly={!canUpdate && !canDelete}
onEdit={canUpdate ? openEdit : undefined}
onDelete={canDelete ? setDeleteTarget : undefined}
onViewChain={
config.slug === "approval-rules"
? () => setChainOpen(true)
: undefined
}
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
onApproveRate={canManage ? handleApproveRate : undefined}
onSubmitRate={canUpdateControls ? (id) => submit.mutate(id) : undefined}
onApproveRate={canUpdateControls ? handleApproveRate : undefined}
/>
)}
</Stack>

View File

@@ -409,6 +409,43 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "isActive", label: "Active", type: "boolean" },
],
},
{
slug: "truck-types",
label: "Truck Types",
category: "configuration",
subtitle:
"Configure the truck configurations vehicles are registered against — capacity and whether a trailer applies",
searchPlaceholder: "Search truck types by name or code...",
cardTitleKey: "name",
columns: [
codeColumn("code"),
{ id: "name", header: "Name", accessorKey: "name" },
{ id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" },
{ id: "hasTrailer", header: "Has trailer", accessorKey: "hasTrailer" },
activeColumn,
],
formFields: [
{ name: "code", label: "Code", type: "text", required: true },
{ name: "name", label: "Name", type: "text", required: true },
{
name: "capacityTons",
label: "Capacity (tons)",
type: "number",
optional: true,
description: "Pre-fills the capacity of every vehicle registered on this type",
},
// Drives the trailer plate on vehicle registration: a rigid truck (Casoni)
// has none, so registering one with a trailer plate is rejected.
{
name: "hasTrailer",
label: "Pulls a trailer",
type: "boolean",
description: "Off for a rigid truck (e.g. Casoni) — its registration has no trailer plate",
},
{ name: "description", label: "Description", type: "textarea", optional: true },
{ name: "isActive", label: "Active", type: "boolean" },
],
},
{
slug: "yard-distances",
label: "Yard Distances",

View File

@@ -1,5 +1,6 @@
import { Link, useParams } from "react-router-dom";
import { isAxiosError } from "axios";
import { useState } from "react";
import {
ArrowLeft,
CalendarClock,
@@ -7,9 +8,11 @@ import {
Flag,
MapPin,
Navigation,
PackageCheck,
Train,
} from "lucide-react";
import {
Alert,
Badge,
Box,
Button,
@@ -25,7 +28,9 @@ import {
} from "@mantine/core";
import { PageContainer } from "@/components/page";
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
import type { TrackStation } from "@/types/trainScheduling";
import {
RouteCorridor,
StatusPill,
@@ -148,6 +153,18 @@ export default function TrainScheduleTrackPage() {
const recordCheckpoint = useMutation(
api.trainScheduling.recordCheckpoint.mutationOptions(),
);
// Yard work drives the log-pass modal: which bookings board/alight per stop.
const yardWorkQuery = useQuery(
api.trainScheduling.yardWork.queryOptions({
input: { scheduleId: scheduleId ?? "" },
enabled: Boolean(scheduleId) && trackQuery.data?.status === "DISPATCHED",
}),
);
const [yardModal, setYardModal] = useState<{
station: TrackStation;
isFinal: boolean;
alreadyLogged: boolean;
} | null>(null);
if (trackQuery.isLoading) {
return (
@@ -181,8 +198,26 @@ export default function TrainScheduleTrackPage() {
const inTransit = track.status === "DISPATCHED";
const arrived = track.status === "ARRIVED";
// Yard work at a station: boarders not yet loaded, and loaded bookings that
// alight there. When either exists, logging the pass goes through the modal
// so the operator sees (and can act on) both lists; empty yards log directly.
const yardWorkFor = (station: TrackStation | undefined) =>
yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId);
const stationHasWork = (station: TrackStation | undefined) => {
const yard = yardWorkFor(station);
return Boolean(
yard &&
(yard.toLoad.some((r) => !r.loadedAt) || yard.toUnload.some((r) => r.canUnload)),
);
};
const handleLog = (sequenceNo: number) => {
const station = track.stations.find((s) => s.sequenceNo === sequenceNo);
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
if (station && stationHasWork(station)) {
setYardModal({ station, isFinal, alreadyLogged: false });
return;
}
recordCheckpoint.mutate(
{ id: scheduleId, payload: { sequenceNo } },
{
@@ -203,6 +238,15 @@ export default function TrainScheduleTrackPage() {
);
};
// "Forgot to load" catch: while the train sits at the current station, any
// boarder there that is still unloaded can be loaded until the next pass.
const currentStationObj = track.stations.find(
(s) => s.sequenceNo === track.currentSequenceNo,
);
const currentYard = canLog ? yardWorkFor(currentStationObj) : undefined;
const forgottenBoarders =
currentYard?.toLoad.filter((r) => !r.loadedAt) ?? [];
return (
<PageContainer>
<Button
@@ -416,6 +460,43 @@ export default function TrainScheduleTrackPage() {
}
onLogCheckpoint={handleLog}
/>
{/* Cargo the operator forgot: boarders at the CURRENT station stay
loadable until the next pass is logged. */}
{currentStationObj && forgottenBoarders.length > 0 ? (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<PackageCheck size={16} />}
title={`${forgottenBoarders.length} booking${
forgottenBoarders.length === 1 ? "" : "s"
} at ${currentStationObj.label} not loaded yet`}
>
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
<Text size="sm">
The train is at {currentStationObj.label} cargo boarding here can
still be loaded before the next station is logged.
</Text>
<Button
size="compact-sm"
variant="light"
color="yellow"
onClick={() =>
setYardModal({
station: currentStationObj,
isFinal:
currentStationObj.sequenceNo ===
track.stations[totalStations - 1]?.sequenceNo,
alreadyLogged: true,
})
}
>
Open yard work
</Button>
</Group>
</Alert>
) : null}
</Stack>
</Paper>
@@ -505,6 +586,15 @@ export default function TrainScheduleTrackPage() {
</Timeline>
)}
</Paper>
<LogPassYardWorkModal
opened={yardModal !== null}
onClose={() => setYardModal(null)}
scheduleId={scheduleId}
station={yardModal?.station ?? null}
isFinal={yardModal?.isFinal ?? false}
alreadyLogged={yardModal?.alreadyLogged ?? false}
/>
</PageContainer>
);
}

View File

@@ -398,11 +398,8 @@ export default function TrainScheduleV2DetailPage() {
!b.loadedAt &&
!["IN_TRANSIT", "COMPLETED"].includes(b.status ?? ""),
).length;
// Import-Djibouti trains are HARD-blocked from dispatch until loading is
// confirmed in the workspace — surface it as a blocker, not just a warning.
const loadingBlocksDispatch =
schedule.requiresLoadingConfirmation === true &&
schedule.loadingConfirmed !== true;
// No loading hard-block: bookings may board mid-corridor, so loading happens
// per yard from the track page's log-pass flow. Everything below is advisory.
const hasDispatchWarnings =
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
@@ -1271,22 +1268,6 @@ export default function TrainScheduleV2DetailPage() {
undone.
</Text>
{loadingBlocksDispatch ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertTriangle size={18} />}
title="Loading not confirmed"
>
This import train cannot depart until loading is confirmed. Use{" "}
<Text span fw={700}>
Confirm loading
</Text>{" "}
in the Workspace tab first.
</Alert>
) : null}
{hasDispatchWarnings ? (
<Alert
color="orange"
@@ -1309,8 +1290,9 @@ export default function TrainScheduleV2DetailPage() {
<Text span fw={700}>
{unloadedCount}
</Text>{" "}
wagon-assigned booking{unloadedCount === 1 ? "" : "s"} still marked
unloaded
wagon-assigned booking{unloadedCount === 1 ? "" : "s"} not loaded
yet mid-route boarders load from the track page when the train
reaches their yard
</List.Item>
) : null}
{intercityNotLoadedCount > 0 ? (
@@ -1350,7 +1332,6 @@ export default function TrainScheduleV2DetailPage() {
color="edr-green"
leftSection={<Send size={16} />}
loading={dispatch.isPending}
disabled={loadingBlocksDispatch}
onClick={() => void runDispatch()}
>
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}

View File

@@ -55,6 +55,8 @@ import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { formatRouteLabel } from "@/services/routes.service";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { canCreateSchedule } from "@/lib/permissions";
import type {
FreightType,
TrainScheduleListFilters,
@@ -99,6 +101,8 @@ const parseError = (error: unknown, fallback: string) => {
export default function TrainScheduleV2ListPage() {
const navigate = useNavigate();
const { toast } = useToast();
const { user } = useAuth();
const canCreate = canCreateSchedule(user);
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
@@ -550,9 +554,11 @@ export default function TrainScheduleV2ListPage() {
title="Train Schedules"
subtitle="Operational train scheduling with full allocation workflow."
action={
<Button leftSection={<Train size={18} />} onClick={() => setCreateOpen(true)}>
New schedule
</Button>
canCreate ? (
<Button leftSection={<Train size={18} />} onClick={() => setCreateOpen(true)}>
New schedule
</Button>
) : undefined
}
/>

View File

@@ -16,6 +16,8 @@ import { CheckCircle2, Download, Eye, FileText, Printer, Search } from 'lucide-r
import type { ReactNode } from 'react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import ListControls from '@/components/common/ListControls';
import { useListControls } from '@/hooks/useListControls';
import { PageContainer, PageHeader } from '@/components/page';
import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
@@ -263,6 +265,9 @@ export default function InterchangeDocumentsPage() {
const [viewId, setViewId] = useState<string | null>(null);
const filter = useMemo(() => ({ search: search.trim() || undefined }), [search]);
const { data: documents = [], isLoading } = useInterchangeDocuments(filter);
// Search stays server-side (passed in `filter`); this adds the date range and
// pagination over what comes back.
const controls = useListControls(documents, { dateKey: 'generatedAt' });
const acknowledge = useAcknowledgeInterchangeDocument();
const dispute = useDisputeInterchangeDocument();
@@ -435,7 +440,7 @@ export default function InterchangeDocumentsPage() {
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md">
<Text fw={600}>{documents.length} document(s)</Text>
<Text fw={600}>{controls.totalCount} document(s)</Text>
<TextInput
w={{ base: '100%', sm: 320 }}
leftSection={<Search size={16} />}
@@ -445,6 +450,19 @@ export default function InterchangeDocumentsPage() {
/>
</Group>
<ListControls
showSearch={false}
search=""
onSearchChange={() => {}}
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Generated"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
{!isLoading && documents.length === 0 ? (
<VisualEmptyState
variant="container"
@@ -454,9 +472,10 @@ export default function InterchangeDocumentsPage() {
) : (
<DataTable
columns={documentColumns}
data={documents}
data={controls.pagedRows}
status={isLoading ? 'loading' : 'success'}
containerClassName="border-0 shadow-none"
{...controls.tableProps}
/>
)}
</Card>

View File

@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import {
Alert,
Badge,
Button,
Card,
Center,
Group,
@@ -12,10 +13,16 @@ import {
Text,
Tooltip,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, PackageCheck, TrainFront, Warehouse } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, PackageCheck, PackageOpen, TrainFront, Warehouse } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import type { IntercityRideAlongRow } from "@/types/trainScheduling";
@@ -74,7 +81,45 @@ function FacilityCell({
);
}
const apiErrorMessage = (error: unknown) => {
if (error && typeof error === "object" && "response" in error) {
const message = (error as { response?: { data?: { message?: unknown } } }).response?.data
?.message;
if (Array.isArray(message)) return message.join("; ");
if (typeof message === "string") return message;
}
return error instanceof Error ? error.message : undefined;
};
function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const refresh = () =>
queryClient.invalidateQueries({
queryKey: api.trainScheduling.intercityBookings.queryKey(undefined),
});
// Same endpoints as the schedule page's ride-along panel — the server still
// validates the train's recorded checkpoint, payment and yard equipment.
const load = useMutation(
api.trainScheduling.loadIntercityBooking.mutationOptions({
onSuccess: () => {
toast({ title: "Cargo loaded onto the train" });
void refresh();
},
onError: (error) =>
toast({ variant: "destructive", title: "Load failed", description: apiErrorMessage(error) }),
}),
);
const unload = useMutation(
api.trainScheduling.unloadIntercityBooking.mutationOptions({
onSuccess: () => {
toast({ title: "Cargo unloaded — booking completed" });
void refresh();
},
onError: (error) =>
toast({ variant: "destructive", title: "Unload failed", description: apiErrorMessage(error) }),
}),
);
if (rows.length === 0) {
return (
<Alert variant="light" color="gray">
@@ -95,6 +140,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
<Table.Th ta="right">Weight</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -151,6 +197,38 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
{r.status}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
{/* Work the cargo right here while the train is at the yard. */}
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && (
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
loading={load.isPending}
onClick={() =>
load.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
}
>
Load
</Button>
)}
{r.trainScheduleId && atDestination(r) && isRiding(r) && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
loading={unload.isPending}
onClick={() =>
unload.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
}
>
Unload
</Button>
)}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
@@ -205,6 +283,15 @@ export default function IntercityPage() {
[rows],
);
// Controls follow the active tab, so search/date/paging always describe what
// is on screen. Panels unmount when hidden (keepMounted={false}) so a hidden
// tab can never render another tab's paged slice.
const active = tab === "riding" ? riding : tab === "done" ? done : waiting;
const controls = useListControls(active, {
searchKeys: ["reference", "grnNumber", "customer", "origin", "destination", "trainNumber"],
dateKey: "loadedAt",
});
return (
<PageContainer>
<PageHeader
@@ -283,19 +370,40 @@ export default function IntercityPage() {
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="waiting">
<Rows rows={waiting} />
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Reference, GRN, customer, train…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Loaded"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<Tabs.Panel value="waiting" keepMounted={false}>
<Rows rows={controls.pagedRows} />
</Tabs.Panel>
<Tabs.Panel value="riding">
<Rows rows={riding} />
<Tabs.Panel value="riding" keepMounted={false}>
<Rows rows={controls.pagedRows} />
</Tabs.Panel>
<Tabs.Panel value="done">
<Rows rows={done} />
<Tabs.Panel value="done" keepMounted={false}>
<Rows rows={controls.pagedRows} />
</Tabs.Panel>
</Tabs>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="bookings"
onPaginationChange={controls.setPagination}
/>
<Text size="xs" c="dimmed" mt="sm">
Loading and unloading happen on the train's schedule page, where the ride-along
panel confirms the train is at the yard.
Load and Unload appear on a row while its train is recorded at that yard ("train
here"); the same actions also live on the train's schedule page.
</Text>
</Card>
</>

View File

@@ -15,6 +15,11 @@ import {
useInventoryInquiry,
useWarehouses,
} from '@/hooks/useWarehouses';
import ListControls from '@/components/common/ListControls';
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
import { useListControls } from '@/hooks/useListControls';
import type { InventoryInquiryFilter, InventoryInquiryResult, InventoryStatus } from '@/types/warehouse';
export default function InventoryInquiryPage() {
@@ -29,6 +34,11 @@ export default function InventoryInquiryPage() {
const { data, isFetching } = useInventoryInquiry(applied);
const results = data ?? [];
const controls = useListControls(results, {
searchKeys: ['containerNumber', 'bookingReference', 'customerName', 'cargoDescription', 'locationSummary'],
dateKey: 'arrivedAt',
});
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
@@ -173,7 +183,28 @@ export default function InventoryInquiryPage() {
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
/>
) : (
<WarehouseInquiryTable results={results} onView={setViewResult} />
<>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Container, booking, customer, location…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Arrived"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<WarehouseInquiryTable results={controls.pagedRows} onView={setViewResult} />
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="items"
onPaginationChange={controls.setPagination}
/>
</>
)}
</Card>
</Stack>

View File

@@ -1,5 +1,7 @@
import { Badge, Card, Group, Text } from '@mantine/core';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import ListControls from '@/components/common/ListControls';
import { useListControls } from '@/hooks/useListControls';
import { PageContainer, PageHeader } from '@/components/page';
import { useQuery } from '@tanstack/react-query';
@@ -67,6 +69,10 @@ export default function LoadedInventoryPage() {
api.warehouses.loadings.queryOptions({ input: {} }),
);
const loadings = data ?? [];
const controls = useListControls(loadings, {
searchKeys: ['wagonNumber'],
dateKey: 'loadedAt',
});
return (
<PageContainer>
@@ -83,12 +89,27 @@ export default function LoadedInventoryPage() {
description="Once items are loaded onto a wagon, their records show here."
/>
) : (
<DataTable
columns={columns}
data={loadings}
status={isLoading ? 'loading' : 'success'}
containerClassName="border-0 shadow-none"
/>
<>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search by wagon"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Loaded"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<DataTable
columns={columns}
data={controls.pagedRows}
status={isLoading ? 'loading' : 'success'}
containerClassName="border-0 shadow-none"
{...controls.tableProps}
/>
</>
)}
</Card>
</PageContainer>

View File

@@ -7,12 +7,15 @@ import {
SegmentedControl,
Table,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { Search } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path; reused here rather than adding a second one.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import { useTrucksOnSite } from "@/hooks/useWarehouses";
import type { TruckOnSite } from "@/types/warehouse";
@@ -148,20 +151,21 @@ export default function TrucksOnSitePage() {
scopeParam === "ON_SITE" || scopeParam === "INBOUND" ? scopeParam : "ALL",
);
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
const [search, setSearch] = useState("");
const rows = useMemo(() => {
const term = search.trim().toLowerCase();
return trucks
.filter((t) => scope === "ALL" || t.status === scope)
.filter((t) => source === "ALL" || t.source === source)
.filter((t) =>
!term
? true
: [t.plateNumber, t.driverName, t.bookingReference, t.customerName, t.containers]
.some((field) => field?.toLowerCase().includes(term)),
);
}, [trucks, scope, source, search]);
// Scope/source are page filters and run first; the shared control then does
// search + arrival-date range + pagination over what they leave.
const scoped = useMemo(
() =>
trucks
.filter((t) => scope === "ALL" || t.status === scope)
.filter((t) => source === "ALL" || t.source === source),
[trucks, scope, source],
);
const controls = useListControls(scoped, {
searchKeys: ["plateNumber", "driverName", "bookingReference", "customerName", "containers"],
dateKey: "arrivedAt",
});
const onSiteCount = trucks.filter((t) => t.status === "ON_SITE").length;
const inboundCount = trucks.length - onSiteCount;
@@ -198,17 +202,35 @@ export default function TrucksOnSitePage() {
]}
/>
</Group>
<TextInput
size="xs"
w={280}
placeholder="Plate, driver, booking, container…"
leftSection={<Search size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Group>
{isLoading ? <Text size="sm">Loading</Text> : <Rows rows={rows} />}
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Plate, driver, booking, container…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Arrived"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
{isLoading ? (
<Text size="sm">Loading</Text>
) : (
<>
<Rows rows={controls.pagedRows} />
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="trucks"
onPaginationChange={controls.setPagination}
/>
</>
)}
</PageContainer>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import {
ActionIcon,
Badge,
@@ -15,9 +15,11 @@ import {
Text,
TextInput,
} from '@mantine/core';
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt, Search } from 'lucide-react';
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import ListControls from '@/components/common/ListControls';
import { useListControls } from '@/hooks/useListControls';
import { PageContainer, PageHeader } from '@/components/page';
import { AccrualDashboard } from '@/components/warehouses';
@@ -50,7 +52,6 @@ const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '
export default function WarehouseInvoicesPage() {
const [status, setStatus] = useState<WarehouseInvoiceStatus | null>(null);
const [search, setSearch] = useState('');
const [detailId, setDetailId] = useState<string | null>(null);
const { data, isLoading } = useQuery(
@@ -60,11 +61,10 @@ export default function WarehouseInvoicesPage() {
);
const invoices = data ?? [];
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return invoices;
return invoices.filter((i) => [i.invoiceNumber, i.bookingId, i.customerId].join(' ').toLowerCase().includes(q));
}, [invoices, search]);
const controls = useListControls(invoices, {
searchKeys: ['invoiceNumber', 'bookingReference', 'customerName', 'containerNumber'],
dateKey: 'issuedAt',
});
const invoiceColumns: ColumnDef<WarehouseFeeInvoice>[] = [
{
@@ -131,30 +131,36 @@ export default function WarehouseInvoicesPage() {
</Stack>
<Card>
<Group justify="space-between" mb="md" wrap="wrap">
<TextInput
placeholder="Search invoice no / booking / customer"
leftSection={<Search size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={320}
/>
<Select
placeholder="All statuses"
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
value={status}
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
clearable
w={200}
/>
</Group>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search invoice no / booking / customer"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Issued"
hasFilters={controls.hasFilters}
onReset={controls.reset}
>
<Select
label="Status"
placeholder="All statuses"
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
value={status}
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
clearable
w={200}
/>
</ListControls>
<DataTable
columns={invoiceColumns}
data={filtered}
data={controls.pagedRows}
status={isLoading ? 'loading' : 'success'}
emptyMessage="No invoices found."
containerClassName="border-0 shadow-none"
{...controls.tableProps}
/>
</Card>

View File

@@ -12,6 +12,11 @@ import {
WarehouseTable,
type WarehouseView,
} from '@/components/warehouses';
import ListControls from '@/components/common/ListControls';
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
import { useListControls } from '@/hooks/useListControls';
import { useWarehouses } from '@/hooks/useWarehouses';
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
@@ -31,6 +36,11 @@ export default function WarehouseListPage() {
const { data, isLoading, isError } = useWarehouses(queryFilter);
const warehouses = data ?? [];
// Search stays with WarehouseFilters — it is server-side and debounced, so
// re-doing it client-side here would be a regression. This adds only the date
// range + pagination, shared by both the table and card views.
const controls = useListControls(warehouses, { dateKey: 'createdAt' });
const openCreate = () => {
setEditing(null);
setModalOpen(true);
@@ -57,6 +67,19 @@ export default function WarehouseListPage() {
<Stack gap="md">
<WarehouseFilters filter={filter} onChange={setFilter} view={view} onViewChange={setView} />
<ListControls
showSearch={false}
search=""
onSearchChange={() => {}}
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Created"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
{isLoading ? (
<Center py="xl">
<Loader />
@@ -65,10 +88,21 @@ export default function WarehouseListPage() {
<Text c="red" ta="center" py="xl">
Failed to load warehouses.
</Text>
) : view === 'table' ? (
<WarehouseTable warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
) : (
<WarehouseCardView warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
<>
{view === 'table' ? (
<WarehouseTable warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
) : (
<WarehouseCardView warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
)}
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="warehouses"
onPaginationChange={controls.setPagination}
/>
</>
)}
</Stack>
</Card>

View File

@@ -19,6 +19,8 @@ import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import ListControls from '@/components/common/ListControls';
import { useListControls } from '@/hooks/useListControls';
import { PageContainer, PageHeader } from '@/components/page';
import { useToast } from '@/hooks/use-toast';
@@ -39,7 +41,6 @@ import {
FEE_RULE_BASIS_LABELS,
FEE_RULE_TYPES,
FEE_RULE_TYPE_LABELS,
VEHICLE_TYPES,
type AllocationRule,
type FeeRule,
type FeeRuleBasis,
@@ -141,6 +142,10 @@ function AllocationRules() {
});
const rules = data ?? [];
const controls = useListControls(rules, {
searchKeys: ['name', 'targetYardCode', 'targetWarehouseCode', 'targetZoneCode', 'freightType', 'tradeDirection', 'cargoTypeCode', 'storageType'],
dateKey: 'createdAt',
});
const yardOptions = yards
.filter((yard) => yard.code)
.map((yard) => ({
@@ -255,7 +260,7 @@ function AllocationRules() {
<>
<Group justify="space-between" mb="sm">
<Text c="dimmed" size="sm">
{rules.length} rule(s) matched by ascending priority
{controls.totalCount} rule(s) matched by ascending priority
</Text>
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
New allocation rule
@@ -268,12 +273,26 @@ function AllocationRules() {
</Text>
</Alert>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search allocation rules…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Created"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<DataTable
columns={allocationColumns}
data={rules}
data={controls.pagedRows}
status={isLoading ? 'loading' : 'success'}
emptyMessage="No allocation rules yet. Create one to route inventory to a yard automatically."
containerClassName="border-0 shadow-none"
{...controls.tableProps}
/>
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
@@ -393,6 +412,12 @@ function FeeRules() {
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
);
// Detention rules match on the truck-type CODE denormalised onto
// vehicles.vehicle_type, so the options come from the managed truck types
// rather than a hardcoded list that drifts the moment a type is added.
const { data: truckTypes = [], isLoading: truckTypesLoading } = useQuery(
api.truckTypes.list.queryOptions({ staleTime: Infinity }),
);
const create = useCreateFeeRule();
const update = useUpdateFeeRule();
const remove = useDeleteFeeRule();
@@ -414,6 +439,10 @@ function FeeRules() {
currency: 'USD',
});
const rules = data ?? [];
const controls = useListControls(rules, {
searchKeys: ['name', 'ruleType', 'freightType', 'tradeDirection', 'cargoTypeCode', 'containerType', 'vehicleType', 'currency'],
dateKey: 'createdAt',
});
const cargoTypeOptions = codeOptions(cargoTypes);
const containerTypeOptions = codeOptions(containerTypes);
const isBulkRule = form.freightType === 'BULK';
@@ -642,19 +671,33 @@ function FeeRules() {
<>
<Group justify="space-between" mb="sm">
<Text c="dimmed" size="sm">
{rules.length} rule(s) - most specific match applies
{controls.totalCount} rule(s) - most specific match applies
</Text>
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
New fee rule
</Button>
</Group>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search fee rules…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Created"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<DataTable
columns={feeColumns}
data={rules}
data={controls.pagedRows}
status={isLoading ? 'loading' : 'success'}
emptyMessage="No storage or demurrage fee rules yet."
containerClassName="border-0 shadow-none"
{...controls.tableProps}
/>
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
@@ -712,8 +755,9 @@ function FeeRules() {
{isTruckDetention && (
<Select
label="Truck type"
placeholder="Any truck type"
data={VEHICLE_TYPES.map((v) => ({ value: v, label: v.charAt(0) + v.slice(1).toLowerCase() }))}
placeholder={truckTypesLoading ? 'Loading truck types...' : 'Any truck type'}
data={truckTypes.map((t) => ({ value: t.code, label: `${t.name} (${t.code})` }))}
disabled={truckTypesLoading}
value={form.vehicleType || null}
onChange={(value) => setForm((f) => ({ ...f, vehicleType: selectValue(value) }))}
clearable

View File

@@ -195,6 +195,7 @@ import {
type UsedTrainNumbers,
} from "./trainBuilder.service";
import { trainSchedulingService } from "./trainScheduling.service";
import { truckTypesService, type TruckType } from "./truck-types.service";
import { wagonTypesService, type WagonType } from "./wagon-types.service";
import {
wagonService,
@@ -657,7 +658,16 @@ export const api = {
"finalize-schedule",
(id) => trainSchedulingService.finalizeSchedule(id),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
// Finalize only flips statuses (schedule DRAFT→SCHEDULED + each booking's
// schedulingStatus) — it never touches allocation or loading. Refresh only
// the schedule detail/list + booking views instead of the broad
// train-scheduling ROOT, which refired the eligible-bookings / pool /
// yard-work queries and made the booking lists visibly reload.
() => [
["train-scheduling", "schedule"],
["train-scheduling", "schedules"],
QUERY_KEYS.BOOKINGS.ROOT,
],
),
dispatchSchedule: endpoint<string, TrainScheduleDetail>(
@@ -2073,6 +2083,36 @@ export const api = {
),
},
truckTypes: {
list: endpoint<void, TruckType[]>("truck-types", "list", () =>
truckTypesService.getTruckTypes(),
),
create: endpoint<Partial<TruckType>, TruckType>(
"truck-types",
"create",
(payload) => truckTypesService.create(payload).then((r) => r.data),
undefined,
() => [["truck-types"]],
),
update: endpoint<{ id: string; data: Partial<TruckType> }, TruckType>(
"truck-types",
"update",
({ id, data }) => truckTypesService.update(id, data).then((r) => r.data),
undefined,
() => [["truck-types"]],
),
remove: endpoint<string, void>(
"truck-types",
"remove",
(id) => truckTypesService.delete(id).then(() => undefined),
undefined,
() => [["truck-types"]],
),
},
wagonTypes: {
list: endpoint<void, WagonType[]>("wagon-types", "list", () =>
wagonTypesService.getWagonTypes(),

View File

@@ -97,6 +97,7 @@ export interface ContractView {
contractId: string;
reference: string;
status: string;
freightType: "BULK" | "CONTAINER";
templateKey: string;
title: string;
html: string;

View File

@@ -22,7 +22,10 @@ export interface FirstMileBooking {
serviceType?: { id: string; label?: string } | null;
originYard?: { id: string; label?: string } | null;
destinationYard?: { id: string; label?: string } | null;
cargoType?: { id: string; label?: string } | null;
cargoType?: { id: string; label?: string; cargoTypeName?: string; name?: string } | null;
freightType?: string | null;
/** Attached server-side: the train schedule this booking rides. */
trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null;
/** Container lines — total container count drives how many trucks are needed. */
bookingContainers?: Array<{
id: string;
@@ -68,6 +71,8 @@ export interface FirstMileRecord {
vehicleId: string;
containerNumber?: string | null;
distanceKm?: number | null;
tons?: number | null;
quantity?: number | null;
vehicle?: FirstMileVehicle | null;
}>;
/** Present only when an invoice has actually been generated (not on distance). */
@@ -95,7 +100,12 @@ export const firstMileService = {
api.delete<void>(FM.BY_ID(id)),
setVehicles: (
id: string,
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
vehicles: Array<{
vehicleId: string;
containerNumber?: string | null;
tons?: number | null;
quantity?: number | null;
}>,
) => api.post<FirstMileRecord>(`${FM.BASE}/${id}/vehicles`, { vehicles }),
setDistances: (
id: string,

View File

@@ -85,6 +85,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
"wagon-types": URL_CONSTANTS.RULE_ENGINE.WAGON_TYPES,
"truck-types": URL_CONSTANTS.RULE_ENGINE.TRUCK_TYPES,
"priority-configs": URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIGS,
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
@@ -103,6 +104,8 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
return URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id);
case "wagon-types":
return URL_CONSTANTS.RULE_ENGINE.WAGON_TYPE_BY_ID(id);
case "truck-types":
return URL_CONSTANTS.RULE_ENGINE.TRUCK_TYPE_BY_ID(id);
case "priority-configs":
return URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIG_BY_ID(id);
case "service-types":

View File

@@ -0,0 +1,30 @@
import { api } from "../auth/http";
type ListResponse<T> = T[] | { data: T[] };
export interface TruckType {
id: string;
code: string;
name: string;
/** Pre-fills a vehicle's capacity — capacity belongs to the type, not each truck. */
capacityTons: number | null;
/** False for a rigid truck (e.g. Casoni), which has no trailer plate at all. */
hasTrailer: boolean;
description?: string | null;
isActive: boolean;
}
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const truckTypesService = {
async getTruckTypes() {
const response = await api.get<ListResponse<TruckType>>('/truck-types', {
params: { isActive: 'all', pageSize: 500 },
});
return asList(response.data);
},
create: (data: Partial<TruckType>) => api.post('/truck-types', data),
update: (id: string, data: Partial<TruckType>) => api.patch(`/truck-types/${id}`, data),
delete: (id: string) => api.delete(`/truck-types/${id}`),
};

View File

@@ -20,7 +20,14 @@ export interface Vehicle {
id: string;
plateNumber: string;
registrationNumber: string;
/** Denormalised truck-type code, written server-side. Register with `truckTypeId`. */
vehicleType: VehicleType;
/** Truck configuration from the managed truck types. */
truckTypeId?: string | null;
/** Vehicle Identification Number — unique across the fleet. */
vin?: string | null;
/** OWNED | OUTSOURCED. */
ownership?: string | null;
manufacturer: string;
model: string;
year: number;

View File

@@ -2,6 +2,7 @@ export type RuleEngineResourceSlug =
| "cargo-types"
| "container-types"
| "wagon-types"
| "truck-types"
| "priority-configs"
| "service-types"
| "weight-limit-rules"

View File

@@ -756,8 +756,10 @@ export interface AllocationRule {
targetZoneCode?: string | null;
storageType?: string | null;
isActive: boolean;
/** Set by the API (BaseEntity); used for the list date filter. */
createdAt?: string;
}
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id'>;
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id' | 'createdAt'>;
export const FEE_RULE_TYPES = [
'STORAGE_FEE',
@@ -813,8 +815,10 @@ export interface FeeRule {
tiers?: FeeRuleTier[];
currency: string;
isActive: boolean;
/** Set by the API (BaseEntity); used for the list date filter. */
createdAt?: string;
}
export type SaveFeeRulePayload = Omit<FeeRule, 'id'>;
export type SaveFeeRulePayload = Omit<FeeRule, 'id' | 'createdAt'>;
export interface FeeRuleTier {
fromDay: number;

View File

@@ -8,6 +8,7 @@ import {
Group,
Loader,
MultiSelect,
NumberInput,
Select,
SimpleGrid,
Stack,
@@ -79,6 +80,8 @@ export function CustomerTruckAssignmentCard({
const [driverName, setDriverName] = useState("");
const [truckType, setTruckType] = useState("");
const [containers, setContainers] = useState<string[]>([]);
const [plannedTons, setPlannedTons] = useState<number | "">("");
const [plannedQty, setPlannedQty] = useState<number | "">("");
const [editingId, setEditingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [bulkModalOpen, setBulkModalOpen] = useState(false);
@@ -105,15 +108,23 @@ export function CustomerTruckAssignmentCard({
setDriverName("");
setTruckType("");
setContainers([]);
setPlannedTons("");
setPlannedQty("");
setEditingId(null);
setError(null);
};
const startEdit = (t: Freight.ICustomerTruck) => {
const planned = t as Freight.ICustomerTruck & {
plannedTons?: number | string | null;
plannedQuantity?: number | null;
};
setPlateNumber(t.plateNumber ?? "");
setDriverName(t.driverName ?? "");
setTruckType(t.truckType ?? "");
setContainers((t.containers ?? []).map((c) => c.containerNumber));
setPlannedTons(planned.plannedTons != null ? Number(planned.plannedTons) : "");
setPlannedQty(planned.plannedQuantity != null ? Number(planned.plannedQuantity) : "");
setEditingId(t.id);
setError(null);
};
@@ -129,6 +140,12 @@ export function CustomerTruckAssignmentCard({
driverName: driverName.trim(),
truckType: truckType.trim(),
containerNumbers: isBulk ? [] : containers,
...(isBulk
? {
plannedTons: plannedTons === "" ? undefined : Number(plannedTons),
plannedQuantity: plannedQty === "" ? undefined : Number(plannedQty),
}
: {}),
};
return editingId
? customerTrucksService.update(booking.id, editingId, payload)
@@ -170,6 +187,10 @@ export function CustomerTruckAssignmentCard({
setError("Select 1 or 2 container numbers for this truck.");
return;
}
if (isBulk && plannedTons === "") {
setError("Enter the tonnes this truck will haul.");
return;
}
setError(null);
addMutation.mutate();
};
@@ -324,6 +345,40 @@ export function CustomerTruckAssignmentCard({
nothingFoundMessage="No unassigned containers"
/>
)}
{isBulk && (
<NumberInput
label="Tonnes to load"
description={(() => {
const total = Number(booking.cargoTotalWeightVgm) || 0;
const assigned = trucks
.filter((t) => t.id !== editingId)
.reduce((s, t) => {
const x = t as Freight.ICustomerTruck & {
netWeightTons?: number | string | null;
plannedTons?: number | string | null;
};
return s + (Number(x.netWeightTons ?? x.plannedTons) || 0);
}, 0);
const remaining = Math.max(0, Math.round((total - assigned) * 1000) / 1000);
return total > 0
? `${assigned} t of ${total} t already on trucks · ${remaining} t remaining`
: "Tonnage this truck hauls";
})()}
required
min={0}
value={plannedTons}
onChange={(v) => setPlannedTons(v === "" ? "" : Number(v))}
/>
)}
{isBulk && (
<NumberInput
label="Items quantity (pcs)"
description="Optional piece count on this truck"
min={0}
value={plannedQty}
onChange={(v) => setPlannedQty(v === "" ? "" : Number(v))}
/>
)}
</SimpleGrid>
<Group justify="flex-end">
{editingId && (

View File

@@ -257,6 +257,20 @@ function usePlacesSearch(): PlacesSearch | null {
}, [placesLib]);
}
/**
* Coerce a coordinate to a finite number or null. Numeric-typed API fields
* (e.g. contract/booking `..Lat`/`..Lng`) come back from Postgres `numeric`
* columns as strings — the TS type says `number` but the value crossing the
* network is not. Google Maps' `panTo`/`Marker` throw on anything that isn't
* a real finite number, so every coordinate is normalised at this one shared
* entry point rather than trusting each caller to have converted it.
*/
function toFiniteNumber(v: number | string | null | undefined): number | null {
if (v == null) return null;
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : null;
}
/** Recenters the map imperatively when the pinned coordinate changes. */
function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) {
const map = useMap();
@@ -290,12 +304,17 @@ export interface LocationPickerProps {
* Reports the resolved address and coordinates up via `onChange`.
*/
export function LocationPicker(props: LocationPickerProps) {
const value: LocationValue = {
...props.value,
lat: toFiniteNumber(props.value.lat),
lng: toFiniteNumber(props.value.lng),
};
return (
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
{props.variant === "modal" ? (
<LocationPickerModal {...props} />
<LocationPickerModal {...props} value={value} />
) : (
<LocationPickerInline {...props} />
<LocationPickerInline {...props} value={value} />
)}
</APIProvider>
);

View File

@@ -42,21 +42,27 @@ export function Step2ServiceType({
const lastMileEnabled = form.watch("lastMile.enabled");
const prevServiceType = useRef(serviceType);
// Only clear a mile when the current service doesn't include it — this ran
// unconditionally before, so it wiped an already-entered (or prefilled, or
// draft-restored) pickup/delivery address on every service change, even one
// that still includes that mile.
useEffect(() => {
if (includesFirstMile) return;
form.setValue(
"firstMile",
{ enabled: false, pickUpAddress: "", lat: null, lng: null },
{ shouldValidate: true },
);
}, [includesFirstMile]);
}, [includesFirstMile, form]);
useEffect(() => {
if (includesLastMile) return;
form.setValue(
"lastMile",
{ enabled: false, deliveryAddress: "", lat: null, lng: null },
{ shouldValidate: true },
);
}, [includesLastMile]);
}, [includesLastMile, form]);
useEffect(() => {
const prev = prevServiceType.current;
@@ -82,6 +88,10 @@ export function Step2ServiceType({
);
useEffect(() => {
// Skip right after a hydration `form.reset()` (draft restore / renewal
// prefill) — a restored selection is trusted as-is until the customer
// actually changes something live.
if (!form.formState.isDirty) return;
const currentId = form.getValues("serviceTypeId");
if (!currentId) return;
if (!bookableServices.some((s) => s.id === currentId)) {

View File

@@ -100,12 +100,17 @@ export function Step4Route({
// here — e.g. switching import→export flips which end must be in Ethiopia.
// Clear any selection that no longer matches the operation's required country
// so the customer can't submit a route that contradicts the operation.
// Skipped right after a hydration `form.reset()` (draft restore / renewal
// prefill) — a restored yard is trusted as-is until the customer changes
// something live; `isDirty` is false only in that pristine post-reset render.
useEffect(() => {
if (!form.formState.isDirty) return;
if (originCountry && origin && origin.country !== originCountry) {
form.setValue("originYard", "");
}
}, [originCountry, origin, form]);
useEffect(() => {
if (!form.formState.isDirty) return;
if (destinationCountry && dest && dest.country !== destinationCountry) {
form.setValue("destinationYard", "");
}

View File

@@ -335,6 +335,11 @@ export function Step2ServiceType({
);
useEffect(() => {
// Skip right after a hydration `form.reset()` (edit mode) — a saved
// contract's service is trusted as-is until the customer actually
// changes something live; `isDirty` is false only in that pristine
// post-hydration render, so this never clears a value nobody touched.
if (!form.formState.isDirty) return;
const currentId = form.getValues("serviceTypeId");
if (!currentId) return;
if (!standaloneServices.some((s) => s.id === currentId)) {

View File

@@ -1,23 +1,44 @@
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { Container, Flame, RotateCcw, Snowflake } from "lucide-react";
// Snowflake — restore with the Refrigerated Cargo switch below.
import { Container, Flame, RotateCcw } from "lucide-react";
import {
Box,
Button,
Group,
Loader,
Modal,
Select,
Skeleton,
Stack,
Switch,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { SmartFileInput } from "@edr/ui-common";
import type { Freight } from "@edr/types";
import { api } from "@/services/api";
import {
CONTAINER_SIZES,
ContractFormInputValues,
type ContractDocuments,
type ContractFormValues,
} from "./schema";
import { operationToTradeDirection } from "./helpers";
import { fieldStyles, SelectField, StepLabel } from "./shared";
/**
* file_upload_settings code holding the hazardous-cargo document requirements.
* Fields are configured by an admin in the backoffice file-settings editor —
* whatever is configured there is what the modal asks for.
*/
const HAZARDOUS_DOC_SETTING_CODE = "hazardous_documents";
function hasUploaded(value: File | File[] | null | undefined): boolean {
if (!value) return false;
return Array.isArray(value) ? value.length > 0 : true;
}
const CARGO_TYPE_OPTIONS = [
{ value: "container", label: "Containerized (20ft / 40ft)" },
{ value: "bulk", label: "General / Bulk cargo" },
@@ -51,6 +72,101 @@ export function Step3CargoScope({
const cargoTypePath = form.watch("cargoTypePath") ?? [];
const parentId = cargoTypePath[0];
// Hazardous cargo is a ONE-TIME-contract-only option; refrigerated cargo and
// empty-container return are offered on IMPORT operations only.
const operationType = form.watch("operationType");
const isOneTime = (form.watch("contractKind") ?? "one_time") === "one_time";
const isImport = operationType
? operationToTradeDirection(operationType) === "IMPORT"
: false;
const hazardSettingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: HAZARDOUS_DOC_SETTING_CODE },
enabled: isOneTime,
}),
);
const hazardFields = hazardSettingQuery.data?.fields ?? [];
const hazardKeys = useMemo(
() => hazardFields.map((f) => f.fileKey),
[hazardFields],
);
const [hazardModalOpen, setHazardModalOpen] = useState(false);
const [hazardDraft, setHazardDraft] = useState<ContractDocuments>({});
const [hazardErrors, setHazardErrors] = useState<Record<string, string>>({});
/** Drop every hazardous document from the contract's document map. */
const clearHazardDocs = () => {
const docs = { ...((form.getValues("documents") ?? {}) as ContractDocuments) };
let changed = false;
for (const key of hazardKeys) {
if (key in docs) {
delete docs[key];
changed = true;
}
}
if (changed) form.setValue("documents", docs, { shouldDirty: true });
};
const openHazardModal = () => {
const docs = (form.getValues("documents") ?? {}) as ContractDocuments;
setHazardDraft(
Object.fromEntries(
hazardKeys.filter((k) => k in docs).map((k) => [k, docs[k]]),
),
);
setHazardErrors({});
setHazardModalOpen(true);
};
const confirmHazardDocs = () => {
const missing = hazardFields.filter(
(f) => f.isRequired && !hasUploaded(hazardDraft[f.fileKey]),
);
if (missing.length > 0) {
setHazardErrors(
Object.fromEntries(
missing.map((f) => [f.fileKey, `${f.fileLabel} is required.`]),
),
);
return;
}
form.setValue(
"documents",
{
...((form.getValues("documents") ?? {}) as ContractDocuments),
...hazardDraft,
},
{ shouldDirty: true },
);
form.setValue("isHazardous", true, { shouldDirty: true });
setHazardModalOpen(false);
};
// A hidden flag must never leak into the payload: a general contract can't be
// hazardous, and a non-import contract carries neither reefer nor empty return.
useEffect(() => {
if (isOneTime) return;
if (form.getValues("isHazardous")) {
form.setValue("isHazardous", false, { shouldDirty: true });
}
// Runs again once the hazard field list loads — a no-op when nothing matches.
clearHazardDocs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOneTime, hazardKeys, form]);
useEffect(() => {
// Reefer has no switch right now, so it must never leave the form true.
if (form.getValues("isRefrigerated")) {
form.setValue("isRefrigerated", false, { shouldDirty: true });
}
if (isImport) return;
if (form.getValues("equipmentReturn") === "with_return") {
form.setValue("equipmentReturn", "without_return", { shouldDirty: true });
}
}, [isImport, form]);
// Reset the commodity child only when the parent group really changes.
const prevParentIdRef = useRef<string | undefined>(parentId);
useEffect(() => {
@@ -233,40 +349,55 @@ export function Step3CargoScope({
<Box>
<StepLabel>Cargo handling</StepLabel>
<Stack gap={12} mt={12}>
<Controller
name="isHazardous"
control={form.control}
render={({ field }) => (
<ToggleRow
icon={<Flame size={18} />}
iconBg="#FBEAE7"
iconColor="#C0392B"
title="Hazardous Material"
description="Applies a hazard surcharge as a per-container unit rate."
checked={field.value ?? false}
onChange={(v) => field.onChange(v)}
/>
)}
/>
<Controller
name="isRefrigerated"
control={form.control}
render={({ field }) => (
<ToggleRow
icon={<Snowflake size={18} />}
iconBg="#E9F0F8"
iconColor="#2E5B96"
title="Refrigerated Cargo"
description="Temperature-controlled transport applies a reefer surcharge."
checked={field.value ?? false}
onChange={(v) => field.onChange(v)}
/>
)}
/>
{/* Empty-container return is a container-only service. Like hazardous,
enabling it here adds the return surcharge as a unit rate; at
booking time the customer/GL sets how many containers return. */}
{cargoType === "container" && (
{/* Hazardous cargo is only carried under a one-time contract, and
enabling it requires the configured hazard documents up front. */}
{isOneTime && (
<Controller
name="isHazardous"
control={form.control}
render={({ field }) => (
<ToggleRow
icon={<Flame size={18} />}
iconBg="#FBEAE7"
iconColor="#C0392B"
title="Hazardous Material"
description="Applies a hazard surcharge as a per-container unit rate. Requires hazard documents."
checked={field.value ?? false}
onChange={(v) => {
if (v) {
openHazardModal();
return;
}
field.onChange(false);
clearHazardDocs();
}}
/>
)}
/>
)}
{/* Refrigerated cargo is hidden for now — import-only when re-enabled.
The effect above keeps isRefrigerated false while it's off.
{isImport && (
<Controller
name="isRefrigerated"
control={form.control}
render={({ field }) => (
<ToggleRow
icon={<Snowflake size={18} />}
iconBg="#E9F0F8"
iconColor="#2E5B96"
title="Refrigerated Cargo"
description="Temperature-controlled transport applies a reefer surcharge."
checked={field.value ?? false}
onChange={(v) => field.onChange(v)}
/>
)}
/>
)} */}
{/* Empty-container return is a container-only IMPORT service. Like
hazardous, enabling it here adds the return surcharge as a unit
rate; at booking time the customer/GL sets how many return. */}
{isImport && cargoType === "container" && (
<Controller
name="equipmentReturn"
control={form.control}
@@ -287,6 +418,54 @@ export function Step3CargoScope({
)}
</Stack>
</Box>
<Modal
opened={hazardModalOpen}
onClose={() => setHazardModalOpen(false)}
title="Hazardous cargo documents"
size="lg"
centered
radius={14}
>
<Stack gap={16}>
<Text fz={13} c="#6B7C8E">
Hazardous cargo can only move once the documents below are attached
to the contract.
</Text>
{hazardSettingQuery.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" color="edr-green" />
</Group>
) : hazardFields.length > 0 && hazardSettingQuery.data ? (
<SmartFileInput
file={hazardSettingQuery.data}
value={hazardDraft}
onChange={(v) => setHazardDraft(v)}
errors={hazardErrors}
/>
) : (
<Text fz={13} c="#6B7C8E">
No hazardous document requirements are configured yet. You can
still flag the cargo as hazardous EDR will request the paperwork
during review.
</Text>
)}
<Group justify="flex-end" gap={10}>
<Button
variant="default"
radius={10}
onClick={() => setHazardModalOpen(false)}
>
Cancel
</Button>
<Button color="edr-green" radius={10} onClick={confirmHazardDocs}>
Save &amp; mark hazardous
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -72,12 +72,17 @@ export function Step4Route({
const direction = getRouteDirection(origin, dest);
// Clear yards that no longer match the operation's required country.
// Skipped right after a hydration `form.reset()` (edit mode) — a saved
// contract's yards are trusted as-is until the customer changes something
// live; `isDirty` is false only in that pristine post-hydration render.
useEffect(() => {
if (!form.formState.isDirty) return;
if (originCountry && origin && origin.country !== originCountry) {
form.setValue("originYard", "");
}
}, [originCountry, origin, form]);
useEffect(() => {
if (!form.formState.isDirty) return;
if (destinationCountry && dest && dest.country !== destinationCountry) {
form.setValue("destinationYard", "");
}

Some files were not shown because too many files have changed in this diff Show More