mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
contrat,booking,global logestic
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { Check, Circle, Clock, MinusCircle } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
export interface ClearanceMilestoneTimelineProps {
|
||||
milestones: Freight.IClearanceMilestone[];
|
||||
/** Complete a milestone by code (omit to render read-only). */
|
||||
onComplete?: (code: string, note?: string) => void;
|
||||
/** True while a complete mutation is in flight. */
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_META: Record<
|
||||
Freight.MilestoneStatus,
|
||||
{ color: string; label: string }
|
||||
> = {
|
||||
COMPLETED: { color: "edr-green", label: "Completed" },
|
||||
PENDING: { color: "gray", label: "Pending" },
|
||||
SKIPPED: { color: "gray", label: "Skipped" },
|
||||
};
|
||||
|
||||
/** Vertical timeline of GL clearance milestones with inline complete actions. */
|
||||
export function ClearanceMilestoneTimeline({
|
||||
milestones,
|
||||
onComplete,
|
||||
busy,
|
||||
}: ClearanceMilestoneTimelineProps) {
|
||||
const [openNote, setOpenNote] = useState<Record<string, boolean>>({});
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
|
||||
const sorted = [...milestones].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
const nextPending = sorted.find((m) => m.status === "PENDING");
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
No milestones for this shipment yet.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
{sorted.map((m, index) => {
|
||||
const isLast = index === sorted.length - 1;
|
||||
const meta = STATUS_META[m.status];
|
||||
const isNext = nextPending?.id === m.id;
|
||||
const Icon =
|
||||
m.status === "COMPLETED"
|
||||
? Check
|
||||
: m.status === "SKIPPED"
|
||||
? MinusCircle
|
||||
: isNext
|
||||
? Clock
|
||||
: Circle;
|
||||
|
||||
return (
|
||||
<Group key={m.id} gap="sm" wrap="nowrap" align="flex-start">
|
||||
<Stack gap={0} align="center" style={{ flexShrink: 0 }}>
|
||||
<ThemeIcon
|
||||
variant={m.status === "COMPLETED" ? "filled" : "light"}
|
||||
color={isNext ? "edr-green" : meta.color}
|
||||
radius="xl"
|
||||
size={30}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2.2} />
|
||||
</ThemeIcon>
|
||||
{!isLast && (
|
||||
<Box
|
||||
style={{
|
||||
width: 2,
|
||||
flex: 1,
|
||||
minHeight: 28,
|
||||
background:
|
||||
m.status === "COMPLETED"
|
||||
? "var(--mantine-color-edr-green-4)"
|
||||
: "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box pb={isLast ? 0 : "md"} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{m.milestoneLabel}
|
||||
</Text>
|
||||
<Group gap={6} mt={2} wrap="nowrap">
|
||||
<Badge size="xs" variant="light" color={meta.color}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{m.ownerRegion ? (
|
||||
<Badge size="xs" variant="default">
|
||||
{m.ownerRegion}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
{m.note ? (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{m.note}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{onComplete && m.status === "PENDING" && isNext ? (
|
||||
!openNote[m.milestoneCode] ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="edr-green"
|
||||
leftSection={<Check size={13} />}
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setOpenNote((o) => ({
|
||||
...o,
|
||||
[m.milestoneCode]: true,
|
||||
}))
|
||||
}
|
||||
>
|
||||
Complete
|
||||
</Button>
|
||||
) : null
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{onComplete && openNote[m.milestoneCode] && (
|
||||
<Box mt="xs">
|
||||
<Textarea
|
||||
placeholder="Optional note for this milestone…"
|
||||
value={notes[m.milestoneCode] ?? ""}
|
||||
onChange={(e) =>
|
||||
setNotes((n) => ({
|
||||
...n,
|
||||
[m.milestoneCode]: e.currentTarget.value,
|
||||
}))
|
||||
}
|
||||
autosize
|
||||
minRows={2}
|
||||
size="sm"
|
||||
radius="md"
|
||||
/>
|
||||
<Group justify="flex-end" gap={8} mt={8}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setOpenNote((o) => ({
|
||||
...o,
|
||||
[m.milestoneCode]: false,
|
||||
}))
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="edr-green"
|
||||
leftSection={<Check size={13} />}
|
||||
loading={busy}
|
||||
onClick={() => {
|
||||
onComplete(
|
||||
m.milestoneCode,
|
||||
notes[m.milestoneCode]?.trim() || undefined,
|
||||
);
|
||||
setOpenNote((o) => ({
|
||||
...o,
|
||||
[m.milestoneCode]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
Mark complete
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Check,
|
||||
FileSignature,
|
||||
MessageSquareWarning,
|
||||
PackagePlus,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canCreateContractBooking } from "@/lib/permissions";
|
||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||
|
||||
type Mutations = ReturnType<typeof useContractMutations>;
|
||||
|
||||
interface ContractActionsToolbarProps {
|
||||
contract: Freight.IContract;
|
||||
mutations: Mutations;
|
||||
}
|
||||
|
||||
/** Detail-page staff actions: accept / request changes / reject / generate / sign. */
|
||||
export function ContractActionsToolbar({
|
||||
contract,
|
||||
mutations,
|
||||
}: ContractActionsToolbarProps) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { status } = contract;
|
||||
|
||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
||||
const [validityDays, setValidityDays] = useState<number | string>(365);
|
||||
const [changesOpen, setChangesOpen] = useState(false);
|
||||
const [changesNote, setChangesNote] = useState("");
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
|
||||
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";
|
||||
const canGenerate = ["APPROVED", "APPROVED_PENDING_SIGNATURE"].includes(
|
||||
status,
|
||||
);
|
||||
const canSign = ["CONTRACT_READY", "SIGNED_CUSTOMER"].includes(status);
|
||||
const canCreateBooking =
|
||||
status === "CLEARANCE_READY_FOR_BOOKING" &&
|
||||
contract.customsClearingEnabled &&
|
||||
canCreateContractBooking(user);
|
||||
|
||||
const signStaff = () =>
|
||||
mutations.signContract.mutate({
|
||||
role: "STAFF",
|
||||
signatureImageBase64: "",
|
||||
signerDisplayName:
|
||||
user?.name?.en || user?.username || user?.email || "Staff",
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionCard icon={Zap} title="Staff actions">
|
||||
<Stack gap="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
Confirm each step before it is applied.
|
||||
</Text>
|
||||
|
||||
{canAccept && (
|
||||
<>
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<Check size={16} />}
|
||||
onClick={() => setAcceptOpen(true)}
|
||||
>
|
||||
Accept for approval
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<MessageSquareWarning size={16} />}
|
||||
onClick={() => setChangesOpen(true)}
|
||||
>
|
||||
Request changes
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<XCircle size={16} />}
|
||||
onClick={() => setRejectOpen(true)}
|
||||
>
|
||||
Reject contract
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canGenerate && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<Sparkles size={16} />}
|
||||
loading={mutations.generateContract.isPending}
|
||||
onClick={() => mutations.generateContract.mutate()}
|
||||
>
|
||||
Generate contract
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canSign && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
loading={mutations.signContract.isPending}
|
||||
onClick={signStaff}
|
||||
>
|
||||
Sign as staff
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canCreateBooking && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/contracts/${contract.id}/create-booking`)
|
||||
}
|
||||
>
|
||||
Create booking (GL)
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!canAccept &&
|
||||
!canGenerate &&
|
||||
!canSign &&
|
||||
!canCreateBooking && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No staff actions available for this status. Monitor until the
|
||||
workflow advances.
|
||||
</Text>
|
||||
)}
|
||||
</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">
|
||||
Set the contract validity window, then start the approval chain.
|
||||
</Text>
|
||||
<NumberInput
|
||||
label="Validity (days)"
|
||||
min={1}
|
||||
value={validityDays}
|
||||
onChange={setValidityDays}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={mutations.staffAccept.isPending}
|
||||
onClick={() =>
|
||||
mutations.staffAccept.mutate(Number(validityDays) || 365, {
|
||||
onSuccess: () => setAcceptOpen(false),
|
||||
})
|
||||
}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||
import type { ContractListRow } from "@/features/contracts/mapContractListRow";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ContractApprovalProgressCellProps {
|
||||
row: ContractListRow;
|
||||
}
|
||||
|
||||
export function ContractApprovalProgressCell({
|
||||
row,
|
||||
}: ContractApprovalProgressCellProps) {
|
||||
const summary = formatContractApprovalProgress(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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useMemo } from "react";
|
||||
import { Check, ShieldCheck } from "lucide-react";
|
||||
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||
|
||||
type Mutations = ReturnType<typeof useContractMutations>;
|
||||
|
||||
interface ContractApprovalStepsCardProps {
|
||||
contract: Freight.IContract;
|
||||
mutations: Mutations;
|
||||
}
|
||||
|
||||
/** Approval chain with inline approve on the next pending step. */
|
||||
export function ContractApprovalStepsCard({
|
||||
contract,
|
||||
mutations,
|
||||
}: ContractApprovalStepsCardProps) {
|
||||
const steps = useMemo(
|
||||
() =>
|
||||
[...(contract.approvalSteps ?? [])].sort(
|
||||
(a, b) => a.stepOrder - b.stepOrder,
|
||||
),
|
||||
[contract.approvalSteps],
|
||||
);
|
||||
|
||||
const nextPending = steps.find((s) => s.status === "PENDING");
|
||||
const summary = formatContractApprovalProgress(contract.status, steps);
|
||||
|
||||
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}
|
||||
isNext={nextPending?.id === step.id}
|
||||
isPending={mutations.approveStep.isPending}
|
||||
onApprove={() =>
|
||||
mutations.approveStep.mutate({
|
||||
stepId: step.id,
|
||||
requiredRole: step.requiredRole,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function StepRow({
|
||||
step,
|
||||
isNext,
|
||||
isPending,
|
||||
onApprove,
|
||||
}: {
|
||||
step: Freight.IContractApprovalStep;
|
||||
isNext: boolean;
|
||||
isPending: boolean;
|
||||
onApprove: () => void;
|
||||
}) {
|
||||
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.note && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{step.note}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{isNext && step.status === "PENDING" && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<Check size={14} />}
|
||||
disabled={isPending}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
<Badge
|
||||
variant="light"
|
||||
color={statusColor}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
tt="uppercase"
|
||||
>
|
||||
{step.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileCheck2,
|
||||
FileText,
|
||||
MessageSquareWarning,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useContractClearanceMutations } from "@/hooks/contracts/useContracts";
|
||||
|
||||
export interface ContractClearanceReviewSectionProps {
|
||||
contractId: string;
|
||||
/** Called after any review/finalize mutation so the parent can refetch. */
|
||||
onChanged?: () => void;
|
||||
/** Hide the inline progress summary (e.g. when the parent renders its own). */
|
||||
hideSummary?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_META: Record<
|
||||
Freight.ContractDocReviewStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
APPROVED: { label: "Approved", color: "edr-green" },
|
||||
QUERIED: { label: "Queried", color: "red" },
|
||||
PENDING: { label: "Pending", color: "gray" },
|
||||
};
|
||||
|
||||
/**
|
||||
* GL-ET pre-booking clearance review for a CONTRACT (Path B): approve / query
|
||||
* each customer document, upload GL output documents and finalize once every
|
||||
* required document is approved → CLEARANCE_READY_FOR_BOOKING.
|
||||
*/
|
||||
export function ContractClearanceReviewSection({
|
||||
contractId,
|
||||
onChanged,
|
||||
hideSummary,
|
||||
}: ContractClearanceReviewSectionProps) {
|
||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
||||
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({});
|
||||
|
||||
const { data: clearance, isLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId),
|
||||
queryFn: () => contractsService.getClearance(contractId),
|
||||
});
|
||||
|
||||
const { reviewDocument, uploadOutputDocuments, finalizeClearance } =
|
||||
useContractClearanceMutations(contractId);
|
||||
|
||||
const customerDocs = useMemo(
|
||||
() =>
|
||||
(clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
|
||||
[clearance],
|
||||
);
|
||||
const glDocs = useMemo(
|
||||
() =>
|
||||
(clearance?.documents ?? []).filter(
|
||||
(d) => d.uploadedBy === "gl_et" || d.uploadedBy === "gl_dj",
|
||||
),
|
||||
[clearance],
|
||||
);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = customerDocs.length;
|
||||
const approved = customerDocs.filter(
|
||||
(d) => d.reviewStatus === "APPROVED",
|
||||
).length;
|
||||
const queried = customerDocs.filter(
|
||||
(d) => d.reviewStatus === "QUERIED",
|
||||
).length;
|
||||
const pending = total - approved - queried;
|
||||
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
|
||||
return { total, approved, queried, pending, pct };
|
||||
}, [customerDocs]);
|
||||
|
||||
if (isLoading || !clearance) {
|
||||
return (
|
||||
<Group justify="center" py="xl" gap={10}>
|
||||
<Loader size="sm" color="edr-green" />
|
||||
<Text c="dimmed">Loading clearance…</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const handleReview = (
|
||||
fileKey: string,
|
||||
status: "APPROVED" | "QUERIED",
|
||||
note?: string,
|
||||
) =>
|
||||
reviewDocument.mutate(
|
||||
{ fileKey, status, note },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (status === "QUERIED")
|
||||
setOpenQuery((o) => ({ ...o, [fileKey]: false }));
|
||||
onChanged?.();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionCard
|
||||
icon={FileText}
|
||||
title="Customer documents"
|
||||
subtitle="Approve each document, or open a query to tell the customer what to fix."
|
||||
extra={
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
{stats.approved}/{stats.total} approved
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack gap={12}>
|
||||
{!hideSummary && stats.total > 0 && (
|
||||
<Box>
|
||||
<Progress
|
||||
value={stats.pct}
|
||||
color="edr-green"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
mb={6}
|
||||
/>
|
||||
<Group gap="lg">
|
||||
<StatPill color="edr-green" label="Approved" value={stats.approved} />
|
||||
<StatPill color="red" label="Queried" value={stats.queried} />
|
||||
<StatPill color="gray" label="Pending" value={stats.pending} />
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
{customerDocs.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No customer documents are required for this contract.
|
||||
</Text>
|
||||
) : (
|
||||
customerDocs.map((doc) => (
|
||||
<DocReviewCard
|
||||
key={`${doc.settingCode}:${doc.fileKey}`}
|
||||
doc={doc}
|
||||
note={queryNotes[doc.fileKey] ?? ""}
|
||||
queryOpen={openQuery[doc.fileKey] ?? false}
|
||||
onToggleQuery={(open) =>
|
||||
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
|
||||
}
|
||||
onNote={(v) =>
|
||||
setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))
|
||||
}
|
||||
onApprove={() => handleReview(doc.fileKey, "APPROVED")}
|
||||
onQuery={() =>
|
||||
handleReview(doc.fileKey, "QUERIED", queryNotes[doc.fileKey])
|
||||
}
|
||||
busy={reviewDocument.isPending}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
{glDocs.length > 0 && (
|
||||
<SectionCard
|
||||
icon={Upload}
|
||||
title="GL output documents"
|
||||
subtitle="Upload IM4/IM5/EX3/EX8/T1 and other cleared paperwork."
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack gap={10}>
|
||||
{glDocs.map((doc) => (
|
||||
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<FileText size={16} color="var(--mantine-color-edr-green-6)" />
|
||||
<Text fz="13px" c="edr-text" truncate>
|
||||
{doc.label}
|
||||
{doc.required ? " *" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{doc.file ? (
|
||||
<Tooltip label="Download">
|
||||
<Box
|
||||
component="a"
|
||||
href={doc.file.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
c="edr-green"
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<Download size={15} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text fz="12px" c="edr-muted">
|
||||
Not uploaded
|
||||
</Text>
|
||||
)}
|
||||
<FileButton
|
||||
onChange={(f) =>
|
||||
f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f }))
|
||||
}
|
||||
accept="application/pdf,image/*"
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Upload size={13} />}
|
||||
>
|
||||
{outputFiles[doc.fileKey] ? "Selected" : "Upload"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Upload size={15} />}
|
||||
disabled={Object.keys(outputFiles).length === 0}
|
||||
loading={uploadOutputDocuments.isPending}
|
||||
onClick={() =>
|
||||
uploadOutputDocuments.mutate(outputFiles, {
|
||||
onSuccess: () => {
|
||||
setOutputFiles({});
|
||||
onChanged?.();
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Upload output documents
|
||||
</Button>
|
||||
</Group>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{finalizeClearance.isError && (
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
{finalizeClearance.error instanceof Error
|
||||
? finalizeClearance.error.message
|
||||
: "Could not finalize clearance."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={clearance.allApproved ? "edr-green" : "gray"}
|
||||
radius="md"
|
||||
size={28}
|
||||
>
|
||||
<FileCheck2 size={15} />
|
||||
</ThemeIcon>
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
{clearance.allApproved
|
||||
? "All required documents are approved — you can finalize."
|
||||
: "Approve every required document to unlock finalization."}
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
disabled={!clearance.allApproved}
|
||||
loading={finalizeClearance.isPending}
|
||||
onClick={() =>
|
||||
finalizeClearance.mutate(undefined, {
|
||||
onSuccess: () => onChanged?.(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Finalize clearance
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function StatPill({
|
||||
color,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${color}-6)`,
|
||||
}}
|
||||
/>
|
||||
<Text fz="12.5px" c="edr-text" fw={600}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function DocReviewCard({
|
||||
doc,
|
||||
note,
|
||||
queryOpen,
|
||||
onToggleQuery,
|
||||
onNote,
|
||||
onApprove,
|
||||
onQuery,
|
||||
busy,
|
||||
}: {
|
||||
doc: Freight.ContractClearanceDocument;
|
||||
note: string;
|
||||
queryOpen: boolean;
|
||||
onToggleQuery: (open: boolean) => void;
|
||||
onNote: (v: string) => void;
|
||||
onApprove: () => void;
|
||||
onQuery: () => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const status = doc.reviewStatus ?? "PENDING";
|
||||
const meta = STATUS_META[status];
|
||||
const hasFile = !!doc.file;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{
|
||||
borderColor:
|
||||
status === "QUERIED"
|
||||
? "var(--mantine-color-red-2)"
|
||||
: status === "APPROVED"
|
||||
? "var(--mantine-color-edr-green-2)"
|
||||
: "var(--mantine-color-edr-border-6)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={hasFile ? "edr-green" : "gray"}
|
||||
radius="md"
|
||||
size={40}
|
||||
>
|
||||
<FileText size={19} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz="14px" fw={700} c="edr-text" truncate>
|
||||
{doc.label}
|
||||
{doc.required ? " *" : ""}
|
||||
</Text>
|
||||
<Text fz="12px" c="edr-muted" truncate>
|
||||
{hasFile ? doc.file!.name : "Not uploaded by customer"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Badge variant="light" color={meta.color} radius="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{hasFile && (
|
||||
<Tooltip label="Open document">
|
||||
<Button
|
||||
component="a"
|
||||
href={doc.file!.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<ExternalLink size={13} />}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{status === "QUERIED" && doc.note && (
|
||||
<Alert
|
||||
mt="sm"
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<MessageSquareWarning size={15} />}
|
||||
p="xs"
|
||||
>
|
||||
<Text fz="12.5px" c="red.9">
|
||||
{doc.note}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasFile && (
|
||||
<Box mt="sm">
|
||||
{!queryOpen ? (
|
||||
<Group justify="flex-end" gap={8}>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<MessageSquareWarning size={14} />}
|
||||
disabled={busy}
|
||||
onClick={() => onToggleQuery(true)}
|
||||
>
|
||||
Open query
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
disabled={busy}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: "var(--mantine-color-red-0)",
|
||||
border: "1px solid var(--mantine-color-red-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap={6} mb={6}>
|
||||
<MessageSquareWarning
|
||||
size={14}
|
||||
color="var(--mantine-color-red-7)"
|
||||
/>
|
||||
<Text fz="12.5px" fw={700} c="red.8">
|
||||
Describe the problem for the customer
|
||||
</Text>
|
||||
</Group>
|
||||
<Textarea
|
||||
placeholder="e.g. The commercial invoice is missing the HS code."
|
||||
value={note}
|
||||
onChange={(e) => onNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
radius="md"
|
||||
size="sm"
|
||||
autoFocus
|
||||
/>
|
||||
<Group justify="flex-end" gap={8} mt={8}>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
disabled={busy}
|
||||
onClick={() => onToggleQuery(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<MessageSquareWarning size={14} />}
|
||||
loading={busy}
|
||||
disabled={!note.trim()}
|
||||
onClick={onQuery}
|
||||
>
|
||||
Send query to customer
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Badge, Group } from "@mantine/core";
|
||||
import { Repeat } from "lucide-react";
|
||||
|
||||
import {
|
||||
CONTRACT_STATUS_COLOR,
|
||||
CONTRACT_STATUS_STYLES,
|
||||
} from "@/features/contracts/contract-status.config";
|
||||
|
||||
interface ContractStatusBadgeProps {
|
||||
status: string;
|
||||
/** When the contract is a renewal of a prior one, show a sibling badge. */
|
||||
isRenewal?: boolean;
|
||||
}
|
||||
|
||||
export function ContractStatusBadge({
|
||||
status,
|
||||
isRenewal,
|
||||
}: ContractStatusBadgeProps) {
|
||||
const style = CONTRACT_STATUS_STYLES[status] ?? {
|
||||
label: status,
|
||||
color: "gray",
|
||||
};
|
||||
const color = CONTRACT_STATUS_COLOR[status] ?? "gray";
|
||||
|
||||
const statusBadge = (
|
||||
<Badge
|
||||
color={color}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
tt="uppercase"
|
||||
fw={600}
|
||||
title={style.label}
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
letterSpacing: "0.05em",
|
||||
display: "inline-flex",
|
||||
maxWidth: "100%",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{style.label}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (!isRenewal) return statusBadge;
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{statusBadge}
|
||||
<Badge
|
||||
color="indigo"
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
tt="uppercase"
|
||||
fw={600}
|
||||
leftSection={<Repeat size={12} />}
|
||||
title="Renewal of a prior contract"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
letterSpacing: "0.05em",
|
||||
display: "inline-flex",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
Renewal
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Badge, ScrollArea, Tabs } from "@mantine/core";
|
||||
import {
|
||||
ClipboardCheck,
|
||||
FileSignature,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
ShieldCheck,
|
||||
Truck,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import "@/components/overview/overview.css";
|
||||
import {
|
||||
CONTRACT_LIST_TABS,
|
||||
type ContractStatusTabKey,
|
||||
} from "@/features/contracts/contract-status.config";
|
||||
|
||||
const TAB_ICONS: Record<ContractStatusTabKey, React.ReactNode> = {
|
||||
all: <LayoutGrid size={17} strokeWidth={1.85} />,
|
||||
intake: <Inbox size={17} strokeWidth={1.85} />,
|
||||
in_approval: <ClipboardCheck size={17} strokeWidth={1.85} />,
|
||||
approved_contract: <FileSignature size={17} strokeWidth={1.85} />,
|
||||
clearance: <ShieldCheck size={17} strokeWidth={1.85} />,
|
||||
active: <Truck size={17} strokeWidth={1.85} />,
|
||||
closed: <XCircle size={17} strokeWidth={1.85} />,
|
||||
};
|
||||
|
||||
interface ContractStatusTabsProps {
|
||||
active: ContractStatusTabKey;
|
||||
onChange: (tab: ContractStatusTabKey) => void;
|
||||
counts?: Partial<Record<ContractStatusTabKey, number>>;
|
||||
}
|
||||
|
||||
export function ContractStatusTabs({
|
||||
active,
|
||||
onChange,
|
||||
counts,
|
||||
}: ContractStatusTabsProps) {
|
||||
return (
|
||||
<Tabs
|
||||
value={active}
|
||||
onChange={(value) => onChange((value as ContractStatusTabKey) ?? "all")}
|
||||
variant="pills"
|
||||
color="edr-green"
|
||||
keepMounted={false}
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars="x">
|
||||
<Tabs.List style={{ flexWrap: "nowrap", width: "max-content" }}>
|
||||
{CONTRACT_LIST_TABS.map((tab) => {
|
||||
const isActive = active === tab.key;
|
||||
const count = counts?.[tab.key];
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
leftSection={TAB_ICONS[tab.key]}
|
||||
size={"sm"}
|
||||
rightSection={
|
||||
count !== undefined ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant={isActive ? "white" : "light"}
|
||||
color={isActive ? "edr-green" : "gray"}
|
||||
styles={
|
||||
isActive
|
||||
? {
|
||||
root: {
|
||||
background: "rgba(255,255,255,0.9)",
|
||||
color: "#15805f",
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export type { ContractStatusTabKey };
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
Check,
|
||||
FileSignature,
|
||||
FileText,
|
||||
ShieldCheck,
|
||||
Truck,
|
||||
Workflow,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Paper, Group, Stack, Text, Box } from "@mantine/core";
|
||||
|
||||
import {
|
||||
CONTRACT_WORKFLOW_STAGES,
|
||||
getContractWorkflowStageIndex,
|
||||
} from "@/features/contracts/contract-status.config";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import {
|
||||
BRAND_GREEN,
|
||||
detailStyles,
|
||||
} from "@/components/bookings/detail/booking-detail.styles";
|
||||
|
||||
const STAGE_ICONS: LucideIcon[] = [
|
||||
FileText,
|
||||
FileSignature,
|
||||
FileSignature,
|
||||
ShieldCheck,
|
||||
Truck,
|
||||
Check,
|
||||
];
|
||||
|
||||
interface ContractWorkflowStepperProps {
|
||||
status: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function ContractWorkflowStepper({
|
||||
status,
|
||||
title,
|
||||
description,
|
||||
}: ContractWorkflowStepperProps) {
|
||||
const currentStage = getContractWorkflowStageIndex(status);
|
||||
const isTerminal = currentStage < 0;
|
||||
|
||||
return (
|
||||
<SectionCard icon={Workflow} title="Workflow progress">
|
||||
<Group gap={0} wrap="nowrap" align="flex-start" mb="lg">
|
||||
{CONTRACT_WORKFLOW_STAGES.map((stage, index) => {
|
||||
const Icon = STAGE_ICONS[index] ?? FileText;
|
||||
const isComplete = !isTerminal && index < currentStage;
|
||||
const isActive = !isTerminal && index === currentStage;
|
||||
const isLast = index === CONTRACT_WORKFLOW_STAGES.length - 1;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={stage.label}
|
||||
style={{ flex: isLast ? "0 0 auto" : 1, minWidth: 0 }}
|
||||
>
|
||||
<Group gap={0} wrap="nowrap" align="center">
|
||||
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: "50%",
|
||||
background: isComplete
|
||||
? BRAND_GREEN
|
||||
: isActive
|
||||
? "white"
|
||||
: "var(--mantine-color-gray-1)",
|
||||
border: isActive
|
||||
? `2px solid ${BRAND_GREEN}`
|
||||
: isComplete
|
||||
? "2px solid transparent"
|
||||
: "2px solid var(--mantine-color-gray-2)",
|
||||
color: isComplete
|
||||
? "white"
|
||||
: isActive
|
||||
? "var(--freight-brand-dark)"
|
||||
: "var(--mantine-color-gray-5)",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{isComplete ? (
|
||||
<Check size={16} strokeWidth={3} />
|
||||
) : (
|
||||
<Icon size={15} />
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={isActive ? 600 : 500}
|
||||
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
|
||||
ta="center"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{stage.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
{!isLast && (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 2,
|
||||
marginInline: 8,
|
||||
marginBottom: 20,
|
||||
borderRadius: 2,
|
||||
background: isComplete
|
||||
? BRAND_GREEN
|
||||
: "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
|
||||
<Paper
|
||||
radius="md"
|
||||
withBorder
|
||||
p="md"
|
||||
style={
|
||||
isTerminal
|
||||
? detailStyles.statusBannerTerminal
|
||||
: detailStyles.statusBanner
|
||||
}
|
||||
>
|
||||
<Text size="sm" fw={600} c={isTerminal ? "red.7" : "dark"}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{description}
|
||||
</Text>
|
||||
</Paper>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Container as ContainerIcon,
|
||||
FileText,
|
||||
Package,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import {
|
||||
useContractDetail,
|
||||
useContractMutations,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
|
||||
interface UnitDraft {
|
||||
containerNumber: string;
|
||||
sealNumber: string;
|
||||
vgmTons: number | string;
|
||||
}
|
||||
|
||||
interface ContainerLineDraft {
|
||||
containerSize: string;
|
||||
hazardousQuantity: number | string;
|
||||
reeferQuantity: number | string;
|
||||
units: UnitDraft[];
|
||||
}
|
||||
|
||||
interface BulkLineDraft {
|
||||
cargoTypeId: string;
|
||||
cargoWeightTons: number | string;
|
||||
itemCount: number | string;
|
||||
hazardousQuantity: number | string;
|
||||
}
|
||||
|
||||
function emptyUnit(): UnitDraft {
|
||||
return { containerNumber: "", sealNumber: "", vgmTons: "" };
|
||||
}
|
||||
|
||||
export default function GlCreateBookingForm() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: contract, isLoading } = useContractDetail(id);
|
||||
const mutations = useContractMutations(id ?? "");
|
||||
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
|
||||
|
||||
const isContainer = contract?.freightType === "CONTAINER";
|
||||
const routes = useMemo(
|
||||
() =>
|
||||
[...(contract?.routes ?? [])].sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
[contract?.routes],
|
||||
);
|
||||
const needsRouteSelect = contract?.contractKind === "GENERAL" && routes.length > 1;
|
||||
|
||||
const containerSizes = useMemo(() => {
|
||||
const sizes = new Set<string>();
|
||||
(contract?.cargoScope ?? []).forEach((s) => {
|
||||
if (s.containerSize) sizes.add(s.containerSize);
|
||||
});
|
||||
return [...sizes];
|
||||
}, [contract?.cargoScope]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Center mih="50vh">
|
||||
<Loader color="gray" />
|
||||
</Center>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!contract) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Contract not found"
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Container line helpers ──
|
||||
const addContainerLine = () =>
|
||||
setContainerLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
containerSize: containerSizes[0] ?? "20ft",
|
||||
hazardousQuantity: "",
|
||||
reeferQuantity: "",
|
||||
units: [emptyUnit()],
|
||||
},
|
||||
]);
|
||||
const removeContainerLine = (idx: number) =>
|
||||
setContainerLines((prev) => prev.filter((_, i) => i !== idx));
|
||||
const patchLine = (idx: number, patch: Partial<ContainerLineDraft>) =>
|
||||
setContainerLines((prev) =>
|
||||
prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)),
|
||||
);
|
||||
const addUnit = (lineIdx: number) =>
|
||||
patchLine(lineIdx, {
|
||||
units: [...containerLines[lineIdx].units, emptyUnit()],
|
||||
});
|
||||
const removeUnit = (lineIdx: number, unitIdx: number) =>
|
||||
patchLine(lineIdx, {
|
||||
units: containerLines[lineIdx].units.filter((_, i) => i !== unitIdx),
|
||||
});
|
||||
const patchUnit = (
|
||||
lineIdx: number,
|
||||
unitIdx: number,
|
||||
patch: Partial<UnitDraft>,
|
||||
) =>
|
||||
patchLine(lineIdx, {
|
||||
units: containerLines[lineIdx].units.map((u, i) =>
|
||||
i === unitIdx ? { ...u, ...patch } : u,
|
||||
),
|
||||
});
|
||||
|
||||
// ── Bulk line helpers ──
|
||||
const addBulkLine = () =>
|
||||
setBulkLines((prev) => [
|
||||
...prev,
|
||||
{ cargoTypeId: "", cargoWeightTons: "", itemCount: "", hazardousQuantity: "" },
|
||||
]);
|
||||
const removeBulkLine = (idx: number) =>
|
||||
setBulkLines((prev) => prev.filter((_, i) => i !== idx));
|
||||
const patchBulk = (idx: number, patch: Partial<BulkLineDraft>) =>
|
||||
setBulkLines((prev) =>
|
||||
prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)),
|
||||
);
|
||||
|
||||
const canSubmit =
|
||||
Boolean(scheduledDate) &&
|
||||
(!needsRouteSelect || Boolean(contractRouteId)) &&
|
||||
(isContainer ? containerLines.length > 0 : bulkLines.length > 0);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!scheduledDate) return;
|
||||
|
||||
const payload: Freight.CreateBookingUnderContractDto = {
|
||||
scheduledDate,
|
||||
...(contractRouteId ? { contractRouteId } : {}),
|
||||
...(notes.trim() ? { notes: notes.trim() } : {}),
|
||||
};
|
||||
|
||||
if (isContainer) {
|
||||
payload.containers = containerLines.map((l) => ({
|
||||
containerSize: l.containerSize,
|
||||
quantity: l.units.length,
|
||||
...(l.hazardousQuantity !== ""
|
||||
? { hazardousQuantity: Number(l.hazardousQuantity) }
|
||||
: {}),
|
||||
...(l.reeferQuantity !== ""
|
||||
? { reeferQuantity: Number(l.reeferQuantity) }
|
||||
: {}),
|
||||
units: l.units.map((u) => ({
|
||||
containerNumber: u.containerNumber,
|
||||
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
||||
vgmTons: Number(u.vgmTons) || 0,
|
||||
})),
|
||||
}));
|
||||
} else {
|
||||
payload.bulkLines = bulkLines.map((l) => ({
|
||||
...(l.cargoTypeId ? { cargoTypeId: l.cargoTypeId } : {}),
|
||||
...(l.cargoWeightTons !== ""
|
||||
? { cargoWeightTons: Number(l.cargoWeightTons) }
|
||||
: {}),
|
||||
...(l.itemCount !== "" ? { itemCount: Number(l.itemCount) } : {}),
|
||||
...(l.hazardousQuantity !== ""
|
||||
? { hazardousQuantity: Number(l.hazardousQuantity) }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
mutations.createBooking.mutate(payload, {
|
||||
onSuccess: (booking) =>
|
||||
navigate(`/dashboard/bookings/${booking.id}/milestones`),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Create booking (GL)"
|
||||
subtitle={`Enter the shipment details on behalf of the customer for contract ${contract.reference}.`}
|
||||
backTo={`/dashboard/contracts/clearance/${contract.id}`}
|
||||
breadcrumbs={[
|
||||
{ label: "Contract Clearance", href: "/dashboard/contracts/clearance" },
|
||||
{
|
||||
label: contract.reference,
|
||||
href: `/dashboard/contracts/clearance/${contract.id}`,
|
||||
},
|
||||
{ label: "Create booking" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Stack gap="lg">
|
||||
<SectionCard icon={FileText} title="Schedule">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<TextInput
|
||||
label="Scheduled date"
|
||||
type="date"
|
||||
description="Binding shipment day"
|
||||
value={scheduledDate}
|
||||
onChange={(e) => setScheduledDate(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</Grid.Col>
|
||||
{needsRouteSelect && (
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Select
|
||||
label="Route"
|
||||
placeholder="Select contract route"
|
||||
value={contractRouteId}
|
||||
onChange={setContractRouteId}
|
||||
data={routes.map((r) => ({
|
||||
value: r.id,
|
||||
label: `${r.originYard?.label ?? r.originYard?.code ?? "Origin"} → ${
|
||||
r.destinationYard?.label ??
|
||||
r.destinationYard?.code ??
|
||||
"Destination"
|
||||
}`,
|
||||
}))}
|
||||
required
|
||||
/>
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
</SectionCard>
|
||||
|
||||
{isContainer ? (
|
||||
<SectionCard
|
||||
icon={ContainerIcon}
|
||||
title="Containers"
|
||||
extra={
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={14} />}
|
||||
onClick={addContainerLine}
|
||||
>
|
||||
Add line
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{containerLines.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Add at least one container line.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="lg">
|
||||
{containerLines.map((line, lineIdx) => (
|
||||
<Box
|
||||
key={lineIdx}
|
||||
p="md"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={600} size="sm">
|
||||
Line {lineIdx + 1}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => removeContainerLine(lineIdx)}
|
||||
aria-label="Remove line"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Grid gap="sm">
|
||||
<Grid.Col span={{ base: 12, sm: 4 }}>
|
||||
<Select
|
||||
label="Container size"
|
||||
value={line.containerSize}
|
||||
onChange={(v) =>
|
||||
patchLine(lineIdx, {
|
||||
containerSize: v ?? line.containerSize,
|
||||
})
|
||||
}
|
||||
data={
|
||||
containerSizes.length > 0
|
||||
? containerSizes
|
||||
: ["20ft", "40ft"]
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 6, sm: 4 }}>
|
||||
<NumberInput
|
||||
label="Hazard qty"
|
||||
min={0}
|
||||
value={line.hazardousQuantity}
|
||||
onChange={(v) =>
|
||||
patchLine(lineIdx, { hazardousQuantity: v })
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 6, sm: 4 }}>
|
||||
<NumberInput
|
||||
label="Reefer qty"
|
||||
min={0}
|
||||
value={line.reeferQuantity}
|
||||
onChange={(v) =>
|
||||
patchLine(lineIdx, { reeferQuantity: v })
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Divider
|
||||
my="sm"
|
||||
label={`${line.units.length} container unit${
|
||||
line.units.length === 1 ? "" : "s"
|
||||
}`}
|
||||
labelPosition="left"
|
||||
/>
|
||||
|
||||
<Stack gap="xs">
|
||||
{line.units.map((unit, unitIdx) => (
|
||||
<Grid key={unitIdx} gap="xs" align="flex-end">
|
||||
<Grid.Col span={{ base: 12, sm: 4 }}>
|
||||
<TextInput
|
||||
label={unitIdx === 0 ? "Container no." : undefined}
|
||||
placeholder="MSKU1234567"
|
||||
value={unit.containerNumber}
|
||||
onChange={(e) =>
|
||||
patchUnit(lineIdx, unitIdx, {
|
||||
containerNumber: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 6, sm: 3 }}>
|
||||
<TextInput
|
||||
label={unitIdx === 0 ? "Seal no." : undefined}
|
||||
placeholder="Optional"
|
||||
value={unit.sealNumber}
|
||||
onChange={(e) =>
|
||||
patchUnit(lineIdx, unitIdx, {
|
||||
sealNumber: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 5, sm: 3 }}>
|
||||
<NumberInput
|
||||
label={unitIdx === 0 ? "VGM (t)" : undefined}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={unit.vgmTons}
|
||||
onChange={(v) =>
|
||||
patchUnit(lineIdx, unitIdx, { vgmTons: v })
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 1, sm: 2 }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={line.units.length === 1}
|
||||
onClick={() => removeUnit(lineIdx, unitIdx)}
|
||||
aria-label="Remove unit"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</ActionIcon>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
))}
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={13} />}
|
||||
onClick={() => addUnit(lineIdx)}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Add container unit
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
) : (
|
||||
<SectionCard
|
||||
icon={Package}
|
||||
title="Bulk cargo"
|
||||
extra={
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={14} />}
|
||||
onClick={addBulkLine}
|
||||
>
|
||||
Add line
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{bulkLines.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Add at least one bulk line.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{bulkLines.map((line, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p="md"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={600} size="sm">
|
||||
Line {idx + 1}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => removeBulkLine(idx)}
|
||||
aria-label="Remove line"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Grid gap="sm">
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<TextInput
|
||||
label="Cargo type id"
|
||||
placeholder="Optional"
|
||||
value={line.cargoTypeId}
|
||||
onChange={(e) =>
|
||||
patchBulk(idx, { cargoTypeId: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 6, sm: 6 }}>
|
||||
<NumberInput
|
||||
label="Weight (tons)"
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={line.cargoWeightTons}
|
||||
onChange={(v) =>
|
||||
patchBulk(idx, { cargoWeightTons: v })
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 6, sm: 6 }}>
|
||||
<NumberInput
|
||||
label="Item count"
|
||||
min={0}
|
||||
value={line.itemCount}
|
||||
onChange={(v) => patchBulk(idx, { itemCount: v })}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 6, sm: 6 }}>
|
||||
<NumberInput
|
||||
label="Hazard qty"
|
||||
min={0}
|
||||
value={line.hazardousQuantity}
|
||||
onChange={(v) =>
|
||||
patchBulk(idx, { hazardousQuantity: v })
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard icon={FileText} title="Notes">
|
||||
<Textarea
|
||||
placeholder="Internal GL notes (optional)"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/contracts/clearance/${contract.id}`)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
disabled={!canSubmit}
|
||||
loading={mutations.createBooking.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user