mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 02:00:56 +00:00
2
.gitignore
vendored
2
.gitignore
vendored
@@ -33,6 +33,7 @@ docker-compose.override.yml
|
||||
# cypress e2e artifacts
|
||||
e2e/**/cypress/videos/
|
||||
e2e/**/cypress/screenshots/
|
||||
e2e/**/cypress/reports/
|
||||
e2e/**/cypress/downloads/
|
||||
|
||||
# e2e launcher state (ports of the running stack)
|
||||
@@ -47,3 +48,4 @@ e2e-ui-report/
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
RUNNING_LOCALLY.md
|
||||
|
||||
440
E2E_TEST_REPORT.md
Normal file
440
E2E_TEST_REPORT.md
Normal file
@@ -0,0 +1,440 @@
|
||||
# EDR Freight — End-to-End Test Report
|
||||
|
||||
**Date:** 23 July 2026
|
||||
**Branch:** `freight_feature/usermanagement`
|
||||
**Command that was run:**
|
||||
|
||||
```bash
|
||||
E2E_PORTAL_PORT=5374 docker compose -f docker-compose.e2e.yaml --profile cypress \
|
||||
run --rm cypress --spec 'cypress/e2e/flows/*.cy.ts'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Short summary (read this first)
|
||||
|
||||
I ran the end-to-end (E2E) test suite and found **four separate problems**. They are not all the same kind of problem, and this is the most important thing to understand:
|
||||
|
||||
| # | Problem | Kind of problem | Status |
|
||||
|---|---------|-----------------|--------|
|
||||
| 1 | Tests crashed instantly with `exit code 137` | Machine / environment | Explained + how to avoid |
|
||||
| 2 | Portal tests pointed at a dead port (`5374`) | Wrong command setting | Explained + corrected |
|
||||
| 3 | `contract-lifecycle` failed all 5 of its tests | **Real bug in the test code** | ✅ **Fixed and verified** |
|
||||
| 4 | 5 other test files failed | Old app running inside Docker | Diagnosed, needs a rebuild |
|
||||
|
||||
**The only real code bug was problem 3, and it is now fixed.** Problems 1, 2 and 4 are about *how the tests were run*, not about the application logic.
|
||||
|
||||
---
|
||||
|
||||
## 2. Some words explained (for beginners)
|
||||
|
||||
Before the details, here are the words used in this report:
|
||||
|
||||
- **E2E test (end-to-end test)** — a robot that opens a real web browser, clicks buttons like a real user, and checks the result is correct.
|
||||
- **Cypress** — the tool that drives that robot browser.
|
||||
- **Spec** — one test file. It ends with `.cy.ts`. Example: `contract-lifecycle.cy.ts`.
|
||||
- **Docker container** — a small, isolated box that runs one program (the API, the website, the database).
|
||||
- **Docker image** — a *frozen photograph* of your code. A container is started **from** an image. This idea matters a lot in problem 4.
|
||||
- **Port** — a numbered door on your computer. A program listens on one port. If you knock on the wrong door, nobody answers.
|
||||
|
||||
---
|
||||
|
||||
## 3. Result of the full test run
|
||||
|
||||
I ran all 38 test files once, cleanly. This took about 44 minutes.
|
||||
|
||||
```
|
||||
✖ 6 of 38 failed (16%) 44:17 259 tests 223 passing 36 failing
|
||||
```
|
||||
|
||||
The 6 test files that failed:
|
||||
|
||||
| Test file | Tests | Passed | Failed |
|
||||
|-----------|-------|--------|--------|
|
||||
| `contract-lifecycle.cy.ts` | 5 | 0 | **5** |
|
||||
| `export_one_time.cy.ts` | 16 | 9 | 7 |
|
||||
| `import_full_train.cy.ts` | 13 | 8 | 5 |
|
||||
| `intercity_one_time.cy.ts` | 16 | 11 | 5 |
|
||||
| `onboarding.cy.ts` | 3 | 1 | 2 |
|
||||
| `segment_weight.cy.ts` | 13 | 1 | 12 |
|
||||
|
||||
The other 32 test files passed completely.
|
||||
|
||||
---
|
||||
|
||||
## 4. Problem 1 — The tests died immediately with `exit code 137`
|
||||
|
||||
### What you saw
|
||||
|
||||
The command stopped almost at once. There were no test results. The exit code was `137`.
|
||||
|
||||
### What it means
|
||||
|
||||
`137` means the program was **force-killed** by the operating system (it is `128 + 9`, where `9` is the "kill" signal). It is *not* a test failure. The tests never even started.
|
||||
|
||||
### Why it happened
|
||||
|
||||
Look at this part of `docker-compose.e2e.yaml`:
|
||||
|
||||
```yaml
|
||||
cypress:
|
||||
network_mode: host
|
||||
# NOTE: host network shares the abstract X-socket namespace with the host.
|
||||
# Cypress spawns its Xvfb on :99 — run only ONE cypress container at a
|
||||
# time, and don't run it on a host whose X server occupies :99.
|
||||
```
|
||||
|
||||
Cypress needs a screen to draw the browser on. Because there is no real monitor, it creates a fake screen called **Xvfb** on display number **`:99`**.
|
||||
|
||||
Because of `network_mode: host`, that fake screen is shared with the whole computer — **not** kept private inside the container.
|
||||
|
||||
So if **two Cypress runs happen at the same time**, both try to take display `:99`. They fight, Chrome dies, and you get `137`.
|
||||
|
||||
On this machine there was in fact **another Cypress run already going** (from a second terminal session), which is what killed my run.
|
||||
|
||||
### The solution
|
||||
|
||||
**Run only one Cypress container at a time.** Before starting, check nothing else is running:
|
||||
|
||||
```bash
|
||||
docker ps --format '{{.Names}}' | grep cypress
|
||||
```
|
||||
|
||||
If that command prints something, wait for it to finish. If it prints nothing, you are safe to start.
|
||||
|
||||
> This was a problem with the machine being busy — **not** a problem with the tests or the application.
|
||||
|
||||
---
|
||||
|
||||
## 5. Problem 2 — `E2E_PORTAL_PORT=5374` pointed at a dead port
|
||||
|
||||
### What you saw
|
||||
|
||||
Tests that use the customer **portal** website failed. They could not open the page at all.
|
||||
|
||||
### Why it happened
|
||||
|
||||
Your command set the portal port to **5374**:
|
||||
|
||||
```bash
|
||||
E2E_PORTAL_PORT=5374 docker compose ...
|
||||
```
|
||||
|
||||
But the portal container was actually published on port **5373**. Here is the proof:
|
||||
|
||||
```
|
||||
$ cat e2e/freight/.e2e-ports.json
|
||||
{ "E2E_API_PORT": 3101, "E2E_PORTAL_PORT": 5373, "E2E_BACKOFFICE_PORT": 5383, ... }
|
||||
|
||||
$ docker ps
|
||||
edr-freight-e2e-freight-portal-e2e-1 0.0.0.0:5373->80/tcp
|
||||
```
|
||||
|
||||
Here is the important detail. Setting `E2E_PORTAL_PORT=5374` **only changes where Cypress looks**. It does **not** move the already-running portal container. From `cypress.config.ts`:
|
||||
|
||||
```ts
|
||||
portalUrl: process.env.CYPRESS_PORTAL_URL ?? "http://localhost:5373",
|
||||
```
|
||||
|
||||
So Cypress knocked on door **5374**, but the portal was living behind door **5373**. Nobody answered.
|
||||
|
||||
This affected the 5 test files that open the portal:
|
||||
`onboarding`, `contract-lifecycle`, `cross-app`, `export_one_time`, `intercity_one_time`.
|
||||
|
||||
### The solution
|
||||
|
||||
Use the port that matches the running container — simply leave the setting out, because `5373` is already the default:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yaml --profile cypress \
|
||||
run --rm cypress --spec 'cypress/e2e/flows/*.cy.ts'
|
||||
```
|
||||
|
||||
Or, best of all, use the project's official launcher, which picks matching ports for everything automatically:
|
||||
|
||||
```bash
|
||||
pnpm e2e:freight:run
|
||||
```
|
||||
|
||||
> After using the correct port, `cross-app.cy.ts` passed 3/3 — proving the port was the only thing wrong there.
|
||||
|
||||
---
|
||||
|
||||
## 6. Problem 3 — The real bug: `contract-lifecycle` failed all 5 tests ✅ FIXED
|
||||
|
||||
This was the one **genuine code problem**, and it is now fixed and verified.
|
||||
|
||||
### What you saw
|
||||
|
||||
```
|
||||
1) customer creates and submits a GENERAL import container contract:
|
||||
AssertionError: Timed out retrying after 10000ms:
|
||||
Expected to find element: `[role="checkbox"][aria-label="20ft Container"]`,
|
||||
but never found it.
|
||||
```
|
||||
|
||||
And then 4 more failures after it.
|
||||
|
||||
### Why it happened
|
||||
|
||||
The test was looking for a **checkbox** to choose the container size (20ft):
|
||||
|
||||
```ts
|
||||
cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
|
||||
cy.get('textarea[placeholder*="Electronics"]').type("E2E electronics shipment scope");
|
||||
```
|
||||
|
||||
But **the application was deliberately changed**. Container contracts now automatically cover **both** 20ft and 40ft sizes, so the checkbox was removed and replaced by a simple information card. The cargo description was also moved to the booking step.
|
||||
|
||||
You can see this clearly in the current application code, `step3-cargo-scope.tsx`:
|
||||
|
||||
```tsx
|
||||
{/* Container scope: the contract always covers BOTH sizes and quotes both
|
||||
rates. Quantities (a size can be 0) and the cargo description are
|
||||
captured at booking time. */}
|
||||
{cargoType === "container" && (
|
||||
...
|
||||
<Text fz={14} fw={700}>20ft & 40ft containers covered</Text>
|
||||
```
|
||||
|
||||
And the validation rules confirm nothing else is needed (`schema.ts`):
|
||||
|
||||
```ts
|
||||
// Container scope needs no validation: both sizes are always in scope and
|
||||
// the cargo description moved to booking time.
|
||||
```
|
||||
|
||||
So: **the application was updated, but the test was not.** The test kept looking for a button that no longer exists.
|
||||
|
||||
**Why all 5 tests failed, not just one.** These 5 tests run in order and build on each other. Test 1 creates the contract; tests 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 '<p.mantine-Text-root>' to be 'visible'
|
||||
This element is not visible because its content is being clipped by one of its
|
||||
parent elements, which has a CSS property of overflow: hidden, clip, scroll or auto
|
||||
```
|
||||
|
||||
The contract *was* created correctly. The word "Submitted" *was* on the screen. But it sits inside a side-scrolling list, so Cypress treated it as hidden.
|
||||
|
||||
Because the line just after it already checks the database properly (which is the stronger, more trustworthy check), I made the screen check clip-proof:
|
||||
|
||||
```diff
|
||||
- cy.contains("Submitted", { timeout: 15000 }).should("be.visible");
|
||||
+ // `exist`, not `be.visible`: the status badge sits inside the list's
|
||||
+ // horizontally-scrolling container, so Cypress reports it as clipped by an
|
||||
+ // overflow parent. The DB assertion below is the authoritative check.
|
||||
+ cy.contains("Submitted", { timeout: 15000 }).should("exist");
|
||||
```
|
||||
|
||||
### Proof that it works
|
||||
|
||||
**Before the fix:**
|
||||
|
||||
```
|
||||
contract-lifecycle.cy.ts 5 tests 0 passing 5 failing
|
||||
```
|
||||
|
||||
**After the fix:**
|
||||
|
||||
```
|
||||
✓ customer creates and submits a GENERAL import container contract (7574ms)
|
||||
✓ marketer accepts the submission and approves the LINE_STAFF step (3668ms)
|
||||
✓ director approves the final step — contract PDF becomes ready (2916ms)
|
||||
✓ customer signs the contract with OTP (5143ms)
|
||||
✓ staff counter-signs — GENERAL contract becomes CONTRACT_ACTIVE (3643ms)
|
||||
|
||||
5 passing (29s) EXIT=0
|
||||
```
|
||||
|
||||
✅ **All 5 tests now pass.**
|
||||
|
||||
---
|
||||
|
||||
## 7. Problem 4 — The remaining 5 test files: the app inside Docker is old
|
||||
|
||||
This is the second most important finding, and it explains **almost all remaining failures**.
|
||||
|
||||
### What you saw
|
||||
|
||||
Many strange, unrelated-looking errors, for example:
|
||||
|
||||
```
|
||||
CypressError: cy.request() failed on:
|
||||
http://localhost:3101/api/train-scheduling/schedules/<id>/dispatch
|
||||
The response we received from your web server was:
|
||||
> 400: Bad Request
|
||||
```
|
||||
|
||||
```
|
||||
AssertionError: expected '/dashboard/operations/train-scheduling-v2'
|
||||
to match /\/dashboard\/operations\/train-scheduling-v2\/.+/
|
||||
```
|
||||
|
||||
```
|
||||
AssertionError: Expected to find content: 'Clearance Review' within the selector: '[role="tab"]'
|
||||
```
|
||||
|
||||
### Why it happened
|
||||
|
||||
**The test files are new, but the running application is old.**
|
||||
|
||||
- The test files live on your disk and are shared into the container live, so they are always the newest version.
|
||||
- The API and websites run from **Docker images**, which are frozen photographs of the code. They only change when you **rebuild** them.
|
||||
|
||||
Here are the actual times:
|
||||
|
||||
```
|
||||
freight-api-e2e image built: 2026-07-23 09:22
|
||||
freight-portal-e2e image built: 2026-07-23 09:22
|
||||
freight-backoffice-e2e image built: 2026-07-23 09:22
|
||||
|
||||
latest commit (HEAD): 2026-07-23 20:24 ← 11 hours newer
|
||||
```
|
||||
|
||||
**5 commits were made after those images were built:**
|
||||
|
||||
```
|
||||
668b5e1c add permissions and fix issues
|
||||
40f16f3c changes
|
||||
12767605 changes
|
||||
15a6bab5 revert back the clerance payment
|
||||
13609f8d changes
|
||||
```
|
||||
|
||||
### The clearest proof
|
||||
|
||||
Commit `668b5e1c` changed the **API and its tests together**, in the same commit:
|
||||
|
||||
```
|
||||
apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
|
||||
apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
|
||||
e2e/freight/cypress/e2e/flows/import-utils.ts
|
||||
e2e/freight/cypress/e2e/flows/import_full_train.cy.ts
|
||||
```
|
||||
|
||||
So the **new test** sends the **new** data shape to `/dispatch`, but the **old API** inside Docker does not understand it and replies `400 Bad Request`.
|
||||
|
||||
This is not a bug in the code. It is simply **new tests talking to an old server**.
|
||||
|
||||
The `train-scheduling-v2` failures have the same cause: creating a train schedule fails on the old server, so no train exists, and every later step in those files fails as a cascade (this is why `segment_weight` lost 12 of 13 tests from a single root cause).
|
||||
|
||||
### The solution
|
||||
|
||||
Rebuild the Docker images so they contain the current code, then run the tests again:
|
||||
|
||||
```bash
|
||||
# stop the old stack and rebuild from current code
|
||||
docker compose -f docker-compose.e2e.yaml down
|
||||
docker compose -f docker-compose.e2e.yaml build
|
||||
pnpm e2e:freight:run
|
||||
```
|
||||
|
||||
> ⚠️ Note: `down` deletes the test database (it is a throwaway database, which is normal and safe).
|
||||
> ⚠️ Note: only do this when nobody else is using the same stack.
|
||||
|
||||
---
|
||||
|
||||
## 8. Files I changed
|
||||
|
||||
Only test files were changed. **No application code was modified.**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts` | Removed deleted container-size checkbox + description box; made the "Submitted" check clip-proof; updated the header comment |
|
||||
| `e2e/freight/cypress/e2e/flows/export_one_time.cy.ts` | Removed deleted 20ft + 40ft checkboxes and description box |
|
||||
| `e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts` | Removed deleted 20ft checkbox and description box |
|
||||
|
||||
Evidence the last two fixes helped, even on the old server:
|
||||
|
||||
- `export_one_time` — previously failed at contract creation; now reaches 9 passing tests.
|
||||
- `intercity_one_time` — previously failed at contract creation; now reaches 11 passing tests.
|
||||
|
||||
Their remaining failures are all from problem 4 (old Docker images).
|
||||
|
||||
---
|
||||
|
||||
## 9. How to run the tests correctly
|
||||
|
||||
**Step 1 — make sure no other Cypress is running** (this avoids the `137` crash):
|
||||
|
||||
```bash
|
||||
docker ps --format '{{.Names}}' | grep cypress
|
||||
```
|
||||
|
||||
**Step 2 — rebuild so Docker has the current code:**
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yaml down
|
||||
docker compose -f docker-compose.e2e.yaml build
|
||||
```
|
||||
|
||||
**Step 3 — run the tests using the official launcher** (it chooses matching ports for you):
|
||||
|
||||
```bash
|
||||
pnpm e2e:freight:run
|
||||
```
|
||||
|
||||
If you prefer the raw Docker command, **do not** override the portal port unless you also restart the portal container on that same port:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yaml --profile cypress \
|
||||
run --rm cypress --spec 'cypress/e2e/flows/*.cy.ts'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Conclusion
|
||||
|
||||
- **One real bug was found and fixed:** three test files were still clicking a container-size checkbox and a description box that the application no longer has, because container contracts now cover both 20ft and 40ft automatically.
|
||||
- `contract-lifecycle.cy.ts` went from **0 of 5 passing** to **5 of 5 passing**, confirmed by running it twice.
|
||||
- The `exit 137` crash was caused by **two Cypress runs at the same time** fighting over the shared virtual screen `:99`.
|
||||
- The portal failures were caused by **`E2E_PORTAL_PORT=5374`**, while the portal was really on **5373**.
|
||||
- The remaining failures are caused by **Docker images that are 11 hours older than the code**. They need a rebuild, not a code fix.
|
||||
|
||||
**Recommended next step:** rebuild the Docker images and run the full suite again. Only then can the remaining 5 test files be judged fairly.
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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)])),
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Bulk tonnage at assignment time. First-mile trucks and export self-haul
|
||||
* trucks carry a planned load (tonnes + optional item count) so bulk bookings
|
||||
* draw down as vehicles are assigned — not only at the weighbridge.
|
||||
*/
|
||||
export class AddMileTonsQuantity2820000000000 implements MigrationInterface {
|
||||
name = 'AddMileTonsQuantity2820000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.first_mile_vehicle_assignments ADD COLUMN IF NOT EXISTS tons numeric(14,3);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.first_mile_vehicle_assignments ADD COLUMN IF NOT EXISTS quantity integer;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.customer_truck_assignments ADD COLUMN IF NOT EXISTS planned_tons numeric(14,3);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.customer_truck_assignments ADD COLUMN IF NOT EXISTS planned_quantity integer;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.customer_truck_assignments DROP COLUMN IF EXISTS planned_quantity;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.customer_truck_assignments DROP COLUMN IF EXISTS planned_tons;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.first_mile_vehicle_assignments DROP COLUMN IF EXISTS quantity;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.first_mile_vehicle_assignments DROP COLUMN IF EXISTS tons;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Truck types become back-office data instead of a hardcoded `VehicleType` enum,
|
||||
* so EDR can add a configuration without a code change.
|
||||
*
|
||||
* `vehicles.vehicle_type` is deliberately LEFT IN PLACE as a denormalised code.
|
||||
* Truck-detention billing groups trucks with raw SQL over that column
|
||||
* (`SELECT v.vehicle_type ... GROUP BY`, warehouse-fee.service.ts) and matches
|
||||
* the result against `warehouse_fee_rules.vehicle_type`. Swapping it for the FK
|
||||
* outright would silently drop detention charges, so the FK is additive and the
|
||||
* service writes the type's code through on every save.
|
||||
*
|
||||
* Raw SQL, `freight.`-qualified, IF NOT EXISTS throughout — the TypeORM builder
|
||||
* API resolves bare names against `public` and crash-loops boot.
|
||||
*/
|
||||
export class AddTruckTypes2840000000000 implements MigrationInterface {
|
||||
name = "AddTruckTypes2840000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.truck_types (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code varchar(32) NOT NULL,
|
||||
name varchar(100) NOT NULL,
|
||||
capacity_tons numeric(10,3),
|
||||
has_trailer boolean NOT NULL DEFAULT false,
|
||||
description text,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_truck_types_code
|
||||
ON freight.truck_types (code)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS ix_truck_types_is_active
|
||||
ON freight.truck_types (is_active)
|
||||
`);
|
||||
|
||||
// Seed one row per legacy enum value so vehicles already carrying that code
|
||||
// keep resolving, plus CASONI as the first rigid (no-trailer) configuration.
|
||||
// has_trailer is true only for the articulated configurations.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.truck_types (code, name, has_trailer)
|
||||
VALUES
|
||||
('TRUCK', 'Truck', true),
|
||||
('TRAILER', 'Trailer', true),
|
||||
('TANKER', 'Tanker', true),
|
||||
('FLATBED', 'Flatbed', true),
|
||||
('VAN', 'Van', false),
|
||||
('CAR', 'Car', false),
|
||||
('BUS', 'Bus', false),
|
||||
('CASONI', 'Casoni (rigid, no trailer)', false)
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS truck_type_id uuid
|
||||
`);
|
||||
|
||||
// Separate DO block: ADD CONSTRAINT has no IF NOT EXISTS in Postgres.
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_vehicles_truck_type'
|
||||
) THEN
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD CONSTRAINT fk_vehicles_truck_type
|
||||
FOREIGN KEY (truck_type_id) REFERENCES freight.truck_types (id)
|
||||
ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
`);
|
||||
|
||||
// Backfill the FK from the code already stored on each vehicle.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.vehicles v
|
||||
SET truck_type_id = t.id
|
||||
FROM freight.truck_types t
|
||||
WHERE v.truck_type_id IS NULL
|
||||
AND upper(trim(v.vehicle_type)) = t.code
|
||||
`);
|
||||
|
||||
// Truck-type codes are varchar(32); the fee-rule column they are matched
|
||||
// against was varchar(20) and would truncate/reject longer codes.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_fee_rules
|
||||
ALTER COLUMN vehicle_type TYPE varchar(32)
|
||||
`);
|
||||
|
||||
// A VIN identifies exactly one vehicle worldwide. Partial index so the many
|
||||
// existing rows without a VIN do not collide.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_vehicles_vin
|
||||
ON freight.vehicles (vin)
|
||||
WHERE vin IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_vehicles_vin`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP CONSTRAINT IF EXISTS fk_vehicles_truck_type
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP COLUMN IF EXISTS truck_type_id
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.truck_types`);
|
||||
// warehouse_fee_rules.vehicle_type is left widened: narrowing it back would
|
||||
// fail on any row that stored a code longer than 20 characters.
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -526,6 +526,8 @@ export class ContractsController {
|
||||
contractId: view.bookingId,
|
||||
reference: view.reference,
|
||||
status: view.status,
|
||||
// Drives the per-freight-type sign permission on the client.
|
||||
freightType: contract.freightType,
|
||||
templateKey: view.templateKey,
|
||||
title: view.template.title,
|
||||
html,
|
||||
@@ -577,19 +579,21 @@ export class ContractsController {
|
||||
@Post(':id/contract/sign')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||
signContract(
|
||||
async signContract(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SignContractDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Each staff signing role maps to the permission that step already requires;
|
||||
// customers sign their own contract with no permission key.
|
||||
const signRolePermission: Record<string, string> = {
|
||||
STAFF: FREIGHT_PERMS.contracts.signStaff,
|
||||
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
||||
};
|
||||
if (dto.role !== 'CUSTOMER') {
|
||||
// Each staff signing role maps to the permission that step already
|
||||
// requires; the STAFF counter-signature is split per freight type, so a
|
||||
// bulk signer cannot counter-sign a container contract (and vice versa).
|
||||
const contract = await this.contractsService.findById(id);
|
||||
const signRolePermission: Record<string, string> = {
|
||||
STAFF: forFreightType(FREIGHT_PERMS.contracts.signStaff, contract.freightType),
|
||||
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
||||
};
|
||||
assertFreightPermission(user, signRolePermission[dto.role]);
|
||||
}
|
||||
return this.transitionService.sign(id, dto, {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -532,17 +532,47 @@ export class FirstMileService {
|
||||
*/
|
||||
async setVehicles(
|
||||
id: string,
|
||||
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
||||
inputs: Array<{
|
||||
vehicleId: string;
|
||||
containerNumber?: string | null;
|
||||
tons?: number | null;
|
||||
quantity?: number | null;
|
||||
}>,
|
||||
): Promise<FirstMile> {
|
||||
const existing = await this.findById(id);
|
||||
// Dedupe by vehicleId, keeping the container number; preserve order.
|
||||
const desiredMap = new Map<string, string | null>();
|
||||
// Dedupe by vehicleId, keeping the load details; preserve order.
|
||||
const desiredMap = new Map<
|
||||
string,
|
||||
{ containerNumber: string | null; tons: number | null; quantity: number | null }
|
||||
>();
|
||||
for (const inp of inputs) {
|
||||
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
|
||||
if (inp.vehicleId) {
|
||||
desiredMap.set(inp.vehicleId, {
|
||||
containerNumber: inp.containerNumber ?? null,
|
||||
tons: inp.tons ?? null,
|
||||
quantity: inp.quantity ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
const desired = [...desiredMap.keys()];
|
||||
const desiredSet = new Set(desired);
|
||||
|
||||
// Bulk drawdown: assigned tonnage may not exceed what the booking declares.
|
||||
const totalTons = [...desiredMap.values()].reduce((s, v) => s + (Number(v.tons) || 0), 0);
|
||||
if (totalTons > 0 && existing.bookingId) {
|
||||
const [b]: Array<{ vgm: string | null }> = await this.dataSource.query(
|
||||
`SELECT cargo_total_weight_vgm AS vgm FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[existing.bookingId],
|
||||
);
|
||||
const declared = Number(b?.vgm ?? 0);
|
||||
if (declared > 0 && totalTons > declared + 0.001) {
|
||||
throw new BadRequestException(
|
||||
`Assigned tonnage (${totalTons} t) exceeds the booking's declared ${declared} t`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const manager = this.dataSource.manager;
|
||||
const current = await manager.find(FirstMileVehicleAssignment, {
|
||||
where: { firstMileId: id },
|
||||
@@ -555,12 +585,16 @@ export class FirstMileService {
|
||||
)];
|
||||
const added = desired.filter((v) => !junctionSet.has(v));
|
||||
const removed = releaseIds.filter((v) => !desiredSet.has(v));
|
||||
// Vehicles that stay but whose container number changed.
|
||||
const changed = current.filter(
|
||||
(a) =>
|
||||
desiredMap.has(a.vehicleId) &&
|
||||
(a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null),
|
||||
);
|
||||
// Vehicles that stay but whose load details changed.
|
||||
const changed = current.filter((a) => {
|
||||
const want = desiredMap.get(a.vehicleId);
|
||||
if (!want) return false;
|
||||
return (
|
||||
(a.containerNumber ?? null) !== want.containerNumber ||
|
||||
(a.tons == null ? null : Number(a.tons)) !== want.tons ||
|
||||
(a.quantity ?? null) !== want.quantity
|
||||
);
|
||||
});
|
||||
|
||||
await this.dataSource.transaction(async (tx) => {
|
||||
if (removed.length) {
|
||||
@@ -570,17 +604,25 @@ export class FirstMileService {
|
||||
});
|
||||
}
|
||||
for (const vehicleId of added) {
|
||||
const want = desiredMap.get(vehicleId);
|
||||
await tx.insert(FirstMileVehicleAssignment, {
|
||||
firstMileId: id,
|
||||
vehicleId,
|
||||
containerNumber: desiredMap.get(vehicleId) ?? null,
|
||||
containerNumber: want?.containerNumber ?? null,
|
||||
tons: want?.tons ?? null,
|
||||
quantity: want?.quantity ?? null,
|
||||
});
|
||||
}
|
||||
for (const row of changed) {
|
||||
const want = desiredMap.get(row.vehicleId);
|
||||
await tx.update(
|
||||
FirstMileVehicleAssignment,
|
||||
{ firstMileId: id, vehicleId: row.vehicleId },
|
||||
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
|
||||
{
|
||||
containerNumber: want?.containerNumber ?? null,
|
||||
tons: want?.tons ?? null,
|
||||
quantity: want?.quantity ?? null,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
|
||||
import { CreateTruckTypeDto } from './create-truck-type.dto';
|
||||
|
||||
export class UpdateTruckTypeDto extends PartialType(CreateTruckTypeDto) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
RuleEngineCreate,
|
||||
RuleEngineDelete,
|
||||
RuleEngineUpdate,
|
||||
RuleEngineView,
|
||||
} from '../../common/rule-engine-guards';
|
||||
|
||||
import { CreateTruckTypeDto } from './dto/create-truck-type.dto';
|
||||
import { UpdateTruckTypeDto } from './dto/update-truck-type.dto';
|
||||
import { TruckTypesService } from './truck-types.service';
|
||||
|
||||
@ApiTags('truck-types')
|
||||
@Controller('truck-types')
|
||||
@ApiBearerAuth()
|
||||
export class TruckTypesController {
|
||||
constructor(private readonly truckTypesService: TruckTypesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('truck-types')
|
||||
@ApiOperation({ summary: 'List truck types' })
|
||||
findAll(@Query() query: Record<string, string | undefined>) {
|
||||
return this.truckTypesService.findAll({
|
||||
isActive:
|
||||
query.isActive === 'all'
|
||||
? undefined
|
||||
: query.isActive !== undefined
|
||||
? query.isActive === 'true'
|
||||
: true,
|
||||
page: query.page ? parseInt(query.page, 10) : undefined,
|
||||
pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
|
||||
sortBy: query.sortBy,
|
||||
sortOrder: query.sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('truck-types')
|
||||
@ApiOperation({ summary: 'Get a truck type by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.truckTypesService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineCreate('truck-types')
|
||||
@ApiOperation({ summary: 'Create a truck type' })
|
||||
create(@Body() dto: CreateTruckTypeDto) {
|
||||
return this.truckTypesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineUpdate('truck-types')
|
||||
@ApiOperation({ summary: 'Update a truck type' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTruckTypeDto) {
|
||||
return this.truckTypesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineDelete('truck-types')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a truck type' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.truckTypesService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { TruckType } from './entities/truck-type.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TruckTypesRepository extends BaseRepository<TruckType> {
|
||||
constructor(
|
||||
@InjectRepository(TruckType)
|
||||
repository: Repository<TruckType>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByCode(code: string): Promise<TruckType | null> {
|
||||
return this.repository.findOne({ where: { code } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsOrder } from 'typeorm';
|
||||
|
||||
import { CreateTruckTypeDto } from './dto/create-truck-type.dto';
|
||||
import { UpdateTruckTypeDto } from './dto/update-truck-type.dto';
|
||||
import { TruckType } from './entities/truck-type.entity';
|
||||
import { TruckTypesRepository } from './truck-types.repository';
|
||||
|
||||
type TruckTypeListFilter = {
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TruckTypesService {
|
||||
constructor(private readonly truckTypesRepository: TruckTypesRepository) {}
|
||||
|
||||
async findAll(filter: TruckTypeListFilter = {}): Promise<{
|
||||
data: TruckType[];
|
||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 500;
|
||||
const sortBy = ['code', 'name', 'capacityTons', 'hasTrailer', 'isActive'].includes(
|
||||
filter.sortBy ?? '',
|
||||
)
|
||||
? (filter.sortBy as keyof TruckType)
|
||||
: 'code';
|
||||
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
|
||||
const [data, total] = await this.truckTypesRepository.findAndCount({
|
||||
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
|
||||
order: { [sortBy]: sortOrder } as FindOptionsOrder<TruckType>,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<TruckType> {
|
||||
const truckType = await this.truckTypesRepository.findById(id);
|
||||
|
||||
if (!truckType) {
|
||||
throw new NotFoundException(`Truck type ${id} not found`);
|
||||
}
|
||||
|
||||
return truckType;
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<TruckType> {
|
||||
const truckType = await this.truckTypesRepository.findByCode(code);
|
||||
if (!truckType) {
|
||||
throw new NotFoundException(`Truck type ${code} not found`);
|
||||
}
|
||||
return truckType;
|
||||
}
|
||||
|
||||
async create(dto: CreateTruckTypeDto): Promise<TruckType> {
|
||||
const code = dto.code.trim().toUpperCase();
|
||||
const existing = await this.truckTypesRepository.findByCode(code);
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(`Truck type code "${code}" already exists`);
|
||||
}
|
||||
|
||||
return this.truckTypesRepository.create({
|
||||
code,
|
||||
name: dto.name.trim(),
|
||||
capacityTons: dto.capacityTons ?? null,
|
||||
hasTrailer: dto.hasTrailer ?? false,
|
||||
description: dto.description?.trim() ?? null,
|
||||
isActive: dto.isActive ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateTruckTypeDto): Promise<TruckType> {
|
||||
const truckType = await this.findById(id);
|
||||
const nextCode = dto.code?.trim().toUpperCase();
|
||||
|
||||
if (nextCode && nextCode !== truckType.code) {
|
||||
const existing = await this.truckTypesRepository.findByCode(nextCode);
|
||||
if (existing) {
|
||||
throw new ConflictException(`Truck type code "${nextCode}" already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.truckTypesRepository.update(id, {
|
||||
...dto,
|
||||
...(nextCode ? { code: nextCode } : {}),
|
||||
...(dto.name ? { name: dto.name.trim() } : {}),
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Truck type ${id} not found`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.truckTypesRepository.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Not, Repository } from 'typeorm';
|
||||
import { CreateVehicleDto } from './dto/create-vehicle.dto';
|
||||
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
|
||||
import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity';
|
||||
import { TruckType } from '../truck-types/entities/truck-type.entity';
|
||||
import { TruckTypesService } from '../truck-types/truck-types.service';
|
||||
import { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.entity';
|
||||
import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity';
|
||||
import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity';
|
||||
@@ -18,8 +25,26 @@ export class VehiclesService {
|
||||
@InjectRepository(Vehicle)
|
||||
private readonly vehicleRepo: Repository<Vehicle>,
|
||||
private readonly history: FleetHistoryService,
|
||||
private readonly truckTypes: TruckTypesService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* A trailer plate only exists on a configuration that pulls a trailer — a
|
||||
* rigid truck (Casoni) has none. Checked against the RESULTING record, not
|
||||
* just the patch, so switching an articulated truck to a rigid type cannot
|
||||
* leave its old trailer plate stranded on the row.
|
||||
*/
|
||||
private assertTrailerPlateAllowed(
|
||||
truckType: TruckType,
|
||||
trailerPlateNo?: string | null,
|
||||
): void {
|
||||
if (!truckType.hasTrailer && trailerPlateNo) {
|
||||
throw new BadRequestException(
|
||||
`${truckType.name} has no trailer — remove the trailer plate number`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A driver holds one truck at a time — reassignment requires detaching them
|
||||
* from their current truck first.
|
||||
@@ -54,10 +79,17 @@ export class VehiclesService {
|
||||
await this.assertDriverUnassigned(dto.assignedDriverId);
|
||||
}
|
||||
|
||||
const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
|
||||
const truckType = await this.truckTypes.findById(dto.truckTypeId);
|
||||
this.assertTrailerPlateAllowed(truckType, dto.trailerPlateNo);
|
||||
|
||||
const registrationNumber = `REG-${truckType.code}-${Date.now()}`;
|
||||
const vehicle = this.vehicleRepo.create({
|
||||
...dto,
|
||||
registrationNumber,
|
||||
// Denormalised for truck-detention billing, which groups on this column.
|
||||
vehicleType: truckType.code,
|
||||
// Capacity belongs to the type; an explicit value still wins for one-offs.
|
||||
capacity: dto.capacity ?? truckType.capacityTons ?? undefined,
|
||||
});
|
||||
|
||||
const saved = await this.vehicleRepo.save(vehicle);
|
||||
@@ -148,6 +180,17 @@ export class VehiclesService {
|
||||
await this.assertDriverUnassigned(dto.assignedDriverId, id);
|
||||
}
|
||||
|
||||
// Re-resolve the truck type whenever the type OR the trailer plate moves —
|
||||
// either edit can produce a rigid truck holding a trailer plate.
|
||||
const nextTruckTypeId = dto.truckTypeId ?? vehicle.truckTypeId;
|
||||
let nextTruckType: TruckType | null = null;
|
||||
if (nextTruckTypeId && (dto.truckTypeId !== undefined || dto.trailerPlateNo !== undefined)) {
|
||||
nextTruckType = await this.truckTypes.findById(nextTruckTypeId);
|
||||
const nextTrailerPlate =
|
||||
dto.trailerPlateNo !== undefined ? dto.trailerPlateNo : vehicle.trailerPlateNo;
|
||||
this.assertTrailerPlateAllowed(nextTruckType, nextTrailerPlate);
|
||||
}
|
||||
|
||||
const prev = {
|
||||
assignedDriverId: vehicle.assignedDriverId,
|
||||
assignedDriverName: vehicle.assignedDriverName,
|
||||
@@ -156,6 +199,11 @@ export class VehiclesService {
|
||||
};
|
||||
|
||||
Object.assign(vehicle, dto);
|
||||
// After the patch is applied, so the denormalised billing code always
|
||||
// reflects the type the vehicle actually ends up on.
|
||||
if (nextTruckType) {
|
||||
vehicle.vehicleType = nextTruckType.code;
|
||||
}
|
||||
const saved = await this.vehicleRepo.save(vehicle);
|
||||
|
||||
// Driver (re)assignment — emit an unassign for the old driver and/or an
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -19,6 +19,9 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
|
||||
'rates',
|
||||
'approval-rules',
|
||||
'yard-distances',
|
||||
// Keep new slugs at the END: ruleEngineCrudId derives ids from list index,
|
||||
// so a mid-list insert would shift ids already seeded for later slugs.
|
||||
'truck-types',
|
||||
] as const;
|
||||
|
||||
export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number];
|
||||
@@ -57,7 +60,6 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('a1000001-0001-4000-8000-000000000021', 'edr_freight_app:bookings:upload_clearance_output', 'Upload customs output documents'),
|
||||
perm('a1000001-0001-4000-8000-000000000022', 'edr_freight_app:bookings:finalize_clearance', 'Finalize document clearance'),
|
||||
perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'),
|
||||
perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'),
|
||||
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
|
||||
perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'),
|
||||
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
|
||||
@@ -83,7 +85,10 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('a3000001-0001-4000-8000-000000000006', 'edr_freight_app:contracts:approve_director', 'Approve contract as director'),
|
||||
perm('a3000001-0001-4000-8000-000000000007', 'edr_freight_app:contracts:approve_ceo', 'Approve contract as CEO'),
|
||||
perm('a3000001-0001-4000-8000-000000000008', 'edr_freight_app:contracts:generate_contract', 'Generate contract document'),
|
||||
perm('a3000001-0001-4000-8000-000000000009', 'edr_freight_app:contracts:sign_staff', 'Staff contract signature'),
|
||||
// Staff counter-signature is split per freight type too — fresh ids for the
|
||||
// same reason as the intake keys above.
|
||||
perm('a3000001-0001-4000-8000-000000000017', 'edr_freight_app:contracts:sign_staff:bulk', 'Staff contract signature: bulk'),
|
||||
perm('a3000001-0001-4000-8000-000000000018', 'edr_freight_app:contracts:sign_staff:container', 'Staff contract signature: container'),
|
||||
perm('a3000001-0001-4000-8000-00000000000a', 'edr_freight_app:contracts:clearance_review', 'Review pre-booking clearance docs'),
|
||||
perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'),
|
||||
perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'),
|
||||
@@ -93,24 +98,43 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'),
|
||||
];
|
||||
|
||||
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
||||
'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' },
|
||||
'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' },
|
||||
'wagon-types': { view: 'b2000001-0001-4000-8000-000000000015', manage: 'b2000001-0001-4000-8000-000000000016' },
|
||||
'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' },
|
||||
yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' },
|
||||
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },
|
||||
'weight-limit-rules': { view: 'b2000001-0001-4000-8000-00000000000b', manage: 'b2000001-0001-4000-8000-00000000000c' },
|
||||
'priority-configs': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' },
|
||||
rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' },
|
||||
'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' },
|
||||
'yard-distances': { view: 'b2000001-0001-4000-8000-000000000018', manage: 'b2000001-0001-4000-8000-000000000019' },
|
||||
// Existing per-slug view ids are kept as-is: position-type grants reference
|
||||
// them by id, so re-minting would orphan those rows.
|
||||
const RULE_ENGINE_VIEW_IDS: Record<RuleEngineResourceSlug, string> = {
|
||||
'cargo-types': 'b2000001-0001-4000-8000-000000000001',
|
||||
'container-types': 'b2000001-0001-4000-8000-000000000003',
|
||||
'wagon-types': 'b2000001-0001-4000-8000-000000000015',
|
||||
'truck-types': 'b2000001-0001-4000-8000-00000000001a',
|
||||
'service-types': 'b2000001-0001-4000-8000-000000000005',
|
||||
yards: 'b2000001-0001-4000-8000-000000000007',
|
||||
'shipping-lines': 'b2000001-0001-4000-8000-000000000009',
|
||||
'weight-limit-rules': 'b2000001-0001-4000-8000-00000000000b',
|
||||
'priority-configs': 'b2000001-0001-4000-8000-00000000000f',
|
||||
rates: 'b2000001-0001-4000-8000-000000000011',
|
||||
'approval-rules': 'b2000001-0001-4000-8000-000000000013',
|
||||
'yard-distances': 'b2000001-0001-4000-8000-000000000018',
|
||||
};
|
||||
|
||||
// CRUD replaces the retired coarse `:manage`. New ids live in a fresh block
|
||||
// (b2000002-…) so a stale `:manage` grant can never silently confer a CRUD
|
||||
// action — the migration re-grants create/update/delete explicitly.
|
||||
const RULE_ENGINE_CRUD_ACTIONS = ['create', 'update', 'delete'] as const;
|
||||
type RuleEngineCrudAction = (typeof RULE_ENGINE_CRUD_ACTIONS)[number];
|
||||
const ruleEngineCrudId = (
|
||||
slug: RuleEngineResourceSlug,
|
||||
action: RuleEngineCrudAction,
|
||||
): string => {
|
||||
const n =
|
||||
RULE_ENGINE_RESOURCE_SLUGS.indexOf(slug) * 3 +
|
||||
RULE_ENGINE_CRUD_ACTIONS.indexOf(action) +
|
||||
1; // 1..36
|
||||
return `b2000002-0001-4000-8000-${n.toString(16).padStart(12, '0')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Slugs whose changes go through a separate approver. `manage` lets a staff
|
||||
* member propose a change; only `approve` lets someone put it into effect.
|
||||
* Only listed slugs get the permission — the rest are manage-only.
|
||||
* Slugs whose changes go through a separate approver. CRUD lets a staff member
|
||||
* propose a change; only `approve` lets someone put it into effect. Only listed
|
||||
* slugs get the permission.
|
||||
*/
|
||||
const RULE_ENGINE_APPROVE_PERMISSION_IDS: Partial<Record<RuleEngineResourceSlug, string>> = {
|
||||
rates: 'b2000001-0001-4000-8000-000000000017',
|
||||
@@ -121,11 +145,12 @@ export type RuleEngineApprovableSlug = 'rates';
|
||||
export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap(
|
||||
(slug) => {
|
||||
const resource = slugToResourceKey(slug);
|
||||
const ids = RULE_ENGINE_PERMISSION_IDS[slug];
|
||||
const approveId = RULE_ENGINE_APPROVE_PERMISSION_IDS[slug];
|
||||
return [
|
||||
perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`),
|
||||
perm(ids.manage, `edr_freight_app:rule_engine:${resource}:manage`, `Manage ${slug}`),
|
||||
perm(RULE_ENGINE_VIEW_IDS[slug], `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`),
|
||||
perm(ruleEngineCrudId(slug, 'create'), `edr_freight_app:rule_engine:${resource}:create`, `Create ${slug}`),
|
||||
perm(ruleEngineCrudId(slug, 'update'), `edr_freight_app:rule_engine:${resource}:update`, `Update ${slug}`),
|
||||
perm(ruleEngineCrudId(slug, 'delete'), `edr_freight_app:rule_engine:${resource}:delete`, `Delete ${slug}`),
|
||||
...(approveId
|
||||
? [perm(approveId, `edr_freight_app:rule_engine:${resource}:approve`, `Approve ${slug} changes`)]
|
||||
: []),
|
||||
@@ -395,7 +420,10 @@ export const FREIGHT_PERMS = {
|
||||
approveDirector: 'edr_freight_app:contracts:approve_director',
|
||||
approveCeo: 'edr_freight_app:contracts:approve_ceo',
|
||||
generateContract: 'edr_freight_app:contracts:generate_contract',
|
||||
signStaff: 'edr_freight_app:contracts:sign_staff',
|
||||
signStaff: {
|
||||
bulk: 'edr_freight_app:contracts:sign_staff:bulk',
|
||||
container: 'edr_freight_app:contracts:sign_staff:container',
|
||||
},
|
||||
clearanceReview: 'edr_freight_app:contracts:clearance_review',
|
||||
finalizeClearance: 'edr_freight_app:contracts:finalize_clearance',
|
||||
createBooking: 'edr_freight_app:contracts:create_booking',
|
||||
@@ -406,7 +434,6 @@ export const FREIGHT_PERMS = {
|
||||
},
|
||||
trainScheduling: {
|
||||
view: 'edr_freight_app:train_scheduling:view',
|
||||
manage: 'edr_freight_app:train_scheduling:manage',
|
||||
create: 'edr_freight_app:train_scheduling:create',
|
||||
update: 'edr_freight_app:train_scheduling:update',
|
||||
cancel: 'edr_freight_app:train_scheduling:cancel',
|
||||
@@ -421,8 +448,12 @@ export const FREIGHT_PERMS = {
|
||||
ruleEngine: {
|
||||
view: (slug: RuleEngineResourceSlug) =>
|
||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`,
|
||||
manage: (slug: RuleEngineResourceSlug) =>
|
||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`,
|
||||
create: (slug: RuleEngineResourceSlug) =>
|
||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:create`,
|
||||
update: (slug: RuleEngineResourceSlug) =>
|
||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:update`,
|
||||
delete: (slug: RuleEngineResourceSlug) =>
|
||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:delete`,
|
||||
approve: (slug: RuleEngineApprovableSlug) =>
|
||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`,
|
||||
},
|
||||
@@ -751,7 +782,11 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.bookings.operations,
|
||||
FREIGHT_PERMS.trainScheduling.view,
|
||||
FREIGHT_PERMS.trainScheduling.manage,
|
||||
FREIGHT_PERMS.trainScheduling.create,
|
||||
FREIGHT_PERMS.trainScheduling.update,
|
||||
FREIGHT_PERMS.trainScheduling.cancel,
|
||||
FREIGHT_PERMS.trainScheduling.reschedule,
|
||||
FREIGHT_PERMS.trainScheduling.rulesManage,
|
||||
FREIGHT_PERMS.fleet.view,
|
||||
FREIGHT_PERMS.fleet.manage,
|
||||
...FLEET_GRANULAR_KEYS,
|
||||
@@ -836,7 +871,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
|
||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
FREIGHT_PERMS.contracts.generateContract,
|
||||
FREIGHT_PERMS.contracts.signStaff,
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff),
|
||||
],
|
||||
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Button, Group, TextInput } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { Search, X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface ListControlsProps {
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
/** `YYYY-MM-DD`, matching Mantine 9's date inputs. */
|
||||
dateFrom: string | null;
|
||||
onDateFromChange: (value: string | null) => void;
|
||||
dateTo: string | null;
|
||||
onDateToChange: (value: string | null) => void;
|
||||
/** Label above the range, naming the date being filtered (e.g. "Arrival date"). */
|
||||
dateLabel?: string;
|
||||
hasFilters?: boolean;
|
||||
onReset?: () => void;
|
||||
/** Page-specific selects (status, warehouse…) rendered after the date range. */
|
||||
children?: ReactNode;
|
||||
showSearch?: boolean;
|
||||
showDateRange?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search box + inclusive date range + clear, shared by every freight list so the
|
||||
* controls sit in the same place and behave the same way on all of them.
|
||||
* Pair with `useListControls`, which owns the state and does the filtering.
|
||||
*/
|
||||
const ListControls = ({
|
||||
search,
|
||||
onSearchChange,
|
||||
searchPlaceholder = "Search…",
|
||||
dateFrom,
|
||||
onDateFromChange,
|
||||
dateTo,
|
||||
onDateToChange,
|
||||
dateLabel,
|
||||
hasFilters,
|
||||
onReset,
|
||||
children,
|
||||
showSearch = true,
|
||||
showDateRange = true,
|
||||
}: ListControlsProps) => (
|
||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||
{showSearch && (
|
||||
<TextInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
||||
leftSection={<Search size={16} />}
|
||||
style={{ flex: "1 1 240px", minWidth: 200 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDateRange && (
|
||||
<>
|
||||
<DatePickerInput
|
||||
label={dateLabel ? `${dateLabel} from` : "From"}
|
||||
placeholder="Any"
|
||||
value={dateFrom}
|
||||
onChange={onDateFromChange}
|
||||
// Cannot start after it ends — the picker refuses the invalid range
|
||||
// instead of silently returning nothing.
|
||||
maxDate={dateTo ?? undefined}
|
||||
clearable
|
||||
w={150}
|
||||
/>
|
||||
<DatePickerInput
|
||||
label={dateLabel ? `${dateLabel} to` : "To"}
|
||||
placeholder="Any"
|
||||
value={dateTo}
|
||||
onChange={onDateToChange}
|
||||
minDate={dateFrom ?? undefined}
|
||||
clearable
|
||||
w={150}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{hasFilters && onReset && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={onReset}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
|
||||
export default ListControls;
|
||||
@@ -126,6 +126,28 @@ const FleetFormDialog = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, recordId]);
|
||||
|
||||
// Seed the `_`-prefixed scratch that `onOptionSelected` derives (e.g.
|
||||
// _hasTrailer) for the value already on the record. Without this, editing a
|
||||
// rigid truck would show a Trailer Plate field until the type is re-picked.
|
||||
// Only scratch keys are written, so a stored one-off capacity is never
|
||||
// clobbered by the type's default; re-deriving from the live value is
|
||||
// idempotent, so this is safe to run again when the options finally load.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setValues((current) => {
|
||||
const scratch: Record<string, unknown> = {};
|
||||
fields.forEach((field) => {
|
||||
if (!field.onOptionSelected) return;
|
||||
const selected = field.options?.find((o) => o.value === current[field.name]);
|
||||
if (!selected) return;
|
||||
Object.entries(field.onOptionSelected(selected, current)).forEach(([key, value]) => {
|
||||
if (key.startsWith("_")) scratch[key] = value;
|
||||
});
|
||||
});
|
||||
return Object.keys(scratch).length ? { ...current, ...scratch } : current;
|
||||
});
|
||||
}, [open, fields]);
|
||||
|
||||
// Receive the ?code&state relayed by the /callback popup, exchange it for
|
||||
// the verified identity, and prefill the matching form fields.
|
||||
useEffect(() => {
|
||||
@@ -202,18 +224,52 @@ const FleetFormDialog = ({
|
||||
|
||||
const faydaVerified = values.faydaVerified === true;
|
||||
|
||||
/**
|
||||
* Fields the current answers actually apply to — a rigid truck type (Casoni)
|
||||
* has no trailer, so its plate field disappears. Honoured in three places, not
|
||||
* just here: a hidden field must also skip validation (an invisible "required"
|
||||
* error blocks submit with nothing to fix) and must submit an explicit null
|
||||
* (so switching to a rigid type CLEARS the stored trailer plate rather than
|
||||
* stranding it on the row).
|
||||
*/
|
||||
const visibleFields = useMemo(
|
||||
() =>
|
||||
fields.filter((field) => {
|
||||
if (
|
||||
field.hideWhen &&
|
||||
field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? ""))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
field.showWhen &&
|
||||
!field.showWhen.equals.includes(String(values[field.showWhen.field] ?? ""))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (field.showIf && !field.showIf(values)) return false;
|
||||
return true;
|
||||
}),
|
||||
[fields, values],
|
||||
);
|
||||
|
||||
const hiddenFieldNames = useMemo(() => {
|
||||
const visible = new Set(visibleFields.map((f) => f.name));
|
||||
return fields.filter((f) => !visible.has(f.name)).map((f) => f.name);
|
||||
}, [fields, visibleFields]);
|
||||
|
||||
const shortFields = useMemo(
|
||||
() => fields.filter((f) => f.type !== "textarea"),
|
||||
[fields],
|
||||
() => visibleFields.filter((f) => f.type !== "textarea"),
|
||||
[visibleFields],
|
||||
);
|
||||
const longFields = useMemo(
|
||||
() => fields.filter((f) => f.type === "textarea"),
|
||||
[fields],
|
||||
() => visibleFields.filter((f) => f.type === "textarea"),
|
||||
[visibleFields],
|
||||
);
|
||||
|
||||
const validate = () => {
|
||||
const next: Record<string, string> = {};
|
||||
fields.forEach((field) => {
|
||||
visibleFields.forEach((field) => {
|
||||
const value = values[field.name];
|
||||
const stringValue =
|
||||
typeof value === "string" ? value.trim() : String(value ?? "");
|
||||
@@ -295,9 +351,19 @@ const FleetFormDialog = ({
|
||||
fields.forEach((field) => {
|
||||
if (field.derivedValue) submitted[field.name] = field.derivedValue(values);
|
||||
});
|
||||
// A field the answers hid no longer applies to this record — send an explicit
|
||||
// null so the column is unset, instead of leaving a stale value behind.
|
||||
hiddenFieldNames.forEach((name) => {
|
||||
submitted[name] = null;
|
||||
});
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(submitted)
|
||||
// `_`-prefixed keys are form-local scratch written by `onOptionSelected`
|
||||
// (e.g. _hasTrailer, which drives visibility). The API validates with
|
||||
// forbidNonWhitelisted, so an undeclared key would 400 the whole save.
|
||||
.filter(([key]) => !key.startsWith("_"))
|
||||
.map(([key, value]) => {
|
||||
if (hiddenFieldNames.includes(key)) return [key, null];
|
||||
if (value === FLEET_SELECT_NONE || value === "" || value == null)
|
||||
return [key, clearableByName[key] ? null : undefined];
|
||||
if (fieldTypeByName[key] === "number") {
|
||||
@@ -371,7 +437,15 @@ const FleetFormDialog = ({
|
||||
: String(value)
|
||||
}
|
||||
onChange={(next) =>
|
||||
setValues((current) => ({ ...current, [field.name]: next ?? "" }))
|
||||
setValues((current) => {
|
||||
const patch = field.onOptionSelected
|
||||
? field.onOptionSelected(
|
||||
field.options?.find((o) => o.value === next),
|
||||
current,
|
||||
)
|
||||
: {};
|
||||
return { ...current, [field.name]: next ?? "", ...patch };
|
||||
})
|
||||
}
|
||||
error={error}
|
||||
searchable
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Flag,
|
||||
MapPin,
|
||||
PackageCheck,
|
||||
TrainFront,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import type { TrackStation, YardWorkBookingRow } from "@/types/trainScheduling";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
const message = (error as { response?: { data?: { message?: string | string[] } } })
|
||||
?.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join("; ");
|
||||
return message || (error as Error)?.message || fallback;
|
||||
};
|
||||
|
||||
const fmtDate = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
};
|
||||
|
||||
const DIRECTION_COLORS: Record<string, string> = {
|
||||
IMPORT: "blue",
|
||||
EXPORT: "teal",
|
||||
DOMESTIC: "violet",
|
||||
};
|
||||
|
||||
/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
|
||||
const DIRECTION_LABELS: Record<string, string> = Freight.TRADE_DIRECTION_LABELS;
|
||||
|
||||
function DirectionChip({ direction }: { direction: string }) {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color={DIRECTION_COLORS[direction] ?? "gray"}>
|
||||
{DIRECTION_LABELS[direction] ?? direction}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({
|
||||
icon,
|
||||
title,
|
||||
count,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
count: number;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={8} align="center">
|
||||
<ThemeIcon size={26} radius="md" variant="light" color="edr-green">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color="gray" radius="sm">
|
||||
{count}
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Yard-work modal for the track page's "Log pass" step.
|
||||
*
|
||||
* A train runs A→B→C→D and bookings board/alight at any stop, so logging the
|
||||
* pass at a yard is the moment its yard work happens: bookings destined here
|
||||
* flip to ARRIVED (import/export) or COMPLETED (intercity) automatically the
|
||||
* instant the pass is logged, and bookings boarding here become loadable —
|
||||
* the server only accepts a load while the train's latest checkpoint is this
|
||||
* yard. The modal therefore drives the sequence: log the pass first, then
|
||||
* load anything that boards here (including cargo the operator forgot — it
|
||||
* stays loadable until the next pass is logged).
|
||||
*/
|
||||
export function LogPassYardWorkModal({
|
||||
opened,
|
||||
onClose,
|
||||
scheduleId,
|
||||
station,
|
||||
isFinal,
|
||||
alreadyLogged,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
scheduleId: string;
|
||||
station: TrackStation | null;
|
||||
isFinal: boolean;
|
||||
/** True when opened for the current station (pass already logged). */
|
||||
alreadyLogged: boolean;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [justLogged, setJustLogged] = useState(false);
|
||||
useEffect(() => setJustLogged(false), [station?.sequenceNo, opened]);
|
||||
const logged = alreadyLogged || justLogged;
|
||||
|
||||
const yardWorkQuery = useQuery(
|
||||
api.trainScheduling.yardWork.queryOptions({
|
||||
input: { scheduleId },
|
||||
enabled: opened && Boolean(scheduleId),
|
||||
}),
|
||||
);
|
||||
const recordCheckpoint = useMutation(
|
||||
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
||||
);
|
||||
const load = useMutation(api.trainScheduling.loadScheduleBooking.mutationOptions());
|
||||
|
||||
const yard = yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId);
|
||||
const boarders: YardWorkBookingRow[] = yard?.toLoad ?? [];
|
||||
const arrivals: YardWorkBookingRow[] = yard?.toUnload ?? [];
|
||||
const pendingBoarders = boarders.filter((r) => !r.loadedAt);
|
||||
|
||||
const doLogPass = () => {
|
||||
if (!station) return;
|
||||
recordCheckpoint.mutate(
|
||||
{ id: scheduleId, payload: { sequenceNo: station.sequenceNo } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setJustLogged(true);
|
||||
toast({
|
||||
title: isFinal
|
||||
? "Train arrived — remaining bookings marked arrived, assets freed"
|
||||
: `Pass logged at ${station.label}`,
|
||||
description: isFinal
|
||||
? undefined
|
||||
: arrivals.some((r) => r.canUnload)
|
||||
? "Bookings arriving here have been marked arrived."
|
||||
: undefined,
|
||||
});
|
||||
void yardWorkQuery.refetch();
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not log checkpoint",
|
||||
description: parseError(err, "Please try again"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const doLoad = (row: YardWorkBookingRow) => {
|
||||
load.mutate(
|
||||
{ scheduleId, bookingId: row.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: `${row.reference ?? "Booking"} loaded`,
|
||||
description: `Cargo boarded the train at ${station?.label ?? "this yard"}.`,
|
||||
});
|
||||
void yardWorkQuery.refetch();
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not load booking",
|
||||
description: parseError(err, "Please try again"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const hasWork = boarders.length > 0 || arrivals.length > 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
title={
|
||||
<Group gap={8}>
|
||||
{isFinal ? <Flag size={18} /> : <MapPin size={18} />}
|
||||
<Text fw={700}>
|
||||
{isFinal ? "Arrival" : "Yard work"} — {station?.label ?? ""}
|
||||
</Text>
|
||||
{logged ? (
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
{isFinal ? "Arrived" : "Pass logged"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{yardWorkQuery.isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
) : !hasWork ? (
|
||||
<Alert color="gray" variant="light" radius="md" icon={<MapPin size={16} />}>
|
||||
No bookings board or alight at this station.
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{/* ── Arriving here ─────────────────────────────────────────── */}
|
||||
{arrivals.length > 0 ? (
|
||||
<Stack gap="xs">
|
||||
<SectionLabel
|
||||
icon={<Flag size={14} />}
|
||||
title="Arriving at this yard"
|
||||
count={arrivals.length}
|
||||
/>
|
||||
{!logged ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Logging the pass marks the loaded bookings below as Arrived
|
||||
(import/export) or Completed (intercity) automatically.
|
||||
</Text>
|
||||
) : null}
|
||||
<Table.ScrollContainer minWidth={620}>
|
||||
<Table verticalSpacing="xs" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Direction</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Arrived</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{arrivals.map((row) => (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{row.reference ?? row.id.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.customer}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DirectionChip direction={row.tradeDirection} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingStatusBadge status={row.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.arrivedAt ? fmtDate(row.arrivedAt) : "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{arrivals.length > 0 && boarders.length > 0 ? <Divider /> : null}
|
||||
|
||||
{/* ── Boarding here ─────────────────────────────────────────── */}
|
||||
{boarders.length > 0 ? (
|
||||
<Stack gap="xs">
|
||||
<SectionLabel
|
||||
icon={<TrainFront size={14} />}
|
||||
title="Boarding at this yard"
|
||||
count={boarders.length}
|
||||
/>
|
||||
{!logged && pendingBoarders.length > 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Log the pass first — the train must be at {station?.label} before
|
||||
cargo can be loaded.
|
||||
</Text>
|
||||
) : null}
|
||||
<Table.ScrollContainer minWidth={620}>
|
||||
<Table verticalSpacing="xs" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Direction</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Loaded</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{boarders.map((row) => (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{row.reference ?? row.id.slice(0, 8)}
|
||||
</Text>
|
||||
{row.isGovernment ? (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
GOV
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.customer}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DirectionChip direction={row.tradeDirection} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingStatusBadge status={row.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{row.loadedAt ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<CheckCircle2
|
||||
size={13}
|
||||
color="var(--mantine-color-edr-green-7)"
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{fmtDate(row.loadedAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
Not loaded
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{!row.loadedAt ? (
|
||||
<Tooltip
|
||||
label={
|
||||
!logged
|
||||
? "Log the pass first — the train must be at this yard"
|
||||
: !row.canLoad
|
||||
? "Booking is not ready to load (payment pending)"
|
||||
: "Confirm cargo loaded onto the train"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
disabled={!logged || !row.canLoad}
|
||||
loading={
|
||||
load.isPending && load.variables?.bookingId === row.id
|
||||
}
|
||||
onClick={() => doLoad(row)}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Stack>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{logged && pendingBoarders.length > 0
|
||||
? `${pendingBoarders.length} booking${pendingBoarders.length === 1 ? "" : "s"} still to load before the next station.`
|
||||
: ""}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
{!logged ? (
|
||||
<Button
|
||||
color={isFinal ? "teal" : "edr-green"}
|
||||
leftSection={isFinal ? <Flag size={15} /> : <MapPin size={15} />}
|
||||
loading={recordCheckpoint.isPending}
|
||||
onClick={doLogPass}
|
||||
>
|
||||
{isFinal
|
||||
? `Mark arrived at ${station?.label ?? "destination"}`
|
||||
: `Log pass at ${station?.label ?? "station"}`}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -166,9 +166,6 @@ export function ScheduleWorkspacePanel({
|
||||
const setLoading = useMutation(
|
||||
api.trainScheduling.setLoadingStatus.mutationOptions(),
|
||||
);
|
||||
const confirmLoading = useMutation(
|
||||
api.trainScheduling.confirmLoading.mutationOptions(),
|
||||
);
|
||||
const moveSchedule = useMutation(
|
||||
api.trainScheduling.moveBookingSchedule.mutationOptions(),
|
||||
);
|
||||
@@ -301,25 +298,6 @@ export function ScheduleWorkspacePanel({
|
||||
);
|
||||
};
|
||||
|
||||
const doConfirmLoading = () => {
|
||||
confirmLoading
|
||||
.mutateAsync({ id: schedule.id })
|
||||
.then(() => {
|
||||
toast({ title: "Loading confirmed", description: "The train is cleared to dispatch." });
|
||||
onChanged();
|
||||
})
|
||||
.catch((error) =>
|
||||
toast({
|
||||
title: "Could not confirm loading",
|
||||
description: apiErrorMessage(
|
||||
error,
|
||||
"Grant the Djibouti gatepass first, then confirm loading.",
|
||||
),
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// Point the pool booking at the chosen same-day train, then put it on wagons.
|
||||
// If the wagon step fails (that train is short too) the booking stays paid &
|
||||
// unassigned in the pool — nothing is lost, staff just pick another train.
|
||||
@@ -460,53 +438,8 @@ export function ScheduleWorkspacePanel({
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* Loading confirmation — required before dispatch for import-Djibouti
|
||||
trains; shown for every direction so staff have one place to confirm. */}
|
||||
{canManage ? (
|
||||
<Group
|
||||
gap={10}
|
||||
p="sm"
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: schedule.loadingConfirmed
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: "var(--mantine-color-yellow-0)",
|
||||
border: `1px solid ${
|
||||
schedule.loadingConfirmed
|
||||
? "var(--mantine-color-edr-green-2)"
|
||||
: "var(--mantine-color-yellow-3)"
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
|
||||
{schedule.loadingConfirmed ? (
|
||||
<CheckCircle2 size={18} color="var(--mantine-color-edr-green-7)" />
|
||||
) : (
|
||||
<PackageCheck size={18} color="#B7791F" />
|
||||
)}
|
||||
<Text size="sm" fw={600}>
|
||||
{schedule.loadingConfirmed
|
||||
? "Loading confirmed — cleared to dispatch"
|
||||
: "Confirm loading before dispatching this train"}
|
||||
</Text>
|
||||
</Group>
|
||||
{!schedule.loadingConfirmed ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
loading={confirmLoading.isPending}
|
||||
onClick={doConfirmLoading}
|
||||
>
|
||||
Confirm loading
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
{/* Loading confirmation gate removed: bookings can board mid-corridor,
|
||||
so per-yard loading happens from the track page's log-pass flow. */}
|
||||
|
||||
{/* Two-panel board */}
|
||||
<Group align="stretch" gap="lg" grow wrap="wrap">
|
||||
|
||||
@@ -18,6 +18,11 @@ import { LoadInventoryModal } from './LoadInventoryModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path; reused here rather than adding a second one.
|
||||
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||
import { openPdfBlob, saveBlob } from './pdf';
|
||||
|
||||
@@ -56,8 +61,17 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
api.warehouses.bulkMarkInspected.mutationOptions(),
|
||||
);
|
||||
|
||||
const controls = useListControls(items, {
|
||||
searchKeys: ['grnNumber', 'bookingReference', 'customerName', 'status', 'releaseOrderReference', 'notes'],
|
||||
dateKey: 'arrivedAt',
|
||||
});
|
||||
const visible = controls.filteredRows;
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const allSelected = items.length > 0 && selected.size === items.length;
|
||||
// Select-all spans everything matching the current filters, not just the rows
|
||||
// on screen — bulk "mark inspected" over one page of a filtered set would be a
|
||||
// surprise. Counts compare against the filtered set for the same reason.
|
||||
const allSelected = visible.length > 0 && selected.size === visible.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
const toggleSelect = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
@@ -66,7 +80,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
return next;
|
||||
});
|
||||
const toggleSelectAll = () =>
|
||||
setSelected(allSelected ? new Set() : new Set(items.map((i) => i.id)));
|
||||
setSelected(allSelected ? new Set() : new Set(visible.map((i) => i.id)));
|
||||
|
||||
const markInspected = async () => {
|
||||
if (selected.size === 0) {
|
||||
@@ -268,8 +282,21 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="GRN, container, booking, customer…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Arrived"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
<WarehouseInventoryTable
|
||||
items={items}
|
||||
items={controls.pagedRows}
|
||||
busyId={busyId}
|
||||
onAdvance={advance}
|
||||
onMove={setMoveItem}
|
||||
@@ -287,6 +314,14 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
allSelected={allSelected}
|
||||
someSelected={someSelected}
|
||||
/>
|
||||
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="items"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
||||
complete: FREIGHT_PERMS.bookings.operations,
|
||||
operationAccept: FREIGHT_PERMS.bookings.operations,
|
||||
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
|
||||
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
|
||||
allocateBooking: FREIGHT_PERMS.trainScheduling.update,
|
||||
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||
};
|
||||
|
||||
|
||||
164
apps/edr-freight-web/backoffice/src/hooks/useListControls.ts
Normal file
164
apps/edr-freight-web/backoffice/src/hooks/useListControls.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { usePagination } from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Search + date-range + pagination over an already-fetched array.
|
||||
*
|
||||
* Client-side on purpose: the freight lists are hundreds of rows (largest table
|
||||
* is ~1.1k), so filtering in the browser avoids paginating ~20 API endpoints —
|
||||
* several of which sit on billing paths. If a list ever outgrows this (roughly
|
||||
* 5k rows, where the per-keystroke filter starts to feel slow), move that ONE
|
||||
* page to a server-side query; the component API here stays the same.
|
||||
*
|
||||
* Dates are `YYYY-MM-DD` strings, matching Mantine 9's date inputs. Comparing
|
||||
* them lexically keeps the range on calendar days and sidesteps timezone drift
|
||||
* entirely — a UTC timestamp is truncated to its date before the comparison.
|
||||
*
|
||||
* ponytail: linear scan per keystroke, no debounce — fine at this size; add
|
||||
* a debounce (or server-side filtering) if a list gets big enough to stutter.
|
||||
*/
|
||||
export interface ListControlsOptions<T> {
|
||||
/**
|
||||
* Fields matched against the search box. Constrained to real keys of the row
|
||||
* so a typo is a compile error rather than a filter that silently matches
|
||||
* nothing. For nested or derived values, pass `searchValue` instead.
|
||||
*/
|
||||
searchKeys?: (keyof T)[];
|
||||
/**
|
||||
* Row's meaningful business date (arrival, invoice, dispatch…), which is what
|
||||
* staff actually filter by. Falls back to `createdAt` when the row has no
|
||||
* value for it, so a record is never silently invisible to a date range.
|
||||
*/
|
||||
dateKey?: keyof T;
|
||||
/** Rows per page. */
|
||||
pageSize?: number;
|
||||
/** Custom search extractor when the value isn't a top-level field. */
|
||||
searchValue?: (row: T) => string;
|
||||
}
|
||||
|
||||
const readField = (row: unknown, key: string): unknown =>
|
||||
row && typeof row === "object" ? (row as Record<string, unknown>)[key] : undefined;
|
||||
|
||||
/**
|
||||
* Reduce any stored date to its `YYYY-MM-DD` calendar day. ISO strings are cut
|
||||
* directly rather than parsed, so a timestamp is never shifted into the
|
||||
* previous/next day by the viewer's timezone.
|
||||
*/
|
||||
export const toDayString = (raw: unknown): string | null => {
|
||||
if (!raw) return null;
|
||||
if (raw instanceof Date) {
|
||||
return Number.isNaN(raw.getTime()) ? null : raw.toISOString().slice(0, 10);
|
||||
}
|
||||
const text = String(raw);
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(text)) return text.slice(0, 10);
|
||||
const parsed = new Date(text);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
/**
|
||||
* Does a stored date fall inside an inclusive `YYYY-MM-DD` range? Exported for
|
||||
* lists that already own their filtering (e.g. FleetResourcePage, which folds
|
||||
* server-side filters and search together) so the range semantics — inclusive
|
||||
* ends, undated rows excluded — stay defined in exactly one place.
|
||||
*/
|
||||
export const matchesDayRange = (
|
||||
raw: unknown,
|
||||
dateFrom: string | null,
|
||||
dateTo: string | null,
|
||||
): boolean => {
|
||||
if (!dateFrom && !dateTo) return true;
|
||||
const day = toDayString(raw);
|
||||
if (!day) return false;
|
||||
if (dateFrom && day < dateFrom) return false;
|
||||
if (dateTo && day > dateTo) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const useListControls = <T,>(rows: T[], options: ListControlsOptions<T> = {}) => {
|
||||
const { searchKeys = [], dateKey, pageSize = 10, searchValue } = options;
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [dateFrom, setDateFrom] = useState<string | null>(null);
|
||||
const [dateTo, setDateTo] = useState<string | null>(null);
|
||||
const { pagination, setPagination } = usePagination({ pageSize });
|
||||
|
||||
const keys = searchKeys.map(String);
|
||||
const keySignature = keys.join("|");
|
||||
const dateKeyStr = dateKey ? String(dateKey) : undefined;
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
if (!term && !dateFrom && !dateTo) return rows;
|
||||
|
||||
return rows.filter((row) => {
|
||||
if (term) {
|
||||
const haystack = searchValue
|
||||
? searchValue(row)
|
||||
: keys.map((key) => String(readField(row, key) ?? "")).join(" ");
|
||||
if (!haystack.toLowerCase().includes(term)) return false;
|
||||
}
|
||||
if (dateFrom || dateTo) {
|
||||
const raw = dateKeyStr
|
||||
? (readField(row, dateKeyStr) ?? readField(row, "createdAt"))
|
||||
: null;
|
||||
if (!matchesDayRange(raw, dateFrom, dateTo)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rows, search, dateFrom, dateTo, keySignature, dateKeyStr, searchValue]);
|
||||
|
||||
// Narrowing the result set can strand the user on a page that no longer
|
||||
// exists (filter to 3 rows while on page 5 → empty table). Snap back to the
|
||||
// first page whenever the filters change.
|
||||
useEffect(() => {
|
||||
setPagination((prev) => (prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }));
|
||||
}, [search, dateFrom, dateTo, setPagination]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
|
||||
|
||||
const pagedRows = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return filteredRows.slice(start, start + pagination.pageSize);
|
||||
}, [filteredRows, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
const hasFilters = Boolean(search || dateFrom || dateTo);
|
||||
|
||||
const reset = () => {
|
||||
setSearch("");
|
||||
setDateFrom(null);
|
||||
setDateTo(null);
|
||||
};
|
||||
|
||||
return {
|
||||
search,
|
||||
setSearch,
|
||||
dateFrom,
|
||||
setDateFrom,
|
||||
dateTo,
|
||||
setDateTo,
|
||||
hasFilters,
|
||||
reset,
|
||||
filteredRows,
|
||||
pagedRows,
|
||||
pageCount,
|
||||
pagination,
|
||||
setPagination,
|
||||
totalCount: filteredRows.length,
|
||||
/** Spread straight onto <DataTable /> so every list paginates identically. */
|
||||
tableProps: {
|
||||
pagination: {
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: filteredRows.length,
|
||||
},
|
||||
tableOptions: {
|
||||
manualPagination: true as const,
|
||||
pageCount,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -20,6 +20,8 @@ import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuc
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
/**
|
||||
* Staff contract preview + sign. Staff must open and read the generated
|
||||
@@ -30,6 +32,7 @@ export default function ContractViewPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const { user } = useAuth();
|
||||
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [successOpen, setSuccessOpen] = useState(false);
|
||||
@@ -116,6 +119,15 @@ export default function ContractViewPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Counter-signature permission is split per freight type — a bulk signer must
|
||||
// not sign a container contract (API enforces the same on POST /contract/sign).
|
||||
const maySign = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.contracts.signStaff[
|
||||
data.freightType === "BULK" ? "bulk" : "container"
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box p={{ base: "md", md: "xl" }}>
|
||||
<Box maw={920} mx="auto">
|
||||
@@ -145,7 +157,7 @@ export default function ContractViewPage() {
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
{data.canSignStaff && (
|
||||
{data.canSignStaff && maySign && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { Plus, AlertTriangle } from "lucide-react";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path.
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
complianceService,
|
||||
@@ -86,6 +91,11 @@ export default function CompliancePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const controls = useListControls(records as ComplianceRecord[], {
|
||||
searchKeys: ["type", "status", "documentNumber"],
|
||||
dateKey: "expiryDate",
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: typeof formData) => {
|
||||
const res = await complianceService.create({
|
||||
@@ -210,6 +220,18 @@ export default function CompliancePage() {
|
||||
Compliance Records
|
||||
</Title>
|
||||
<Card withBorder>
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search type, status, document no…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Expiry"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -239,7 +261,7 @@ export default function CompliancePage() {
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : null}
|
||||
{(records as ComplianceRecord[]).map((record) => (
|
||||
{controls.pagedRows.map((record) => (
|
||||
<Table.Tr key={record.id}>
|
||||
<Table.Td>{vehicleLabel(record)}</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -259,6 +281,13 @@ export default function CompliancePage() {
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="records"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Modal */}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canFleetAction } from "@/lib/permissions";
|
||||
import { canFleetAction, hasPermission, FREIGHT_PERMS } from "@/lib/permissions";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { Inbox, Plus, Warehouse } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -15,6 +16,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
||||
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
|
||||
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import { matchesDayRange } from "@/hooks/useListControls";
|
||||
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
||||
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
|
||||
import WagonTransferRequestsModal from "@/components/wagons/WagonTransferRequestsModal";
|
||||
@@ -43,10 +45,20 @@ const FleetResourcePage = () => {
|
||||
const canCreate = canFleetAction(user, slug, "create");
|
||||
const canUpdate = canFleetAction(user, slug, "update");
|
||||
const canDelete = canFleetAction(user, slug, "delete");
|
||||
// Wagon transfer workspace: shown only to holders of a transfer capability
|
||||
// (raise a request, fulfill one, or see the cross-yard history).
|
||||
const canTransfer =
|
||||
hasPermission(user, FREIGHT_PERMS.wagons.transferRequest) ||
|
||||
hasPermission(user, FREIGHT_PERMS.wagons.transferFulfill) ||
|
||||
hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll);
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||
// Registration date range. Server-side list filters (status/yard/train) are
|
||||
// applied by the API; this narrows what comes back, alongside search.
|
||||
const [dateFrom, setDateFrom] = useState<string | null>(null);
|
||||
const [dateTo, setDateTo] = useState<string | null>(null);
|
||||
const [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
||||
@@ -94,6 +106,9 @@ const FleetResourcePage = () => {
|
||||
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
|
||||
api.wagonTypes.list.queryOptions(),
|
||||
);
|
||||
const { data: truckTypes = [], isLoading: truckTypesLoading } = useQuery(
|
||||
api.truckTypes.list.queryOptions(),
|
||||
);
|
||||
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
|
||||
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
@@ -122,7 +137,7 @@ const FleetResourcePage = () => {
|
||||
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||
}, [search, listFilterValues, setPagination]);
|
||||
}, [search, listFilterValues, dateFrom, dateTo, setPagination]);
|
||||
|
||||
const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status"));
|
||||
const usesServerListFilters = Boolean(config?.listFilters?.length);
|
||||
@@ -169,17 +184,34 @@ const FleetResourcePage = () => {
|
||||
(y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }),
|
||||
);
|
||||
|
||||
// Carries capacity + trailer configuration so picking a truck type can
|
||||
// pre-fill the vehicle's capacity and drop the trailer plate on a rigid type.
|
||||
const truckTypeOpts = (
|
||||
truckTypes as Array<{
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string;
|
||||
capacityTons?: number | null;
|
||||
hasTrailer?: boolean;
|
||||
}>
|
||||
).map((t) => ({
|
||||
value: t.id,
|
||||
label: t.name ? `${t.name} (${t.code})` : t.code,
|
||||
meta: { capacityTons: t.capacityTons, hasTrailer: t.hasTrailer },
|
||||
}));
|
||||
|
||||
registerFleetOptionLabels("currentYardId", yardOpts);
|
||||
|
||||
return {
|
||||
wagonTypes: wagonTypeOpts,
|
||||
containerTypes: containerTypeOpts,
|
||||
cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts],
|
||||
truckTypes: truckTypeOpts,
|
||||
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
|
||||
containers: containerOpts,
|
||||
yards: yardOpts,
|
||||
};
|
||||
}, [wagonTypes, containerTypes, cargoTypes, wagons, containers, yards]);
|
||||
}, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards]);
|
||||
|
||||
const listFilterSelects = useMemo(() => {
|
||||
if (!config?.listFilters?.length) return null;
|
||||
@@ -212,6 +244,7 @@ const FleetResourcePage = () => {
|
||||
registerFleetOptionLabels("containerId", dynamicOptions.containers);
|
||||
registerFleetOptionLabels("currentYardId", dynamicOptions.yards);
|
||||
registerFleetOptionLabels("locationId", dynamicOptions.yards);
|
||||
registerFleetOptionLabels("truckTypeId", dynamicOptions.truckTypes);
|
||||
}, [dynamicOptions]);
|
||||
|
||||
const formFields = useMemo((): FleetFormFieldDef[] => {
|
||||
@@ -227,16 +260,20 @@ const FleetResourcePage = () => {
|
||||
wagonTypesLoading ||
|
||||
containerTypesLoading ||
|
||||
cargoTypesLoading ||
|
||||
truckTypesLoading ||
|
||||
wagonsLoading ||
|
||||
containersLoading ||
|
||||
yardsLoading;
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (!config) return allRows;
|
||||
if (usesServerListFilters) return allRows;
|
||||
const term = search.trim().toLowerCase();
|
||||
return allRows.filter((row) => {
|
||||
const record = row as unknown as Record<string, unknown>;
|
||||
// The date range applies even when the API already filtered the list —
|
||||
// it is not one of the server-side filters.
|
||||
if (!matchesDayRange(record.createdAt, dateFrom, dateTo)) return false;
|
||||
if (usesServerListFilters) return true;
|
||||
if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
@@ -247,7 +284,7 @@ const FleetResourcePage = () => {
|
||||
.includes(term),
|
||||
);
|
||||
});
|
||||
}, [allRows, search, statusFilter, config, usesServerListFilters]);
|
||||
}, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
|
||||
const pagedRows = useMemo(() => {
|
||||
@@ -411,15 +448,17 @@ const FleetResourcePage = () => {
|
||||
Yard Workspace
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<Inbox size={16} />}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setTransferRequestsOpen(true)}
|
||||
>
|
||||
Transfer Requests
|
||||
</Button>
|
||||
{canTransfer ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<Inbox size={16} />}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setTransferRequestsOpen(true)}
|
||||
>
|
||||
Transfer Requests
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{canCreate ? (
|
||||
@@ -444,7 +483,30 @@ const FleetResourcePage = () => {
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
filters={
|
||||
listFilterSelects ? (
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
<DatePickerInput
|
||||
aria-label="Created from"
|
||||
placeholder="Created from"
|
||||
value={dateFrom}
|
||||
onChange={setDateFrom}
|
||||
maxDate={dateTo ?? undefined}
|
||||
clearable
|
||||
size="sm"
|
||||
radius="lg"
|
||||
w={160}
|
||||
/>
|
||||
<DatePickerInput
|
||||
aria-label="Created to"
|
||||
placeholder="Created to"
|
||||
value={dateTo}
|
||||
onChange={setDateTo}
|
||||
minDate={dateFrom ?? undefined}
|
||||
clearable
|
||||
size="sm"
|
||||
radius="lg"
|
||||
w={160}
|
||||
/>
|
||||
{listFilterSelects ? (
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
{listFilterSelects.map((filter) => (
|
||||
<Select
|
||||
@@ -487,7 +549,8 @@ const FleetResourcePage = () => {
|
||||
))}
|
||||
</Group>
|
||||
</Group>
|
||||
) : undefined
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { Plus } from "lucide-react";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path.
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/auth/http";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
@@ -118,6 +123,11 @@ export default function FuelPurchasePage() {
|
||||
const totalCost = formData.liters * formData.costPerLiter;
|
||||
|
||||
// Aggregate stats (guarded against divide-by-zero when there are no purchases)
|
||||
const controls = useListControls(purchasesData as FuelPurchase[], {
|
||||
searchKeys: ["fuelStation", "paymentMethod"],
|
||||
dateKey: "purchaseDate",
|
||||
});
|
||||
|
||||
const totalLiters = (purchasesData as FuelPurchase[]).reduce(
|
||||
(sum, p) => sum + Number(p.liters),
|
||||
0
|
||||
@@ -185,6 +195,18 @@ export default function FuelPurchasePage() {
|
||||
|
||||
{/* Purchases Table */}
|
||||
<Card withBorder>
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search station or payment method…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Purchased"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -215,7 +237,7 @@ export default function FuelPurchasePage() {
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : null}
|
||||
{(purchasesData as FuelPurchase[])?.map((purchase) => (
|
||||
{controls.pagedRows.map((purchase) => (
|
||||
<Table.Tr key={purchase.id}>
|
||||
<Table.Td>{(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId}</Table.Td>
|
||||
<Table.Td>{new Date(purchase.purchaseDate).toLocaleDateString()}</Table.Td>
|
||||
@@ -230,6 +252,13 @@ export default function FuelPurchasePage() {
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="purchases"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Modal */}
|
||||
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { Plus } from "lucide-react";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path.
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
incidentsService,
|
||||
@@ -161,6 +166,10 @@ export default function IncidentsPage() {
|
||||
})) || [];
|
||||
|
||||
const incidents = incidentsData as Incident[];
|
||||
const controls = useListControls(incidents, {
|
||||
searchKeys: ["type", "severity", "status"],
|
||||
dateKey: "occurredAt",
|
||||
});
|
||||
const totalCount = incidents.length;
|
||||
const openCount = incidents.filter((i) => OPEN_STATUSES.includes(i.status)).length;
|
||||
const underReviewCount = incidents.filter((i) => i.status === "UNDER_REVIEW").length;
|
||||
@@ -237,6 +246,18 @@ export default function IncidentsPage() {
|
||||
|
||||
{/* Incidents Table */}
|
||||
<Card withBorder>
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search type, severity, status…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Occurred"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -267,7 +288,7 @@ export default function IncidentsPage() {
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : null}
|
||||
{incidents.map((incident) => (
|
||||
{controls.pagedRows.map((incident) => (
|
||||
<Table.Tr key={incident.id}>
|
||||
<Table.Td>{new Date(incident.occurredAt).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -294,6 +315,13 @@ export default function IncidentsPage() {
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="incidents"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Modal */}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
NumberInput,
|
||||
Radio,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
@@ -20,6 +24,7 @@ import {
|
||||
import {
|
||||
ArrowLeft,
|
||||
Fuel,
|
||||
Gauge,
|
||||
History,
|
||||
Route,
|
||||
Truck,
|
||||
@@ -28,7 +33,13 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { api as apiClient2 } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
vehiclesService,
|
||||
type SaveVehiclePayload,
|
||||
type Vehicle,
|
||||
} from "@/services/vehicles.service";
|
||||
import { driversService } from "@/services/drivers.service";
|
||||
import { fleetHistoryService } from "@/services/fleet-history.service";
|
||||
|
||||
@@ -132,6 +143,7 @@ const VehicleDetailPage = () => {
|
||||
<Tabs.Tab value="history" leftSection={<History size={14} />}>History</Tabs.Tab>
|
||||
<Tabs.Tab value="maintenance" leftSection={<Wrench size={14} />}>Maintenance</Tabs.Tab>
|
||||
<Tabs.Tab value="fuel" leftSection={<Fuel size={14} />}>Fuel</Tabs.Tab>
|
||||
<Tabs.Tab value="operations" leftSection={<Gauge size={14} />}>Operations</Tabs.Tab>
|
||||
<Tabs.Tab value="mile" leftSection={<Route size={14} />}>First/Last mile</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
@@ -142,6 +154,8 @@ const VehicleDetailPage = () => {
|
||||
<InfoRow label="Code" value={vehicle.code ?? "—"} />
|
||||
<InfoRow label="Registration" value={vehicle.registrationNumber ?? "—"} />
|
||||
<InfoRow label="Type" value={vehicle.vehicleType ?? "—"} />
|
||||
<InfoRow label="VIN" value={vehicle.vin ?? "—"} />
|
||||
<InfoRow label="Ownership" value={vehicle.ownership ?? "—"} />
|
||||
<InfoRow label="Manufacturer" value={vehicle.manufacturer ?? "—"} />
|
||||
<InfoRow label="Model" value={vehicle.model ?? "—"} />
|
||||
<InfoRow label="Year" value={vehicle.year ?? "—"} />
|
||||
@@ -172,6 +186,10 @@ const VehicleDetailPage = () => {
|
||||
<FuelTab vehicleId={id} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="operations" pt="lg">
|
||||
<OperationsTab vehicle={vehicle} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="mile" pt="lg">
|
||||
<MileTab vehicleId={id} />
|
||||
</Tabs.Panel>
|
||||
@@ -181,6 +199,103 @@ const VehicleDetailPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a truck is and what it costs to run are per-trip operational facts, not
|
||||
* part of registering the vehicle — so they are edited here rather than on the
|
||||
* Add Vehicle form. `pricePerKm` is live billing input: first/last-mile charges
|
||||
* are `distance × pricePerKm`.
|
||||
*/
|
||||
const OperationsTab = ({ vehicle }: { vehicle: Vehicle }) => {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState({
|
||||
locationId: vehicle.locationId ?? "",
|
||||
estimatedDistanceKm: vehicle.estimatedDistanceKm ?? "",
|
||||
actualDistanceKm: vehicle.actualDistanceKm ?? "",
|
||||
pricePerKm: vehicle.pricePerKm ?? "",
|
||||
currency: vehicle.currency ?? "ETB",
|
||||
});
|
||||
|
||||
const { data: yards = [], isLoading: yardsLoading } = useQuery(
|
||||
apiClient2.routes.yards.queryOptions(),
|
||||
);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
vehiclesService.update(vehicle.id, {
|
||||
locationId: form.locationId || null,
|
||||
// Empty means "not recorded" — send null so the column is unset rather
|
||||
// than coerced to 0, which would read as a real measurement.
|
||||
estimatedDistanceKm: form.estimatedDistanceKm === "" ? null : Number(form.estimatedDistanceKm),
|
||||
actualDistanceKm: form.actualDistanceKm === "" ? null : Number(form.actualDistanceKm),
|
||||
pricePerKm: form.pricePerKm === "" ? null : Number(form.pricePerKm),
|
||||
currency: form.currency || null,
|
||||
} as Partial<SaveVehiclePayload> & { locationId?: string | null }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["vehicle", vehicle.id] });
|
||||
toast({ title: "Operational details saved" });
|
||||
},
|
||||
onError: () =>
|
||||
toast({ title: "Could not save operational details", variant: "destructive" }),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<Select
|
||||
label="Location"
|
||||
placeholder={yardsLoading ? "Loading yards..." : "Not set"}
|
||||
data={(yards as Array<{ id: string; label?: string; code?: string }>).map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code ?? y.id,
|
||||
}))}
|
||||
value={form.locationId || null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, locationId: v ?? "" }))}
|
||||
disabled={yardsLoading}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<NumberInput
|
||||
label="Price per KM"
|
||||
description="Used for first/last-mile billing"
|
||||
value={form.pricePerKm}
|
||||
onChange={(v) => setForm((f) => ({ ...f, pricePerKm: v as number | "" }))}
|
||||
min={0}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Estimated Distance (KM)"
|
||||
value={form.estimatedDistanceKm}
|
||||
onChange={(v) => setForm((f) => ({ ...f, estimatedDistanceKm: v as number | "" }))}
|
||||
min={0}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Actual Distance (KM)"
|
||||
value={form.actualDistanceKm}
|
||||
onChange={(v) => setForm((f) => ({ ...f, actualDistanceKm: v as number | "" }))}
|
||||
min={0}
|
||||
/>
|
||||
<Radio.Group
|
||||
label="Currency"
|
||||
value={form.currency}
|
||||
onChange={(v) => setForm((f) => ({ ...f, currency: v }))}
|
||||
>
|
||||
<Group gap="lg" mt={6}>
|
||||
<Radio value="ETB" label="ETB" />
|
||||
<Radio value="USD" label="USD" />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
</SimpleGrid>
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={() => save.mutate()} loading={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const DriverTab = ({
|
||||
vehicleId,
|
||||
driverId,
|
||||
|
||||
@@ -30,10 +30,22 @@ export type FleetDynamicOptions =
|
||||
| "wagonTypes"
|
||||
| "containerTypes"
|
||||
| "cargoTypes"
|
||||
| "truckTypes"
|
||||
| "wagons"
|
||||
| "containers"
|
||||
| "yards";
|
||||
|
||||
/**
|
||||
* A dynamic select option that can carry the record it came from. Picking a
|
||||
* truck type has to pull its capacity and trailer configuration into the form,
|
||||
* which a bare {label, value} pair cannot express.
|
||||
*/
|
||||
export interface FleetSelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FleetResourceColumn {
|
||||
id: string;
|
||||
header: string;
|
||||
@@ -45,6 +57,17 @@ export interface FleetResourceColumn {
|
||||
export interface FleetFormFieldDef extends FormFieldDef {
|
||||
dynamicOptions?: FleetDynamicOptions;
|
||||
noneOption?: boolean;
|
||||
/** Options carrying their source record, so `onOptionSelected` can read it. */
|
||||
options?: FleetSelectOption[];
|
||||
/**
|
||||
* Patch merged into the form when this select changes — for values that are a
|
||||
* property of the chosen option rather than typed per record (a vehicle's
|
||||
* capacity comes from its truck type). Returns the fields to overwrite.
|
||||
*/
|
||||
onOptionSelected?: (
|
||||
option: FleetSelectOption | undefined,
|
||||
values: Record<string, unknown>,
|
||||
) => Record<string, unknown>;
|
||||
/**
|
||||
* Read-only field whose value is computed from the other fields rather than
|
||||
* typed. Rendered non-editable and recomputed on every change, so the stored
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -229,6 +229,21 @@ const cargoDesc = (r: FirstMileRecord) => {
|
||||
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
|
||||
return parts.join(" · ") || "—";
|
||||
};
|
||||
const cargoTypeName = (r: FirstMileRecord) =>
|
||||
r.booking?.cargoType?.cargoTypeName ??
|
||||
r.booking?.cargoType?.label ??
|
||||
r.booking?.cargoType?.name ??
|
||||
r.booking?.cargoFreeText ??
|
||||
"—";
|
||||
const isBulkBooking = (r: FirstMileRecord) => r.booking?.freightType === "BULK";
|
||||
const bookingTotalTons = (r: FirstMileRecord) => Number(r.booking?.cargoTotalWeightVgm) || 0;
|
||||
const trainScheduleLabel = (r: FirstMileRecord) => {
|
||||
const s = r.booking?.trainSchedule;
|
||||
if (!s?.trainNumber && !s?.departureDate) return "—";
|
||||
return [s.trainNumber, s.departureDate ? new Date(s.departureDate).toLocaleDateString() : null]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
};
|
||||
// First-mile destination is the origin yard (pickup → origin yard)
|
||||
const destinationYardName = (r: FirstMileRecord) =>
|
||||
r.booking?.originYard?.label ?? "—";
|
||||
@@ -277,7 +292,9 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => {
|
||||
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
||||
{hasPickupAddress && <InfoRow label="Pickup location" value={pickupLocation(record)} />}
|
||||
<InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
|
||||
<InfoRow label="Cargo Type" value={cargoTypeName(record)} />
|
||||
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
||||
<InfoRow label="Train Schedule" value={trainScheduleLabel(record)} />
|
||||
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment, currencyOf(record))} />
|
||||
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment, currencyOf(record))} />
|
||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||
@@ -496,10 +513,11 @@ const FirstMilePage = () => {
|
||||
const [tripSlipVehicleId, setTripSlipVehicleId] = useState<string | null>(null);
|
||||
const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
|
||||
// Multi-vehicle assign: one row per truck — vehicle + its load (container for
|
||||
// container bookings; tonnes + optional item count for bulk).
|
||||
const [vehicleRows, setVehicleRows] = useState<
|
||||
Array<{ vehicleId: string | null; containerNumber: string }>
|
||||
>([{ vehicleId: null, containerNumber: "" }]);
|
||||
Array<{ vehicleId: string | null; containerNumber: string; tons: number | ""; quantity: number | "" }>
|
||||
>([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
|
||||
|
||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
||||
const [acceptStep, setAcceptStep] = useState<1 | 2>(1);
|
||||
@@ -925,13 +943,19 @@ const FirstMilePage = () => {
|
||||
? rec.vehicleAssignments.map((a, i) => ({
|
||||
vehicleId: a.vehicleId,
|
||||
containerNumber: a.containerNumber ?? nums[i] ?? "",
|
||||
tons: (a.tons != null ? Number(a.tons) : "") as number | "",
|
||||
quantity: (a.quantity != null ? Number(a.quantity) : "") as number | "",
|
||||
}))
|
||||
: rec?.vehicleId
|
||||
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }]
|
||||
: [{ vehicleId: null, containerNumber: nums[0] ?? "" }];
|
||||
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "", tons: "" as const, quantity: "" as const }]
|
||||
: [{ vehicleId: null, containerNumber: nums[0] ?? "", tons: "" as const, quantity: "" as const }];
|
||||
setBulkMode(false);
|
||||
setActiveId(resolved);
|
||||
setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]);
|
||||
setVehicleRows(
|
||||
rows.length
|
||||
? rows
|
||||
: [{ vehicleId: null, containerNumber: nums[0] ?? "", tons: "", quantity: "" }],
|
||||
);
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
@@ -944,7 +968,7 @@ const FirstMilePage = () => {
|
||||
}
|
||||
setBulkMode(true);
|
||||
setActiveId(null);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
@@ -952,15 +976,41 @@ const FirstMilePage = () => {
|
||||
setAssignOpen(false);
|
||||
setBulkMode(false);
|
||||
setActiveId(null);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
const bulkCargo = activeRecord != null && isBulkBooking(activeRecord);
|
||||
if (bulkCargo && vehicleRows.some((r) => r.vehicleId && r.tons === "")) {
|
||||
toast({
|
||||
title: "Tonnes required",
|
||||
description: "Enter the tonnage each truck hauls — bulk assignment draws down the booking total.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (bulkCargo) {
|
||||
const total = bookingTotalTons(activeRecord);
|
||||
const assigning = vehicleRows.reduce((s, r) => s + (r.vehicleId ? Number(r.tons) || 0 : 0), 0);
|
||||
if (total > 0 && assigning > total + 0.001) {
|
||||
toast({
|
||||
title: "Over booking tonnage",
|
||||
description: `Assigned ${assigning} t exceeds the booking's ${total} t.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const vehicles = vehicleRows
|
||||
.filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId))
|
||||
.filter((r): r is (typeof vehicleRows)[number] & { vehicleId: string } => Boolean(r.vehicleId))
|
||||
.filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId)))
|
||||
.map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null }));
|
||||
.map((r) => ({
|
||||
vehicleId: r.vehicleId,
|
||||
containerNumber: r.containerNumber.trim() || null,
|
||||
tons: bulkCargo && r.tons !== "" ? Number(r.tons) : null,
|
||||
quantity: bulkCargo && r.quantity !== "" ? Number(r.quantity) : null,
|
||||
}));
|
||||
const count = vehicles.length;
|
||||
const targetIds = bulkMode
|
||||
? selectedIds
|
||||
@@ -1432,30 +1482,63 @@ const FirstMilePage = () => {
|
||||
clearable
|
||||
disabled={assignVehicleOptions.length === 0}
|
||||
/>
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
label={i === 0 ? "Container no." : undefined}
|
||||
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
|
||||
data={[
|
||||
...containerOptions.filter(
|
||||
(n) =>
|
||||
n === row.containerNumber ||
|
||||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
|
||||
),
|
||||
// keep a manual/legacy value selectable even if not in the booking
|
||||
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
|
||||
? [row.containerNumber]
|
||||
: []),
|
||||
]}
|
||||
value={row.containerNumber || null}
|
||||
onChange={(value) =>
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
|
||||
)
|
||||
}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
{!bulkMode && activeRecord && isBulkBooking(activeRecord) ? (
|
||||
<>
|
||||
<NumberInput
|
||||
style={{ flex: 0.8 }}
|
||||
label={i === 0 ? "Tonnes" : undefined}
|
||||
placeholder="t"
|
||||
min={0}
|
||||
value={row.tons}
|
||||
onChange={(v) =>
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i ? { ...x, tons: v === "" ? "" : Number(v) } : x,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
style={{ flex: 0.8 }}
|
||||
label={i === 0 ? "Items qty (pcs)" : undefined}
|
||||
placeholder="optional"
|
||||
min={0}
|
||||
value={row.quantity}
|
||||
onChange={(v) =>
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i ? { ...x, quantity: v === "" ? "" : Number(v) } : x,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
label={i === 0 ? "Container no." : undefined}
|
||||
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
|
||||
data={[
|
||||
...containerOptions.filter(
|
||||
(n) =>
|
||||
n === row.containerNumber ||
|
||||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
|
||||
),
|
||||
// keep a manual/legacy value selectable even if not in the booking
|
||||
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
|
||||
? [row.containerNumber]
|
||||
: []),
|
||||
]}
|
||||
value={row.containerNumber || null}
|
||||
onChange={(value) =>
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
|
||||
)
|
||||
}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
)}
|
||||
{vehicleRows.length > 1 && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
@@ -1479,6 +1562,8 @@ const FirstMilePage = () => {
|
||||
vehicleId: null,
|
||||
containerNumber:
|
||||
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
|
||||
tons: "" as const,
|
||||
quantity: "" as const,
|
||||
},
|
||||
])
|
||||
}
|
||||
@@ -1491,6 +1576,22 @@ const FirstMilePage = () => {
|
||||
>
|
||||
Add vehicle
|
||||
</Button>
|
||||
{!bulkMode && activeRecord && isBulkBooking(activeRecord) && (() => {
|
||||
const total = bookingTotalTons(activeRecord);
|
||||
const assigning = vehicleRows.reduce(
|
||||
(s, r) => s + (r.vehicleId ? Number(r.tons) || 0 : 0),
|
||||
0,
|
||||
);
|
||||
const remaining = Math.round((total - assigning) * 1000) / 1000;
|
||||
return (
|
||||
<Text size="sm" c={remaining < 0 ? "red" : "dimmed"}>
|
||||
Bulk drawdown: {assigning} t of {total} t assigned —{" "}
|
||||
<Text span fw={600} c={remaining < 0 ? "red" : undefined}>
|
||||
{remaining} t remaining
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
})()}
|
||||
</Stack>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={closeAssign}>Cancel</Button>
|
||||
|
||||
@@ -114,7 +114,9 @@ const CargoTypesPage = () => {
|
||||
const config = getRuleEngineResource(CARGO_SLUG);
|
||||
|
||||
const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view");
|
||||
const canManage = canAccessRuleEngineResource(user, CARGO_SLUG, "manage");
|
||||
const canCreate = canAccessRuleEngineResource(user, CARGO_SLUG, "create");
|
||||
const canUpdate = canAccessRuleEngineResource(user, CARGO_SLUG, "update");
|
||||
const canDelete = canAccessRuleEngineResource(user, CARGO_SLUG, "delete");
|
||||
|
||||
// One fetch of the whole (small) set — page-walked because the API caps
|
||||
// pageSize at 100; the tree, ancestry and each level are derived client-side
|
||||
@@ -128,7 +130,7 @@ const CargoTypesPage = () => {
|
||||
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
|
||||
|
||||
// Wagon-type options for the "Wagon types" picker (bulk cargo → allowed list).
|
||||
const { data: wagonTypeOptions } = useWagonTypeOptions(canManage);
|
||||
const { data: wagonTypeOptions } = useWagonTypeOptions(canCreate || canUpdate);
|
||||
const formFields = useMemo<FormFieldDef[]>(
|
||||
() =>
|
||||
FORM_FIELDS.map((field) =>
|
||||
@@ -291,7 +293,7 @@ const CargoTypesPage = () => {
|
||||
leftSection={<Search size={16} />}
|
||||
w={240}
|
||||
/>
|
||||
{canManage && (
|
||||
{canCreate && (
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<Plus size={16} />}
|
||||
@@ -335,7 +337,7 @@ const CargoTypesPage = () => {
|
||||
? "No cargo categories yet"
|
||||
: `No cargo types under “${str(current?.cargoTypeName)}” yet`}
|
||||
</Text>
|
||||
{!term && canManage && (
|
||||
{!term && canCreate && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
@@ -354,7 +356,8 @@ const CargoTypesPage = () => {
|
||||
node={node}
|
||||
childCount={(childrenOf.get(node.id) ?? []).length}
|
||||
topBorder={i > 0}
|
||||
canManage={canManage}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
onOpen={() => navigate(`${BASE_PATH}/${node.id}`)}
|
||||
onEdit={() => setFormMode({ kind: "edit", record: node })}
|
||||
onDelete={() => setDeleteTarget(node)}
|
||||
@@ -444,7 +447,8 @@ interface CargoRowProps {
|
||||
node: CargoNode;
|
||||
childCount: number;
|
||||
topBorder: boolean;
|
||||
canManage: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
onOpen: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
@@ -454,7 +458,8 @@ function CargoRow({
|
||||
node,
|
||||
childCount,
|
||||
topBorder,
|
||||
canManage,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
onOpen,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -529,19 +534,19 @@ function CargoRow({
|
||||
</UnstyledButton>
|
||||
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{canManage && (
|
||||
<>
|
||||
<Tooltip label="Edit" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="gray" onClick={onEdit} px={8}>
|
||||
<Pencil size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="red" onClick={onDelete} px={8}>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
{canUpdate && (
|
||||
<Tooltip label="Edit" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="gray" onClick={onEdit} px={8}>
|
||||
<Pencil size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Tooltip label="Delete" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="red" onClick={onDelete} px={8}>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Open" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="teal" onClick={onOpen} px={8}>
|
||||
|
||||
@@ -150,9 +150,20 @@ const RuleEngineResourcePage = () => {
|
||||
const canView = Boolean(
|
||||
config && canAccessRuleEngineResource(user, config.slug, "view"),
|
||||
);
|
||||
const canManage = Boolean(
|
||||
config && canAccessRuleEngineResource(user, config.slug, "manage"),
|
||||
// Per-action gates replace the retired coarse "manage": Add shows only with
|
||||
// create, row Edit with update, row Delete with delete.
|
||||
const canCreate = Boolean(
|
||||
config && canAccessRuleEngineResource(user, config.slug, "create"),
|
||||
);
|
||||
const canUpdate = Boolean(
|
||||
config && canAccessRuleEngineResource(user, config.slug, "update"),
|
||||
);
|
||||
const canDelete = Boolean(
|
||||
config && canAccessRuleEngineResource(user, config.slug, "delete"),
|
||||
);
|
||||
// Update-class controls (reorder, rate submit/approve, approval-rule decide)
|
||||
// all map to the update permission — the matching endpoints now require it.
|
||||
const canUpdateControls = canUpdate;
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
@@ -480,7 +491,7 @@ const RuleEngineResourcePage = () => {
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
||||
<Group gap="xs" wrap="nowrap" justify="flex-end">
|
||||
{config.orderConfig && canManage ? (
|
||||
{config.orderConfig && canUpdateControls ? (
|
||||
<RuleEngineOrderControls
|
||||
record={row.original}
|
||||
orderConfig={config.orderConfig}
|
||||
@@ -493,7 +504,7 @@ const RuleEngineResourcePage = () => {
|
||||
record={row.original}
|
||||
config={config}
|
||||
layout="row"
|
||||
readOnly={!canManage}
|
||||
readOnly={!canUpdateControls}
|
||||
onEdit={(record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
@@ -504,8 +515,8 @@ const RuleEngineResourcePage = () => {
|
||||
? () => setChainOpen(true)
|
||||
: undefined
|
||||
}
|
||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||
onSubmitRate={canUpdateControls ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canUpdateControls ? handleApproveRate : undefined}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
@@ -514,7 +525,7 @@ const RuleEngineResourcePage = () => {
|
||||
|
||||
return base;
|
||||
}, [
|
||||
canManage,
|
||||
canUpdateControls,
|
||||
config,
|
||||
isRates,
|
||||
pendingByRateId,
|
||||
@@ -642,7 +653,7 @@ const RuleEngineResourcePage = () => {
|
||||
title={config.label}
|
||||
subtitle={config.subtitle}
|
||||
action={
|
||||
canManage && config.slug !== "container-types" ? (
|
||||
canCreate && config.slug !== "container-types" ? (
|
||||
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
|
||||
{addLabel}
|
||||
</Button>
|
||||
@@ -653,7 +664,7 @@ const RuleEngineResourcePage = () => {
|
||||
{isPriorityRules ? (
|
||||
<PriorityRuleApprovalsSection
|
||||
requests={priorityWorkflow.pending.data ?? []}
|
||||
canDecide={canManage}
|
||||
canDecide={canUpdateControls}
|
||||
approve={priorityWorkflow.approve}
|
||||
reject={priorityWorkflow.reject}
|
||||
/>
|
||||
@@ -742,7 +753,7 @@ const RuleEngineResourcePage = () => {
|
||||
showSearch={Boolean(config.supportsSearch)}
|
||||
searchPlaceholder={config.searchPlaceholder}
|
||||
onManageOrder={
|
||||
canManage && config.orderConfig
|
||||
canUpdateControls && config.orderConfig
|
||||
? () => setOrderDialogOpen(true)
|
||||
: undefined
|
||||
}
|
||||
@@ -806,16 +817,16 @@ const RuleEngineResourcePage = () => {
|
||||
pageCount={pageCount}
|
||||
totalCount={totalCount}
|
||||
onPaginationChange={setPagination}
|
||||
readOnly={!canManage}
|
||||
onEdit={canManage ? openEdit : undefined}
|
||||
onDelete={canManage ? setDeleteTarget : undefined}
|
||||
readOnly={!canUpdate && !canDelete}
|
||||
onEdit={canUpdate ? openEdit : undefined}
|
||||
onDelete={canDelete ? setDeleteTarget : undefined}
|
||||
onViewChain={
|
||||
config.slug === "approval-rules"
|
||||
? () => setChainOpen(true)
|
||||
: undefined
|
||||
}
|
||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||
onSubmitRate={canUpdateControls ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canUpdateControls ? handleApproveRate : undefined}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
@@ -7,9 +8,11 @@ import {
|
||||
Flag,
|
||||
MapPin,
|
||||
Navigation,
|
||||
PackageCheck,
|
||||
Train,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -25,7 +28,9 @@ import {
|
||||
} from "@mantine/core";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
|
||||
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
|
||||
import type { TrackStation } from "@/types/trainScheduling";
|
||||
import {
|
||||
RouteCorridor,
|
||||
StatusPill,
|
||||
@@ -148,6 +153,18 @@ export default function TrainScheduleTrackPage() {
|
||||
const recordCheckpoint = useMutation(
|
||||
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
||||
);
|
||||
// Yard work drives the log-pass modal: which bookings board/alight per stop.
|
||||
const yardWorkQuery = useQuery(
|
||||
api.trainScheduling.yardWork.queryOptions({
|
||||
input: { scheduleId: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId) && trackQuery.data?.status === "DISPATCHED",
|
||||
}),
|
||||
);
|
||||
const [yardModal, setYardModal] = useState<{
|
||||
station: TrackStation;
|
||||
isFinal: boolean;
|
||||
alreadyLogged: boolean;
|
||||
} | null>(null);
|
||||
|
||||
if (trackQuery.isLoading) {
|
||||
return (
|
||||
@@ -181,8 +198,26 @@ export default function TrainScheduleTrackPage() {
|
||||
const inTransit = track.status === "DISPATCHED";
|
||||
const arrived = track.status === "ARRIVED";
|
||||
|
||||
// Yard work at a station: boarders not yet loaded, and loaded bookings that
|
||||
// alight there. When either exists, logging the pass goes through the modal
|
||||
// so the operator sees (and can act on) both lists; empty yards log directly.
|
||||
const yardWorkFor = (station: TrackStation | undefined) =>
|
||||
yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId);
|
||||
const stationHasWork = (station: TrackStation | undefined) => {
|
||||
const yard = yardWorkFor(station);
|
||||
return Boolean(
|
||||
yard &&
|
||||
(yard.toLoad.some((r) => !r.loadedAt) || yard.toUnload.some((r) => r.canUnload)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleLog = (sequenceNo: number) => {
|
||||
const station = track.stations.find((s) => s.sequenceNo === sequenceNo);
|
||||
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
|
||||
if (station && stationHasWork(station)) {
|
||||
setYardModal({ station, isFinal, alreadyLogged: false });
|
||||
return;
|
||||
}
|
||||
recordCheckpoint.mutate(
|
||||
{ id: scheduleId, payload: { sequenceNo } },
|
||||
{
|
||||
@@ -203,6 +238,15 @@ export default function TrainScheduleTrackPage() {
|
||||
);
|
||||
};
|
||||
|
||||
// "Forgot to load" catch: while the train sits at the current station, any
|
||||
// boarder there that is still unloaded can be loaded until the next pass.
|
||||
const currentStationObj = track.stations.find(
|
||||
(s) => s.sequenceNo === track.currentSequenceNo,
|
||||
);
|
||||
const currentYard = canLog ? yardWorkFor(currentStationObj) : undefined;
|
||||
const forgottenBoarders =
|
||||
currentYard?.toLoad.filter((r) => !r.loadedAt) ?? [];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Button
|
||||
@@ -416,6 +460,43 @@ export default function TrainScheduleTrackPage() {
|
||||
}
|
||||
onLogCheckpoint={handleLog}
|
||||
/>
|
||||
|
||||
{/* Cargo the operator forgot: boarders at the CURRENT station stay
|
||||
loadable until the next pass is logged. */}
|
||||
{currentStationObj && forgottenBoarders.length > 0 ? (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<PackageCheck size={16} />}
|
||||
title={`${forgottenBoarders.length} booking${
|
||||
forgottenBoarders.length === 1 ? "" : "s"
|
||||
} at ${currentStationObj.label} not loaded yet`}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
|
||||
<Text size="sm">
|
||||
The train is at {currentStationObj.label} — cargo boarding here can
|
||||
still be loaded before the next station is logged.
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
onClick={() =>
|
||||
setYardModal({
|
||||
station: currentStationObj,
|
||||
isFinal:
|
||||
currentStationObj.sequenceNo ===
|
||||
track.stations[totalStations - 1]?.sequenceNo,
|
||||
alreadyLogged: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
Open yard work
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -505,6 +586,15 @@ export default function TrainScheduleTrackPage() {
|
||||
</Timeline>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<LogPassYardWorkModal
|
||||
opened={yardModal !== null}
|
||||
onClose={() => setYardModal(null)}
|
||||
scheduleId={scheduleId}
|
||||
station={yardModal?.station ?? null}
|
||||
isFinal={yardModal?.isFinal ?? false}
|
||||
alreadyLogged={yardModal?.alreadyLogged ?? false}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -398,11 +398,8 @@ export default function TrainScheduleV2DetailPage() {
|
||||
!b.loadedAt &&
|
||||
!["IN_TRANSIT", "COMPLETED"].includes(b.status ?? ""),
|
||||
).length;
|
||||
// Import-Djibouti trains are HARD-blocked from dispatch until loading is
|
||||
// confirmed in the workspace — surface it as a blocker, not just a warning.
|
||||
const loadingBlocksDispatch =
|
||||
schedule.requiresLoadingConfirmation === true &&
|
||||
schedule.loadingConfirmed !== true;
|
||||
// No loading hard-block: bookings may board mid-corridor, so loading happens
|
||||
// per yard from the track page's log-pass flow. Everything below is advisory.
|
||||
const hasDispatchWarnings =
|
||||
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
|
||||
|
||||
@@ -1271,22 +1268,6 @@ export default function TrainScheduleV2DetailPage() {
|
||||
undone.
|
||||
</Text>
|
||||
|
||||
{loadingBlocksDispatch ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title="Loading not confirmed"
|
||||
>
|
||||
This import train cannot depart until loading is confirmed. Use{" "}
|
||||
<Text span fw={700}>
|
||||
Confirm loading
|
||||
</Text>{" "}
|
||||
in the Workspace tab first.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{hasDispatchWarnings ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
@@ -1309,8 +1290,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Text span fw={700}>
|
||||
{unloadedCount}
|
||||
</Text>{" "}
|
||||
wagon-assigned booking{unloadedCount === 1 ? "" : "s"} still marked
|
||||
unloaded
|
||||
wagon-assigned booking{unloadedCount === 1 ? "" : "s"} not loaded
|
||||
yet — mid-route boarders load from the track page when the train
|
||||
reaches their yard
|
||||
</List.Item>
|
||||
) : null}
|
||||
{intercityNotLoadedCount > 0 ? (
|
||||
@@ -1350,7 +1332,6 @@ export default function TrainScheduleV2DetailPage() {
|
||||
color="edr-green"
|
||||
leftSection={<Send size={16} />}
|
||||
loading={dispatch.isPending}
|
||||
disabled={loadingBlocksDispatch}
|
||||
onClick={() => void runDispatch()}
|
||||
>
|
||||
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}
|
||||
|
||||
@@ -55,6 +55,8 @@ import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { formatRouteLabel } from "@/services/routes.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canCreateSchedule } from "@/lib/permissions";
|
||||
import type {
|
||||
FreightType,
|
||||
TrainScheduleListFilters,
|
||||
@@ -99,6 +101,8 @@ const parseError = (error: unknown, fallback: string) => {
|
||||
export default function TrainScheduleV2ListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canCreate = canCreateSchedule(user);
|
||||
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -550,9 +554,11 @@ export default function TrainScheduleV2ListPage() {
|
||||
title="Train Schedules"
|
||||
subtitle="Operational train scheduling with full allocation workflow."
|
||||
action={
|
||||
<Button leftSection={<Train size={18} />} onClick={() => setCreateOpen(true)}>
|
||||
New schedule
|
||||
</Button>
|
||||
canCreate ? (
|
||||
<Button leftSection={<Train size={18} />} onClick={() => setCreateOpen(true)}>
|
||||
New schedule
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import { CheckCircle2, Download, Eye, FileText, Printer, Search } from 'lucide-r
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
|
||||
@@ -263,6 +265,9 @@ export default function InterchangeDocumentsPage() {
|
||||
const [viewId, setViewId] = useState<string | null>(null);
|
||||
const filter = useMemo(() => ({ search: search.trim() || undefined }), [search]);
|
||||
const { data: documents = [], isLoading } = useInterchangeDocuments(filter);
|
||||
// Search stays server-side (passed in `filter`); this adds the date range and
|
||||
// pagination over what comes back.
|
||||
const controls = useListControls(documents, { dateKey: 'generatedAt' });
|
||||
const acknowledge = useAcknowledgeInterchangeDocument();
|
||||
const dispute = useDisputeInterchangeDocument();
|
||||
|
||||
@@ -435,7 +440,7 @@ export default function InterchangeDocumentsPage() {
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{documents.length} document(s)</Text>
|
||||
<Text fw={600}>{controls.totalCount} document(s)</Text>
|
||||
<TextInput
|
||||
w={{ base: '100%', sm: 320 }}
|
||||
leftSection={<Search size={16} />}
|
||||
@@ -445,6 +450,19 @@ export default function InterchangeDocumentsPage() {
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<ListControls
|
||||
showSearch={false}
|
||||
search=""
|
||||
onSearchChange={() => {}}
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Generated"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
{!isLoading && documents.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
@@ -454,9 +472,10 @@ export default function InterchangeDocumentsPage() {
|
||||
) : (
|
||||
<DataTable
|
||||
columns={documentColumns}
|
||||
data={documents}
|
||||
data={controls.pagedRows}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
containerClassName="border-0 shadow-none"
|
||||
{...controls.tableProps}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
@@ -12,10 +13,16 @@ import {
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, PackageCheck, TrainFront, Warehouse } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, PackageCheck, PackageOpen, TrainFront, Warehouse } from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path.
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import type { IntercityRideAlongRow } from "@/types/trainScheduling";
|
||||
|
||||
@@ -74,7 +81,45 @@ function FacilityCell({
|
||||
);
|
||||
}
|
||||
|
||||
const apiErrorMessage = (error: unknown) => {
|
||||
if (error && typeof error === "object" && "response" in error) {
|
||||
const message = (error as { response?: { data?: { message?: unknown } } }).response?.data
|
||||
?.message;
|
||||
if (Array.isArray(message)) return message.join("; ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return error instanceof Error ? error.message : undefined;
|
||||
};
|
||||
|
||||
function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const refresh = () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.intercityBookings.queryKey(undefined),
|
||||
});
|
||||
// Same endpoints as the schedule page's ride-along panel — the server still
|
||||
// validates the train's recorded checkpoint, payment and yard equipment.
|
||||
const load = useMutation(
|
||||
api.trainScheduling.loadIntercityBooking.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast({ title: "Cargo loaded onto the train" });
|
||||
void refresh();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast({ variant: "destructive", title: "Load failed", description: apiErrorMessage(error) }),
|
||||
}),
|
||||
);
|
||||
const unload = useMutation(
|
||||
api.trainScheduling.unloadIntercityBooking.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast({ title: "Cargo unloaded — booking completed" });
|
||||
void refresh();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast({ variant: "destructive", title: "Unload failed", description: apiErrorMessage(error) }),
|
||||
}),
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Alert variant="light" color="gray">
|
||||
@@ -95,6 +140,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
<Table.Th ta="right">Weight</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -151,6 +197,38 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
{r.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{/* Work the cargo right here while the train is at the yard. */}
|
||||
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
loading={load.isPending}
|
||||
onClick={() =>
|
||||
load.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
|
||||
}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
)}
|
||||
{r.trainScheduleId && atDestination(r) && isRiding(r) && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<PackageOpen size={13} />}
|
||||
loading={unload.isPending}
|
||||
onClick={() =>
|
||||
unload.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
|
||||
}
|
||||
>
|
||||
Unload
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
@@ -205,6 +283,15 @@ export default function IntercityPage() {
|
||||
[rows],
|
||||
);
|
||||
|
||||
// Controls follow the active tab, so search/date/paging always describe what
|
||||
// is on screen. Panels unmount when hidden (keepMounted={false}) so a hidden
|
||||
// tab can never render another tab's paged slice.
|
||||
const active = tab === "riding" ? riding : tab === "done" ? done : waiting;
|
||||
const controls = useListControls(active, {
|
||||
searchKeys: ["reference", "grnNumber", "customer", "origin", "destination", "trainNumber"],
|
||||
dateKey: "loadedAt",
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
@@ -283,19 +370,40 @@ export default function IntercityPage() {
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="waiting">
|
||||
<Rows rows={waiting} />
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Reference, GRN, customer, train…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Loaded"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
<Tabs.Panel value="waiting" keepMounted={false}>
|
||||
<Rows rows={controls.pagedRows} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="riding">
|
||||
<Rows rows={riding} />
|
||||
<Tabs.Panel value="riding" keepMounted={false}>
|
||||
<Rows rows={controls.pagedRows} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="done">
|
||||
<Rows rows={done} />
|
||||
<Tabs.Panel value="done" keepMounted={false}>
|
||||
<Rows rows={controls.pagedRows} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="bookings"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt="sm">
|
||||
Loading and unloading happen on the train's schedule page, where the ride-along
|
||||
panel confirms the train is at the yard.
|
||||
Load and Unload appear on a row while its train is recorded at that yard ("train
|
||||
here"); the same actions also live on the train's schedule page.
|
||||
</Text>
|
||||
</Card>
|
||||
</>
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
useInventoryInquiry,
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path.
|
||||
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
import type { InventoryInquiryFilter, InventoryInquiryResult, InventoryStatus } from '@/types/warehouse';
|
||||
|
||||
export default function InventoryInquiryPage() {
|
||||
@@ -29,6 +34,11 @@ export default function InventoryInquiryPage() {
|
||||
const { data, isFetching } = useInventoryInquiry(applied);
|
||||
const results = data ?? [];
|
||||
|
||||
const controls = useListControls(results, {
|
||||
searchKeys: ['containerNumber', 'bookingReference', 'customerName', 'cargoDescription', 'locationSummary'],
|
||||
dateKey: 'arrivedAt',
|
||||
});
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||
[warehousesQuery.data],
|
||||
@@ -173,7 +183,28 @@ export default function InventoryInquiryPage() {
|
||||
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
|
||||
/>
|
||||
) : (
|
||||
<WarehouseInquiryTable results={results} onView={setViewResult} />
|
||||
<>
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Container, booking, customer, location…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Arrived"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
<WarehouseInquiryTable results={controls.pagedRows} onView={setViewResult} />
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="items"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Badge, Card, Group, Text } from '@mantine/core';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -67,6 +69,10 @@ export default function LoadedInventoryPage() {
|
||||
api.warehouses.loadings.queryOptions({ input: {} }),
|
||||
);
|
||||
const loadings = data ?? [];
|
||||
const controls = useListControls(loadings, {
|
||||
searchKeys: ['wagonNumber'],
|
||||
dateKey: 'loadedAt',
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -83,12 +89,27 @@ export default function LoadedInventoryPage() {
|
||||
description="Once items are loaded onto a wagon, their records show here."
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={loadings}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
<>
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search by wagon…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Loaded"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={controls.pagedRows}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
containerClassName="border-0 shadow-none"
|
||||
{...controls.tableProps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</PageContainer>
|
||||
|
||||
@@ -7,12 +7,15 @@ import {
|
||||
SegmentedControl,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path; reused here rather than adding a second one.
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useTrucksOnSite } from "@/hooks/useWarehouses";
|
||||
import type { TruckOnSite } from "@/types/warehouse";
|
||||
|
||||
@@ -148,20 +151,21 @@ export default function TrucksOnSitePage() {
|
||||
scopeParam === "ON_SITE" || scopeParam === "INBOUND" ? scopeParam : "ALL",
|
||||
);
|
||||
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
return trucks
|
||||
.filter((t) => scope === "ALL" || t.status === scope)
|
||||
.filter((t) => source === "ALL" || t.source === source)
|
||||
.filter((t) =>
|
||||
!term
|
||||
? true
|
||||
: [t.plateNumber, t.driverName, t.bookingReference, t.customerName, t.containers]
|
||||
.some((field) => field?.toLowerCase().includes(term)),
|
||||
);
|
||||
}, [trucks, scope, source, search]);
|
||||
// Scope/source are page filters and run first; the shared control then does
|
||||
// search + arrival-date range + pagination over what they leave.
|
||||
const scoped = useMemo(
|
||||
() =>
|
||||
trucks
|
||||
.filter((t) => scope === "ALL" || t.status === scope)
|
||||
.filter((t) => source === "ALL" || t.source === source),
|
||||
[trucks, scope, source],
|
||||
);
|
||||
|
||||
const controls = useListControls(scoped, {
|
||||
searchKeys: ["plateNumber", "driverName", "bookingReference", "customerName", "containers"],
|
||||
dateKey: "arrivedAt",
|
||||
});
|
||||
|
||||
const onSiteCount = trucks.filter((t) => t.status === "ON_SITE").length;
|
||||
const inboundCount = trucks.length - onSiteCount;
|
||||
@@ -198,17 +202,35 @@ export default function TrucksOnSitePage() {
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
size="xs"
|
||||
w={280}
|
||||
placeholder="Plate, driver, booking, container…"
|
||||
leftSection={<Search size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{isLoading ? <Text size="sm">Loading…</Text> : <Rows rows={rows} />}
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Plate, driver, booking, container…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Arrived"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Text size="sm">Loading…</Text>
|
||||
) : (
|
||||
<>
|
||||
<Rows rows={controls.pagedRows} />
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="trucks"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt, Search } from 'lucide-react';
|
||||
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { AccrualDashboard } from '@/components/warehouses';
|
||||
@@ -50,7 +52,6 @@ const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '
|
||||
|
||||
export default function WarehouseInvoicesPage() {
|
||||
const [status, setStatus] = useState<WarehouseInvoiceStatus | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery(
|
||||
@@ -60,11 +61,10 @@ export default function WarehouseInvoicesPage() {
|
||||
);
|
||||
const invoices = data ?? [];
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return invoices;
|
||||
return invoices.filter((i) => [i.invoiceNumber, i.bookingId, i.customerId].join(' ').toLowerCase().includes(q));
|
||||
}, [invoices, search]);
|
||||
const controls = useListControls(invoices, {
|
||||
searchKeys: ['invoiceNumber', 'bookingReference', 'customerName', 'containerNumber'],
|
||||
dateKey: 'issuedAt',
|
||||
});
|
||||
|
||||
const invoiceColumns: ColumnDef<WarehouseFeeInvoice>[] = [
|
||||
{
|
||||
@@ -131,30 +131,36 @@ export default function WarehouseInvoicesPage() {
|
||||
</Stack>
|
||||
|
||||
<Card>
|
||||
<Group justify="space-between" mb="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search invoice no / booking / customer"
|
||||
leftSection={<Search size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={320}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
|
||||
value={status}
|
||||
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
</Group>
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search invoice no / booking / customer"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Issued"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
>
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="All statuses"
|
||||
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
|
||||
value={status}
|
||||
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
</ListControls>
|
||||
|
||||
<DataTable
|
||||
columns={invoiceColumns}
|
||||
data={filtered}
|
||||
data={controls.pagedRows}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
emptyMessage="No invoices found."
|
||||
containerClassName="border-0 shadow-none"
|
||||
{...controls.tableProps}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
WarehouseTable,
|
||||
type WarehouseView,
|
||||
} from '@/components/warehouses';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path.
|
||||
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
import { useWarehouses } from '@/hooks/useWarehouses';
|
||||
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
|
||||
|
||||
@@ -31,6 +36,11 @@ export default function WarehouseListPage() {
|
||||
const { data, isLoading, isError } = useWarehouses(queryFilter);
|
||||
const warehouses = data ?? [];
|
||||
|
||||
// Search stays with WarehouseFilters — it is server-side and debounced, so
|
||||
// re-doing it client-side here would be a regression. This adds only the date
|
||||
// range + pagination, shared by both the table and card views.
|
||||
const controls = useListControls(warehouses, { dateKey: 'createdAt' });
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setModalOpen(true);
|
||||
@@ -57,6 +67,19 @@ export default function WarehouseListPage() {
|
||||
<Stack gap="md">
|
||||
<WarehouseFilters filter={filter} onChange={setFilter} view={view} onViewChange={setView} />
|
||||
|
||||
<ListControls
|
||||
showSearch={false}
|
||||
search=""
|
||||
onSearchChange={() => {}}
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Created"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
@@ -65,10 +88,21 @@ export default function WarehouseListPage() {
|
||||
<Text c="red" ta="center" py="xl">
|
||||
Failed to load warehouses.
|
||||
</Text>
|
||||
) : view === 'table' ? (
|
||||
<WarehouseTable warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
|
||||
) : (
|
||||
<WarehouseCardView warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
|
||||
<>
|
||||
{view === 'table' ? (
|
||||
<WarehouseTable warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
|
||||
) : (
|
||||
<WarehouseCardView warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
|
||||
)}
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="warehouses"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -19,6 +19,8 @@ import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
@@ -39,7 +41,6 @@ import {
|
||||
FEE_RULE_BASIS_LABELS,
|
||||
FEE_RULE_TYPES,
|
||||
FEE_RULE_TYPE_LABELS,
|
||||
VEHICLE_TYPES,
|
||||
type AllocationRule,
|
||||
type FeeRule,
|
||||
type FeeRuleBasis,
|
||||
@@ -141,6 +142,10 @@ function AllocationRules() {
|
||||
});
|
||||
|
||||
const rules = data ?? [];
|
||||
const controls = useListControls(rules, {
|
||||
searchKeys: ['name', 'targetYardCode', 'targetWarehouseCode', 'targetZoneCode', 'freightType', 'tradeDirection', 'cargoTypeCode', 'storageType'],
|
||||
dateKey: 'createdAt',
|
||||
});
|
||||
const yardOptions = yards
|
||||
.filter((yard) => yard.code)
|
||||
.map((yard) => ({
|
||||
@@ -255,7 +260,7 @@ function AllocationRules() {
|
||||
<>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text c="dimmed" size="sm">
|
||||
{rules.length} rule(s) matched by ascending priority
|
||||
{controls.totalCount} rule(s) matched by ascending priority
|
||||
</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
||||
New allocation rule
|
||||
@@ -268,12 +273,26 @@ function AllocationRules() {
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search allocation rules…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Created"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={allocationColumns}
|
||||
data={rules}
|
||||
data={controls.pagedRows}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
emptyMessage="No allocation rules yet. Create one to route inventory to a yard automatically."
|
||||
containerClassName="border-0 shadow-none"
|
||||
{...controls.tableProps}
|
||||
/>
|
||||
|
||||
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
|
||||
@@ -393,6 +412,12 @@ function FeeRules() {
|
||||
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
|
||||
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
// Detention rules match on the truck-type CODE denormalised onto
|
||||
// vehicles.vehicle_type, so the options come from the managed truck types
|
||||
// rather than a hardcoded list that drifts the moment a type is added.
|
||||
const { data: truckTypes = [], isLoading: truckTypesLoading } = useQuery(
|
||||
api.truckTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
const create = useCreateFeeRule();
|
||||
const update = useUpdateFeeRule();
|
||||
const remove = useDeleteFeeRule();
|
||||
@@ -414,6 +439,10 @@ function FeeRules() {
|
||||
currency: 'USD',
|
||||
});
|
||||
const rules = data ?? [];
|
||||
const controls = useListControls(rules, {
|
||||
searchKeys: ['name', 'ruleType', 'freightType', 'tradeDirection', 'cargoTypeCode', 'containerType', 'vehicleType', 'currency'],
|
||||
dateKey: 'createdAt',
|
||||
});
|
||||
const cargoTypeOptions = codeOptions(cargoTypes);
|
||||
const containerTypeOptions = codeOptions(containerTypes);
|
||||
const isBulkRule = form.freightType === 'BULK';
|
||||
@@ -642,19 +671,33 @@ function FeeRules() {
|
||||
<>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text c="dimmed" size="sm">
|
||||
{rules.length} rule(s) - most specific match applies
|
||||
{controls.totalCount} rule(s) - most specific match applies
|
||||
</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
||||
New fee rule
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search fee rules…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Created"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={feeColumns}
|
||||
data={rules}
|
||||
data={controls.pagedRows}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
emptyMessage="No storage or demurrage fee rules yet."
|
||||
containerClassName="border-0 shadow-none"
|
||||
{...controls.tableProps}
|
||||
/>
|
||||
|
||||
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
|
||||
@@ -712,8 +755,9 @@ function FeeRules() {
|
||||
{isTruckDetention && (
|
||||
<Select
|
||||
label="Truck type"
|
||||
placeholder="Any truck type"
|
||||
data={VEHICLE_TYPES.map((v) => ({ value: v, label: v.charAt(0) + v.slice(1).toLowerCase() }))}
|
||||
placeholder={truckTypesLoading ? 'Loading truck types...' : 'Any truck type'}
|
||||
data={truckTypes.map((t) => ({ value: t.code, label: `${t.name} (${t.code})` }))}
|
||||
disabled={truckTypesLoading}
|
||||
value={form.vehicleType || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, vehicleType: selectValue(value) }))}
|
||||
clearable
|
||||
|
||||
@@ -195,6 +195,7 @@ import {
|
||||
type UsedTrainNumbers,
|
||||
} from "./trainBuilder.service";
|
||||
import { trainSchedulingService } from "./trainScheduling.service";
|
||||
import { truckTypesService, type TruckType } from "./truck-types.service";
|
||||
import { wagonTypesService, type WagonType } from "./wagon-types.service";
|
||||
import {
|
||||
wagonService,
|
||||
@@ -657,7 +658,16 @@ export const api = {
|
||||
"finalize-schedule",
|
||||
(id) => trainSchedulingService.finalizeSchedule(id),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
// Finalize only flips statuses (schedule DRAFT→SCHEDULED + each booking's
|
||||
// schedulingStatus) — it never touches allocation or loading. Refresh only
|
||||
// the schedule detail/list + booking views instead of the broad
|
||||
// train-scheduling ROOT, which refired the eligible-bookings / pool /
|
||||
// yard-work queries and made the booking lists visibly reload.
|
||||
() => [
|
||||
["train-scheduling", "schedule"],
|
||||
["train-scheduling", "schedules"],
|
||||
QUERY_KEYS.BOOKINGS.ROOT,
|
||||
],
|
||||
),
|
||||
|
||||
dispatchSchedule: endpoint<string, TrainScheduleDetail>(
|
||||
@@ -2073,6 +2083,36 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
truckTypes: {
|
||||
list: endpoint<void, TruckType[]>("truck-types", "list", () =>
|
||||
truckTypesService.getTruckTypes(),
|
||||
),
|
||||
|
||||
create: endpoint<Partial<TruckType>, TruckType>(
|
||||
"truck-types",
|
||||
"create",
|
||||
(payload) => truckTypesService.create(payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => [["truck-types"]],
|
||||
),
|
||||
|
||||
update: endpoint<{ id: string; data: Partial<TruckType> }, TruckType>(
|
||||
"truck-types",
|
||||
"update",
|
||||
({ id, data }) => truckTypesService.update(id, data).then((r) => r.data),
|
||||
undefined,
|
||||
() => [["truck-types"]],
|
||||
),
|
||||
|
||||
remove: endpoint<string, void>(
|
||||
"truck-types",
|
||||
"remove",
|
||||
(id) => truckTypesService.delete(id).then(() => undefined),
|
||||
undefined,
|
||||
() => [["truck-types"]],
|
||||
),
|
||||
},
|
||||
|
||||
wagonTypes: {
|
||||
list: endpoint<void, WagonType[]>("wagon-types", "list", () =>
|
||||
wagonTypesService.getWagonTypes(),
|
||||
|
||||
@@ -97,6 +97,7 @@ export interface ContractView {
|
||||
contractId: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
freightType: "BULK" | "CONTAINER";
|
||||
templateKey: string;
|
||||
title: string;
|
||||
html: string;
|
||||
|
||||
@@ -22,7 +22,10 @@ export interface FirstMileBooking {
|
||||
serviceType?: { id: string; label?: string } | null;
|
||||
originYard?: { id: string; label?: string } | null;
|
||||
destinationYard?: { id: string; label?: string } | null;
|
||||
cargoType?: { id: string; label?: string } | null;
|
||||
cargoType?: { id: string; label?: string; cargoTypeName?: string; name?: string } | null;
|
||||
freightType?: string | null;
|
||||
/** Attached server-side: the train schedule this booking rides. */
|
||||
trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null;
|
||||
/** Container lines — total container count drives how many trucks are needed. */
|
||||
bookingContainers?: Array<{
|
||||
id: string;
|
||||
@@ -68,6 +71,8 @@ export interface FirstMileRecord {
|
||||
vehicleId: string;
|
||||
containerNumber?: string | null;
|
||||
distanceKm?: number | null;
|
||||
tons?: number | null;
|
||||
quantity?: number | null;
|
||||
vehicle?: FirstMileVehicle | null;
|
||||
}>;
|
||||
/** Present only when an invoice has actually been generated (not on distance). */
|
||||
@@ -95,7 +100,12 @@ export const firstMileService = {
|
||||
api.delete<void>(FM.BY_ID(id)),
|
||||
setVehicles: (
|
||||
id: string,
|
||||
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
||||
vehicles: Array<{
|
||||
vehicleId: string;
|
||||
containerNumber?: string | null;
|
||||
tons?: number | null;
|
||||
quantity?: number | null;
|
||||
}>,
|
||||
) => api.post<FirstMileRecord>(`${FM.BASE}/${id}/vehicles`, { vehicles }),
|
||||
setDistances: (
|
||||
id: string,
|
||||
|
||||
@@ -85,6 +85,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
||||
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
|
||||
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
|
||||
"wagon-types": URL_CONSTANTS.RULE_ENGINE.WAGON_TYPES,
|
||||
"truck-types": URL_CONSTANTS.RULE_ENGINE.TRUCK_TYPES,
|
||||
"priority-configs": URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIGS,
|
||||
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
|
||||
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
|
||||
@@ -103,6 +104,8 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
|
||||
return URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id);
|
||||
case "wagon-types":
|
||||
return URL_CONSTANTS.RULE_ENGINE.WAGON_TYPE_BY_ID(id);
|
||||
case "truck-types":
|
||||
return URL_CONSTANTS.RULE_ENGINE.TRUCK_TYPE_BY_ID(id);
|
||||
case "priority-configs":
|
||||
return URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIG_BY_ID(id);
|
||||
case "service-types":
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { api } from "../auth/http";
|
||||
|
||||
type ListResponse<T> = T[] | { data: T[] };
|
||||
|
||||
export interface TruckType {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
/** Pre-fills a vehicle's capacity — capacity belongs to the type, not each truck. */
|
||||
capacityTons: number | null;
|
||||
/** False for a rigid truck (e.g. Casoni), which has no trailer plate at all. */
|
||||
hasTrailer: boolean;
|
||||
description?: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const asList = <T>(payload: ListResponse<T>): T[] =>
|
||||
Array.isArray(payload) ? payload : payload.data;
|
||||
|
||||
export const truckTypesService = {
|
||||
async getTruckTypes() {
|
||||
const response = await api.get<ListResponse<TruckType>>('/truck-types', {
|
||||
params: { isActive: 'all', pageSize: 500 },
|
||||
});
|
||||
return asList(response.data);
|
||||
},
|
||||
create: (data: Partial<TruckType>) => api.post('/truck-types', data),
|
||||
update: (id: string, data: Partial<TruckType>) => api.patch(`/truck-types/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/truck-types/${id}`),
|
||||
};
|
||||
@@ -20,7 +20,14 @@ export interface Vehicle {
|
||||
id: string;
|
||||
plateNumber: string;
|
||||
registrationNumber: string;
|
||||
/** Denormalised truck-type code, written server-side. Register with `truckTypeId`. */
|
||||
vehicleType: VehicleType;
|
||||
/** Truck configuration from the managed truck types. */
|
||||
truckTypeId?: string | null;
|
||||
/** Vehicle Identification Number — unique across the fleet. */
|
||||
vin?: string | null;
|
||||
/** OWNED | OUTSOURCED. */
|
||||
ownership?: string | null;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
year: number;
|
||||
|
||||
@@ -2,6 +2,7 @@ export type RuleEngineResourceSlug =
|
||||
| "cargo-types"
|
||||
| "container-types"
|
||||
| "wagon-types"
|
||||
| "truck-types"
|
||||
| "priority-configs"
|
||||
| "service-types"
|
||||
| "weight-limit-rules"
|
||||
|
||||
@@ -756,8 +756,10 @@ export interface AllocationRule {
|
||||
targetZoneCode?: string | null;
|
||||
storageType?: string | null;
|
||||
isActive: boolean;
|
||||
/** Set by the API (BaseEntity); used for the list date filter. */
|
||||
createdAt?: string;
|
||||
}
|
||||
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id'>;
|
||||
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id' | 'createdAt'>;
|
||||
|
||||
export const FEE_RULE_TYPES = [
|
||||
'STORAGE_FEE',
|
||||
@@ -813,8 +815,10 @@ export interface FeeRule {
|
||||
tiers?: FeeRuleTier[];
|
||||
currency: string;
|
||||
isActive: boolean;
|
||||
/** Set by the API (BaseEntity); used for the list date filter. */
|
||||
createdAt?: string;
|
||||
}
|
||||
export type SaveFeeRulePayload = Omit<FeeRule, 'id'>;
|
||||
export type SaveFeeRulePayload = Omit<FeeRule, 'id' | 'createdAt'>;
|
||||
|
||||
export interface FeeRuleTier {
|
||||
fromDay: number;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
@@ -79,6 +80,8 @@ export function CustomerTruckAssignmentCard({
|
||||
const [driverName, setDriverName] = useState("");
|
||||
const [truckType, setTruckType] = useState("");
|
||||
const [containers, setContainers] = useState<string[]>([]);
|
||||
const [plannedTons, setPlannedTons] = useState<number | "">("");
|
||||
const [plannedQty, setPlannedQty] = useState<number | "">("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [bulkModalOpen, setBulkModalOpen] = useState(false);
|
||||
@@ -105,15 +108,23 @@ export function CustomerTruckAssignmentCard({
|
||||
setDriverName("");
|
||||
setTruckType("");
|
||||
setContainers([]);
|
||||
setPlannedTons("");
|
||||
setPlannedQty("");
|
||||
setEditingId(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const startEdit = (t: Freight.ICustomerTruck) => {
|
||||
const planned = t as Freight.ICustomerTruck & {
|
||||
plannedTons?: number | string | null;
|
||||
plannedQuantity?: number | null;
|
||||
};
|
||||
setPlateNumber(t.plateNumber ?? "");
|
||||
setDriverName(t.driverName ?? "");
|
||||
setTruckType(t.truckType ?? "");
|
||||
setContainers((t.containers ?? []).map((c) => c.containerNumber));
|
||||
setPlannedTons(planned.plannedTons != null ? Number(planned.plannedTons) : "");
|
||||
setPlannedQty(planned.plannedQuantity != null ? Number(planned.plannedQuantity) : "");
|
||||
setEditingId(t.id);
|
||||
setError(null);
|
||||
};
|
||||
@@ -129,6 +140,12 @@ export function CustomerTruckAssignmentCard({
|
||||
driverName: driverName.trim(),
|
||||
truckType: truckType.trim(),
|
||||
containerNumbers: isBulk ? [] : containers,
|
||||
...(isBulk
|
||||
? {
|
||||
plannedTons: plannedTons === "" ? undefined : Number(plannedTons),
|
||||
plannedQuantity: plannedQty === "" ? undefined : Number(plannedQty),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return editingId
|
||||
? customerTrucksService.update(booking.id, editingId, payload)
|
||||
@@ -170,6 +187,10 @@ export function CustomerTruckAssignmentCard({
|
||||
setError("Select 1 or 2 container numbers for this truck.");
|
||||
return;
|
||||
}
|
||||
if (isBulk && plannedTons === "") {
|
||||
setError("Enter the tonnes this truck will haul.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
addMutation.mutate();
|
||||
};
|
||||
@@ -324,6 +345,40 @@ export function CustomerTruckAssignmentCard({
|
||||
nothingFoundMessage="No unassigned containers"
|
||||
/>
|
||||
)}
|
||||
{isBulk && (
|
||||
<NumberInput
|
||||
label="Tonnes to load"
|
||||
description={(() => {
|
||||
const total = Number(booking.cargoTotalWeightVgm) || 0;
|
||||
const assigned = trucks
|
||||
.filter((t) => t.id !== editingId)
|
||||
.reduce((s, t) => {
|
||||
const x = t as Freight.ICustomerTruck & {
|
||||
netWeightTons?: number | string | null;
|
||||
plannedTons?: number | string | null;
|
||||
};
|
||||
return s + (Number(x.netWeightTons ?? x.plannedTons) || 0);
|
||||
}, 0);
|
||||
const remaining = Math.max(0, Math.round((total - assigned) * 1000) / 1000);
|
||||
return total > 0
|
||||
? `${assigned} t of ${total} t already on trucks · ${remaining} t remaining`
|
||||
: "Tonnage this truck hauls";
|
||||
})()}
|
||||
required
|
||||
min={0}
|
||||
value={plannedTons}
|
||||
onChange={(v) => setPlannedTons(v === "" ? "" : Number(v))}
|
||||
/>
|
||||
)}
|
||||
{isBulk && (
|
||||
<NumberInput
|
||||
label="Items quantity (pcs)"
|
||||
description="Optional piece count on this truck"
|
||||
min={0}
|
||||
value={plannedQty}
|
||||
onChange={(v) => setPlannedQty(v === "" ? "" : Number(v))}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
<Group justify="flex-end">
|
||||
{editingId && (
|
||||
|
||||
@@ -257,6 +257,20 @@ function usePlacesSearch(): PlacesSearch | null {
|
||||
}, [placesLib]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a coordinate to a finite number or null. Numeric-typed API fields
|
||||
* (e.g. contract/booking `..Lat`/`..Lng`) come back from Postgres `numeric`
|
||||
* columns as strings — the TS type says `number` but the value crossing the
|
||||
* network is not. Google Maps' `panTo`/`Marker` throw on anything that isn't
|
||||
* a real finite number, so every coordinate is normalised at this one shared
|
||||
* entry point rather than trusting each caller to have converted it.
|
||||
*/
|
||||
function toFiniteNumber(v: number | string | null | undefined): number | null {
|
||||
if (v == null) return null;
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** Recenters the map imperatively when the pinned coordinate changes. */
|
||||
function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) {
|
||||
const map = useMap();
|
||||
@@ -290,12 +304,17 @@ export interface LocationPickerProps {
|
||||
* Reports the resolved address and coordinates up via `onChange`.
|
||||
*/
|
||||
export function LocationPicker(props: LocationPickerProps) {
|
||||
const value: LocationValue = {
|
||||
...props.value,
|
||||
lat: toFiniteNumber(props.value.lat),
|
||||
lng: toFiniteNumber(props.value.lng),
|
||||
};
|
||||
return (
|
||||
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
|
||||
{props.variant === "modal" ? (
|
||||
<LocationPickerModal {...props} />
|
||||
<LocationPickerModal {...props} value={value} />
|
||||
) : (
|
||||
<LocationPickerInline {...props} />
|
||||
<LocationPickerInline {...props} value={value} />
|
||||
)}
|
||||
</APIProvider>
|
||||
);
|
||||
|
||||
@@ -42,21 +42,27 @@ export function Step2ServiceType({
|
||||
const lastMileEnabled = form.watch("lastMile.enabled");
|
||||
|
||||
const prevServiceType = useRef(serviceType);
|
||||
// Only clear a mile when the current service doesn't include it — this ran
|
||||
// unconditionally before, so it wiped an already-entered (or prefilled, or
|
||||
// draft-restored) pickup/delivery address on every service change, even one
|
||||
// that still includes that mile.
|
||||
useEffect(() => {
|
||||
if (includesFirstMile) return;
|
||||
form.setValue(
|
||||
"firstMile",
|
||||
{ enabled: false, pickUpAddress: "", lat: null, lng: null },
|
||||
{ shouldValidate: true },
|
||||
);
|
||||
}, [includesFirstMile]);
|
||||
}, [includesFirstMile, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (includesLastMile) return;
|
||||
form.setValue(
|
||||
"lastMile",
|
||||
{ enabled: false, deliveryAddress: "", lat: null, lng: null },
|
||||
{ shouldValidate: true },
|
||||
);
|
||||
}, [includesLastMile]);
|
||||
}, [includesLastMile, form]);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevServiceType.current;
|
||||
@@ -82,6 +88,10 @@ export function Step2ServiceType({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip right after a hydration `form.reset()` (draft restore / renewal
|
||||
// prefill) — a restored selection is trusted as-is until the customer
|
||||
// actually changes something live.
|
||||
if (!form.formState.isDirty) return;
|
||||
const currentId = form.getValues("serviceTypeId");
|
||||
if (!currentId) return;
|
||||
if (!bookableServices.some((s) => s.id === currentId)) {
|
||||
|
||||
@@ -100,12 +100,17 @@ export function Step4Route({
|
||||
// here — e.g. switching import→export flips which end must be in Ethiopia.
|
||||
// Clear any selection that no longer matches the operation's required country
|
||||
// so the customer can't submit a route that contradicts the operation.
|
||||
// Skipped right after a hydration `form.reset()` (draft restore / renewal
|
||||
// prefill) — a restored yard is trusted as-is until the customer changes
|
||||
// something live; `isDirty` is false only in that pristine post-reset render.
|
||||
useEffect(() => {
|
||||
if (!form.formState.isDirty) return;
|
||||
if (originCountry && origin && origin.country !== originCountry) {
|
||||
form.setValue("originYard", "");
|
||||
}
|
||||
}, [originCountry, origin, form]);
|
||||
useEffect(() => {
|
||||
if (!form.formState.isDirty) return;
|
||||
if (destinationCountry && dest && dest.country !== destinationCountry) {
|
||||
form.setValue("destinationYard", "");
|
||||
}
|
||||
|
||||
@@ -335,6 +335,11 @@ export function Step2ServiceType({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip right after a hydration `form.reset()` (edit mode) — a saved
|
||||
// contract's service is trusted as-is until the customer actually
|
||||
// changes something live; `isDirty` is false only in that pristine
|
||||
// post-hydration render, so this never clears a value nobody touched.
|
||||
if (!form.formState.isDirty) return;
|
||||
const currentId = form.getValues("serviceTypeId");
|
||||
if (!currentId) return;
|
||||
if (!standaloneServices.some((s) => s.id === currentId)) {
|
||||
|
||||
@@ -1,23 +1,44 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Container, Flame, RotateCcw, Snowflake } from "lucide-react";
|
||||
// Snowflake — restore with the Refrigerated Cargo switch below.
|
||||
import { Container, Flame, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
CONTAINER_SIZES,
|
||||
ContractFormInputValues,
|
||||
type ContractDocuments,
|
||||
type ContractFormValues,
|
||||
} from "./schema";
|
||||
import { operationToTradeDirection } from "./helpers";
|
||||
import { fieldStyles, SelectField, StepLabel } from "./shared";
|
||||
|
||||
/**
|
||||
* file_upload_settings code holding the hazardous-cargo document requirements.
|
||||
* Fields are configured by an admin in the backoffice file-settings editor —
|
||||
* whatever is configured there is what the modal asks for.
|
||||
*/
|
||||
const HAZARDOUS_DOC_SETTING_CODE = "hazardous_documents";
|
||||
|
||||
function hasUploaded(value: File | File[] | null | undefined): boolean {
|
||||
if (!value) return false;
|
||||
return Array.isArray(value) ? value.length > 0 : true;
|
||||
}
|
||||
|
||||
const CARGO_TYPE_OPTIONS = [
|
||||
{ value: "container", label: "Containerized (20ft / 40ft)" },
|
||||
{ value: "bulk", label: "General / Bulk cargo" },
|
||||
@@ -51,6 +72,101 @@ export function Step3CargoScope({
|
||||
const cargoTypePath = form.watch("cargoTypePath") ?? [];
|
||||
const parentId = cargoTypePath[0];
|
||||
|
||||
// Hazardous cargo is a ONE-TIME-contract-only option; refrigerated cargo and
|
||||
// empty-container return are offered on IMPORT operations only.
|
||||
const operationType = form.watch("operationType");
|
||||
const isOneTime = (form.watch("contractKind") ?? "one_time") === "one_time";
|
||||
const isImport = operationType
|
||||
? operationToTradeDirection(operationType) === "IMPORT"
|
||||
: false;
|
||||
|
||||
const hazardSettingQuery = useQuery(
|
||||
api.fileUploadSettings.getByCode.queryOptions({
|
||||
input: { code: HAZARDOUS_DOC_SETTING_CODE },
|
||||
enabled: isOneTime,
|
||||
}),
|
||||
);
|
||||
const hazardFields = hazardSettingQuery.data?.fields ?? [];
|
||||
const hazardKeys = useMemo(
|
||||
() => hazardFields.map((f) => f.fileKey),
|
||||
[hazardFields],
|
||||
);
|
||||
|
||||
const [hazardModalOpen, setHazardModalOpen] = useState(false);
|
||||
const [hazardDraft, setHazardDraft] = useState<ContractDocuments>({});
|
||||
const [hazardErrors, setHazardErrors] = useState<Record<string, string>>({});
|
||||
|
||||
/** Drop every hazardous document from the contract's document map. */
|
||||
const clearHazardDocs = () => {
|
||||
const docs = { ...((form.getValues("documents") ?? {}) as ContractDocuments) };
|
||||
let changed = false;
|
||||
for (const key of hazardKeys) {
|
||||
if (key in docs) {
|
||||
delete docs[key];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) form.setValue("documents", docs, { shouldDirty: true });
|
||||
};
|
||||
|
||||
const openHazardModal = () => {
|
||||
const docs = (form.getValues("documents") ?? {}) as ContractDocuments;
|
||||
setHazardDraft(
|
||||
Object.fromEntries(
|
||||
hazardKeys.filter((k) => k in docs).map((k) => [k, docs[k]]),
|
||||
),
|
||||
);
|
||||
setHazardErrors({});
|
||||
setHazardModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmHazardDocs = () => {
|
||||
const missing = hazardFields.filter(
|
||||
(f) => f.isRequired && !hasUploaded(hazardDraft[f.fileKey]),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
setHazardErrors(
|
||||
Object.fromEntries(
|
||||
missing.map((f) => [f.fileKey, `${f.fileLabel} is required.`]),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
form.setValue(
|
||||
"documents",
|
||||
{
|
||||
...((form.getValues("documents") ?? {}) as ContractDocuments),
|
||||
...hazardDraft,
|
||||
},
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
form.setValue("isHazardous", true, { shouldDirty: true });
|
||||
setHazardModalOpen(false);
|
||||
};
|
||||
|
||||
// A hidden flag must never leak into the payload: a general contract can't be
|
||||
// hazardous, and a non-import contract carries neither reefer nor empty return.
|
||||
useEffect(() => {
|
||||
if (isOneTime) return;
|
||||
if (form.getValues("isHazardous")) {
|
||||
form.setValue("isHazardous", false, { shouldDirty: true });
|
||||
}
|
||||
// Runs again once the hazard field list loads — a no-op when nothing matches.
|
||||
clearHazardDocs();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOneTime, hazardKeys, form]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reefer has no switch right now, so it must never leave the form true.
|
||||
if (form.getValues("isRefrigerated")) {
|
||||
form.setValue("isRefrigerated", false, { shouldDirty: true });
|
||||
}
|
||||
if (isImport) return;
|
||||
if (form.getValues("equipmentReturn") === "with_return") {
|
||||
form.setValue("equipmentReturn", "without_return", { shouldDirty: true });
|
||||
}
|
||||
}, [isImport, form]);
|
||||
|
||||
// Reset the commodity child only when the parent group really changes.
|
||||
const prevParentIdRef = useRef<string | undefined>(parentId);
|
||||
useEffect(() => {
|
||||
@@ -233,40 +349,55 @@ export function Step3CargoScope({
|
||||
<Box>
|
||||
<StepLabel>Cargo handling</StepLabel>
|
||||
<Stack gap={12} mt={12}>
|
||||
<Controller
|
||||
name="isHazardous"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ToggleRow
|
||||
icon={<Flame size={18} />}
|
||||
iconBg="#FBEAE7"
|
||||
iconColor="#C0392B"
|
||||
title="Hazardous Material"
|
||||
description="Applies a hazard surcharge as a per-container unit rate."
|
||||
checked={field.value ?? false}
|
||||
onChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="isRefrigerated"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ToggleRow
|
||||
icon={<Snowflake size={18} />}
|
||||
iconBg="#E9F0F8"
|
||||
iconColor="#2E5B96"
|
||||
title="Refrigerated Cargo"
|
||||
description="Temperature-controlled transport applies a reefer surcharge."
|
||||
checked={field.value ?? false}
|
||||
onChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* Empty-container return is a container-only service. Like hazardous,
|
||||
enabling it here adds the return surcharge as a unit rate; at
|
||||
booking time the customer/GL sets how many containers return. */}
|
||||
{cargoType === "container" && (
|
||||
{/* Hazardous cargo is only carried under a one-time contract, and
|
||||
enabling it requires the configured hazard documents up front. */}
|
||||
{isOneTime && (
|
||||
<Controller
|
||||
name="isHazardous"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ToggleRow
|
||||
icon={<Flame size={18} />}
|
||||
iconBg="#FBEAE7"
|
||||
iconColor="#C0392B"
|
||||
title="Hazardous Material"
|
||||
description="Applies a hazard surcharge as a per-container unit rate. Requires hazard documents."
|
||||
checked={field.value ?? false}
|
||||
onChange={(v) => {
|
||||
if (v) {
|
||||
openHazardModal();
|
||||
return;
|
||||
}
|
||||
field.onChange(false);
|
||||
clearHazardDocs();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{/* Refrigerated cargo is hidden for now — import-only when re-enabled.
|
||||
The effect above keeps isRefrigerated false while it's off.
|
||||
{isImport && (
|
||||
<Controller
|
||||
name="isRefrigerated"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ToggleRow
|
||||
icon={<Snowflake size={18} />}
|
||||
iconBg="#E9F0F8"
|
||||
iconColor="#2E5B96"
|
||||
title="Refrigerated Cargo"
|
||||
description="Temperature-controlled transport applies a reefer surcharge."
|
||||
checked={field.value ?? false}
|
||||
onChange={(v) => field.onChange(v)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)} */}
|
||||
{/* Empty-container return is a container-only IMPORT service. Like
|
||||
hazardous, enabling it here adds the return surcharge as a unit
|
||||
rate; at booking time the customer/GL sets how many return. */}
|
||||
{isImport && cargoType === "container" && (
|
||||
<Controller
|
||||
name="equipmentReturn"
|
||||
control={form.control}
|
||||
@@ -287,6 +418,54 @@ export function Step3CargoScope({
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Modal
|
||||
opened={hazardModalOpen}
|
||||
onClose={() => setHazardModalOpen(false)}
|
||||
title="Hazardous cargo documents"
|
||||
size="lg"
|
||||
centered
|
||||
radius={14}
|
||||
>
|
||||
<Stack gap={16}>
|
||||
<Text fz={13} c="#6B7C8E">
|
||||
Hazardous cargo can only move once the documents below are attached
|
||||
to the contract.
|
||||
</Text>
|
||||
|
||||
{hazardSettingQuery.isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
) : hazardFields.length > 0 && hazardSettingQuery.data ? (
|
||||
<SmartFileInput
|
||||
file={hazardSettingQuery.data}
|
||||
value={hazardDraft}
|
||||
onChange={(v) => setHazardDraft(v)}
|
||||
errors={hazardErrors}
|
||||
/>
|
||||
) : (
|
||||
<Text fz={13} c="#6B7C8E">
|
||||
No hazardous document requirements are configured yet. You can
|
||||
still flag the cargo as hazardous — EDR will request the paperwork
|
||||
during review.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap={10}>
|
||||
<Button
|
||||
variant="default"
|
||||
radius={10}
|
||||
onClick={() => setHazardModalOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="edr-green" radius={10} onClick={confirmHazardDocs}>
|
||||
Save & mark hazardous
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,12 +72,17 @@ export function Step4Route({
|
||||
const direction = getRouteDirection(origin, dest);
|
||||
|
||||
// Clear yards that no longer match the operation's required country.
|
||||
// Skipped right after a hydration `form.reset()` (edit mode) — a saved
|
||||
// contract's yards are trusted as-is until the customer changes something
|
||||
// live; `isDirty` is false only in that pristine post-hydration render.
|
||||
useEffect(() => {
|
||||
if (!form.formState.isDirty) return;
|
||||
if (originCountry && origin && origin.country !== originCountry) {
|
||||
form.setValue("originYard", "");
|
||||
}
|
||||
}, [originCountry, origin, form]);
|
||||
useEffect(() => {
|
||||
if (!form.formState.isDirty) return;
|
||||
if (destinationCountry && dest && dest.country !== destinationCountry) {
|
||||
form.setValue("destinationYard", "");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user