mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 07:51:02 +00:00
354 lines
12 KiB
TypeScript
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 & 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>
|
|
);
|
|
}
|