mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 02:28:18 +00:00
changes
This commit is contained in:
@@ -19,24 +19,50 @@ import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
Clock,
|
||||
PackageCheck,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
type RoleMode = "ET" | "DJ" | "ALL";
|
||||
|
||||
function resolveRoleMode(
|
||||
canReview: boolean,
|
||||
canEt: boolean,
|
||||
canDj: boolean,
|
||||
): RoleMode {
|
||||
if (canReview || (canEt && canDj)) return "ALL";
|
||||
if (canEt) return "ET";
|
||||
if (canDj) return "DJ";
|
||||
return "ALL";
|
||||
}
|
||||
|
||||
export default function ContractClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { user } = useAuth();
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||
const canEt =
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) || canReview;
|
||||
const canDj =
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) || canReview;
|
||||
const roleMode = resolveRoleMode(canReview, canEt, canDj);
|
||||
|
||||
const { data: contract } = useContractDetail(id);
|
||||
const {
|
||||
@@ -63,10 +89,17 @@ export default function ContractClearanceDetailPage() {
|
||||
}, [clearance]);
|
||||
|
||||
const reference = contract?.reference ?? "Clearance";
|
||||
// Customs (Path B) hub. The customer always creates the booking in the portal
|
||||
// after GL finalizes clearance — there is no GL "Create booking" action here.
|
||||
const ready = clearance?.bookingReady ?? clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
|
||||
const clearanceReadOnly = Boolean(
|
||||
const phasedCustoms =
|
||||
contract?.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled);
|
||||
const docsPhaseComplete =
|
||||
clearance?.milestones?.some(
|
||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||
) ?? false;
|
||||
const ready = clearance?.bookingReady === true;
|
||||
const docReviewLocked = phasedCustoms
|
||||
? docsPhaseComplete
|
||||
: clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
|
||||
const shipmentLocked = Boolean(
|
||||
contract?.status &&
|
||||
[
|
||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
@@ -76,6 +109,14 @@ export default function ContractClearanceDetailPage() {
|
||||
"EXPIRED",
|
||||
].includes(contract.status),
|
||||
);
|
||||
const reviewReadOnly =
|
||||
roleMode === "DJ" ? true : shipmentLocked;
|
||||
const reviewColSpan = roleMode === "DJ" ? 12 : 7;
|
||||
const actionColSpan = roleMode === "DJ" ? 12 : 5;
|
||||
const bookingHref =
|
||||
roleMode === "ET" || roleMode === "ALL"
|
||||
? `/dashboard/contracts/${id}/create-booking`
|
||||
: undefined;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -109,6 +150,8 @@ export default function ContractClearanceDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const workflowFiles = clearance.workflowFiles ?? [];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
@@ -156,15 +199,6 @@ export default function ContractClearanceDetailPage() {
|
||||
|
||||
<ClearanceHero contract={contract} stats={stats} />
|
||||
|
||||
{contract?.contractKind === "ONE_TIME" && contract.customsClearingEnabled ? (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<ClearancePhaseStepper
|
||||
clearance={clearance}
|
||||
tradeDirection={contract.tradeDirection}
|
||||
/>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{ready ? (
|
||||
<Alert
|
||||
color="edr-green"
|
||||
@@ -176,29 +210,57 @@ export default function ContractClearanceDetailPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly={clearanceReadOnly}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, lg: reviewColSpan }}>
|
||||
{roleMode === "DJ" ? (
|
||||
<SectionCard
|
||||
icon={ClipboardList}
|
||||
title="Contract context"
|
||||
accent="edr-green"
|
||||
>
|
||||
<Text size="sm" c="dimmed" mb="sm">
|
||||
Review upstream status before uploading Djibouti documents.
|
||||
</Text>
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly
|
||||
phasedCustoms={phasedCustoms}
|
||||
/>
|
||||
</SectionCard>
|
||||
) : (
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly={reviewReadOnly}
|
||||
approvalsLocked={phasedCustoms && docReviewLocked}
|
||||
phasedCustoms={phasedCustoms}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
{workflowFiles.length > 0 ? (
|
||||
<Box mt="lg">
|
||||
<ClearanceWorkflowFilesPanel
|
||||
files={workflowFiles}
|
||||
onView={view}
|
||||
onDownload={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Grid.Col span={{ base: 12, lg: actionColSpan }}>
|
||||
<Stack gap="md">
|
||||
{contract?.contractKind === "ONE_TIME" && contract.customsClearingEnabled ? (
|
||||
{phasedCustoms ? (
|
||||
<PhasedClearanceActionPanel
|
||||
contractId={id!}
|
||||
clearance={clearance}
|
||||
tradeDirection={contract.tradeDirection}
|
||||
roleMode="ALL"
|
||||
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
|
||||
roleMode={roleMode}
|
||||
onChanged={() => void refetch()}
|
||||
bookingCreateHref={
|
||||
ready ? `/dashboard/contracts/${id}/create-booking` : undefined
|
||||
}
|
||||
bookingCreateHref={ready ? bookingHref : undefined}
|
||||
/>
|
||||
) : (
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
@@ -248,17 +310,8 @@ export default function ContractClearanceDetailPage() {
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{clearance.milestones && clearance.milestones.length > 0 ? (
|
||||
<ClearanceMilestoneTimeline
|
||||
milestones={
|
||||
clearance.milestones as Parameters<
|
||||
typeof ClearanceMilestoneTimeline
|
||||
>[0]["milestones"]
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
{viewer}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Flag,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
PackageCheck,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Ship,
|
||||
ShipWheel,
|
||||
Table as TableIcon,
|
||||
Truck,
|
||||
@@ -43,9 +45,16 @@ import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import {
|
||||
useContractClearanceQueue,
|
||||
useDjClearanceQueue,
|
||||
useEtClearanceQueue,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
type QueueTab = "all" | "et" | "dj";
|
||||
|
||||
interface ClearanceRow {
|
||||
id: string;
|
||||
@@ -167,12 +176,81 @@ function StatusBadge({ row }: { row: ClearanceRow }) {
|
||||
*/
|
||||
export default function ContractClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
||||
const canDj = hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
|
||||
|
||||
const defaultQueue: QueueTab = canReview
|
||||
? "all"
|
||||
: canEt
|
||||
? "et"
|
||||
: canDj
|
||||
? "dj"
|
||||
: "all";
|
||||
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
|
||||
const [query, setQuery] = useState("");
|
||||
const [view, setView] = useState<ViewMode>("table");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } =
|
||||
useContractClearanceQueue(true);
|
||||
const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } =
|
||||
useContractClearanceQueue(queueTab === "all");
|
||||
const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } =
|
||||
useEtClearanceQueue(queueTab === "et");
|
||||
const { data: djData, isLoading: djLoading, isError: djError, isFetching: djFetching, refetch: refetchDj } =
|
||||
useDjClearanceQueue(queueTab === "dj");
|
||||
|
||||
const data =
|
||||
queueTab === "et" ? etData : queueTab === "dj" ? djData : allData;
|
||||
const isLoading =
|
||||
queueTab === "et" ? etLoading : queueTab === "dj" ? djLoading : allLoading;
|
||||
const isError =
|
||||
queueTab === "et" ? etError : queueTab === "dj" ? djError : allError;
|
||||
const isFetching =
|
||||
queueTab === "et" ? etFetching : queueTab === "dj" ? djFetching : allFetching;
|
||||
const refetch = () => {
|
||||
if (queueTab === "et") void refetchEt();
|
||||
else if (queueTab === "dj") void refetchDj();
|
||||
else void refetchAll();
|
||||
};
|
||||
|
||||
const queueTabOptions = useMemo(() => {
|
||||
const opts: { value: QueueTab; label: ReactNode }[] = [];
|
||||
if (canReview || (canEt && canDj)) {
|
||||
opts.push({
|
||||
value: "all",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ShieldCheck size={15} />
|
||||
<Box visibleFrom="sm">All</Box>
|
||||
</Group>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (canEt) {
|
||||
opts.push({
|
||||
value: "et",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Flag size={15} />
|
||||
<Box visibleFrom="sm">ET queue</Box>
|
||||
</Group>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (canDj) {
|
||||
opts.push({
|
||||
value: "dj",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Ship size={15} />
|
||||
<Box visibleFrom="sm">DJ queue</Box>
|
||||
</Group>
|
||||
),
|
||||
});
|
||||
}
|
||||
return opts;
|
||||
}, [canReview, canEt, canDj]);
|
||||
|
||||
const allRows = useMemo(
|
||||
() => (data?.items ?? []).map(toClearanceRow),
|
||||
@@ -380,6 +458,20 @@ export default function ContractClearanceListPage() {
|
||||
|
||||
<Card p={0} withBorder shadow="sm" radius="lg">
|
||||
<Stack gap={0}>
|
||||
{queueTabOptions.length > 1 ? (
|
||||
<Box px="md" pt="md">
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={queueTab}
|
||||
onChange={(v) => {
|
||||
setQueueTab(v as QueueTab);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
data={queueTabOptions}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box px="md" pt="md" pb="sm">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
@@ -48,6 +49,7 @@ import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflow
|
||||
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import {
|
||||
ContractCustomerCard,
|
||||
ContractDocumentsCard,
|
||||
@@ -59,6 +61,7 @@ import {
|
||||
useContractMutations,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -140,6 +143,15 @@ export default function ContractRequestDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const showClearanceTabQuery = Boolean(
|
||||
contract && CLEARANCE_REVIEW_STATUSES.includes(contract.status),
|
||||
);
|
||||
const { data: clearanceView } = useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
||||
queryFn: () => contractsService.getClearance(id!),
|
||||
enabled: Boolean(id) && showClearanceTabQuery,
|
||||
});
|
||||
|
||||
const downloadContractPdf = async () => {
|
||||
if (!contract?.id) return;
|
||||
try {
|
||||
@@ -222,6 +234,13 @@ export default function ContractRequestDetailPage() {
|
||||
contract.status === "APPROVED_PENDING_SIGNATURE";
|
||||
|
||||
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
|
||||
const phasedCustoms =
|
||||
contract.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled);
|
||||
const docsPhaseComplete =
|
||||
clearanceView?.milestones?.some(
|
||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||
) ?? false;
|
||||
const clearanceApprovalsLocked = phasedCustoms && docsPhaseComplete;
|
||||
// Once clearance is finalized the tab is informational only — no approve/query.
|
||||
const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
|
||||
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
|
||||
@@ -414,18 +433,39 @@ export default function ContractRequestDetailPage() {
|
||||
{/* LEFT — primary content */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
{currentTab === "clearance" ? (
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
selfClear={selfClear}
|
||||
readOnly={clearanceReadOnly}
|
||||
onChanged={() => refetch()}
|
||||
/>
|
||||
<Stack gap="lg">
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
selfClear={selfClear}
|
||||
readOnly={clearanceReadOnly}
|
||||
phasedCustoms={phasedCustoms}
|
||||
approvalsLocked={clearanceApprovalsLocked}
|
||||
onChanged={() => refetch()}
|
||||
/>
|
||||
{(clearanceView?.workflowFiles?.length ?? 0) > 0 ? (
|
||||
<ClearanceWorkflowFilesPanel
|
||||
files={clearanceView!.workflowFiles!}
|
||||
onView={view}
|
||||
onDownload={(f) => void handleDownloadFile({ id: f.id, name: f.name } as never)}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : currentTab === "documents" ? (
|
||||
<ContractDocumentsCard
|
||||
files={files}
|
||||
onView={handleViewFile}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
<Stack gap="lg">
|
||||
<ContractDocumentsCard
|
||||
files={files}
|
||||
onView={handleViewFile}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
{(clearanceView?.workflowFiles?.length ?? 0) > 0 ? (
|
||||
<ClearanceWorkflowFilesPanel
|
||||
files={clearanceView!.workflowFiles!}
|
||||
title="Customs workflow documents"
|
||||
onView={view}
|
||||
onDownload={(f) => void handleDownloadFile({ id: f.id, name: f.name } as never)}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : currentTab === "customer" ? (
|
||||
<ContractCustomerCard contract={contract} />
|
||||
) : (
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alert, Grid, Loader, Paper, Stack, Text } from "@mantine/core";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
||||
|
||||
export default function GlClearanceDetailPage({
|
||||
backTo,
|
||||
breadcrumbsLabel,
|
||||
roleMode,
|
||||
}: {
|
||||
backTo: string;
|
||||
breadcrumbsLabel: string;
|
||||
roleMode: "ET" | "DJ";
|
||||
}) {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: contract } = useContractDetail(id);
|
||||
const {
|
||||
data: clearance,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
||||
queryFn: () => contractsService.getClearance(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack align="center" py={80}>
|
||||
<Loader color="edr-green" />
|
||||
<Text c="dimmed">Loading clearance…</Text>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !clearance || !contract) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Alert color="red" icon={<AlertCircle size={16} />}>
|
||||
Could not load clearance for this contract.
|
||||
</Alert>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const bookingHref =
|
||||
roleMode === "ET" ? `/dashboard/contracts/${id}/create-booking` : undefined;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={contract.reference}
|
||||
backTo={backTo}
|
||||
breadcrumbs={[
|
||||
{ label: breadcrumbsLabel, href: backTo },
|
||||
{ label: contract.reference },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<ClearancePhaseStepper
|
||||
clearance={clearance}
|
||||
tradeDirection={contract.tradeDirection}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, lg: roleMode === "DJ" ? 12 : 7 }}>
|
||||
{roleMode === "ET" ? (
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly={false}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
) : (
|
||||
<SectionCard title="Contract context" accent="edr-green">
|
||||
<Text size="sm" c="dimmed" mb="sm">
|
||||
Review upstream status before uploading Djibouti documents.
|
||||
</Text>
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: roleMode === "DJ" ? 12 : 5 }}>
|
||||
<PhasedClearanceActionPanel
|
||||
contractId={id!}
|
||||
clearance={clearance}
|
||||
tradeDirection={contract.tradeDirection}
|
||||
roleMode={roleMode}
|
||||
onChanged={() => void refetch()}
|
||||
bookingCreateHref={bookingHref}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{clearance.milestones && clearance.milestones.length > 0 ? (
|
||||
<ClearanceMilestoneTimeline
|
||||
milestones={clearance.milestones as Parameters<typeof ClearanceMilestoneTimeline>[0]["milestones"]}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { ChevronRight, Ship } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
|
||||
export default function GlDjiboutiClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useDjClearanceQueue();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="GL Djibouti — Clearance"
|
||||
subtitle="Contracts awaiting Djibouti GL action (DO / RO)."
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py={60}>
|
||||
<Loader color="edr-green" />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{(data?.items ?? []).length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No contracts need Djibouti GL action right now.
|
||||
</Text>
|
||||
) : (
|
||||
(data?.items ?? []).map((c) => (
|
||||
<Card
|
||||
key={c.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<Ship size={18} className="text-[color:var(--freight-brand)]" />
|
||||
<div>
|
||||
<Text fw={700}>{c.reference}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{c.tradeDirection} · {c.status}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color="cyan">
|
||||
{c.tradeDirection}
|
||||
</Badge>
|
||||
<ChevronRight size={18} className="text-muted-foreground" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { ChevronRight, Flag } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { useEtClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
|
||||
export default function GlEthiopiaClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useEtClearanceQueue();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="GL Ethiopia — Clearance"
|
||||
subtitle="Contracts awaiting Ethiopia GL action in the phased clearance workflow."
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py={60}>
|
||||
<Loader color="edr-green" />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{(data?.items ?? []).length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No contracts need ET GL action right now.
|
||||
</Text>
|
||||
) : (
|
||||
(data?.items ?? []).map((c) => (
|
||||
<Card
|
||||
key={c.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dashboard/gl-ethiopia/clearance/${c.id}`)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<Flag size={18} className="text-[color:var(--freight-brand)]" />
|
||||
<div>
|
||||
<Text fw={700}>{c.reference}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{c.tradeDirection} · {c.status}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color="edr-green">
|
||||
{c.tradeDirection}
|
||||
</Badge>
|
||||
<ChevronRight size={18} className="text-muted-foreground" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,30 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { ChevronRight, Inbox, PackageSearch, RefreshCw, Search } from "lucide-react";
|
||||
import { Inbox, PackageSearch, RefreshCw, Search } from "lucide-react";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import {
|
||||
getShipmentRejectAction,
|
||||
getShipmentStaffRowAction,
|
||||
type ShipmentListRow,
|
||||
} from "@/features/contracts/mapShipmentListRow";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
|
||||
const cellMeta = {
|
||||
@@ -33,7 +41,6 @@ const fmtDate = (iso?: string | null) =>
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
/** Summarize requested quantities for the list row. */
|
||||
function summarizeLines(lines: Freight.RequestedShipmentLines): string {
|
||||
if (lines.containers?.length) {
|
||||
return lines.containers
|
||||
@@ -49,17 +56,13 @@ function summarizeLines(lines: Freight.RequestedShipmentLines): string {
|
||||
return "—";
|
||||
}
|
||||
|
||||
interface RequestRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
contractReference: string;
|
||||
scheduledDate?: string | null;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export default function ShipmentRequestsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [query, setQuery] = useState("");
|
||||
const [rejectTarget, setRejectTarget] = useState<ShipmentListRow | null>(null);
|
||||
const [rejectNote, setRejectNote] = useState("");
|
||||
const [acceptTarget, setAcceptTarget] = useState<ShipmentListRow | null>(null);
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||
queryKey: ["shipment-request-queue"],
|
||||
@@ -67,13 +70,26 @@ export default function ShipmentRequestsPage() {
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const rows = useMemo<RequestRow[]>(() => {
|
||||
const reject = useMutation({
|
||||
mutationFn: () =>
|
||||
contractsService.rejectBookingRequest(rejectTarget!.id, rejectNote),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["shipment-request-queue"] });
|
||||
setRejectTarget(null);
|
||||
setRejectNote("");
|
||||
},
|
||||
});
|
||||
|
||||
const rows = useMemo<ShipmentListRow[]>(() => {
|
||||
const all = (data ?? []).map((r) => ({
|
||||
id: r.id,
|
||||
reference: r.reference || r.id.slice(0, 8),
|
||||
contractId: r.contractId,
|
||||
contractReference: r.contract?.reference ?? r.contractId,
|
||||
scheduledDate: r.scheduledDate,
|
||||
summary: summarizeLines(r.requestedLines ?? {}),
|
||||
status: r.status,
|
||||
createdBookingId: r.createdBookingId,
|
||||
}));
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
@@ -85,7 +101,7 @@ export default function ShipmentRequestsPage() {
|
||||
);
|
||||
}, [data, query]);
|
||||
|
||||
const columns = useMemo<ColumnDef<RequestRow>[]>(
|
||||
const columns = useMemo<ColumnDef<ShipmentListRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "reference",
|
||||
@@ -126,16 +142,55 @@ export default function ShipmentRequestsPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "go",
|
||||
size: 56,
|
||||
cell: () => (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<ChevronRight size={16} className="text-muted-foreground" />
|
||||
</Group>
|
||||
),
|
||||
id: "actions",
|
||||
header: () => <span className={ruleEngineTable.headerCell}>Action</span>,
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => {
|
||||
const primary = getShipmentStaffRowAction(row.original);
|
||||
const rejectAction = getShipmentRejectAction(row.original);
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap" justify="flex-end">
|
||||
{rejectAction ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRejectTarget(row.original);
|
||||
}}
|
||||
>
|
||||
{rejectAction.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{primary.kind === "navigate" ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
variant={primary.variant === "filled" ? "filled" : primary.variant}
|
||||
color="edr-green"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (
|
||||
primary.label === "Accept" &&
|
||||
row.original.status === "PENDING"
|
||||
) {
|
||||
setAcceptTarget(row.original);
|
||||
} else {
|
||||
navigate(primary.to(row.original));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{primary.label}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -203,6 +258,95 @@ export default function ShipmentRequestsPage() {
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={rejectTarget !== null}
|
||||
onClose={() => {
|
||||
if (!reject.isPending) {
|
||||
setRejectTarget(null);
|
||||
setRejectNote("");
|
||||
}
|
||||
}}
|
||||
title="Reject shipment request"
|
||||
radius="md"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Reject request{" "}
|
||||
<Text span fw={600}>
|
||||
{rejectTarget?.reference}
|
||||
</Text>
|
||||
? The customer will be notified.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
placeholder="Explain why this request cannot be accepted…"
|
||||
value={rejectNote}
|
||||
onChange={(e) => setRejectNote(e.currentTarget.value)}
|
||||
minRows={3}
|
||||
radius="md"
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => {
|
||||
setRejectTarget(null);
|
||||
setRejectNote("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius="md"
|
||||
loading={reject.isPending}
|
||||
disabled={!rejectNote.trim()}
|
||||
onClick={() => reject.mutate()}
|
||||
>
|
||||
Reject request
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={acceptTarget !== null}
|
||||
onClose={() => setAcceptTarget(null)}
|
||||
title="Accept shipment request"
|
||||
radius="md"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Proceed to create a booking for request{" "}
|
||||
<Text span fw={600}>
|
||||
{acceptTarget?.reference}
|
||||
</Text>
|
||||
? You will confirm the shipment price before submitting.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={() => setAcceptTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
onClick={() => {
|
||||
if (!acceptTarget) return;
|
||||
const to = getShipmentStaffRowAction(acceptTarget);
|
||||
if (to.kind === "navigate") {
|
||||
navigate(to.to(acceptTarget));
|
||||
}
|
||||
setAcceptTarget(null);
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user