mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
contrat nad booking modification
This commit is contained in:
@@ -1,217 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, ShieldCheck } from "lucide-react";
|
||||
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
|
||||
|
||||
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { formatApprovalProgress } from "@/features/bookings/approval-progress";
|
||||
import {
|
||||
buildApproveActionForStep,
|
||||
canActOnApprovalStep,
|
||||
getNextPendingApprovalStep,
|
||||
} from "@/features/bookings/booking-actions.config";
|
||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
|
||||
import { SectionCard } from "./detail/SectionCard";
|
||||
|
||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||
|
||||
interface ApprovalStepsCardProps {
|
||||
booking: BookingDetail;
|
||||
mutations: Mutations;
|
||||
}
|
||||
|
||||
/** Approval chain with inline approve on the current pending step. */
|
||||
export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) {
|
||||
const { user } = useAuth();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(null);
|
||||
|
||||
const steps = useMemo(
|
||||
() => [...(booking.approvalSteps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder),
|
||||
[booking.approvalSteps],
|
||||
);
|
||||
|
||||
const nextPending = getNextPendingApprovalStep(steps);
|
||||
const summary = formatApprovalProgress(booking.status, steps);
|
||||
const pendingAction = pendingStep ? buildApproveActionForStep(pendingStep) : null;
|
||||
|
||||
const openApprove = (step: BookingApprovalStep) => {
|
||||
setPendingStep(step);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const closeApprove = () => {
|
||||
setConfirmOpen(false);
|
||||
setPendingStep(null);
|
||||
};
|
||||
|
||||
const runApprove = () => {
|
||||
if (!pendingStep) return;
|
||||
mutations.approveStep.mutate(
|
||||
{ stepId: pendingStep.id, requiredRole: pendingStep.requiredRole },
|
||||
{ onSuccess: () => closeApprove() },
|
||||
);
|
||||
};
|
||||
|
||||
const subtitle =
|
||||
summary.detail ||
|
||||
(nextPending
|
||||
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
|
||||
: steps.length
|
||||
? "All steps complete"
|
||||
: "Accept submission to begin");
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionCard
|
||||
icon={ShieldCheck}
|
||||
title="Approval chain"
|
||||
extra={
|
||||
<Badge color="edr-green" variant="light" radius="sm">
|
||||
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{subtitle}
|
||||
</Text>
|
||||
|
||||
{steps.length === 0 ? (
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
ta="center"
|
||||
py="lg"
|
||||
px="md"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px dashed var(--mantine-color-gray-3)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
Use <strong>Accept for approval</strong> in staff actions to instantiate steps.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{steps.map((step) => (
|
||||
<StepRow
|
||||
key={step.id}
|
||||
step={step}
|
||||
steps={steps}
|
||||
user={user}
|
||||
isNext={nextPending?.id === step.id}
|
||||
isPending={mutations.approveStep.isPending}
|
||||
onApprove={openApprove}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<BookingConfirmDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeApprove();
|
||||
else setConfirmOpen(true);
|
||||
}}
|
||||
action={pendingAction}
|
||||
reference={booking.reference}
|
||||
inputValue=""
|
||||
onInputChange={() => {}}
|
||||
onConfirm={runApprove}
|
||||
isPending={mutations.approveStep.isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StepRow({
|
||||
step,
|
||||
steps,
|
||||
user,
|
||||
isNext,
|
||||
isPending,
|
||||
onApprove,
|
||||
}: {
|
||||
step: BookingApprovalStep;
|
||||
steps: BookingApprovalStep[];
|
||||
user: ReturnType<typeof useAuth>["user"];
|
||||
isNext: boolean;
|
||||
isPending: boolean;
|
||||
onApprove: (step: BookingApprovalStep) => void;
|
||||
}) {
|
||||
const canApprove = canActOnApprovalStep(user, step, steps);
|
||||
const statusColor =
|
||||
step.status === "APPROVED"
|
||||
? "edr-green"
|
||||
: step.status === "REJECTED"
|
||||
? "red"
|
||||
: isNext
|
||||
? "edr-green"
|
||||
: "gray";
|
||||
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
gap="sm"
|
||||
px="sm"
|
||||
py="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderLeft: isNext
|
||||
? "3px solid var(--freight-brand)"
|
||||
: "1px solid var(--mantine-color-gray-2)",
|
||||
background: isNext ? "var(--mantine-color-gray-0)" : "white",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
flexShrink: 0,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
color: isNext ? "var(--mantine-color-gray-7)" : "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
>
|
||||
{step.stepOrder}
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{step.requiredRole}
|
||||
</Text>
|
||||
{step.remarks && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{step.remarks}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{canApprove && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<Check size={14} />}
|
||||
disabled={isPending}
|
||||
onClick={() => onApprove(step)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
<Badge variant="light" color={statusColor} size="sm" radius="sm" tt="uppercase">
|
||||
{step.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||
import { useBookingActionDialog } from "./useBookingActionDialog";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import {
|
||||
getNextPendingApprovalStep,
|
||||
isAllocateAction,
|
||||
isClearanceNavAction,
|
||||
isContractNavAction,
|
||||
@@ -37,7 +36,6 @@ export function BookingActionsMenu({
|
||||
status: row.status,
|
||||
paymentCurrency: row.paymentCurrency,
|
||||
reference: row.reference,
|
||||
approvalSteps: row.approvalSteps,
|
||||
schedulingStatus: row.schedulingStatus,
|
||||
customsClearingEnabled: row.customsClearingEnabled,
|
||||
};
|
||||
@@ -192,28 +190,6 @@ function ActionDialog({
|
||||
}}
|
||||
isPending={flow.mutations.isPending || flow.detailLoading}
|
||||
confirmDisabled={flow.confirmDisabled}
|
||||
extra={
|
||||
flow.detailLoading ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading approval steps…
|
||||
</Text>
|
||||
) : pendingAction?.id === "approve" &&
|
||||
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? (
|
||||
<Text
|
||||
size="sm"
|
||||
c="orange.9"
|
||||
p="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--mantine-color-orange-2)",
|
||||
background: "var(--mantine-color-orange-0)",
|
||||
}}
|
||||
>
|
||||
No pending approval step. Refresh the page after staff accept, or reject the
|
||||
booking.
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { formatApprovalProgress } from "@/features/bookings/approval-progress";
|
||||
import type { BookingListRow } from "@/types/booking";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BookingApprovalProgressCellProps {
|
||||
row: BookingListRow;
|
||||
}
|
||||
|
||||
export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCellProps) {
|
||||
const summary = formatApprovalProgress(row.status, row.approvalSteps);
|
||||
|
||||
return (
|
||||
<div className="min-w-[8.5rem] py-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-semibold",
|
||||
summary.complete ? "text-[color:var(--freight-brand)]" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{summary.label}
|
||||
</p>
|
||||
{summary.detail ? (
|
||||
<p className="mt-0.5 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
|
||||
{summary.detail}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { CheckCircle, Clock, XCircle } from "lucide-react";
|
||||
import { Group, Text, Badge, Timeline } from "@mantine/core";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import {
|
||||
approvalStatusColor,
|
||||
formatDateTime,
|
||||
type BookingApprovalStepView,
|
||||
} from "./booking-detail.styles";
|
||||
|
||||
export interface BookingApprovalCardProps {
|
||||
steps: BookingApprovalStepView[];
|
||||
approvedCount: number;
|
||||
}
|
||||
|
||||
/** Vertical timeline of the booking's approval chain. */
|
||||
export function BookingApprovalCard({ steps, approvedCount }: BookingApprovalCardProps) {
|
||||
return (
|
||||
<SectionCard
|
||||
icon={CheckCircle}
|
||||
title="Approval Workflow"
|
||||
accent="edr-green"
|
||||
extra={
|
||||
<Badge color="edr-green" variant="light" radius="sm">
|
||||
{approvedCount} / {steps.length} approved
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<Timeline active={approvedCount - 1} bulletSize={26} lineWidth={2} color="edr-green">
|
||||
{steps.map((step) => (
|
||||
<Timeline.Item
|
||||
key={step.id}
|
||||
color={approvalStatusColor(step.status)}
|
||||
bullet={
|
||||
step.status === "APPROVED" ? (
|
||||
<CheckCircle size={14} />
|
||||
) : step.status === "REJECTED" ? (
|
||||
<XCircle size={14} />
|
||||
) : (
|
||||
<Clock size={14} />
|
||||
)
|
||||
}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<Text fw={600} size="sm">
|
||||
{step.requiredRole.replace(/_/g, " ")}
|
||||
</Text>
|
||||
<Badge
|
||||
color={approvalStatusColor(step.status)}
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
>
|
||||
{step.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{step.actionedAt && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDateTime(step.actionedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -102,14 +102,6 @@ export interface BookingContainerView {
|
||||
};
|
||||
}
|
||||
|
||||
export interface BookingApprovalStepView {
|
||||
id: string;
|
||||
stepOrder: number;
|
||||
requiredRole: string;
|
||||
status: string;
|
||||
actionedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface BookingReviewNoteView {
|
||||
id: string;
|
||||
note: string;
|
||||
@@ -150,7 +142,6 @@ export interface BookingDetailView {
|
||||
cargoType?: BookingNamedRefView;
|
||||
shippingLine?: BookingNamedRefView;
|
||||
bookingContainers?: BookingContainerView[];
|
||||
approvalSteps?: BookingApprovalStepView[];
|
||||
reviewNotes?: BookingReviewNoteView[];
|
||||
files?: BookingFileView[];
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ export * from "./BookingLifecycleStepper";
|
||||
export * from "./BookingRouteCard";
|
||||
export * from "./BookingContainersCard";
|
||||
export * from "./BookingContainerUnitsCard";
|
||||
export * from "./BookingApprovalCard";
|
||||
export * from "./BookingReviewNotesCard";
|
||||
export * from "./BookingPaymentCard";
|
||||
export * from "./BookingPaymentCountdownCard";
|
||||
|
||||
@@ -2,12 +2,11 @@ import { useCallback, useState } from "react";
|
||||
|
||||
import {
|
||||
getBookingActions,
|
||||
getNextPendingApprovalStep,
|
||||
type BookingActionContext,
|
||||
type BookingActionDef,
|
||||
} from "@/features/bookings/booking-actions.config";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
import { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
|
||||
/** A contract validity window must be a whole number of days, 1–365. */
|
||||
function isValidValidityDays(value: string): boolean {
|
||||
@@ -24,22 +23,11 @@ export function useBookingActionDialog(
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const needsApprovalSteps =
|
||||
pendingAction?.id === "approve" || pendingAction?.id === "rejectApproval";
|
||||
// Bookings no longer have an approval chain, so the dialog needs nothing
|
||||
// beyond the list-row context it was handed.
|
||||
const detailLoading = false;
|
||||
|
||||
const needsApprovalContext =
|
||||
context.status === "PENDING_APPROVAL" ||
|
||||
context.status === "APPROVED_PENDING_SIGNATURE";
|
||||
|
||||
const { data: detail, isLoading: detailLoading } = useBookingDetail(
|
||||
needsApprovalSteps || needsApprovalContext ? bookingId : undefined,
|
||||
);
|
||||
|
||||
const mergedContext: BookingActionContext = {
|
||||
...context,
|
||||
approvalSteps: detail?.approvalSteps ?? context.approvalSteps,
|
||||
reference: detail?.reference ?? context.reference,
|
||||
};
|
||||
const mergedContext: BookingActionContext = { ...context };
|
||||
|
||||
const { user } = useAuth();
|
||||
const mutations = useBookingMutations(bookingId);
|
||||
@@ -86,24 +74,6 @@ export function useBookingActionDialog(
|
||||
{ onSuccess },
|
||||
);
|
||||
break;
|
||||
case "approve": {
|
||||
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
|
||||
if (!step) return;
|
||||
mutations.approveStep.mutate(
|
||||
{ stepId: step.id, requiredRole: step.requiredRole },
|
||||
{ onSuccess },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "rejectApproval": {
|
||||
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
|
||||
if (!step) return;
|
||||
mutations.rejectStep.mutate(
|
||||
{ stepId: step.id, reason: inputValue.trim() },
|
||||
{ onSuccess },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "viewContract":
|
||||
break;
|
||||
case "startTransit":
|
||||
@@ -122,16 +92,12 @@ export function useBookingActionDialog(
|
||||
pendingAction,
|
||||
inputValue,
|
||||
selectedFile,
|
||||
mergedContext.approvalSteps,
|
||||
mutations,
|
||||
closeDialog,
|
||||
]);
|
||||
|
||||
const confirmDisabled =
|
||||
mutations.isPending ||
|
||||
(needsApprovalSteps && detailLoading) ||
|
||||
(pendingAction?.id === "approve" &&
|
||||
!getNextPendingApprovalStep(mergedContext.approvalSteps)) ||
|
||||
(pendingAction?.input === "file" && !selectedFile) ||
|
||||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
|
||||
(pendingAction?.input === "note" && !inputValue.trim()) ||
|
||||
|
||||
@@ -4,11 +4,10 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||
import {
|
||||
Check,
|
||||
FileCheck,
|
||||
Eye,
|
||||
FilePen,
|
||||
FileSignature,
|
||||
MessageSquareWarning,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
XCircle,
|
||||
Zap,
|
||||
@@ -16,8 +15,10 @@ import {
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ContractDocumentEditorModal } from "@/components/contracts/ContractDocumentEditorModal";
|
||||
import { ContractPreviewModal } from "@/components/contracts/ContractPreviewModal";
|
||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||
|
||||
/** Dropdown-settings code holding the admin-configured contract validity days. */
|
||||
@@ -51,11 +52,21 @@ export function ContractActionsToolbar({
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [changesOpen, setChangesOpen] = useState(false);
|
||||
const [changesNote, setChangesNote] = useState("");
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
|
||||
// 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.
|
||||
const { data: draft } = useQuery({
|
||||
queryKey: ["contracts", contract.id, "document-draft"],
|
||||
queryFn: () => contractsService.getContractDocumentDraft(contract.id),
|
||||
enabled: contract.status === "PENDING_APPROVAL",
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// Admin-configured validity durations (days) for the accept dialog. Staff can
|
||||
// only pick one of these — no free-typing. Read-only setting, fetched once.
|
||||
const { data: validitySetting, isLoading: validityLoading } = useQuery({
|
||||
@@ -87,18 +98,11 @@ export function ContractActionsToolbar({
|
||||
}
|
||||
|
||||
const canAccept = status === "SUBMITTED";
|
||||
// While the contract is PENDING_APPROVAL and NO approver has acted yet, staff
|
||||
// can edit this contract's articles and (re)generate its PDF. The first
|
||||
// approval action locks the document.
|
||||
const docLocked =
|
||||
status !== "PENDING_APPROVAL" ||
|
||||
(contract.approvalSteps ?? []).some((s) => s.status !== "PENDING");
|
||||
const canEditGenerate = status === "PENDING_APPROVAL" && !docLocked;
|
||||
const documentGenerated = Boolean(contract.contractGeneratedAt);
|
||||
// Legacy fallback: if a contract ever lands on APPROVED without a document
|
||||
// (older flow), still offer a manual generate that moves it to CONTRACT_READY.
|
||||
const needsManualGenerate =
|
||||
status === "APPROVED" && !contract.contractGeneratedAt;
|
||||
// The document stays editable for the whole approval chain, but only by the
|
||||
// approver whose turn it is. The server resolves that against the caller's
|
||||
// position type; the client cannot derive it.
|
||||
const canEditDocument = Boolean(draft?.editableByMe);
|
||||
const inApproval = status === "PENDING_APPROVAL";
|
||||
// Signing now happens on the contract VIEW page (staff must open and read the
|
||||
// generated contract before signing) — no sign button in this toolbar.
|
||||
const canViewContract =
|
||||
@@ -154,56 +158,41 @@ export function ContractActionsToolbar({
|
||||
</>
|
||||
)}
|
||||
|
||||
{canEditGenerate && (
|
||||
{inApproval && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed">
|
||||
{documentGenerated
|
||||
? "Document generated. Approvers can now review it. You can still edit and regenerate until the first approval."
|
||||
: "Review the contract document, edit its articles if needed, then generate it so approvers can review."}
|
||||
{canEditDocument
|
||||
? "It is your turn to approve. You can edit the articles before approving — the PDF is generated automatically once the last approver approves."
|
||||
: draft?.nextApproverRole
|
||||
? `Awaiting ${draft.nextApproverRole}. Only the current approver can edit the document.`
|
||||
: "Awaiting approval."}
|
||||
</Text>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<FilePen size={16} />}
|
||||
onClick={() => {
|
||||
setEditorMode("edit");
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
leftSection={<Eye size={16} />}
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
>
|
||||
Edit contract articles
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={
|
||||
documentGenerated ? (
|
||||
<RefreshCw size={16} />
|
||||
) : (
|
||||
<FileCheck size={16} />
|
||||
)
|
||||
}
|
||||
loading={mutations.generateContract.isPending}
|
||||
onClick={() => mutations.generateContract.mutate()}
|
||||
>
|
||||
{documentGenerated ? "Regenerate contract" : "Generate contract"}
|
||||
Preview document
|
||||
</Button>
|
||||
{canEditDocument && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<FilePen size={16} />}
|
||||
onClick={() => {
|
||||
setEditorMode("edit");
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
Edit contract articles
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{needsManualGenerate && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<FileCheck size={16} />}
|
||||
loading={mutations.generateContract.isPending}
|
||||
onClick={() => mutations.generateContract.mutate()}
|
||||
>
|
||||
Generate contract
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canViewContract && (
|
||||
<Button
|
||||
fullWidth
|
||||
@@ -233,8 +222,7 @@ export function ContractActionsToolbar({
|
||||
the customer creates the booking in the portal. */}
|
||||
|
||||
{!canAccept &&
|
||||
!canEditGenerate &&
|
||||
!needsManualGenerate &&
|
||||
!inApproval &&
|
||||
!canViewContract &&
|
||||
!canReviewClearance && (
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -267,6 +255,12 @@ export function ContractActionsToolbar({
|
||||
}
|
||||
/>
|
||||
|
||||
<ContractPreviewModal
|
||||
opened={previewOpen}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
contractId={contract.id}
|
||||
/>
|
||||
|
||||
{/* Request changes */}
|
||||
<Modal
|
||||
opened={changesOpen}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { AlertTriangle, Check, FileCheck, ShieldCheck, X } from "lucide-react";
|
||||
import { AlertTriangle, Check, ShieldCheck, X } from "lucide-react";
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
@@ -31,7 +31,6 @@ export function ContractApprovalStepsCard({
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingStep, setPendingStep] =
|
||||
useState<Freight.IContractApprovalStep | null>(null);
|
||||
const [needsGenerateOpen, setNeedsGenerateOpen] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectStepRow, setRejectStepRow] =
|
||||
useState<Freight.IContractApprovalStep | null>(null);
|
||||
@@ -48,17 +47,9 @@ export function ContractApprovalStepsCard({
|
||||
const nextPending = steps.find((s) => s.status === "PENDING");
|
||||
const summary = formatContractApprovalProgress(contract.status, steps);
|
||||
|
||||
// Approvers must review the GENERATED contract document before approving. If
|
||||
// it has not been generated yet, block the approval and tell staff to generate
|
||||
// it first (via "Generate contract" in Staff actions) — mirrors the server
|
||||
// guard so the user sees a clear reason, not a generic failure toast.
|
||||
const documentGenerated = Boolean(contract.contractGeneratedAt);
|
||||
|
||||
// Approvers review a live preview of the document; there is no PDF to
|
||||
// generate first — the final approval is what produces it.
|
||||
const openApprove = (step: Freight.IContractApprovalStep) => {
|
||||
if (contract.status === "PENDING_APPROVAL" && !documentGenerated) {
|
||||
setNeedsGenerateOpen(true);
|
||||
return;
|
||||
}
|
||||
setPendingStep(step);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
@@ -71,7 +62,7 @@ export function ContractApprovalStepsCard({
|
||||
const runApprove = () => {
|
||||
if (!pendingStep) return;
|
||||
mutations.approveStep.mutate(
|
||||
{ stepId: pendingStep.id, requiredRole: pendingStep.requiredRole },
|
||||
{ stepId: pendingStep.id },
|
||||
{ onSuccess: () => closeApprove() },
|
||||
);
|
||||
};
|
||||
@@ -192,46 +183,6 @@ export function ContractApprovalStepsCard({
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={needsGenerateOpen}
|
||||
onClose={() => setNeedsGenerateOpen(false)}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<AlertTriangle size={18} color="var(--mantine-color-orange-6)" />
|
||||
<Text fw={700}>Generate the contract first</Text>
|
||||
</Group>
|
||||
}
|
||||
radius="md"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
The contract document for{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{contract.reference}
|
||||
</Text>{" "}
|
||||
has not been generated yet. Approvers must review the generated
|
||||
document before it can be approved.
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Use{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
Generate contract
|
||||
</Text>{" "}
|
||||
in the Staff actions panel — edit the articles first if needed — then
|
||||
return here to approve.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<FileCheck size={16} />}
|
||||
onClick={() => setNeedsGenerateOpen(false)}
|
||||
>
|
||||
Got it
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={rejectOpen}
|
||||
|
||||
@@ -116,7 +116,9 @@ export function ContractDocumentEditorModal({
|
||||
}
|
||||
}, [mode, validityDays, validityOptions]);
|
||||
|
||||
const locked = mode === "edit" && Boolean(draft?.locked);
|
||||
// Editing rights belong to the approver whose turn it is, so the server
|
||||
// decides per-caller — the client cannot derive this from the contract alone.
|
||||
const locked = mode === "edit" && !draft?.editableByMe;
|
||||
|
||||
const moveArticle = (index: number, delta: number) => {
|
||||
setArticles((prev) => {
|
||||
@@ -215,7 +217,9 @@ export function ContractDocumentEditorModal({
|
||||
icon={locked ? <Lock size={16} /> : <Info size={16} />}
|
||||
>
|
||||
{locked
|
||||
? "This document is locked — an approver has already acted, so it can no longer be edited."
|
||||
? draft?.nextApproverRole
|
||||
? `Only the current approver (${draft.nextApproverRole}) can edit this document right now.`
|
||||
: "This document can no longer be edited — the contract has advanced beyond approval."
|
||||
: "Edits apply to THIS contract only. The six shared templates are never changed."}
|
||||
</Alert>
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Alert, Group, Loader, Modal, Text } from "@mantine/core";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
|
||||
interface ContractPreviewModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
contractId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live preview of the contract document. Renders server-side HTML, not the
|
||||
* stored PDF — the PDF is only produced once the final approver approves, so
|
||||
* before that this is the document. Served in an iframe so the contract's own
|
||||
* styles stay sandboxed away from the app.
|
||||
*/
|
||||
export function ContractPreviewModal({
|
||||
opened,
|
||||
onClose,
|
||||
contractId,
|
||||
}: ContractPreviewModalProps) {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["contracts", contractId, "contract-view"],
|
||||
queryFn: () => contractsService.getContractView(contractId),
|
||||
enabled: opened,
|
||||
// The document changes as approvers edit it, so never serve a stale render.
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title="Contract document preview"
|
||||
>
|
||||
<Alert
|
||||
icon={<Info size={16} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
mb="sm"
|
||||
p="xs"
|
||||
>
|
||||
<Text size="xs">
|
||||
Draft preview. The PDF is generated automatically once the final
|
||||
approver approves.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{isLoading ? (
|
||||
<Group gap="xs" py="xl" justify="center">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Rendering document…
|
||||
</Text>
|
||||
</Group>
|
||||
) : isError || !data?.html ? (
|
||||
<Text size="sm" c="red">
|
||||
The document could not be rendered. Check that the contract has a
|
||||
template and try again.
|
||||
</Text>
|
||||
) : (
|
||||
<iframe
|
||||
srcDoc={data.html}
|
||||
title="Contract document preview"
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: "70vh",
|
||||
border: "none",
|
||||
background: "white",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { History } from "lucide-react";
|
||||
import { Badge, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
|
||||
interface ContractRevisionTimelineProps {
|
||||
contractId: string;
|
||||
}
|
||||
|
||||
type Change = Freight.IContractDocumentChange;
|
||||
|
||||
/** Badge colour + verb per change kind, so a revision reads at a glance. */
|
||||
const CHANGE_STYLES: Record<Change["kind"], { color: string; label: string }> = {
|
||||
ARTICLE_ADDED: { color: "green", label: "Added" },
|
||||
ARTICLE_REMOVED: { color: "red", label: "Removed" },
|
||||
ARTICLE_RENAMED: { color: "violet", label: "Renamed" },
|
||||
ARTICLE_BODY_CHANGED: { color: "blue", label: "Edited" },
|
||||
ARTICLE_REORDERED: { color: "gray", label: "Reordered" },
|
||||
DOCUMENT_TITLE_CHANGED: { color: "grape", label: "Title" },
|
||||
WHEREAS_CHANGED: { color: "teal", label: "Recitals" },
|
||||
};
|
||||
|
||||
/** What the change applies to — an article title, or the document itself. */
|
||||
function changeSubject(change: Change): string {
|
||||
switch (change.kind) {
|
||||
case "DOCUMENT_TITLE_CHANGED":
|
||||
return change.fromTitle
|
||||
? `“${change.fromTitle}” → “${change.title}”`
|
||||
: change.title;
|
||||
case "WHEREAS_CHANGED": {
|
||||
const parts: string[] = [];
|
||||
if (change.added) parts.push(`+${change.added}`);
|
||||
if (change.removed) parts.push(`−${change.removed}`);
|
||||
return parts.join(" ") || "changed";
|
||||
}
|
||||
case "ARTICLE_RENAMED":
|
||||
return `“${change.fromTitle}” → “${change.title}”`;
|
||||
case "ARTICLE_REORDERED":
|
||||
return `${change.title} (${change.fromOrder} → ${change.toOrder})`;
|
||||
default:
|
||||
return change.title;
|
||||
}
|
||||
}
|
||||
|
||||
function formatWhen(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
return date.toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit trail of edits to the contract document. The document stays editable
|
||||
* through the approval chain, so this is the record of who changed what.
|
||||
*/
|
||||
export function ContractRevisionTimeline({
|
||||
contractId,
|
||||
}: ContractRevisionTimelineProps) {
|
||||
const { data: revisions, isLoading } = useQuery({
|
||||
queryKey: ["contracts", contractId, "document-revisions"],
|
||||
queryFn: () => contractsService.getContractDocumentRevisions(contractId),
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionCard icon={History} title="Document history">
|
||||
{isLoading ? (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading history…
|
||||
</Text>
|
||||
</Group>
|
||||
) : !revisions?.length ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No edits recorded yet. Changes made to the contract articles during
|
||||
approval will appear here.
|
||||
</Text>
|
||||
) : (
|
||||
<Timeline
|
||||
active={revisions.length}
|
||||
bulletSize={18}
|
||||
lineWidth={2}
|
||||
color="edr-green"
|
||||
>
|
||||
{revisions.map((revision) => (
|
||||
<Timeline.Item
|
||||
key={revision.id}
|
||||
title={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{revision.actorRole ?? "Staff"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatWhen(revision.createdAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap={6} mt={4}>
|
||||
{revision.summary && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{revision.summary}
|
||||
</Text>
|
||||
)}
|
||||
{revision.changes.map((change, index) => {
|
||||
const style = CHANGE_STYLES[change.kind];
|
||||
return (
|
||||
<Group key={index} gap="xs" wrap="nowrap" align="flex-start">
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={style?.color ?? "gray"}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{style?.label ?? change.kind}
|
||||
</Badge>
|
||||
<Text size="xs" style={{ lineHeight: 1.5 }}>
|
||||
{changeSubject(change)}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -123,10 +123,6 @@ export const URL_CONSTANTS = {
|
||||
`/bookings/${id}/staff/request-changes`,
|
||||
STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`,
|
||||
GOVERNMENT_EXPEDITE: (id: string) => `/bookings/${id}/government-expedite`,
|
||||
APPROVE_STEP: (id: string, stepId: string) =>
|
||||
`/bookings/${id}/approval-steps/${stepId}/approve`,
|
||||
REJECT_STEP: (id: string, stepId: string) =>
|
||||
`/bookings/${id}/approval-steps/${stepId}/reject`,
|
||||
CONTRACT_GENERATE: (id: string) => `/bookings/${id}/contract/generate`,
|
||||
CONTRACT_VIEW: (id: string) => `/bookings/${id}/contract/view`,
|
||||
CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`,
|
||||
@@ -177,6 +173,8 @@ export const URL_CONSTANTS = {
|
||||
CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`,
|
||||
CONTRACT_SIGN: (id: string) => `/contracts/${id}/contract/sign`,
|
||||
CONTRACT_DOCUMENT_DRAFT: (id: string) => `/contracts/${id}/document/draft`,
|
||||
CONTRACT_DOCUMENT_REVISIONS: (id: string) =>
|
||||
`/contracts/${id}/document/revisions`,
|
||||
CONTRACT_DOCUMENT_ARTICLES: (id: string) =>
|
||||
`/contracts/${id}/document/articles`,
|
||||
CLEARANCE_QUEUE: "/contracts/clearance/queue",
|
||||
@@ -437,6 +435,7 @@ export const URL_CONSTANTS = {
|
||||
APPROVAL_RULES: "/approval-rules",
|
||||
APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`,
|
||||
APPROVAL_RULES_CHAIN: "/approval-rules/chain",
|
||||
APPROVAL_RULES_POSITION_TYPES: "/approval-rules/position-types",
|
||||
},
|
||||
RATE_MATRIX: {
|
||||
BASE: "/api/rate-matrices",
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { BookingApprovalStep, BookingStatus } from "@/types/booking";
|
||||
import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
|
||||
|
||||
export interface ApprovalProgressSummary {
|
||||
label: string;
|
||||
detail: string;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/** Compact approval chain summary for list rows and badges. */
|
||||
export function formatApprovalProgress(
|
||||
status: BookingStatus | string,
|
||||
steps?: BookingApprovalStep[] | null,
|
||||
): ApprovalProgressSummary {
|
||||
const sorted = [...(steps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
if (status === "SUBMITTED") {
|
||||
return {
|
||||
label: "Awaiting accept",
|
||||
detail: "Staff must accept intake",
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
status === "PENDING_APPROVAL" ||
|
||||
status === "APPROVED_PENDING_SIGNATURE"
|
||||
) {
|
||||
return {
|
||||
label: "No steps",
|
||||
detail: "Approval chain not started",
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
[
|
||||
"APPROVED",
|
||||
"CONTRACT_READY",
|
||||
"SIGNED_CUSTOMER",
|
||||
"FULLY_EXECUTED",
|
||||
"PAID",
|
||||
"COMPLETED",
|
||||
].includes(status)
|
||||
) {
|
||||
return {
|
||||
label: "Approved",
|
||||
detail: "Internal approval complete",
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
return { label: "—", detail: "", complete: false };
|
||||
}
|
||||
|
||||
const approved = sorted.filter((s) => s.status === "APPROVED").length;
|
||||
const total = sorted.length;
|
||||
const next = getNextPendingApprovalStep(sorted);
|
||||
|
||||
if (!next && approved === total) {
|
||||
return {
|
||||
label: `${approved}/${total} done`,
|
||||
detail: sorted.map((s) => `${s.requiredRole} ✓`).join(" · "),
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (next) {
|
||||
return {
|
||||
label: `${approved}/${total}`,
|
||||
detail: `Next: ${next.requiredRole} (step ${next.stepOrder})`,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: `${approved}/${total}`,
|
||||
detail: sorted.map((s) => `${s.requiredRole}: ${s.status}`).join(" · "),
|
||||
complete: approved === total,
|
||||
};
|
||||
}
|
||||
@@ -14,18 +14,12 @@ import {
|
||||
hasPermission,
|
||||
isFreightApprovalAdmin,
|
||||
} from "@/lib/permissions";
|
||||
import type {
|
||||
BookingApprovalStep,
|
||||
BookingDetail,
|
||||
BookingStatus,
|
||||
} from "@/types/booking";
|
||||
import type { BookingDetail, BookingStatus } from "@/types/booking";
|
||||
|
||||
export type BookingActionId =
|
||||
| "accept"
|
||||
| "requestChanges"
|
||||
| "reject"
|
||||
| "approve"
|
||||
| "rejectApproval"
|
||||
| "viewContract"
|
||||
| "signContractStaff"
|
||||
| "reviewClearance"
|
||||
@@ -62,7 +56,6 @@ export type BookingActionContext = Pick<
|
||||
BookingDetail,
|
||||
| "status"
|
||||
| "paymentCurrency"
|
||||
| "approvalSteps"
|
||||
| "reference"
|
||||
| "schedulingStatus"
|
||||
| "customsClearingEnabled"
|
||||
@@ -86,39 +79,6 @@ export function canAllocateBooking(
|
||||
);
|
||||
}
|
||||
|
||||
export function getNextPendingApprovalStep(
|
||||
steps?: BookingApprovalStep[] | null,
|
||||
): BookingApprovalStep | undefined {
|
||||
if (!steps?.length) return undefined;
|
||||
return [...steps]
|
||||
.sort((a, b) => a.stepOrder - b.stepOrder)
|
||||
.find((s) => s.status === "PENDING");
|
||||
}
|
||||
|
||||
function approvalActions(
|
||||
steps?: BookingApprovalStep[] | null,
|
||||
): BookingActionDef[] {
|
||||
const next = getNextPendingApprovalStep(steps);
|
||||
if (!next) return [];
|
||||
return [
|
||||
buildApproveActionForStep(next),
|
||||
{
|
||||
id: "rejectApproval",
|
||||
label: "Reject approval",
|
||||
shortLabel: "Reject",
|
||||
description: "Reject at the current approval step",
|
||||
confirmTitle: "Reject at approval step?",
|
||||
confirmDescription:
|
||||
"The booking will be marked rejected. This action cannot be undone from the UI.",
|
||||
variant: "destructive",
|
||||
icon: XCircle,
|
||||
input: "reason",
|
||||
inputLabel: "Rejection reason",
|
||||
inputPlaceholder: "Explain why this booking is rejected…",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const SUBMITTED_ACTIONS: BookingActionDef[] = [
|
||||
{
|
||||
id: "accept",
|
||||
@@ -232,7 +192,6 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
||||
accept: FREIGHT_PERMS.bookings.staffAccept,
|
||||
requestChanges: FREIGHT_PERMS.bookings.requestChanges,
|
||||
reject: FREIGHT_PERMS.bookings.reject,
|
||||
rejectApproval: FREIGHT_PERMS.bookings.rejectApproval,
|
||||
viewContract: FREIGHT_PERMS.bookings.view,
|
||||
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
|
||||
reviewClearance: FREIGHT_PERMS.bookings.reviewDocuments,
|
||||
@@ -244,55 +203,12 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
||||
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||
};
|
||||
|
||||
const approvePermissionForRole = (role: string): string | undefined => {
|
||||
if (role === "LINE_STAFF") return FREIGHT_PERMS.bookings.approveLineStaff;
|
||||
if (role === "DIRECTOR") return FREIGHT_PERMS.bookings.approveDirector;
|
||||
if (role === "CEO") return FREIGHT_PERMS.bookings.approveCeo;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** True when this step is the current pending step and the user may approve it. */
|
||||
export function canActOnApprovalStep(
|
||||
user: AuthUser | null | undefined,
|
||||
step: BookingApprovalStep,
|
||||
steps?: BookingApprovalStep[] | null,
|
||||
): boolean {
|
||||
if (step.status !== "PENDING") return false;
|
||||
const next = getNextPendingApprovalStep(steps);
|
||||
if (!next || next.id !== step.id) return false;
|
||||
if (isFreightApprovalAdmin(user)) return true;
|
||||
const perm = approvePermissionForRole(step.requiredRole);
|
||||
return perm ? hasPermission(user, perm) : false;
|
||||
}
|
||||
|
||||
export function buildApproveActionForStep(
|
||||
step: BookingApprovalStep,
|
||||
): BookingActionDef {
|
||||
return {
|
||||
id: "approve",
|
||||
label: `Approve (${step.requiredRole})`,
|
||||
shortLabel: "Approve",
|
||||
description: `Complete step ${step.stepOrder} as ${step.requiredRole}`,
|
||||
confirmTitle: `Approve as ${step.requiredRole}?`,
|
||||
confirmDescription:
|
||||
"This records your approval and advances the booking to the next step in the chain.",
|
||||
variant: "default",
|
||||
icon: Check,
|
||||
primary: true,
|
||||
};
|
||||
}
|
||||
|
||||
function filterActionsByUser(
|
||||
actions: BookingActionDef[],
|
||||
user: AuthUser | null | undefined,
|
||||
approvalSteps?: BookingApprovalStep[] | null,
|
||||
): BookingActionDef[] {
|
||||
if (!user) return [];
|
||||
const next = getNextPendingApprovalStep(approvalSteps);
|
||||
return actions.filter((action) => {
|
||||
if (action.id === "approve" && next) {
|
||||
return canActOnApprovalStep(user, next, approvalSteps);
|
||||
}
|
||||
const perm = ACTION_PERMISSION[action.id];
|
||||
return perm ? hasPermission(user, perm) : true;
|
||||
});
|
||||
@@ -303,7 +219,7 @@ export function getBookingActions(
|
||||
ctx: BookingActionContext,
|
||||
user?: AuthUser | null,
|
||||
): BookingActionDef[] {
|
||||
const { status, approvalSteps } = ctx;
|
||||
const { status } = ctx;
|
||||
|
||||
let actions: BookingActionDef[];
|
||||
|
||||
@@ -313,8 +229,6 @@ export function getBookingActions(
|
||||
break;
|
||||
case "PENDING_APPROVAL":
|
||||
case "APPROVED_PENDING_SIGNATURE":
|
||||
actions = withCancel(approvalActions(approvalSteps));
|
||||
break;
|
||||
case "APPROVED":
|
||||
actions = [CANCEL_ACTION];
|
||||
break;
|
||||
@@ -370,7 +284,7 @@ export function getBookingActions(
|
||||
}
|
||||
|
||||
if (user === undefined) return actions;
|
||||
return filterActionsByUser(actions, user, approvalSteps);
|
||||
return filterActionsByUser(actions, user);
|
||||
}
|
||||
|
||||
/** Opens contract page without confirmation dialog. */
|
||||
@@ -392,7 +306,6 @@ export function listRowHasActions(
|
||||
row: {
|
||||
status: BookingStatus;
|
||||
paymentCurrency: string;
|
||||
approvalSteps?: BookingApprovalStep[] | null;
|
||||
customsClearingEnabled?: boolean;
|
||||
},
|
||||
user?: AuthUser | null,
|
||||
@@ -402,7 +315,6 @@ export function listRowHasActions(
|
||||
status: row.status,
|
||||
paymentCurrency: row.paymentCurrency,
|
||||
reference: "",
|
||||
approvalSteps: row.approvalSteps ?? undefined,
|
||||
schedulingStatus: row.status,
|
||||
customsClearingEnabled: row.customsClearingEnabled,
|
||||
},
|
||||
|
||||
@@ -20,7 +20,6 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
||||
reference: booking.reference,
|
||||
contractReference: booking.contractReference ?? null,
|
||||
contractId: booking.contractId ?? null,
|
||||
approvalSteps: booking.approvalSteps,
|
||||
customerLabel: booking.isGovernment
|
||||
? (booking.governmentInstitution ?? "Government")
|
||||
: labelFromRef(booking.company, booking.companyId ?? undefined),
|
||||
@@ -46,6 +45,8 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
||||
consolidationPartnerId: booking.consolidationPartnerId ?? null,
|
||||
consolidationPartnerReference: booking.consolidationPartner?.reference ?? null,
|
||||
customsClearingEnabled: booking.customsClearingEnabled ?? false,
|
||||
bookingKind:
|
||||
booking.contractKind === "GENERAL" ? "GENERAL_CONTRACT" : "ONE_TIME",
|
||||
createdAt: booking.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,40 +87,6 @@ export function useBookingMutations(bookingId: string) {
|
||||
},
|
||||
});
|
||||
|
||||
const approveStep = useMutation({
|
||||
mutationFn: ({
|
||||
stepId,
|
||||
requiredRole,
|
||||
}: {
|
||||
stepId: string;
|
||||
requiredRole: string;
|
||||
}) =>
|
||||
api.bookings.approveStep.call({
|
||||
id: bookingId,
|
||||
stepId,
|
||||
requiredRole,
|
||||
}),
|
||||
onSuccess: (data) => onSuccess(data, "Approval step completed"),
|
||||
onError: (error) => toast.error(parseApiError(error, "Failed to approve step")),
|
||||
});
|
||||
|
||||
const rejectStep = useMutation({
|
||||
mutationFn: ({
|
||||
stepId,
|
||||
reason,
|
||||
}: {
|
||||
stepId: string;
|
||||
reason: string;
|
||||
}) =>
|
||||
api.bookings.rejectStep.call({
|
||||
id: bookingId,
|
||||
stepId,
|
||||
reason,
|
||||
}),
|
||||
onSuccess: (data) => onSuccess(data, "Booking rejected at approval step"),
|
||||
onError: (error) => toast.error(parseApiError(error, "Failed to reject step")),
|
||||
});
|
||||
|
||||
const generateContract = useMutation({
|
||||
mutationFn: () => api.bookings.generateContract.call({ id: bookingId }),
|
||||
onSuccess: (data) => onSuccess(data, "Contract generated"),
|
||||
@@ -167,8 +133,6 @@ export function useBookingMutations(bookingId: string) {
|
||||
staffAccept.isPending ||
|
||||
requestChanges.isPending ||
|
||||
staffReject.isPending ||
|
||||
approveStep.isPending ||
|
||||
rejectStep.isPending ||
|
||||
generateContract.isPending ||
|
||||
signContract.isPending ||
|
||||
payBooking.isPending ||
|
||||
@@ -182,8 +146,6 @@ export function useBookingMutations(bookingId: string) {
|
||||
requestChanges,
|
||||
staffReject,
|
||||
reviewOperation,
|
||||
approveStep,
|
||||
rejectStep,
|
||||
generateContract,
|
||||
signContract,
|
||||
payBooking,
|
||||
|
||||
@@ -122,6 +122,15 @@ export function useContractMutations(contractId: string) {
|
||||
const onSuccess = (data: { id: string }, message: string) => {
|
||||
toast.success(message);
|
||||
void invalidateContractDetail(qc, data.id);
|
||||
// Approving or editing can change who holds document-editing rights (it
|
||||
// passes to the next approver), and edits add revisions — so both the draft
|
||||
// and the history are refreshed on every contract mutation.
|
||||
void qc.invalidateQueries({
|
||||
queryKey: ["contracts", data.id, "document-draft"],
|
||||
});
|
||||
void qc.invalidateQueries({
|
||||
queryKey: ["contracts", data.id, "document-revisions"],
|
||||
});
|
||||
};
|
||||
|
||||
const staffAccept = useMutation({
|
||||
@@ -160,21 +169,16 @@ export function useContractMutations(contractId: string) {
|
||||
});
|
||||
|
||||
const approveStep = useMutation({
|
||||
mutationFn: ({
|
||||
stepId,
|
||||
requiredRole,
|
||||
}: {
|
||||
stepId: string;
|
||||
requiredRole: string;
|
||||
}) =>
|
||||
contractsService.approveStep({ id: contractId, stepId, requiredRole }),
|
||||
// The server derives the required role from the step itself, so the client
|
||||
// does not send one.
|
||||
mutationFn: ({ stepId }: { stepId: string }) =>
|
||||
contractsService.approveStep({ id: contractId, stepId }),
|
||||
onSuccess: (data) => {
|
||||
// The document is generated at the accept stage and reviewed during
|
||||
// approval, so the final approval moves the contract straight to
|
||||
// CONTRACT_READY on the server — no client-side generate call here.
|
||||
// The final approval is what generates the PDF and moves the contract to
|
||||
// CONTRACT_READY — before that approvers review a live preview.
|
||||
const message =
|
||||
data.status === "CONTRACT_READY"
|
||||
? "Final approval complete — contract ready to sign"
|
||||
? "Final approval complete — contract generated and ready to sign"
|
||||
: "Approval step completed";
|
||||
onSuccess(data, message);
|
||||
},
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
type SubmitPriorityRuleChangePayload,
|
||||
type SubmitRateChangePayload,
|
||||
} from "@/services/ruleEngine/ruleEngine.service";
|
||||
import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources";
|
||||
import {
|
||||
LEGACY_APPROVAL_ROLES,
|
||||
RULE_ENGINE_SELECT_NONE,
|
||||
} from "@/pages/ruleEngine/config/resources";
|
||||
import type {
|
||||
RuleEngineRecord,
|
||||
RuleEngineResourceSlug,
|
||||
@@ -155,6 +158,33 @@ export const useContainerTypeOptions = (
|
||||
select: (rows) => buildContainerTypeSelectOptions(rows, includeNone),
|
||||
});
|
||||
|
||||
/**
|
||||
* Approval-step role options, sourced from the live IAM position types. The
|
||||
* three pre-IAM role strings are appended (marked "(legacy)") so an approval
|
||||
* rule still stored against one of them renders its label instead of an empty
|
||||
* select; a position type that reuses one of those values wins the dedupe.
|
||||
*/
|
||||
export const useApprovalRoleOptions = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("approval-rules", {
|
||||
positionTypes: true,
|
||||
}),
|
||||
queryFn: () => ruleEngineService.getApprovalPositionTypes(),
|
||||
enabled,
|
||||
select: (rows): { label: string; value: string }[] => {
|
||||
const byValue = new Map<string, { label: string; value: string }>();
|
||||
for (const row of rows) {
|
||||
const value = String(row?.value ?? "").trim();
|
||||
if (!value) continue;
|
||||
byValue.set(value, { label: String(row.label ?? "").trim() || value, value });
|
||||
}
|
||||
for (const legacy of LEGACY_APPROVAL_ROLES) {
|
||||
if (!byValue.has(legacy.value)) byValue.set(legacy.value, legacy);
|
||||
}
|
||||
return [...byValue.values()];
|
||||
},
|
||||
});
|
||||
|
||||
/** A yard option that remembers its country, so callers can filter by leg. */
|
||||
export interface YardOption {
|
||||
label: string;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
BookingApprovalCard,
|
||||
BookingContainersCard,
|
||||
BookingDetailToolbar,
|
||||
BookingDocumentsCard,
|
||||
@@ -75,29 +74,6 @@ const BookingDetailPage = () => {
|
||||
containerType: { label: "20FT Standard", sizeFt: 20 },
|
||||
},
|
||||
],
|
||||
approvalSteps: [
|
||||
{
|
||||
id: "1",
|
||||
stepOrder: 1,
|
||||
requiredRole: "LINE_STAFF",
|
||||
status: "APPROVED",
|
||||
actionedAt: "2026-06-05T11:00:00Z",
|
||||
},
|
||||
// {
|
||||
// id: "2",
|
||||
// stepOrder: 2,
|
||||
// requiredRole: "DIRECTOR",
|
||||
// status: "APPROVED",
|
||||
// actionedAt: "2026-06-05T13:30:00Z",
|
||||
// },
|
||||
{
|
||||
id: "3",
|
||||
stepOrder: 3,
|
||||
requiredRole: "CEO",
|
||||
status: "APPROVED",
|
||||
actionedAt: "2026-06-05T15:45:00Z",
|
||||
},
|
||||
],
|
||||
reviewNotes: [
|
||||
{
|
||||
id: "1",
|
||||
@@ -119,11 +95,6 @@ const BookingDetailPage = () => {
|
||||
],
|
||||
};
|
||||
|
||||
const approvalSteps = booking.approvalSteps ?? [];
|
||||
const approvedCount = approvalSteps.filter(
|
||||
(s) => s.status === "APPROVED",
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div style={detailStyles.page}>
|
||||
<Container size="xxl" py="lg">
|
||||
@@ -136,13 +107,6 @@ const BookingDetailPage = () => {
|
||||
{ label: booking.reference },
|
||||
]}
|
||||
/>
|
||||
{/*
|
||||
<BookingDetailHeader
|
||||
booking={booking}
|
||||
approvedCount={approvedCount}
|
||||
totalSteps={totalSteps}
|
||||
/> */}
|
||||
|
||||
<BookingLifecycleStepper status={booking.status} />
|
||||
|
||||
<Grid>
|
||||
@@ -164,10 +128,6 @@ const BookingDetailPage = () => {
|
||||
await allocateMutation.mutateAsync({ allocations });
|
||||
}}
|
||||
/>
|
||||
<BookingApprovalCard
|
||||
steps={approvalSteps}
|
||||
approvedCount={approvedCount}
|
||||
/>
|
||||
<BookingReviewNotesCard notes={booking.reviewNotes ?? []} />
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
|
||||
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
||||
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
|
||||
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
|
||||
@@ -130,10 +129,6 @@ export default function BookingRequestDetailPage() {
|
||||
|
||||
const row = toBookingListRow(booking);
|
||||
const statusMeta = getStatusMeta(booking.status);
|
||||
const showApprovalCard =
|
||||
booking.status === "PENDING_APPROVAL" ||
|
||||
booking.status === "APPROVED_PENDING_SIGNATURE";
|
||||
|
||||
// Non-customs clearance is reviewed here by Marketing in its own tab; customs
|
||||
// bookings are handled in the Global Logistics clearance queue instead.
|
||||
const showClearanceTab =
|
||||
@@ -283,9 +278,6 @@ export default function BookingRequestDetailPage() {
|
||||
View document clearance
|
||||
</Button>
|
||||
)}
|
||||
{showApprovalCard && (
|
||||
<ApprovalStepsCard booking={booking} mutations={mutations} />
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
MultiSelect,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
@@ -33,7 +32,6 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
|
||||
@@ -60,10 +58,10 @@ import {
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/** The two booking-kind tabs: one-time vs general-contract bookings. */
|
||||
type BookingKindTab = "ONE_TIME" | "GENERAL_CONTRACT";
|
||||
/** Booking kind: one-time vs general-contract bookings. Now a filter, not a tab. */
|
||||
type BookingKind = "ONE_TIME" | "GENERAL_CONTRACT";
|
||||
|
||||
const BOOKING_KIND_TABS: { value: BookingKindTab; label: string }[] = [
|
||||
const BOOKING_KIND_OPTIONS: { value: BookingKind; label: string }[] = [
|
||||
{ value: "ONE_TIME", label: "One-time booking" },
|
||||
{ value: "GENERAL_CONTRACT", label: "General booking" },
|
||||
];
|
||||
@@ -128,9 +126,9 @@ export default function BookingRequestsPage() {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
// Booking-kind tabs (one-time vs general contract) replace the old status tabs.
|
||||
const [kindTab, setKindTab] = useState<BookingKindTab>("ONE_TIME");
|
||||
// Per-tab filter controls (empty/null = "all").
|
||||
// Booking kind is a filter now — one list holds both kinds (null = "all").
|
||||
const [kindFilter, setKindFilter] = useState<BookingKind | null>(null);
|
||||
// Filter controls (empty/null = "all").
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
|
||||
@@ -158,9 +156,9 @@ export default function BookingRequestsPage() {
|
||||
pageSize: pagination.pageSize,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
// React Query cache key per kind tab.
|
||||
tab: kindTab,
|
||||
bookingType: kindTab,
|
||||
// React Query cache key per kind selection ("ALL" when unfiltered).
|
||||
tab: kindFilter ?? "ALL",
|
||||
...(kindFilter ? { bookingType: kindFilter } : {}),
|
||||
// Server-side free-text search (booking ref, customer, contract ref).
|
||||
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
|
||||
...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
|
||||
@@ -182,7 +180,7 @@ export default function BookingRequestsPage() {
|
||||
}, [
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
kindTab,
|
||||
kindFilter,
|
||||
debouncedQuery,
|
||||
statusFilter,
|
||||
directionFilter,
|
||||
@@ -226,6 +224,7 @@ export default function BookingRequestsPage() {
|
||||
}, [setPagination, pagination.pageSize]);
|
||||
|
||||
const activeFilterCount =
|
||||
(kindFilter ? 1 : 0) +
|
||||
(statusFilter.length ? 1 : 0) +
|
||||
(directionFilter ? 1 : 0) +
|
||||
(freightTypeFilter ? 1 : 0) +
|
||||
@@ -237,6 +236,7 @@ export default function BookingRequestsPage() {
|
||||
(scheduledFrom || scheduledTo ? 1 : 0);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setKindFilter(null);
|
||||
setStatusFilter([]);
|
||||
setDirectionFilter(null);
|
||||
setFreightTypeFilter(null);
|
||||
@@ -327,6 +327,23 @@ export default function BookingRequestsPage() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bookingKind",
|
||||
header: () => <span className={bookingTable.headerCell}>Type</span>,
|
||||
cell: ({ row }) => {
|
||||
const isGeneral = row.original.bookingKind === "GENERAL_CONTRACT";
|
||||
return (
|
||||
<div className="py-1">
|
||||
<Badge
|
||||
variant={isGeneral ? "secondary" : "outline"}
|
||||
className="h-5 px-1.5 text-[10px] font-medium"
|
||||
>
|
||||
{isGeneral ? "General" : "One-time"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
@@ -376,13 +393,6 @@ export default function BookingRequestsPage() {
|
||||
cellClassName: "min-w-[11rem]",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "approval",
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>Approval</span>
|
||||
),
|
||||
cell: ({ row }) => <BookingApprovalProgressCell row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "scheduled",
|
||||
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
|
||||
@@ -482,22 +492,6 @@ export default function BookingRequestsPage() {
|
||||
/>
|
||||
*/}
|
||||
|
||||
<Tabs
|
||||
value={kindTab}
|
||||
onChange={(value) => {
|
||||
setKindTab((value as BookingKindTab) ?? "ONE_TIME");
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
>
|
||||
<Tabs.List>
|
||||
{BOOKING_KIND_TABS.map((t) => (
|
||||
<Tabs.Tab key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
@@ -535,6 +529,18 @@ export default function BookingRequestsPage() {
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All booking types"
|
||||
data={BOOKING_KIND_OPTIONS}
|
||||
value={kindFilter}
|
||||
onChange={(v) => {
|
||||
setKindFilter((v as BookingKind | null) ?? null);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 190 }}
|
||||
/>
|
||||
<MultiSelect
|
||||
placeholder={statusFilter.length ? undefined : "All statuses"}
|
||||
data={STATUS_OPTIONS}
|
||||
|
||||
@@ -50,6 +50,7 @@ import { ContractActionsToolbar } from "@/components/contracts/ContractActionsTo
|
||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
|
||||
import {
|
||||
ContractCustomerCard,
|
||||
ContractDocumentsCard,
|
||||
@@ -493,6 +494,7 @@ export default function ContractRequestDetailPage() {
|
||||
onView={handleViewFile}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
<ContractRevisionTimeline contractId={contract.id} />
|
||||
<ContractDocumentsCard
|
||||
files={profileDocuments}
|
||||
title="Customer profile documents"
|
||||
|
||||
@@ -36,6 +36,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
|
||||
import {
|
||||
useApprovalChain,
|
||||
useApprovalRoleOptions,
|
||||
useCargoLeafOptions,
|
||||
useCargoTypeParentOptions,
|
||||
useContainerTypeOptions,
|
||||
@@ -247,6 +248,13 @@ const RuleEngineResourcePage = () => {
|
||||
);
|
||||
const { data: yardOptions, isLoading: yardOptionsLoading } =
|
||||
useYardOptions(usesYardField);
|
||||
const usesApprovalRoleField = Boolean(
|
||||
config?.formFields.some(
|
||||
(f) => f.name === "requiredRole" || f.name === "blocksRole",
|
||||
),
|
||||
);
|
||||
const { data: approvalRoleOptions, isLoading: approvalRoleOptionsLoading } =
|
||||
useApprovalRoleOptions(usesApprovalRoleField);
|
||||
|
||||
// Full rule list backing the auto-filled "min wagon count": the next range
|
||||
// always continues the chain for the selected type (per currency), so the
|
||||
@@ -321,6 +329,20 @@ const RuleEngineResourcePage = () => {
|
||||
options: wagonTypeOptions ?? [],
|
||||
};
|
||||
}
|
||||
// Approval steps are configured against live IAM position types; until
|
||||
// they load, the static legacy list on the field config stands in so an
|
||||
// existing row's role still shows a label.
|
||||
if (field.name === "requiredRole" || field.name === "blocksRole") {
|
||||
if (!approvalRoleOptions) return field;
|
||||
const includeNone = field.name === "blocksRole";
|
||||
return {
|
||||
...field,
|
||||
type: "select" as const,
|
||||
options: includeNone
|
||||
? [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...approvalRoleOptions]
|
||||
: approvalRoleOptions,
|
||||
};
|
||||
}
|
||||
// Each end of the leg only offers yards in the country that end of the
|
||||
// trade actually sits in, so an import can't be configured as if it
|
||||
// started inland. Resolved per keystroke because the legal set changes
|
||||
@@ -336,7 +358,7 @@ const RuleEngineResourcePage = () => {
|
||||
}
|
||||
return field;
|
||||
});
|
||||
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, yardOptions, isPriorityRules, allPriorityRules, editing, editingId]);
|
||||
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, yardOptions, approvalRoleOptions, isPriorityRules, allPriorityRules, editing, editingId]);
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const meta = data?.meta;
|
||||
@@ -765,7 +787,8 @@ const RuleEngineResourcePage = () => {
|
||||
(usesCargoTypeField && cargoLeafOptionsLoading) ||
|
||||
(usesLiveRateField && liveRateOptionsLoading) ||
|
||||
(usesWagonTypeField && wagonTypeOptionsLoading) ||
|
||||
(usesYardField && yardOptionsLoading)
|
||||
(usesYardField && yardOptionsLoading) ||
|
||||
(usesApprovalRoleField && approvalRoleOptionsLoading)
|
||||
}
|
||||
positionOptions={!editing ? createPositionOptions : undefined}
|
||||
positionLoading={createPositionLoading}
|
||||
|
||||
@@ -118,10 +118,16 @@ const YARD_COUNTRIES = [
|
||||
{ label: "Djibouti", value: "Djibouti" },
|
||||
];
|
||||
|
||||
const APPROVAL_ROLES = [
|
||||
{ label: "Line staff", value: "LINE_STAFF" },
|
||||
{ label: "Director", value: "DIRECTOR" },
|
||||
{ label: "CEO", value: "CEO" },
|
||||
/**
|
||||
* The three role strings the approval chain was hardcoded to before it was
|
||||
* driven by IAM position types. Kept only so rows still stored against them
|
||||
* render a readable label instead of a blank select — the live options come
|
||||
* from GET /approval-rules/position-types (see `useApprovalRoleOptions`).
|
||||
*/
|
||||
export const LEGACY_APPROVAL_ROLES = [
|
||||
{ label: "Line staff (legacy)", value: "LINE_STAFF" },
|
||||
{ label: "Director (legacy)", value: "DIRECTOR" },
|
||||
{ label: "CEO (legacy)", value: "CEO" },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -718,7 +724,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
label: "Required role",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: APPROVAL_ROLES,
|
||||
// Replaced at render time with live IAM position types (+ legacy values).
|
||||
options: LEGACY_APPROVAL_ROLES,
|
||||
},
|
||||
{ name: "actionLabel", label: "Action label", type: "text", required: true },
|
||||
{
|
||||
@@ -726,7 +733,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
label: "Blocks role",
|
||||
type: "select",
|
||||
optional: true,
|
||||
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...APPROVAL_ROLES],
|
||||
// Replaced at render time with live IAM position types (+ legacy values).
|
||||
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...LEGACY_APPROVAL_ROLES],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -132,9 +132,7 @@ import { endpoint } from "@/utils/endpoint";
|
||||
import {
|
||||
BookingListFilter,
|
||||
bookingsService,
|
||||
type ApproveStepPayload,
|
||||
type PaginatedBookings,
|
||||
type RejectStepPayload,
|
||||
} from "./bookings.service";
|
||||
import { cargoTypesService } from "./cargo-types.service";
|
||||
import {
|
||||
@@ -2481,18 +2479,6 @@ export const api = {
|
||||
bookingsService.reviewOperation(id, decision, { note }),
|
||||
),
|
||||
|
||||
approveStep: endpoint<ApproveStepPayload, BookingDetail>(
|
||||
"bookings",
|
||||
"approveStep",
|
||||
(payload) => bookingsService.approveStep(payload),
|
||||
),
|
||||
|
||||
rejectStep: endpoint<RejectStepPayload, BookingDetail>(
|
||||
"bookings",
|
||||
"rejectStep",
|
||||
(payload) => bookingsService.rejectStep(payload),
|
||||
),
|
||||
|
||||
generateContract: endpoint<{ id: string }, BookingDetail>(
|
||||
"bookings",
|
||||
"generateContract",
|
||||
|
||||
@@ -72,18 +72,6 @@ export interface BookingListSummary {
|
||||
tabs: BookingListSummaryTabs;
|
||||
}
|
||||
|
||||
export interface ApproveStepPayload {
|
||||
id: string;
|
||||
stepId: string;
|
||||
requiredRole: string;
|
||||
}
|
||||
|
||||
export interface RejectStepPayload {
|
||||
id: string;
|
||||
stepId: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ContractView {
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
@@ -255,12 +243,6 @@ export const bookingsService = {
|
||||
...options,
|
||||
}),
|
||||
|
||||
approveStep: ({ id, stepId, requiredRole }: ApproveStepPayload) =>
|
||||
postBooking<BookingDetail>(B.APPROVE_STEP(id, stepId), { requiredRole }),
|
||||
|
||||
rejectStep: ({ id, stepId, reason }: RejectStepPayload) =>
|
||||
postBooking<BookingDetail>(B.REJECT_STEP(id, stepId), { reason }),
|
||||
|
||||
generateContract: (id: string) =>
|
||||
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),
|
||||
|
||||
|
||||
@@ -196,6 +196,14 @@ export const contractsService = {
|
||||
return unwrap(response.data) as Freight.IContractDocumentDraft;
|
||||
},
|
||||
|
||||
/** Audit trail of edits to this contract's document, newest first. */
|
||||
getContractDocumentRevisions: async (
|
||||
id: string,
|
||||
): Promise<Freight.IContractDocumentRevision[]> => {
|
||||
const response = await client.get(C.CONTRACT_DOCUMENT_REVISIONS(id));
|
||||
return unwrap(response.data) as Freight.IContractDocumentRevision[];
|
||||
},
|
||||
|
||||
/** Save this contract's edited document articles (never touches the templates). */
|
||||
updateContractDocument: async (
|
||||
id: string,
|
||||
@@ -214,18 +222,12 @@ export const contractsService = {
|
||||
reject: (id: string, reason: string) =>
|
||||
postContract<Freight.IContract>(C.STAFF_REJECT(id), { reason }),
|
||||
|
||||
approveStep: ({
|
||||
id,
|
||||
stepId,
|
||||
requiredRole,
|
||||
}: {
|
||||
id: string;
|
||||
stepId: string;
|
||||
requiredRole: string;
|
||||
}) =>
|
||||
postContract<Freight.IContract>(C.APPROVE_STEP(id, stepId), {
|
||||
requiredRole,
|
||||
}),
|
||||
/**
|
||||
* Approve the next pending step. The server resolves the step's required role
|
||||
* and authorizes against it — the client never declares its own role.
|
||||
*/
|
||||
approveStep: ({ id, stepId }: { id: string; stepId: string }) =>
|
||||
postContract<Freight.IContract>(C.APPROVE_STEP(id, stepId)),
|
||||
|
||||
rejectStep: ({
|
||||
id,
|
||||
|
||||
@@ -72,6 +72,12 @@ export interface SubmitRateChangePayload {
|
||||
update: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** One selectable IAM position type, as returned by /approval-rules/position-types. */
|
||||
export interface ApprovalPositionType {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
||||
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
|
||||
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
|
||||
@@ -368,6 +374,28 @@ export const ruleEngineService = {
|
||||
return unwrap(response.data) as RateChangeRequest;
|
||||
},
|
||||
|
||||
/**
|
||||
* IAM position types that an approval step can require/block. Replaces the
|
||||
* old hardcoded LINE_STAFF/DIRECTOR/CEO triple — the chain is configured from
|
||||
* whatever positions IAM actually defines.
|
||||
*/
|
||||
getApprovalPositionTypes: async (): Promise<ApprovalPositionType[]> => {
|
||||
const response = await client.get(
|
||||
URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES_POSITION_TYPES,
|
||||
);
|
||||
const body = unwrap(response.data) as unknown;
|
||||
if (Array.isArray(body)) return body as ApprovalPositionType[];
|
||||
if (
|
||||
body &&
|
||||
typeof body === "object" &&
|
||||
"data" in body &&
|
||||
Array.isArray((body as { data: unknown }).data)
|
||||
) {
|
||||
return (body as { data: ApprovalPositionType[] }).data;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
getApprovalChain: async (
|
||||
requiresDirectorApproval = true,
|
||||
): Promise<RuleEngineRecord[]> => {
|
||||
|
||||
@@ -101,16 +101,6 @@ export interface BookingContainerLine {
|
||||
units?: BookingContainerUnit[];
|
||||
}
|
||||
|
||||
export interface BookingApprovalStep {
|
||||
id: string;
|
||||
stepOrder: number;
|
||||
requiredRole: string;
|
||||
blocksRole?: string | null;
|
||||
status: "PENDING" | "APPROVED" | "REJECTED" | "SKIPPED";
|
||||
actionedAt?: string | null;
|
||||
remarks?: string | null;
|
||||
}
|
||||
|
||||
export interface BookingNextStep {
|
||||
action: string;
|
||||
description: string;
|
||||
@@ -219,7 +209,6 @@ export interface BookingDetail {
|
||||
cargoType?: BookingNamedRef;
|
||||
shippingLine?: BookingNamedRef;
|
||||
bookingContainers?: BookingContainerLine[];
|
||||
approvalSteps?: BookingApprovalStep[];
|
||||
reviewNotes?: BookingReviewNote[];
|
||||
files?: BookingFile[];
|
||||
cargoModifiers?: Array<{
|
||||
@@ -236,7 +225,6 @@ export interface BookingListRow {
|
||||
/** Needed to link the reference to the contract's detail page. */
|
||||
contractId?: string | null;
|
||||
customerLabel: string;
|
||||
approvalSteps?: BookingApprovalStep[];
|
||||
status: BookingStatus;
|
||||
scheduledDate: string;
|
||||
totalAmount: number;
|
||||
@@ -255,5 +243,11 @@ export interface BookingListRow {
|
||||
consolidationPartnerId?: string | null;
|
||||
consolidationPartnerReference?: string | null;
|
||||
customsClearingEnabled?: boolean;
|
||||
/**
|
||||
* Derived booking kind for the list "Type" column. Mirrors the server's
|
||||
* bookingType filter: bookings under a GENERAL contract are general,
|
||||
* everything else is one-time.
|
||||
*/
|
||||
bookingKind?: "ONE_TIME" | "GENERAL_CONTRACT";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user