Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx
2026-07-23 11:06:10 +00:00

354 lines
12 KiB
TypeScript

import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
import {
Check,
Eye,
FilePen,
FileSignature,
MessageSquareWarning,
ShieldCheck,
XCircle,
Zap,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
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. */
const CONTRACT_VALIDITY_PERIODS_CODE = "contract_validity_periods";
type Mutations = ReturnType<typeof useContractMutations>;
interface ContractActionsToolbarProps {
contract: Freight.IContract;
mutations: Mutations;
/** Switch the detail page to its Clearance Review tab. */
onReviewClearance?: () => void;
}
// Contract is in the pre-booking clearance phase — staff can review the
// customer's uploaded documents.
const CLEARANCE_REVIEW_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
];
/** Detail-page staff actions: accept / request changes / reject / generate / sign. */
export function ContractActionsToolbar({
contract,
mutations,
onReviewClearance,
}: ContractActionsToolbarProps) {
const navigate = useNavigate();
const { user } = useAuth();
const { status } = contract;
// Intake permissions are split per freight type: an accept:bulk holder must
// not see the accept button on a container contract (API enforces the same).
const arm = contract.freightType === "BULK" ? "bulk" : "container";
const mayAccept = hasPermission(user, FREIGHT_PERMS.contracts.staffAccept[arm]);
const mayRequestChanges = hasPermission(
user,
FREIGHT_PERMS.contracts.requestChanges[arm],
);
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
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({
...api.dropdownSettings.getByCode.queryOptions({
input: { code: CONTRACT_VALIDITY_PERIODS_CODE },
}),
retry: false,
});
const validityOptions = useMemo(
() =>
[...(validitySetting?.children ?? [])]
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((o) => ({ value: String(o.value), label: o.label })),
[validitySetting],
);
if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
return null;
}
if (status === "CHANGES_REQUESTED") {
return (
<SectionCard icon={Zap} title="Awaiting customer">
<Text size="sm" c="dimmed">
No staff actions until the customer resubmits the contract.
</Text>
</SectionCard>
);
}
const canAccept =
status === "SUBMITTED" && (mayAccept || mayRequestChanges || mayReject);
// 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 =
["CONTRACT_READY", "SIGNED_CUSTOMER"].includes(status) &&
Boolean(contract.contractGeneratedAt);
// Show "Review clearance" while the contract is in the document-review phase.
// Reviewer = GL (Path B / customs) or Operations (Path A / no customs).
const canReviewClearance =
Boolean(onReviewClearance) &&
CLEARANCE_REVIEW_STATUSES.includes(status);
const clearanceReviewer = contract.customsClearingEnabled
? "Review clearance (GL)"
: "Review clearance (Ops)";
return (
<SectionCard icon={Zap} title="Staff actions">
<Stack gap="sm">
<Text size="xs" c="dimmed">
Confirm each step before it is applied.
</Text>
{canAccept && (
<>
{mayAccept && (
<Button
fullWidth
color="edr-green"
leftSection={<Check size={16} />}
onClick={() => {
setEditorMode("accept");
setEditorOpen(true);
}}
>
Accept for approval
</Button>
)}
{mayRequestChanges && (
<Button
fullWidth
variant="light"
color="orange"
leftSection={<MessageSquareWarning size={16} />}
onClick={() => setChangesOpen(true)}
>
Request changes
</Button>
)}
{mayReject && (
<Button
fullWidth
variant="light"
color="red"
leftSection={<XCircle size={16} />}
onClick={() => setRejectOpen(true)}
>
Reject contract
</Button>
)}
</>
)}
{inApproval && (
<>
<Text size="xs" c="dimmed">
{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={<Eye size={16} />}
onClick={() => setPreviewOpen(true)}
>
Preview document
</Button>
{canEditDocument && (
<Button
fullWidth
variant="light"
color="gray"
leftSection={<FilePen size={16} />}
onClick={() => {
setEditorMode("edit");
setEditorOpen(true);
}}
>
Edit contract articles
</Button>
)}
</>
)}
{canViewContract && (
<Button
fullWidth
color="edr-green"
leftSection={<FileSignature size={16} />}
onClick={() =>
navigate(`/dashboard/contract-requests/${contract.id}/view`)
}
>
View &amp; sign contract
</Button>
)}
{canReviewClearance && (
<Button
fullWidth
color="edr-green"
variant="light"
leftSection={<ShieldCheck size={16} />}
onClick={onReviewClearance}
>
{clearanceReviewer}
</Button>
)}
{/* GL "Create booking" removed for now — clearance ends at finalize and
the customer creates the booking in the portal. */}
{!canAccept &&
!inApproval &&
!canViewContract &&
!canReviewClearance && (
<Text size="sm" c="dimmed">
No staff actions available for this status. Monitor until the
workflow advances.
</Text>
)}
</Stack>
{/* 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),
})
}
/>
<ContractPreviewModal
opened={previewOpen}
onClose={() => setPreviewOpen(false)}
contractId={contract.id}
/>
{/* Request changes */}
<Modal
opened={changesOpen}
onClose={() => setChangesOpen(false)}
title="Request changes"
centered
>
<Stack gap="md">
<Textarea
label="What needs to change?"
placeholder="Describe the changes the customer must make…"
autosize
minRows={3}
value={changesNote}
onChange={(e) => setChangesNote(e.currentTarget.value)}
/>
<Button
color="orange"
disabled={!changesNote.trim()}
loading={mutations.requestChanges.isPending}
onClick={() =>
mutations.requestChanges.mutate(changesNote, {
onSuccess: () => {
setChangesOpen(false);
setChangesNote("");
},
})
}
>
Send to customer
</Button>
</Stack>
</Modal>
{/* Reject */}
<Modal
opened={rejectOpen}
onClose={() => setRejectOpen(false)}
title="Reject contract"
centered
>
<Stack gap="md">
<Textarea
label="Reason for rejection"
placeholder="Explain why this contract is rejected…"
autosize
minRows={3}
value={rejectReason}
onChange={(e) => setRejectReason(e.currentTarget.value)}
/>
<Button
color="red"
disabled={!rejectReason.trim()}
loading={mutations.reject.isPending}
onClick={() =>
mutations.reject.mutate(rejectReason, {
onSuccess: () => {
setRejectOpen(false);
setRejectReason("");
},
})
}
>
Reject
</Button>
</Stack>
</Modal>
</SectionCard>
);
}