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({
|
@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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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" },
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user