mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
contrat nad booking modification
This commit is contained in:
@@ -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