mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
changes
This commit is contained in:
@@ -1,19 +1,13 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||
import {
|
||||
Check,
|
||||
FilePen,
|
||||
FileSignature,
|
||||
MessageSquareWarning,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
@@ -23,6 +17,7 @@ import type { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ContractDocumentEditorModal } from "@/components/contracts/ContractDocumentEditorModal";
|
||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||
|
||||
/** Dropdown-settings code holding the admin-configured contract validity days. */
|
||||
@@ -54,8 +49,8 @@ export function ContractActionsToolbar({
|
||||
const navigate = useNavigate();
|
||||
const { status } = contract;
|
||||
|
||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
||||
const [validityDays, setValidityDays] = useState<string | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||
const [changesOpen, setChangesOpen] = useState(false);
|
||||
const [changesNote, setChangesNote] = useState("");
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
@@ -76,12 +71,6 @@ export function ContractActionsToolbar({
|
||||
.map((o) => ({ value: String(o.value), label: o.label })),
|
||||
[validitySetting],
|
||||
);
|
||||
// Default the selection to the first configured option when the dialog opens.
|
||||
useEffect(() => {
|
||||
if (acceptOpen && !validityDays && validityOptions.length > 0) {
|
||||
setValidityDays(validityOptions[0].value);
|
||||
}
|
||||
}, [acceptOpen, validityDays, validityOptions]);
|
||||
|
||||
if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
|
||||
return null;
|
||||
@@ -98,10 +87,16 @@ export function ContractActionsToolbar({
|
||||
}
|
||||
|
||||
const canAccept = status === "SUBMITTED";
|
||||
// Generation only becomes available once EVERY approval step is complete and
|
||||
// the contract reaches APPROVED. While any step is still pending the contract
|
||||
// stays in PENDING_APPROVAL, so this button does not appear after only the
|
||||
// first (line-staff) approval — the director step must land first.
|
||||
// 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;
|
||||
// Signing now happens on the contract VIEW page (staff must open and read the
|
||||
@@ -131,7 +126,10 @@ export function ContractActionsToolbar({
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<Check size={16} />}
|
||||
onClick={() => setAcceptOpen(true)}
|
||||
onClick={() => {
|
||||
setEditorMode("accept");
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
Accept for approval
|
||||
</Button>
|
||||
@@ -156,6 +154,43 @@ export function ContractActionsToolbar({
|
||||
</>
|
||||
)}
|
||||
|
||||
{canEditGenerate && (
|
||||
<>
|
||||
<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."}
|
||||
</Text>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<FilePen size={16} />}
|
||||
onClick={() => {
|
||||
setEditorMode("edit");
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
Edit contract articles
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={
|
||||
documentGenerated ? (
|
||||
<RefreshCw size={16} />
|
||||
) : (
|
||||
<Sparkles size={16} />
|
||||
)
|
||||
}
|
||||
loading={mutations.generateContract.isPending}
|
||||
onClick={() => mutations.generateContract.mutate()}
|
||||
>
|
||||
{documentGenerated ? "Regenerate contract" : "Generate contract"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{needsManualGenerate && (
|
||||
<Button
|
||||
fullWidth
|
||||
@@ -198,6 +233,7 @@ export function ContractActionsToolbar({
|
||||
the customer creates the booking in the portal. */}
|
||||
|
||||
{!canAccept &&
|
||||
!canEditGenerate &&
|
||||
!needsManualGenerate &&
|
||||
!canViewContract &&
|
||||
!canReviewClearance && (
|
||||
@@ -208,62 +244,28 @@ export function ContractActionsToolbar({
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Accept — sets the contract validity window */}
|
||||
<Modal
|
||||
opened={acceptOpen}
|
||||
onClose={() => setAcceptOpen(false)}
|
||||
title="Accept contract for approval"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Pick the contract validity window, then start the approval chain.
|
||||
</Text>
|
||||
{validityOptions.length > 0 ? (
|
||||
<Select
|
||||
label="Validity"
|
||||
placeholder="Select a validity period"
|
||||
data={validityOptions}
|
||||
value={validityDays}
|
||||
onChange={setValidityDays}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm" c="orange.7">
|
||||
{validityLoading
|
||||
? "Loading validity periods…"
|
||||
: "No validity periods are configured yet. Add them under "}
|
||||
{!validityLoading && (
|
||||
<Anchor
|
||||
href="/dashboard/dropdown-settings"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate("/dashboard/dropdown-settings");
|
||||
}}
|
||||
>
|
||||
Dropdown Settings
|
||||
</Anchor>
|
||||
)}
|
||||
{!validityLoading && "."}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={mutations.staffAccept.isPending}
|
||||
disabled={!validityDays}
|
||||
onClick={() => {
|
||||
const days = Number(validityDays);
|
||||
if (!days) return;
|
||||
mutations.staffAccept.mutate(days, {
|
||||
onSuccess: () => setAcceptOpen(false),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
{/* Accept / edit — review + optionally edit this contract's articles */}
|
||||
<ContractDocumentEditorModal
|
||||
opened={editorOpen}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
contractId={contract.id}
|
||||
mode={editorMode}
|
||||
validityOptions={validityOptions}
|
||||
validityLoading={validityLoading}
|
||||
accepting={mutations.staffAccept.isPending}
|
||||
saving={mutations.updateDocument.isPending}
|
||||
onAccept={(days, snapshot) =>
|
||||
mutations.staffAccept.mutate(
|
||||
{ validityDays: days, documentSnapshot: snapshot },
|
||||
{ onSuccess: () => setEditorOpen(false) },
|
||||
)
|
||||
}
|
||||
onSaveEdit={(snapshot) =>
|
||||
mutations.updateDocument.mutate(snapshot, {
|
||||
onSuccess: () => setEditorOpen(false),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Request changes */}
|
||||
<Modal
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
FileText,
|
||||
Info,
|
||||
Lock,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
|
||||
/** New client-side article id (server keeps whatever id we send). */
|
||||
function newArticleId(): string {
|
||||
const c = globalThis.crypto;
|
||||
if (c && typeof c.randomUUID === "function") return c.randomUUID();
|
||||
return `art-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
interface EditableArticle {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ContractDocumentEditorModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
contractId: string;
|
||||
/**
|
||||
* "accept" — shown from the Accept-for-approval action: pick a validity window
|
||||
* and (optionally) edit the articles, then start the approval chain.
|
||||
* "edit" — re-edit the frozen articles of an already-accepted contract before
|
||||
* generating/regenerating its PDF.
|
||||
*/
|
||||
mode: "accept" | "edit";
|
||||
/** Validity options (accept mode only). */
|
||||
validityOptions?: Array<{ value: string; label: string }>;
|
||||
validityLoading?: boolean;
|
||||
accepting?: boolean;
|
||||
saving?: boolean;
|
||||
onAccept?: (
|
||||
validityDays: number,
|
||||
snapshot: Freight.IContractDocumentSnapshot,
|
||||
) => void;
|
||||
onSaveEdit?: (snapshot: Freight.IContractDocumentSnapshot) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-contract contract-document editor. Loads the resolved template (or this
|
||||
* contract's frozen snapshot) and lets staff add/remove/reorder/edit articles
|
||||
* for THIS contract only — it never writes back to the shared six templates.
|
||||
*/
|
||||
export function ContractDocumentEditorModal({
|
||||
opened,
|
||||
onClose,
|
||||
contractId,
|
||||
mode,
|
||||
validityOptions = [],
|
||||
validityLoading = false,
|
||||
accepting = false,
|
||||
saving = false,
|
||||
onAccept,
|
||||
onSaveEdit,
|
||||
}: ContractDocumentEditorModalProps) {
|
||||
const { data: draft, isLoading } = useQuery({
|
||||
queryKey: ["contracts", contractId, "document-draft"],
|
||||
queryFn: () => contractsService.getContractDocumentDraft(contractId),
|
||||
enabled: opened && Boolean(contractId),
|
||||
// Always refetch the current draft when the dialog opens.
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const [documentTitle, setDocumentTitle] = useState("");
|
||||
const [whereasClauses, setWhereasClauses] = useState<string[]>([]);
|
||||
const [articles, setArticles] = useState<EditableArticle[]>([]);
|
||||
const [validityDays, setValidityDays] = useState<string | null>(null);
|
||||
|
||||
// Seed the editor from the loaded draft whenever the dialog (re)opens.
|
||||
useEffect(() => {
|
||||
if (!opened || !draft) return;
|
||||
setDocumentTitle(draft.documentTitle ?? "");
|
||||
setWhereasClauses(draft.whereasClauses ?? []);
|
||||
setArticles(
|
||||
(draft.articles ?? []).map((a) => ({
|
||||
id: a.id || newArticleId(),
|
||||
title: a.title,
|
||||
body: a.body,
|
||||
})),
|
||||
);
|
||||
}, [opened, draft]);
|
||||
|
||||
// Default validity to the first configured option (accept mode).
|
||||
useEffect(() => {
|
||||
if (mode === "accept" && !validityDays && validityOptions.length > 0) {
|
||||
setValidityDays(validityOptions[0].value);
|
||||
}
|
||||
}, [mode, validityDays, validityOptions]);
|
||||
|
||||
const locked = mode === "edit" && Boolean(draft?.locked);
|
||||
|
||||
const moveArticle = (index: number, delta: number) => {
|
||||
setArticles((prev) => {
|
||||
const next = [...prev];
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= next.length) return prev;
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateArticle = (id: string, patch: Partial<EditableArticle>) =>
|
||||
setArticles((prev) =>
|
||||
prev.map((a) => (a.id === id ? { ...a, ...patch } : a)),
|
||||
);
|
||||
|
||||
const removeArticle = (id: string) =>
|
||||
setArticles((prev) => prev.filter((a) => a.id !== id));
|
||||
|
||||
const addArticle = () =>
|
||||
setArticles((prev) => [
|
||||
...prev,
|
||||
{ id: newArticleId(), title: "", body: "" },
|
||||
]);
|
||||
|
||||
const buildSnapshot = (): Freight.IContractDocumentSnapshot => ({
|
||||
code: draft?.code ?? null,
|
||||
name: draft?.name ?? null,
|
||||
documentTitle: documentTitle.trim() || null,
|
||||
whereasClauses: whereasClauses
|
||||
.map((c) => c.trim())
|
||||
.filter((c) => c.length > 0),
|
||||
articles: articles
|
||||
.filter((a) => a.title.trim().length > 0 || a.body.trim().length > 0)
|
||||
.map((a, index) => ({
|
||||
id: a.id,
|
||||
title: a.title.trim(),
|
||||
body: a.body,
|
||||
order: index + 1,
|
||||
})),
|
||||
});
|
||||
|
||||
const hasArticles = useMemo(
|
||||
() => articles.some((a) => a.title.trim() || a.body.trim()),
|
||||
[articles],
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
const snapshot = buildSnapshot();
|
||||
if (mode === "accept") {
|
||||
const days = Number(validityDays);
|
||||
if (!days) return;
|
||||
onAccept?.(days, snapshot);
|
||||
} else {
|
||||
onSaveEdit?.(snapshot);
|
||||
}
|
||||
};
|
||||
|
||||
const submitting = accepting || saving;
|
||||
const canSubmit =
|
||||
hasArticles &&
|
||||
!locked &&
|
||||
(mode === "edit" || Boolean(validityDays)) &&
|
||||
!submitting;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
centered
|
||||
radius="md"
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<FileText size={18} />
|
||||
<Text fw={700}>
|
||||
{mode === "accept"
|
||||
? "Review contract document & accept"
|
||||
: "Edit contract document"}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" color="gray" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading document…
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
variant="light"
|
||||
color={locked ? "orange" : "blue"}
|
||||
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."
|
||||
: "Edits apply to THIS contract only. The six shared templates are never changed."}
|
||||
</Alert>
|
||||
|
||||
<TextInput
|
||||
label="Document title"
|
||||
placeholder="e.g. Bulk Cargo Transportation and Customs Clearance Services"
|
||||
value={documentTitle}
|
||||
onChange={(e) => setDocumentTitle(e.currentTarget.value)}
|
||||
disabled={locked}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
WHEREAS recitals
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<Plus size={13} />}
|
||||
disabled={locked}
|
||||
onClick={() => setWhereasClauses((p) => [...p, ""])}
|
||||
>
|
||||
Add recital
|
||||
</Button>
|
||||
</Group>
|
||||
{whereasClauses.length === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
No recitals.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{whereasClauses.map((clause, i) => (
|
||||
<Group key={i} gap="xs" wrap="nowrap" align="flex-start">
|
||||
<Textarea
|
||||
style={{ flex: 1 }}
|
||||
autosize
|
||||
minRows={1}
|
||||
value={clause}
|
||||
disabled={locked}
|
||||
onChange={(e) =>
|
||||
setWhereasClauses((prev) =>
|
||||
prev.map((c, idx) =>
|
||||
idx === i ? e.currentTarget.value : c,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={locked}
|
||||
onClick={() =>
|
||||
setWhereasClauses((prev) =>
|
||||
prev.filter((_, idx) => idx !== i),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider label="Articles" labelPosition="left" />
|
||||
|
||||
<Stack gap="md">
|
||||
{articles.map((article, index) => (
|
||||
<Box
|
||||
key={article.id}
|
||||
p="sm"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb="xs" wrap="nowrap">
|
||||
<Text size="sm" fw={700} c="dimmed">
|
||||
Article {index + 1}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Tooltip label="Move up" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
disabled={locked || index === 0}
|
||||
onClick={() => moveArticle(index, -1)}
|
||||
>
|
||||
<ArrowUp size={15} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Move down" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
disabled={locked || index === articles.length - 1}
|
||||
onClick={() => moveArticle(index, 1)}
|
||||
>
|
||||
<ArrowDown size={15} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete article" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={locked}
|
||||
onClick={() => removeArticle(article.id)}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
placeholder="Article title"
|
||||
value={article.title}
|
||||
disabled={locked}
|
||||
onChange={(e) =>
|
||||
updateArticle(article.id, { title: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Article body — each line becomes a numbered clause. Use '- ' for bullets. Placeholders like {{client.companyName}} are supported."
|
||||
autosize
|
||||
minRows={3}
|
||||
styles={{ input: { fontFamily: "var(--mantine-font-family-monospace)" } }}
|
||||
value={article.body}
|
||||
disabled={locked}
|
||||
onChange={(e) =>
|
||||
updateArticle(article.id, { body: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={15} />}
|
||||
disabled={locked}
|
||||
onClick={addArticle}
|
||||
>
|
||||
Add article
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{mode === "accept" && (
|
||||
<>
|
||||
{validityOptions.length > 0 ? (
|
||||
<Select
|
||||
label="Contract validity"
|
||||
placeholder="Select a validity period"
|
||||
data={validityOptions}
|
||||
value={validityDays}
|
||||
onChange={setValidityDays}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm" c="orange.7">
|
||||
{validityLoading
|
||||
? "Loading validity periods…"
|
||||
: "No validity periods are configured yet. Add them under Dropdown Settings."}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={submitting}
|
||||
disabled={!canSubmit}
|
||||
onClick={submit}
|
||||
>
|
||||
{mode === "accept"
|
||||
? "Accept & start approval"
|
||||
: "Save document changes"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user