diff --git a/apps/edr-freight-api/src/migrations/2000000000000-AddTruckDetentionTiming.ts b/apps/edr-freight-api/src/migrations/2000000000000-AddTruckDetentionTiming.ts new file mode 100644 index 000000000..ce7bfd326 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2000000000000-AddTruckDetentionTiming.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Truck detention support. + * - last_mile.arrived_at / delivered_at: the detention window for an EDR + * last-mile vehicle. The clock runs from arrival at destination; the customer + * has a grace period (default 3h) to clear/return, after which detention + * accrues per truck per day until delivered_at (or now, if still out). + * - warehouse_fee_rules.free_hours: configurable grace window (hours) for a + * TRUCK_DETENTION_FEE rule; null/0 falls back to the 3-hour default. + */ +export class AddTruckDetentionTiming2000000000000 implements MigrationInterface { + name = 'AddTruckDetentionTiming2000000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.last_mile ADD COLUMN IF NOT EXISTS arrived_at timestamptz`, + ); + await queryRunner.query( + `ALTER TABLE freight.last_mile ADD COLUMN IF NOT EXISTS delivered_at timestamptz`, + ); + await queryRunner.query( + `ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS free_hours int`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS free_hours`); + await queryRunner.query(`ALTER TABLE freight.last_mile DROP COLUMN IF EXISTS delivered_at`); + await queryRunner.query(`ALTER TABLE freight.last_mile DROP COLUMN IF EXISTS arrived_at`); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 9cf06c905..9f12b937d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -250,6 +250,44 @@ export class ContractTransitionService { return updated; } + /** + * Reject one approval step (line staff / director / CEO). The rejecting + * approver must supply a reason. A rejection is terminal: the whole contract + * moves to REJECTED and the customer must create a new one — there is no + * resubmit of the same contract. The reason is recorded both on the step and + * as a REJECTION review note so it is visible to the customer and the rest of + * the approval chain. + */ + async rejectStep( + contractId: string, + stepId: string, + actorId: string, + reason: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); + + const step = await this.contractsRepository.findApprovalStepById(contractId, stepId); + if (!step) throw new BadRequestException('Approval step not found'); + + await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason); + + await this.contractsRepository.createReviewNote( + contractId, + reason, + 'REJECTION', + actorId, + 'STAFF', + ); + + await this.contractsRepository.update(contractId, { + status: 'REJECTED', + } as never); + const updated = await this.contractsService.findById(contractId); + this.notifier.rejected(updated, reason); + return updated; + } + /** Approve one approval step in sequence; → APPROVED when all complete. */ async approveStep( contractId: string, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 8591a54d8..4ea7634b6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -60,6 +60,7 @@ import { AcceptContractDto } from './dto/accept-contract.dto'; import { ApproveStepDto, RejectContractDto, + RejectStepDto, RequestChangesDto, } from './dto/approve-step.dto'; import { SignContractDto } from './dto/sign-contract.dto'; @@ -390,6 +391,27 @@ export class ContractsController { ); } + @Post(':id/approval-steps/:stepId/reject') + @BookingStaff([ + FREIGHT_PERMS.contracts.approveLineStaff, + FREIGHT_PERMS.contracts.approveDirector, + FREIGHT_PERMS.contracts.approveCeo, + ]) + @ApiOperation({ summary: 'Reject one approval step (terminal → REJECTED)' }) + rejectStep( + @Param('id', ParseUUIDPipe) id: string, + @Param('stepId', ParseUUIDPipe) stepId: string, + @Body() dto: RejectStepDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.transitionService.rejectStep( + id, + stepId, + resolveAuthUserId(user), + dto.reason, + ); + } + @Post(':id/contract/generate') @BookingStaff(FREIGHT_PERMS.contracts.generateContract) @ApiOperation({ summary: 'Generate contract document → CONTRACT_READY' }) diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/update-last-mile.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/update-last-mile.dto.ts index 9d0c4262d..405273a54 100644 --- a/apps/edr-freight-api/src/modules/last-mile/dto/update-last-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile/dto/update-last-mile.dto.ts @@ -1,5 +1,18 @@ -import { PartialType } from '@nestjs/mapped-types'; +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsISO8601, IsOptional } from 'class-validator'; import { CreateLastMileDto } from './create-last-mile.dto'; -export class UpdateLastMileDto extends PartialType(CreateLastMileDto) {} +export class UpdateLastMileDto extends PartialType(CreateLastMileDto) { + /** Truck-detention clock start (vehicle arrived at destination). Overrides the auto-stamp. */ + @ApiPropertyOptional({ description: 'Vehicle arrival time (ISO 8601) — detention clock start.' }) + @IsOptional() + @IsISO8601() + arrivedAt?: string; + + /** Truck-detention clock end (cargo cleared / vehicle returned). Overrides the auto-stamp. */ + @ApiPropertyOptional({ description: 'Delivery/return time (ISO 8601) — detention clock end.' }) + @IsOptional() + @IsISO8601() + deliveredAt?: string; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 1f8bda8fc..5dca86cc4 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -30,6 +30,15 @@ export class LastMile extends BaseEntity { @Column({ name: 'status', type: 'varchar', length: 30, default: 'PAYMENT_PENDING' }) status!: LastMileStatus; + // Truck-detention window. arrivedAt = vehicle reached destination (IN_TRANSIT); + // deliveredAt = cargo cleared / vehicle returned (DELIVERED). Detention accrues + // between them beyond the rule's grace hours (default 3h), per truck per day. + @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) + arrivedAt?: Date | null; + + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt?: Date | null; + @Column({ name: 'advanced_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) advancedPayment!: number; diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 5876f7c05..15b8df3e7 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -282,6 +282,17 @@ export class LastMileService { ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), ...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}), + // Truck-detention clock: stamp arrival when the vehicle goes IN_TRANSIT and + // delivery when it reaches DELIVERED (first time only). Explicit dto values + // below override the auto-stamp so staff can record the real times. + ...(dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT' && !existing.arrivedAt + ? { arrivedAt: new Date() } + : {}), + ...(dto.status === 'DELIVERED' && existing.status !== 'DELIVERED' && !existing.deliveredAt + ? { deliveredAt: new Date() } + : {}), + ...(dtoAny.arrivedAt !== undefined ? { arrivedAt: dtoAny.arrivedAt ? new Date(dtoAny.arrivedAt) : null } : {}), + ...(dtoAny.deliveredAt !== undefined ? { deliveredAt: dtoAny.deliveredAt ? new Date(dtoAny.deliveredAt) : null } : {}), } as any); if (!updated) { diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts index 1213b1177..680eb2591 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -87,6 +87,12 @@ export class CreateFeeRuleDto { @Min(0) ratePerDay!: number; + @ApiPropertyOptional({ description: 'Truck detention only: grace window in hours (default 3).' }) + @IsOptional() + @IsInt() + @Min(0) + freeHours?: number; + @ApiPropertyOptional({ enum: FEE_RULE_BASES, description: 'Double-handling charge basis: PER_CONTAINER | PER_TON | PER_ITEM.', diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts index a8bc7bf23..f6ad7ec8f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts @@ -71,6 +71,11 @@ export class WarehouseFeeRule extends BaseEntity { @Column({ name: 'free_days', type: 'int', default: 0 }) freeDays!: number; + // Truck detention only: grace window in HOURS before detention accrues + // (contract default 3h). Null/0 → the 3-hour default. + @Column({ name: 'free_hours', type: 'int', nullable: true }) + freeHours?: number | null; + @Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 }) ratePerDay!: number; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 16eca7849..c322482b2 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -407,12 +407,9 @@ export class WarehouseFeeService { const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); const now = new Date(); - const byType: FeeRuleType[] = [ - 'DEMURRAGE_FEE', - 'STORAGE_FEE', - 'DOUBLE_HANDLING_FEE', - 'TRUCK_DETENTION_FEE', - ]; + // Truck detention is a per-truck last-mile charge, not a per-inventory fee — + // it is computed separately via previewTruckDetention(), not here. + const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE', 'DOUBLE_HANDLING_FEE']; return Promise.all( byType.map((type) => this.compute( @@ -425,4 +422,111 @@ export class WarehouseFeeService { ), ); } + + /** + * Truck detention preview for an EDR last-mile leg. The vehicle should be + * returned within the rule's grace window (default 3h) of arriving; beyond + * that, detention accrues per truck per day (flat rate/day or progressive + * tiers by detention day) until it is delivered/returned (or now, if open). + */ + async previewTruckDetention(lastMileId: string, billingCurrency = 'USD'): Promise { + const [row] = await this.dataSource.query( + `SELECT lm.arrived_at AS "arrivedAt", + lm.delivered_at AS "deliveredAt", + b.freight_type AS "freightType", + b.trade_direction AS "tradeDirection", + (SELECT count(*) FROM freight.last_mile_vehicle_assignments va + WHERE va.last_mile_id = lm.id AND va.deleted_at IS NULL) AS "truckCount" + FROM freight.last_mile lm + LEFT JOIN freight.bookings b ON b.id = lm.booking_id + WHERE lm.id = $1 AND lm.deleted_at IS NULL`, + [lastMileId], + ); + if (!row) throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + + const item: ItemAttributes = { + arrivedAt: null, + gateClearedAt: null, + releaseDate: null, + freightType: row.freightType ?? null, + tradeDirection: row.tradeDirection ?? null, + cargoTypeCode: null, + containerTypeCode: null, + inventoryQuantity: 1, + bookingContainerCount: 1, + cargoQuantity: 0, + facilityId: null, + warehouseId: null, + yardId: null, + zoneId: null, + }; + const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); + const rule = this.bestRule( + rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'), + item, + ); + return this.computeTruckDetention(rule, row, new Date(), billingCurrency); + } + + private async computeTruckDetention( + rule: WarehouseFeeRule | null, + row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number | string }, + now: Date, + billingCurrency: string, + ): Promise { + const graceHours = rule?.freeHours && Number(rule.freeHours) > 0 ? Number(rule.freeHours) : 3; + const truckCount = Math.max(1, Math.round(Number(row.truckCount) || 1)); + const start = row.arrivedAt ? new Date(row.arrivedAt) : null; + const end = row.deliveredAt ? new Date(row.deliveredAt) : now; + const endIsOpen = !row.deliveredAt; + + let chargeableDays = 0; + if (start) { + const detentionMs = end.getTime() - start.getTime() - graceHours * 60 * 60 * 1000; + chargeableDays = detentionMs > 0 ? Math.ceil(detentionMs / MS_PER_DAY) : 0; + } + + const ratePerDay = Number(rule?.ratePerDay ?? 0); + const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null; + const targetCurrency = this.normalizeCurrency(billingCurrency); + const hasTiers = Boolean(rule?.tiers?.length); + const tiered = this.calculateTieredAmount(rule?.tiers, chargeableDays, truckCount); + const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * truckCount; + const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100; + const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0; + const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay; + const convertedRatePerDay = ruleCurrency + ? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency) + : 0; + const convertedTiers = ruleCurrency + ? await Promise.all( + tiered.tiers.map(async (tier) => ({ + ...tier, + ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency), + amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency), + })), + ) + : []; + + return { + ruleType: 'TRUCK_DETENTION_FEE', + basis: null, + ruleId: rule?.id ?? null, + ruleName: rule?.name ?? null, + freeDays: 0, + ratePerDay: convertedRatePerDay, + currency: targetCurrency, + ruleCurrency, + billingCurrency: targetCurrency, + startDate: start ? start.toISOString() : null, + endDate: end.toISOString(), + endIsOpen, + elapsedDays: chargeableDays, + chargeableDays, + containerCount: truckCount, // reused as the per-truck count + billableUnits, + amount, + tiers: hasTiers ? convertedTiers : [], + }; + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts index 8b16e3bec..a1c5db837 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -18,6 +18,15 @@ export class WarehouseInvoiceController { return this.invoiceService.generateForInventory(id, dto); } + @Post('last-mile/:id/generate-truck-detention-invoice') + @ApiOperation({ summary: 'Generate a truck-detention invoice for a last-mile leg (per truck per day)' }) + generateTruckDetention( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: GenerateInvoiceDto, + ) { + return this.invoiceService.generateTruckDetentionInvoice(id, dto); + } + @Get('warehouse-inventory/:id/fee-invoices') @ApiOperation({ summary: 'List fee invoices for an inventory item' }) listForInventory(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index b7cd3628e..4683b3bda 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -269,6 +269,79 @@ export class WarehouseInvoiceService { return detail; } + /** + * Generate a truck-detention invoice for a last-mile leg. Unlike warehouse fees + * (per inventory item), detention is a per-truck charge on the last-mile leg, so + * it becomes a `last_mile` invoice with its own `TRUCK_DETENTION_FEE` type — kept + * separate from the delivery-fee invoice. Returns the global Invoice. + */ + async generateTruckDetentionInvoice( + lastMileId: string, + opts: { billingCurrency?: "ETB" | "USD"; confirmZero?: boolean } = {}, + ): Promise { + const [lm] = await this.dataSource.query( + `SELECT lm.id, + b.company_id AS "companyId", + b.company_profile_id AS "companyProfileId", + b.payment_currency AS "paymentCurrency" + FROM freight.last_mile lm + LEFT JOIN freight.bookings b ON b.id = lm.booking_id + WHERE lm.id = $1 AND lm.deleted_at IS NULL`, + [lastMileId], + ); + if (!lm) throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + if (!lm.companyId) { + throw new BadRequestException( + "Cannot invoice truck detention: the last-mile leg has no billable company (no associated booking).", + ); + } + + const existing = await this.billing.findPayable( + "last_mile" as Freight.InvoiceSource, + lastMileId, + "TRUCK_DETENTION_FEE", + ); + if (existing) { + throw new ConflictException( + "An active truck detention invoice already exists for this last-mile leg. Cancel it before generating a new one.", + ); + } + + const billingCurrency: "ETB" | "USD" = + opts.billingCurrency ?? (lm.paymentCurrency === "ETB" ? "ETB" : "USD"); + const preview = await this.feeService.previewTruckDetention(lastMileId, billingCurrency); + if (preview.amount <= 0 && !opts.confirmZero) { + throw new BadRequestException( + "No truck detention is currently payable for this last-mile leg.", + ); + } + + const truckCount = preview.containerCount; // reused as the per-truck count + const line: InvoiceLineInput = { + chargeType: "TRUCK_DETENTION", + description: `Truck detention - ${preview.chargeableDays} day(s) x ${truckCount} truck(s)${preview.tiers.length ? " using tiered tariff" : ""}`, + quantity: preview.billableUnits, + unitRate: preview.ratePerDay, + amount: preview.amount, + currency: preview.currency, + metadata: { + feeRuleId: preview.ruleId ?? null, + chargeableDays: preview.chargeableDays ?? null, + }, + }; + + return this.billing.generateInvoice({ + source: "last_mile" as Freight.InvoiceSource, + sourceId: lastMileId, + type: "TRUCK_DETENTION_FEE", + companyId: lm.companyId, + companyProfileId: lm.companyProfileId || "", + currency: billingCurrency, + lines: [line], + status: Freight.InvoiceStatus.Issued, + }); + } + // ── Reads ──────────────────────────────────────────────────────────────── async findById(id: string): Promise { const invoice = await this.loadWarehouseInvoice(id); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts index 715080612..d35587d9e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts @@ -85,4 +85,13 @@ export class WarehouseRulesController { ) { return this.feeService.previewForInventory(id, billingCurrency); } + + @Get('last-mile/:id/truck-detention-preview') + @ApiOperation({ summary: 'Preview truck detention for a last-mile leg (per truck per day after grace)' }) + truckDetentionPreview( + @Param('id', ParseUUIDPipe) id: string, + @Query('billingCurrency') billingCurrency?: string, + ) { + return this.feeService.previewTruckDetention(id, billingCurrency); + } } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx index 0d7adc5be..044c729ee 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Check, ShieldCheck } from "lucide-react"; +import { Check, ShieldCheck, X } from "lucide-react"; import { Stack, Group, @@ -8,6 +8,7 @@ import { Button, Box, Modal, + Textarea, } from "@mantine/core"; import type { Freight } from "@edr/types"; @@ -30,6 +31,10 @@ export function ContractApprovalStepsCard({ const [confirmOpen, setConfirmOpen] = useState(false); const [pendingStep, setPendingStep] = useState(null); + const [rejectOpen, setRejectOpen] = useState(false); + const [rejectStepRow, setRejectStepRow] = + useState(null); + const [rejectReason, setRejectReason] = useState(""); const steps = useMemo( () => @@ -60,6 +65,28 @@ export function ContractApprovalStepsCard({ ); }; + const openReject = (step: Freight.IContractApprovalStep) => { + setRejectStepRow(step); + setRejectReason(""); + setRejectOpen(true); + }; + + const closeReject = () => { + setRejectOpen(false); + setRejectStepRow(null); + setRejectReason(""); + }; + + const trimmedReason = rejectReason.trim(); + + const runReject = () => { + if (!rejectStepRow || !trimmedReason) return; + mutations.rejectStep.mutate( + { stepId: rejectStepRow.id, reason: trimmedReason }, + { onSuccess: () => closeReject() }, + ); + }; + const subtitle = summary.detail || (nextPending @@ -106,8 +133,12 @@ export function ContractApprovalStepsCard({ key={step.id} step={step} isNext={nextPending?.id === step.id} - isPending={mutations.approveStep.isPending} + isPending={ + mutations.approveStep.isPending || + mutations.rejectStep.isPending + } onApprove={() => openApprove(step)} + onReject={() => openReject(step)} /> ))} @@ -149,6 +180,54 @@ export function ContractApprovalStepsCard({ + + + + + Rejecting the{" "} + + {rejectStepRow?.requiredRole} + {" "} + step rejects contract{" "} + + {contract.reference} + {" "} + outright. The customer must create a new contract — this cannot be + undone. + +