mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
changes
This commit is contained in:
@@ -34,7 +34,7 @@ export class CreateServiceTypeDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
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()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -28,9 +28,11 @@ export class ServiceType extends BaseEntity {
|
||||
includesCustoms!: boolean;
|
||||
|
||||
/**
|
||||
* EDR clears customs on the Ethiopian side only. Requires includesCustoms —
|
||||
* the clearance flow (GL review, duty) is identical; only the fee differs:
|
||||
* pricing looks up ETHIOPIAN_CUSTOMS_CLEARANCE instead of CUSTOMS_CLEARANCE.
|
||||
* EDR clears customs on the Ethiopian side only. The admin picks full customs
|
||||
* OR Ethiopian-only, never both; the API stores includesCustoms = true for
|
||||
* 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 })
|
||||
includesEthiopianCustomsOnly!: boolean;
|
||||
|
||||
@@ -49,7 +49,10 @@ export class ServiceTypesService {
|
||||
const existing = await this.repository.findByCode(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', {
|
||||
explicitOrder: dto.displayOrder,
|
||||
insertAfterId: dto.insertAfterId,
|
||||
@@ -62,8 +65,7 @@ export class ServiceTypesService {
|
||||
canBeBookedAlone: dto.canBeBookedAlone ?? true,
|
||||
includesFirstMile: dto.includesFirstMile ?? false,
|
||||
includesLastMile: dto.includesLastMile ?? false,
|
||||
includesCustoms: dto.includesCustoms ?? false,
|
||||
includesEthiopianCustomsOnly: dto.includesEthiopianCustomsOnly ?? false,
|
||||
...customs,
|
||||
isActive: dto.isActive ?? true,
|
||||
displayOrder,
|
||||
});
|
||||
@@ -72,23 +74,41 @@ export class ServiceTypesService {
|
||||
/** Update an existing service type. */
|
||||
async update(id: string, dto: UpdateServiceTypeDto): Promise<ServiceType> {
|
||||
const existing = await this.findById(id);
|
||||
this.assertCustomsFlags(
|
||||
dto.includesCustoms ?? existing.includesCustoms,
|
||||
dto.includesEthiopianCustomsOnly ?? existing.includesEthiopianCustomsOnly,
|
||||
);
|
||||
const { ...patch } = dto;
|
||||
const ethiopian = dto.includesEthiopianCustomsOnly ?? existing.includesEthiopianCustomsOnly;
|
||||
// The form sends both flags whenever either is touched; a payload with only
|
||||
// one is a plain edit (name, order…) that keeps the stored pair.
|
||||
const customs =
|
||||
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);
|
||||
if (!updated) throw new NotFoundException(`Service type ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** "Ethiopian customs only" narrows a customs service — it cannot stand alone. */
|
||||
private assertCustomsFlags(includesCustoms: boolean, ethiopianOnly: boolean): void {
|
||||
if (ethiopianOnly && !includesCustoms) {
|
||||
/**
|
||||
* Full customs and Ethiopian-only customs are alternatives: the admin picks
|
||||
* 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(
|
||||
'"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. */
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Collapse,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Eye,
|
||||
FileCheck2,
|
||||
@@ -187,73 +190,102 @@ export function ClearanceReviewSection({
|
||||
|
||||
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>
|
||||
<Paper radius={13} withBorder style={{ overflow: "hidden" }} p={0}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px={18}
|
||||
py={15}
|
||||
style={{ borderBottom: "1px solid #EFF3F7" }}
|
||||
>
|
||||
<Group gap={9} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<FileText size={16} color="#0A8A5F" />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={700} c="edr-text">
|
||||
Customer documents
|
||||
</Text>
|
||||
<Text fz={11.5} c="#93A4B5" truncate>
|
||||
{stats.approved} of {stats.total} approved
|
||||
{stats.queried > 0 ? ` · ${stats.queried} queried` : ""} · required
|
||||
marked *
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{customerDocs.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No customer documents are required for this booking.
|
||||
</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>
|
||||
) : (
|
||||
customerDocs.map((doc) => (
|
||||
<DocReviewCard
|
||||
key={`${doc.settingCode}:${doc.fileKey}`}
|
||||
doc={doc}
|
||||
approvalsLocked={effectiveApprovalsLocked}
|
||||
queriesLocked={queriesLocked}
|
||||
readOnly={readOnly}
|
||||
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={() =>
|
||||
reviewMutation.mutate({
|
||||
fileKey: doc.fileKey,
|
||||
status: "APPROVED",
|
||||
})
|
||||
}
|
||||
onQuery={() =>
|
||||
reviewMutation.mutate({
|
||||
fileKey: doc.fileKey,
|
||||
status: "QUERIED",
|
||||
note: queryNotes[doc.fileKey],
|
||||
})
|
||||
}
|
||||
onView={view}
|
||||
busy={reviewMutation.isPending}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</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">
|
||||
<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" px={18} py={20}>
|
||||
No customer documents are required for this booking.
|
||||
</Text>
|
||||
) : (
|
||||
customerDocs.map((doc, i) => (
|
||||
<DocReviewCard
|
||||
key={`${doc.settingCode}:${doc.fileKey}`}
|
||||
doc={doc}
|
||||
first={i === 0}
|
||||
approvalsLocked={effectiveApprovalsLocked}
|
||||
queriesLocked={queriesLocked}
|
||||
readOnly={readOnly}
|
||||
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={() =>
|
||||
reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" })
|
||||
}
|
||||
onQuery={() =>
|
||||
reviewMutation.mutate({
|
||||
fileKey: doc.fileKey,
|
||||
status: "QUERIED",
|
||||
note: queryNotes[doc.fileKey],
|
||||
})
|
||||
}
|
||||
onView={view}
|
||||
busy={reviewMutation.isPending}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{clearance.outputCode && !phasedCustoms && (
|
||||
<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({
|
||||
doc,
|
||||
approvalsLocked,
|
||||
@@ -572,6 +621,7 @@ function DocReviewCard({
|
||||
onQuery,
|
||||
onView,
|
||||
busy,
|
||||
first,
|
||||
}: {
|
||||
doc: Freight.ClearanceDocument;
|
||||
approvalsLocked: boolean;
|
||||
@@ -585,146 +635,177 @@ function DocReviewCard({
|
||||
onQuery: () => void;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
busy: boolean;
|
||||
first: boolean;
|
||||
}) {
|
||||
const status = doc.reviewStatus ?? "PENDING";
|
||||
const meta = STATUS_META[status];
|
||||
const tone = ROW_TONE[status];
|
||||
const hasFile = !!doc.file;
|
||||
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 (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
<Box
|
||||
style={{
|
||||
borderColor:
|
||||
status === "QUERIED"
|
||||
? "var(--mantine-color-red-2)"
|
||||
: status === "APPROVED"
|
||||
? "var(--mantine-color-edr-green-2)"
|
||||
: "var(--mantine-color-edr-border-6)",
|
||||
background: tone.bg,
|
||||
borderTop: first ? undefined : "1px solid #EFF3F7",
|
||||
}}
|
||||
>
|
||||
<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={12} wrap="nowrap" align="center" px={18} py={13}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 9,
|
||||
background: tone.chipBg,
|
||||
color: tone.fg,
|
||||
}}
|
||||
>
|
||||
<FileText size={16} />
|
||||
</Box>
|
||||
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Badge variant="light" color={meta.color} radius="sm">
|
||||
{meta.label}
|
||||
</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>
|
||||
)}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz={12.5} fw={600} c="edr-text" truncate>
|
||||
{doc.label}
|
||||
{doc.required ? " *" : ""}
|
||||
</Text>
|
||||
<Text fz={11} c="#93A4B5" truncate>
|
||||
{fileLine}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Badge
|
||||
variant="light"
|
||||
radius="xl"
|
||||
color={meta.color}
|
||||
styles={{ root: { flexShrink: 0 } }}
|
||||
>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
|
||||
<Group gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{hasFile && !readOnly && !isApproved && !approvalsLocked && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
radius={7}
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={12} />}
|
||||
disabled={busy}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</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">
|
||||
<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",
|
||||
}}
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius={7}
|
||||
size={29}
|
||||
onClick={() => void downloadBookingFile(doc.file!.id, doc.file!.name)}
|
||||
>
|
||||
<Download size={15} />
|
||||
</Box>
|
||||
<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>
|
||||
|
||||
{(doc.history?.length ?? 0) > 0 && (
|
||||
<DocHistoryTimeline history={doc.history!} />
|
||||
)}
|
||||
<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}
|
||||
|
||||
{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>
|
||||
)}
|
||||
{history.length > 0 ? <DocHistoryTimeline history={history} /> : null}
|
||||
|
||||
{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
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
disabled={busy}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
) : (
|
||||
{queryOpen && !readOnly ? (
|
||||
<Box
|
||||
mt="sm"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
@@ -733,11 +814,8 @@ function DocReviewCard({
|
||||
}}
|
||||
>
|
||||
<Group gap={6} mb={6}>
|
||||
<MessageSquareWarning
|
||||
size={14}
|
||||
color="var(--mantine-color-red-7)"
|
||||
/>
|
||||
<Text fz="12.5px" fw={700} c="red.8">
|
||||
<MessageSquareWarning size={14} color="var(--mantine-color-red-7)" />
|
||||
<Text fz={12.5} fw={700} c="red.8">
|
||||
Describe the problem for the customer
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -775,9 +853,9 @@ function DocReviewCard({
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Check } from "lucide-react";
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
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 = [
|
||||
"CUSTOMER_INTAKE",
|
||||
@@ -24,6 +28,18 @@ const PHASE_LABELS: Record<string, string> = {
|
||||
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 = [
|
||||
"CUSTOMER_INTAKE",
|
||||
"GL_ET_REVIEW",
|
||||
@@ -38,6 +54,20 @@ function phaseIndex(phases: readonly string[], current?: string | null): number
|
||||
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({
|
||||
clearance,
|
||||
tradeDirection,
|
||||
@@ -50,61 +80,69 @@ export function ClearancePhaseStepper({
|
||||
const phases = tradeDirection === "EXPORT" ? EXPORT_PHASES : IMPORT_PHASES;
|
||||
const current = clearance?.phase ?? phases[0];
|
||||
const activeIdx = phaseIndex(phases, current);
|
||||
const dot = compact ? 26 : 28;
|
||||
|
||||
return (
|
||||
<Group gap={0} wrap="nowrap" align="flex-start" style={{ overflowX: "auto" }}>
|
||||
{phases.map((phase, index) => {
|
||||
const isComplete = index < activeIdx;
|
||||
const isActive = index === activeIdx;
|
||||
const isLast = index === phases.length - 1;
|
||||
const actor = PHASE_ACTOR[phase];
|
||||
|
||||
return (
|
||||
<Box key={phase} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: compact ? 72 : 88 }}>
|
||||
<Group gap={0} wrap="nowrap" align="center">
|
||||
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: compact ? 28 : 34,
|
||||
height: compact ? 28 : 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-3)",
|
||||
color: isComplete ? "white" : isActive ? BRAND_GREEN : "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
>
|
||||
{isComplete ? <Check size={compact ? 14 : 16} strokeWidth={3} /> : null}
|
||||
</Box>
|
||||
<Text
|
||||
size={compact ? "10px" : "xs"}
|
||||
fw={isActive ? 600 : 500}
|
||||
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
|
||||
ta="center"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{PHASE_LABELS[phase] ?? phase}
|
||||
</Text>
|
||||
</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)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Stack
|
||||
key={phase}
|
||||
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
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
width: dot,
|
||||
height: dot,
|
||||
borderRadius: 999,
|
||||
background: isComplete ? GREEN : "#FFFFFF",
|
||||
border: `2px solid ${
|
||||
isComplete ? GREEN : isActive ? BLUE : BORDER
|
||||
}`,
|
||||
color: isComplete ? "#FFFFFF" : isActive ? BLUE : MUTED,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{isComplete ? <Check size={14} strokeWidth={3} /> : index + 1}
|
||||
</Box>
|
||||
<Line done={isComplete} hidden={index === phases.length - 1} />
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
fz={10.5}
|
||||
fw={700}
|
||||
lh={1.3}
|
||||
ta="center"
|
||||
style={{ color: isActive || isComplete ? INK : MUTED }}
|
||||
>
|
||||
{PHASE_LABELS[phase] ?? phase}
|
||||
</Text>
|
||||
{actor ? (
|
||||
<Text
|
||||
fz={9}
|
||||
fw={700}
|
||||
lts="0.3px"
|
||||
style={{ color: isActive ? BLUE : MUTED, marginTop: -3 }}
|
||||
>
|
||||
{actor}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Progress,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
@@ -14,14 +16,9 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} 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 {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FileText,
|
||||
@@ -30,10 +27,17 @@ import {
|
||||
PackageOpen,
|
||||
Receipt,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Ship,
|
||||
Truck,
|
||||
Upload,
|
||||
} 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 {
|
||||
deliveryOrderFileLabel,
|
||||
isDeliveryOrderFileCode,
|
||||
@@ -113,6 +117,9 @@ export function isBookingMilestoneDone(
|
||||
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(
|
||||
clearance: ClearanceViewLike,
|
||||
bookingCreated: boolean,
|
||||
@@ -322,19 +329,64 @@ export function PhasedClearanceActionPanel({
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{clearance.nextAction ? (
|
||||
<Alert color="blue" variant="light" title="Next step">
|
||||
<Text size="sm">
|
||||
<strong>{clearance.nextAction.actor.replace("_", " ")}</strong> —{" "}
|
||||
{clearance.nextAction.action}
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Paper withBorder radius={13} p={0} style={{ overflow: "hidden" }}>
|
||||
{/* Header: what this workflow is, and how far along it is. */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px={18}
|
||||
py={15}
|
||||
style={{ borderBottom: "1px solid #EFF3F7" }}
|
||||
>
|
||||
<Group gap={9} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ShieldCheck size={16} color="#0A8A5F" />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={700} c="edr-text">
|
||||
Import pre-booking clearance
|
||||
</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>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} size="sm" mb="md">
|
||||
Import pre-booking clearance
|
||||
</Text>
|
||||
{/* 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
|
||||
active={activeStep}
|
||||
orientation="vertical"
|
||||
@@ -826,7 +878,8 @@ export function PhasedClearanceActionPanel({
|
||||
onDownloadFile={onDownloadFile}
|
||||
/>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
</Stepper>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -263,6 +263,14 @@ const RuleEngineFormDialog = ({
|
||||
next.cargoTypeId = "";
|
||||
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
|
||||
// nothing answered under the other shape may survive into the payload.
|
||||
if (name === "isShippingLineRate") {
|
||||
@@ -397,7 +405,11 @@ const RuleEngineFormDialog = ({
|
||||
// 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
|
||||
// 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"
|
||||
color="edr-green"
|
||||
/>
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface FormFieldDef {
|
||||
disabled?: boolean;
|
||||
/** Editable on create, locked when editing an existing record. */
|
||||
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). */
|
||||
suffix?: string;
|
||||
/** 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: "includesFirstMile", label: "Includes first 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",
|
||||
label: "Ethiopian customs only",
|
||||
type: "boolean",
|
||||
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.",
|
||||
showIf: (v) => v.includesCustoms === true,
|
||||
"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.",
|
||||
disabledIf: (v) => v.includesCustoms === true,
|
||||
},
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user