mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Merge pull request #1461 from Tria-plc/freight_feature/usermanagement
feat(contracts): implement staff cancellation of contracts with reaso…
This commit is contained in:
@@ -176,6 +176,18 @@ export class ContractNotifierService {
|
|||||||
this.inApp(c, 'Contract suspension lifted', msg);
|
this.inApp(c, 'Contract suspension lifted', msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backoffice cancelled the contract. Terminal — the customer is told they may
|
||||||
|
* submit a new contract with the same details if they still need the service.
|
||||||
|
*/
|
||||||
|
cancelledByStaff(c: Contract, reason: string): void {
|
||||||
|
const msg =
|
||||||
|
`Your contract ${c.reference} has been cancelled. Reason: ${reason}. ` +
|
||||||
|
`If you still need this service you can submit a new contract request with the same details.`;
|
||||||
|
void this.notifyContact(c, msg, 'CANCELLED');
|
||||||
|
this.inApp(c, 'Contract cancelled', msg);
|
||||||
|
}
|
||||||
|
|
||||||
/** Customer cancelled their own contract — staff-side record. */
|
/** Customer cancelled their own contract — staff-side record. */
|
||||||
cancelledByCustomer(c: Contract, reason: string): void {
|
cancelledByCustomer(c: Contract, reason: string): void {
|
||||||
this.inAppStaff(
|
this.inAppStaff(
|
||||||
|
|||||||
@@ -120,3 +120,34 @@ describe('contract base freight is priced on the contract lane only', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('contract base freight ignores shipping-line rates', () => {
|
||||||
|
it("never prices a customer contract off a line's negotiated rate (CTR-2026-00049)", async () => {
|
||||||
|
// Both LIVE on the contract's own lane: the line rate sorted first and won,
|
||||||
|
// so the contract quoted 32 USD/wagon instead of the standard 1690.
|
||||||
|
const breakdown = await service([
|
||||||
|
rate({
|
||||||
|
containerTypeId: CT20,
|
||||||
|
rateValue: 32,
|
||||||
|
rateUnit: 'PER_WAGON',
|
||||||
|
shippingLineCompanyId: 'line-1',
|
||||||
|
}),
|
||||||
|
rate({ containerTypeId: CT20, rateValue: 1690, rateUnit: 'PER_WAGON' }),
|
||||||
|
]).buildBreakdown(contract({}));
|
||||||
|
expect(breakdown.lineItems).toEqual([
|
||||||
|
expect.objectContaining({ code: 'CONTAINER_20FT', unitPrice: 1690 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks when the only rate on the lane belongs to a shipping line', async () => {
|
||||||
|
await expect(
|
||||||
|
service([
|
||||||
|
rate({
|
||||||
|
containerTypeId: CT20,
|
||||||
|
rateValue: 32,
|
||||||
|
shippingLineCompanyId: 'line-1',
|
||||||
|
}),
|
||||||
|
]).buildBreakdown(contract({})),
|
||||||
|
).rejects.toThrow(UnprocessableEntityException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -85,7 +85,15 @@ export class ContractPricingService {
|
|||||||
* commodity rate) — NO totals or quantities (doc §9.1).
|
* commodity rate) — NO totals or quantities (doc §9.1).
|
||||||
*/
|
*/
|
||||||
async buildBreakdown(contract: Contract): Promise<ContractPricingBreakdown> {
|
async buildBreakdown(contract: Contract): Promise<ContractPricingBreakdown> {
|
||||||
const liveRates = await this.ratesService.findLiveRates();
|
// Contracts belong to a customer company — there is no shipping-line
|
||||||
|
// contract (no shipping_line_company_id on the entity), so a contract may
|
||||||
|
// only ever price off the standard rates. Without this filter a line's
|
||||||
|
// negotiated rate on the same lane matched first and the contract froze it
|
||||||
|
// for a customer: CTR-2026-00049 quoted a line's 32 USD/wagon 20ft and
|
||||||
|
// 23 USD/container 40ft instead of the standard 1690 / 1676.
|
||||||
|
const liveRates = (await this.ratesService.findLiveRates()).filter(
|
||||||
|
(r) => !r.shippingLineCompanyId,
|
||||||
|
);
|
||||||
const currency = contract.paymentCurrency;
|
const currency = contract.paymentCurrency;
|
||||||
const isEtb = currency === 'ETB';
|
const isEtb = currency === 'ETB';
|
||||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
|
import type { Contract } from './entities/contract.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff cancel is terminal, so the rules that matter are: it needs its own
|
||||||
|
* permission (suspend must NOT imply it), it refuses to strand live shipments,
|
||||||
|
* it works on a suspended contract, and it cannot be applied twice.
|
||||||
|
*/
|
||||||
|
describe('ContractTransitionService — staff cancel', () => {
|
||||||
|
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||||
|
({
|
||||||
|
id: 'c-1',
|
||||||
|
reference: 'CTR-2026-00042',
|
||||||
|
companyId: 'co-1',
|
||||||
|
status: 'CONTRACT_ACTIVE',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
...over,
|
||||||
|
}) as Contract;
|
||||||
|
|
||||||
|
let current: Contract;
|
||||||
|
let repo: {
|
||||||
|
update: jest.Mock;
|
||||||
|
createReviewNote: jest.Mock;
|
||||||
|
countActiveBookings: jest.Mock;
|
||||||
|
};
|
||||||
|
let notifier: { cancelledByStaff: jest.Mock };
|
||||||
|
let service: ContractTransitionService;
|
||||||
|
|
||||||
|
const staff = {
|
||||||
|
permissions: [{ key: 'edr_freight_app:contracts:cancel' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
current = contract();
|
||||||
|
repo = {
|
||||||
|
update: jest.fn().mockImplementation((_id: string, patch: object) => {
|
||||||
|
current = { ...current, ...patch } as Contract;
|
||||||
|
return Promise.resolve(current);
|
||||||
|
}),
|
||||||
|
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||||
|
countActiveBookings: jest.fn().mockResolvedValue(0),
|
||||||
|
};
|
||||||
|
notifier = { cancelledByStaff: jest.fn() };
|
||||||
|
service = Object.create(
|
||||||
|
ContractTransitionService.prototype,
|
||||||
|
) as ContractTransitionService;
|
||||||
|
Object.assign(service, {
|
||||||
|
contractsRepository: repo,
|
||||||
|
contractsService: { findById: () => Promise.resolve(current) },
|
||||||
|
notifier,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels, records the reason as a staff note, and notifies the customer', async () => {
|
||||||
|
await service.cancelByStaff('c-1', 'Duplicate request', 'staff-1', staff as never);
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenCalledWith('c-1', {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
statusBeforeSuspension: null,
|
||||||
|
});
|
||||||
|
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
||||||
|
'c-1',
|
||||||
|
'Duplicate request',
|
||||||
|
'CANCELLATION',
|
||||||
|
'staff-1',
|
||||||
|
'STAFF',
|
||||||
|
);
|
||||||
|
expect(notifier.cancelledByStaff).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels a suspended contract — freezing it is exactly when staff kill it', async () => {
|
||||||
|
current = contract({
|
||||||
|
status: 'SUSPENDED',
|
||||||
|
statusBeforeSuspension: 'CONTRACT_ACTIVE',
|
||||||
|
} as Partial<Contract>);
|
||||||
|
|
||||||
|
await service.cancelByStaff('c-1', 'Customer withdrew', 'staff-1', staff as never);
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenCalledWith('c-1', {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
statusBeforeSuspension: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses while a shipment is still running', async () => {
|
||||||
|
repo.countActiveBookings.mockResolvedValue(2);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.cancelByStaff('c-1', 'Change of plan', 'staff-1', staff as never),
|
||||||
|
).rejects.toThrow('2 active shipments');
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to cancel an already-terminal contract', async () => {
|
||||||
|
current = contract({ status: 'CANCELLED' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.cancelByStaff('c-1', 'Again', 'staff-1', staff as never),
|
||||||
|
).rejects.toThrow(/already cancelled/i);
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a user holding only the suspend key — cancel is a separate permission', async () => {
|
||||||
|
const suspender = {
|
||||||
|
permissions: [{ key: 'edr_freight_app:contracts:suspend' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.cancelByStaff('c-1', 'Not allowed', 'staff-1', suspender as never),
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1452,6 +1452,54 @@ export class ContractTransitionService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff cancel — terminal, unlike suspend. The contract is dead; a fresh one
|
||||||
|
* with the same parameters can be submitted afterwards (references are minted
|
||||||
|
* per contract, so nothing about the old row blocks the new one).
|
||||||
|
*
|
||||||
|
* Cancellable from ANY non-terminal status, including SUSPENDED: a frozen
|
||||||
|
* contract is exactly the one staff most often need to kill outright.
|
||||||
|
*/
|
||||||
|
async cancelByStaff(
|
||||||
|
contractId: string,
|
||||||
|
reason: string,
|
||||||
|
actorId: string,
|
||||||
|
user?: TCurrentUser | null,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertFreightPermission(user, FREIGHT_PERMS.contracts.cancel);
|
||||||
|
if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Contract is already ${contract.status.toLowerCase().replace(/_/g, ' ')}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same guard as the customer path: live shipments must be settled first,
|
||||||
|
// otherwise cancelling the contract orphans cargo already in motion.
|
||||||
|
const active = await this.contractsRepository.countActiveBookings(contractId);
|
||||||
|
if (active > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`This contract has ${active} active shipment${active === 1 ? '' : 's'}. ` +
|
||||||
|
'Cancel or complete them before cancelling the contract.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.contractsRepository.createReviewNote(
|
||||||
|
contractId,
|
||||||
|
reason,
|
||||||
|
'CANCELLATION',
|
||||||
|
actorId,
|
||||||
|
'STAFF',
|
||||||
|
);
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
statusBeforeSuspension: null,
|
||||||
|
} as never);
|
||||||
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.cancelledByStaff(updated, reason);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async renew(contractId: string, userId?: string): Promise<Contract> {
|
async renew(contractId: string, userId?: string): Promise<Contract> {
|
||||||
const source = await this.contractsService.findById(contractId);
|
const source = await this.contractsService.findById(contractId);
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import {
|
|||||||
RequestChangesDto,
|
RequestChangesDto,
|
||||||
ResumeContractDto,
|
ResumeContractDto,
|
||||||
SuspendContractDto,
|
SuspendContractDto,
|
||||||
|
CancelContractByStaffDto,
|
||||||
} from './dto/approve-step.dto';
|
} from './dto/approve-step.dto';
|
||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||||
@@ -552,6 +553,25 @@ export class ContractsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/staff/cancel')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.cancel)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Staff cancel a contract (terminal — a new contract with the same details may be submitted after)',
|
||||||
|
})
|
||||||
|
cancelByStaff(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: CancelContractByStaffDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.transitionService.cancelByStaff(
|
||||||
|
id,
|
||||||
|
dto.reason,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/approval-steps/:stepId/approve')
|
@Post(':id/approval-steps/:stepId/approve')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.view)
|
@BookingStaff(FREIGHT_PERMS.contracts.view)
|
||||||
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ export class CancelContractDto {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Staff cancel is terminal, so the reason is mandatory — it is the audit record. */
|
||||||
|
export class CancelContractByStaffDto {
|
||||||
|
@ApiProperty({ description: 'Why the contract is being cancelled — shown to the customer' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class SuspendContractDto {
|
export class SuspendContractDto {
|
||||||
@ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' })
|
@ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' })
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -465,6 +465,13 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:contracts:suspend",
|
"edr_freight_app:contracts:suspend",
|
||||||
"Suspend / resume a signed contract",
|
"Suspend / resume a signed contract",
|
||||||
),
|
),
|
||||||
|
// Terminal kill switch. Unlike suspend this cannot be undone — the customer
|
||||||
|
// re-submits a fresh contract with the same parameters instead.
|
||||||
|
perm(
|
||||||
|
"a3000001-0001-4000-8000-00000000001c",
|
||||||
|
"edr_freight_app:contracts:cancel",
|
||||||
|
"Cancel a contract (terminal)",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and
|
// Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and
|
||||||
@@ -2066,6 +2073,7 @@ export const FREIGHT_PERMS = {
|
|||||||
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
||||||
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
||||||
suspend: "edr_freight_app:contracts:suspend",
|
suspend: "edr_freight_app:contracts:suspend",
|
||||||
|
cancel: "edr_freight_app:contracts:cancel",
|
||||||
editDocument: "edr_freight_app:contracts:edit_document",
|
editDocument: "edr_freight_app:contracts:edit_document",
|
||||||
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
||||||
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
||||||
@@ -2876,6 +2884,9 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.contracts.generateContract,
|
FREIGHT_PERMS.contracts.generateContract,
|
||||||
...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff),
|
...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff),
|
||||||
FREIGHT_PERMS.contracts.suspend,
|
FREIGHT_PERMS.contracts.suspend,
|
||||||
|
// Terminal kill switch, granted alongside suspend on the same desk that
|
||||||
|
// already rejects contracts and cancels bookings.
|
||||||
|
FREIGHT_PERMS.contracts.cancel,
|
||||||
FREIGHT_PERMS.contracts.editDocument,
|
FREIGHT_PERMS.contracts.editDocument,
|
||||||
...BOOKING_DESK_NOTIFICATION_KEYS,
|
...BOOKING_DESK_NOTIFICATION_KEYS,
|
||||||
// Marketing follows up with the customer when a reviewer sends profile
|
// Marketing follows up with the customer when a reviewer sends profile
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
Ban,
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
// FilePen, // ponytail: back with the "Edit contract articles" button
|
// FilePen, // ponytail: back with the "Edit contract articles" button
|
||||||
@@ -81,6 +82,8 @@ export function ContractActionsToolbar({
|
|||||||
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
|
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
|
||||||
// One key both ways — whoever can freeze a contract can unfreeze it.
|
// One key both ways — whoever can freeze a contract can unfreeze it.
|
||||||
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend);
|
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend);
|
||||||
|
// Cancel is its own key — it is terminal, so it is NOT implied by suspend.
|
||||||
|
const mayCancel = hasPermission(user, FREIGHT_PERMS.contracts.cancel);
|
||||||
|
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||||
@@ -93,6 +96,67 @@ export function ContractActionsToolbar({
|
|||||||
const [suspendReason, setSuspendReason] = useState("");
|
const [suspendReason, setSuspendReason] = useState("");
|
||||||
const [resumeOpen, setResumeOpen] = useState(false);
|
const [resumeOpen, setResumeOpen] = useState(false);
|
||||||
const [resumeNote, setResumeNote] = useState("");
|
const [resumeNote, setResumeNote] = useState("");
|
||||||
|
const [cancelOpen, setCancelOpen] = useState(false);
|
||||||
|
const [cancelReason, setCancelReason] = useState("");
|
||||||
|
|
||||||
|
// Shared by the suspended branch and the normal toolbar — both can cancel.
|
||||||
|
const cancelModal = (
|
||||||
|
<Modal
|
||||||
|
opened={cancelOpen}
|
||||||
|
onClose={() => setCancelOpen(false)}
|
||||||
|
title="Cancel this contract?"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
Contract <b>{contract.reference}</b> will be cancelled permanently.
|
||||||
|
This cannot be undone — there is no way to reactivate it. A new
|
||||||
|
contract with the same details can be submitted afterwards. The
|
||||||
|
customer is notified.
|
||||||
|
</Text>
|
||||||
|
<Textarea
|
||||||
|
label="Reason for cancellation"
|
||||||
|
placeholder="Explain why this contract is being cancelled…"
|
||||||
|
autosize
|
||||||
|
minRows={3}
|
||||||
|
value={cancelReason}
|
||||||
|
onChange={(e) => setCancelReason(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={() => setCancelOpen(false)}>
|
||||||
|
Keep contract
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
disabled={!cancelReason.trim()}
|
||||||
|
loading={mutations.cancelByStaff.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
mutations.cancelByStaff.mutate(cancelReason, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setCancelOpen(false);
|
||||||
|
setCancelReason("");
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Cancel contract
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelButton = mayCancel ? (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
leftSection={<Ban size={16} />}
|
||||||
|
onClick={() => setCancelOpen(true)}
|
||||||
|
>
|
||||||
|
Cancel contract
|
||||||
|
</Button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
// Whether the document is editable depends on WHO is viewing — only the
|
// Whether the document is editable depends on WHO is viewing — only the
|
||||||
// approver whose turn it is may edit — so the server decides, not the client.
|
// approver whose turn it is may edit — so the server decides, not the client.
|
||||||
@@ -126,9 +190,13 @@ export function ContractActionsToolbar({
|
|||||||
if (status === "CHANGES_REQUESTED") {
|
if (status === "CHANGES_REQUESTED") {
|
||||||
return (
|
return (
|
||||||
<SectionCard icon={Zap} title="Awaiting customer">
|
<SectionCard icon={Zap} title="Awaiting customer">
|
||||||
<Text size="sm" c="dimmed">
|
<Stack gap="sm">
|
||||||
No staff actions until the customer resubmits the contract.
|
<Text size="sm" c="dimmed">
|
||||||
</Text>
|
No staff actions until the customer resubmits the contract.
|
||||||
|
</Text>
|
||||||
|
{cancelButton}
|
||||||
|
</Stack>
|
||||||
|
{cancelModal}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -166,6 +234,7 @@ export function ContractActionsToolbar({
|
|||||||
You do not have permission to lift a suspension.
|
You do not have permission to lift a suspension.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{cancelButton}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -209,6 +278,8 @@ export function ContractActionsToolbar({
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{cancelModal}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -362,11 +433,16 @@ export function ContractActionsToolbar({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Available at every non-terminal status — the early returns above
|
||||||
|
already cover the statuses where cancelling makes no sense. */}
|
||||||
|
{cancelButton}
|
||||||
|
|
||||||
{!canAccept &&
|
{!canAccept &&
|
||||||
!inApproval &&
|
!inApproval &&
|
||||||
!canViewContract &&
|
!canViewContract &&
|
||||||
!canReviewClearance &&
|
!canReviewClearance &&
|
||||||
!canSuspend && (
|
!canSuspend &&
|
||||||
|
!mayCancel && (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
No staff actions available for this status. Monitor until the
|
No staff actions available for this status. Monitor until the
|
||||||
workflow advances.
|
workflow advances.
|
||||||
@@ -515,6 +591,8 @@ export function ContractActionsToolbar({
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{cancelModal}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ export const URL_CONSTANTS = {
|
|||||||
STAFF_REQUEST_CHANGES: (id: string) =>
|
STAFF_REQUEST_CHANGES: (id: string) =>
|
||||||
`/contracts/${id}/staff/request-changes`,
|
`/contracts/${id}/staff/request-changes`,
|
||||||
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
|
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
|
||||||
|
STAFF_CANCEL: (id: string) => `/contracts/${id}/staff/cancel`,
|
||||||
SUSPEND: (id: string) => `/contracts/${id}/suspend`,
|
SUSPEND: (id: string) => `/contracts/${id}/suspend`,
|
||||||
RESUME: (id: string) => `/contracts/${id}/resume`,
|
RESUME: (id: string) => `/contracts/${id}/resume`,
|
||||||
APPROVE_STEP: (id: string, stepId: string) =>
|
APPROVE_STEP: (id: string, stepId: string) =>
|
||||||
|
|||||||
@@ -151,6 +151,14 @@ export function useContractMutations(contractId: string) {
|
|||||||
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject contract")),
|
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject contract")),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cancelByStaff = useMutation({
|
||||||
|
mutationFn: (reason: string) =>
|
||||||
|
contractsService.cancelByStaff(contractId, reason),
|
||||||
|
onSuccess: (data) => onSuccess(data, "Contract cancelled"),
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(extractErrorMessage(error, "Failed to cancel contract")),
|
||||||
|
});
|
||||||
|
|
||||||
const suspend = useMutation({
|
const suspend = useMutation({
|
||||||
mutationFn: (reason: string) => contractsService.suspend(contractId, reason),
|
mutationFn: (reason: string) => contractsService.suspend(contractId, reason),
|
||||||
onSuccess: (data) => onSuccess(data, "Contract suspended"),
|
onSuccess: (data) => onSuccess(data, "Contract suspended"),
|
||||||
@@ -269,6 +277,7 @@ export function useContractMutations(contractId: string) {
|
|||||||
updateDocument,
|
updateDocument,
|
||||||
requestChanges,
|
requestChanges,
|
||||||
reject,
|
reject,
|
||||||
|
cancelByStaff,
|
||||||
suspend,
|
suspend,
|
||||||
resume,
|
resume,
|
||||||
approveStep,
|
approveStep,
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export const FREIGHT_PERMS = {
|
|||||||
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
|
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
|
||||||
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
||||||
suspend: "edr_freight_app:contracts:suspend",
|
suspend: "edr_freight_app:contracts:suspend",
|
||||||
|
cancel: "edr_freight_app:contracts:cancel",
|
||||||
editDocument: "edr_freight_app:contracts:edit_document",
|
editDocument: "edr_freight_app:contracts:edit_document",
|
||||||
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
||||||
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
||||||
|
|||||||
@@ -288,6 +288,13 @@ export const contractsService = {
|
|||||||
reject: (id: string, reason: string) =>
|
reject: (id: string, reason: string) =>
|
||||||
postContract<Freight.IContract>(C.STAFF_REJECT(id), { reason }),
|
postContract<Freight.IContract>(C.STAFF_REJECT(id), { reason }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a contract outright. Terminal — unlike {@link suspend} there is no
|
||||||
|
* way back; a new contract with the same details must be submitted instead.
|
||||||
|
*/
|
||||||
|
cancelByStaff: (id: string, reason: string) =>
|
||||||
|
postContract<Freight.IContract>(C.STAFF_CANCEL(id), { reason }),
|
||||||
|
|
||||||
/** Freeze a signed contract. Reversible — see {@link resume}. */
|
/** Freeze a signed contract. Reversible — see {@link resume}. */
|
||||||
suspend: (id: string, reason: string) =>
|
suspend: (id: string, reason: string) =>
|
||||||
postContract<Freight.IContract>(C.SUSPEND(id), { reason }),
|
postContract<Freight.IContract>(C.SUSPEND(id), { reason }),
|
||||||
|
|||||||
Reference in New Issue
Block a user