contrat,booking,global logestic

This commit is contained in:
Marshal
2026-06-26 23:24:48 +00:00
parent f931342f31
commit 01d53c218c
105 changed files with 19573 additions and 909 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 };

View File

@@ -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>
);
}

View File

@@ -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>
);
}