diff --git a/.gitignore b/.gitignore index cadb36cea..b9dc16c3c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/E2E_TEST_REPORT.md b/E2E_TEST_REPORT.md new file mode 100644 index 000000000..186bc3f5d --- /dev/null +++ b/E2E_TEST_REPORT.md @@ -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" && ( + ... + 20ft & 40ft containers covered +``` + +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 2–5 then approve and sign *that* contract. Because test 1 could not finish, there was no fresh contract, so tests 2–5 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 '' 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//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. diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 9270dd0a1..aab9e0e4a 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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(); diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index a412bf990..92958b364 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -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, diff --git a/apps/edr-freight-api/src/common/mile-financials.util.ts b/apps/edr-freight-api/src/common/mile-financials.util.ts index 2f5288048..f22e86925 100644 --- a/apps/edr-freight-api/src/common/mile-financials.util.ts +++ b/apps/edr-freight-api/src/common/mile-financials.util.ts @@ -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), ); diff --git a/apps/edr-freight-api/src/common/rule-engine-guards.ts b/apps/edr-freight-api/src/common/rule-engine-guards.ts index 14c0385ee..ba8096b5b 100644 --- a/apps/edr-freight-api/src/common/rule-engine-guards.ts +++ b/apps/edr-freight-api/src/common/rule-engine-guards.ts @@ -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)])), ); /** diff --git a/apps/edr-freight-api/src/migrations/2820000000000-AddMileTonsQuantity.ts b/apps/edr-freight-api/src/migrations/2820000000000-AddMileTonsQuantity.ts new file mode 100644 index 000000000..e1e02c418 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2820000000000-AddMileTonsQuantity.ts @@ -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 { + 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 { + 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;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2840000000000-AddTruckTypes.ts b/apps/edr-freight-api/src/migrations/2840000000000-AddTruckTypes.ts new file mode 100644 index 000000000..fb22cbee9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2840000000000-AddTruckTypes.ts @@ -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 { + 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 { + 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. + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 4ca578f0b..3df681d9c 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -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( diff --git a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts index 4356d66ec..9816b3405 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts index 3892d2a97..94ab3b212 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index ba80cc035..a015cd469 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -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 = { - 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 = { + 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, { diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts index 8656b2109..512c22566 100644 --- a/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts +++ b/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts @@ -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. */ diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts index 39bf51a50..5c9f02c42 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 948853e22..b11912b2c 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -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 { const existing = await this.findById(id); - // Dedupe by vehicleId, keeping the container number; preserve order. - const desiredMap = new Map(); + // 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, + }, ); } }); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts index 5d17efe1e..ae50b9622 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts index 3b1bf6ce1..8bd9d90f5 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts index 9ae96af9b..82bda724a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts index 0b3334431..3188d0846 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts index 942b2c32c..2633a0cf1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts index 9972ab06a..8c3a99c9d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts index 92ac7f5d6..f10c0afab 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts index 85d76b326..34b4f53a5 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts index f078624eb..62ca5f575 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts index 98c45798e..b51ac13ff 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts index b0ba2c8d5..320a62a3e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts index b8f88b6b3..883793e03 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts index 5be404ead..e4f8a1e46 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index b1f79733e..a05286cb3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 03b293e4c..614afe275 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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); } diff --git a/apps/edr-freight-api/src/modules/truck-types/dto/create-truck-type.dto.ts b/apps/edr-freight-api/src/modules/truck-types/dto/create-truck-type.dto.ts new file mode 100644 index 000000000..ec673a471 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/dto/create-truck-type.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/truck-types/dto/update-truck-type.dto.ts b/apps/edr-freight-api/src/modules/truck-types/dto/update-truck-type.dto.ts new file mode 100644 index 000000000..269bce685 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/dto/update-truck-type.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/mapped-types'; + +import { CreateTruckTypeDto } from './create-truck-type.dto'; + +export class UpdateTruckTypeDto extends PartialType(CreateTruckTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/truck-types/entities/truck-type.entity.ts b/apps/edr-freight-api/src/modules/truck-types/entities/truck-type.entity.ts new file mode 100644 index 000000000..4a09dcd40 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/entities/truck-type.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.controller.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.controller.ts new file mode 100644 index 000000000..516d1d7d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.controller.ts @@ -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) { + 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); + } +} diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.module.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.module.ts new file mode 100644 index 000000000..466caa91f --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.repository.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.repository.ts new file mode 100644 index 000000000..bb803bd8c --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.repository.ts @@ -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 { + constructor( + @InjectRepository(TruckType) + repository: Repository, + ) { + super(repository); + } + + findByCode(code: string): Promise { + return this.repository.findOne({ where: { code } }); + } +} diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.service.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.service.ts new file mode 100644 index 000000000..1cc6421d6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.service.ts @@ -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, + 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 { + const truckType = await this.truckTypesRepository.findById(id); + + if (!truckType) { + throw new NotFoundException(`Truck type ${id} not found`); + } + + return truckType; + } + + async findByCode(code: string): Promise { + const truckType = await this.truckTypesRepository.findByCode(code); + if (!truckType) { + throw new NotFoundException(`Truck type ${code} not found`); + } + return truckType; + } + + async create(dto: CreateTruckTypeDto): Promise { + 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 { + 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 { + await this.findById(id); + await this.truckTypesRepository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index 4dda3c053..79ecc76c5 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index 534019bc4..59725fc5e 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts index cb810abaf..6171602d7 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts @@ -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); }); diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts index 07aa4bd2f..a3febac70 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts @@ -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], diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 98d38cbce..30c72e1ba 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -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, 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 diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.trailer-plate-guard.spec.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.trailer-plate-guard.spec.ts new file mode 100644 index 000000000..e8f73a38f --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.trailer-plate-guard.spec.ts @@ -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'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts index 8f6417220..fca35c124 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 3b61d7ff0..a60261c1e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -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 }]; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts index 7bd56e593..cdf34cd66 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 0174ef3e9..b3868b465 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts index 4ad469ce0..c2f5cec9c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts index 5f4205815..7e658d9db 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts index b0371cbcc..594fd7a6f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts index 63c40de94..3ee381a8c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts @@ -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, diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 03908b62b..fa74f9043 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -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: diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 9871f6980..537bac332 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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 = { - '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 = { + '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> = { 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; diff --git a/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx b/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx new file mode 100644 index 000000000..92ab514ad --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx @@ -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) => ( + + {showSearch && ( + onSearchChange(e.currentTarget.value)} + leftSection={} + style={{ flex: "1 1 240px", minWidth: 200 }} + /> + )} + + {showDateRange && ( + <> + + + + )} + + {children} + + {hasFilters && onReset && ( + + )} + +); + +export default ListControls; diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index 5d1247358..f0ccf0be0 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -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 = {}; + 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 = {}; - 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 diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx new file mode 100644 index 000000000..0e1db09be --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx @@ -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 = { + IMPORT: "blue", + EXPORT: "teal", + DOMESTIC: "violet", +}; + +/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */ +const DIRECTION_LABELS: Record = Freight.TRADE_DIRECTION_LABELS; + +function DirectionChip({ direction }: { direction: string }) { + return ( + + {DIRECTION_LABELS[direction] ?? direction} + + ); +} + +function SectionLabel({ + icon, + title, + count, +}: { + icon: React.ReactNode; + title: string; + count: number; +}) { + return ( + + + {icon} + + + {title} + + + {count} + + + ); +} + +/** + * 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 ( + + {isFinal ? : } + + {isFinal ? "Arrival" : "Yard work"} — {station?.label ?? ""} + + {logged ? ( + + {isFinal ? "Arrived" : "Pass logged"} + + ) : null} + + } + > + + {yardWorkQuery.isLoading ? ( + + + + ) : !hasWork ? ( + }> + No bookings board or alight at this station. + + ) : ( + <> + {/* ── Arriving here ─────────────────────────────────────────── */} + {arrivals.length > 0 ? ( + + } + title="Arriving at this yard" + count={arrivals.length} + /> + {!logged ? ( + + Logging the pass marks the loaded bookings below as Arrived + (import/export) or Completed (intercity) automatically. + + ) : null} + + + + + Booking + Customer + Direction + Status + Arrived + + + + {arrivals.map((row) => ( + + + + {row.reference ?? row.id.slice(0, 8)} + + + + {row.customer} + + + + + + + + + + {row.arrivedAt ? fmtDate(row.arrivedAt) : "—"} + + + + ))} + +
+
+
+ ) : null} + + {arrivals.length > 0 && boarders.length > 0 ? : null} + + {/* ── Boarding here ─────────────────────────────────────────── */} + {boarders.length > 0 ? ( + + } + title="Boarding at this yard" + count={boarders.length} + /> + {!logged && pendingBoarders.length > 0 ? ( + + Log the pass first — the train must be at {station?.label} before + cargo can be loaded. + + ) : null} + + + + + Booking + Customer + Direction + Status + Loaded + + + + + {boarders.map((row) => ( + + + + + {row.reference ?? row.id.slice(0, 8)} + + {row.isGovernment ? ( + + GOV + + ) : null} + + + + {row.customer} + + + + + + + + + {row.loadedAt ? ( + + + + {fmtDate(row.loadedAt)} + + + ) : ( + + Not loaded + + )} + + + {!row.loadedAt ? ( + + + + ) : null} + + + ))} + +
+
+
+ ) : null} + + )} + + + + {logged && pendingBoarders.length > 0 + ? `${pendingBoarders.length} booking${pendingBoarders.length === 1 ? "" : "s"} still to load before the next station.` + : ""} + + + + {!logged ? ( + + ) : null} + + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index 02475214e..58ff55912 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -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({ ) : null} - {/* Loading confirmation — required before dispatch for import-Djibouti - trains; shown for every direction so staff have one place to confirm. */} - {canManage ? ( - - - {schedule.loadingConfirmed ? ( - - ) : ( - - )} - - {schedule.loadingConfirmed - ? "Loading confirmed — cleared to dispatch" - : "Confirm loading before dispatching this train"} - - - {!schedule.loadingConfirmed ? ( - - ) : null} - - ) : 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 */} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx index d8b370de0..88c6de18c 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -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>(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 + + + + setMoveItem(null)} item={moveItem} /> diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index ade491a7b..d08cac603 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -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}`, diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts index d87fe067c..5600f6cfb 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts @@ -199,7 +199,7 @@ const ACTION_PERMISSION: Partial> = { 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, }; diff --git a/apps/edr-freight-web/backoffice/src/hooks/useListControls.ts b/apps/edr-freight-web/backoffice/src/hooks/useListControls.ts new file mode 100644 index 000000000..11e0124eb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useListControls.ts @@ -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 { + /** + * 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)[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 = (rows: T[], options: ListControlsOptions = {}) => { + const { searchKeys = [], dateKey, pageSize = 10, searchValue } = options; + + const [search, setSearch] = useState(""); + const [dateFrom, setDateFrom] = useState(null); + const [dateTo, setDateTo] = useState(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 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, + }, + }, + }; +}; diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 5505a4145..389719964 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -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( diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx index 7e8f06e0b..147d637ef 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx @@ -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(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 ( @@ -145,7 +157,7 @@ export default function ContractViewPage() { > Download PDF - {data.canSignStaff && ( + {data.canSignStaff && maySign && ( ) : null} - + {canTransfer ? ( + + ) : null} ) : null} {canCreate ? ( @@ -444,7 +483,30 @@ const FleetResourcePage = () => { viewMode={viewMode} onViewModeChange={setViewMode} filters={ - listFilterSelects ? ( + + + + {listFilterSelects ? ( {listFilterSelects.map((filter) => ( ).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 + /> + setForm((f) => ({ ...f, pricePerKm: v as number | "" }))} + min={0} + /> + setForm((f) => ({ ...f, estimatedDistanceKm: v as number | "" }))} + min={0} + /> + setForm((f) => ({ ...f, actualDistanceKm: v as number | "" }))} + min={0} + /> + setForm((f) => ({ ...f, currency: v }))} + > + + + + + + + + + + + + ); +}; + const DriverTab = ({ vehicleId, driverId, diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 0e6633eab..94654a8eb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -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; +} + 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, + ) => Record; /** * 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 diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index f25be0ff7..c8e6667f4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -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: "", diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index f30330d96..835d1a82f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -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 }) => { {hasPickupAddress && } + + @@ -496,10 +513,11 @@ const FirstMilePage = () => { const [tripSlipVehicleId, setTripSlipVehicleId] = useState(null); const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false); const [activeId, setActiveId] = useState(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(); 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} /> - + 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 && ( { vehicleId: null, containerNumber: (activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "", + tons: "" as const, + quantity: "" as const, }, ]) } @@ -1491,6 +1576,22 @@ const FirstMilePage = () => { > Add vehicle + {!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 ( + + Bulk drawdown: {assigning} t of {total} t assigned —{" "} + + {remaining} t remaining + + + ); + })()} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx index dbf23b6ee..a515e5614 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx @@ -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( () => FORM_FIELDS.map((field) => @@ -291,7 +293,7 @@ const CargoTypesPage = () => { leftSection={} w={240} /> - {canManage && ( + {canCreate && ( - - - - - + {canUpdate && ( + + + + )} + {canDelete && ( + + + )} @@ -653,7 +664,7 @@ const RuleEngineResourcePage = () => { {isPriorityRules ? ( @@ -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} /> )} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 44c28a9e9..15d80b899 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -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", diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx index e253a8db8..28481ce7a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx @@ -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 ( + + + ) : null} @@ -505,6 +586,15 @@ export default function TrainScheduleTrackPage() { )} + + setYardModal(null)} + scheduleId={scheduleId} + station={yardModal?.station ?? null} + isFinal={yardModal?.isFinal ?? false} + alreadyLogged={yardModal?.alreadyLogged ?? false} + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 137b3944f..4ddbc9efe 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -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. - {loadingBlocksDispatch ? ( - } - title="Loading not confirmed" - > - This import train cannot depart until loading is confirmed. Use{" "} - - Confirm loading - {" "} - in the Workspace tab first. - - ) : null} - {hasDispatchWarnings ? ( {unloadedCount} {" "} - 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 ) : null} {intercityNotLoadedCount > 0 ? ( @@ -1350,7 +1332,6 @@ export default function TrainScheduleV2DetailPage() { color="edr-green" leftSection={} loading={dispatch.isPending} - disabled={loadingBlocksDispatch} onClick={() => void runDispatch()} > {hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index 6fbc32cce..4fa614848 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -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={ - + canCreate ? ( + + ) : undefined } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx index 04fe38970..3cc1f1ab6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx @@ -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(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() { - {documents.length} document(s) + {controls.totalCount} document(s) } @@ -445,6 +450,19 @@ export default function InterchangeDocumentsPage() { /> + {}} + dateFrom={controls.dateFrom} + onDateFromChange={controls.setDateFrom} + dateTo={controls.dateTo} + onDateToChange={controls.setDateTo} + dateLabel="Generated" + hasFilters={controls.hasFilters} + onReset={controls.reset} + /> + {!isLoading && documents.length === 0 ? ( )} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx index bce1f52e3..cfda31629 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx @@ -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 ( @@ -95,6 +140,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) { Weight GRN Status + @@ -151,6 +197,38 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) { {r.status} + + + {/* Work the cargo right here while the train is at the yard. */} + {r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && ( + + )} + {r.trainScheduleId && atDestination(r) && isRiding(r) && ( + + )} + + ))} @@ -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 ( - - + + + + - - + + - - + + + + - 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. diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx index d85cb815b..72d2a9732 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx @@ -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." /> ) : ( - + <> + + + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx index 7dfc1996c..dbf7b14e3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx @@ -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 ( @@ -83,12 +89,27 @@ export default function LoadedInventoryPage() { description="Once items are loaded onto a wagon, their records show here." /> ) : ( - + <> + + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx index 8210d7784..f095d94d4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx @@ -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() { ]} /> - } - value={search} - onChange={(e) => setSearch(e.currentTarget.value)} - /> - {isLoading ? Loading… : } + + + {isLoading ? ( + Loading… + ) : ( + <> + + + + )} ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx index 1f632c1e7..92f807363 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx @@ -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(null); - const [search, setSearch] = useState(''); const [detailId, setDetailId] = useState(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[] = [ { @@ -131,30 +131,36 @@ export default function WarehouseInvoicesPage() { - - } - value={search} - onChange={(e) => setSearch(e.currentTarget.value)} - w={320} - /> - ({ value: s, label: s.replace(/_/g, ' ') }))} + value={status} + onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)} + clearable + w={200} + /> + diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx index 3f9df44f6..8b3600026 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx @@ -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() { + {}} + dateFrom={controls.dateFrom} + onDateFromChange={controls.setDateFrom} + dateTo={controls.dateTo} + onDateToChange={controls.setDateTo} + dateLabel="Created" + hasFilters={controls.hasFilters} + onReset={controls.reset} + /> + {isLoading ? (
@@ -65,10 +88,21 @@ export default function WarehouseListPage() { Failed to load warehouses. - ) : view === 'table' ? ( - ) : ( - + <> + {view === 'table' ? ( + + ) : ( + + )} + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index 61ce58f5f..20b7efbb0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -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() { <> - {rules.length} rule(s) matched by ascending priority + {controls.totalCount} rule(s) matched by ascending priority + + { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg"> @@ -712,8 +755,9 @@ function FeeRules() { {isTruckDetention && ( { + setScheduleId(e.target.value); + setTab("summary"); + setSearch(""); + setFilterBoarded("ALL"); + }} + disabled={loadingSchedules} + > + + {schedules.map((s) => ( + + ))} + + + + {isLoading &&

Loading…

} + {isError &&

Failed to load report.

} + + + {!scheduleId && ( +
+ +

Select a schedule above to load the boarding report

+
+ )} + + {data && ( + <> + {/* Schedule info */} +
+
+ +
+
+

{data.schedule.trainName}

+

+ {data.schedule.origin} → {data.schedule.destination} · + Departure: {formatDateTime(data.schedule.departureAt)} +

+
+
+ + {/* Tabs */} +
+ + +
+ + {/* Summary Tab */} + {tab === "summary" && ( +
+ {/* KPI cards */} +
+
+
+
+

Total Tickets

+

{data.summary.total}

+

Confirmed passengers

+
+ +
+
+ +
+
+
+

Boarded

+

+ {data.summary.boardedCount} +

+

Scanned at gate

+
+ +
+
+ +
+
+
+

Not Boarded

+

+ {data.summary.notBoardedCount} +

+

No-shows / pending

+
+ +
+
+ +
+
+
+

Boarding Rate

+

+ {data.summary.boardingRate}% +

+
+
+
+
+ +
+
+
+ + {/* By Coach */} + {data.byCoach.length > 0 && ( +
+
+ + + + + + + + + + + + {data.byCoach.map((c) => { + const rate = c.total > 0 ? +((c.boarded / c.total) * 100).toFixed(1) : 0; + return ( + + + + + + + + ); + })} + +
CoachTotalBoardedNot BoardedRate
{c.coachNumber}{c.total}{c.boarded}{c.total - c.boarded} +
+
+
+
+ {rate}% +
+
+
+
+ )} +
+ )} + + {/* Details Tab */} + {tab === "details" && ( +
+
+ { setSearch(e.target.value); reset(); }} + /> + + + Export CSV + +
+
+ + + + {["Booking Ref", "Passenger", "Seat Class · Coach · Seat", "Route", "Status", "Boarded At"].map((h) => ( + + ))} + + + + {paged.map((row, i) => ( + + + + + + + + + ))} + {paged.length === 0 && ( + + + + )} + +
+ {h} +
{row.bookingRef}{row.passengerName} + {row.seatClassName ?? "—"} + {row.coachNumber && · {row.coachNumber}} + {row.seatNumber && · #{row.seatNumber}} + + {row.origin && row.destination ? `${row.origin} → ${row.destination}` : (row.origin ?? row.destination ?? "—")} + + + {row.boarded ? "Boarded" : "Not Boarded"} + + + {row.boardedAt ? formatDateTime(row.boardedAt) : "—"} +
+ No passengers found +
+
+ +
+ )} + + )} + + {!data && !isLoading && scheduleId && ( +
+ No data found for this schedule. +
+ )} +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index feca7e3b5..251d034b1 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus, Edit, Trash2, X, Search, Train, Save, GripVertical } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; @@ -10,6 +10,7 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { routesApi } from '@/lib/api/routes'; import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api'; +import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; interface RouteStop { stationId: string; @@ -17,6 +18,7 @@ interface RouteStop { distanceKm?: number; distanceFromOrigin?: number; checkinMinutesBefore?: number; + travelMinutesToStop?: number; } type Tab = 'routes' | 'coaches'; @@ -175,8 +177,18 @@ export default function RoutesPage() { const [destinationDistance, setDestinationDistance] = useState(undefined); const [originCheckinMinutes, setOriginCheckinMinutes] = useState(undefined); const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState(undefined); + const [destinationTravelMinutes, setDestinationTravelMinutes] = useState(undefined); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null }); const [search, setSearch] = useState(''); + const [error, setError] = useState(null); + const errorBannerRef = useRef(null); + + // The form scrolls internally (long stop lists push the error banner above the fold), so a + // submit failure can land silently off-screen with no visible indication anything went wrong. + // Scroll the banner into view whenever a new error appears. + useEffect(() => { + if (error) errorBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, [error]); const queryClient = useQueryClient(); const { data: routes, isLoading: routesLoading } = useQuery({ @@ -198,6 +210,11 @@ export default function RoutesPage() { queryClient.invalidateQueries({ queryKey: ['routes'] }); setShowModal(false); setEditingRoute(null); + setError(null); + }, + onError: (e: any) => { + const msg = e?.response?.data?.message || e?.message || 'Failed to create route'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -207,6 +224,11 @@ export default function RoutesPage() { queryClient.invalidateQueries({ queryKey: ['routes'] }); setShowModal(false); setEditingRoute(null); + setError(null); + }, + onError: (e: any) => { + const msg = e?.response?.data?.message || e?.message || 'Failed to update route'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -244,6 +266,8 @@ export default function RoutesPage() { const sortedMiddleStops = stops; // distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm) + // travelMinutesToStop = minutes of travel from the PREVIOUS stop, used to estimate this + // stop's arrival time. The origin (sequence 1) has no predecessor, so it gets none. const stopsArray = [ { stationId: originStationId, sequence: 1, distanceKm: 0, checkinMinutesBefore: originCheckinMinutes ?? undefined }, ...sortedMiddleStops.map((stop, idx) => ({ @@ -251,12 +275,14 @@ export default function RoutesPage() { sequence: idx + 2, distanceKm: stop.distanceFromOrigin || 0, checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined, + travelMinutesToStop: stop.travelMinutesToStop ?? undefined, })), { stationId: destinationStationId, sequence: sortedMiddleStops.length + 2, distanceKm: destinationDistance || 0, checkinMinutesBefore: destinationCheckinMinutes ?? undefined, + travelMinutesToStop: destinationTravelMinutes ?? undefined, }, ]; @@ -266,8 +292,10 @@ export default function RoutesPage() { name: formData.get('name') as string, description: formData.get('description') as string || undefined, active: !editingRoute ? (formData.get('active') !== 'false') : undefined, - effectiveFrom: formData.get('effectiveFrom') as string, - effectiveUntil: formData.get('effectiveUntil') as string || undefined, + effectiveFrom: eatLocalToISO(formData.get('effectiveFrom') as string), + // null (not undefined) so clearing the field on an edit explicitly clears effectiveUntil + // server-side, instead of being silently dropped as "no change". + effectiveUntil: formData.get('effectiveUntil') ? eatLocalToISO(formData.get('effectiveUntil') as string) : null, checkinMinutesBefore: checkinRaw ? parseInt(checkinRaw) : undefined, stops: stopsArray, }; @@ -280,7 +308,12 @@ export default function RoutesPage() { }; const addStop = () => { - setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: 0 }]); + // distanceKm must be cumulative and strictly increasing (enforced server-side) — defaulting + // every new stop to 0 made each one collide with the previous, so simply clicking "Add + // Intermediate Stop" a few times and saving without hand-editing every distance always failed + // validation. Default each new stop's distance a step past whatever precedes it instead. + const lastDistance = stops.length > 0 ? (stops[stops.length - 1].distanceFromOrigin ?? 0) : 0; + setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: lastDistance + 10 }]); }; const removeStop = (index: number) => { @@ -390,14 +423,17 @@ export default function RoutesPage() { setDestinationStationId(destStop.stationId); setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined); setDestinationDistance(destStop.distanceKm || 0); + setDestinationTravelMinutes(destStop.travelMinutesToStop ?? undefined); setStops(routeStops.slice(1, -1).map((s: any) => ({ stationId: s.stationId, sequence: s.sequence, distanceKm: s.distanceKm, distanceFromOrigin: s.distanceKm || 0, checkinMinutesBefore: s.checkinMinutesBefore ?? undefined, + travelMinutesToStop: s.travelMinutesToStop ?? undefined, }))); } + setError(null); setShowModal(true); } finally { setEditLoading(false); @@ -436,7 +472,9 @@ export default function RoutesPage() { setDestinationStationId(''); setDestinationCheckinMinutes(undefined); setDestinationDistance(undefined); + setDestinationTravelMinutes(undefined); setStops([]); + setError(null); setShowModal(true); }} > @@ -515,13 +553,20 @@ export default function RoutesPage() { setDestinationStationId(''); setDestinationCheckinMinutes(undefined); setDestinationDistance(undefined); + setDestinationTravelMinutes(undefined); setStops([]); setSearch(''); + setError(null); }} title={`${editingRoute ? 'Edit' : 'Add'} Route`} size="lg" >
+ {error && ( +
+ {error} +
+ )} {editingRoute && (

⚠ Warning

@@ -647,7 +692,7 @@ export default function RoutesPage() { type="datetime-local" name="effectiveFrom" className="input" - defaultValue={editingRoute?.effectiveFrom ? new Date(editingRoute.effectiveFrom).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16)} + defaultValue={editingRoute?.effectiveFrom ? isoToEATLocal(editingRoute.effectiveFrom) : isoToEATLocal(new Date())} required />
@@ -657,7 +702,7 @@ export default function RoutesPage() { type="datetime-local" name="effectiveUntil" className="input" - defaultValue={editingRoute?.effectiveUntil ? new Date(editingRoute.effectiveUntil).toISOString().slice(0, 16) : ''} + defaultValue={editingRoute?.effectiveUntil ? isoToEATLocal(editingRoute.effectiveUntil) : ''} /> @@ -665,7 +710,7 @@ export default function RoutesPage() {
- Drag to rearrange · Cutoff min overrides route check-in window per stop (leave blank to inherit) + Drag to rearrange · Travel min estimates arrival from the previous stop (falls back to distance if blank) · Cutoff min overrides route check-in window per stop (leave blank to inherit)
@@ -741,6 +786,17 @@ export default function RoutesPage() { required />
+
+ updateStop(index, 'travelMinutesToStop', e.target.value ? parseInt(e.target.value) : undefined)} + min={1} + title="Travel time in minutes from the previous stop, used to estimate this stop's arrival time" + /> +
)}
+
+ {destinationStationId && ( + setDestinationTravelMinutes(e.target.value ? parseInt(e.target.value) : undefined)} + min={1} + title="Travel time in minutes from the previous stop, used to estimate this stop's arrival time" + /> + )} +
@@ -833,8 +902,10 @@ export default function RoutesPage() { setDestinationStationId(''); setDestinationCheckinMinutes(undefined); setDestinationDistance(undefined); + setDestinationTravelMinutes(undefined); setStops([]); setSearch(''); + setError(null); }} > Cancel diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index 0f37825fb..17a31832a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } from 'lucide-react'; +import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, RefreshCw } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; @@ -12,6 +12,7 @@ import { routeCoachTemplatesApi } from '@/lib/api'; import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; import { formatDateTime } from '@/lib/utils'; +import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; import DateTimePicker from '@/components/ui/DateTimePicker'; interface Schedule { @@ -60,8 +61,15 @@ export default function SchedulesPage() { { isOpen: false, item: null } ); const [error, setError] = useState(null); + const errorBannerRef = useRef(null); const queryClient = useQueryClient(); + // These modals can scroll internally — a submit failure can land silently off-screen with no + // visible indication anything went wrong. Scroll the banner into view when a new error appears. + useEffect(() => { + if (error) errorBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, [error]); + const [bulkForm, setBulkForm] = useState({ trainId: '', routeId: '', @@ -167,7 +175,8 @@ export default function SchedulesPage() { setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to generate schedules'); + const msg = err.response?.data?.message || 'Failed to generate schedules'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -181,7 +190,8 @@ export default function SchedulesPage() { setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to create schedule'); + const msg = err.response?.data?.message || 'Failed to create schedule'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -195,7 +205,15 @@ export default function SchedulesPage() { setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to update schedule'); + const msg = err.response?.data?.message || 'Failed to update schedule'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); + }, + }); + + const recalculateStopsMutation = useMutation({ + mutationFn: (id: string) => apiClient.post(`/schedules/${id}/recalculate-stops`, {}), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['schedules'] }); }, }); @@ -229,20 +247,6 @@ export default function SchedulesPage() { }, }); - /** Parse a datetime-local string ("YYYY-MM-DDTHH:mm") as EAT (UTC+3) and return an ISO string. */ - const eatLocalToISO = (local: string): string => { - if (!local) return ''; - return new Date(local + ':00+03:00').toISOString(); - }; - - /** Convert a UTC ISO string to a datetime-local value in EAT (UTC+3). */ - const isoToEATLocal = (iso: string): string => { - if (!iso) return ''; - const utcMs = new Date(iso).getTime(); - const eatMs = utcMs + 3 * 60 * 60 * 1000; - return new Date(eatMs).toISOString().slice(0, 16); - }; - const handleBulkSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); @@ -685,7 +689,7 @@ export default function SchedulesPage() { size="xl" > - {error &&
{error}
} + {error &&
{error}
}
@@ -803,7 +807,7 @@ export default function SchedulesPage() { > {error && ( -
+
{error}
)} @@ -1022,7 +1026,7 @@ export default function SchedulesPage() { {editingSchedule && ( {error && ( -
+
{error}
)} @@ -1133,6 +1137,15 @@ export default function SchedulesPage() { > Cancel + editingSchedule && recalculateStopsMutation.mutate(editingSchedule.id)} + > + Recalculate Stop Times + Update Schedule diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index be5449e97..3ce651ac8 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -2,11 +2,11 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api'; +import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } from '@/lib/api'; import { routesApi } from '@/lib/api/routes'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton' -import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench } from 'lucide-react'; +import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench, Ticket as TicketIcon } from 'lucide-react'; export default function SeatsPage() { const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route'); @@ -24,6 +24,22 @@ export default function SeatsPage() { const [coachToUnblock, setCoachToUnblock] = useState(null); const [showMaintenanceModal, setShowMaintenanceModal] = useState(false); const [maintenanceReason, setMaintenanceReason] = useState(''); + const [showIssueBookingModal, setShowIssueBookingModal] = useState(false); + const [issueBookingCoach, setIssueBookingCoach] = useState(null); + const [issueBookingForm, setIssueBookingForm] = useState({ + bookingKind: 'STAFF' as 'STAFF' | 'PASSENGER', + // No seatClassId — the seat's class is already fixed by the reservation; the backend + // resolves it from the seat's own coach type + nationality tier. + passengerName: '', + dateOfBirth: '', + idDocumentType: 'PASSPORT' as 'NATIONAL_ID' | 'PASSPORT', + idDocumentNumber: '', + passportNumber: '', + nationality: '' as '' | 'Ethiopian' | 'Djiboutian' | 'Other', + phone: '', + email: '', + }); + const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string } | null>(null); const queryClient = useQueryClient(); const { data: schedulesData } = useQuery({ @@ -105,6 +121,21 @@ export default function SeatsPage() { }, }); + const issueBookingMutation = useMutation({ + mutationFn: ({ seatId, data }: { seatId: string; data: any }) => + bookingsApi.issueFromReservation(seatId, data), + onSuccess: (result: any) => { + invalidateSeatData(); + setIssueBookingResult({ payUrl: result?.payUrl }); + if (!result?.payUrl) { + // STAFF booking — nothing further to show the admin, close immediately. + setShowIssueBookingModal(false); + setSelectedSeat(null); + setIssueBookingCoach(null); + } + }, + }); + const removeSeatMutation = useMutation({ mutationFn: (seatId: string) => seatsApi.removeSeat(seatId), onSuccess: () => { @@ -186,11 +217,66 @@ export default function SeatsPage() { }; const handleUnblock = async (seat: any) => { - if (confirm('Are you sure you want to unblock this seat?')) { + if (confirm('Release this reservation and make the seat available to the public?')) { await unblockMutation.mutateAsync(seat.id); } }; + const handleIssueBooking = (seat: any, coach: any) => { + if (activeTab !== 'schedule' || !selectedSchedule) { + alert('Select a specific schedule (Schedule tab) to issue a booking for a reserved seat.'); + return; + } + setSelectedSeat(seat); + setIssueBookingCoach(coach); + setIssueBookingResult(null); + setIssueBookingForm({ + bookingKind: 'STAFF', + passengerName: '', + dateOfBirth: '', + idDocumentType: 'PASSPORT', + idDocumentNumber: '', + passportNumber: '', + nationality: '', + phone: '', + email: '', + }); + setShowIssueBookingModal(true); + }; + + const submitIssueBooking = async () => { + const schedule = schedules.find((s: any) => s.id === selectedSchedule); + if (!schedule?.originStation?.id || !schedule?.destinationStation?.id) { + alert('Could not resolve this schedule\'s origin/destination stations.'); + return; + } + if (!issueBookingForm.passengerName.trim() || !issueBookingForm.dateOfBirth) { + alert('Traveler name and date of birth are required.'); + return; + } + if (!issueBookingForm.nationality) { + alert('Select a nationality.'); + return; + } + if (issueBookingForm.idDocumentType === 'PASSPORT' && !issueBookingForm.passportNumber.trim()) { + alert('Passport number is required.'); + return; + } + if (issueBookingForm.bookingKind === 'PASSENGER' && !issueBookingForm.phone.trim()) { + alert('Phone number is required for a passenger booking (used to send the payment link).'); + return; + } + await issueBookingMutation.mutateAsync({ + seatId: selectedSeat.id, + data: { + scheduleId: selectedSchedule, + originStationId: schedule.originStation.id, + destinationStationId: schedule.destinationStation.id, + ...issueBookingForm, + }, + }); + }; + const handleRemoveSeat = (seat: any) => { setSelectedSeat(seat); setShowRemoveModal(true); @@ -252,7 +338,7 @@ export default function SeatsPage() { const submitBlock = async () => { if (!blockReason.trim()) { - alert('Please provide a reason for blocking'); + alert('Please provide a reason for the reservation'); return; } await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason }); @@ -350,6 +436,7 @@ export default function SeatsPage() { handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} + handleIssueBooking={handleIssueBooking} hideNumber={true} /> ))} @@ -445,6 +532,7 @@ export default function SeatsPage() { handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} + handleIssueBooking={handleIssueBooking} hideNumber={true} /> ))} @@ -467,6 +555,7 @@ export default function SeatsPage() { handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} + handleIssueBooking={handleIssueBooking} hideNumber={true} /> ))} @@ -749,15 +838,15 @@ export default function SeatsPage() { setSelectedSeat(null); setBlockReason(''); }} - title="Block Seat" + title="Reserve Seat" size="md" >

- Block seat {selectedSeat?.seatNumber} in Coach {selectedSeat?.coach?.coachNumber} + Reserve seat {selectedSeat?.seatNumber} in Coach {selectedSeat?.coach?.coachNumber}

- +