This commit is contained in:
Marshal
2026-08-20 18:06:13 +00:00
parent 934ed43ccb
commit b72cbab535
8 changed files with 495 additions and 277 deletions

View File

@@ -34,7 +34,7 @@ export class CreateServiceTypeDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
default: false, default: false,
description: 'Customs cleared on the Ethiopian side only — requires includesCustoms. Prices off the Ethiopian customs rate.', description: 'Customs cleared on the Ethiopian side only (alternative to full includesCustoms; implies it). Prices off the Ethiopian customs rate.',
}) })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()

View File

@@ -28,9 +28,11 @@ export class ServiceType extends BaseEntity {
includesCustoms!: boolean; includesCustoms!: boolean;
/** /**
* EDR clears customs on the Ethiopian side only. Requires includesCustoms * EDR clears customs on the Ethiopian side only. The admin picks full customs
* the clearance flow (GL review, duty) is identical; only the fee differs: * OR Ethiopian-only, never both; the API stores includesCustoms = true for
* pricing looks up ETHIOPIAN_CUSTOMS_CLEARANCE instead of CUSTOMS_CLEARANCE. * either so every clearance read (GL review, duty, docs) stays unchanged —
* only the fee differs: pricing looks up ETHIOPIAN_CUSTOMS_CLEARANCE instead
* of CUSTOMS_CLEARANCE.
*/ */
@Column({ name: 'includes_ethiopian_customs_only', type: 'boolean', default: false }) @Column({ name: 'includes_ethiopian_customs_only', type: 'boolean', default: false })
includesEthiopianCustomsOnly!: boolean; includesEthiopianCustomsOnly!: boolean;

View File

@@ -49,7 +49,10 @@ export class ServiceTypesService {
const existing = await this.repository.findByCode(code); const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${code}"`); if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${code}"`);
this.assertCustomsFlags(dto.includesCustoms ?? false, dto.includesEthiopianCustomsOnly ?? false); const customs = this.resolveCustomsFlags(
dto.includesCustoms ?? false,
dto.includesEthiopianCustomsOnly ?? false,
);
const displayOrder = await this.displayOrder.resolveCreateOrder(ServiceType, 'displayOrder', { const displayOrder = await this.displayOrder.resolveCreateOrder(ServiceType, 'displayOrder', {
explicitOrder: dto.displayOrder, explicitOrder: dto.displayOrder,
insertAfterId: dto.insertAfterId, insertAfterId: dto.insertAfterId,
@@ -62,8 +65,7 @@ export class ServiceTypesService {
canBeBookedAlone: dto.canBeBookedAlone ?? true, canBeBookedAlone: dto.canBeBookedAlone ?? true,
includesFirstMile: dto.includesFirstMile ?? false, includesFirstMile: dto.includesFirstMile ?? false,
includesLastMile: dto.includesLastMile ?? false, includesLastMile: dto.includesLastMile ?? false,
includesCustoms: dto.includesCustoms ?? false, ...customs,
includesEthiopianCustomsOnly: dto.includesEthiopianCustomsOnly ?? false,
isActive: dto.isActive ?? true, isActive: dto.isActive ?? true,
displayOrder, displayOrder,
}); });
@@ -72,23 +74,41 @@ export class ServiceTypesService {
/** Update an existing service type. */ /** Update an existing service type. */
async update(id: string, dto: UpdateServiceTypeDto): Promise<ServiceType> { async update(id: string, dto: UpdateServiceTypeDto): Promise<ServiceType> {
const existing = await this.findById(id); const existing = await this.findById(id);
this.assertCustomsFlags( const ethiopian = dto.includesEthiopianCustomsOnly ?? existing.includesEthiopianCustomsOnly;
dto.includesCustoms ?? existing.includesCustoms, // The form sends both flags whenever either is touched; a payload with only
dto.includesEthiopianCustomsOnly ?? existing.includesEthiopianCustomsOnly, // one is a plain edit (name, order…) that keeps the stored pair.
); const customs =
const { ...patch } = dto; dto.includesCustoms !== undefined || dto.includesEthiopianCustomsOnly !== undefined
? this.resolveCustomsFlags(
dto.includesCustoms ?? (existing.includesCustoms && !existing.includesEthiopianCustomsOnly),
ethiopian,
)
: {};
const patch = { ...dto, ...customs };
const updated = await this.repository.update(id, patch); const updated = await this.repository.update(id, patch);
if (!updated) throw new NotFoundException(`Service type ${id} not found`); if (!updated) throw new NotFoundException(`Service type ${id} not found`);
return updated; return updated;
} }
/** "Ethiopian customs only" narrows a customs service — it cannot stand alone. */ /**
private assertCustomsFlags(includesCustoms: boolean, ethiopianOnly: boolean): void { * Full customs and Ethiopian-only customs are alternatives: the admin picks
if (ethiopianOnly && !includesCustoms) { * one. Ethiopian-only is still a customs service, so it is stored with
* includesCustoms = true — every clearance read keeps working unchanged and
* only pricing looks at the Ethiopian flag.
*/
private resolveCustomsFlags(
includesCustoms: boolean,
ethiopianOnly: boolean,
): Pick<ServiceType, 'includesCustoms' | 'includesEthiopianCustomsOnly'> {
if (includesCustoms && ethiopianOnly) {
throw new BadRequestException( throw new BadRequestException(
'"Ethiopian customs only" requires "Includes customs" to be enabled.', 'Pick either "Includes customs" or "Ethiopian customs only", not both.',
); );
} }
return {
includesCustoms: includesCustoms || ethiopianOnly,
includesEthiopianCustomsOnly: ethiopianOnly,
};
} }
/** Soft-delete a service type. */ /** Soft-delete a service type. */

View File

@@ -1,10 +1,12 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ActionIcon,
Alert, Alert,
Badge, Badge,
Box, Box,
Button, Button,
Collapse,
FileButton, FileButton,
Group, Group,
Loader, Loader,
@@ -20,6 +22,7 @@ import {
import { import {
AlertCircle, AlertCircle,
CheckCircle2, CheckCircle2,
ChevronDown,
Download, Download,
Eye, Eye,
FileCheck2, FileCheck2,
@@ -187,26 +190,59 @@ export function ClearanceReviewSection({
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<SectionCard <Paper radius={13} withBorder style={{ overflow: "hidden" }} p={0}>
icon={FileText} <Group
title="Customer documents" justify="space-between"
subtitle="Approve each document, or open a query to tell the customer what to fix." wrap="nowrap"
extra={ px={18}
<Text size="xs" c="dimmed" fw={600}> py={15}
{stats.approved}/{stats.total} approved style={{ borderBottom: "1px solid #EFF3F7" }}
</Text>
}
> >
<Stack gap={12}> <Group gap={9} wrap="nowrap" style={{ minWidth: 0 }}>
{!hideSummary && stats.total > 0 && ( <FileText size={16} color="#0A8A5F" />
<Box> <Box style={{ minWidth: 0 }}>
<Progress <Text fz={14} fw={700} c="edr-text">
value={stats.pct} Customer documents
color="edr-green" </Text>
radius="xl" <Text fz={11.5} c="#93A4B5" truncate>
size="sm" {stats.approved} of {stats.total} approved
mb={6} {stats.queried > 0 ? ` · ${stats.queried} queried` : ""} · required
marked *
</Text>
</Box>
</Group>
<Group
gap={5}
wrap="nowrap"
px={8}
py={3}
style={{
flexShrink: 0,
borderRadius: 6,
background: approvalsLocked ? "#F4F7FA" : "#E7F5EF",
}}
>
<Box
style={{
width: 6,
height: 6,
borderRadius: 999,
background: approvalsLocked ? "#93A4B5" : "#0A8A5F",
}}
/> />
<Text
fz={10.5}
fw={700}
style={{ color: approvalsLocked ? "#67788A" : "#0A8A5F" }}
>
{approvalsLocked ? "Uploads closed" : "Uploads open"}
</Text>
</Group>
</Group>
{!hideSummary && stats.total > 0 && (
<Box px={18} py={12} style={{ borderBottom: "1px solid #EFF3F7" }}>
<Progress value={stats.pct} color="edr-green" radius="xl" size="sm" mb={8} />
<Group gap="lg"> <Group gap="lg">
<StatPill color="edr-green" label="Approved" value={stats.approved} /> <StatPill color="edr-green" label="Approved" value={stats.approved} />
<StatPill color="red" label="Queried" value={stats.queried} /> <StatPill color="red" label="Queried" value={stats.queried} />
@@ -214,15 +250,17 @@ export function ClearanceReviewSection({
</Group> </Group>
</Box> </Box>
)} )}
{customerDocs.length === 0 ? ( {customerDocs.length === 0 ? (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed" px={18} py={20}>
No customer documents are required for this booking. No customer documents are required for this booking.
</Text> </Text>
) : ( ) : (
customerDocs.map((doc) => ( customerDocs.map((doc, i) => (
<DocReviewCard <DocReviewCard
key={`${doc.settingCode}:${doc.fileKey}`} key={`${doc.settingCode}:${doc.fileKey}`}
doc={doc} doc={doc}
first={i === 0}
approvalsLocked={effectiveApprovalsLocked} approvalsLocked={effectiveApprovalsLocked}
queriesLocked={queriesLocked} queriesLocked={queriesLocked}
readOnly={readOnly} readOnly={readOnly}
@@ -231,14 +269,9 @@ export function ClearanceReviewSection({
onToggleQuery={(open) => onToggleQuery={(open) =>
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open })) setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
} }
onNote={(v) => onNote={(v) => setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))}
setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))
}
onApprove={() => onApprove={() =>
reviewMutation.mutate({ reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" })
fileKey: doc.fileKey,
status: "APPROVED",
})
} }
onQuery={() => onQuery={() =>
reviewMutation.mutate({ reviewMutation.mutate({
@@ -252,8 +285,7 @@ export function ClearanceReviewSection({
/> />
)) ))
)} )}
</Stack> </Paper>
</SectionCard>
{clearance.outputCode && !phasedCustoms && ( {clearance.outputCode && !phasedCustoms && (
<SectionCard <SectionCard
@@ -559,6 +591,23 @@ function StatPill({
); );
} }
/** Row tints straight from the design tokens. */
const ROW_TONE: Record<
Freight.DocumentReviewStatus,
{ bg: string; chipBg: string; fg: string }
> = {
APPROVED: { bg: "#FFFFFF", chipBg: "#E7F5EF", fg: "#0A8A5F" },
QUERIED: { bg: "#FBECEA", chipBg: "#FBECEA", fg: "#C0392B" },
PENDING: { bg: "#FFFFFF", chipBg: "#FCF2E2", fg: "#A76F08" },
};
/**
* One document as a compact 60px row that expands in place. Collapsed it shows
* name, file line, status chip and the review actions; expanded it reveals the
* per-document history timeline and the query note. Keeping the actions in the
* collapsed row means approving a stack of documents never needs a single
* expand.
*/
function DocReviewCard({ function DocReviewCard({
doc, doc,
approvalsLocked, approvalsLocked,
@@ -572,6 +621,7 @@ function DocReviewCard({
onQuery, onQuery,
onView, onView,
busy, busy,
first,
}: { }: {
doc: Freight.ClearanceDocument; doc: Freight.ClearanceDocument;
approvalsLocked: boolean; approvalsLocked: boolean;
@@ -585,146 +635,177 @@ function DocReviewCard({
onQuery: () => void; onQuery: () => void;
onView: (file: { name: string; url: string }) => void; onView: (file: { name: string; url: string }) => void;
busy: boolean; busy: boolean;
first: boolean;
}) { }) {
const status = doc.reviewStatus ?? "PENDING"; const status = doc.reviewStatus ?? "PENDING";
const meta = STATUS_META[status]; const meta = STATUS_META[status];
const tone = ROW_TONE[status];
const hasFile = !!doc.file; const hasFile = !!doc.file;
const isApproved = status === "APPROVED"; const isApproved = status === "APPROVED";
const history = doc.history ?? [];
// A queried document is the one the reviewer must act on, so it opens itself.
const [open, setOpen] = useState(status === "QUERIED");
const expandable = history.length > 0 || Boolean(doc.note);
// Opening the query form has to reveal the body it lives in.
const bodyOpen = open || queryOpen;
// The file line carries the same at-a-glance summary as the design: file
// name, who decided, when.
const last = history[history.length - 1];
const fileLine = hasFile
? [
doc.file!.name,
status === "APPROVED" && last ? `Approved by ${last.byName ?? "staff"}` : null,
status === "PENDING" ? "awaiting review" : null,
status === "QUERIED" ? doc.note : null,
last ? formatDateTime(last.at) : null,
]
.filter(Boolean)
.join(" · ")
: "Not uploaded by customer";
return ( return (
<Paper <Box
withBorder
radius="md"
p="md"
style={{ style={{
borderColor: background: tone.bg,
status === "QUERIED" borderTop: first ? undefined : "1px solid #EFF3F7",
? "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" align="center" px={18} py={13}>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}> <Box
<ThemeIcon style={{
variant="light" display: "flex",
color={hasFile ? "edr-green" : "gray"} alignItems: "center",
radius="md" justifyContent: "center",
size={40} flexShrink: 0,
width: 34,
height: 34,
borderRadius: 9,
background: tone.chipBg,
color: tone.fg,
}}
> >
<FileText size={19} /> <FileText size={16} />
</ThemeIcon> </Box>
<Box style={{ minWidth: 0 }}>
<Text fz="14px" fw={700} c="edr-text" truncate> <Box style={{ flex: 1, minWidth: 0 }}>
<Text fz={12.5} fw={600} c="edr-text" truncate>
{doc.label} {doc.label}
{doc.required ? " *" : ""} {doc.required ? " *" : ""}
</Text> </Text>
<Text fz="12px" c="edr-muted" truncate> <Text fz={11} c="#93A4B5" truncate>
{hasFile ? doc.file!.name : "Not uploaded by customer"} {fileLine}
</Text> </Text>
</Box> </Box>
</Group>
<Group gap={8} wrap="nowrap"> <Badge
<Badge variant="light" color={meta.color} radius="sm"> variant="light"
radius="xl"
color={meta.color}
styles={{ root: { flexShrink: 0 } }}
>
{meta.label} {meta.label}
</Badge> </Badge>
{hasFile &&
isViewable({
name: doc.file!.name,
url: "",
}) && (
<Tooltip label="Preview document">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
void fetchViewableFile(doc.file!.id, doc.file!.name).then(
onView,
)
}
>
View
</Button>
</Tooltip>
)}
{hasFile && (
<Tooltip label="Download">
<Box
component="button"
type="button"
onClick={() =>
void downloadBookingFile(doc.file!.id, doc.file!.name)
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
<Download size={15} />
</Box>
</Tooltip>
)}
</Group>
</Group>
{(doc.history?.length ?? 0) > 0 && ( <Group gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
<DocHistoryTimeline history={doc.history!} /> {hasFile && !readOnly && !isApproved && !approvalsLocked && (
)}
{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 && !readOnly && (
<Box mt="sm">
{!queryOpen ? (
<Group justify="flex-end" gap={8}>
{!queriesLocked && (
<Button
size="compact-sm"
variant="light"
color="red"
radius="md"
leftSection={<MessageSquareWarning size={14} />}
disabled={busy}
onClick={() => onToggleQuery(true)}
>
Open query
</Button>
)}
{!isApproved && !approvalsLocked && (
<Button <Button
size="compact-sm" size="compact-sm"
radius={7}
color="edr-green" color="edr-green"
radius="md" leftSection={<CheckCircle2 size={12} />}
leftSection={<CheckCircle2 size={14} />}
disabled={busy} disabled={busy}
onClick={onApprove} onClick={onApprove}
> >
Approve Approve
</Button> </Button>
)} )}
{hasFile && !readOnly && !queriesLocked && !queryOpen && (
<Button
size="compact-sm"
radius={7}
variant="default"
disabled={busy}
onClick={() => {
onToggleQuery(true);
setOpen(true);
}}
>
Query
</Button>
)}
{hasFile && isViewable({ name: doc.file!.name, url: "" }) && (
<Tooltip label="Preview document">
<ActionIcon
variant="default"
radius={7}
size={29}
onClick={() =>
void fetchViewableFile(doc.file!.id, doc.file!.name).then(onView)
}
>
<Eye size={13} />
</ActionIcon>
</Tooltip>
)}
{hasFile && (
<Tooltip label="Download">
<ActionIcon
variant="default"
radius={7}
size={29}
onClick={() => void downloadBookingFile(doc.file!.id, doc.file!.name)}
>
<Download size={13} />
</ActionIcon>
</Tooltip>
)}
{expandable && (
<Tooltip label={bodyOpen ? "Hide history" : "Show history"}>
<ActionIcon
variant="subtle"
color="gray"
radius={7}
size={29}
aria-expanded={bodyOpen}
aria-label={bodyOpen ? "Hide history" : "Show history"}
onClick={() => setOpen((o) => !o)}
>
<ChevronDown
size={14}
style={{
transition: "transform 150ms",
transform: bodyOpen ? "rotate(180deg)" : undefined,
}}
/>
</ActionIcon>
</Tooltip>
)}
</Group> </Group>
) : ( </Group>
<Collapse expanded={bodyOpen}>
<Box px={18} pb={14} pl={64}>
{status === "QUERIED" && doc.note ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<MessageSquareWarning size={15} />}
p="xs"
mb="sm"
>
<Text fz={12.5} c="red.9">
{doc.note}
</Text>
</Alert>
) : null}
{history.length > 0 ? <DocHistoryTimeline history={history} /> : null}
{queryOpen && !readOnly ? (
<Box <Box
mt="sm"
p="sm" p="sm"
style={{ style={{
borderRadius: 12, borderRadius: 12,
@@ -733,11 +814,8 @@ function DocReviewCard({
}} }}
> >
<Group gap={6} mb={6}> <Group gap={6} mb={6}>
<MessageSquareWarning <MessageSquareWarning size={14} color="var(--mantine-color-red-7)" />
size={14} <Text fz={12.5} fw={700} c="red.8">
color="var(--mantine-color-red-7)"
/>
<Text fz="12.5px" fw={700} c="red.8">
Describe the problem for the customer Describe the problem for the customer
</Text> </Text>
</Group> </Group>
@@ -775,9 +853,9 @@ function DocReviewCard({
</Button> </Button>
</Group> </Group>
</Box> </Box>
)} ) : null}
</Box>
</Collapse>
</Box> </Box>
)}
</Paper>
); );
} }

View File

@@ -2,7 +2,11 @@ import { Check } from "lucide-react";
import { Box, Group, Stack, Text } from "@mantine/core"; import { Box, Group, Stack, Text } from "@mantine/core";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
const BRAND_GREEN = "var(--freight-brand, #0A6F4D)"; const GREEN = "#0A8A5F";
const BLUE = "#1D6FD1";
const BORDER = "#E4EBF1";
const MUTED = "#93A4B5";
const INK = "#10202F";
const IMPORT_PHASES = [ const IMPORT_PHASES = [
"CUSTOMER_INTAKE", "CUSTOMER_INTAKE",
@@ -24,6 +28,18 @@ const PHASE_LABELS: Record<string, string> = {
POST_TRANSIT: "Transit", POST_TRANSIT: "Transit",
}; };
/** Which desk owns each phase — shown under the label, as in the design. */
const PHASE_ACTOR: Record<string, string> = {
CUSTOMER_INTAKE: "CUSTOMER",
GL_ET_REVIEW: "GL ET",
GL_ET_OUTPUT: "GL ET",
CUSTOMER_DUTY: "CUSTOMER",
GL_ET_POST_CLEARANCE: "GL ET",
GL_DJ_COLLECTION: "GL DJ",
GL_DJ_LOADING: "GL DJ",
POST_TRANSIT: "OPS",
};
const EXPORT_PHASES = [ const EXPORT_PHASES = [
"CUSTOMER_INTAKE", "CUSTOMER_INTAKE",
"GL_ET_REVIEW", "GL_ET_REVIEW",
@@ -38,6 +54,20 @@ function phaseIndex(phases: readonly string[], current?: string | null): number
return idx >= 0 ? idx : 0; return idx >= 0 ? idx : 0;
} }
/** Half-width connector; only the segment behind a completed dot is green. */
function Line({ done, hidden }: { done: boolean; hidden: boolean }) {
return (
<Box
style={{
flex: 1,
height: 2,
borderRadius: 2,
background: hidden ? "transparent" : done ? GREEN : BORDER,
}}
/>
);
}
export function ClearancePhaseStepper({ export function ClearancePhaseStepper({
clearance, clearance,
tradeDirection, tradeDirection,
@@ -50,61 +80,69 @@ export function ClearancePhaseStepper({
const phases = tradeDirection === "EXPORT" ? EXPORT_PHASES : IMPORT_PHASES; const phases = tradeDirection === "EXPORT" ? EXPORT_PHASES : IMPORT_PHASES;
const current = clearance?.phase ?? phases[0]; const current = clearance?.phase ?? phases[0];
const activeIdx = phaseIndex(phases, current); const activeIdx = phaseIndex(phases, current);
const dot = compact ? 26 : 28;
return ( return (
<Group gap={0} wrap="nowrap" align="flex-start" style={{ overflowX: "auto" }}> <Group gap={0} wrap="nowrap" align="flex-start" style={{ overflowX: "auto" }}>
{phases.map((phase, index) => { {phases.map((phase, index) => {
const isComplete = index < activeIdx; const isComplete = index < activeIdx;
const isActive = index === activeIdx; const isActive = index === activeIdx;
const isLast = index === phases.length - 1; const actor = PHASE_ACTOR[phase];
return ( return (
<Box key={phase} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: compact ? 72 : 88 }}> <Stack
<Group gap={0} wrap="nowrap" align="center"> key={phase}
<Stack gap={4} align="center" style={{ flexShrink: 0 }}> gap={7}
align="center"
style={{ flex: 1, minWidth: compact ? 92 : 112 }}
>
{/* Dot sits centred on its own row so the connectors meet it edge-to-edge. */}
<Group gap={0} wrap="nowrap" align="center" style={{ width: "100%" }}>
<Line done={isComplete || isActive} hidden={index === 0} />
<Box <Box
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
width: compact ? 28 : 34, flexShrink: 0,
height: compact ? 28 : 34, width: dot,
borderRadius: "50%", height: dot,
background: isComplete ? BRAND_GREEN : isActive ? "white" : "var(--mantine-color-gray-1)", borderRadius: 999,
border: isActive background: isComplete ? GREEN : "#FFFFFF",
? `2px solid ${BRAND_GREEN}` border: `2px solid ${
: isComplete isComplete ? GREEN : isActive ? BLUE : BORDER
? "2px solid transparent" }`,
: "2px solid var(--mantine-color-gray-3)", color: isComplete ? "#FFFFFF" : isActive ? BLUE : MUTED,
color: isComplete ? "white" : isActive ? BRAND_GREEN : "var(--mantine-color-gray-5)", fontSize: 12,
fontWeight: 700,
lineHeight: 1,
}} }}
> >
{isComplete ? <Check size={compact ? 14 : 16} strokeWidth={3} /> : null} {isComplete ? <Check size={14} strokeWidth={3} /> : index + 1}
</Box> </Box>
<Line done={isComplete} hidden={index === phases.length - 1} />
</Group>
<Text <Text
size={compact ? "10px" : "xs"} fz={10.5}
fw={isActive ? 600 : 500} fw={700}
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"} lh={1.3}
ta="center" ta="center"
style={{ whiteSpace: "nowrap" }} style={{ color: isActive || isComplete ? INK : MUTED }}
> >
{PHASE_LABELS[phase] ?? phase} {PHASE_LABELS[phase] ?? phase}
</Text> </Text>
{actor ? (
<Text
fz={9}
fw={700}
lts="0.3px"
style={{ color: isActive ? BLUE : MUTED, marginTop: -3 }}
>
{actor}
</Text>
) : null}
</Stack> </Stack>
{!isLast && (
<Box
style={{
flex: 1,
height: 2,
marginInline: 6,
marginBottom: compact ? 16 : 20,
borderRadius: 2,
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
}}
/>
)}
</Group>
</Box>
); );
})} })}
</Group> </Group>

View File

@@ -2,10 +2,12 @@ import { useEffect, useMemo, useState } from "react";
import { import {
Alert, Alert,
Badge, Badge,
Box,
Button, Button,
Group, Group,
NumberInput, NumberInput,
Paper, Paper,
Progress,
SegmentedControl, SegmentedControl,
Select, Select,
Stack, Stack,
@@ -14,14 +16,9 @@ import {
Text, Text,
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
} from "@/components/contracts/TransitPermitMultiUpload";
import { import {
AlertTriangle, AlertTriangle,
ArrowRight,
CheckCircle2, CheckCircle2,
Clock, Clock,
FileText, FileText,
@@ -30,10 +27,17 @@ import {
PackageOpen, PackageOpen,
Receipt, Receipt,
ShieldAlert, ShieldAlert,
ShieldCheck,
Ship, Ship,
Truck, Truck,
Upload, Upload,
} from "lucide-react"; } from "lucide-react";
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
} from "@/components/contracts/TransitPermitMultiUpload";
import { import {
deliveryOrderFileLabel, deliveryOrderFileLabel,
isDeliveryOrderFileCode, isDeliveryOrderFileCode,
@@ -113,6 +117,9 @@ export function isBookingMilestoneDone(
return m?.status === "COMPLETED" || m?.status === "SKIPPED"; return m?.status === "COMPLETED" || m?.status === "SKIPPED";
} }
/** Number of steps in the import stepper — drives the header progress bar. */
const IMPORT_STEP_COUNT = 12;
function computeImportActiveStep( function computeImportActiveStep(
clearance: ClearanceViewLike, clearance: ClearanceViewLike,
bookingCreated: boolean, bookingCreated: boolean,
@@ -322,19 +329,64 @@ export function PhasedClearanceActionPanel({
</Alert> </Alert>
) : null} ) : null}
{clearance.nextAction ? ( <Paper withBorder radius={13} p={0} style={{ overflow: "hidden" }}>
<Alert color="blue" variant="light" title="Next step"> {/* Header: what this workflow is, and how far along it is. */}
<Text size="sm"> <Group
<strong>{clearance.nextAction.actor.replace("_", " ")}</strong> {" "} justify="space-between"
{clearance.nextAction.action} wrap="nowrap"
</Text> px={18}
</Alert> py={15}
) : null} style={{ borderBottom: "1px solid #EFF3F7" }}
>
<Paper withBorder radius="md" p="md"> <Group gap={9} wrap="nowrap" style={{ minWidth: 0 }}>
<Text fw={600} size="sm" mb="md"> <ShieldCheck size={16} color="#0A8A5F" />
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} c="edr-text">
Import pre-booking clearance Import pre-booking clearance
</Text> </Text>
<Text fz={11.5} c="#93A4B5" truncate>
Step {Math.min(activeStep + 1, IMPORT_STEP_COUNT)} of{" "}
{IMPORT_STEP_COUNT}
{clearance.nextAction
? ` · ${clearance.nextAction.action}`
: ""}
</Text>
</Box>
</Group>
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
<Progress
value={Math.round((activeStep / IMPORT_STEP_COUNT) * 100)}
color="edr-green"
radius="xl"
size={6}
w={110}
/>
<Text fz={11.5} c="#67788A" fw={600}>
{Math.round((activeStep / IMPORT_STEP_COUNT) * 100)}%
</Text>
</Group>
</Group>
{/* Whose desk the flow is sitting on right now. */}
{clearance.nextAction ? (
<Group
gap={10}
wrap="nowrap"
px={18}
py={12}
style={{ background: "#E9F1FC", borderBottom: "1px solid #EFF3F7" }}
>
<ArrowRight size={15} color="#1D6FD1" style={{ flexShrink: 0 }} />
<Text fz={10.5} fw={700} lts="0.4px" c="#1D6FD1" style={{ flexShrink: 0 }}>
{clearance.nextAction.actor.replace("_", " ").toUpperCase()}
</Text>
<Text fz={11.5} fw={600} c="edr-text" style={{ minWidth: 0 }}>
{clearance.nextAction.action}
</Text>
</Group>
) : null}
<Box p="md">
<Stepper <Stepper
active={activeStep} active={activeStep}
orientation="vertical" orientation="vertical"
@@ -827,6 +879,7 @@ export function PhasedClearanceActionPanel({
/> />
</Stepper.Step> </Stepper.Step>
</Stepper> </Stepper>
</Box>
</Paper> </Paper>
</Stack> </Stack>
); );

View File

@@ -263,6 +263,14 @@ const RuleEngineFormDialog = ({
next.cargoTypeId = ""; next.cargoTypeId = "";
next.rateUnit = ""; next.rateUnit = "";
} }
// Full customs and Ethiopian-only customs are alternatives on a service
// type — switching one on drops the other so the API never sees both.
if (name === "includesCustoms" && value === true) {
next.includesEthiopianCustomsOnly = false;
}
if (name === "includesEthiopianCustomsOnly" && value === true) {
next.includesCustoms = false;
}
// Turning the shipping-line toggle on or off swaps the entire form, so // Turning the shipping-line toggle on or off swaps the entire form, so
// nothing answered under the other shape may survive into the payload. // nothing answered under the other shape may survive into the payload.
if (name === "isShippingLineRate") { if (name === "isShippingLineRate") {
@@ -397,7 +405,11 @@ const RuleEngineFormDialog = ({
// A toggle that re-targets what an existing record means (e.g. who // A toggle that re-targets what an existing record means (e.g. who
// a rate is priced for) is create-only — flipping it on a saved row // a rate is priced for) is create-only — flipping it on a saved row
// would silently change every booking that prices off it. // would silently change every booking that prices off it.
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)} disabled={
field.disabled ||
(field.disabledOnEdit && !!initialRecord) ||
field.disabledIf?.(values) === true
}
size="md" size="md"
color="edr-green" color="edr-green"
/> />

View File

@@ -41,6 +41,8 @@ export interface FormFieldDef {
disabled?: boolean; disabled?: boolean;
/** Editable on create, locked when editing an existing record. */ /** Editable on create, locked when editing an existing record. */
disabledOnEdit?: boolean; disabledOnEdit?: boolean;
/** Lock the field while the predicate accepts the live form values. */
disabledIf?: (values: Record<string, unknown>) => boolean;
/** Trailing unit label shown inside the input (e.g. "USD" on a rate value). */ /** Trailing unit label shown inside the input (e.g. "USD" on a rate value). */
suffix?: string; suffix?: string;
/** Hide this field when another field currently equals one of these values. */ /** Hide this field when another field currently equals one of these values. */
@@ -738,14 +740,27 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "canBeBookedAlone", label: "Can be booked alone", type: "boolean" }, { name: "canBeBookedAlone", label: "Can be booked alone", type: "boolean" },
{ name: "includesFirstMile", label: "Includes first mile", type: "boolean" }, { name: "includesFirstMile", label: "Includes first mile", type: "boolean" },
{ name: "includesLastMile", label: "Includes last mile", type: "boolean" }, { name: "includesLastMile", label: "Includes last mile", type: "boolean" },
{ name: "includesCustoms", label: "Includes customs", type: "boolean" }, // Full customs and Ethiopian-only customs are alternatives — turning one
// on clears and locks the other (see RuleEngineFormDialog.setField). The
// API stores includesCustoms = true for both; the toggle shown here is
// "full customs", so an Ethiopian-only record reads it back as off.
{
name: "includesCustoms",
label: "Includes customs",
type: "boolean",
description:
"Full customs clearance bundled with the service. Cannot be combined with Ethiopian customs only.",
getInitialValue: (record) =>
record.includesCustoms === true && record.includesEthiopianCustomsOnly !== true,
disabledIf: (v) => v.includesEthiopianCustomsOnly === true,
},
{ {
name: "includesEthiopianCustomsOnly", name: "includesEthiopianCustomsOnly",
label: "Ethiopian customs only", label: "Ethiopian customs only",
type: "boolean", type: "boolean",
description: description:
"EDR clears customs on the Ethiopian side only. Same clearance flow; contracts and bookings price off the Ethiopian customs clearance rate instead of the standard one.", "EDR clears customs on the Ethiopian side only. Same clearance flow; contracts and bookings price off the Ethiopian customs clearance rate. Cannot be combined with Includes customs.",
showIf: (v) => v.includesCustoms === true, disabledIf: (v) => v.includesCustoms === true,
}, },
{ name: "isActive", label: "Active", type: "boolean" }, { name: "isActive", label: "Active", type: "boolean" },
], ],