mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
Merge pull request #1113 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -3,7 +3,10 @@ import Handlebars from 'handlebars';
|
|||||||
/** One numbered clause of a dynamic article, with optional nested bullets. */
|
/** One numbered clause of a dynamic article, with optional nested bullets. */
|
||||||
export interface RenderedClause {
|
export interface RenderedClause {
|
||||||
text: string;
|
text: string;
|
||||||
/** Computed outline number, e.g. "3" or "2.1.4". */
|
/**
|
||||||
|
* Computed outline marker for this clause at its own level: "3" at depth 1,
|
||||||
|
* "b" at depth 2, "iv" at depth 3, cycling back to arabic at depth 4.
|
||||||
|
*/
|
||||||
number: string;
|
number: string;
|
||||||
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
|
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
|
||||||
depth: number;
|
depth: number;
|
||||||
@@ -35,6 +38,51 @@ const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
|
|||||||
/** Deepest supported sub-clause level (1.1.1.1.1.1). */
|
/** Deepest supported sub-clause level (1.1.1.1.1.1). */
|
||||||
const MAX_CLAUSE_DEPTH = 6;
|
const MAX_CLAUSE_DEPTH = 6;
|
||||||
|
|
||||||
|
/** 1 → "a", 2 → "b", … 27 → "aa". */
|
||||||
|
function toAlpha(n: number): string {
|
||||||
|
let out = '';
|
||||||
|
let value = n;
|
||||||
|
while (value > 0) {
|
||||||
|
const rem = (value - 1) % 26;
|
||||||
|
out = String.fromCharCode(97 + rem) + out;
|
||||||
|
value = Math.floor((value - 1) / 26);
|
||||||
|
}
|
||||||
|
return out || 'a';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROMAN: Array<[number, string]> = [
|
||||||
|
[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'],
|
||||||
|
[100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'],
|
||||||
|
[10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 1 → "i", 4 → "iv", 9 → "ix". */
|
||||||
|
function toRoman(n: number): string {
|
||||||
|
let value = n;
|
||||||
|
let out = '';
|
||||||
|
for (const [amount, numeral] of ROMAN) {
|
||||||
|
while (value >= amount) {
|
||||||
|
out += numeral;
|
||||||
|
value -= amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out || 'i';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Word-processor outline markers, cycling by depth the way Quill's own list
|
||||||
|
* rendering does: 1. → a. → i. → 1. … Depth 1 keeps plain arabic numerals so
|
||||||
|
* top-level clauses read as "1.", "2." in the contract; the marker is the
|
||||||
|
* clause's own counter at its level, NOT a dotted path — "a" under clause 2 is
|
||||||
|
* "a", not "2.a".
|
||||||
|
*/
|
||||||
|
export function clauseMarker(counter: number, depth: number): string {
|
||||||
|
const style = (depth - 1) % 3;
|
||||||
|
if (style === 1) return toAlpha(counter);
|
||||||
|
if (style === 2) return toRoman(counter);
|
||||||
|
return String(counter);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a template article body into clauses. Format: one clause per line.
|
* Parse a template article body into clauses. Format: one clause per line.
|
||||||
* A leading outline number ("2. ", "2.1 ", "2.1.3 ") nests the line as a
|
* A leading outline number ("2. ", "2.1 ", "2.1.3 ") nests the line as a
|
||||||
@@ -82,7 +130,7 @@ export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph
|
|||||||
|
|
||||||
clauses.push({
|
clauses.push({
|
||||||
text: match ? line.slice(match[0].length).trim() : line,
|
text: match ? line.slice(match[0].length).trim() : line,
|
||||||
number: counters.slice(0, depth).join('.'),
|
number: clauseMarker(counters[depth - 1], depth),
|
||||||
depth,
|
depth,
|
||||||
bullets: [],
|
bullets: [],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
}),
|
}),
|
||||||
contractYear: new Date().getFullYear(),
|
contractYear: new Date().getFullYear(),
|
||||||
|
contractStartDate: this.formatDate(contract.contractValidFrom),
|
||||||
|
contractEndDate: this.formatDate(contract.contractValidUntil),
|
||||||
client: {
|
client: {
|
||||||
companyName: contract.company?.name ?? 'Client',
|
companyName: contract.company?.name ?? 'Client',
|
||||||
companyAddress: this.valueOrDash(contract.company?.address),
|
companyAddress: this.valueOrDash(contract.company?.address),
|
||||||
@@ -281,7 +283,8 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
|
|
||||||
private buildSchedule(contract: Contract): ContractViewModel['schedule'] {
|
private buildSchedule(contract: Contract): ContractViewModel['schedule'] {
|
||||||
const firstRoute = this.firstRoute(contract);
|
const firstRoute = this.firstRoute(contract);
|
||||||
const cargoScope = (contract.cargoScope ?? [])[0];
|
const scope = contract.cargoScope ?? [];
|
||||||
|
const cargoScope = scope[0];
|
||||||
const cargoName =
|
const cargoName =
|
||||||
cargoScope?.cargoType?.cargoTypeName ||
|
cargoScope?.cargoType?.cargoTypeName ||
|
||||||
cargoScope?.cargoFreeText ||
|
cargoScope?.cargoFreeText ||
|
||||||
@@ -289,6 +292,28 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
? `${cargoScope.containerSize} container`
|
? `${cargoScope.containerSize} container`
|
||||||
: 'Container cargo');
|
: 'Container cargo');
|
||||||
|
|
||||||
|
// A contract's scope can list several cargo lines (e.g. coffee in 20ft and
|
||||||
|
// 40ft); name each distinctly rather than collapsing to the first.
|
||||||
|
const containerType = [
|
||||||
|
...new Set(scope.map((s) => s.containerSize ?? '').filter(Boolean)),
|
||||||
|
].join(', ');
|
||||||
|
const cargoTypeName = [
|
||||||
|
...new Set(
|
||||||
|
scope
|
||||||
|
.map((s) => s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? '')
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
].join(', ');
|
||||||
|
const cargoSummary = scope
|
||||||
|
.map((s) => {
|
||||||
|
const name = s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? null;
|
||||||
|
const size = s.containerSize ? `(${s.containerSize})` : null;
|
||||||
|
const cap = s.quantityCap ? `× ${Number(s.quantityCap)}` : null;
|
||||||
|
return [name, size, cap].filter(Boolean).join(' ');
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('; ');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
originLabel: this.yardLabel(firstRoute?.originYard),
|
originLabel: this.yardLabel(firstRoute?.originYard),
|
||||||
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
|
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
|
||||||
@@ -302,6 +327,9 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
scheduledDate: this.formatDate(null),
|
scheduledDate: this.formatDate(null),
|
||||||
contractType: this.valueOrDash(contract.contractType),
|
contractType: this.valueOrDash(contract.contractType),
|
||||||
cargoDescription: this.valueOrDash(cargoName),
|
cargoDescription: this.valueOrDash(cargoName),
|
||||||
|
cargoTypeName: this.valueOrDash(cargoTypeName),
|
||||||
|
containerType: this.valueOrDash(containerType),
|
||||||
|
cargoSummary: this.valueOrDash(cargoSummary),
|
||||||
totalWeightVgm: '—',
|
totalWeightVgm: '—',
|
||||||
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
|
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
|
||||||
// A hazardous contract names the declared class + UN number on the
|
// A hazardous contract names the declared class + UN number on the
|
||||||
|
|||||||
@@ -27,18 +27,32 @@ describe('parseArticleBody', () => {
|
|||||||
expect(parsed.clauses).toEqual([]);
|
expect(parsed.clauses).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('nests numbered sub-clauses by their outline token and renumbers sequentially', () => {
|
it('nests sub-clauses by outline token and marks each level 1. → a. → i.', () => {
|
||||||
const parsed = parseArticleBody(
|
const parsed = parseArticleBody(
|
||||||
'1. Scope\n5.1 Rail transport\n1.1.1 Wagon supply\n2. Payment',
|
'1. Scope\n5.1 Rail transport\n1.1.1 Wagon supply\n2. Payment',
|
||||||
);
|
);
|
||||||
expect(parsed.clauses.map((c) => [c.number, c.depth, c.text])).toEqual([
|
expect(parsed.clauses.map((c) => [c.number, c.depth, c.text])).toEqual([
|
||||||
['1', 1, 'Scope'],
|
['1', 1, 'Scope'],
|
||||||
['1.1', 2, 'Rail transport'],
|
['a', 2, 'Rail transport'],
|
||||||
['1.1.1', 3, 'Wagon supply'],
|
['i', 3, 'Wagon supply'],
|
||||||
['2', 1, 'Payment'],
|
['2', 1, 'Payment'],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cycles markers back to arabic at depth 4 and counts each level on its own', () => {
|
||||||
|
const parsed = parseArticleBody(
|
||||||
|
'1. One\n1.1 Alpha\n1.2 Beta\n1.2.1 Roman one\n1.2.2 Roman two\n1.2.2.1 Deep',
|
||||||
|
);
|
||||||
|
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
|
||||||
|
['1', 1],
|
||||||
|
['a', 2],
|
||||||
|
['b', 2],
|
||||||
|
['i', 3],
|
||||||
|
['ii', 3],
|
||||||
|
['1', 4],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('clamps a sub-clause with no open parent to the next available level', () => {
|
it('clamps a sub-clause with no open parent to the next available level', () => {
|
||||||
const parsed = parseArticleBody('1.1.1 Orphan sub-clause\nSecond clause.');
|
const parsed = parseArticleBody('1.1.1 Orphan sub-clause\nSecond clause.');
|
||||||
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
|
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
|
||||||
@@ -90,6 +104,8 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
|
|||||||
template: { ...meta, title: 'Bulk Import Contract', templateFile: 'edr-dynamic.hbs' },
|
template: { ...meta, title: 'Bulk Import Contract', templateFile: 'edr-dynamic.hbs' },
|
||||||
contractDate: '1 January 2026',
|
contractDate: '1 January 2026',
|
||||||
contractYear: 2026,
|
contractYear: 2026,
|
||||||
|
contractStartDate: '1 January 2026',
|
||||||
|
contractEndDate: '31 December 2026',
|
||||||
client: {
|
client: {
|
||||||
companyName: 'Abyssinia Trading PLC',
|
companyName: 'Abyssinia Trading PLC',
|
||||||
companyAddress: 'Bole Sub-city, Addis Ababa',
|
companyAddress: 'Bole Sub-city, Addis Ababa',
|
||||||
@@ -117,6 +133,9 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
|
|||||||
scheduledDate: '—',
|
scheduledDate: '—',
|
||||||
contractType: 'GENERAL',
|
contractType: 'GENERAL',
|
||||||
cargoDescription: 'Steel billets',
|
cargoDescription: 'Steel billets',
|
||||||
|
cargoTypeName: 'Steel billets',
|
||||||
|
containerType: '—',
|
||||||
|
cargoSummary: 'Steel billets × 2,800',
|
||||||
totalWeightVgm: '—',
|
totalWeightVgm: '—',
|
||||||
equipmentReturn: '—',
|
equipmentReturn: '—',
|
||||||
hazardousLabel: 'No',
|
hazardousLabel: 'No',
|
||||||
@@ -192,6 +211,42 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
|
|||||||
expect(html).toContain('#1b9e7a');
|
expect(html).toContain('#1b9e7a');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the contract validity window in the commercial schedule annex', () => {
|
||||||
|
const html = renderer.render(dynamicView());
|
||||||
|
expect(html).toContain('Valid from');
|
||||||
|
expect(html).toContain('Valid until');
|
||||||
|
expect(html).toContain('1 January 2026');
|
||||||
|
expect(html).toContain('31 December 2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interpolates the start/end date placeholders inside article text', () => {
|
||||||
|
const view = dynamicView();
|
||||||
|
expect(
|
||||||
|
interpolateTemplateText(
|
||||||
|
'In force {{contractStartDate}} to {{contractEndDate}}.',
|
||||||
|
view,
|
||||||
|
),
|
||||||
|
).toBe('In force 1 January 2026 to 31 December 2026.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows cargo type and container type in the commercial schedule annex', () => {
|
||||||
|
const html = renderer.render(dynamicView());
|
||||||
|
expect(html).toContain('Cargo type');
|
||||||
|
expect(html).toContain('Container type');
|
||||||
|
expect(html).toContain('Cargo scope');
|
||||||
|
expect(html).toContain('Steel billets × 2,800');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interpolates the cargo/container placeholders inside article text', () => {
|
||||||
|
const view = dynamicView();
|
||||||
|
const body =
|
||||||
|
'Cargo: {{schedule.cargoTypeName}} in {{schedule.containerType}} ' +
|
||||||
|
'({{schedule.freightType}}). Scope: {{schedule.cargoSummary}}.';
|
||||||
|
expect(interpolateTemplateText(body, view)).toBe(
|
||||||
|
'Cargo: Steel billets in — (BULK). Scope: Steel billets × 2,800.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the live rate schedule lane under the pricing article', () => {
|
it('renders the live rate schedule lane under the pricing article', () => {
|
||||||
const html = renderer.render(dynamicView());
|
const html = renderer.render(dynamicView());
|
||||||
expect(html).toContain('Rate Schedule');
|
expect(html).toContain('Rate Schedule');
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ describe('ContractRendererService', () => {
|
|||||||
template,
|
template,
|
||||||
contractDate: '1 January 2026',
|
contractDate: '1 January 2026',
|
||||||
contractYear: 2026,
|
contractYear: 2026,
|
||||||
|
contractStartDate: '1 January 2026',
|
||||||
|
contractEndDate: '31 December 2026',
|
||||||
client: {
|
client: {
|
||||||
companyName: 'Test Co',
|
companyName: 'Test Co',
|
||||||
companyAddress: 'Addis Ababa',
|
companyAddress: 'Addis Ababa',
|
||||||
@@ -43,6 +45,9 @@ describe('ContractRendererService', () => {
|
|||||||
scheduledDate: '1 January 2026',
|
scheduledDate: '1 January 2026',
|
||||||
contractType: 'NEW',
|
contractType: 'NEW',
|
||||||
cargoDescription: 'Container cargo',
|
cargoDescription: 'Container cargo',
|
||||||
|
cargoTypeName: 'Coffee',
|
||||||
|
containerType: '40ft',
|
||||||
|
cargoSummary: 'Coffee (40ft) × 12',
|
||||||
totalWeightVgm: '24 tons',
|
totalWeightVgm: '24 tons',
|
||||||
equipmentReturn: 'RETURN',
|
equipmentReturn: 'RETURN',
|
||||||
hazardousLabel: 'No',
|
hazardousLabel: 'No',
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ export interface ContractViewModel {
|
|||||||
template: ContractTemplateMeta;
|
template: ContractTemplateMeta;
|
||||||
contractDate: string;
|
contractDate: string;
|
||||||
contractYear: number;
|
contractYear: number;
|
||||||
|
/**
|
||||||
|
* The contract's validity window (`contract_valid_from` / `_until`). Distinct
|
||||||
|
* from `contractDate`, which is the day the document is generated — these are
|
||||||
|
* the dates the contract is actually in force between. "—" when unset.
|
||||||
|
*/
|
||||||
|
contractStartDate: string;
|
||||||
|
contractEndDate: string;
|
||||||
client: {
|
client: {
|
||||||
companyName: string;
|
companyName: string;
|
||||||
companyAddress: string;
|
companyAddress: string;
|
||||||
@@ -68,6 +75,16 @@ export interface ContractViewModel {
|
|||||||
scheduledDate: string;
|
scheduledDate: string;
|
||||||
contractType: string;
|
contractType: string;
|
||||||
cargoDescription: string;
|
cargoDescription: string;
|
||||||
|
/**
|
||||||
|
* The named cargo type on its own (e.g. "Coffee"), separate from
|
||||||
|
* `cargoDescription` which folds in free text and a container fallback.
|
||||||
|
* Lets a clause name the commodity without the surrounding prose.
|
||||||
|
*/
|
||||||
|
cargoTypeName: string;
|
||||||
|
/** Container size alone, e.g. "20ft" / "40ft"; "—" for bulk. */
|
||||||
|
containerType: string;
|
||||||
|
/** Every cargo line on the contract, e.g. "Coffee (40ft) × 12". */
|
||||||
|
cargoSummary: string;
|
||||||
totalWeightVgm: string;
|
totalWeightVgm: string;
|
||||||
equipmentReturn: string;
|
equipmentReturn: string;
|
||||||
hazardousLabel: string;
|
hazardousLabel: string;
|
||||||
@@ -133,6 +150,8 @@ export class ContractViewModelBuilder {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
}),
|
}),
|
||||||
contractYear: new Date().getFullYear(),
|
contractYear: new Date().getFullYear(),
|
||||||
|
contractStartDate: this.formatDate(booking.contractValidFrom),
|
||||||
|
contractEndDate: this.formatDate(booking.contractValidUntil),
|
||||||
client: {
|
client: {
|
||||||
companyName: booking.company?.name ?? 'Client',
|
companyName: booking.company?.name ?? 'Client',
|
||||||
companyAddress: this.valueOrDash(booking.company?.address),
|
companyAddress: this.valueOrDash(booking.company?.address),
|
||||||
@@ -195,6 +214,21 @@ export class ContractViewModelBuilder {
|
|||||||
'Bulk commodity'
|
'Bulk commodity'
|
||||||
: booking.cargoType?.cargoTypeName || 'Container cargo';
|
: booking.cargoType?.cargoTypeName || 'Container cargo';
|
||||||
const totalWeight = Number(booking.cargoTotalWeightVgm || 0);
|
const totalWeight = Number(booking.cargoTotalWeightVgm || 0);
|
||||||
|
// A booking may carry both sizes; name each one once, in the order booked.
|
||||||
|
const containerType = [
|
||||||
|
...new Set(
|
||||||
|
(booking.bookingContainers ?? [])
|
||||||
|
.map(
|
||||||
|
(line) =>
|
||||||
|
line.containerType?.label ??
|
||||||
|
(line.containerType?.sizeFt
|
||||||
|
? `${line.containerType.sizeFt}ft`
|
||||||
|
: line.containerSize) ??
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
originLabel: this.yardLabel(booking.originYard),
|
originLabel: this.yardLabel(booking.originYard),
|
||||||
@@ -207,6 +241,13 @@ export class ContractViewModelBuilder {
|
|||||||
scheduledDate: this.formatDate(booking.scheduledDate),
|
scheduledDate: this.formatDate(booking.scheduledDate),
|
||||||
contractType: this.valueOrDash(booking.contractType),
|
contractType: this.valueOrDash(booking.contractType),
|
||||||
cargoDescription: this.valueOrDash(cargoName),
|
cargoDescription: this.valueOrDash(cargoName),
|
||||||
|
cargoTypeName: this.valueOrDash(booking.cargoType?.cargoTypeName),
|
||||||
|
containerType: this.valueOrDash(containerType),
|
||||||
|
cargoSummary: this.valueOrDash(
|
||||||
|
[cargoName, containerType ? `(${containerType})` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
|
),
|
||||||
totalWeightVgm:
|
totalWeightVgm:
|
||||||
totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—',
|
totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—',
|
||||||
equipmentReturn: this.valueOrDash(booking.equipmentReturn),
|
equipmentReturn: this.valueOrDash(booking.equipmentReturn),
|
||||||
|
|||||||
@@ -125,12 +125,30 @@
|
|||||||
<th>Hazardous cargo</th>
|
<th>Hazardous cargo</th>
|
||||||
<td>{{schedule.hazardousLabel}}</td>
|
<td>{{schedule.hazardousLabel}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Cargo type</th>
|
||||||
|
<td>{{schedule.cargoTypeName}}</td>
|
||||||
|
<th>Container type</th>
|
||||||
|
<td>{{schedule.containerType}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Cargo scope</th>
|
||||||
|
<td>{{schedule.cargoSummary}}</td>
|
||||||
|
<th>Freight type</th>
|
||||||
|
<td>{{schedule.freightType}}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Equipment return</th>
|
<th>Equipment return</th>
|
||||||
<td>{{schedule.equipmentReturn}}</td>
|
<td>{{schedule.equipmentReturn}}</td>
|
||||||
<th>Payment currency</th>
|
<th>Payment currency</th>
|
||||||
<td>{{paymentArticle}}</td>
|
<td>{{paymentArticle}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Valid from</th>
|
||||||
|
<td>{{contractStartDate}}</td>
|
||||||
|
<th>Valid until</th>
|
||||||
|
<td>{{contractEndDate}}</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -208,6 +208,9 @@ export class ContractTemplatesService {
|
|||||||
year: "numeric",
|
year: "numeric",
|
||||||
}),
|
}),
|
||||||
contractYear: now.getFullYear(),
|
contractYear: now.getFullYear(),
|
||||||
|
// Representative validity window for the admin preview only.
|
||||||
|
contractStartDate: `1 January ${now.getFullYear()}`,
|
||||||
|
contractEndDate: `31 December ${now.getFullYear()}`,
|
||||||
client: {
|
client: {
|
||||||
companyName: "Abyssinia Trading PLC",
|
companyName: "Abyssinia Trading PLC",
|
||||||
companyAddress: "Bole Sub-city, Woreda 03, H.No 1234, Addis Ababa",
|
companyAddress: "Bole Sub-city, Woreda 03, H.No 1234, Addis Ababa",
|
||||||
@@ -239,6 +242,11 @@ export class ContractTemplatesService {
|
|||||||
scheduledDate: "—",
|
scheduledDate: "—",
|
||||||
contractType: "GENERAL",
|
contractType: "GENERAL",
|
||||||
cargoDescription: isBulk ? "Steel billets — 2,800 MT" : "40ft containers — FMCG cargo",
|
cargoDescription: isBulk ? "Steel billets — 2,800 MT" : "40ft containers — FMCG cargo",
|
||||||
|
cargoTypeName: isBulk ? "Steel billets" : "Coffee",
|
||||||
|
containerType: isBulk ? "—" : "40ft",
|
||||||
|
cargoSummary: isBulk
|
||||||
|
? "Steel billets × 2,800"
|
||||||
|
: "Coffee (40ft) × 12; Sesame (20ft) × 6",
|
||||||
totalWeightVgm: "—",
|
totalWeightVgm: "—",
|
||||||
equipmentReturn: isBulk ? "—" : "With empty return",
|
equipmentReturn: isBulk ? "—" : "With empty return",
|
||||||
hazardousLabel: "No",
|
hazardousLabel: "No",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Body, Controller, Get, Patch } from "@nestjs/common";
|
import { Body, Controller, Get, Patch } from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
import { CurrentUser, ExchangeService } from "@edr/api-common";
|
import { CurrentUser } from "@edr/api-common";
|
||||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||||
|
|
||||||
import { FreightAdmin } from "../../common/booking-guards";
|
import { FreightAdmin } from "../../common/booking-guards";
|
||||||
@@ -11,10 +11,7 @@ import { ExchangeSettingsService } from "./exchange-settings.service";
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller("exchange-settings")
|
@Controller("exchange-settings")
|
||||||
export class ExchangeSettingsController {
|
export class ExchangeSettingsController {
|
||||||
constructor(
|
constructor(private readonly service: ExchangeSettingsService) {}
|
||||||
private readonly service: ExchangeSettingsService,
|
|
||||||
private readonly exchangeService: ExchangeService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@FreightAdmin()
|
@FreightAdmin()
|
||||||
@@ -22,24 +19,15 @@ export class ExchangeSettingsController {
|
|||||||
summary: "Current USD→ETB fallback rate and CBE feed health",
|
summary: "Current USD→ETB fallback rate and CBE feed health",
|
||||||
})
|
})
|
||||||
async get() {
|
async get() {
|
||||||
const [setting, status] = [
|
const setting = await this.service.get();
|
||||||
await this.service.get(),
|
const status = this.service.getFeedStatus();
|
||||||
this.exchangeService.getProviderStatus(),
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fallbackRate: setting.fallbackRate,
|
fallbackRate: setting.fallbackRate,
|
||||||
fallbackSource: setting.fallbackSource,
|
fallbackSource: setting.fallbackSource,
|
||||||
lastSyncedAt: setting.lastSyncedAt,
|
lastSyncedAt: setting.lastSyncedAt,
|
||||||
updatedById: setting.updatedById,
|
updatedById: setting.updatedById,
|
||||||
feed: {
|
feed: status,
|
||||||
rate: status.rate,
|
|
||||||
source: status.source,
|
|
||||||
lastSuccessAt: status.lastSuccessAt
|
|
||||||
? new Date(status.lastSuccessAt).toISOString()
|
|
||||||
: null,
|
|
||||||
lastError: status.lastError,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ import { ExchangeSetting } from "./entities/exchange-setting.entity";
|
|||||||
*/
|
*/
|
||||||
const SEED_FALLBACK_RATE = 162.4165;
|
const SEED_FALLBACK_RATE = 162.4165;
|
||||||
|
|
||||||
|
/** Health of the CBE feed, as surfaced to the backoffice. */
|
||||||
|
export interface ExchangeFeedStatus {
|
||||||
|
/** Rate most recently observed, whatever its source. */
|
||||||
|
rate: number | null;
|
||||||
|
/** `live` means CBE answered; `stored`/`default` mean it is failing. */
|
||||||
|
source: "live" | "stored" | null;
|
||||||
|
/** ISO timestamp of the last successful fetch. */
|
||||||
|
lastSuccessAt: string | null;
|
||||||
|
/** Message from the most recent failure, cleared on success. */
|
||||||
|
lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the single `exchange_settings` row: the USD→ETB fallback used when the
|
* Owns the single `exchange_settings` row: the USD→ETB fallback used when the
|
||||||
* CBE endpoint is unreachable.
|
* CBE endpoint is unreachable.
|
||||||
@@ -22,11 +34,30 @@ const SEED_FALLBACK_RATE = 162.4165;
|
|||||||
export class ExchangeSettingsService {
|
export class ExchangeSettingsService {
|
||||||
private readonly logger = new Logger(ExchangeSettingsService.name);
|
private readonly logger = new Logger(ExchangeSettingsService.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed health, recorded from the exchange provider's callbacks rather than
|
||||||
|
* read off an injected `ExchangeService`. The provider is registered several
|
||||||
|
* times (bookings, contracts, warehouses), so no single instance sees every
|
||||||
|
* fetch — and injecting one here would be circular, since those
|
||||||
|
* registrations inject *this* service.
|
||||||
|
*/
|
||||||
|
private feed: ExchangeFeedStatus = {
|
||||||
|
rate: null,
|
||||||
|
source: null,
|
||||||
|
lastSuccessAt: null,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(ExchangeSetting)
|
@InjectRepository(ExchangeSetting)
|
||||||
private readonly repository: Repository<ExchangeSetting>,
|
private readonly repository: Repository<ExchangeSetting>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** Health of the CBE feed as last observed by any provider instance. */
|
||||||
|
getFeedStatus(): ExchangeFeedStatus {
|
||||||
|
return { ...this.feed };
|
||||||
|
}
|
||||||
|
|
||||||
/** The settings row, created at the seed rate on first access. */
|
/** The settings row, created at the seed rate on first access. */
|
||||||
async get(): Promise<ExchangeSetting> {
|
async get(): Promise<ExchangeSetting> {
|
||||||
const existing = await this.repository.findOne({ where: {} });
|
const existing = await this.repository.findOne({ where: {} });
|
||||||
@@ -47,15 +78,22 @@ export class ExchangeSettingsService {
|
|||||||
* than propagating a database error into a pricing call.
|
* than propagating a database error into a pricing call.
|
||||||
*/
|
*/
|
||||||
async loadFallbackRate(): Promise<number | null> {
|
async loadFallbackRate(): Promise<number | null> {
|
||||||
|
// Only reached when the live fetch failed, so this call is itself the
|
||||||
|
// signal that the feed is down.
|
||||||
try {
|
try {
|
||||||
const { fallbackRate } = await this.get();
|
const { fallbackRate } = await this.get();
|
||||||
return Number.isFinite(fallbackRate) && fallbackRate > 0
|
const usable = Number.isFinite(fallbackRate) && fallbackRate > 0;
|
||||||
? fallbackRate
|
this.feed = {
|
||||||
: null;
|
...this.feed,
|
||||||
|
rate: usable ? fallbackRate : this.feed.rate,
|
||||||
|
source: "stored",
|
||||||
|
lastError: this.feed.lastError ?? "CBE endpoint unreachable",
|
||||||
|
};
|
||||||
|
return usable ? fallbackRate : null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(
|
const message = (err as Error).message;
|
||||||
`Could not read stored exchange fallback: ${(err as Error).message}`,
|
this.feed = { ...this.feed, source: "stored", lastError: message };
|
||||||
);
|
this.logger.warn(`Could not read stored exchange fallback: ${message}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,6 +104,14 @@ export class ExchangeSettingsService {
|
|||||||
* down, so a working CBE feed takes precedence again.
|
* down, so a working CBE feed takes precedence again.
|
||||||
*/
|
*/
|
||||||
async saveFallbackRate(rate: number): Promise<void> {
|
async saveFallbackRate(rate: number): Promise<void> {
|
||||||
|
// Only called after a successful fetch, so the feed is confirmed healthy.
|
||||||
|
this.feed = {
|
||||||
|
rate,
|
||||||
|
source: "live",
|
||||||
|
lastSuccessAt: new Date().toISOString(),
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
const current = await this.get();
|
const current = await this.get();
|
||||||
await this.repository.update(current.id, {
|
await this.repository.update(current.id, {
|
||||||
fallbackRate: rate,
|
fallbackRate: rate,
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { FleetManage, StaffReference } from '../../common/booking-guards';
|
import {
|
||||||
|
BookingStaff,
|
||||||
|
FleetManage,
|
||||||
|
StaffReference,
|
||||||
|
} from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||||
@@ -59,4 +75,18 @@ export class LocomotivesController {
|
|||||||
decommission(@Param('id', ParseUUIDPipe) id: string) {
|
decommission(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.locomotivesService.decommission(id);
|
return this.locomotivesService.decommission(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BookingStaff, not FleetManage: the latter also accepts the coarse
|
||||||
|
// fleet:manage key, which would hand an irreversible purge to everyone who
|
||||||
|
// can edit the fleet. This action requires its own grant, nothing else.
|
||||||
|
@Delete(':id/permanent')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.locomotives.hardDelete)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Permanently delete a locomotive (irreversible; refused if any train references it)',
|
||||||
|
})
|
||||||
|
purge(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.locomotivesService.purge(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,4 +222,61 @@ export class LocomotivesService {
|
|||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently purge a locomotive — irreversible, and only for rows nothing
|
||||||
|
* references: a mistyped or duplicated entry.
|
||||||
|
*
|
||||||
|
* Every FK onto `locomotives` is NO ACTION, so Postgres would reject the
|
||||||
|
* delete with a raw constraint error. The references are resolved up front
|
||||||
|
* instead, naming the trains involved so the message says what to detach.
|
||||||
|
* Decommissioning (`decommission`) stays the answer for a real locomotive
|
||||||
|
* leaving service.
|
||||||
|
*/
|
||||||
|
async purge(id: string): Promise<void> {
|
||||||
|
const locomotive = await this.locomotivesRepository.findById(id);
|
||||||
|
if (!locomotive) {
|
||||||
|
throw new NotFoundException(`Locomotive ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [builtTrains, setsViaJoin, setsDirect] = await Promise.all([
|
||||||
|
this.dataSource.query<Array<{ code: string | null }>>(
|
||||||
|
`SELECT t.code
|
||||||
|
FROM freight.train_locomotives tl
|
||||||
|
JOIN freight.trains t ON t.id = tl.train_id
|
||||||
|
WHERE tl.locomotive_id = $1`,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
this.dataSource.query<Array<{ code: string | null }>>(
|
||||||
|
`SELECT t.code
|
||||||
|
FROM freight.train_set_locomotives tsl
|
||||||
|
JOIN freight.train_sets ts ON ts.id = tsl.train_set_id
|
||||||
|
LEFT JOIN freight.trains t ON t.id = ts.train_id
|
||||||
|
WHERE tsl.locomotive_id = $1`,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
this.dataSource.query<Array<{ code: string | null }>>(
|
||||||
|
`SELECT t.code
|
||||||
|
FROM freight.train_sets ts
|
||||||
|
LEFT JOIN freight.trains t ON t.id = ts.train_id
|
||||||
|
WHERE ts.locomotive_id = $1`,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const referencing = [...builtTrains, ...setsViaJoin, ...setsDirect];
|
||||||
|
if (referencing.length > 0) {
|
||||||
|
const codes = [
|
||||||
|
...new Set(referencing.map((r) => r.code).filter(Boolean)),
|
||||||
|
];
|
||||||
|
const named = codes.length > 0 ? ` (${codes.join(', ')})` : '';
|
||||||
|
throw new ConflictException(
|
||||||
|
`Locomotive ${locomotive.code} is used by ${referencing.length} train record(s)${named}; detach it before deleting it permanently. Decommission it instead to take it out of service.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(Locomotive)
|
||||||
|
.delete({ id });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
apps/edr-freight-api/src/modules/routes/purge-guard.spec.ts
Normal file
61
apps/edr-freight-api/src/modules/routes/purge-guard.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { RoutesService } from './routes.service';
|
||||||
|
|
||||||
|
const ROUTE = {
|
||||||
|
id: 'r1',
|
||||||
|
originYard: { code: 'ADD', label: 'Addis' },
|
||||||
|
destinationYard: { code: 'DIR', label: 'Dire Dawa' },
|
||||||
|
milestones: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeService = (scheduleCount: number, route: unknown = ROUTE) => {
|
||||||
|
const deletes: string[] = [];
|
||||||
|
const manager = {
|
||||||
|
getRepository: (entity: { name?: string }) => ({
|
||||||
|
delete: async () => {
|
||||||
|
deletes.push(entity?.name ?? 'unknown');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const dataSource = {
|
||||||
|
query: jest.fn(async () => [{ count: scheduleCount }]),
|
||||||
|
transaction: jest.fn(async (cb: (m: unknown) => Promise<void>) => cb(manager)),
|
||||||
|
};
|
||||||
|
const routesRepository = {};
|
||||||
|
const svc = new RoutesService(dataSource as never, routesRepository as never);
|
||||||
|
// findById is the service's own loader; stub it to isolate the purge guard.
|
||||||
|
(svc as unknown as { findById: (id: string) => Promise<unknown> }).findById =
|
||||||
|
async () => {
|
||||||
|
if (!route) throw new NotFoundException('Route not found');
|
||||||
|
return route;
|
||||||
|
};
|
||||||
|
return { svc, dataSource, deletes };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RoutesService.purge', () => {
|
||||||
|
it('purges a route no schedule references', async () => {
|
||||||
|
const { svc, dataSource, deletes } = makeService(0);
|
||||||
|
await svc.purge('r1');
|
||||||
|
expect(dataSource.transaction).toHaveBeenCalled();
|
||||||
|
// Milestones then the route itself, inside one transaction.
|
||||||
|
expect(deletes).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses while train schedules reference it', async () => {
|
||||||
|
const { svc, dataSource } = makeService(4);
|
||||||
|
await expect(svc.purge('r1')).rejects.toThrow(ConflictException);
|
||||||
|
await expect(svc.purge('r1')).rejects.toThrow(/4 train schedule\(s\)/);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the route in the refusal so the message is actionable', async () => {
|
||||||
|
const { svc } = makeService(1);
|
||||||
|
await expect(svc.purge('r1')).rejects.toThrow(/ADD|Addis/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates a not-found route', async () => {
|
||||||
|
const { svc, dataSource } = makeService(0, null);
|
||||||
|
await expect(svc.purge('nope')).rejects.toThrow(NotFoundException);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,23 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
import {
|
||||||
|
BookingStaff,
|
||||||
|
FleetManage,
|
||||||
|
FleetView,
|
||||||
|
} from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { CreateRouteDto } from './dto/create-route.dto';
|
import { CreateRouteDto } from './dto/create-route.dto';
|
||||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||||
@@ -48,6 +64,21 @@ export class RoutesController {
|
|||||||
return this.routesService.update(id, dto);
|
return this.routesService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Declared before @Delete(':id') so "permanent" is never captured as an id.
|
||||||
|
// BookingStaff, not FleetManage: the latter also accepts the coarse
|
||||||
|
// fleet:manage key, which would hand an irreversible purge to everyone who
|
||||||
|
// can edit the fleet. This action requires its own grant, nothing else.
|
||||||
|
@Delete(':id/permanent')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.routes.hardDelete)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Permanently delete a route (irreversible; refused while any train schedule references it)',
|
||||||
|
})
|
||||||
|
purge(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.routesService.purge(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@FleetManage(FREIGHT_PERMS.routes.delete)
|
@FleetManage(FREIGHT_PERMS.routes.delete)
|
||||||
@ApiOperation({ summary: 'Deactivate route' })
|
@ApiOperation({ summary: 'Deactivate route' })
|
||||||
|
|||||||
@@ -206,6 +206,41 @@ export class RoutesService {
|
|||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently purge a route — irreversible, and only for corridors nothing
|
||||||
|
* has run on: a mistyped or duplicated definition.
|
||||||
|
*
|
||||||
|
* `train_schedules.route_id` is NO ACTION, so Postgres would reject the
|
||||||
|
* delete with a raw constraint error; the schedules are counted up front
|
||||||
|
* instead so the refusal says what is blocking. The route's own milestones
|
||||||
|
* cascade with it, which is correct — they are the route's definition, not
|
||||||
|
* history that outlives it. Deactivating (`deactivate`) stays the answer for
|
||||||
|
* a corridor that has actually been used.
|
||||||
|
*/
|
||||||
|
async purge(id: string): Promise<void> {
|
||||||
|
const route = await this.findById(id);
|
||||||
|
|
||||||
|
const [schedules] = await this.dataSource.query<Array<{ count: number }>>(
|
||||||
|
`SELECT count(*)::int AS count
|
||||||
|
FROM freight.train_schedules
|
||||||
|
WHERE route_id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (schedules?.count > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Route ${formatRouteLabel(route)} cannot be permanently deleted — ${schedules.count} train schedule(s) still reference it. Deactivate it instead, which keeps the history intact.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
// Milestones are FK-cascaded, but delete them explicitly so the intent is
|
||||||
|
// visible here rather than depending on the constraint alone.
|
||||||
|
await manager.getRepository(RouteMilestone).delete({ routeId: id });
|
||||||
|
await manager.getRepository(Route).delete({ id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A route IS its ordered stop list — "Addis → Adama → Dire Dawa" and
|
* A route IS its ordered stop list — "Addis → Adama → Dire Dawa" and
|
||||||
* "Addis → Dire Dawa" share endpoints but are different corridors. So the
|
* "Addis → Dire Dawa" share endpoints but are different corridors. So the
|
||||||
|
|||||||
61
apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts
Normal file
61
apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { WagonsService } from './wagons.service';
|
||||||
|
|
||||||
|
// Minimal stubs: only what purge() touches.
|
||||||
|
const makeService = (wagon: any, counts: [number, number, number], pinned = false) => {
|
||||||
|
const wagonRepo = { findOne: jest.fn().mockResolvedValue(wagon), remove: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
// Keyed off the SQL so the stub survives repeated purge() calls in one test.
|
||||||
|
const dataSource = {
|
||||||
|
query: jest.fn(async (sql: string) => {
|
||||||
|
if (sql.includes('train_schedule')) return pinned ? [{ x: 1 }] : [];
|
||||||
|
if (sql.includes('wagon_movements')) return [{ count: counts[0] }];
|
||||||
|
if (sql.includes('containers')) return [{ count: counts[1] }];
|
||||||
|
if (sql.includes('train_set_wagons')) return [{ count: counts[2] }];
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const svc = new WagonsService(wagonRepo as any, {} as any, dataSource as any);
|
||||||
|
return { svc, wagonRepo };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('WagonsService.purge', () => {
|
||||||
|
const clean = { id: 'w1', wagonNumber: 'W-0001', trainId: null };
|
||||||
|
|
||||||
|
it('purges a wagon with no references', async () => {
|
||||||
|
const { svc, wagonRepo } = makeService(clean, [0, 0, 0]);
|
||||||
|
await svc.purge('w1');
|
||||||
|
expect(wagonRepo.remove).toHaveBeenCalledWith(clean);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses when the wagon has movement history', async () => {
|
||||||
|
const { svc, wagonRepo } = makeService(clean, [12, 0, 0]);
|
||||||
|
await expect(svc.purge('w1')).rejects.toThrow(ConflictException);
|
||||||
|
await expect(svc.purge('w1')).rejects.toThrow(/12 movement record/);
|
||||||
|
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses when containers or train-set slots reference it', async () => {
|
||||||
|
const { svc, wagonRepo } = makeService(clean, [0, 3, 2]);
|
||||||
|
await expect(svc.purge('w1')).rejects.toThrow(/3 container\(s\), 2 train-set slot/);
|
||||||
|
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a coupled wagon before any count query runs', async () => {
|
||||||
|
const { svc, wagonRepo } = makeService({ ...clean, trainId: 't1' }, [0, 0, 0]);
|
||||||
|
await expect(svc.purge('w1')).rejects.toThrow(/coupled to a train/);
|
||||||
|
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a wagon pinned to a live schedule', async () => {
|
||||||
|
const { svc, wagonRepo } = makeService(clean, [0, 0, 0], true);
|
||||||
|
await expect(svc.purge('w1')).rejects.toThrow(/pinned to an active schedule/);
|
||||||
|
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s an unknown wagon', async () => {
|
||||||
|
const wagonRepo = { findOne: jest.fn().mockResolvedValue(null), remove: jest.fn() };
|
||||||
|
const svc = new WagonsService(wagonRepo as any, {} as any, { query: jest.fn() } as any);
|
||||||
|
await expect(svc.purge('nope')).rejects.toThrow(NotFoundException);
|
||||||
|
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
@@ -12,7 +14,11 @@ import {
|
|||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
import { FleetManage, StaffReference } from '../../common/booking-guards';
|
import {
|
||||||
|
BookingStaff,
|
||||||
|
FleetManage,
|
||||||
|
StaffReference,
|
||||||
|
} from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||||
@@ -69,6 +75,21 @@ export class WagonsController {
|
|||||||
return this.wagonsService.update(id, dto);
|
return this.wagonsService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Declared before @Delete(':id') so "permanent" is never captured as an id.
|
||||||
|
// BookingStaff, not FleetManage: the latter also accepts the coarse
|
||||||
|
// fleet:manage key, which would hand an irreversible purge to everyone who
|
||||||
|
// can edit the fleet. This action requires its own grant, nothing else.
|
||||||
|
@Delete(':id/permanent')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.wagons.hardDelete)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)',
|
||||||
|
})
|
||||||
|
purge(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.wagonsService.purge(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@FleetManage(FREIGHT_PERMS.wagons.delete)
|
@FleetManage(FREIGHT_PERMS.wagons.delete)
|
||||||
@ApiOperation({ summary: 'Delete a wagon' })
|
@ApiOperation({ summary: 'Delete a wagon' })
|
||||||
|
|||||||
@@ -212,6 +212,75 @@ export class WagonsService {
|
|||||||
await this.wagonRepo.softRemove(wagon);
|
await this.wagonRepo.softRemove(wagon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently purge a wagon — irreversible, and only for rows that carry no
|
||||||
|
* history: a mistyped or duplicated entry someone wants gone for good.
|
||||||
|
*
|
||||||
|
* `wagon_movements` cascades on delete, so a wagon with movements would take
|
||||||
|
* its ledger history down with it. Rather than allow that, every reference is
|
||||||
|
* checked first and the purge is refused if any exist — soft delete (`remove`)
|
||||||
|
* stays the answer for a wagon that has actually been used.
|
||||||
|
*
|
||||||
|
* Soft-deleted wagons are purgeable, so `withDeleted` is used to find them.
|
||||||
|
*/
|
||||||
|
async purge(id: string): Promise<void> {
|
||||||
|
const wagon = await this.wagonRepo.findOne({
|
||||||
|
where: { id },
|
||||||
|
withDeleted: true,
|
||||||
|
});
|
||||||
|
if (!wagon) {
|
||||||
|
throw new NotFoundException(`Wagon ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wagon.trainId != null) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Wagon ${wagon.wagonNumber} is coupled to a train; detach it via train-builder before deleting it permanently`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (await this.isWagonPinnedToLiveSchedule(id)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be deleted permanently`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each of these would either lose history (movements cascade) or silently
|
||||||
|
// blank a live reference (containers / train-set slots are SET NULL).
|
||||||
|
const blockers: string[] = [];
|
||||||
|
const [movements, containers, trainSetSlots] = await Promise.all([
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT count(*)::int AS count FROM freight.wagon_movements WHERE wagon_id = $1`,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT count(*)::int AS count FROM freight.containers WHERE wagon_id = $1`,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT count(*)::int AS count FROM freight.train_set_wagons WHERE physical_wagon_id = $1`,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
if (movements[0]?.count > 0) {
|
||||||
|
blockers.push(`${movements[0].count} movement record(s)`);
|
||||||
|
}
|
||||||
|
if (containers[0]?.count > 0) {
|
||||||
|
blockers.push(`${containers[0].count} container(s)`);
|
||||||
|
}
|
||||||
|
if (trainSetSlots[0]?.count > 0) {
|
||||||
|
blockers.push(`${trainSetSlots[0].count} train-set slot(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockers.length > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Wagon ${wagon.wagonNumber} cannot be permanently deleted — it still has ${blockers.join(
|
||||||
|
', ',
|
||||||
|
)}. Delete it normally instead, which keeps the history intact.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.wagonRepo.remove(wagon);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wagon is busy when any live (DRAFT/SCHEDULED/DISPATCHED) schedule pins it
|
* A wagon is busy when any live (DRAFT/SCHEDULED/DISPATCHED) schedule pins it
|
||||||
* to one of its slots — schedule occupancy lives on TrainSetWagon rows, not
|
* to one of its slots — schedule occupancy lives on TrainSetWagon rows, not
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('e1a00001-0001-4000-8000-000000000002', 'edr_freight_app:locomotives:create', 'Create locomotive'),
|
perm('e1a00001-0001-4000-8000-000000000002', 'edr_freight_app:locomotives:create', 'Create locomotive'),
|
||||||
perm('e1a00001-0001-4000-8000-000000000003', 'edr_freight_app:locomotives:update', 'Update locomotive'),
|
perm('e1a00001-0001-4000-8000-000000000003', 'edr_freight_app:locomotives:update', 'Update locomotive'),
|
||||||
perm('e1a00001-0001-4000-8000-000000000004', 'edr_freight_app:locomotives:delete', 'Delete locomotive'),
|
perm('e1a00001-0001-4000-8000-000000000004', 'edr_freight_app:locomotives:delete', 'Delete locomotive'),
|
||||||
|
perm('e1a00001-0001-4000-8000-000000000005', 'edr_freight_app:locomotives:hard_delete', 'Permanently delete locomotive'),
|
||||||
perm('e1b00001-0001-4000-8000-000000000001', 'edr_freight_app:wagons:view', 'View wagons'),
|
perm('e1b00001-0001-4000-8000-000000000001', 'edr_freight_app:wagons:view', 'View wagons'),
|
||||||
perm('e1b00001-0001-4000-8000-000000000002', 'edr_freight_app:wagons:create', 'Create wagon'),
|
perm('e1b00001-0001-4000-8000-000000000002', 'edr_freight_app:wagons:create', 'Create wagon'),
|
||||||
perm('e1b00001-0001-4000-8000-000000000003', 'edr_freight_app:wagons:update', 'Update wagon'),
|
perm('e1b00001-0001-4000-8000-000000000003', 'edr_freight_app:wagons:update', 'Update wagon'),
|
||||||
@@ -248,6 +249,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('e1b00001-0001-4000-8000-000000000008', 'edr_freight_app:wagons:transfer_view', 'View wagon transfer requests'),
|
perm('e1b00001-0001-4000-8000-000000000008', 'edr_freight_app:wagons:transfer_view', 'View wagon transfer requests'),
|
||||||
perm('e1b00001-0001-4000-8000-000000000009', 'edr_freight_app:wagons:transfer_cancel', 'Withdraw a wagon transfer request'),
|
perm('e1b00001-0001-4000-8000-000000000009', 'edr_freight_app:wagons:transfer_cancel', 'Withdraw a wagon transfer request'),
|
||||||
perm('e1b00001-0001-4000-8000-00000000000a', 'edr_freight_app:wagons:transfer_close_short', 'Close a transfer request short of the requested count'),
|
perm('e1b00001-0001-4000-8000-00000000000a', 'edr_freight_app:wagons:transfer_close_short', 'Close a transfer request short of the requested count'),
|
||||||
|
perm('e1b00001-0001-4000-8000-00000000000b', 'edr_freight_app:wagons:hard_delete', 'Permanently delete wagon'),
|
||||||
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
|
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
|
||||||
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
|
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
|
||||||
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
|
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
|
||||||
@@ -257,6 +259,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('e1d00001-0001-4000-8000-000000000002', 'edr_freight_app:routes:create', 'Create route'),
|
perm('e1d00001-0001-4000-8000-000000000002', 'edr_freight_app:routes:create', 'Create route'),
|
||||||
perm('e1d00001-0001-4000-8000-000000000003', 'edr_freight_app:routes:update', 'Update route'),
|
perm('e1d00001-0001-4000-8000-000000000003', 'edr_freight_app:routes:update', 'Update route'),
|
||||||
perm('e1d00001-0001-4000-8000-000000000004', 'edr_freight_app:routes:delete', 'Delete route'),
|
perm('e1d00001-0001-4000-8000-000000000004', 'edr_freight_app:routes:delete', 'Delete route'),
|
||||||
|
perm('e1d00001-0001-4000-8000-000000000005', 'edr_freight_app:routes:hard_delete', 'Permanently delete route'),
|
||||||
perm('e1e00001-0001-4000-8000-000000000001', 'edr_freight_app:containers:view', 'View containers'),
|
perm('e1e00001-0001-4000-8000-000000000001', 'edr_freight_app:containers:view', 'View containers'),
|
||||||
perm('e1e00001-0001-4000-8000-000000000002', 'edr_freight_app:containers:create', 'Create container'),
|
perm('e1e00001-0001-4000-8000-000000000002', 'edr_freight_app:containers:create', 'Create container'),
|
||||||
perm('e1e00001-0001-4000-8000-000000000003', 'edr_freight_app:containers:update', 'Update container'),
|
perm('e1e00001-0001-4000-8000-000000000003', 'edr_freight_app:containers:update', 'Update container'),
|
||||||
@@ -530,12 +533,19 @@ export const FREIGHT_PERMS = {
|
|||||||
create: 'edr_freight_app:locomotives:create',
|
create: 'edr_freight_app:locomotives:create',
|
||||||
update: 'edr_freight_app:locomotives:update',
|
update: 'edr_freight_app:locomotives:update',
|
||||||
delete: 'edr_freight_app:locomotives:delete',
|
delete: 'edr_freight_app:locomotives:delete',
|
||||||
|
/**
|
||||||
|
* Permanently purge the row — irreversible, and separate from `delete`
|
||||||
|
* (which only decommissions) so it can be granted to far fewer people.
|
||||||
|
*/
|
||||||
|
hardDelete: 'edr_freight_app:locomotives:hard_delete',
|
||||||
},
|
},
|
||||||
wagons: {
|
wagons: {
|
||||||
view: 'edr_freight_app:wagons:view',
|
view: 'edr_freight_app:wagons:view',
|
||||||
create: 'edr_freight_app:wagons:create',
|
create: 'edr_freight_app:wagons:create',
|
||||||
update: 'edr_freight_app:wagons:update',
|
update: 'edr_freight_app:wagons:update',
|
||||||
delete: 'edr_freight_app:wagons:delete',
|
delete: 'edr_freight_app:wagons:delete',
|
||||||
|
/** Permanently purge the row — irreversible; see locomotives.hardDelete. */
|
||||||
|
hardDelete: 'edr_freight_app:wagons:hard_delete',
|
||||||
// Requester creates a transfer request; OCC fulfils it (picks the wagons and
|
// Requester creates a transfer request; OCC fulfils it (picks the wagons and
|
||||||
// executes the move). Distinct keys so OCC can hold fulfil without request.
|
// executes the move). Distinct keys so OCC can hold fulfil without request.
|
||||||
transferRequest: 'edr_freight_app:wagons:transfer_request',
|
transferRequest: 'edr_freight_app:wagons:transfer_request',
|
||||||
@@ -562,6 +572,8 @@ export const FREIGHT_PERMS = {
|
|||||||
create: 'edr_freight_app:routes:create',
|
create: 'edr_freight_app:routes:create',
|
||||||
update: 'edr_freight_app:routes:update',
|
update: 'edr_freight_app:routes:update',
|
||||||
delete: 'edr_freight_app:routes:delete',
|
delete: 'edr_freight_app:routes:delete',
|
||||||
|
/** Permanently purge the row — irreversible; see locomotives.hardDelete. */
|
||||||
|
hardDelete: 'edr_freight_app:routes:hard_delete',
|
||||||
},
|
},
|
||||||
containers: {
|
containers: {
|
||||||
view: 'edr_freight_app:containers:view',
|
view: 'edr_freight_app:containers:view',
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface FleetCardGridProps {
|
|||||||
/** Omit to hide the action (caller lacks the update/delete permission). */
|
/** Omit to hide the action (caller lacks the update/delete permission). */
|
||||||
onEdit?: (record: FleetRecord) => void;
|
onEdit?: (record: FleetRecord) => void;
|
||||||
onRemove?: (record: FleetRecord) => void;
|
onRemove?: (record: FleetRecord) => void;
|
||||||
|
/** Irreversible purge — omitted unless the caller holds the hard-delete grant. */
|
||||||
|
onPurge?: (record: FleetRecord) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FleetCardGrid = ({
|
const FleetCardGrid = ({
|
||||||
@@ -35,6 +37,7 @@ const FleetCardGrid = ({
|
|||||||
onPaginationChange,
|
onPaginationChange,
|
||||||
onEdit,
|
onEdit,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onPurge,
|
||||||
}: FleetCardGridProps) => {
|
}: FleetCardGridProps) => {
|
||||||
const presentation = resolveFleetCardPresentation(config);
|
const presentation = resolveFleetCardPresentation(config);
|
||||||
|
|
||||||
@@ -183,6 +186,7 @@ const FleetCardGrid = ({
|
|||||||
layout="compact"
|
layout="compact"
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
|
onPurge={onPurge}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { Edit2, Trash2, Eye, Users, MoreVertical, History } from "lucide-react";
|
import {
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
Users,
|
||||||
|
MoreVertical,
|
||||||
|
History,
|
||||||
|
ShieldAlert,
|
||||||
|
} from "lucide-react";
|
||||||
import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core";
|
import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
@@ -11,6 +19,8 @@ export interface FleetRecordActionsProps {
|
|||||||
/** Omit to hide the action (caller lacks the update/delete permission). */
|
/** Omit to hide the action (caller lacks the update/delete permission). */
|
||||||
onEdit?: (record: FleetRecord) => void;
|
onEdit?: (record: FleetRecord) => void;
|
||||||
onRemove?: (record: FleetRecord) => void;
|
onRemove?: (record: FleetRecord) => void;
|
||||||
|
/** Irreversible purge — omitted unless the caller holds the hard-delete grant. */
|
||||||
|
onPurge?: (record: FleetRecord) => void;
|
||||||
onAssignDriver?: (record: FleetRecord) => void;
|
onAssignDriver?: (record: FleetRecord) => void;
|
||||||
onHistory?: (record: FleetRecord) => void;
|
onHistory?: (record: FleetRecord) => void;
|
||||||
onViewDetail?: (record: FleetRecord) => void;
|
onViewDetail?: (record: FleetRecord) => void;
|
||||||
@@ -22,6 +32,7 @@ const FleetRecordActions = ({
|
|||||||
config,
|
config,
|
||||||
onEdit,
|
onEdit,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onPurge,
|
||||||
onAssignDriver,
|
onAssignDriver,
|
||||||
onHistory,
|
onHistory,
|
||||||
onViewDetail,
|
onViewDetail,
|
||||||
@@ -46,6 +57,7 @@ const FleetRecordActions = ({
|
|||||||
if (
|
if (
|
||||||
!onEdit &&
|
!onEdit &&
|
||||||
!onRemove &&
|
!onRemove &&
|
||||||
|
!onPurge &&
|
||||||
!showDetail &&
|
!showDetail &&
|
||||||
!showViewDetail &&
|
!showViewDetail &&
|
||||||
!showHistory &&
|
!showHistory &&
|
||||||
@@ -170,6 +182,15 @@ const FleetRecordActions = ({
|
|||||||
{removeLabel}
|
{removeLabel}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
{onPurge ? (
|
||||||
|
<MenuItem
|
||||||
|
color="red"
|
||||||
|
onClick={() => onPurge(record)}
|
||||||
|
leftSection={<ShieldAlert size={14} strokeWidth={2} />}
|
||||||
|
>
|
||||||
|
Delete permanently
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -122,12 +122,16 @@ export const FREIGHT_PERMS = {
|
|||||||
create: "edr_freight_app:locomotives:create",
|
create: "edr_freight_app:locomotives:create",
|
||||||
update: "edr_freight_app:locomotives:update",
|
update: "edr_freight_app:locomotives:update",
|
||||||
delete: "edr_freight_app:locomotives:delete",
|
delete: "edr_freight_app:locomotives:delete",
|
||||||
|
/** Permanent purge — irreversible, granted separately from `delete`. */
|
||||||
|
hardDelete: "edr_freight_app:locomotives:hard_delete",
|
||||||
},
|
},
|
||||||
wagons: {
|
wagons: {
|
||||||
view: "edr_freight_app:wagons:view",
|
view: "edr_freight_app:wagons:view",
|
||||||
create: "edr_freight_app:wagons:create",
|
create: "edr_freight_app:wagons:create",
|
||||||
update: "edr_freight_app:wagons:update",
|
update: "edr_freight_app:wagons:update",
|
||||||
delete: "edr_freight_app:wagons:delete",
|
delete: "edr_freight_app:wagons:delete",
|
||||||
|
/** Permanent purge — irreversible, granted separately from `delete`. */
|
||||||
|
hardDelete: "edr_freight_app:wagons:hard_delete",
|
||||||
transferRequest: "edr_freight_app:wagons:transfer_request",
|
transferRequest: "edr_freight_app:wagons:transfer_request",
|
||||||
transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
|
transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
|
||||||
transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
|
transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
|
||||||
@@ -148,6 +152,8 @@ export const FREIGHT_PERMS = {
|
|||||||
create: "edr_freight_app:routes:create",
|
create: "edr_freight_app:routes:create",
|
||||||
update: "edr_freight_app:routes:update",
|
update: "edr_freight_app:routes:update",
|
||||||
delete: "edr_freight_app:routes:delete",
|
delete: "edr_freight_app:routes:delete",
|
||||||
|
/** Permanent purge — irreversible, granted separately from `delete`. */
|
||||||
|
hardDelete: "edr_freight_app:routes:hard_delete",
|
||||||
},
|
},
|
||||||
containers: {
|
containers: {
|
||||||
view: "edr_freight_app:containers:view",
|
view: "edr_freight_app:containers:view",
|
||||||
@@ -598,6 +604,19 @@ export function canFleetAction(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanent-purge check for locomotives and wagons. Unlike
|
||||||
|
* {@link canFleetAction} this does NOT fall back to the coarse fleet:manage
|
||||||
|
* key — an irreversible delete needs its own grant, and the API guards these
|
||||||
|
* endpoints the same way.
|
||||||
|
*/
|
||||||
|
export function canFleetHardDelete(
|
||||||
|
user: AuthUser | null | undefined,
|
||||||
|
resource: "locomotives" | "wagons" | "routes",
|
||||||
|
): boolean {
|
||||||
|
return hasPermission(user, FREIGHT_PERMS[resource].hardDelete);
|
||||||
|
}
|
||||||
|
|
||||||
export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
|
export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
|
||||||
return hasPermission(user, FREIGHT_PERMS.admin);
|
return hasPermission(user, FREIGHT_PERMS.admin);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,20 +21,23 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import ReactQuill from "react-quill-new";
|
||||||
|
import "react-quill-new/dist/quill.snow.css";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
|
ArrowLeftRight,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
|
Boxes,
|
||||||
Building2,
|
Building2,
|
||||||
|
Container,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CalendarRange,
|
CalendarRange,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Coins,
|
Coins,
|
||||||
Hash,
|
Hash,
|
||||||
ListOrdered,
|
|
||||||
ListPlus,
|
|
||||||
ListTree,
|
|
||||||
Mail,
|
Mail,
|
||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
@@ -58,9 +61,10 @@ import {
|
|||||||
useUpdateContractTemplate,
|
useUpdateContractTemplate,
|
||||||
} from "@/hooks/contract-templates/useContractTemplates";
|
} from "@/hooks/contract-templates/useContractTemplates";
|
||||||
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
|
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
|
||||||
|
import { bodyToHtml, htmlToBody } from "./article-html";
|
||||||
|
|
||||||
const BODY_HINT =
|
const BODY_HINT =
|
||||||
'One clause per line. Use "New clause" for the next number (1., 2., …), "Sub-clause" for a nested number (1.1, then 1.1.1), and "Bullet" for a • point — the number or bullet is typed for you, just add the text. Placeholders are filled from the contract when the document is generated.';
|
"Enter starts a new line. Use the numbered list for clauses and Tab (or Indent) to nest — levels number 1. → a. → i. like a word processor. Numbering is assigned when the document is generated, so it always comes out sequential. Placeholders are filled from the contract.";
|
||||||
|
|
||||||
interface ArticleDraft {
|
interface ArticleDraft {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -105,6 +109,18 @@ const QUICK_PLACEHOLDERS: PlaceholderDef[] = [
|
|||||||
icon: CalendarRange,
|
icon: CalendarRange,
|
||||||
hint: "Year the contract is signed",
|
hint: "Year the contract is signed",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
token: "{{contractStartDate}}",
|
||||||
|
label: "Start date",
|
||||||
|
icon: CalendarClock,
|
||||||
|
hint: "Date the contract's validity begins",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{contractEndDate}}",
|
||||||
|
label: "End date",
|
||||||
|
icon: CalendarClock,
|
||||||
|
hint: "Date the contract's validity ends",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
|
const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
|
||||||
@@ -170,6 +186,42 @@ const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
|
|||||||
icon: Package,
|
icon: Package,
|
||||||
hint: "Description of the cargo",
|
hint: "Description of the cargo",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.cargoTypeName}}",
|
||||||
|
label: "Cargo type",
|
||||||
|
icon: Package,
|
||||||
|
hint: "Named commodity on its own, e.g. Coffee",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.containerType}}",
|
||||||
|
label: "Container type",
|
||||||
|
icon: Container,
|
||||||
|
hint: "Container size, e.g. 20ft / 40ft — dash for bulk",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.cargoSummary}}",
|
||||||
|
label: "Cargo summary",
|
||||||
|
icon: Boxes,
|
||||||
|
hint: "Every cargo line, e.g. Coffee (40ft) × 12",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.tradeDirection}}",
|
||||||
|
label: "Trade direction",
|
||||||
|
icon: ArrowLeftRight,
|
||||||
|
hint: "IMPORT / EXPORT / DOMESTIC",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.freightType}}",
|
||||||
|
label: "Freight type",
|
||||||
|
icon: Boxes,
|
||||||
|
hint: "CONTAINER or BULK",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.hazardousLabel}}",
|
||||||
|
label: "Hazardous",
|
||||||
|
icon: AlertTriangle,
|
||||||
|
hint: "Declared hazard class + UN number, or No",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
token: "{{schedule.totalWeightVgm}}",
|
token: "{{schedule.totalWeightVgm}}",
|
||||||
label: "Total weight",
|
label: "Total weight",
|
||||||
@@ -237,6 +289,22 @@ const ALL_PLACEHOLDERS: PlaceholderDef[] = [
|
|||||||
...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items),
|
...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliberately narrow toolbar: the stored body carries STRUCTURE only (clause
|
||||||
|
* depth + bullets), which is what the contract renderer numbers and lays out.
|
||||||
|
* Bold/colour/font would be dropped on save, so they are not offered —
|
||||||
|
* an author never loses formatting they were allowed to apply.
|
||||||
|
*/
|
||||||
|
const QUILL_MODULES = {
|
||||||
|
toolbar: [
|
||||||
|
[{ list: "ordered" }, { list: "bullet" }],
|
||||||
|
[{ indent: "-1" }, { indent: "+1" }],
|
||||||
|
["clean"],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUILL_FORMATS = ["list", "indent"];
|
||||||
|
|
||||||
const KNOWN_TOKENS = new Set<string>([
|
const KNOWN_TOKENS = new Set<string>([
|
||||||
...ALL_PLACEHOLDERS.map((p) => p.token),
|
...ALL_PLACEHOLDERS.map((p) => p.token),
|
||||||
// Still filled by the renderer, just no longer offered as an insert button.
|
// Still filled by the renderer, just no longer offered as an insert button.
|
||||||
@@ -282,6 +350,49 @@ function matchDepth(match: RegExpExecArray | null): number | null {
|
|||||||
/** Deepest supported sub-clause level. */
|
/** Deepest supported sub-clause level. */
|
||||||
const MAX_CLAUSE_DEPTH = 6;
|
const MAX_CLAUSE_DEPTH = 6;
|
||||||
|
|
||||||
|
/** 1 → "a", 2 → "b", … 27 → "aa". Mirrors the API's `toAlpha`. */
|
||||||
|
function toAlpha(n: number): string {
|
||||||
|
let out = "";
|
||||||
|
let value = n;
|
||||||
|
while (value > 0) {
|
||||||
|
const rem = (value - 1) % 26;
|
||||||
|
out = String.fromCharCode(97 + rem) + out;
|
||||||
|
value = Math.floor((value - 1) / 26);
|
||||||
|
}
|
||||||
|
return out || "a";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROMAN: Array<[number, string]> = [
|
||||||
|
[1000, "m"], [900, "cm"], [500, "d"], [400, "cd"],
|
||||||
|
[100, "c"], [90, "xc"], [50, "l"], [40, "xl"],
|
||||||
|
[10, "x"], [9, "ix"], [5, "v"], [4, "iv"], [1, "i"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 1 → "i", 4 → "iv". Mirrors the API's `toRoman`. */
|
||||||
|
function toRoman(n: number): string {
|
||||||
|
let value = n;
|
||||||
|
let out = "";
|
||||||
|
for (const [amount, numeral] of ROMAN) {
|
||||||
|
while (value >= amount) {
|
||||||
|
out += numeral;
|
||||||
|
value -= amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out || "i";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outline marker for a clause at its own level, cycling 1. → a. → i. by depth.
|
||||||
|
* Mirrors `clauseMarker` in the API's contract-article.util.ts — the preview
|
||||||
|
* must match the generated document exactly.
|
||||||
|
*/
|
||||||
|
function clauseMarker(counter: number, depth: number): string {
|
||||||
|
const style = (depth - 1) % 3;
|
||||||
|
if (style === 1) return toAlpha(counter);
|
||||||
|
if (style === 2) return toRoman(counter);
|
||||||
|
return String(counter);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirror of the API renderer's rules (contract-article.util.ts): one clause per
|
* Mirror of the API renderer's rules (contract-article.util.ts): one clause per
|
||||||
* line; a leading outline number ("2. ", "2.1 ") nests the line as a sub-clause
|
* line; a leading outline number ("2. ", "2.1 ") nests the line as a sub-clause
|
||||||
@@ -311,7 +422,7 @@ function parseArticleBody(body: string): ParsedBody {
|
|||||||
counters[depth - 1] += 1;
|
counters[depth - 1] += 1;
|
||||||
clauses.push({
|
clauses.push({
|
||||||
text: match ? cleaned.slice(match[0].length).trim() : cleaned,
|
text: match ? cleaned.slice(match[0].length).trim() : cleaned,
|
||||||
number: counters.slice(0, depth).join("."),
|
number: clauseMarker(counters[depth - 1], depth),
|
||||||
depth,
|
depth,
|
||||||
bullets: [],
|
bullets: [],
|
||||||
});
|
});
|
||||||
@@ -326,31 +437,6 @@ function parseArticleBody(body: string): ParsedBody {
|
|||||||
return { clauses };
|
return { clauses };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rewrite the leading outline tokens in a body so every numbered clause line
|
|
||||||
* carries its computed sequential number (stale numbers self-heal). Lines
|
|
||||||
* without a number token and bullet lines pass through untouched.
|
|
||||||
*/
|
|
||||||
function renumberBody(body: string): string {
|
|
||||||
const counters: number[] = [];
|
|
||||||
return body
|
|
||||||
.split("\n")
|
|
||||||
.map((raw) => {
|
|
||||||
const line = raw.trim();
|
|
||||||
if (!line || line.startsWith("- ")) return raw;
|
|
||||||
const match = CLAUSE_NUMBER_RE.exec(line);
|
|
||||||
let depth = matchDepth(match) ?? 1;
|
|
||||||
depth = Math.min(depth, counters.length + 1);
|
|
||||||
counters.splice(depth);
|
|
||||||
while (counters.length < depth) counters.push(0);
|
|
||||||
counters[depth - 1] += 1;
|
|
||||||
if (!match) return raw;
|
|
||||||
const number = counters.slice(0, depth).join(".");
|
|
||||||
return `${number}. ${line.slice(match[0].length).trim()}`;
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render clause text with {{placeholders}} highlighted as green chips. */
|
/** Render clause text with {{placeholders}} highlighted as green chips. */
|
||||||
function HighlightedText({ text }: { text: string }) {
|
function HighlightedText({ text }: { text: string }) {
|
||||||
const parts = text.split(/(\{\{[^{}]+\}\})/g);
|
const parts = text.split(/(\{\{[^{}]+\}\})/g);
|
||||||
@@ -674,88 +760,46 @@ function ArticleEditorModal({
|
|||||||
}: ArticleEditorModalProps) {
|
}: ArticleEditorModalProps) {
|
||||||
const [title, setTitle] = useState(initial.title);
|
const [title, setTitle] = useState(initial.title);
|
||||||
const [body, setBody] = useState(initial.body);
|
const [body, setBody] = useState(initial.body);
|
||||||
|
// Quill is uncontrolled-ish: it owns its own DOM, so seed it once from the
|
||||||
|
// stored body and let onChange convert edits back rather than re-deriving
|
||||||
|
// HTML from `body` on every keystroke (which would fight the caret).
|
||||||
|
const [html, setHtml] = useState(() => bodyToHtml(initial.body));
|
||||||
|
|
||||||
const titleRef = useRef<HTMLInputElement>(null);
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
const quillRef = useRef<ReactQuill>(null);
|
||||||
// Placeholders drop into whichever field held the cursor last (body default).
|
// Placeholders drop into whichever field held the cursor last (body default).
|
||||||
const lastFocused = useRef<"title" | "body">("body");
|
const lastFocused = useRef<"title" | "body">("body");
|
||||||
|
|
||||||
const insertAtCursor = (snippet: string) => {
|
/** Body is the source of truth for saving/preview; HTML is the editor view. */
|
||||||
const isTitle = lastFocused.current === "title";
|
const applyHtml = (nextHtml: string) => {
|
||||||
const el = isTitle ? titleRef.current : bodyRef.current;
|
setHtml(nextHtml);
|
||||||
const value = isTitle ? title : body;
|
setBody(htmlToBody(nextHtml));
|
||||||
const start = el?.selectionStart ?? value.length;
|
|
||||||
const end = el?.selectionEnd ?? start;
|
|
||||||
const next = value.slice(0, start) + snippet + value.slice(end);
|
|
||||||
if (isTitle) setTitle(next);
|
|
||||||
else setBody(next);
|
|
||||||
// Refocus and place the caret right after the inserted snippet once the
|
|
||||||
// controlled re-render has flushed.
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!el) return;
|
|
||||||
el.focus();
|
|
||||||
const caret = start + snippet.length;
|
|
||||||
el.setSelectionRange(caret, caret);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const insertAtCursor = (snippet: string) => {
|
||||||
* Insert a structured line (clause / sub-clause / bullet) on a fresh line
|
if (lastFocused.current === "title") {
|
||||||
* below the one the caret is on. Clause lines get their outline number typed
|
const el = titleRef.current;
|
||||||
* in automatically ("3. ", "3.1. ", …) and every numbered line in the body is
|
const start = el?.selectionStart ?? title.length;
|
||||||
* renumbered so the text always matches the preview.
|
const end = el?.selectionEnd ?? start;
|
||||||
*/
|
setTitle(title.slice(0, start) + snippet + title.slice(end));
|
||||||
const insertStructuredLine = (kind: "clause" | "sub" | "bullet") => {
|
requestAnimationFrame(() => {
|
||||||
const el = bodyRef.current;
|
if (!el) return;
|
||||||
lastFocused.current = "body";
|
el.focus();
|
||||||
const caret = el?.selectionStart ?? body.length;
|
const caret = start + snippet.length;
|
||||||
// Structured lines never split a sentence — insert after the caret's line.
|
el.setSelectionRange(caret, caret);
|
||||||
const lineEnd = body.indexOf("\n", caret);
|
});
|
||||||
const insertAt = lineEnd === -1 ? body.length : lineEnd;
|
return;
|
||||||
const before = body.slice(0, insertAt);
|
|
||||||
const after = body.slice(insertAt); // "" or starts with "\n"
|
|
||||||
|
|
||||||
let prefix: string;
|
|
||||||
if (kind === "bullet") {
|
|
||||||
prefix = "- ";
|
|
||||||
} else {
|
|
||||||
// New clause always starts a fresh top-level number. Sub-clause nests
|
|
||||||
// one level under a clause (1 → 1.1) but adds a SIBLING when the caret
|
|
||||||
// is already on a sub-clause (1.1 → 1.2 → 1.3, not ever-deeper) — a
|
|
||||||
// third level is reached by typing its number (e.g. "1.1.1 ") directly.
|
|
||||||
const above = parseArticleBody(before);
|
|
||||||
const lastDepth = above.paragraph
|
|
||||||
? 1
|
|
||||||
: (above.clauses[above.clauses.length - 1]?.depth ?? 0);
|
|
||||||
const depth =
|
|
||||||
kind === "sub"
|
|
||||||
? lastDepth <= 1
|
|
||||||
? Math.min(lastDepth + 1, MAX_CLAUSE_DEPTH)
|
|
||||||
: lastDepth
|
|
||||||
: 1;
|
|
||||||
// Digits are placeholders — renumberBody assigns the real value.
|
|
||||||
prefix = `${Array.from({ length: depth }, () => "1").join(".")}. `;
|
|
||||||
}
|
}
|
||||||
|
// Quill tracks its own selection; insert there so the token lands where the
|
||||||
const beforeLines = before.length > 0 ? before.split("\n") : [];
|
// author was typing instead of at the end of the document.
|
||||||
const afterLines =
|
const editor = quillRef.current?.getEditor();
|
||||||
after.length > 0 ? after.slice(1).split("\n") : [];
|
if (!editor) return;
|
||||||
const insertedIdx = beforeLines.length;
|
const range = editor.getSelection(true);
|
||||||
const joined = [...beforeLines, prefix, ...afterLines].join("\n");
|
const at = range?.index ?? editor.getLength();
|
||||||
const next = kind === "bullet" ? joined : renumberBody(joined);
|
editor.deleteText(at, range?.length ?? 0);
|
||||||
setBody(next);
|
editor.insertText(at, snippet, "user");
|
||||||
|
editor.setSelection(at + snippet.length, 0);
|
||||||
// Caret lands at the end of the inserted line, ready for typing.
|
applyHtml(editor.root.innerHTML);
|
||||||
const caretTarget = next
|
|
||||||
.split("\n")
|
|
||||||
.slice(0, insertedIdx + 1)
|
|
||||||
.join("\n").length;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const field = bodyRef.current;
|
|
||||||
if (!field) return;
|
|
||||||
field.focus();
|
|
||||||
field.setSelectionRange(caretTarget, caretTarget);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsed = useMemo(() => parseArticleBody(body), [body]);
|
const parsed = useMemo(() => parseArticleBody(body), [body]);
|
||||||
@@ -846,78 +890,24 @@ function ArticleEditorModal({
|
|||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text size="sm" fw={500} mb={4}>
|
<Text size="sm" fw={500} mb={4}>
|
||||||
Add structure
|
Article body
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap={6} wrap="wrap">
|
<Text size="xs" c="dimmed" mb={6}>
|
||||||
<Tooltip
|
{BODY_HINT}
|
||||||
label="New line with the next clause number typed for you (1., 2., 3., …)"
|
</Text>
|
||||||
withArrow
|
<Box onFocusCapture={() => (lastFocused.current = "body")}>
|
||||||
>
|
<ReactQuill
|
||||||
<Button
|
ref={quillRef}
|
||||||
variant="light"
|
theme="snow"
|
||||||
color="edr-green"
|
value={html}
|
||||||
size="compact-sm"
|
onChange={applyHtml}
|
||||||
radius="md"
|
modules={QUILL_MODULES}
|
||||||
leftSection={<ListOrdered size={13} />}
|
formats={QUILL_FORMATS}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
placeholder="Write the article — each paragraph becomes a numbered clause."
|
||||||
onClick={() => insertStructuredLine("clause")}
|
/>
|
||||||
>
|
</Box>
|
||||||
New clause
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip
|
|
||||||
label="Numbered point under the current clause — 1.1, then 1.2, 1.3 on each click. For a deeper level type its number yourself (e.g. 1.1.1 )"
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
size="compact-sm"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<ListTree size={13} />}
|
|
||||||
disabled={body.trim().length === 0}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => insertStructuredLine("sub")}
|
|
||||||
>
|
|
||||||
Sub-clause
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip
|
|
||||||
label="New line with a bullet (•) under the current clause"
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
size="compact-sm"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<ListPlus size={13} />}
|
|
||||||
disabled={body.trim().length === 0}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => insertStructuredLine("bullet")}
|
|
||||||
>
|
|
||||||
Bullet
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Textarea
|
|
||||||
ref={bodyRef}
|
|
||||||
label="Article body"
|
|
||||||
description={BODY_HINT}
|
|
||||||
value={body}
|
|
||||||
onChange={(event) => setBody(event.currentTarget.value)}
|
|
||||||
onFocus={() => (lastFocused.current = "body")}
|
|
||||||
autosize
|
|
||||||
minRows={12}
|
|
||||||
maxRows={22}
|
|
||||||
styles={{
|
|
||||||
input: { fontFamily: "ui-monospace, monospace", fontSize: 13 },
|
|
||||||
}}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
{unknown.length > 0 && (
|
{unknown.length > 0 && (
|
||||||
<Group gap={6} wrap="nowrap" align="flex-start">
|
<Group gap={6} wrap="nowrap" align="flex-start">
|
||||||
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-red-600" />
|
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-red-600" />
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { bodyToHtml, htmlToBody } from "./article-html";
|
||||||
|
|
||||||
|
describe("article body ↔ Quill HTML", () => {
|
||||||
|
it("round-trips clauses and bullets unchanged", () => {
|
||||||
|
const body = [
|
||||||
|
"Provide written instructions for each shipment.",
|
||||||
|
"Prepare all necessary documents.",
|
||||||
|
"- Commercial invoice",
|
||||||
|
"- Packing list",
|
||||||
|
"Pay 100% in advance.",
|
||||||
|
].join("\n");
|
||||||
|
expect(htmlToBody(bodyToHtml(body))).toBe(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a single paragraph a single paragraph", () => {
|
||||||
|
const body = "The contract is valid once signed by both parties.";
|
||||||
|
expect(htmlToBody(bodyToHtml(body))).toBe(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves placeholders verbatim through a round trip", () => {
|
||||||
|
const body = "Valid until August 31, {{contractYear}} for {{client.companyName}}.";
|
||||||
|
expect(htmlToBody(bodyToHtml(body))).toBe(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes and restores characters that are HTML-significant", () => {
|
||||||
|
const body = "Rates < 100 & > 50 apply to the Client's cargo.";
|
||||||
|
expect(htmlToBody(bodyToHtml(body))).toBe(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps Quill indent classes onto sub-clause depth", () => {
|
||||||
|
const html =
|
||||||
|
"<p>Top level clause.</p>" +
|
||||||
|
'<p class="ql-indent-1">Nested one level.</p>' +
|
||||||
|
'<p class="ql-indent-2">Nested two levels.</p>';
|
||||||
|
expect(htmlToBody(html)).toBe(
|
||||||
|
["Top level clause.", "1.1. Nested one level.", "1.1.1. Nested two levels."].join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns Quill bullet lists into '- ' lines", () => {
|
||||||
|
const html = "<p>Documents:</p><ul><li>Invoice</li><li>Waybill</li></ul>";
|
||||||
|
expect(htmlToBody(html)).toBe("Documents:\n- Invoice\n- Waybill");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an ordered list as clause lines, not bullets", () => {
|
||||||
|
const html = "<ol><li>First clause.</li><li>Second clause.</li></ol>";
|
||||||
|
expect(htmlToBody(html)).toBe("1. First clause.\n1. Second clause.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises the nbsp Quill inserts and drops empty blocks", () => {
|
||||||
|
const html = "<p>Payment in advance.</p><p><br></p><p></p>";
|
||||||
|
expect(htmlToBody(html)).toBe("Payment in advance.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty editor for an empty body", () => {
|
||||||
|
expect(bodyToHtml("")).toBe("<p><br></p>");
|
||||||
|
expect(htmlToBody("<p><br></p>")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Bridge between the Quill editor (HTML) and the stored article body, which is
|
||||||
|
* the structural plain text the server parses into numbered clauses
|
||||||
|
* (`parseArticleBody` in contract-article.util.ts): one clause per line, a
|
||||||
|
* leading outline token ("2.", "2.1") for depth, and "- " for bullets.
|
||||||
|
*
|
||||||
|
* Quill owns presentation; the body format owns structure. Converting on the
|
||||||
|
* way in and out keeps the renderer, the server-side renumbering, and the
|
||||||
|
* generated PDF working exactly as before.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ESCAPES: Record<string, string> = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeHtml = (text: string): string =>
|
||||||
|
text.replace(/[&<>]/g, (c) => ESCAPES[c]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body text → Quill HTML. Clauses become `<p>` carrying their outline token so
|
||||||
|
* the author sees the real numbering; bullets become a `<ul>` under the clause
|
||||||
|
* they belong to.
|
||||||
|
*/
|
||||||
|
export function bodyToHtml(body: string): string {
|
||||||
|
const lines = (body ?? "").split("\n").filter((l) => l.trim().length > 0);
|
||||||
|
if (lines.length === 0) return "<p><br></p>";
|
||||||
|
|
||||||
|
const out: string[] = [];
|
||||||
|
let inList = false;
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith("- ")) {
|
||||||
|
if (!inList) {
|
||||||
|
out.push("<ul>");
|
||||||
|
inList = true;
|
||||||
|
}
|
||||||
|
out.push(`<li>${escapeHtml(trimmed.slice(2).trim())}</li>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inList) {
|
||||||
|
out.push("</ul>");
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
out.push(`<p>${escapeHtml(trimmed)}</p>`);
|
||||||
|
}
|
||||||
|
if (inList) out.push("</ul>");
|
||||||
|
return out.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quill HTML → body text. `<li>` inside a `<ul>` becomes a "- " bullet; every
|
||||||
|
* other block becomes its own line. Quill's indent classes
|
||||||
|
* (`ql-indent-1`, …) map onto sub-clause depth, so indenting in the toolbar
|
||||||
|
* produces "1.1"-style nesting — the exact digits are placeholders, the server
|
||||||
|
* renumbers them.
|
||||||
|
*
|
||||||
|
* Runs through DOMParser rather than regex: the input is real HTML from a
|
||||||
|
* contenteditable, and entity handling (&, ) has to be right or the
|
||||||
|
* text lands in the PDF mangled.
|
||||||
|
*/
|
||||||
|
export function htmlToBody(html: string): string {
|
||||||
|
if (!html) return "";
|
||||||
|
const doc = new DOMParser().parseFromString(
|
||||||
|
`<div id="root">${html}</div>`,
|
||||||
|
"text/html",
|
||||||
|
);
|
||||||
|
const root = doc.getElementById("root");
|
||||||
|
if (!root) return "";
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
const textOf = (el: Element): string =>
|
||||||
|
// is the nbsp Quill inserts for trailing spaces — plain space in the
|
||||||
|
// stored body, otherwise it survives into the contract text.
|
||||||
|
(el.textContent ?? "").replace(/ /g, " ").trim();
|
||||||
|
|
||||||
|
const indentOf = (el: Element): number => {
|
||||||
|
const match = /ql-indent-(\d+)/.exec(el.className ?? "");
|
||||||
|
return match ? Number(match[1]) : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const walk = (node: Element, insideList: boolean) => {
|
||||||
|
for (const child of Array.from(node.children)) {
|
||||||
|
const tag = child.tagName.toLowerCase();
|
||||||
|
if (tag === "ul" || tag === "ol") {
|
||||||
|
// <ol> is authored numbering; the body format numbers clauses itself,
|
||||||
|
// so an ordered list is clause lines, not bullets.
|
||||||
|
walk(child, tag === "ul");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tag === "li") {
|
||||||
|
const text = textOf(child);
|
||||||
|
if (!text) continue;
|
||||||
|
if (insideList) {
|
||||||
|
lines.push(`- ${text}`);
|
||||||
|
} else {
|
||||||
|
const depth = indentOf(child) + 1;
|
||||||
|
lines.push(`${Array.from({ length: depth }, () => "1").join(".")}. ${text}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tag === "p" || tag === "div") {
|
||||||
|
const text = textOf(child);
|
||||||
|
if (text) {
|
||||||
|
const depth = indentOf(child);
|
||||||
|
lines.push(
|
||||||
|
depth > 0
|
||||||
|
? `${Array.from({ length: depth + 1 }, () => "1").join(".")}. ${text}`
|
||||||
|
: text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Anything else (blockquote, heading, stray span): keep its text on a
|
||||||
|
// line rather than dropping the author's words.
|
||||||
|
const text = textOf(child);
|
||||||
|
if (text) lines.push(text);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(root, false);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import type { ColumnDef } from "@edr/ui-common";
|
import type { ColumnDef } from "@edr/ui-common";
|
||||||
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core";
|
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Title } from "@mantine/core";
|
||||||
import { DatePickerInput } from "@mantine/dates";
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { canFleetAction, hasPermission, FREIGHT_PERMS } from "@/lib/permissions";
|
import {
|
||||||
|
canFleetAction,
|
||||||
|
canFleetHardDelete,
|
||||||
|
hasPermission,
|
||||||
|
FREIGHT_PERMS,
|
||||||
|
} from "@/lib/permissions";
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import { Inbox, Plus, Warehouse } from "lucide-react";
|
import { Inbox, Plus, Warehouse } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@@ -31,6 +36,7 @@ import {
|
|||||||
type FleetResourceSlug,
|
type FleetResourceSlug,
|
||||||
} from "@/pages/fleet/config/resources";
|
} from "@/pages/fleet/config/resources";
|
||||||
import {
|
import {
|
||||||
|
isFleetPurgeable,
|
||||||
isFleetServerPaginated,
|
isFleetServerPaginated,
|
||||||
type FleetListFilters,
|
type FleetListFilters,
|
||||||
type FleetRecord,
|
type FleetRecord,
|
||||||
@@ -49,6 +55,12 @@ const FleetResourcePage = () => {
|
|||||||
const canCreate = canFleetAction(user, slug, "create");
|
const canCreate = canFleetAction(user, slug, "create");
|
||||||
const canUpdate = canFleetAction(user, slug, "update");
|
const canUpdate = canFleetAction(user, slug, "update");
|
||||||
const canDelete = canFleetAction(user, slug, "delete");
|
const canDelete = canFleetAction(user, slug, "delete");
|
||||||
|
// Irreversible purge: only locomotives/wagons expose it, and it needs its own
|
||||||
|
// grant — the coarse fleet:manage key deliberately does not unlock it.
|
||||||
|
const canPurge =
|
||||||
|
isFleetPurgeable(slug) &&
|
||||||
|
(slug === "locomotives" || slug === "wagons") &&
|
||||||
|
canFleetHardDelete(user, slug);
|
||||||
// Wagon transfer workspace: shown only to holders of a transfer capability
|
// Wagon transfer workspace: shown only to holders of a transfer capability
|
||||||
// (raise a request, fulfill one, or see the cross-yard history).
|
// (raise a request, fulfill one, or see the cross-yard history).
|
||||||
const canTransfer =
|
const canTransfer =
|
||||||
@@ -71,6 +83,10 @@ const FleetResourcePage = () => {
|
|||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
||||||
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
|
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
|
||||||
|
const [purgeTarget, setPurgeTarget] = useState<FleetRecord | null>(null);
|
||||||
|
// Typing the record's own code is the confirmation — a purge cannot be undone,
|
||||||
|
// so a single misplaced click must not be enough to trigger it.
|
||||||
|
const [purgeConfirmText, setPurgeConfirmText] = useState("");
|
||||||
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
|
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
|
||||||
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
|
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
|
||||||
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
||||||
@@ -142,6 +158,7 @@ const FleetResourcePage = () => {
|
|||||||
const create = useMutation(api.fleet.create.mutationOptions());
|
const create = useMutation(api.fleet.create.mutationOptions());
|
||||||
const update = useMutation(api.fleet.update.mutationOptions());
|
const update = useMutation(api.fleet.update.mutationOptions());
|
||||||
const remove = useMutation(api.fleet.remove.mutationOptions());
|
const remove = useMutation(api.fleet.remove.mutationOptions());
|
||||||
|
const purge = useMutation(api.fleet.purge.mutationOptions());
|
||||||
|
|
||||||
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
|
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
|
||||||
api.wagonTypes.list.queryOptions(),
|
api.wagonTypes.list.queryOptions(),
|
||||||
@@ -385,6 +402,7 @@ const FleetResourcePage = () => {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onRemove={canDelete ? setRemoveTarget : undefined}
|
onRemove={canDelete ? setRemoveTarget : undefined}
|
||||||
|
onPurge={canPurge ? setPurgeTarget : undefined}
|
||||||
onAssignDriver={canUpdate ? setAssigningDriver : undefined}
|
onAssignDriver={canUpdate ? setAssigningDriver : undefined}
|
||||||
onHistory={setHistoryTarget}
|
onHistory={setHistoryTarget}
|
||||||
/>
|
/>
|
||||||
@@ -446,6 +464,37 @@ const FleetResourcePage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The code the operator must retype to confirm a purge. */
|
||||||
|
const purgeFields = purgeTarget
|
||||||
|
? (purgeTarget as unknown as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const purgeLabel = purgeFields
|
||||||
|
? String(purgeFields.wagonNumber ?? purgeFields.code ?? "")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const closePurge = () => {
|
||||||
|
setPurgeTarget(null);
|
||||||
|
setPurgeConfirmText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePurge = async () => {
|
||||||
|
if (!purgeTarget || !("id" in purgeTarget)) return;
|
||||||
|
try {
|
||||||
|
await purge.mutateAsync({ slug, id: String(purgeTarget.id) });
|
||||||
|
toast({ title: `${config.entityLabel} permanently deleted` });
|
||||||
|
closePurge();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||||
|
"Permanent delete failed";
|
||||||
|
toast({
|
||||||
|
title: "Permanent delete failed",
|
||||||
|
description: String(message),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAssignDriver = async () => {
|
const handleAssignDriver = async () => {
|
||||||
if (!assigningDriver || !("id" in assigningDriver) || !selectedDriver) return;
|
if (!assigningDriver || !("id" in assigningDriver) || !selectedDriver) return;
|
||||||
try {
|
try {
|
||||||
@@ -673,6 +722,7 @@ const FleetResourcePage = () => {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onRemove={canDelete ? setRemoveTarget : undefined}
|
onRemove={canDelete ? setRemoveTarget : undefined}
|
||||||
|
onPurge={canPurge ? setPurgeTarget : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -718,6 +768,48 @@ const FleetResourcePage = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={Boolean(purgeTarget)}
|
||||||
|
onClose={closePurge}
|
||||||
|
title={<Text fw={600}>Delete permanently</Text>}
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
This permanently removes{" "}
|
||||||
|
<Text span fw={700}>
|
||||||
|
{purgeLabel || `this ${config.entityLabel.toLowerCase()}`}
|
||||||
|
</Text>{" "}
|
||||||
|
from the database. It cannot be undone.
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Only unused records can be purged — if it has any history or is still
|
||||||
|
referenced, the request is refused and you should use{" "}
|
||||||
|
{(config.removeActionLabel ?? "Delete").toLowerCase()} instead.
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
label={`Type ${purgeLabel} to confirm`}
|
||||||
|
placeholder={purgeLabel}
|
||||||
|
value={purgeConfirmText}
|
||||||
|
onChange={(e) => setPurgeConfirmText(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={closePurge}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
loading={purge.isPending}
|
||||||
|
disabled={purgeConfirmText.trim() !== purgeLabel || !purgeLabel}
|
||||||
|
onClick={handlePurge}
|
||||||
|
>
|
||||||
|
Delete permanently
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={Boolean(assigningDriver)}
|
opened={Boolean(assigningDriver)}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Route as RouteIcon,
|
Route as RouteIcon,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
ShieldAlert,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ColumnDef } from "@edr/ui-common";
|
import type { ColumnDef } from "@edr/ui-common";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
@@ -38,7 +40,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { ruleEngineService } from "@/services/ruleEngine/ruleEngine.service";
|
import { ruleEngineService } from "@/services/ruleEngine/ruleEngine.service";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { canFleetAction } from "@/lib/permissions";
|
import { canFleetAction, canFleetHardDelete } from "@/lib/permissions";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import {
|
import {
|
||||||
formatRouteLabel,
|
formatRouteLabel,
|
||||||
@@ -168,6 +170,14 @@ export default function RoutesPage() {
|
|||||||
const canCreate = canFleetAction(user, "routes", "create");
|
const canCreate = canFleetAction(user, "routes", "create");
|
||||||
const canUpdate = canFleetAction(user, "routes", "update");
|
const canUpdate = canFleetAction(user, "routes", "update");
|
||||||
const canDelete = canFleetAction(user, "routes", "delete");
|
const canDelete = canFleetAction(user, "routes", "delete");
|
||||||
|
// Irreversible purge needs its own grant — the coarse fleet:manage key that
|
||||||
|
// canFleetAction accepts deliberately does not unlock it.
|
||||||
|
const canPurge = canFleetHardDelete(user, "routes");
|
||||||
|
// Both destructive actions confirm first: deactivate is recoverable but still
|
||||||
|
// changes what operations can book, and a purge cannot be undone at all.
|
||||||
|
const [deactivateTarget, setDeactivateTarget] = useState<RouteRecord | null>(null);
|
||||||
|
const [purgeTarget, setPurgeTarget] = useState<RouteRecord | null>(null);
|
||||||
|
const [purgeConfirmText, setPurgeConfirmText] = useState("");
|
||||||
|
|
||||||
const routesQuery = useQuery({
|
const routesQuery = useQuery({
|
||||||
...api.routes.listPaged.queryOptions({
|
...api.routes.listPaged.queryOptions({
|
||||||
@@ -202,6 +212,7 @@ export default function RoutesPage() {
|
|||||||
const createMutation = useMutation(api.routes.create.mutationOptions());
|
const createMutation = useMutation(api.routes.create.mutationOptions());
|
||||||
const updateMutation = useMutation(api.routes.update.mutationOptions());
|
const updateMutation = useMutation(api.routes.update.mutationOptions());
|
||||||
const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions());
|
const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions());
|
||||||
|
const purgeMutation = useMutation(api.routes.purge.mutationOptions());
|
||||||
|
|
||||||
// Narrowing the result set can strand the user on a page that no longer
|
// Narrowing the result set can strand the user on a page that no longer
|
||||||
// exists (search down to 3 rows while on page 5 → empty table).
|
// exists (search down to 3 rows while on page 5 → empty table).
|
||||||
@@ -353,15 +364,40 @@ export default function RoutesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeactivate = async (route: RouteRecord) => {
|
const handleDeactivate = async () => {
|
||||||
|
if (!deactivateTarget) return;
|
||||||
try {
|
try {
|
||||||
await deactivateMutation.mutateAsync(route.id);
|
await deactivateMutation.mutateAsync(deactivateTarget.id);
|
||||||
toast({ title: "Route marked stop working" });
|
toast({ title: "Route marked stop working" });
|
||||||
|
setDeactivateTarget(null);
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: "Update failed", description: "Could not update route status", variant: "destructive" });
|
toast({ title: "Update failed", description: "Could not update route status", variant: "destructive" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closePurge = () => {
|
||||||
|
setPurgeTarget(null);
|
||||||
|
setPurgeConfirmText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The label the operator must retype to confirm an irreversible purge. */
|
||||||
|
const purgeLabel = purgeTarget ? formatRouteLabel(purgeTarget) : "";
|
||||||
|
|
||||||
|
const handlePurge = async () => {
|
||||||
|
if (!purgeTarget) return;
|
||||||
|
try {
|
||||||
|
await purgeMutation.mutateAsync(purgeTarget.id);
|
||||||
|
toast({ title: "Route permanently deleted" });
|
||||||
|
closePurge();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Permanent delete failed",
|
||||||
|
description: normalizeRouteError(error),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleStatusChange = async (route: RouteRecord, status: RouteStatus) => {
|
const handleStatusChange = async (route: RouteRecord, status: RouteStatus) => {
|
||||||
try {
|
try {
|
||||||
await updateMutation.mutateAsync({ id: route.id, data: { status } });
|
await updateMutation.mutateAsync({ id: route.id, data: { status } });
|
||||||
@@ -465,17 +501,29 @@ export default function RoutesPage() {
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color="red"
|
||||||
disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending}
|
disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending}
|
||||||
onClick={() => handleDeactivate(row.original)}
|
onClick={() => setDeactivateTarget(row.original)}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canPurge ? (
|
||||||
|
<Tooltip label="Delete permanently">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
disabled={purgeMutation.isPending}
|
||||||
|
onClick={() => setPurgeTarget(row.original)}
|
||||||
|
>
|
||||||
|
<ShieldAlert size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [deactivateMutation.isPending, canUpdate, canDelete]);
|
}, [deactivateMutation.isPending, purgeMutation.isPending, canUpdate, canDelete, canPurge]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -696,6 +744,78 @@ export default function RoutesPage() {
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={Boolean(deactivateTarget)}
|
||||||
|
onClose={() => setDeactivateTarget(null)}
|
||||||
|
title={<Text fw={600}>Mark stop working</Text>}
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
Stop new operations on{" "}
|
||||||
|
<Text span fw={700}>
|
||||||
|
{deactivateTarget ? formatRouteLabel(deactivateTarget) : ""}
|
||||||
|
</Text>
|
||||||
|
? The route keeps its history and can no longer be booked.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setDeactivateTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
loading={deactivateMutation.isPending}
|
||||||
|
onClick={handleDeactivate}
|
||||||
|
>
|
||||||
|
Mark stop working
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={Boolean(purgeTarget)}
|
||||||
|
onClose={closePurge}
|
||||||
|
title={<Text fw={600}>Delete permanently</Text>}
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
This permanently removes{" "}
|
||||||
|
<Text span fw={700}>
|
||||||
|
{purgeLabel}
|
||||||
|
</Text>{" "}
|
||||||
|
and its stops from the database. It cannot be undone.
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Only unused routes can be purged — if any train schedule still
|
||||||
|
references it, the request is refused and you should mark it stop
|
||||||
|
working instead.
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
label="Type the route to confirm"
|
||||||
|
placeholder={purgeLabel}
|
||||||
|
value={purgeConfirmText}
|
||||||
|
onChange={(e) => setPurgeConfirmText(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={closePurge}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
loading={purgeMutation.isPending}
|
||||||
|
disabled={purgeConfirmText.trim() !== purgeLabel || !purgeLabel}
|
||||||
|
onClick={handlePurge}
|
||||||
|
>
|
||||||
|
Delete permanently
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={Boolean(viewing)}
|
opened={Boolean(viewing)}
|
||||||
onClose={() => setViewing(null)}
|
onClose={() => setViewing(null)}
|
||||||
|
|||||||
@@ -222,10 +222,10 @@ const RuleEngineResourcePage = () => {
|
|||||||
// instead of mutating: the rate keeps its current value until an approver
|
// instead of mutating: the rate keeps its current value until an approver
|
||||||
// applies the change. DRAFT rates still edit directly.
|
// applies the change. DRAFT rates still edit directly.
|
||||||
const isRates = config?.slug === "rates";
|
const isRates = config?.slug === "rates";
|
||||||
const [rateError, setRateError] = useState<string | null>(null);
|
// No error modal here: the workflow falls back to a toast when no handler is
|
||||||
|
// passed, which keeps failures visible without a dialog to dismiss.
|
||||||
const rateChangeWorkflow = useRateChangeWorkflow(
|
const rateChangeWorkflow = useRateChangeWorkflow(
|
||||||
Boolean(isRates && canView),
|
Boolean(isRates && canView),
|
||||||
setRateError,
|
|
||||||
);
|
);
|
||||||
const canApproveRates = Boolean(isRates && canApproveRuleEngineChange(user, "rates"));
|
const canApproveRates = Boolean(isRates && canApproveRuleEngineChange(user, "rates"));
|
||||||
/** rateId → its pending change, for the row badge. */
|
/** rateId → its pending change, for the row badge. */
|
||||||
@@ -265,8 +265,10 @@ const RuleEngineResourcePage = () => {
|
|||||||
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
|
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
|
||||||
const { data: cargoLeafOptions, isLoading: cargoLeafOptionsLoading } =
|
const { data: cargoLeafOptions, isLoading: cargoLeafOptionsLoading } =
|
||||||
useCargoLeafOptions(usesCargoTypeField);
|
useCargoLeafOptions(usesCargoTypeField);
|
||||||
|
// No "None" on rates: a rate's container scope is either a real type or the
|
||||||
|
// field is hidden entirely, so offering None only invites an unscoped rate.
|
||||||
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
|
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
|
||||||
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
|
useContainerTypeOptions(false, usesContainerTypeField);
|
||||||
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
||||||
useLiveRateOptions(usesLiveRateField);
|
useLiveRateOptions(usesLiveRateField);
|
||||||
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
||||||
@@ -695,22 +697,6 @@ const RuleEngineResourcePage = () => {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Modal
|
|
||||||
opened={rateError != null}
|
|
||||||
onClose={() => setRateError(null)}
|
|
||||||
title="Cannot save rate change"
|
|
||||||
centered
|
|
||||||
>
|
|
||||||
<Text size="sm" c="red">
|
|
||||||
{rateError}
|
|
||||||
</Text>
|
|
||||||
<Group justify="flex-end" mt="md">
|
|
||||||
<Button variant="light" onClick={() => setRateError(null)}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={priorityError != null}
|
opened={priorityError != null}
|
||||||
onClose={() => setPriorityError(null)}
|
onClose={() => setPriorityError(null)}
|
||||||
|
|||||||
@@ -24,18 +24,11 @@ function feedLabel(source: ExchangeRateSource | null): {
|
|||||||
switch (source) {
|
switch (source) {
|
||||||
case "live":
|
case "live":
|
||||||
return { live: true, text: "CBE reachable — using the live rate" };
|
return { live: true, text: "CBE reachable — using the live rate" };
|
||||||
case "cache":
|
|
||||||
return { live: true, text: "Using the rate cached from CBE" };
|
|
||||||
case "stored":
|
case "stored":
|
||||||
return {
|
return {
|
||||||
live: false,
|
live: false,
|
||||||
text: "CBE unreachable — using the fallback rate below",
|
text: "CBE unreachable — using the fallback rate below",
|
||||||
};
|
};
|
||||||
case "default":
|
|
||||||
return {
|
|
||||||
live: false,
|
|
||||||
text: "CBE unreachable and no rate stored — using the built-in default",
|
|
||||||
};
|
|
||||||
default:
|
default:
|
||||||
return { live: true, text: "No rate requested yet since the last restart" };
|
return { live: true, text: "No rate requested yet since the last restart" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1605,6 +1605,15 @@ export const api = {
|
|||||||
undefined,
|
undefined,
|
||||||
() => [["routes"]],
|
() => [["routes"]],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
/** Irreversible purge — refused while any train schedule uses the route. */
|
||||||
|
purge: endpoint<string, void>(
|
||||||
|
"routes",
|
||||||
|
"purge",
|
||||||
|
(id) => routesService.purge(id).then(() => undefined),
|
||||||
|
undefined,
|
||||||
|
() => [["routes"]],
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
stations: {
|
stations: {
|
||||||
@@ -2194,6 +2203,15 @@ export const api = {
|
|||||||
undefined,
|
undefined,
|
||||||
({ slug }) => [QUERY_KEYS.FLEET.list(slug)],
|
({ slug }) => [QUERY_KEYS.FLEET.list(slug)],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
/** Irreversible purge — locomotives and wagons only. */
|
||||||
|
purge: endpoint<{ slug: FleetResourceSlug; id: string }, unknown>(
|
||||||
|
"fleet",
|
||||||
|
"purge",
|
||||||
|
({ slug, id }) => fleetService.purge(slug, id),
|
||||||
|
undefined,
|
||||||
|
({ slug }) => [QUERY_KEYS.FLEET.list(slug)],
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
truckTypes: {
|
truckTypes: {
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import type { ApiResponse } from "@/types/apiResponse";
|
|||||||
|
|
||||||
const BASE = URL_CONSTANTS.EXCHANGE_SETTINGS.BASE;
|
const BASE = URL_CONSTANTS.EXCHANGE_SETTINGS.BASE;
|
||||||
|
|
||||||
/** Where the rate the API last served came from. */
|
/**
|
||||||
export type ExchangeRateSource = "live" | "cache" | "stored" | "default";
|
* Where the rate the API last served came from. `live` means CBE answered;
|
||||||
|
* `stored` means it is failing and the fallback is in use.
|
||||||
|
*/
|
||||||
|
export type ExchangeRateSource = "live" | "stored";
|
||||||
|
|
||||||
/** Health of the CBE exchange-rate feed. */
|
/** Health of the CBE exchange-rate feed. */
|
||||||
export interface ExchangeFeedStatus {
|
export interface ExchangeFeedStatus {
|
||||||
|
|||||||
@@ -77,6 +77,21 @@ const removeHandlers: Record<FleetResourceSlug, (id: string) => Promise<unknown>
|
|||||||
drivers: (id) => driversService.delete(id),
|
drivers: (id) => driversService.delete(id),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanent purge, only for the two slugs that expose it. Everything else stays
|
||||||
|
* soft-delete/decommission only, so there is deliberately no entry here.
|
||||||
|
*/
|
||||||
|
const purgeHandlers: Partial<
|
||||||
|
Record<FleetResourceSlug, (id: string) => Promise<unknown>>
|
||||||
|
> = {
|
||||||
|
locomotives: (id) => locomotivesService.purge(id),
|
||||||
|
wagons: (id) => wagonService.purge(id),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True when the slug supports an irreversible purge. */
|
||||||
|
export const isFleetPurgeable = (slug: FleetResourceSlug) =>
|
||||||
|
slug in purgeHandlers;
|
||||||
|
|
||||||
export const fleetService = {
|
export const fleetService = {
|
||||||
list: (slug: FleetResourceSlug, filters?: FleetListFilters) => listHandlers[slug](filters),
|
list: (slug: FleetResourceSlug, filters?: FleetListFilters) => listHandlers[slug](filters),
|
||||||
/** Only for slugs in `pagedHandlers` — guard with `isFleetServerPaginated`. */
|
/** Only for slugs in `pagedHandlers` — guard with `isFleetServerPaginated`. */
|
||||||
@@ -89,4 +104,11 @@ export const fleetService = {
|
|||||||
update: (slug: FleetResourceSlug, id: string, data: Record<string, unknown>) =>
|
update: (slug: FleetResourceSlug, id: string, data: Record<string, unknown>) =>
|
||||||
updateHandlers[slug](id, data),
|
updateHandlers[slug](id, data),
|
||||||
remove: (slug: FleetResourceSlug, id: string) => removeHandlers[slug](id),
|
remove: (slug: FleetResourceSlug, id: string) => removeHandlers[slug](id),
|
||||||
|
purge: (slug: FleetResourceSlug, id: string) => {
|
||||||
|
const handler = purgeHandlers[slug];
|
||||||
|
if (!handler) {
|
||||||
|
throw new Error(`Fleet resource "${slug}" cannot be permanently deleted`);
|
||||||
|
}
|
||||||
|
return handler(id);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -85,4 +85,7 @@ export const locomotivesService = {
|
|||||||
update: (id: string, data: Partial<SaveLocomotivePayload>) =>
|
update: (id: string, data: Partial<SaveLocomotivePayload>) =>
|
||||||
apiClient.patch(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id), data),
|
apiClient.patch(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id), data),
|
||||||
decommission: (id: string) => apiClient.post(URL_CONSTANTS.LOCOMOTIVES.DECOMMISSION(id), {}),
|
decommission: (id: string) => apiClient.post(URL_CONSTANTS.LOCOMOTIVES.DECOMMISSION(id), {}),
|
||||||
|
/** Irreversible purge — the API refuses it while any train references the loco. */
|
||||||
|
purge: (id: string) =>
|
||||||
|
apiClient.delete(`${URL_CONSTANTS.LOCOMOTIVES.BY_ID(id)}/permanent`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ export const routesService = {
|
|||||||
update: (id: string, data: Partial<SaveRoutePayload>) =>
|
update: (id: string, data: Partial<SaveRoutePayload>) =>
|
||||||
apiClient.patch(URL_CONSTANTS.ROUTES.BY_ID(id), data),
|
apiClient.patch(URL_CONSTANTS.ROUTES.BY_ID(id), data),
|
||||||
deactivate: (id: string) => apiClient.delete(URL_CONSTANTS.ROUTES.BY_ID(id)),
|
deactivate: (id: string) => apiClient.delete(URL_CONSTANTS.ROUTES.BY_ID(id)),
|
||||||
|
/** Irreversible purge — the API refuses it while any train schedule uses the route. */
|
||||||
|
purge: (id: string) =>
|
||||||
|
apiClient.delete(`${URL_CONSTANTS.ROUTES.BY_ID(id)}/permanent`),
|
||||||
/** All active yards (page-walked — the yards list API caps pageSize at 100). */
|
/** All active yards (page-walked — the yards list API caps pageSize at 100). */
|
||||||
getYards: async (): Promise<YardRef[]> => {
|
getYards: async (): Promise<YardRef[]> => {
|
||||||
const rows = await ruleEngineService.listAll("yards", { isActive: true });
|
const rows = await ruleEngineService.listAll("yards", { isActive: true });
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ export const wagonService = {
|
|||||||
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
|
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
|
||||||
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
|
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
|
||||||
delete: (id: string) => apiClient.delete(`/wagons/${id}`),
|
delete: (id: string) => apiClient.delete(`/wagons/${id}`),
|
||||||
|
/** Irreversible purge — the API refuses it when the wagon has any history. */
|
||||||
|
purge: (id: string) => apiClient.delete(`/wagons/${id}/permanent`),
|
||||||
/** Relocate many wagons to one yard in a single call (writes movement ledger). */
|
/** Relocate many wagons to one yard in a single call (writes movement ledger). */
|
||||||
bulkTransfer: (wagonIds: string[], toYardId: string) =>
|
bulkTransfer: (wagonIds: string[], toYardId: string) =>
|
||||||
apiClient.post<{ moved: number }>('/wagons/bulk-transfer', { wagonIds, toYardId }),
|
apiClient.post<{ moved: number }>('/wagons/bulk-transfer', { wagonIds, toYardId }),
|
||||||
|
|||||||
Reference in New Issue
Block a user