From 2f894ba51befcb99972207dfc91bb6839b9d89c4 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 20 Aug 2026 18:06:13 +0000 Subject: [PATCH] changes --- .../dto/create-service-type.dto.ts | 2 +- .../entities/service-type.entity.ts | 8 +- .../services/service-types.service.ts | 44 +- .../detail/ClearanceReviewSection.tsx | 460 ++++++++++-------- .../contracts/ClearancePhaseStepper.tsx | 132 +++-- .../contracts/PhasedClearanceActionPanel.tsx | 91 +++- .../ruleEngine/RuleEngineFormDialog.tsx | 14 +- .../src/pages/ruleEngine/config/resources.ts | 21 +- 8 files changed, 495 insertions(+), 277 deletions(-) diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts index 9904e67fd..fd89243f6 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts index 217cb6dea..ac6f9b83d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts index 1353b4d28..1be51a259 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts @@ -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 { 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 { + 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. */ diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index 291bc0d05..a11133711 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -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 ( - - {stats.approved}/{stats.total} approved - - } - > - - {!hideSummary && stats.total > 0 && ( - - - - - - - + + + + + + + Customer documents + + + {stats.approved} of {stats.total} approved + {stats.queried > 0 ? ` · ${stats.queried} queried` : ""} · required + marked * + - )} - {customerDocs.length === 0 ? ( - - No customer documents are required for this booking. + + + + + {approvalsLocked ? "Uploads closed" : "Uploads open"} - ) : ( - customerDocs.map((doc) => ( - - 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} - /> - )) - )} - - + + + + {!hideSummary && stats.total > 0 && ( + + + + + + + + + )} + + {customerDocs.length === 0 ? ( + + No customer documents are required for this booking. + + ) : ( + customerDocs.map((doc, i) => ( + + 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} + /> + )) + )} + {clearance.outputCode && !phasedCustoms && ( = { + 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 ( - - - - - - - - - {doc.label} - {doc.required ? " *" : ""} - - - {hasFile ? doc.file!.name : "Not uploaded by customer"} - - - + + + + - - - {meta.label} - - {hasFile && - isViewable({ - name: doc.file!.name, - url: "", - }) && ( - - - - )} + + + {doc.label} + {doc.required ? " *" : ""} + + + {fileLine} + + + + + {meta.label} + + + + {hasFile && !readOnly && !isApproved && !approvalsLocked && ( + + )} + {hasFile && !readOnly && !queriesLocked && !queryOpen && ( + + )} + {hasFile && isViewable({ name: doc.file!.name, url: "" }) && ( + + + void fetchViewableFile(doc.file!.id, doc.file!.name).then(onView) + } + > + + + + )} {hasFile && ( - - void downloadBookingFile(doc.file!.id, doc.file!.name) - } - c="edr-green" - style={{ - display: "flex", - background: "transparent", - border: "none", - cursor: "pointer", - }} + void downloadBookingFile(doc.file!.id, doc.file!.name)} > - - + + + + )} + {expandable && ( + + setOpen((o) => !o)} + > + + )} - {(doc.history?.length ?? 0) > 0 && ( - - )} + + + {status === "QUERIED" && doc.note ? ( + } + p="xs" + mb="sm" + > + + {doc.note} + + + ) : null} - {status === "QUERIED" && doc.note && ( - } - p="xs" - > - - {doc.note} - - - )} + {history.length > 0 ? : null} - {hasFile && !readOnly && ( - - {!queryOpen ? ( - - {!queriesLocked && ( - - )} - {!isApproved && !approvalsLocked && ( - - )} - - ) : ( + {queryOpen && !readOnly ? ( - - + + Describe the problem for the customer @@ -775,9 +853,9 @@ function DocReviewCard({ - )} + ) : null} - )} - + + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx index ba93a790d..11cba11ba 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx @@ -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 = { POST_TRANSIT: "Transit", }; +/** Which desk owns each phase — shown under the label, as in the design. */ +const PHASE_ACTOR: Record = { + 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 ( + ); })} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 4b44edda6..b19c3af41 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -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({ ) : null} - {clearance.nextAction ? ( - - - {clearance.nextAction.actor.replace("_", " ")} —{" "} - {clearance.nextAction.action} - - - ) : null} + + {/* Header: what this workflow is, and how far along it is. */} + + + + + + Import pre-booking clearance + + + Step {Math.min(activeStep + 1, IMPORT_STEP_COUNT)} of{" "} + {IMPORT_STEP_COUNT} + {clearance.nextAction + ? ` · ${clearance.nextAction.action}` + : ""} + + + + + + + {Math.round((activeStep / IMPORT_STEP_COUNT) * 100)}% + + + - - - Import pre-booking clearance - + {/* Whose desk the flow is sitting on right now. */} + {clearance.nextAction ? ( + + + + {clearance.nextAction.actor.replace("_", " ").toUpperCase()} + + + {clearance.nextAction.action} + + + ) : null} + + - + + ); diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index bb2a428d8..e3bf11375 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -254,6 +254,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") { @@ -388,7 +396,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" /> diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index c6003d8a4..5fd619bae 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -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) => 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. */ @@ -888,14 +890,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" }, ],