From 8ba8376f45f0e035ab5618b07c4f6da5f1191870 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 15 Aug 2026 08:39:59 +0000 Subject: [PATCH 1/4] feat(filters): migrate FleetResourcePage to the pill filter bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real live fleet page — FleetCrudPages.tsx (previous commit) turned out to be dead code; every fleet route (locomotives/trains/wagons/ containers/cargoes/vehicles/drivers) actually renders this one, config-driven off resources.ts. Highest-leverage single file in the remaining inventory: 7 routes at once. - filterDefs are built per slug from `config.listFilters` (status/yard/ wagon type/train…, wired to real server params via each def's default `{key: value}` toParams) plus a "Registered" date range. The 3 slugs with no server list filters (trains/containers/cargoes) fall back to a plain client-only Status filter off a fixed enum, not "whatever status exists in the currently-loaded rows" — the latter would be circular (filterDefs feeds useFilters, which feeds the query that produces those rows). - serverListFilters/pagedFilters derive straight from `controls.params` instead of hand-picking each field off local state — the def keys already match the API's param names, so this is mostly free. - filteredRows keeps the original's exact "usesServerListFilters ⇒ trust the server, don't re-check client-side" short-circuit — some server list filters (a wagon's `trainNumber` matches either of two different columns) aren't expressible as a plain client-side field equality, and re-applying them would silently break. - Pagination moved from a local `usePagination()` (component state) to useFilters' URL-backed page/pageSize — the whole point of this pass. DataTable takes `controls.tableProps(total)` directly; FleetCardGrid (not a DataTable) is fed the same pieces by hand. - The two manual "reset on slug/filter change" effects are dropped — a same-app nav Link to a bare path already clears the query string, and useFilters already deletes `page` on every filter/search change. - FleetToolbar's view-mode SegmentedControl moves into FilterBar's `children` slot; its search/filters props are no longer used here. --- .../src/pages/fleet/FleetResourcePage.tsx | 399 ++++++++---------- 1 file changed, 168 insertions(+), 231 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index d837ee712..23dc6b76b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -1,7 +1,5 @@ import type { ColumnDef } from "@edr/ui-common"; -import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Title } from "@mantine/core"; -import { DatePickerInput } from "@mantine/dates"; -import { getDateRangePresets } from "@/components/common/dateRangePresets"; +import { Box, Button, Card, Container, Group, Modal, SegmentedControl, Select, Stack, Text, TextInput, Title } from "@mantine/core"; import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; @@ -13,7 +11,7 @@ import { FREIGHT_PERMS, } from "@/lib/permissions"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; -import { Inbox, Plus, Warehouse } from "lucide-react"; +import { Inbox, LayoutGrid, Plus, Table2, Warehouse } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Link, Navigate, useLocation } from "react-router-dom"; @@ -21,13 +19,12 @@ import FleetCardGrid from "@/components/fleet/FleetCardGrid"; import FleetFormDialog from "@/components/fleet/FleetFormDialog"; import FleetHistoryModal from "@/components/fleet/FleetHistoryModal"; import FleetRecordActions from "@/components/fleet/FleetRecordActions"; -import FleetToolbar from "@/components/fleet/FleetToolbar"; import { matchesDayRange } from "@/hooks/useListControls"; import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal"; import WagonStatusActions from "@/components/wagons/WagonStatusActions"; import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal"; import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat"; -import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; +import { useFleetViewMode, type FleetViewMode } from "@/components/fleet/useFleetViewMode"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { useToast } from "@/hooks/use-toast"; import { @@ -43,11 +40,46 @@ import { type FleetListFilters, type FleetRecord, } from "@/services/fleet/fleet.service"; -import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; -import { useDebouncedValue } from "@mantine/hooks"; +import { DataTable, DataTableFooter } from "@edr/ui-common"; +import { dateRangeParams, FilterBar, useFilters, type FilterDef, type FilterOption } from "@/components/filters"; const DEFAULT_SLUG: FleetResourceSlug = "locomotives"; +const SERVER_FILTERED_SLUGS: FleetResourceSlug[] = ["wagons", "locomotives", "vehicles", "drivers"]; + +// trains/containers/cargoes have no server-side `listFilters` config (see +// resources.ts) — they get a plain client-only Status filter instead, off a +// fixed enum rather than "whatever status happens to exist in the currently +// loaded rows" (which would create a circular dependency: filterDefs feeds +// useFilters, which feeds the query that produces those rows). +const TRAIN_STATUS_OPTIONS: FilterOption[] = [ + { value: "AVAILABLE", label: "Available" }, + { value: "SCHEDULED", label: "Scheduled" }, + { value: "IN_SERVICE", label: "In service" }, + { value: "UNDER_MAINTENANCE", label: "Under maintenance" }, + { value: "OUT_OF_SERVICE", label: "Out of service" }, + { value: "DEACTIVATED", label: "Deactivated" }, +]; +const CONTAINER_STATUS_OPTIONS: FilterOption[] = [ + { value: "AVAILABLE", label: "Available" }, + { value: "LOADED", label: "Loaded" }, + { value: "IN_TRANSIT", label: "In transit" }, + { value: "MAINTENANCE", label: "Maintenance" }, + { value: "DAMAGED", label: "Damaged" }, +]; +const CARGO_STATUS_OPTIONS: FilterOption[] = [ + { value: "PENDING", label: "Pending" }, + { value: "LOADED", label: "Loaded" }, + { value: "IN_TRANSIT", label: "In transit" }, + { value: "DELIVERED", label: "Delivered" }, + { value: "UNLOADED", label: "Unloaded" }, +]; +const FALLBACK_STATUS_OPTIONS: Partial> = { + trains: TRAIN_STATUS_OPTIONS, + containers: CONTAINER_STATUS_OPTIONS, + cargoes: CARGO_STATUS_OPTIONS, +}; + const FleetResourcePage = () => { const location = useLocation(); const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG; @@ -70,18 +102,9 @@ const FleetResourcePage = () => { hasPermission(user, FREIGHT_PERMS.wagons.transferFulfill) || hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll); - const { pagination, setPagination } = usePagination({ pageSize: 10 }); - const [search, setSearch] = useState(""); - const [debouncedSearch] = useDebouncedValue(search, 300); // Wagons and locomotives page in the database; the rest still list in full // and page in the browser (see `pagedHandlers` in fleet.service). const serverPaged = isFleetServerPaginated(slug); - const [statusFilter, setStatusFilter] = useState("ALL"); - // Registration date range. Server-side list filters (status/yard/train) are - // applied by the API; this narrows what comes back, alongside search. - const [dateFrom, setDateFrom] = useState(null); - const [dateTo, setDateTo] = useState(null); - const [listFilterValues, setListFilterValues] = useState>({}); const [formOpen, setFormOpen] = useState(false); const [editing, setEditing] = useState(null); const [removeTarget, setRemoveTarget] = useState(null); @@ -95,77 +118,6 @@ const FleetResourcePage = () => { const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false); const { viewMode, setViewMode } = useFleetViewMode(slug); - const serverListFilters = useMemo((): FleetListFilters | undefined => { - const serverFilteredSlugs: FleetResourceSlug[] = ["wagons", "locomotives", "vehicles", "drivers"]; - if (!serverFilteredSlugs.includes(slug)) return undefined; - const filters: FleetListFilters = {}; - const status = listFilterValues.status; - const currentYardId = listFilterValues.currentYardId; - const availability = listFilterValues.availability; - const trainNumber = listFilterValues.trainNumber; - const trainId = listFilterValues.trainId; - if (status && status !== "ALL") { - (filters as { status?: string }).status = status; - } - if (currentYardId && currentYardId !== "ALL") { - filters.currentYardId = currentYardId; - } - if (availability && availability !== "ALL") { - (filters as { availability?: string }).availability = availability; - } - if (trainNumber && trainNumber !== "ALL") { - (filters as { trainNumber?: string }).trainNumber = trainNumber; - } - if (trainId && trainId !== "ALL") { - filters.trainId = trainId; - } - // Wagons only: narrow the fleet to one wagon type (the API filters on it). - const wagonTypeId = listFilterValues.wagonTypeId; - if (wagonTypeId && wagonTypeId !== "ALL") { - (filters as { wagonTypeId?: string }).wagonTypeId = wagonTypeId; - } - // The plain locomotives list has no server-side search — its page window - // does, so the term is only sent on the paginated path. - if ((serverPaged || slug !== "locomotives") && debouncedSearch.trim()) { - filters.search = debouncedSearch.trim(); - } - return filters; - }, [slug, listFilterValues, debouncedSearch, serverPaged]); - - // On the server-paged path the page window, the search and the registration - // date range are all resolved by the API — nothing is filtered client-side. - const pagedFilters = useMemo( - (): FleetListFilters => ({ - ...serverListFilters, - page: pagination.pageIndex + 1, - pageSize: pagination.pageSize, - ...(dateFrom ? { createdFrom: dateFrom } : {}), - ...(dateTo ? { createdTo: dateTo } : {}), - }), - [serverListFilters, pagination.pageIndex, pagination.pageSize, dateFrom, dateTo], - ); - - const listQuery = useQuery({ - ...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }), - enabled: !serverPaged, - }); - const pagedQuery = useQuery({ - ...api.fleet.listPaged.queryOptions({ input: { slug, filters: pagedFilters } }), - enabled: serverPaged, - placeholderData: keepPreviousData, - }); - - const activeQuery = serverPaged ? pagedQuery : listQuery; - const { isLoading, isError, error } = activeQuery; - const allRows = useMemo( - () => (serverPaged ? (pagedQuery.data?.items ?? []) : (listQuery.data ?? [])), - [serverPaged, pagedQuery.data, listQuery.data], - ); - const create = useMutation(api.fleet.create.mutationOptions()); - const update = useMutation(api.fleet.update.mutationOptions()); - const remove = useMutation(api.fleet.remove.mutationOptions()); - const purge = useMutation(api.fleet.purge.mutationOptions()); - const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery( api.wagonTypes.list.queryOptions(), ); @@ -202,40 +154,8 @@ const FleetResourcePage = () => { enabled: slug === "wagons", }); - useEffect(() => { - setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); - setSearch(""); - setStatusFilter("ALL"); - setListFilterValues({}); - }, [slug, setPagination]); - - useEffect(() => { - setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); - }, [search, listFilterValues, dateFrom, dateTo, setPagination]); - - const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status")); const usesServerListFilters = Boolean(config?.listFilters?.length); - const statusFilterOptions = useMemo(() => { - if (!hasStatusColumn || usesServerListFilters) return []; - if (slug === "vehicles" || slug === "drivers") { - return [ - { value: "ALL", label: "All statuses" }, - { value: "ACTIVE", label: "Active" }, - { value: "INACTIVE", label: "Inactive" }, - ]; - } - const statuses = new Set( - allRows - .map((row) => String((row as unknown as Record).status ?? "")) - .filter(Boolean), - ); - return [ - { value: "ALL", label: "All statuses" }, - ...[...statuses].sort().map((status) => ({ value: status, label: status })), - ]; - }, [allRows, hasStatusColumn, usesServerListFilters, slug]); - const dynamicOptions = useMemo(() => { const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map( (t) => ({ value: t.id, label: `${t.code}${t.name ? ` - ${t.name}` : ""}` }), @@ -291,25 +211,78 @@ const FleetResourcePage = () => { }; }, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards, trains]); - const listFilterSelects = useMemo(() => { - if (!config?.listFilters?.length) return null; - return config.listFilters.map((filter) => { - const dynamicOpts = filter.dynamicOptions - ? (dynamicOptions[filter.dynamicOptions] ?? []) - : []; - const staticOpts = - filter.options?.map((opt) => ({ value: opt.value, label: opt.label })) ?? []; - const opts = filter.dynamicOptions ? dynamicOpts : staticOpts; - return { - ...filter, - value: listFilterValues[filter.key] ?? "ALL", - data: [ - { value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` }, - ...opts, - ], - }; - }); - }, [config?.listFilters, listFilterValues, dynamicOptions]); + // One pill per configured server list filter (status/yard/wagon type/train…), + // built off `config.listFilters` — same source the old plain ` { - setListFilterValues((prev) => ({ - ...prev, - [filter.key]: value ?? "ALL", - })); - setPagination((prev) => ({ ...prev, pageIndex: 0 })); - }} - size="sm" - radius="lg" - w={200} - searchable={filter.data.length > 8} - comboboxProps={{ withinPortal: true }} - styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} - /> - ))} - - ) : hasStatusColumn && statusFilterOptions.length > 1 ? ( - - Status: - - {[{ value: "ALL", label: "All" }, ...statusFilterOptions].map((option) => ( - - ))} - - - ) : null} - - } - /> + viewId={`fleet-${slug}`} + > + setViewMode(value as FleetViewMode)} + size="sm" + radius="lg" + data={[ + { + value: "table", + label: ( + + + Table + + ), + }, + { + value: "cards", + label: ( + + + Cards + + ), + }, + ]} + styles={{ root: { background: "var(--mantine-color-gray-1)" } }} + /> + {viewMode === "table" ? ( @@ -696,18 +643,8 @@ const FleetResourcePage = () => { : undefined } emptyMessage={`No ${itemLabel} found`} - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount, - totalCount, - }} - tableOptions={{ - manualPagination: true, - pageCount, - state: { pagination }, - onPaginationChange: setPagination, - }} + pagination={dtPagination} + tableOptions={dtTableOptions} containerClassName="border-0 shadow-none bg-transparent" footer={({ table, pagination: footerPagination }) => ( { rows={pagedRows} status={tableStatus} emptyMessage={`No ${itemLabel} found`} - pagination={pagination} + pagination={{ pageIndex: controls.page - 1, pageSize: controls.pageSize }} pageCount={pageCount} totalCount={totalCount} - onPaginationChange={setPagination} + onPaginationChange={dtTableOptions!.onPaginationChange!} onEdit={ canUpdate ? (record) => { From aae0066a5d9ea4eed84ecaa9e317bfba3cbe16ca Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 15 Aug 2026 08:42:14 +0000 Subject: [PATCH 2/4] feat(filters): migrate ClearanceDocumentsPage to the pill filter bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical Family-A port — same server contract BookingRequestsPage already uses (tradeDirection/freightType/isGovernment/createdFrom/ createdTo/search/page/pageSize all already accepted). One wrinkle: this hub has no true "unfiltered" state — it always scopes to a fixed 4-status baseline (customsClearingEnabled=false self-clearance bookings), with the old status Select's "All statuses" row just being that baseline spelled out as an option. Modeled as a normal optional Status enum pill (4 individual statuses, no synthetic "All" entry) whose absence falls back to the baseline in the query build, not in the filter defs themselves — `customsClearingEnabled` stays a fixed, non-user-facing param the same way. --- .../contracts/ClearanceDocumentsPage.tsx | 249 +++++------------- 1 file changed, 62 insertions(+), 187 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx index 029c53dd2..766f7c897 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx @@ -3,34 +3,28 @@ import { ActionIcon, Box, Card, - Group, - Select, Stack, Text, - TextInput, ThemeIcon, } from "@mantine/core"; -import { DatePickerInput } from "@mantine/dates"; -import { getDateRangePresets } from "@/components/common/dateRangePresets"; -import { useDebouncedValue } from "@mantine/hooks"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { FileText, Inbox, RefreshCw, User } from "lucide-react"; +import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { PageContainer, PageHeader } from "@/components/page"; -import { bookingsService } from "@/services/bookings.service"; +import { bookingsService, type BookingListFilter } from "@/services/bookings.service"; import type { BookingDetail } from "@/types/booking"; import { Badge, DataTable, DataTableFooter, - usePagination, type ColumnDef, } from "@edr/ui-common"; +import { dateRangeParams, FilterBar, useFilters, type FilterDef } from "@/components/filters"; /** * Operations "Clearance Documents" hub — the worklist for self-clearance @@ -43,16 +37,14 @@ import { const PAGE_SIZE = 10; /** - * Status filter options (values = `statuses` param). FULLY_EXECUTED is the - * post-approval status of intercity (domestic) bookings — kept in the list as - * history, otherwise an approved intercity row vanishes from the hub. + * The hub's baseline scope — FULLY_EXECUTED is the post-approval status of + * intercity (domestic) bookings, kept in as history so an approved intercity + * row doesn't just vanish. Sent whenever the Status pill has no narrower pick. */ -const BOOKING_STATUS_OPTIONS = [ - { - value: - "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED", - label: "All statuses", - }, +const DEFAULT_STATUSES = + "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED"; + +const STATUS_OPTIONS = [ { value: "AWAITING_DOCUMENTS", label: "Awaiting documents" }, { value: "DOCUMENTS_UNDER_REVIEW", label: "Under review" }, { value: "CLEARANCE_READY", label: "Clearance ready" }, @@ -75,72 +67,58 @@ const OWNERSHIP_OPTIONS = [ { value: "false", label: "Private" }, ]; -function startOfDayIso(d: Date): string { - const x = new Date(d); - x.setHours(0, 0, 0, 0); - return x.toISOString(); -} - -function endOfDayIso(d: Date): string { - const x = new Date(d); - x.setHours(23, 59, 59, 999); - return x.toISOString(); -} - export default function ClearanceDocumentsPage() { const navigate = useNavigate(); - const [query, setQuery] = useState(""); - const [debouncedQuery] = useDebouncedValue(query, 300); - const [bookingStatuses, setBookingStatuses] = useState( - BOOKING_STATUS_OPTIONS[0].value, - ); const { filterOptions } = useMyTradeAccess(); - const [directionFilter, setDirectionFilter] = useState(null); - const [freightTypeFilter, setFreightTypeFilter] = useState(null); - const [ownershipFilter, setOwnershipFilter] = useState(null); - const [createdFrom, setCreatedFrom] = useState(null); - const [createdTo, setCreatedTo] = useState(null); - const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE }); - const search = debouncedQuery.trim() || undefined; + const filterDefs: FilterDef[] = useMemo( + () => [ + { + key: "status", + label: "Status", + type: "enum", + multiple: false, + options: STATUS_OPTIONS, + // No pick ⇒ no `statuses` param at all; the query fills in + // DEFAULT_STATUSES itself, same as the old Select's "All statuses" row. + toParams: ({ v }) => ({ statuses: v[0] }), + }, + { + key: "tradeDirection", + label: "Direction", + type: "enum", + multiple: false, + options: filterOptions(TRADE_DIRECTION_OPTIONS), + }, + { key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS }, + { key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS }, + { + key: "created", + label: "Created", + type: "date", + toParams: dateRangeParams("createdFrom", "createdTo"), + }, + ], + [filterOptions], + ); - const resetPage = useCallback(() => { - setPagination({ pageIndex: 0, pageSize: PAGE_SIZE }); - }, [setPagination]); + const controls = useFilters(filterDefs, { pageSize: PAGE_SIZE }); - const page = pagination.pageIndex + 1; + const filter: BookingListFilter = useMemo( + () => ({ + ...(controls.params as unknown as BookingListFilter), + // Self-clearance instances carry bookingType=ONE_TIME whatever their + // contract kind, so customsClearingEnabled=false + the status scope + // above are what isolate exactly this worklist. + customsClearingEnabled: "false", + statuses: (controls.params.statuses as string | undefined) ?? DEFAULT_STATUSES, + }), + [controls.params], + ); const bookingsQuery = useQuery({ - queryKey: [ - "clearance-documents", - "bookings", - bookingStatuses, - directionFilter, - freightTypeFilter, - ownershipFilter, - createdFrom, - createdTo, - page, - search, - ], - queryFn: () => - // Self-clearance instances carry bookingType=ONE_TIME whatever their - // contract kind, so customsClearingEnabled=false + the three per-booking - // clearance statuses are what isolate exactly this worklist. - bookingsService.list({ - statuses: bookingStatuses, - customsClearingEnabled: "false", - page, - pageSize: PAGE_SIZE, - search, - ...(directionFilter ? { tradeDirection: directionFilter } : {}), - ...(freightTypeFilter ? { freightType: freightTypeFilter } : {}), - ...(ownershipFilter - ? { isGovernment: ownershipFilter as "true" | "false" } - : {}), - ...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}), - ...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}), - }), + queryKey: ["clearance-documents", "bookings", filter], + queryFn: () => bookingsService.list(filter), placeholderData: keepPreviousData, }); @@ -233,7 +211,6 @@ export default function ClearanceDocumentsPage() { ); const total = bookingsQuery.data?.total ?? 0; - const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); const showEmpty = !bookingsQuery.isLoading && !bookingsQuery.isError && bookingRows.length === 0; const tableStatus = bookingsQuery.isLoading @@ -265,103 +242,12 @@ export default function ClearanceDocumentsPage() { - - } - value={query} - onChange={(e) => { - setQuery(e.target.value); - resetPage(); - }} - rightSection={ - query && ( - { - setQuery(""); - resetPage(); - }} - > - - - ) - } - style={{ flex: 1, minWidth: "200px" }} - radius="lg" - /> - { - setDirectionFilter(v); - resetPage(); - }} - clearable - radius="lg" - style={{ minWidth: 130 }} - aria-label="Filter by direction" - /> - { - setOwnershipFilter(v); - resetPage(); - }} - clearable - radius="lg" - style={{ minWidth: 140 }} - aria-label="Filter by ownership" - /> - { - setCreatedFrom(from ? new Date(from) : null); - setCreatedTo(to ? new Date(to) : null); - resetPage(); - }} - presets={getDateRangePresets()} - clearable - radius="lg" - style={{ minWidth: 220 }} - aria-label="Created date range" - /> - + {showEmpty ? ( @@ -384,18 +270,7 @@ export default function ClearanceDocumentsPage() { state: { from: "/dashboard/contracts/clearance-documents" }, }) } - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination }, - onPaginationChange: setPagination, - manualPagination: true, - pageCount, - }} + {...controls.tableProps(total)} containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words" footer={DataTableFooter} /> From 339996d27fb1fa116e9fd23d806b2cace672f807 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 17 Aug 2026 07:49:12 +0000 Subject: [PATCH 3/4] docs --- CLAUDE.md | 388 +++++++++++++++++++++++++++++++++++++++++--------- CLAUDE_NEW.md | 313 ---------------------------------------- docs/MAP.md | 96 +++++++++++++ 3 files changed, 420 insertions(+), 377 deletions(-) delete mode 100644 CLAUDE_NEW.md create mode 100644 docs/MAP.md diff --git a/CLAUDE.md b/CLAUDE.md index b90d3b1dd..a20fbaee7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,102 +1,362 @@ # EDR Platform — Developer Guide +> This file is the contract. If something here contradicts the code, the code is the +> truth and this file is a bug — fix it in the same PR. + +**Looking for where something lives? Read [`docs/MAP.md`](docs/MAP.md) first.** It routes +you to the right module or page without a repo-wide grep. + ## Overview -Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight Management and Passenger Management applications, plus shared types, NestJS utilities, and React component libraries. +Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight +Management and Passenger Management applications, a payment microservice, plus shared +types, NestJS utilities, and React component libraries. + +The freight domain is the largest and most active area. Its core flow is: +**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload +→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.** +Fees (storage, demurrage, double handling, truck detention) and allocation rules +(warehouse/yard/zone) hang off the warehouse stage. ## Apps -| App | Package name | Purpose | Port | -| ------------------------------ | --------------------------- | -------------------------------------------------- | ---- | -| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 | -| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 | -| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 | -| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 | -| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 | -| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 | -| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 | +The two domains are **not built the same way**. Check which stack you are in before +copying a pattern across: -`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. Each holds a `portal/` and `backoffice/` sub-app, both of which are independent pnpm workspace packages (declared in `pnpm-workspace.yaml`). The existing `pnpm dev:freight` / `pnpm dev:passenger` turbo filters (`@edr/freight-*` / `@edr/passenger-*`) cover all four web apps + their APIs. +| App | Package name | Stack | Default port | +| ------------------------------ | --------------------------- | ---------------------- | ------------ | +| `edr-freight-api` | `@edr/freight-api` | NestJS + **TypeORM** | 3001 | +| `edr-freight-web/portal` | `@edr/freight-portal` | React + **Vite** | 5273 | +| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React + **Vite** | 5283 | +| `edr-passenger-api` | `@edr/passenger-api` | NestJS + **Prisma** | 4000 | +| `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 | +| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 | +| `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 | + +Those are the **fallbacks compiled into the code**, not what you will be running. Every +port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite +apps read it in `vite.config.ts` (`Number(env.PORT) || 5273`). This machine is shared by +the whole team and the low ports are contested — see the workspace root `CLAUDE.md` and +`./wt ports` for who currently holds what. + +`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. +Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace +packages (see `pnpm-workspace.yaml`). + +`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace +package and is not built, linted, or type-checked. Leave it alone unless asked. + +`apps/edr-gps-tracker/` is a separate service with its own `.env.example`. ## Packages -| Package | Purpose | -| ---------------------- | ---------------------------------------------------------------------------------- | -| `@edr/types` | Shared TypeScript interfaces and enums | -| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | -| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) | -| `@edr/ui-common` | Shared React components and theme | -| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) | -| `@edr/tsconfig` | Shared TypeScript configurations | -| `@edr/prettier-config` | Shared Prettier configuration | +| Package | Location | Purpose | +| ----------------------- | ----------------------------- | ------------------------------------------------------------- | +| `@edr/types` | `packages/types` | Shared TypeScript interfaces and enums | +| `@edr/api-common` | `packages/api-common` | NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | +| `@edr/ui-common` | `packages/ui-common` | Shared React components and theme | +| `@edr/iam-seed` | `packages/iam-seed` | IAM baseline seeder for apps sharing the `iam` schema | +| `@edr/payment-providers`| `packages/payment-providers` | Payment gateway integrations | +| `@edr/eslint-config` | `packages/config/eslint-config` | Shared ESLint configs (base/nestjs/react) | +| `@edr/tsconfig` | `packages/config/tsconfig` | Shared TypeScript configs | +| `@edr/prettier-config` | `packages/config/prettier-config` | Shared Prettier config | + +The three `config/*` packages are nested one level deeper than the rest — `packages/config` +itself is not a package. + +**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a +type in `packages/types/src` changes nothing for consumers until you rebuild: + +```bash +pnpm turbo build --filter=@edr/types +``` + +If a type-check fails on a field you just added to `@edr/types`, this is why. ## Commands -| Command | Description | -| -------------------- | ---------------------------------- | -| `pnpm install` | Install all workspace dependencies | -| `pnpm dev` | Run every app in dev mode | -| `pnpm dev:freight` | Run only freight API + web | -| `pnpm dev:passenger` | Run only passenger API + web | -| `pnpm build` | Build every package and app | -| `pnpm test` | Run all tests | -| `pnpm lint` | Lint everything | -| `pnpm type-check` | Type-check every package | -| `pnpm format` | Format all files with Prettier | +| Command | Description | +| ----------------------------- | ---------------------------------------- | +| `pnpm install` | Install all workspace dependencies | +| `pnpm dev` | Run every app in dev mode | +| `pnpm dev:freight` | Freight API + portal + backoffice | +| `pnpm dev:freight:api` | Freight API only | +| `pnpm dev:freight:portal` | Freight portal only | +| `pnpm dev:freight:backoffice` | Freight backoffice only | +| `pnpm dev:passenger` | Passenger API + web | +| `pnpm dev:payment` | Payment API | +| `pnpm build` | Build every package and app | +| `pnpm test` | Run all tests (turbo) | +| `pnpm type-check` | Type-check every package | +| `pnpm format` | Format all files with Prettier | +| `pnpm lint` | **Does not work** — see below | -## Standards +**`pnpm lint` fails.** `eslint` is not installed anywhere in the workspace, so +`turbo run lint` dies with `eslint: not found` even though every package declares a +`lint` script and `@edr/eslint-config` exists. Until someone adds the dependency, +tsc's `noUnusedLocals` is the only working unused-code check. Do not claim a change is +"lint clean". -- **TypeScript strict mode** is enabled in every package and app. -- **pnpm** is the only supported package manager — never run `npm install` or `yarn`. -- **Conventional commits** are enforced via commitlint on every commit. -- **NestJS modules** follow the 4-layer pattern: `module → controller → service → repository` (entities and DTOs live alongside). +`pnpm format` uses bare `prettier`, which ignores `@edr/prettier-config` — it is wired to +nothing. On the single-quoted passenger apps it will re-quote the whole file. Pass +`--config` explicitly there. + +Prefer targeted turbo filters over whole-repo runs — they are minutes faster: + +```bash +pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice +``` + +`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains, +gate-pass scenarios). Read the script before running one; several write real rows. + +## Environment & database + +- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and + no port `5433`/`5434` is published anywhere in the repo. +- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, + `DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a + remote database. +- The connection sits behind a **connection pooler**. Do **not** pass + `extra.options: '-c search_path=…'` — the pooler rejects it with + `08P01 unsupported startup parameter in options: search_path`. `search_path` is applied + per-connection in a pool `connect` handler instead. See + `apps/edr-freight-api/src/config/database.config.ts` before touching connection options. +- Each app owns its own database. **No cross-database joins**; cross-domain data flows + through API calls or message queues. +- IAM tables live in their own `iam` schema (`iam.users`, `iam.user_credentials`), + freight tables in `freight`. +- `psql` is not installed on the dev machine. To query the database, use the `edr-db` + skill (below) or write a short Node script using `pg` and run it from + `apps/edr-freight-api`, where `pg` resolves. + +## Hard rules + +These are non-negotiable. Everything else is a strong default. + +- **pnpm only.** Never run `npm install` or `yarn`. +- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not + reach for `any` to make an error go away. +- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false` + in every config and it has already corrupted this database twice (see *Migrations*). + All schema changes go through migrations. - **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`). -- **All entities** have `createdAt`, `updatedAt`, `deletedAt` (soft delete) via `@edr/api-common`'s `BaseEntity`. -- **All columns** use `snake_case` in the database (`@Column({ name: 'snake_case' })`); TypeScript properties use `camelCase`. -- **Never use `synchronize: true`** in production database config. All schema changes go through TypeORM migrations. -- **ESLint + Prettier** run on pre-commit via Husky + lint-staged. -- **Services** never inject TypeORM `Repository` directly — they inject the custom repository class. -- **Controllers** never contain business logic. +- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`, + `deletedAt` (soft delete). +- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`); + TypeScript properties are `camelCase`. +- **Controllers contain no business logic.** They validate, delegate, and shape the response. +- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`. +- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands. +- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and + offer the safe version. -## Auth +## Architecture -Authentication is handled by an external package (`@edr/iamui-common` or equivalent) that will be integrated later. **Do not** implement any auth, login, logout, JWT verification, password hashing, or user management code in this repo. +### NestJS module shape -When auth integration is needed, use placeholder TODO comments: +`module → controller → service → repository`, with `entities/` and `dto/` alongside. +`docs/MAP.md` lists the ~60 freight modules grouped by domain. -- `// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth` -- `// TODO: integrate @edr/auth — replace stub @CurrentUser with real one` +### Data access — the real model -The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are bare metadata setters with no guard wiring — they exist so controllers can be annotated correctly without depending on auth infrastructure yet. +There are two sanctioned ways to read and write, and you must pick the right one: -## Port Assignments +1. **Entity CRUD → the custom repository class.** Extends `BaseRepository` from + `@edr/api-common`. Services inject the repository class, never `Repository` directly. +2. **Read projections, queue endpoints, cross-table reports → raw SQL** via + `this.dataSource.query(...)` or `manager.query(...)` inside a transaction. -- `edr-freight-api`: 3001 -- `edr-freight-web/portal`: 5173 -- `edr-freight-web/backoffice`: 5183 -- `edr-passenger-api`: 3002 -- `edr-payment-api`: 3003 -- `edr-passenger-web/portal`: 5174 -- `edr-passenger-web/backoffice`: 5184 +Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it. +It carries one obligation: -## Database Layout +> **HARD RULE — validate every raw SQL statement against a real database before you ship it.** +> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through +> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*). -- `postgres-freight` (port 5433): database `edr_freight` — freight API only. -- `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only. -- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`). -- Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues. +Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository, +so they join the caller's transaction. + +**Never do slow I/O inside a database transaction.** Queue the work and fan it out after +commit. An SMS awaited inside a transaction once held capacity locks open for the whole +gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to +no timeout and will wait forever. + +### Migrations + +Migrations are the most dangerous surface in this repo. Two production-grade incidents have +already come from it. **Freight and payment use TypeORM migrations; passenger uses Prisma** +(`apps/edr-passenger-api/prisma/migrations`) — the rules below are about the TypeORM side. + +- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate + one-shot step, via the Dockerfile's `migration` build target (`docker build --target + migration`), with `migrationsTransactionMode: 'each'`. + - CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it + (`docker run --rm --env-file ...`) *before* building/deploying the app image. + - e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and + `freight-api-e2e` depends on it (`condition: service_completed_successfully`). + - Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run + migrations yourself before `docker compose up freight-api`, e.g. + `docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .` + then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't + use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled + output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`. + It silently applies zero freight migrations while exiting 0. +- Consequences you must design for: + - A watch-mode hot reload does **not** re-run migrations. If you add a column that new + code reads, apply it to the dev database yourself (idempotently) or fully restart. + - `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a + hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never + notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own + `forFeature()` registrations), but the standalone migration `DataSource` + (`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing + entity throws `Entity metadata for X#y was not found` at `initialize()`, before a + single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate + for this to break again** — diff the package's entity classes against `iamEntities` + when bumping it. +- **Give every migration a unique timestamp.** `apps/edr-freight-api/src/migrations` holds + 39 files, and 8 timestamps are shared by two or more of them. TypeORM orders by timestamp + and breaks ties non-deterministically. Check before adding one: + + ```bash + ls apps/edr-freight-api/src/migrations | grep -oE '^[0-9]+' | sort | uniq -d + ``` + + The prefix must be unused *and* higher than the newest recorded row. Note the + `freight.migrations` table has far more rows (~309) than this folder has files — most + come from `@tria-plc/iamapi-common`'s own migrations, which run from the same data source. +- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and + backfills guarded by `WHERE col IS NULL`. +- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory` + was recorded in `migrations` while its column was absent — it had been dropped out of band. + TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*. +- **A repair migration's `down()` should be a no-op.** Reverting a repair must not + re-introduce the outage it fixed. + +### Auth + +Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own. + +- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub. +- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`. +- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS..)`. +- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`. + Add a permission there before referencing it. +- Login is freight-api's own `POST /api/auth/login` (SharedAuthModule from + `@tria-plc/api-common`). Every login call needs an **`x-client-app` header** — + `backoffice` for employees, `portal` for customers. Without it the API 403s with + "Missing or unrecognized x-client-app header". Browsers send it; curl must add it. +- IAM has its own migrations, run ahead of freight migrations from the same data source, and + its own CLI scripts (`iam:migration:run`, `iam:seed:run`). + +Ownership checks are separate from permission checks. A staff user passes +`hasFreightPermission`; a customer must additionally pass an ownership assertion such as +`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed. + +## Frontend conventions + +- The **freight** web apps use **Mantine v9** (`^9.3.0`). Its APIs differ from v6/v7 — + check the installed version before copying a snippet. +- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the + freight web apps. Prefer it over re-implementing a component. +- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'` + delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no + `.message` and degrades to `"Request failed with status code 400"`. Use + `await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches + keep the synchronous version — their bodies are already parsed JSON. +- Server-side guards must be reflected in the UI. If the API will reject the action, the + button should be disabled, hidden, or explain the blocker — not fire and surface a 400. +- Prefer disabling a control with a visible reason over silently hiding it. + +## Notifications + +In-app notifications resolve recipients from the company's **linked portal users**. If a +company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error. +SMS and email still send, because they address the company's phone and email directly. Check +this before debugging a "missing notification". + +## PDF generation + +Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled +generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than +assume a headless browser exists. ## Adding a new module to a NestJS app -1. Create `modules//` with `entities/`, `dto/`, and the four `.{module,controller,service,repository}.ts` files. +1. Create `modules//` with `entities/`, `dto/`, and the four + `.{module,controller,service,repository}.ts` files. 2. The entity extends `BaseEntity` from `@edr/api-common`. 3. The repository extends `BaseRepository` from `@edr/api-common`. 4. The service injects the repository class (not `Repository` directly). -5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger. +5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route. 6. Register the module in the app's `app.module.ts`. ## Adding a new shared component to `@edr/ui-common` 1. Create `src/components//.tsx` and `src/components//index.ts`. 2. Export from `src/index.ts`. -3. Component is a functional component with a `ComponentNameProps` interface (named-exported alongside the default). +3. Component is a functional component with a `ComponentNameProps` interface + (named-exported alongside the default). + +## Definition of done + +A change is done when **all** of these hold. State explicitly which you ran. + +1. **It type-checks.** `pnpm turbo type-check --filter=` passes. + If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first. +2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the + dev database without error. +3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds + something the new code reads — applied to the dev database, since watch mode will not run it. +4. **No new test failures.** `pnpm test` for `@edr/freight-api` has been red on `dev`, so a + fully green suite is not the bar — but confirm that for yourself rather than assuming it, + then run the specs covering what you touched and confirm you introduced no new failure. +5. **Formatting is clean** for the files you touched. Git hooks do **not** run automatically + (see below), and `pnpm lint` does not work at all, so `noUnusedLocals` from the + type-check is your only unused-code signal. +6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the + endpoint, or ran the query. If you could not, say so plainly. +7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in + the summary. Never describe unverified work as done. + +### Hooks do not run + +`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed +at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`, +`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever +fire.** Nothing validates your commit message or formats your staged files. Run the checks by +hand; do not assume the hook caught it. + +## Known traps + +| Trap | What happens | What to do | +| --- | --- | --- | +| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one | +| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp | +| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` | +| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout | +| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` | +| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging | +| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully | +| Login 403 from curl | "Missing or unrecognized x-client-app header" | Send `x-client-app: backoffice` or `portal` | +| Copying a passenger pattern into freight | Passenger is Prisma + Next.js, freight is TypeORM + Vite | Check which stack you are in first | + +## Project skills + +Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps: + +| Skill | Use for | +| --- | --- | +| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. | +| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. | +| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. | + +## Working style + +- **Verify before asserting.** Read the code or query the database. Do not infer behaviour + from a filename. +- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the + trade-off before changing files. +- **Small, reviewable commits**, one logical change each, conventional message. +- **Branch from `dev`; PRs target `dev`.** +- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result. diff --git a/CLAUDE_NEW.md b/CLAUDE_NEW.md deleted file mode 100644 index a99f64d0a..000000000 --- a/CLAUDE_NEW.md +++ /dev/null @@ -1,313 +0,0 @@ -# EDR Platform — Developer Guide - -> This file is the contract. If something here contradicts the code, the code is the -> truth and this file is a bug — fix it in the same PR. - -## Overview - -Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight -Management and Passenger Management applications, a payment microservice, plus shared -types, NestJS utilities, and React component libraries. - -The freight domain is the largest and most active area. Its core flow is: -**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload -→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.** -Fees (storage, demurrage, double handling, truck detention) and allocation rules -(warehouse/yard/zone) hang off the warehouse stage. - -## Apps - -| App | Package name | Purpose | Default port | -| ------------------------------ | --------------------------- | -------------------------------------------------- | ------------ | -| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 | -| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 | -| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 | -| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 | -| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 | -| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 | -| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 | - -`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. -Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace -packages (see `pnpm-workspace.yaml`). - -`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace -package and is not built, linted, or type-checked. Leave it alone unless asked. - -## Packages - -| Package | Purpose | -| ---------------------- | ---------------------------------------------------------------------------------- | -| `@edr/types` | Shared TypeScript interfaces and enums | -| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | -| `@edr/ui-common` | Shared React components and theme | -| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) | -| `@edr/tsconfig` | Shared TypeScript configurations | -| `@edr/prettier-config` | Shared Prettier configuration | - -**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a -type in `packages/types/src` changes nothing for consumers until you rebuild: - -```bash -pnpm turbo build --filter=@edr/types -``` - -If a type-check fails on a field you just added to `@edr/types`, this is why. - -## Commands - -| Command | Description | -| --------------------------- | ---------------------------------------- | -| `pnpm install` | Install all workspace dependencies | -| `pnpm dev` | Run every app in dev mode | -| `pnpm dev:freight` | Freight API + portal + backoffice | -| `pnpm dev:freight:api` | Freight API only | -| `pnpm dev:freight:portal` | Freight portal only | -| `pnpm dev:freight:backoffice` | Freight backoffice only | -| `pnpm dev:passenger` | Passenger API + web | -| `pnpm dev:payment` | Payment API | -| `pnpm build` | Build every package and app | -| `pnpm test` | Run all tests (turbo) | -| `pnpm lint` | Lint everything | -| `pnpm type-check` | Type-check every package | -| `pnpm format` | Format all files with Prettier | - -Prefer targeted turbo filters over whole-repo runs — they are minutes faster: - -```bash -pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice -``` - -`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains, -gate-pass scenarios). Read the script before running one; several write real rows. - -## Environment & database - -- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and - no port `5433`/`5434` is published anywhere in the repo. -- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, - `DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a - remote database. -- The connection sits behind a **connection pooler**. Do **not** pass - `extra.options: '-c search_path=…'` — the pooler rejects it with - `08P01 unsupported startup parameter in options: search_path`. `search_path` is applied - per-connection in a pool `connect` handler instead. See - `apps/edr-freight-api/src/config/database.config.ts` before touching connection options. -- Each app owns its own database. **No cross-database joins**; cross-domain data flows - through API calls or message queues. -- `psql` is not installed on the dev machine. To query the database, write a short Node - script using the `pg` client and run it from `apps/edr-freight-api` (where `pg` resolves). - -## Hard rules - -These are non-negotiable. Everything else is a strong default. - -- **pnpm only.** Never run `npm install` or `yarn`. -- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not - reach for `any` to make an error go away. -- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false` - in every config and it has already corrupted this database twice (see *Migrations*). - All schema changes go through TypeORM migrations. -- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`). -- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`, - `deletedAt` (soft delete). -- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`); - TypeScript properties are `camelCase`. -- **Controllers contain no business logic.** They validate, delegate, and shape the response. -- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`. -- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands. -- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and - offer the safe version. - -## Architecture - -### NestJS module shape - -`module → controller → service → repository`, with `entities/` and `dto/` alongside. - -### Data access — the real model - -There are two sanctioned ways to read and write, and you must pick the right one: - -1. **Entity CRUD → the custom repository class.** Extends `BaseRepository` from - `@edr/api-common`. Services inject the repository class, never `Repository` directly. -2. **Read projections, queue endpoints, cross-table reports → raw SQL** via - `this.dataSource.query(...)` or `manager.query(...)` inside a transaction. - -Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it. -It carries one obligation: - -> **HARD RULE — validate every raw SQL statement against a real database before you ship it.** -> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through -> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*). - -Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository, -so they join the caller's transaction. - -**Never do slow I/O inside a database transaction.** Queue the work and fan it out after -commit. An SMS awaited inside a transaction once held capacity locks open for the whole -gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to -no timeout and will wait forever. - -### Migrations - -Migrations are the most dangerous surface in this repo. Two production-grade incidents have -already come from it. - -- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate - one-shot step, via the Dockerfile's `migration` build target (`docker build --target - migration`), with `migrationsTransactionMode: 'each'`. - - CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it - (`docker run --rm --env-file ...`) *before* building/deploying the app image. - - e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and - `freight-api-e2e` depends on it (`condition: service_completed_successfully`). - - Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run - migrations yourself before `docker compose up freight-api`, e.g. - `docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .` - then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't - use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled - output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`. - It silently applies zero freight migrations while exiting 0. -- Consequences you must design for: - - A watch-mode hot reload does **not** re-run migrations. If you add a column that new - code reads, apply it to the dev database yourself (idempotently) or fully restart. - - `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a - hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never - notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own - `forFeature()` registrations), but the standalone migration `DataSource` - (`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing - entity throws `Entity metadata for X#y was not found` at `initialize()`, before a - single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate - for this to break again** — diff the package's entity classes against `iamEntities` - when bumping it. -- **Give every migration a unique timestamp.** 34 timestamps are currently shared by two or - more migrations. TypeORM orders by timestamp and breaks ties non-deterministically. Before - adding one, check the filename prefix is unused *and* higher than the newest recorded row. -- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and - backfills guarded by `WHERE col IS NULL`. -- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory` - was recorded in `migrations` while its column was absent — it had been dropped out of band. - TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*. -- **A repair migration's `down()` should be a no-op.** Reverting a repair must not - re-introduce the outage it fixed. - -### Auth - -Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own. - -- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub. -- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`. -- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS..)`. -- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`. - Add a permission there before referencing it. -- IAM has its own migrations, run ahead of freight migrations from the same data source, and - its own CLI scripts (`iam:migration:run`, `iam:seed:run`). - -Ownership checks are separate from permission checks. A staff user passes -`hasFreightPermission`; a customer must additionally pass an ownership assertion such as -`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed. - -## Frontend conventions - -- The web apps use **Mantine v9**. Its APIs differ from v6/v7 — check the installed version - before copying a snippet. -- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the - freight web apps. Prefer it over re-implementing a component. -- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'` - delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no - `.message` and degrades to `"Request failed with status code 400"`. Use - `await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches - keep the synchronous version — their bodies are already parsed JSON. -- Server-side guards must be reflected in the UI. If the API will reject the action, the - button should be disabled, hidden, or explain the blocker — not fire and surface a 400. -- Prefer disabling a control with a visible reason over silently hiding it. - -## Notifications - -In-app notifications resolve recipients from the company's **linked portal users**. If a -company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error. -SMS and email still send, because they address the company's phone and email directly. Check -this before debugging a "missing notification". - -## PDF generation - -Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled -generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than -assume a headless browser exists. - -## Adding a new module to a NestJS app - -1. Create `modules//` with `entities/`, `dto/`, and the four - `.{module,controller,service,repository}.ts` files. -2. The entity extends `BaseEntity` from `@edr/api-common`. -3. The repository extends `BaseRepository` from `@edr/api-common`. -4. The service injects the repository class (not `Repository` directly). -5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route. -6. Register the module in the app's `app.module.ts`. - -## Adding a new shared component to `@edr/ui-common` - -1. Create `src/components//.tsx` and `src/components//index.ts`. -2. Export from `src/index.ts`. -3. Component is a functional component with a `ComponentNameProps` interface - (named-exported alongside the default). - -## Definition of done - -A change is done when **all** of these hold. State explicitly which you ran. - -1. **It type-checks.** `pnpm turbo type-check --filter=` passes. - If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first. -2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the - dev database without error. -3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds - something the new code reads — applied to the dev database, since watch mode will not run it. -4. **No new test failures.** `pnpm test` for `@edr/freight-api` is **currently red on `dev`**, - so a fully green suite is not the bar. Run the specs covering what you touched and confirm - you introduced no new failure. -5. **Lint and format are clean** for the files you touched. Git hooks do **not** run these - automatically (see below), so run them yourself. -6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the - endpoint, or ran the query. If you could not, say so plainly. -7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in - the summary. Never describe unverified work as done. - -### Hooks do not run - -`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed -at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`, -`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever -fire.** Nothing validates your commit message or formats your staged files. Run the checks by -hand; do not assume the hook caught it. - -## Known traps - -| Trap | What happens | What to do | -| --- | --- | --- | -| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one | -| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp | -| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` | -| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout | -| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` | -| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging | -| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully | - -## Project skills - -Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps: - -| Skill | Use for | -| --- | --- | -| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. | -| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. | -| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. | - -## Working style - -- **Verify before asserting.** Read the code or query the database. Do not infer behaviour - from a filename. -- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the - trade-off before changing files. -- **Small, reviewable commits**, one logical change each, conventional message. -- **Branch from `dev`; PRs target `dev`.** -- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result. diff --git a/docs/MAP.md b/docs/MAP.md new file mode 100644 index 000000000..d6aa76322 --- /dev/null +++ b/docs/MAP.md @@ -0,0 +1,96 @@ +# Repo map — start here + +Routing table for "where does X live". Read this before a repo-wide grep. Paths are from +the repo root. The rules and traps are in [`../CLAUDE.md`](../CLAUDE.md); this file only +answers *where*. + +## Pick your stack first + +| If you are working on… | Code lives in | Stack | +| --------------------------------- | --------------------------------- | -------------------- | +| Freight API / business logic | `apps/edr-freight-api/src` | NestJS + TypeORM | +| Freight customer UI | `apps/edr-freight-web/portal` | React + Vite + Mantine v9 | +| Freight staff UI | `apps/edr-freight-web/backoffice` | React + Vite + Mantine v9 | +| Passenger API | `apps/edr-passenger-api` | NestJS + **Prisma** | +| Passenger UI | `apps/edr-passenger-web/*` | **Next.js** | +| Payments (intents, webhooks) | `apps/edr-payment-api` | NestJS + TypeORM | +| Gateway integrations | `packages/payment-providers` | — | +| A shared type or enum | `packages/types/src` | rebuild after editing | +| A shared React component | `packages/ui-common/src` | — | +| A Nest decorator/filter/base class| `packages/api-common/src` | — | + +## Freight API entry points + +| File | What it is | +| --- | --- | +| `src/main.ts` | Boot, port (`PORT`, falls back to 3001), global pipes | +| `src/app.module.ts` | Every module is registered here — the index of the API | +| `src/config/database.config.ts` | Connection, pooler `search_path` handling, `iamEntities` list | +| `src/data-source.ts` | Standalone DataSource used by migrations only (no `autoLoadEntities`) | +| `src/migrations/` | TypeORM migrations (39 files; check for timestamp clashes) | +| `src/seed/freight-permissions.registry.ts` | Every freight permission; declare before use | +| `src/scripts/` | One-off and `seed:*` scripts — several write real rows | + +## Freight modules by domain + +All under `apps/edr-freight-api/src/modules/`. + +| Domain | Modules | +| --- | --- | +| **Booking & commercial** | `bookings` `contracts` `contract-templates` `consignments` `cargoes` `companies` `user-trade-access` `transit-agents` `shipping-lines` | +| **Warehouse & yard** | `warehouses` `facilities` `container-management` | +| **Rail operations** | `trains` `train-schedules` `train-scheduling` `train-sets` `wagons` `wagon-types` `locomotives` `routes` `scheduling` `scheduling-reschedule` `interchange-documents` | +| **Road / first & last mile** | `first-mile` `last-mile` `last-mile-requests` `drivers` `vehicles` `truck-types` `fleet-history` `fuel` `maintenance` `gps-tracking` `tracking` | +| **Money** | `billing` `payment` `exchange-settings` | +| **Identity & access** | `auth` `otp` `verifayda` `audit` | +| **Documents & files** | `files` `file-upload-settings` `signatures` `stamp-settings` `logo-settings` `minio` | +| **Comms** | `notifications` `notification-inbox` `support-chat` `support-content` | +| **Ops & admin** | `backoffice` `overview` `reports` `dropdown-settings` `rule-engine` `compliance` `procurement` `incidents` `import-operations` `eims` `ai` `health` | + +Each module follows `module → controller → service → repository`, with `entities/` and +`dto/` alongside. + +## Freight web + +Pages live in `src/pages/`, roughly mirroring the API domains. + +- **portal** (customer): `bookings` `contracts` `consignments` `billing` `payments` + `tracking` `accounts` `customers` `shipping-line` `support` `settings`, plus + `MyPortalPage/`, `MySignaturePage.tsx`, `EDRFreightLandingPage.tsx`. +- **backoffice** (staff): `bookings` `contracts` `contract_templates` `consignments` + `customers` `billing` `invoices` `documents` `fleet` `dashboard` `admin` `configuration` + `auth` `ai`, plus many single-file pages (`AuditLogsPage.tsx`, `ActivityLogPage.tsx`, + `BulkUploadPage.tsx`, `ContentManagementPage.tsx`, …). + +Shared components and theme come from `@edr/ui-common` — check there before writing one. + +## Tests + +| Suite | Location | Run with | +| --- | --- | --- | +| Unit / spec | beside the code, `*.spec.ts` | `pnpm --filter @edr/freight-api test` | +| Freight e2e (Cypress, containerized) | `e2e/freight` | `pnpm e2e:freight:ci` | +| Passenger e2e | `e2e/` + `e2e/run.sh` | `pnpm test:e2e:passenger` | +| UI e2e (Playwright) | `e2e-ui/` | `pnpm test:e2e:ui` | +| Integration | `integration/` | `pnpm it:up`, `pnpm it:test` | + +The freight e2e stack has no host Xvfb — use `ci` (containerized), not `run`/`open`. +Its compose project name is fixed (`edr-freight-e2e`), so only one can run on this +machine at a time. + +## Documents + +| Doc | Covers | Trust | +| --- | --- | --- | +| `../CLAUDE.md` | The contract: rules, traps, definition of done | Current — fix in the same PR if wrong | +| `docs/TESTING.md` | Test strategy | — | +| `docs/e2e-test-matrix.md`, `docs/ui-e2e-test-matrix.md` | Coverage matrices | — | +| `docs/ISSUES.md`, `docs/SOLUTIONS.md` | Running log of problems and fixes | Historical | +| `docs/uploads.md` | File upload handling | — | +| `docs/qa/edr-freight-qa-test-plan.md` | QA test plan | — | +| `../DEPLOYMENT.md` | Deploy process | — | +| `../ITMLS_DB_Design.md`, `../orgstructure.md` | Design notes | Historical | +| `../E2E_TEST_REPORT.md`, `../checkpoint.md` | Point-in-time snapshots | **Stale by design — dated artifacts, not references** | + +Root-level `*.sql` and `*.dump` files are ad-hoc data snapshots, not part of the schema. +Migrations are the only source of truth for schema. From 7143ba1040a6a86a66ca76310963d94ac6aaafe0 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 17 Aug 2026 12:38:06 +0000 Subject: [PATCH 4/4] feat(customers): notify marketing on returned changes, name actors in history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps on the backoffice customer detail page: - Rejecting a change request or sending it back for correction notified nobody. Adds CompanyNotifierService.changeRequestReturned, which pings the customer desk with the reviewer, the outcome and the note. Marketing joins that desk via customers:view + customers:get_notification in the role preset — grants still come from the IAM UI, the preset only sets the default for new environments. - submitted_by / reviewed_by / actor_id were stored but never resolved, so the History tab could say what changed but never who asked or who sent it back. Resolves them through a shared iam-user-name util (deduped from the private copy in contract-document-history.service) and renders "Requested by" / "Sent back to marketing by" lines. The changes_requested badge is relabelled to match the workflow. - "View" opened an in-page modal one document at a time. Adds openFileInNewTab, which opens the tab inside the click gesture and fills it once the authenticated fetch resolves, and an "Open all" button that loops over the documents table so every file lands in its own tab. --- .../src/common/utils/iam-user-name.util.ts | 49 +++++++++++ .../modules/companies/companies.service.ts | 82 ++++++++++++++++++- .../companies/company-notifier.service.ts | 29 +++++++ .../dto/change-request-response.dto.ts | 6 ++ .../dto/company-revision-response.dto.ts | 3 + .../entities/company-change-request.entity.ts | 8 ++ .../entities/company-revision.entity.ts | 6 ++ .../contract-document-history.service.ts | 38 +-------- .../src/seed/freight-permissions.registry.ts | 6 ++ .../customers/ChangeRequestReview.tsx | 21 ++--- .../components/customers/CompanyTimeline.tsx | 66 +++++++++++++-- .../pages/customers/CustomerDetailPage.tsx | 57 +++++++------ .../backoffice/src/services/files.service.ts | 47 +++++++++++ .../backoffice/src/types/customer.ts | 6 ++ 14 files changed, 335 insertions(+), 89 deletions(-) create mode 100644 apps/edr-freight-api/src/common/utils/iam-user-name.util.ts diff --git a/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts b/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts new file mode 100644 index 000000000..e4a3465d0 --- /dev/null +++ b/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts @@ -0,0 +1,49 @@ +import { DataSource } from "typeorm"; + +/** + * `iam.users.name` is a localized object ({ en, am, … }), not a string — a + * plain `String(name)` there yields "[object Object]" in an audit trail. + */ +export interface IamUserRow { + name?: Record | string | null; + username?: string | null; + email?: string | null; +} + +/** Best display name for a user row: English label → any locale → login → email. */ +export function pickUserName(user: IamUserRow): string | null { + const { name } = user; + if (typeof name === "string" && name.trim()) return name.trim(); + if (name && typeof name === "object") { + const localized = + name.en ?? + Object.values(name).find((v) => typeof v === "string" && v.trim()); + if (localized?.trim()) return localized.trim(); + } + return user.username?.trim() || user.email?.trim() || null; +} + +/** + * Display names for a set of IAM user ids — one query for the whole set. + * `iam.users` is owned by the auth system and has no entity here, so it is read + * directly. A miss is not an error: the caller still holds the id and can fall + * back to it. + */ +export async function resolveIamUserNames( + dataSource: DataSource, + userIds: (string | null | undefined)[], +): Promise> { + const resolved = new Map(); + const ids = [...new Set(userIds.filter((id): id is string => Boolean(id)))]; + if (ids.length === 0) return resolved; + + const rows = (await dataSource.query( + `SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`, + [ids], + )) as Array; + for (const row of rows) { + const name = pickUserName(row); + if (name) resolved.set(row.id, name); + } + return resolved; +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 98b30f006..bf8184833 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -7,6 +7,7 @@ import { ForbiddenException, } from "@nestjs/common"; import { DataSource, EntityManager } from "typeorm"; +import { resolveIamUserNames } from "../../common/utils/iam-user-name.util"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; @@ -1141,16 +1142,55 @@ export class CompaniesService { return new ProfileResponseDto(profile, live, request); } - /** List a company's change requests, newest first (backoffice review). */ + /** + * List a company's change requests, newest first (backoffice review). Actor + * ids are resolved to display names here — the history screen has to say who + * asked for a change and who sent it back, not print two uuids. + */ async listChangeRequests(companyId: string): Promise { await this.findCompanyById(companyId); - return this.changeRequestRepo.findByCompanyId(companyId); + const requests = await this.changeRequestRepo.findByCompanyId(companyId); + const names = await this.resolveActorNames( + requests.flatMap((r) => [r.submittedBy, r.reviewedBy]), + ); + for (const request of requests) { + request.submittedByName = request.submittedBy + ? (names.get(request.submittedBy) ?? null) + : null; + request.reviewedByName = request.reviewedBy + ? (names.get(request.reviewedBy) ?? null) + : null; + } + return requests; } /** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */ async listCompanyRevisions(companyId: string): Promise { await this.findCompanyById(companyId); - return this.revisionRepo.findByCompanyId(companyId); + const revisions = await this.revisionRepo.findByCompanyId(companyId); + const names = await this.resolveActorNames(revisions.map((r) => r.actorId)); + for (const revision of revisions) { + revision.actorName = revision.actorId + ? (names.get(revision.actorId) ?? null) + : null; + } + return revisions; + } + + /** + * Display names for actor ids, one query for the whole list. A lookup failure + * degrades the history to ids rather than failing the request — the entry is + * still worth showing without the name. + */ + private async resolveActorNames( + actorIds: (string | null | undefined)[], + ): Promise> { + try { + return await resolveIamUserNames(this.dataSource, actorIds); + } catch (err) { + this.logger.warn(`Could not resolve actor names: ${String(err)}`); + return new Map(); + } } /** @@ -1519,6 +1559,7 @@ export class CompaniesService { } await this.discardLicenseChanges(request); await this.discardDocumentChanges(request); + await this.notifyChangeRequestReturned(request, "rejected", note, reviewerId); return ( (await this.changeRequestRepo.update(id, { status: ChangeRequestStatus.Rejected, @@ -1556,6 +1597,12 @@ export class CompaniesService { `Change request ${id} is already ${request.status}`, ); } + await this.notifyChangeRequestReturned( + request, + "changes_requested", + note, + reviewerId, + ); return ( (await this.changeRequestRepo.update(id, { status: ChangeRequestStatus.ChangesRequested, @@ -1566,6 +1613,35 @@ export class CompaniesService { ); } + /** + * Tell the customer desk a change request came back unapproved. Best-effort: + * a missing company or an unresolvable reviewer name must not fail the + * reviewer's decision, which is already the point of the try/catch. + */ + private async notifyChangeRequestReturned( + request: CompanyChangeRequest, + outcome: "rejected" | "changes_requested", + note: string, + reviewerId?: string, + ): Promise { + try { + const company = await this.companiesRepo.findById(request.companyId); + if (!company) return; + const names = await this.resolveActorNames([reviewerId]); + this.companyNotifier.changeRequestReturned( + company, + request.id, + outcome, + note, + reviewerId ? (names.get(reviewerId) ?? null) : null, + ); + } catch (err) { + this.logger.warn( + `Could not notify the customer desk about ${request.id}: ${String(err)}`, + ); + } + } + async deleteCompany(id: string): Promise { await this.findCompanyById(id); await this.companiesRepo.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index 6bcd21f08..d3a556f50 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -244,6 +244,35 @@ export class CompanyNotifierService { ); } + /** + * A reviewer did NOT approve a customer's profile changes — they rejected it + * or sent it back for correction. The customer desk (Marketing included, via + * the `customers:get_notification` key) owns the follow-up with the customer, + * so the decision has to reach their inbox; without this it was silent, and + * only visible to whoever happened to reopen the customer's History tab. + */ + changeRequestReturned( + company: Company, + changeRequestId: string, + outcome: "rejected" | "changes_requested", + note: string, + reviewerName?: string | null, + ): void { + const rejected = outcome === "rejected"; + const by = reviewerName?.trim() ? ` by ${reviewerName.trim()}` : ""; + this.logger.log(`CHANGE_REQUEST_${outcome.toUpperCase()} — ${company.id}`); + this.notifyStaff( + company, + rejected + ? "Customer profile changes rejected" + : "Customer profile changes sent back for correction", + `${company.name}'s profile changes were ` + + `${rejected ? "rejected" : "sent back for correction"}${by}. ` + + `Reason: ${note}`, + { changeRequestId, outcome, note, reviewerName: reviewerName ?? null }, + ); + } + // ── Customer-facing: a specific document needs correcting ────────────────── /** diff --git a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts index 4a931dae3..2a4f65708 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts @@ -23,8 +23,12 @@ export class ChangeRequestResponseDto { documentChanges: DocumentChangeIntent[]; note: string | null; submittedBy: string | null; + /** Who filed the request, for the history screen (null when unresolvable). */ + submittedByName: string | null; submittedAt: Date | null; reviewedBy: string | null; + /** Who approved / rejected / sent it back. */ + reviewedByName: string | null; reviewedAt: Date | null; createdAt: Date; updatedAt: Date; @@ -39,8 +43,10 @@ export class ChangeRequestResponseDto { this.documentChanges = req.documents?.documentChanges ?? []; this.note = req.note ?? null; this.submittedBy = req.submittedBy ?? null; + this.submittedByName = req.submittedByName ?? null; this.submittedAt = req.submittedAt ?? null; this.reviewedBy = req.reviewedBy ?? null; + this.reviewedByName = req.reviewedByName ?? null; this.reviewedAt = req.reviewedAt ?? null; this.createdAt = req.createdAt; this.updatedAt = req.updatedAt; diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts index c93c7387c..198a44e06 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts @@ -8,6 +8,8 @@ export class CompanyRevisionResponseDto { id: string; companyId: string; actorId: string | null; + /** Who made the edit, for the history screen (null when unresolvable). */ + actorName: string | null; summary: string; changes: CompanyRevisionChange[]; createdAt: Date; @@ -16,6 +18,7 @@ export class CompanyRevisionResponseDto { this.id = revision.id; this.companyId = revision.companyId; this.actorId = revision.actorId ?? null; + this.actorName = revision.actorName ?? null; this.summary = revision.summary; this.changes = revision.changes ?? []; this.createdAt = revision.createdAt; diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts index 2ba39ecad..02b49f849 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts @@ -110,4 +110,12 @@ export class CompanyChangeRequest extends BaseEntity { @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) reviewedAt?: Date | null; + + /** + * Display names for {@link submittedBy} / {@link reviewedBy}, resolved from + * `iam.users` on read. Not columns — the history screen has to name the + * person who asked for the change, and an opaque uuid does not. + */ + submittedByName?: string | null; + reviewedByName?: string | null; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts index 222a8364f..533f08d7a 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts @@ -43,4 +43,10 @@ export class CompanyRevision extends BaseEntity { @Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` }) changes!: CompanyRevisionChange[]; + + /** + * Display name for {@link actorId}, resolved from `iam.users` on read. Not a + * column — history has to name who made the edit, and a uuid does not. + */ + actorName?: string | null; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts index e38f737c6..1f1469182 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; +import { resolveIamUserNames } from '../../common/utils/iam-user-name.util'; import { ContractDocumentChange, diffSnapshots, @@ -20,28 +21,6 @@ export interface RecordRevisionInput { stepId?: string | null; } -/** - * `iam.users.name` is a localized object ({ en, am, … }), not a string — a - * plain `String(name)` there yields "[object Object]" in the audit trail. - */ -interface IamUserRow { - name?: Record | string | null; - username?: string | null; - email?: string | null; -} - -/** Best display name for a user row: English label → any locale → login → email. */ -function pickUserName(user: IamUserRow): string | null { - const { name } = user; - if (typeof name === 'string' && name.trim()) return name.trim(); - if (name && typeof name === 'object') { - const localized = - name.en ?? Object.values(name).find((v) => typeof v === 'string' && v.trim()); - if (localized?.trim()) return localized.trim(); - } - return user.username?.trim() || user.email?.trim() || null; -} - /** Pre-computed changes (contract fields), rather than a document diff. */ export interface RecordChangesInput { contractId: string; @@ -120,23 +99,12 @@ export class ContractDocumentHistoryService { private async resolveActorNames( actorIds: string[], ): Promise> { - const resolved = new Map(); - const ids = [...new Set(actorIds.filter(Boolean))]; - if (ids.length === 0) return resolved; - try { - const rows = (await this.dataSource.query( - `SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`, - [ids], - )) as Array; - for (const row of rows) { - const name = pickUserName(row); - if (name) resolved.set(row.id, name); - } + return await resolveIamUserNames(this.dataSource, actorIds); } catch (err) { this.logger.warn(`Could not resolve actor names: ${String(err)}`); + return new Map(); } - return resolved; } /** Revision history for a contract, newest first. */ diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index adbd22584..3fde18648 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -2484,6 +2484,12 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.suspend, FREIGHT_PERMS.contracts.editDocument, ...BOOKING_DESK_NOTIFICATION_KEYS, + // Marketing follows up with the customer when a reviewer sends profile + // changes back, so they sit on the customer desk: read-only on the customer + // record (no verify/deactivate — the decision stays with the chief) plus the + // desk key the change-request pings are addressed to. + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.customers.getNotification, ], orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index 65c4f25e3..7f4cf30ce 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -20,11 +20,10 @@ import { FileX2, } from "lucide-react"; import { useState } from "react"; -import { useFileViewer } from "@edr/ui-common"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; -import { fetchViewableFile } from "@/services/files.service"; +import { openFileInNewTab } from "@/services/files.service"; import { api } from "@/services/api"; import type { Company } from "@/types/customer"; import { formatDate, humanize } from "./format"; @@ -227,7 +226,6 @@ export function ChangeRequestReview({ company }: { company: Company }) { api.customers.requestChangeRequestChanges.mutationOptions(), ); - const { view, viewer } = useFileViewer(); const [actionTarget, setActionTarget] = useState<{ id: string; kind: "reject" | "request-changes"; @@ -350,10 +348,10 @@ export function ChangeRequestReview({ company }: { company: Company }) { type="button" size="sm" onClick={() => - void fetchViewableFile( + openFileInNewTab( c.fileId, c.fileName ?? humanize(c.code), - ).then(view) + ) } style={{ textDecoration: @@ -382,12 +380,7 @@ export function ChangeRequestReview({ company }: { company: Company }) { component="button" type="button" size="sm" - onClick={() => - void fetchViewableFile( - fileId, - `Document ${i + 1}`, - ).then(view) - } + onClick={() => openFileInNewTab(fileId, `Document ${i + 1}`)} > Document {i + 1} @@ -421,10 +414,10 @@ export function ChangeRequestReview({ company }: { company: Company }) { type="button" size="sm" onClick={() => - void fetchViewableFile( + openFileInNewTab( c.fileId, c.fileName ?? "License document", - ).then(view) + ) } style={{ textDecoration: @@ -532,8 +525,6 @@ export function ChangeRequestReview({ company }: { company: Company }) { - - {viewer} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx index cd421d9bd..8469e4031 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx @@ -1,9 +1,8 @@ import { Alert, Anchor, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { FilePlus2, FileX2, History } from "lucide-react"; -import { useFileViewer } from "@edr/ui-common"; -import { fetchViewableFile } from "@/services/files.service"; +import { openFileInNewTab } from "@/services/files.service"; import { api } from "@/services/api"; import type { Company, @@ -36,6 +35,12 @@ interface TimelineEntry { at: string; note?: string | null; summary?: string; + /** Who filed the change (the customer, or staff editing during onboarding). */ + requestedBy?: string | null; + /** When they filed it — the "asked" half of the ask/decide pair below. */ + requestedAt?: string | null; + /** Who decided (approved / rejected / sent it back to marketing). */ + decidedBy?: string | null; fieldDiffs: FieldDiff[]; docDiffs: DocDiff[]; } @@ -43,10 +48,18 @@ interface TimelineEntry { const KIND_BADGE: Record = { approved: { label: "Approved", color: "edr-green" }, rejected: { label: "Rejected", color: "red" }, - changes_requested: { label: "Changes requested", color: "yellow" }, + // Sending a request back is what "reverted to marketing" means here: the + // request stays open and marketing owns the follow-up with the customer. + changes_requested: { label: "Sent back to marketing", color: "yellow" }, revision: { label: "Recorded", color: "blue" }, }; +/** "Requested by X" / "Reviewed by X", with the id-less case reading sanely. */ +function actorLine(verb: string, who?: string | null, when?: string | null) { + if (!who && !when) return null; + return `${verb}${who ? ` by ${who}` : ""}${when ? ` · ${formatDate(when)}` : ""}`; +} + /** * Pair adjacent remove-then-add intents into one before/after doc diff — a * "replace" is always staged as `[{op:'remove'}, {op:'add'}]` pushed together @@ -132,6 +145,9 @@ function fromChangeRequest( kind: r.status as TimelineEntry["kind"], at: r.reviewedAt ?? r.updatedAt, note: r.note, + requestedBy: r.submittedByName, + requestedAt: r.submittedAt ?? r.createdAt, + decidedBy: r.reviewedByName, fieldDiffs, docDiffs, }; @@ -156,6 +172,7 @@ function fromRevision(rev: CompanyRevision): TimelineEntry { kind: "revision", at: rev.createdAt, summary: rev.summary, + requestedBy: rev.actorName, fieldDiffs, docDiffs, }; @@ -170,7 +187,6 @@ function fromRevision(rev: CompanyRevision): TimelineEntry { * single answer instead of two places to check. */ export function CompanyTimeline({ company }: { company: Company }) { - const { view, viewer } = useFileViewer(); const changeRequestsQuery = useQuery( api.customers.changeRequests.queryOptions({ input: { id: company.id } }), ); @@ -186,7 +202,7 @@ export function CompanyTimeline({ company }: { company: Company }) { ].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()); const openFile = (file: { id: string; name: string }) => - void fetchViewableFile(file.id, file.name).then(view); + openFileInNewTab(file.id, file.name); if (entries.length === 0) { return ( @@ -205,6 +221,20 @@ export function CompanyTimeline({ company }: { company: Company }) { {entries.map((entry) => { const badge = KIND_BADGE[entry.kind]; + const requestedLine = actorLine( + entry.kind === "revision" ? "Edited" : "Requested", + entry.requestedBy, + entry.requestedAt, + ); + const decidedLine = actorLine( + entry.kind === "changes_requested" + ? "Sent back to marketing" + : entry.kind === "rejected" + ? "Rejected" + : "Approved", + entry.decidedBy, + entry.kind === "revision" ? null : entry.at, + ); return ( @@ -224,10 +254,33 @@ export function CompanyTimeline({ company }: { company: Company }) { + {/* Who asked, and who decided. Without this the feed said what + changed and when, but never named a person — the first thing + anyone auditing a returned request needs. */} + {(requestedLine || decidedLine) && ( + + {requestedLine && ( + + {requestedLine} + + )} + {decidedLine && ( + + {decidedLine} + + )} + + )} + {entry.note && ( - Note: {entry.note} + + {entry.kind === "changes_requested" + ? "What was asked for:" + : "Note:"} + {" "} + {entry.note} )} @@ -293,7 +346,6 @@ export function CompanyTimeline({ company }: { company: Company }) { ); })} - {viewer} ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index b395cc710..40185f990 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -23,6 +23,7 @@ import { Banknote, Contact, Download, + ExternalLink, Eye, FileSignature, FileText, @@ -69,7 +70,7 @@ import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { downloadBookingFile, - fetchViewableFile, + openFileInNewTab, } from "@/services/files.service"; import { api } from "@/services/api"; import type { @@ -81,12 +82,7 @@ import type { } from "@/types/customer"; import { hasSubmittedOnboarding, isOnboardingDraft } from "@/types/customer"; import type { Invoice } from "@/types/invoice"; -import { - DataTable, - useFileViewer, - usePagination, - type ColumnDef, -} from "@edr/ui-common"; +import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common"; import type { Freight } from "@edr/types"; /** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */ @@ -146,7 +142,6 @@ const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending"; export default function CustomerDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { view, viewer } = useFileViewer(); const { user } = useAuth(); const { data: company, isLoading } = useQuery( @@ -271,9 +266,7 @@ export default function CustomerDetailPage() { variant="subtle" color="gray" aria-label={`View ${f.name}`} - onClick={() => - void fetchViewableFile(f.id, f.name).then(view) - } + onClick={() => openFileInNewTab(f.id, f.name)} > @@ -282,9 +275,7 @@ export default function CustomerDetailPage() { type="button" size="xs" lineClamp={1} - onClick={() => - void fetchViewableFile(f.id, f.name).then(view) - } + onClick={() => openFileInNewTab(f.id, f.name)} style={{ maxWidth: 170, textAlign: "left", @@ -339,7 +330,7 @@ export default function CustomerDetailPage() { ), }, ], - [view, canReview], + [canReview], ); const bookingColumns: ColumnDef[] = useMemo( @@ -522,9 +513,7 @@ export default function CustomerDetailPage() { aria-label="View" data-stop-row-click onClick={() => - void fetchViewableFile(row.original.id, row.original.name).then( - view, - ) + openFileInNewTab(row.original.id, row.original.name) } > @@ -564,7 +553,7 @@ export default function CustomerDetailPage() { ), }, ], - [view, canRequestDocChange], + [canRequestDocChange], ); const paymentColumns: ColumnDef[] = useMemo( @@ -1163,9 +1152,7 @@ export default function CustomerDetailPage() { lineClamp={1} style={{ flex: 1, textAlign: "left" }} onClick={() => - void fetchViewableFile(doc.id, doc.name).then( - view, - ) + openFileInNewTab(doc.id, doc.name) } > {doc.name} @@ -1176,9 +1163,7 @@ export default function CustomerDetailPage() { color="gray" aria-label={`Preview ${doc.name}`} onClick={() => - void fetchViewableFile(doc.id, doc.name).then( - view, - ) + openFileInNewTab(doc.id, doc.name) } > @@ -1280,6 +1265,23 @@ export default function CustomerDetailPage() { {/* DOCUMENTS */} + {/* Reviewing a customer means reading every document, so offer the + whole set at once — each opens in its own tab. The loop is + synchronous inside the click handler on purpose: that is what + keeps the browser treating all of them as user-initiated. */} + + + + - void fetchViewableFile(f.id, f.name).then(view) - } + onClick={() => openFileInNewTab(f.id, f.name)} size="xs" style={{ textDecoration: @@ -1424,7 +1424,6 @@ export default function CustomerDetailPage() { onClose={() => setChangeRequestDoc(null)} /> - {viewer} ); } diff --git a/apps/edr-freight-web/backoffice/src/services/files.service.ts b/apps/edr-freight-web/backoffice/src/services/files.service.ts index 038b5a86d..c86a3beb4 100644 --- a/apps/edr-freight-web/backoffice/src/services/files.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/files.service.ts @@ -25,6 +25,53 @@ export async function downloadBookingFile( URL.revokeObjectURL(url); } +/** + * Open a stored file in its own browser tab. + * + * Two things make this less trivial than an ``: + * - `GET /files/:id` is authenticated, so the bytes have to come through the + * axios client and be handed over as a blob URL (same reason as + * {@link fetchViewableFile}). + * - The tab therefore has to be opened *synchronously*, inside the click + * gesture, and filled once the download resolves — a `window.open()` after an + * `await` is blocked as a popup. That also means a loop over several + * documents opens one tab each, all within the same gesture. + * + * `noopener` is deliberately not passed: it makes `window.open` return null, and + * the handle is what lets us navigate the tab. `opener` is nulled instead. + */ +export function openFileInNewTab(id: string, filename: string): void { + const tab = window.open("", "_blank"); + if (tab) { + tab.opener = null; + tab.document.title = filename; + if (tab.document.body) { + tab.document.body.textContent = `Opening ${filename}…`; + } + } + void filesService.download(id).then( + (blob) => { + const url = URL.createObjectURL(blob); + if (tab) tab.location.replace(url); + // Popup blocked — fall back to a save, so the click still does something. + else { + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + } + // Revoking immediately would cancel the tab's own load of the URL. + setTimeout(() => URL.revokeObjectURL(url), 60_000); + }, + (error: unknown) => { + if (tab?.document.body) { + tab.document.body.textContent = `Could not open ${filename}.`; + } + console.error(`Failed to open file ${id}`, error); + }, + ); +} + /** * GET /files/:id is authenticated (global JwtGuard) — raw browser loads * (/