This commit is contained in:
marshal
2026-07-01 20:55:17 +03:00
parent 612df8daff
commit 9c18d086d7
112 changed files with 5654 additions and 1370 deletions

View File

@@ -4,17 +4,6 @@ import type { Freight } from "@edr/types";
const BRAND_GREEN = "var(--freight-brand, #0A6F4D)";
const PHASE_LABELS: Record<string, string> = {
CUSTOMER_INTAKE: "Customer docs",
GL_ET_REVIEW: "GL ET review",
GL_DJ_COLLECTION: "GL Djibouti",
GL_ET_OUTPUT: "Declaration",
CUSTOMER_DUTY: "Duty / tax",
GL_ET_POST_CLEARANCE: "ET clearance",
GL_DJ_LOADING: "Loading",
POST_TRANSIT: "Transit",
};
const IMPORT_PHASES = [
"CUSTOMER_INTAKE",
"GL_ET_REVIEW",
@@ -24,6 +13,17 @@ const IMPORT_PHASES = [
"GL_DJ_COLLECTION",
] as const;
const PHASE_LABELS: Record<string, string> = {
CUSTOMER_INTAKE: "Customer docs",
GL_ET_REVIEW: "GL ET review",
GL_DJ_COLLECTION: "GL Djibouti DO",
GL_ET_OUTPUT: "Declaration",
CUSTOMER_DUTY: "Duty / customer pays",
GL_ET_POST_CLEARANCE: "Transit & finalize",
GL_DJ_LOADING: "Loading",
POST_TRANSIT: "Transit",
};
const EXPORT_PHASES = [
"CUSTOMER_INTAKE",
"GL_ET_REVIEW",
@@ -43,7 +43,7 @@ export function ClearancePhaseStepper({
tradeDirection,
compact = false,
}: {
clearance?: Freight.ContractClearanceView | null;
clearance?: Freight.ContractClearanceView | Freight.ClearanceView | null;
tradeDirection?: string;
compact?: boolean;
}) {

View File

@@ -0,0 +1,155 @@
import {
Badge,
Box,
Button,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { Download, Eye, FileText } from "lucide-react";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { fileViewUrl } from "@/constants/apiConfig";
const CATEGORY_LABELS: Record<
Freight.ClearanceWorkflowFileCategory,
string
> = {
declaration: "Declaration",
duty: "Duty & taxes",
transit: "Transit",
djibouti: "Djibouti",
};
const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [
"declaration",
"duty",
"transit",
"djibouti",
];
const OWNER_LABELS: Record<Freight.ClearanceWorkflowFileOwner, string> = {
customer: "Customer",
gl_et: "GL Ethiopia",
gl_dj: "GL Djibouti",
};
export interface ClearanceWorkflowFilesPanelProps {
files: Freight.ClearanceWorkflowFile[];
onView: (file: { name: string; url: string }) => void;
onDownload?: (file: { id: string; name: string }) => void;
title?: string;
}
export function ClearanceWorkflowFilesPanel({
files,
onView,
onDownload,
title = "Customs workflow documents",
}: ClearanceWorkflowFilesPanelProps) {
if (files.length === 0) return null;
const grouped = CATEGORY_ORDER.map((category) => ({
category,
label: CATEGORY_LABELS[category],
items: files.filter((f) => f.category === category),
})).filter((g) => g.items.length > 0);
return (
<SectionCard icon={FileText} title={title} accent="edr-green">
<Stack gap="md">
{grouped.map((group) => (
<Box key={group.category}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb={8}>
{group.label}
</Text>
<Stack gap={8}>
{group.items.map((item) => (
<WorkflowFileRow
key={item.code}
item={item}
onView={onView}
onDownload={onDownload}
/>
))}
</Stack>
</Box>
))}
</Stack>
</SectionCard>
);
}
function WorkflowFileRow({
item,
onView,
onDownload,
}: {
item: Freight.ClearanceWorkflowFile;
onView: (file: { name: string; url: string }) => void;
onDownload?: (file: { id: string; name: string }) => void;
}) {
const file = item.file;
if (!file) return null;
const viewUrl = fileViewUrl(file.id);
const canPreview = isViewable({ name: file.name, url: viewUrl });
return (
<Paper withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap" align="center">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={36}>
<FileText size={17} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{item.label}
</Text>
<Group gap={6} wrap="nowrap" mt={2}>
<Badge size="xs" variant="light" color="gray" radius="sm" tt="none">
{OWNER_LABELS[item.uploadedBy]}
</Badge>
<Text size="xs" c="dimmed" truncate>
{file.name}
</Text>
</Group>
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{canPreview ? (
<Tooltip label="Preview">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() => onView({ name: file.name, url: viewUrl })}
>
View
</Button>
</Tooltip>
) : null}
{onDownload ? (
<Tooltip label="Download">
<Button
size="compact-xs"
variant="light"
radius="md"
leftSection={<Download size={13} />}
onClick={() => onDownload({ id: file.id, name: file.name })}
>
Download
</Button>
</Tooltip>
) : null}
</Group>
</Group>
</Paper>
);
}

View File

@@ -1,6 +1,14 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Check, ShieldCheck } from "lucide-react";
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
import {
Stack,
Group,
Text,
Badge,
Button,
Box,
Modal,
} from "@mantine/core";
import type { Freight } from "@edr/types";
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
@@ -19,6 +27,10 @@ export function ContractApprovalStepsCard({
contract,
mutations,
}: ContractApprovalStepsCardProps) {
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] =
useState<Freight.IContractApprovalStep | null>(null);
const steps = useMemo(
() =>
[...(contract.approvalSteps ?? [])].sort(
@@ -30,6 +42,24 @@ export function ContractApprovalStepsCard({
const nextPending = steps.find((s) => s.status === "PENDING");
const summary = formatContractApprovalProgress(contract.status, steps);
const openApprove = (step: Freight.IContractApprovalStep) => {
setPendingStep(step);
setConfirmOpen(true);
};
const closeApprove = () => {
setConfirmOpen(false);
setPendingStep(null);
};
const runApprove = () => {
if (!pendingStep) return;
mutations.approveStep.mutate(
{ stepId: pendingStep.id, requiredRole: pendingStep.requiredRole },
{ onSuccess: () => closeApprove() },
);
};
const subtitle =
summary.detail ||
(nextPending
@@ -39,54 +69,87 @@ export function ContractApprovalStepsCard({
: "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.
<>
<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>
) : (
<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,
})
}
/>
))}
{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={() => openApprove(step)}
/>
))}
</Stack>
)}
</SectionCard>
<Modal
opened={confirmOpen}
onClose={closeApprove}
title="Approve this step?"
radius="md"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
You are about to approve the{" "}
<Text span fw={600} c="dark">
{pendingStep?.requiredRole}
</Text>{" "}
step for contract{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>
. This action cannot be undone from this screen.
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={closeApprove}>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Check size={16} />}
loading={mutations.approveStep.isPending}
onClick={runApprove}
>
Confirm approval
</Button>
</Group>
</Stack>
)}
</SectionCard>
</Modal>
</>
);
}

View File

@@ -54,6 +54,17 @@ export interface ContractClearanceReviewSectionProps {
* by whom, when) but hide all approve / query / finalize actions.
*/
readOnly?: boolean;
/**
* Document approvals are locked (e.g. after all docs approved in phased flow)
* but queries remain available until {@link readOnly}.
*/
approvalsLocked?: boolean;
/**
* ONE_TIME customs contracts use the phased milestone workflow. Hides the
* legacy "Finalize clearance" shortcut; booking readiness follows delivery
* order (import) or export release.
*/
phasedCustoms?: boolean;
}
const STATUS_META: Record<
@@ -89,6 +100,8 @@ export function ContractClearanceReviewSection({
hideSummary,
selfClear = false,
readOnly = false,
phasedCustoms = false,
approvalsLocked = false,
}: ContractClearanceReviewSectionProps) {
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
@@ -170,14 +183,16 @@ export function ContractClearanceReviewSection({
subtitle={
readOnly
? `Reviewed by the ${reviewerTeam} team.`
: "Approve each document, or open a query to tell the customer what to fix."
: approvalsLocked
? "Documents are approved — you can still open a query if something needs fixing."
: "Approve each document, or open a query to tell the customer what to fix."
}
extra={
<Group gap={10} wrap="nowrap" align="center">
<Text size="xs" c="dimmed" fw={600}>
{stats.approved}/{stats.total} approved
</Text>
{!readOnly && approvableKeys.length > 0 && (
{!readOnly && !approvalsLocked && approvableKeys.length > 0 && (
<Button
size="compact-sm"
color="edr-green"
@@ -221,6 +236,7 @@ export function ContractClearanceReviewSection({
doc={doc}
reviewerTeam={reviewerTeam}
readOnly={readOnly}
approvalsLocked={approvalsLocked}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
@@ -241,7 +257,7 @@ export function ContractClearanceReviewSection({
</Stack>
</SectionCard>
{glDocs.length > 0 && (
{glDocs.length > 0 && !phasedCustoms && (
<SectionCard
icon={Upload}
title="GL output documents"
@@ -372,8 +388,39 @@ export function ContractClearanceReviewSection({
<CheckCircle2 size={15} />
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
Clearance was finalized by the {reviewerTeam} team. This is a
read-only record of the approved documents.
{phasedCustoms
? "Document review is complete. Continue customs milestones in the action panel."
: `Clearance was finalized by the ${reviewerTeam} team. This is a read-only record of the approved documents.`}
</Text>
</Group>
</Paper>
) : phasedCustoms && approvalsLocked ? (
<Paper withBorder radius="md" p="md">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={28}>
<CheckCircle2 size={15} />
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
Document review is complete. Use the action panel for declaration, duty, and
transit steps or open a query above if a customer document needs correction.
</Text>
</Group>
</Paper>
) : phasedCustoms ? (
<Paper withBorder radius="md" p="md">
<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. Upload declaration, duty, transit permit, and delivery order in the action panel."
: "Approve every required document to unlock the customs milestone steps."}
</Text>
</Group>
</Paper>
@@ -450,6 +497,7 @@ function DocReviewCard({
doc,
reviewerTeam,
readOnly,
approvalsLocked,
note,
queryOpen,
onToggleQuery,
@@ -462,6 +510,7 @@ function DocReviewCard({
doc: Freight.ContractClearanceDocument;
reviewerTeam: string;
readOnly: boolean;
approvalsLocked: boolean;
note: string;
queryOpen: boolean;
onToggleQuery: (open: boolean) => void;
@@ -598,7 +647,7 @@ function DocReviewCard({
>
Open query
</Button>
{!isApproved && (
{!isApproved && !approvalsLocked && (
<Button
size="sm"
color="edr-green"

View File

@@ -382,7 +382,7 @@ export default function GlCreateBookingForm() {
} catch {
// Non-fatal — the booking exists; the request link can be retried.
}
navigate(`/dashboard/clearance/${booking.id}`);
navigate(`/dashboard/bookings/${booking.id}/clearance`);
} else {
navigate(`/dashboard/bookings/${booking.id}/milestones`);
}

View File

@@ -26,6 +26,7 @@ import {
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import type { Freight } from "@edr/types";
import { clearanceWorkflowFileLabel } from "@edr/types";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
@@ -199,9 +200,10 @@ function formatBytes(bytes?: number | null): string {
/** Human label for a file's `code` (e.g. "business_license" → "Business license"). */
function codeLabel(code?: string | null): string | null {
if (!code) return null;
return code
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
return (
clearanceWorkflowFileLabel(code) ??
code.replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
);
}
export interface ContractDocumentsCardProps {

View File

@@ -34,6 +34,7 @@ import {
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { formatRouteLabel } from "@/services/routes.service";
import { useToast } from "@/hooks/use-toast";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import type { BookingDetail } from "@/types/booking";
@@ -138,7 +139,9 @@ export function AllocateBookingWizard({
const schedulesQuery = useQuery(
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
);
const routesQuery = useQuery(api.routes.list.queryOptions());
const routesQuery = useQuery(
api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }),
);
const locomotivesQuery = useQuery(
api.trainScheduling.availableLocomotives.queryOptions({
input: {
@@ -244,7 +247,7 @@ export function AllocateBookingWizard({
}, [containerUnits, containerSlots, savedPlacementsFromSchedule]);
const activeRoutes = useMemo(
() => (routesQuery.data ?? []).filter((r) => r.isActive),
() => routesQuery.data ?? [],
[routesQuery.data],
);
@@ -522,7 +525,7 @@ export function AllocateBookingWizard({
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<Select
label="Route"
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
data={activeRoutes.map((r) => ({ value: r.id, label: formatRouteLabel(r) }))}
value={routeId || null}
onChange={(v) => setRouteId(v ?? "")}
searchable