mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user