mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add customs clearing filter and enhance clearance documents page for non-customs contracts
This commit is contained in:
@@ -1211,6 +1211,12 @@ export class BookingsService {
|
||||
destinationYardId: filter.destinationYardId,
|
||||
isGovernment: filter.isGovernment,
|
||||
consolidationPaired: filter.consolidationPaired,
|
||||
// DTO carries 'true'/'false' strings (query params); the repo option is a
|
||||
// real boolean — convert, preserving "not filtered" when absent.
|
||||
customsClearingEnabled:
|
||||
filter.customsClearingEnabled === undefined
|
||||
? undefined
|
||||
: filter.customsClearingEnabled === 'true',
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
|
||||
@@ -106,6 +106,14 @@ export class FilterBookingDto {
|
||||
@IsIn(['true', 'false'])
|
||||
isGovernment?: 'true' | 'false';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ['true', 'false'],
|
||||
description: 'Filter customs vs self-clearance (non-customs) bookings',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['true', 'false'])
|
||||
customsClearingEnabled?: 'true' | 'false';
|
||||
|
||||
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
|
||||
@IsOptional()
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
|
||||
@@ -872,6 +872,7 @@ export class ContractClearanceService {
|
||||
pageSize: filter.pageSize ?? 100,
|
||||
statuses: ['CLEARANCE_UNDER_REVIEW'],
|
||||
customsClearingEnabled: false,
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
@@ -896,6 +897,7 @@ export class ContractClearanceService {
|
||||
pageSize: filter.pageSize ?? 50,
|
||||
statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'],
|
||||
customsClearingEnabled: false,
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy ?? 'createdAt',
|
||||
sortOrder: filter.sortOrder ?? 'DESC',
|
||||
});
|
||||
|
||||
@@ -53,6 +53,18 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Light fetch for human-facing labels (notifications): reference, train
|
||||
* number, departure and the two station names — none of the composition
|
||||
* graph {@link findByIdWithFullGraph} drags in.
|
||||
*/
|
||||
findByIdWithStations(id: string): Promise<TrainSchedule | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id },
|
||||
relations: { originStation: true, destinationStation: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: TrainScheduleStatus,
|
||||
|
||||
@@ -2258,7 +2258,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.logger.log(
|
||||
`[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`,
|
||||
);
|
||||
this.notifier.secured(booking, reason);
|
||||
this.notifier.secured(booking, reason, scheduleId);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
void this.markWagonAllocatedMilestone(booking.id);
|
||||
// Customer tracking: freight payment settled (commercial pay-window path).
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
|
||||
@Injectable()
|
||||
@@ -18,8 +19,37 @@ export class BookingNotifierService {
|
||||
constructor(
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly trainSchedules: TrainSchedulesRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Human-readable description of a train schedule for customer messages:
|
||||
* reference (or train number) + route + departure date. Never leaks a UUID —
|
||||
* falls back to a generic phrase when the schedule can't be loaded.
|
||||
*/
|
||||
private async scheduleLabel(scheduleId?: string | null): Promise<string> {
|
||||
const fallback = 'your selected train';
|
||||
if (!scheduleId) return fallback;
|
||||
try {
|
||||
const s = await this.trainSchedules.findByIdWithStations(scheduleId);
|
||||
if (!s) return fallback;
|
||||
const ref = s.reference ?? s.trainNumber ?? null;
|
||||
const route =
|
||||
s.originStation?.label && s.destinationStation?.label
|
||||
? ` (${s.originStation.label} → ${s.destinationStation.label})`
|
||||
: '';
|
||||
const departure = s.scheduledDepartureDate
|
||||
? `, departing ${new Date(s.scheduledDepartureDate).toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE })}`
|
||||
: '';
|
||||
return ref ? `train ${ref}${route}${departure}` : `${fallback}${route}${departure}`;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`scheduleLabel(${scheduleId}) failed: ${(err as Error).message}`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private ref(b: Booking): string {
|
||||
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
|
||||
}
|
||||
@@ -127,12 +157,15 @@ export class BookingNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
secured(b: Booking, reason: 'paid' | 'gov'): void {
|
||||
const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${
|
||||
reason === 'gov' ? ' (government)' : ''
|
||||
}.`;
|
||||
void this.notifyContact(b, msg, 'ALLOCATED');
|
||||
this.inApp(b, 'Wagon allocated', msg);
|
||||
secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void {
|
||||
void (async () => {
|
||||
const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId);
|
||||
const msg = `Booking ${b.reference ?? b.id} allocated on ${label}${
|
||||
reason === 'gov' ? ' (government)' : ''
|
||||
}.`;
|
||||
void this.notifyContact(b, msg, 'ALLOCATED');
|
||||
this.inApp(b, 'Wagon allocated', msg);
|
||||
})();
|
||||
}
|
||||
|
||||
expired(b: Booking): void {
|
||||
|
||||
@@ -52,6 +52,7 @@ import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPa
|
||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
|
||||
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
||||
import ClearanceDocumentsPage from "./pages/contracts/ClearanceDocumentsPage";
|
||||
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
|
||||
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
|
||||
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
|
||||
@@ -158,6 +159,14 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <FileSignature />,
|
||||
permission: FREIGHT_PERMS.contracts.view,
|
||||
},
|
||||
// Operations hub: clearance-document review for contracts WITHOUT
|
||||
// customs clearing (contract-level for one-time, per-booking for general).
|
||||
{
|
||||
label: "Clearance Documents",
|
||||
href: "/dashboard/contracts/clearance-documents",
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||
},
|
||||
{
|
||||
label: "Customers",
|
||||
href: "/dashboard/customers",
|
||||
@@ -788,6 +797,18 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* Operations hub: clearance documents for non-customs contracts —
|
||||
Contracts tab (contract-level) + General tab (per-booking). */}
|
||||
<Route
|
||||
path="contracts/clearance-documents"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
|
||||
>
|
||||
<ClearanceDocumentsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* GL (Path B) contract clearance review hub */}
|
||||
<Route
|
||||
path="contracts/clearance"
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Group,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Tabs,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { Search } from "lucide-react";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
|
||||
/**
|
||||
* Operations "Clearance Documents" hub — the worklist for clearance-document
|
||||
* review on contracts WITHOUT customs clearing (self-clearance / Path A):
|
||||
*
|
||||
* - Contracts tab: contracts whose clearance runs at contract level; rows open
|
||||
* the contract clearance detail where Operations approves + finalizes.
|
||||
* - General tab: booking instances under GENERAL non-customs contracts (those
|
||||
* clear per booking); rows open the booking clearance review page.
|
||||
*
|
||||
* The hub only lists — all review/approve/finalize actions live on the
|
||||
* existing detail pages it links to.
|
||||
*/
|
||||
|
||||
type HubTab = "contracts" | "general";
|
||||
type QueueTab = "queue" | "history";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
/** Booking statuses that mean "docs awaiting review" / "review finished". */
|
||||
const BOOKING_QUEUE_STATUS = "DOCUMENTS_UNDER_REVIEW";
|
||||
const BOOKING_HISTORY_STATUS = "CLEARANCE_READY";
|
||||
|
||||
function formatDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleDateString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function statusLabel(status?: string | null): string {
|
||||
return (status ?? "—").replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status?: string | null }) {
|
||||
const done =
|
||||
status === "CLEARANCE_READY" ||
|
||||
status === "CLEARANCE_READY_FOR_BOOKING" ||
|
||||
status === "ACTIVE" ||
|
||||
status === "CONTRACT_ACTIVE" ||
|
||||
status === "FULLY_EXECUTED";
|
||||
return (
|
||||
<Badge variant="light" color={done ? "edr-green" : "yellow"} radius="sm">
|
||||
{statusLabel(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClearanceDocumentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [hubTab, setHubTab] = useState<HubTab>("contracts");
|
||||
const [queueTab, setQueueTab] = useState<QueueTab>("queue");
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const search = debouncedQuery.trim() || undefined;
|
||||
|
||||
const contractsPager = usePagination({ pageSize: PAGE_SIZE });
|
||||
const generalPager = usePagination({ pageSize: PAGE_SIZE });
|
||||
|
||||
// Any search / queue-history / tab switch restarts both lists from page 1.
|
||||
useEffect(() => {
|
||||
contractsPager.setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
generalPager.setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedQuery, queueTab, hubTab]);
|
||||
|
||||
const isHistory = queueTab === "history";
|
||||
|
||||
const contractsQuery = useQuery({
|
||||
queryKey: [
|
||||
"clearance-documents",
|
||||
"contracts",
|
||||
queueTab,
|
||||
contractsPager.pagination.pageIndex,
|
||||
search,
|
||||
],
|
||||
queryFn: () => {
|
||||
const filter = {
|
||||
page: contractsPager.pagination.pageIndex + 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
search,
|
||||
};
|
||||
return isHistory
|
||||
? contractsService.getOpsClearanceHistory(filter)
|
||||
: contractsService.getOpsClearanceQueue(filter);
|
||||
},
|
||||
enabled: hubTab === "contracts",
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const generalQuery = useQuery({
|
||||
queryKey: [
|
||||
"clearance-documents",
|
||||
"general",
|
||||
queueTab,
|
||||
generalPager.pagination.pageIndex,
|
||||
search,
|
||||
],
|
||||
queryFn: () =>
|
||||
bookingsService.list({
|
||||
status: isHistory ? BOOKING_HISTORY_STATUS : BOOKING_QUEUE_STATUS,
|
||||
bookingType: "GENERAL_CONTRACT",
|
||||
customsClearingEnabled: "false",
|
||||
page: generalPager.pagination.pageIndex + 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
search,
|
||||
}),
|
||||
enabled: hubTab === "general",
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const contractColumns = useMemo(
|
||||
(): ColumnDef<Freight.IContract, unknown>[] => [
|
||||
{
|
||||
header: "Reference",
|
||||
accessorKey: "reference",
|
||||
},
|
||||
{
|
||||
header: "Customer",
|
||||
cell: ({ row }) => row.original.company?.name ?? "—",
|
||||
},
|
||||
{
|
||||
header: "Kind",
|
||||
cell: ({ row }) => statusLabel(row.original.contractKind),
|
||||
},
|
||||
{
|
||||
header: "Direction",
|
||||
cell: ({ row }) => statusLabel(row.original.tradeDirection),
|
||||
},
|
||||
{
|
||||
header: "Freight",
|
||||
cell: ({ row }) => statusLabel(row.original.freightType),
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
header: "Created",
|
||||
cell: ({ row }) => formatDate(row.original.createdAt),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const bookingColumns = useMemo(
|
||||
(): ColumnDef<BookingDetail, unknown>[] => [
|
||||
{
|
||||
header: "Reference",
|
||||
accessorKey: "reference",
|
||||
},
|
||||
{
|
||||
header: "Customer",
|
||||
cell: ({ row }) =>
|
||||
row.original.isGovernment
|
||||
? (row.original.governmentInstitution ?? "Government")
|
||||
: (row.original.company?.name ?? "—"),
|
||||
},
|
||||
{
|
||||
header: "Contract",
|
||||
cell: ({ row }) => row.original.contractReference ?? "—",
|
||||
},
|
||||
{
|
||||
header: "Direction",
|
||||
cell: ({ row }) => statusLabel(row.original.tradeDirection),
|
||||
},
|
||||
{
|
||||
header: "Freight",
|
||||
cell: ({ row }) => statusLabel(row.original.freightType),
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const activeQuery = hubTab === "contracts" ? contractsQuery : generalQuery;
|
||||
const total = activeQuery.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const tableStatus = activeQuery.isLoading
|
||||
? "loading"
|
||||
: activeQuery.isError
|
||||
? "error"
|
||||
: "success";
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Clearance Documents"
|
||||
subtitle="Operations review of customer clearance documents for contracts without customs clearing — contract-level (one-time) and per-booking (general)."
|
||||
/>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
||||
<Tabs
|
||||
value={hubTab}
|
||||
onChange={(v) => setHubTab((v as HubTab) ?? "contracts")}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="contracts">Contracts</Tabs.Tab>
|
||||
<Tabs.Tab value="general">General</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<Group gap="sm">
|
||||
<SegmentedControl
|
||||
value={queueTab}
|
||||
onChange={(v) => setQueueTab(v as QueueTab)}
|
||||
data={[
|
||||
{ value: "queue", label: "Queue" },
|
||||
{ value: "history", label: "History" },
|
||||
]}
|
||||
size="xs"
|
||||
/>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
placeholder={
|
||||
hubTab === "contracts"
|
||||
? "Search reference or customer…"
|
||||
: "Search booking, customer or contract…"
|
||||
}
|
||||
leftSection={<Search size={14} />}
|
||||
w={260}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{hubTab === "contracts" ? (
|
||||
<DataTable<Freight.IContract, unknown>
|
||||
columns={contractColumns}
|
||||
data={contractsQuery.data?.items ?? []}
|
||||
status={tableStatus}
|
||||
onRowClick={(row) =>
|
||||
navigate(`/dashboard/contracts/clearance/${row.id}`)
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: contractsPager.pagination.pageIndex,
|
||||
pageSize: PAGE_SIZE,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination: contractsPager.pagination },
|
||||
onPaginationChange: contractsPager.setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
) : (
|
||||
<DataTable<BookingDetail, unknown>
|
||||
columns={bookingColumns}
|
||||
data={generalQuery.data?.items ?? []}
|
||||
status={tableStatus}
|
||||
onRowClick={(row) => navigate(`/dashboard/clearance/${row.id}`)}
|
||||
pagination={{
|
||||
pageIndex: generalPager.pagination.pageIndex,
|
||||
pageSize: PAGE_SIZE,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination: generalPager.pagination },
|
||||
onPaginationChange: generalPager.setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export interface BookingListFilter {
|
||||
freightType?: string;
|
||||
/** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */
|
||||
bookingType?: string;
|
||||
/** 'true' → customs bookings, 'false' → self-clearance (non-customs). */
|
||||
customsClearingEnabled?: "true" | "false";
|
||||
tradeDirection?: string;
|
||||
paymentCurrency?: string;
|
||||
paymentStatus?: string;
|
||||
@@ -185,6 +187,8 @@ export const bookingsService = {
|
||||
if (filter.originYardId) params.originYardId = filter.originYardId;
|
||||
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
|
||||
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
|
||||
if (filter.customsClearingEnabled)
|
||||
params.customsClearingEnabled = filter.customsClearingEnabled;
|
||||
if (filter.search) params.search = filter.search;
|
||||
}
|
||||
const response = await client.get<PaginatedBookings>(B.BASE, {
|
||||
|
||||
@@ -457,9 +457,14 @@ export const contractsService = {
|
||||
},
|
||||
|
||||
// ── Path A self-clearance (Operations review) ──
|
||||
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
|
||||
getOpsClearanceQueue: async (filter?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
}): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(
|
||||
C.OPS_CLEARANCE_QUEUE,
|
||||
{ params: filter },
|
||||
);
|
||||
const data = unwrap(response.data);
|
||||
return {
|
||||
@@ -474,8 +479,15 @@ export const contractsService = {
|
||||
return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0 };
|
||||
},
|
||||
|
||||
getOpsClearanceHistory: async (): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(C.OPS_CLEARANCE_HISTORY);
|
||||
getOpsClearanceHistory: async (filter?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
}): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(
|
||||
C.OPS_CLEARANCE_HISTORY,
|
||||
{ params: filter },
|
||||
);
|
||||
const data = unwrap(response.data);
|
||||
return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0 };
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user