Merge pull request #1323 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-17 15:45:27 +03:00
committed by GitHub
19 changed files with 986 additions and 902 deletions

View File

@@ -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, string> | 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<Map<string, string>> {
const resolved = new Map<string, string>();
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<IamUserRow & { id: string }>;
for (const row of rows) {
const name = pickUserName(row);
if (name) resolved.set(row.id, name);
}
return resolved;
}

View File

@@ -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<CompanyChangeRequest[]> {
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<CompanyRevision[]> {
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<Map<string, string>> {
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<void> {
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<void> {
await this.findCompanyById(id);
await this.companiesRepo.softDelete(id);

View File

@@ -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 ──────────────────
/**

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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, string> | 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<Map<string, string>> {
const resolved = new Map<string, string>();
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<IamUserRow & { id: string }>;
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. */

View File

@@ -2497,6 +2497,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;

View File

@@ -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}
</Anchor>
@@ -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 }) {
</Group>
</Stack>
</Modal>
{viewer}
</>
);
}

View File

@@ -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<TimelineEntry["kind"], { label: string; color: string }> = {
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 }) {
<Stack gap="md">
{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 (
<Card key={entry.id} withBorder>
<Stack gap="sm">
@@ -224,10 +254,33 @@ export function CompanyTimeline({ company }: { company: Company }) {
</Text>
</Group>
{/* 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) && (
<Stack gap={2}>
{requestedLine && (
<Text size="xs" c="dimmed">
{requestedLine}
</Text>
)}
{decidedLine && (
<Text size="xs" c="dimmed">
{decidedLine}
</Text>
)}
</Stack>
)}
{entry.note && (
<Alert color="yellow" variant="light">
<Text size="sm">
<strong>Note:</strong> {entry.note}
<strong>
{entry.kind === "changes_requested"
? "What was asked for:"
: "Note:"}
</strong>{" "}
{entry.note}
</Text>
</Alert>
)}
@@ -293,7 +346,6 @@ export function CompanyTimeline({ company }: { company: Company }) {
</Card>
);
})}
{viewer}
</Stack>
);
}

View File

@@ -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, Ship, User, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { FileText, Inbox, RefreshCw, Ship, 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" },
@@ -81,77 +73,59 @@ const CUSTOMER_KIND_OPTIONS = [
{ value: "CUSTOMER", label: "Customer" },
];
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<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
const [customerKindFilter, setCustomerKindFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(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: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_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,
customerKindFilter,
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" }
: {}),
...(customerKindFilter
? { customerKind: customerKindFilter as "SHIPPING_LINE" | "CUSTOMER" }
: {}),
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
}),
queryKey: ["clearance-documents", "bookings", filter],
queryFn: () => bookingsService.list(filter),
placeholderData: keepPreviousData,
});
@@ -256,7 +230,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
@@ -288,116 +261,12 @@ export default function ClearanceDocumentsPage() {
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search booking, contract, customer or shipping line…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
resetPage();
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Select
data={BOOKING_STATUS_OPTIONS}
value={bookingStatuses}
onChange={(v) => {
setBookingStatuses(v ?? BOOKING_STATUS_OPTIONS[0].value);
resetPage();
}}
allowDeselect={false}
radius="lg"
w={220}
aria-label="Filter by status"
/>
</Group>
<Group gap="sm" mt="sm" wrap="wrap">
<Select
placeholder="Direction"
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 130 }}
aria-label="Filter by direction"
/>
<Select
placeholder="Freight type"
data={FREIGHT_TYPE_OPTIONS}
value={freightTypeFilter}
onChange={(v) => {
setFreightTypeFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by freight type"
/>
<Select
placeholder="Booked by"
data={CUSTOMER_KIND_OPTIONS}
value={customerKindFilter}
onChange={(v) => {
setCustomerKindFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by booked by"
/>
<Select
placeholder="Gov / Private"
data={OWNERSHIP_OPTIONS}
value={ownershipFilter}
onChange={(v) => {
setOwnershipFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by ownership"
/>
<DatePickerInput
type="range"
placeholder="Created date range"
value={[createdFrom, createdTo]}
onChange={([from, to]) => {
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"
/>
</Group>
<FilterBar
defs={filterDefs}
controls={controls}
searchPlaceholder="Search booking, contract, customer or shipping line…"
viewId="clearance-documents"
/>
</Box>
{showEmpty ? (
@@ -420,18 +289,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}
/>

View File

@@ -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)}
>
<Eye size={14} />
</ActionIcon>
@@ -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<CustomerBooking>[] = 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)
}
>
<Eye size={16} />
@@ -564,7 +553,7 @@ export default function CustomerDetailPage() {
),
},
],
[view, canRequestDocChange],
[canRequestDocChange],
);
const paymentColumns: ColumnDef<CustomerPayment>[] = 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)
}
>
<Eye size={15} />
@@ -1280,6 +1265,23 @@ export default function CustomerDetailPage() {
{/* DOCUMENTS */}
<Tabs.Panel value="documents" pt="lg">
<Stack gap="lg">
{/* 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. */}
<Group justify="flex-end">
<Button
variant="light"
leftSection={<ExternalLink size={16} />}
disabled={documents.length === 0}
onClick={() =>
documents.forEach((d) => openFileInNewTab(d.id, d.name))
}
>
Open all {documents.length > 0 && `(${documents.length})`}
</Button>
</Group>
<TableCard minWidth={760}>
<DataTable
columns={documentColumns}
@@ -1316,9 +1318,7 @@ export default function CustomerDetailPage() {
<Anchor
component="button"
type="button"
onClick={() =>
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}
</PageContainer>
);
}

View File

@@ -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<Record<FleetResourceSlug, FilterOption[]>> = {
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<string | null>(null);
const [dateTo, setDateTo] = useState<string | null>(null);
const [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FleetRecord | null>(null);
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(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<string, unknown>).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 `<Select>` row
// read, just reshaped into FilterDefs. Falls back to a plain client-only
// Status filter for the 3 slugs with no server-side list filters at all.
const filterDefs: FilterDef[] = useMemo(() => {
const dateDef: FilterDef = {
key: "created",
label: "Registered",
type: "date",
secondary: true,
toParams: dateRangeParams("createdFrom", "createdTo"),
};
if (config?.listFilters?.length) {
return [
...config.listFilters.map((filter): FilterDef => ({
key: filter.key,
label: filter.label,
type: "enum",
multiple: false,
options: filter.dynamicOptions
? (dynamicOptions[filter.dynamicOptions] ?? [])
: (filter.options ?? []),
})),
dateDef,
];
}
const fallback = FALLBACK_STATUS_OPTIONS[slug];
return fallback
? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, dateDef]
: [dateDef];
}, [config, dynamicOptions, slug]);
const controls = useFilters(filterDefs, { pageSize: 10 });
// 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.
// `controls.params` already carries every filter's mapped param name (status/
// currentYardId/wagonTypeId/… default to `{key: value}`, "created" maps to
// createdFrom/createdTo) plus search/page/pageSize — it IS the paged filter
// object; the unpaged one is the same minus pagination and the date range
// (which stays client-only for the non-server-paged slugs, see below).
const serverListFilters = useMemo((): FleetListFilters | undefined => {
if (!SERVER_FILTERED_SLUGS.includes(slug)) return undefined;
const { page: _page, pageSize: _pageSize, createdFrom: _cf, createdTo: _ct, ...rest } = controls.params;
return rest as FleetListFilters;
}, [slug, controls.params]);
const pagedFilters = useMemo(
(): FleetListFilters => controls.params as unknown as FleetListFilters,
[controls.params],
);
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());
useEffect(() => {
registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes);
@@ -349,16 +322,22 @@ const FleetResourcePage = () => {
// The API already applied every filter and cut the page — re-filtering here
// would drop rows the server deliberately returned.
if (serverPaged) return allRows;
const term = search.trim().toLowerCase();
return allRows.filter((row) => {
const record = row as unknown as Record<string, unknown>;
// The date range applies even when the API already filtered the list —
// it is not one of the server-side filters.
if (!matchesDayRange(record.createdAt, dateFrom, dateTo)) return false;
if (usesServerListFilters) return true;
if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) {
const created = controls.values.created;
if (created && !matchesDayRange(record.createdAt, created.v[0]?.slice(0, 10) ?? null, created.v[1]?.slice(0, 10) ?? null)) {
return false;
}
// Every other filter (status/yard/wagon type/…) was already applied
// server-side for these slugs — re-checking here against a plain field
// equality would be wrong for one (a wagon's "trainNumber" filter
// matches either of two DIFFERENT columns server-side, not one).
if (usesServerListFilters) return true;
const status = controls.values.status;
if (status && String(record.status ?? "") !== status.v[0]) return false;
const term = controls.searchText.trim().toLowerCase();
if (!term) return true;
return config.searchKeys.some((key) =>
String(record[key] ?? "")
@@ -366,19 +345,23 @@ const FleetResourcePage = () => {
.includes(term),
);
});
}, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo, serverPaged]);
}, [allRows, config, usesServerListFilters, controls.values, controls.searchText, serverPaged]);
const totalCount = serverPaged
? (pagedQuery.data?.meta.total ?? 0)
: filteredRows.length;
const pageCount = serverPaged
? Math.max(1, pagedQuery.data?.meta.totalPages ?? 1)
: Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
: Math.max(1, Math.ceil(filteredRows.length / controls.pageSize));
const pagedRows = useMemo(() => {
if (serverPaged) return filteredRows;
const start = pagination.pageIndex * pagination.pageSize;
return filteredRows.slice(start, start + pagination.pageSize);
}, [filteredRows, pagination.pageIndex, pagination.pageSize, serverPaged]);
const start = (controls.page - 1) * controls.pageSize;
return filteredRows.slice(start, start + controls.pageSize);
}, [filteredRows, controls.page, controls.pageSize, serverPaged]);
// Same {pagination, tableOptions} shape DataTable takes directly; FleetCardGrid
// (not a DataTable) just needs the raw pieces out of it below.
const { pagination: dtPagination, tableOptions: dtTableOptions } = controls.tableProps(totalCount);
const columns = useMemo((): ColumnDef<FleetRecord>[] => {
if (!config) return [];
@@ -604,77 +587,41 @@ const FleetResourcePage = () => {
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="md" w="100%" style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}>
<FleetToolbar
search={search}
onSearchChange={setSearch}
<FilterBar
defs={filterDefs}
controls={controls}
searchPlaceholder={config.searchPlaceholder}
showSearch={config.supportsSearch}
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
<Group gap="sm" wrap="wrap" align="center">
<DatePickerInput
type="range"
aria-label="Created date range"
placeholder="Created date range"
value={[dateFrom, dateTo]}
onChange={([from, to]) => {
setDateFrom(from);
setDateTo(to);
}}
presets={getDateRangePresets()}
clearable
size="sm"
radius="lg"
w={240}
/>
{listFilterSelects ? (
<Group gap="sm" wrap="wrap" align="center">
{listFilterSelects.map((filter) => (
<Select
key={filter.key}
aria-label={filter.label}
placeholder={filter.data[0]?.label ?? filter.label}
data={filter.data}
value={filter.value}
onChange={(value) => {
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)" } }}
/>
))}
</Group>
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
<Group gap={4} wrap="wrap">
<Text size="xs" fw={500} c="dimmed">Status:</Text>
<Group gap={4} wrap="wrap">
{[{ value: "ALL", label: "All" }, ...statusFilterOptions].map((option) => (
<Button
key={option.value}
size="xs"
radius="md"
variant={statusFilter === option.value ? "filled" : "outline"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setStatusFilter(option.value)}
>
{option.label}
</Button>
))}
</Group>
</Group>
) : null}
</Group>
}
/>
viewId={`fleet-${slug}`}
>
<SegmentedControl
value={viewMode}
onChange={(value) => setViewMode(value as FleetViewMode)}
size="sm"
radius="lg"
data={[
{
value: "table",
label: (
<Group gap={6} justify="center" wrap="nowrap">
<Table2 size={14} />
<span>Table</span>
</Group>
),
},
{
value: "cards",
label: (
<Group gap={6} justify="center" wrap="nowrap">
<LayoutGrid size={14} />
<span>Cards</span>
</Group>
),
},
]}
styles={{ root: { background: "var(--mantine-color-gray-1)" } }}
/>
</FilterBar>
</Box>
{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 }) => (
<DataTableFooter
@@ -724,10 +661,10 @@ const FleetResourcePage = () => {
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) => {

View File

@@ -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 `<a target="_blank">`:
* - `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
* (<img>/<iframe>/<a href>) carry no Bearer token and 401. Fetch the bytes

View File

@@ -111,7 +111,11 @@ export interface CompanyChangeRequest {
/** Staged company-document add/remove intents (e.g. the PoA letter). */
documentChanges: DocumentChangeIntent[];
note: string | null;
/** Who filed the request — resolved from `iam.users`, null when unknown. */
submittedByName: string | null;
submittedAt: string | null;
/** Who approved / rejected / sent it back. */
reviewedByName: string | null;
reviewedAt: string | null;
createdAt: string;
updatedAt: string;
@@ -139,6 +143,8 @@ export interface CompanyRevision {
id: string;
companyId: string;
actorId: string | null;
/** Who made the edit — resolved from `iam.users`, null when unknown. */
actorName: string | null;
summary: string;
changes: CompanyRevisionChange[];
createdAt: string;