mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 04:48:18 +00:00
Merge pull request #415 from Tria-plc/freight/feat/fixes-v1
Implement invoice pagination and enhance file viewing features
This commit is contained in:
@@ -1,21 +1,31 @@
|
|||||||
import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common";
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
|
|
||||||
import { FreightAdmin } from "../../common/booking-guards";
|
import { BookingView } from "../../common/booking-guards";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
|
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||||
|
|
||||||
@ApiTags("billing")
|
@ApiTags("billing")
|
||||||
@Controller("billing")
|
@Controller("billing")
|
||||||
@FreightAdmin()
|
@BookingView()
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class BillingController {
|
export class BillingController {
|
||||||
constructor(private readonly billingService: BillingService) { }
|
constructor(private readonly billingService: BillingService) {}
|
||||||
|
|
||||||
@Get("invoices")
|
@Get("invoices")
|
||||||
@ApiOperation({ summary: "List all invoices" })
|
@ApiOperation({
|
||||||
findAll() {
|
summary: "List invoices (paginated, filterable by company/status/search)",
|
||||||
return this.billingService.findAll();
|
})
|
||||||
|
findAll(@Query() query: FilterInvoiceDto) {
|
||||||
|
return this.billingService.findAllPaginated(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("invoices/:id")
|
@Get("invoices/:id")
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export class BillingService {
|
|||||||
private readonly payment: PaymentService,
|
private readonly payment: PaymentService,
|
||||||
private readonly companies: CompaniesService,
|
private readonly companies: CompaniesService,
|
||||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -134,9 +134,56 @@ export class BillingService {
|
|||||||
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
|
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated invoice list for the backoffice — optionally narrowed to a
|
||||||
|
* company (customer detail "Invoices" tab) and/or status/search (global
|
||||||
|
* invoices page).
|
||||||
|
*/
|
||||||
|
async findAllPaginated(
|
||||||
|
filter: {
|
||||||
|
companyId?: string;
|
||||||
|
status?: Freight.InvoiceStatus;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
} = {},
|
||||||
|
): Promise<{ items: Invoice[]; total: number }> {
|
||||||
|
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||||
|
const pageSize =
|
||||||
|
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||||
|
|
||||||
|
const qb = this.dataSource
|
||||||
|
.getRepository(Invoice)
|
||||||
|
.createQueryBuilder("invoice")
|
||||||
|
.leftJoinAndSelect("invoice.company", "company")
|
||||||
|
.orderBy("invoice.issuedAt", "DESC")
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize);
|
||||||
|
|
||||||
|
if (filter.companyId) {
|
||||||
|
qb.andWhere("invoice.companyId = :companyId", {
|
||||||
|
companyId: filter.companyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (filter.status) {
|
||||||
|
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||||
|
}
|
||||||
|
if (filter.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
|
||||||
|
{ search: `%${filter.search}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, total] = await qb.getManyAndCount();
|
||||||
|
return { items, total };
|
||||||
|
}
|
||||||
|
|
||||||
/** Invoice header plus its line items. */
|
/** Invoice header plus its line items. */
|
||||||
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
const invoice = await this.invoices.findById(id);
|
const invoice = await this.invoices.findById(id, {
|
||||||
|
relations: { company: true, companyProfile: true },
|
||||||
|
});
|
||||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||||
const lines = await this.invoiceLines.findAll({
|
const lines = await this.invoiceLines.findAll({
|
||||||
where: { invoiceId: id },
|
where: { invoiceId: id },
|
||||||
@@ -375,7 +422,7 @@ export class BillingService {
|
|||||||
input.dueAt ??
|
input.dueAt ??
|
||||||
new Date(
|
new Date(
|
||||||
Date.now() +
|
Date.now() +
|
||||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
const invoiceNumber = await this.nextInvoiceNumber(mg);
|
const invoiceNumber = await this.nextInvoiceNumber(mg);
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Freight } from "@edr/types";
|
||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { Transform } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Min,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
export class FilterInvoiceDto {
|
||||||
|
@ApiPropertyOptional({ default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }: { value: unknown }) => parseInt(String(value), 10))
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }: { value: unknown }) => parseInt(String(value), 10))
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize?: number = 20;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
companyId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: Freight.InvoiceStatus })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(Object.values(Freight.InvoiceStatus))
|
||||||
|
status?: Freight.InvoiceStatus;
|
||||||
|
}
|
||||||
@@ -93,9 +93,8 @@ export class PaymentRepository {
|
|||||||
p.paid_at,
|
p.paid_at,
|
||||||
p.created_at
|
p.created_at
|
||||||
FROM freight.payments p
|
FROM freight.payments p
|
||||||
JOIN freight.bookings b ON b.id = p.ref_id
|
JOIN freight.bookings b ON b.id = p.ref_id::uuid
|
||||||
WHERE b.company_id = $1
|
WHERE b.company_id = $1
|
||||||
AND p.deleted_at IS NULL
|
|
||||||
AND b.deleted_at IS NULL
|
AND b.deleted_at IS NULL
|
||||||
ORDER BY p.created_at DESC`,
|
ORDER BY p.created_at DESC`,
|
||||||
[companyId],
|
[companyId],
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
PackageCheck,
|
PackageCheck,
|
||||||
PackageOpen,
|
PackageOpen,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
|
Receipt,
|
||||||
Send,
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -32,7 +33,11 @@ import {
|
|||||||
useParams,
|
useParams,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
|
|
||||||
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
|
import {
|
||||||
|
FreightDashboardLayout,
|
||||||
|
type SidebarItem,
|
||||||
|
type SidebarSection,
|
||||||
|
} from "@/components/layout";
|
||||||
import { useAuth } from "./auth/useAuth";
|
import { useAuth } from "./auth/useAuth";
|
||||||
import LoadingScreen from "./components/LoadingScreen";
|
import LoadingScreen from "./components/LoadingScreen";
|
||||||
import LoginPage from "./pages/auth/LoginPage";
|
import LoginPage from "./pages/auth/LoginPage";
|
||||||
@@ -53,6 +58,8 @@ import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
|||||||
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
||||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||||
import CustomersPage from "./pages/customers/CustomersPage";
|
import CustomersPage from "./pages/customers/CustomersPage";
|
||||||
|
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||||
|
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
||||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||||
@@ -61,7 +68,10 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage
|
|||||||
import PaymentsPage from "./pages/payments/PaymentsPage";
|
import PaymentsPage from "./pages/payments/PaymentsPage";
|
||||||
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||||
import { RequirePermission } from "./components/auth/RequirePermission";
|
import { RequirePermission } from "./components/auth/RequirePermission";
|
||||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions";
|
import {
|
||||||
|
FREIGHT_PERMS,
|
||||||
|
hasPermission as hasFreightPermission,
|
||||||
|
} from "./lib/permissions";
|
||||||
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
||||||
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
||||||
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
||||||
@@ -144,6 +154,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <Wallet />,
|
icon: <Wallet />,
|
||||||
permission: FREIGHT_PERMS.bookings.view,
|
permission: FREIGHT_PERMS.bookings.view,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Invoices",
|
||||||
|
href: "/dashboard/invoices",
|
||||||
|
icon: <Receipt />,
|
||||||
|
permission: FREIGHT_PERMS.bookings.view,
|
||||||
|
},
|
||||||
...demoItems,
|
...demoItems,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -506,7 +522,10 @@ const App = () => {
|
|||||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||||
<Route path="/health" element={<HealthCheck />} />
|
<Route path="/health" element={<HealthCheck />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route
|
||||||
|
path="/dashboard"
|
||||||
|
element={<Navigate to="/dashboard/overview" replace />}
|
||||||
|
/>
|
||||||
<Route path="/dashboard" element={<DashboardShell />}>
|
<Route path="/dashboard" element={<DashboardShell />}>
|
||||||
<Route path="overview" element={<OverviewPage />} />
|
<Route path="overview" element={<OverviewPage />} />
|
||||||
<Route path="profile" element={<MyProfilePage />} />
|
<Route path="profile" element={<MyProfilePage />} />
|
||||||
@@ -522,8 +541,27 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
<Route path="customers" element={<CustomersPage />} />
|
<Route path="customers" element={<CustomersPage />} />
|
||||||
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
||||||
|
<Route
|
||||||
|
path="invoices"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
|
||||||
|
<InvoicesPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="invoices/:id"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
|
||||||
|
<InvoiceDetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
||||||
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
<Route
|
||||||
|
path="booking-requests/:id"
|
||||||
|
element={<BookingRequestDetailPage />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="booking-requests/:id/contract"
|
path="booking-requests/:id/contract"
|
||||||
element={<BookingContractPage />}
|
element={<BookingContractPage />}
|
||||||
@@ -536,7 +574,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="clearance/:id"
|
path="clearance/:id"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.bookings.clearanceView}>
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.bookings.clearanceView}
|
||||||
|
>
|
||||||
<DocumentClearanceDetailPage />
|
<DocumentClearanceDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -570,7 +610,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="bookings/:bookingId/clearance"
|
path="bookings/:bookingId/clearance"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.bookings.clearanceView}>
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.bookings.clearanceView}
|
||||||
|
>
|
||||||
<DocumentClearanceDetailPage />
|
<DocumentClearanceDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -578,7 +620,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="shipment-requests"
|
path="shipment-requests"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.contracts.createBooking}
|
||||||
|
>
|
||||||
<ShipmentRequestsPage />
|
<ShipmentRequestsPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -586,7 +630,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="shipment-requests/:id"
|
path="shipment-requests/:id"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.contracts.createBooking}
|
||||||
|
>
|
||||||
<ShipmentRequestDetailPage />
|
<ShipmentRequestDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -618,12 +664,20 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="gl-ethiopia/clearance" element={<LegacyGlEthiopiaClearanceRedirect />} />
|
<Route
|
||||||
<Route path="gl-ethiopia/clearance/:id" element={<LegacyGlEthiopiaClearanceRedirect />} />
|
path="gl-ethiopia/clearance"
|
||||||
|
element={<LegacyGlEthiopiaClearanceRedirect />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="gl-ethiopia/clearance/:id"
|
||||||
|
element={<LegacyGlEthiopiaClearanceRedirect />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="gl-djibouti/clearance"
|
path="gl-djibouti/clearance"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.contracts.clearanceDjActions}
|
||||||
|
>
|
||||||
<GlDjiboutiClearanceListPage />
|
<GlDjiboutiClearanceListPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -631,7 +685,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="gl-djibouti/clearance/:id"
|
path="gl-djibouti/clearance/:id"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.contracts.clearanceDjActions}
|
||||||
|
>
|
||||||
<GlClearanceDetailPage />
|
<GlClearanceDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -644,7 +700,9 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="contracts/:id/create-booking"
|
path="contracts/:id/create-booking"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.contracts.createBooking}
|
||||||
|
>
|
||||||
<GlCreateBookingForm />
|
<GlCreateBookingForm />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -655,155 +713,174 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
<Route
|
||||||
|
path="warehouse-inventory"
|
||||||
|
element={<WarehouseInventoryPage />}
|
||||||
|
/>
|
||||||
<Route path="import-warehouse" element={<ImportWarehouseFlowPage />} />
|
<Route path="import-warehouse" element={<ImportWarehouseFlowPage />} />
|
||||||
<Route path="export-warehouse" element={<ExportWarehouseFlowPage />} />
|
<Route path="export-warehouse" element={<ExportWarehouseFlowPage />} />
|
||||||
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
||||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||||
<Route path="export-djibouti-unloading" element={<ExportDjiboutiUnloadingQueuePage />} />
|
<Route
|
||||||
<Route path="interchange-documents" element={<InterchangeDocumentsPage />} />
|
path="export-djibouti-unloading"
|
||||||
|
element={<ExportDjiboutiUnloadingQueuePage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="interchange-documents"
|
||||||
|
element={<InterchangeDocumentsPage />}
|
||||||
|
/>
|
||||||
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||||
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
|
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
|
||||||
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
|
<Route
|
||||||
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
path="warehouse-fee-invoices"
|
||||||
|
element={<WarehouseInvoicesPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="warehouse-dashboard"
|
||||||
|
element={<WarehouseDashboardPage />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
|
||||||
path="operations/train-scheduling"
|
|
||||||
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="operations/batch-board"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
|
||||||
<BatchBoardPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="operations/first-mile"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
|
||||||
<FirstMilePage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="operations/last-mile"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
|
||||||
<LastMilePage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="operations/batch-board/:scheduleId"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
|
||||||
<BatchScheduleDetailPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="operations/train-scheduling-v2"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
|
||||||
<TrainScheduleV2ListPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="operations/train-scheduling-v2/:scheduleId"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
|
||||||
<TrainScheduleV2DetailPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="operations/train-scheduling-v2/:scheduleId/track"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
|
||||||
<TrainScheduleTrackPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="routes"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
|
||||||
<RoutesPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="locomotives"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
|
||||||
<FleetResourcePage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="trains"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
|
||||||
<FleetResourcePage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="trains/:id"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
|
||||||
<TrainDetailPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="wagons"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
|
||||||
<FleetResourcePage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="containers"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
|
||||||
<FleetResourcePage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="cargoes"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
|
||||||
<FleetResourcePage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="vehicles"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
|
||||||
<FleetResourcePage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="drivers"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
|
||||||
<FleetResourcePage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="operations/train-scheduling"
|
path="operations/train-scheduling"
|
||||||
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
element={
|
||||||
|
<Navigate to="/dashboard/operations/train-scheduling-v2" replace />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="operations/batch-board"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<BatchBoardPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="operations/first-mile"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<FirstMilePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="operations/last-mile"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<LastMilePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="operations/batch-board/:scheduleId"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<BatchScheduleDetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="operations/train-scheduling-v2"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<TrainScheduleV2ListPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="operations/train-scheduling-v2/:scheduleId"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<TrainScheduleV2DetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="operations/train-scheduling-v2/:scheduleId/track"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<TrainScheduleTrackPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="routes"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<RoutesPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="locomotives"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="trains"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="trains/:id"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<TrainDetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="wagons"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="containers"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="cargoes"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="vehicles"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="drivers"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
|
<FleetResourcePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="operations/train-scheduling"
|
||||||
|
element={
|
||||||
|
<Navigate to="/dashboard/operations/train-scheduling-v2" replace />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="operations/batch-board"
|
path="operations/batch-board"
|
||||||
@@ -953,9 +1030,15 @@ const App = () => {
|
|||||||
{/* Legacy embedded user management routes */}
|
{/* Legacy embedded user management routes */}
|
||||||
<Route path="user-management" element={<UserManagementPage />} />
|
<Route path="user-management" element={<UserManagementPage />} />
|
||||||
<Route path="user-management/users" element={<UsersPage />} />
|
<Route path="user-management/users" element={<UsersPage />} />
|
||||||
<Route path="user-management/position-types" element={<PositionTypesPage />} />
|
<Route
|
||||||
|
path="user-management/position-types"
|
||||||
|
element={<PositionTypesPage />}
|
||||||
|
/>
|
||||||
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
|
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
|
||||||
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
<Route
|
||||||
|
path="user-management/permissions"
|
||||||
|
element={<PermissionsPage />}
|
||||||
|
/>
|
||||||
<Route path="user-management/roles" element={<RolesPage />} />
|
<Route path="user-management/roles" element={<RolesPage />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
@@ -977,7 +1060,9 @@ const App = () => {
|
|||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="configuration"
|
path="configuration"
|
||||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
element={
|
||||||
|
<Navigate to="/dashboard/configuration/cargo-types" replace />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="configuration/train-scheduling-rules"
|
path="configuration/train-scheduling-rules"
|
||||||
@@ -996,8 +1081,14 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
||||||
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
|
<Route
|
||||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
path="configuration/cargo-types/:id"
|
||||||
|
element={<CargoTypesPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="configuration/:resource"
|
||||||
|
element={<RuleEngineResourcePage />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="rules"
|
path="rules"
|
||||||
@@ -1007,9 +1098,14 @@ const App = () => {
|
|||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="rule-engine"
|
path="rule-engine"
|
||||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
element={
|
||||||
|
<Navigate to="/dashboard/configuration/cargo-types" replace />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="rule-engine/:resource"
|
||||||
|
element={<RuleEngineLegacyRedirect />}
|
||||||
/>
|
/>
|
||||||
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
|
|
||||||
|
|
||||||
<Route path="user1" element={<DemoUser1Page />} />
|
<Route path="user1" element={<DemoUser1Page />} />
|
||||||
<Route path="user2" element={<DemoUser2Page />} />
|
<Route path="user2" element={<DemoUser2Page />} />
|
||||||
@@ -1026,9 +1122,7 @@ const App = () => {
|
|||||||
/** Redirect removed milestones page to document clearance. */
|
/** Redirect removed milestones page to document clearance. */
|
||||||
function BookingMilestonesRedirect() {
|
function BookingMilestonesRedirect() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
return (
|
return <Navigate to={`/dashboard/bookings/${id}/clearance`} replace />;
|
||||||
<Navigate to={`/dashboard/bookings/${id}/clearance`} replace />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */
|
/** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
import { Badge, Button, Group, Tooltip } from "@mantine/core";
|
import { Badge, Button, Group, Tooltip } from "@mantine/core";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
@@ -88,7 +89,13 @@ export function ProfileChips({
|
|||||||
}) {
|
}) {
|
||||||
if (!profiles.length) {
|
if (!profiles.length) {
|
||||||
return (
|
return (
|
||||||
<Badge color="gray" variant="light" size="sm" radius="md" style={badgeStyle}>
|
<Badge
|
||||||
|
color="gray"
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
No profiles
|
No profiles
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
@@ -118,7 +125,13 @@ export function ProfileChips({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
{extra > 0 ? (
|
{extra > 0 ? (
|
||||||
<Badge color="gray" variant="light" size="sm" radius="md" style={badgeStyle}>
|
<Badge
|
||||||
|
color="gray"
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
+{extra}
|
+{extra}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -169,7 +182,11 @@ const BOOKING_STATUS_COLOR: Record<CustomerBookingStatus, string> = {
|
|||||||
CANCELLED: "red",
|
CANCELLED: "red",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BookingStatusBadge({ status }: { status: CustomerBookingStatus }) {
|
export function BookingStatusBadge({
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
status: CustomerBookingStatus;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
color={BOOKING_STATUS_COLOR[status] ?? "gray"}
|
color={BOOKING_STATUS_COLOR[status] ?? "gray"}
|
||||||
@@ -194,7 +211,11 @@ const PAYMENT_STATUS_COLOR: Record<CustomerPaymentStatus, string> = {
|
|||||||
refunded: "grape",
|
refunded: "grape",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }) {
|
export function PaymentStatusBadge({
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
status: CustomerPaymentStatus;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
color={PAYMENT_STATUS_COLOR[status] ?? "gray"}
|
color={PAYMENT_STATUS_COLOR[status] ?? "gray"}
|
||||||
@@ -210,6 +231,38 @@ export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INVOICE_STATUS_COLOR: Record<Freight.InvoiceStatus, string> = {
|
||||||
|
DRAFT: "gray",
|
||||||
|
ISSUED: "cyan",
|
||||||
|
PENDING: "yellow",
|
||||||
|
PARTIALLY_PAID: "orange",
|
||||||
|
PAID: "edr-green",
|
||||||
|
OVERDUE: "red",
|
||||||
|
CANCELLED: "gray",
|
||||||
|
REFUNDED: "grape",
|
||||||
|
EXPIRED: "red",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InvoiceStatusBadge({
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
status: Freight.InvoiceStatus;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
color={INVOICE_STATUS_COLOR[status] ?? "gray"}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
tt="capitalize"
|
||||||
|
fw={600}
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
|
{humanize(status)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline approval action buttons for a profile row.
|
* Inline approval action buttons for a profile row.
|
||||||
* Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
|
* Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
|
||||||
@@ -225,8 +278,7 @@ export function ProfileApprovalActions({
|
|||||||
api.customers.setProfileStatus.mutationOptions(),
|
api.customers.setProfileStatus.mutationOptions(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const act = (next: ProfileStatus) =>
|
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
|
||||||
mutate({ profileId, status: next });
|
|
||||||
|
|
||||||
if (status === "pending") {
|
if (status === "pending") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export {
|
|||||||
BookingStatusBadge,
|
BookingStatusBadge,
|
||||||
CompanyStatusBadge,
|
CompanyStatusBadge,
|
||||||
CompanyTypeBadge,
|
CompanyTypeBadge,
|
||||||
|
InvoiceStatusBadge,
|
||||||
PaymentStatusBadge,
|
PaymentStatusBadge,
|
||||||
ProfileApprovalActions,
|
ProfileApprovalActions,
|
||||||
ProfileChips,
|
ProfileChips,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { BookingListFilter } from "@/services/bookings.service";
|
|||||||
import type { ContractListFilter } from "@/services/contracts.service";
|
import type { ContractListFilter } from "@/services/contracts.service";
|
||||||
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
|
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
|
||||||
import type { CompanyListFilter } from "@/types/customer";
|
import type { CompanyListFilter } from "@/types/customer";
|
||||||
|
import type { InvoiceListFilter } from "@/types/invoice";
|
||||||
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
||||||
import type { TrainScheduleFilters } from "@/types/trainScheduling";
|
import type { TrainScheduleFilters } from "@/types/trainScheduling";
|
||||||
|
|
||||||
@@ -16,7 +17,8 @@ export const QUERY_KEYS = {
|
|||||||
ROOT: ["file-upload-settings"] as const,
|
ROOT: ["file-upload-settings"] as const,
|
||||||
list: () => ["file-upload-settings", "list"] as const,
|
list: () => ["file-upload-settings", "list"] as const,
|
||||||
byId: (id: string) => ["file-upload-settings", "detail", id] as const,
|
byId: (id: string) => ["file-upload-settings", "detail", id] as const,
|
||||||
byCode: (code: string) => ["file-upload-settings", "by-code", code] as const,
|
byCode: (code: string) =>
|
||||||
|
["file-upload-settings", "by-code", code] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
DROPDOWN_SETTINGS: {
|
DROPDOWN_SETTINGS: {
|
||||||
@@ -33,10 +35,18 @@ export const QUERY_KEYS = {
|
|||||||
["customers", "list", filter ?? {}] as const,
|
["customers", "list", filter ?? {}] as const,
|
||||||
byId: (id: string) => ["customers", "detail", id] as const,
|
byId: (id: string) => ["customers", "detail", id] as const,
|
||||||
bookings: (id: string) => ["customers", "detail", id, "bookings"] as const,
|
bookings: (id: string) => ["customers", "detail", id, "bookings"] as const,
|
||||||
documents: (id: string) => ["customers", "detail", id, "documents"] as const,
|
documents: (id: string) =>
|
||||||
|
["customers", "detail", id, "documents"] as const,
|
||||||
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
INVOICES: {
|
||||||
|
ROOT: ["invoices"] as const,
|
||||||
|
list: (filter?: InvoiceListFilter) =>
|
||||||
|
["invoices", "list", filter ?? {}] as const,
|
||||||
|
byId: (id: string) => ["invoices", "detail", id] as const,
|
||||||
|
},
|
||||||
|
|
||||||
BOOKINGS: {
|
BOOKINGS: {
|
||||||
ROOT: ["bookings"] as const,
|
ROOT: ["bookings"] as const,
|
||||||
list: (filter?: BookingListFilter) =>
|
list: (filter?: BookingListFilter) =>
|
||||||
@@ -80,7 +90,12 @@ export const QUERY_KEYS = {
|
|||||||
TRAIN_SCHEDULING: {
|
TRAIN_SCHEDULING: {
|
||||||
ROOT: ["train-scheduling"] as const,
|
ROOT: ["train-scheduling"] as const,
|
||||||
eligible: (freightType?: string, filters?: TrainScheduleFilters) =>
|
eligible: (freightType?: string, filters?: TrainScheduleFilters) =>
|
||||||
["train-scheduling", "eligible-bookings", freightType ?? "CONTAINER", filters ?? {}] as const,
|
[
|
||||||
|
"train-scheduling",
|
||||||
|
"eligible-bookings",
|
||||||
|
freightType ?? "CONTAINER",
|
||||||
|
filters ?? {},
|
||||||
|
] as const,
|
||||||
locomotives: (routeId?: string) =>
|
locomotives: (routeId?: string) =>
|
||||||
["train-scheduling", "locomotives", routeId ?? "all"] as const,
|
["train-scheduling", "locomotives", routeId ?? "all"] as const,
|
||||||
stations: () => ["train-scheduling", "stations"] as const,
|
stations: () => ["train-scheduling", "stations"] as const,
|
||||||
@@ -98,31 +113,37 @@ export const QUERY_KEYS = {
|
|||||||
|
|
||||||
FLEET: {
|
FLEET: {
|
||||||
ROOT: ["fleet"] as const,
|
ROOT: ["fleet"] as const,
|
||||||
list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const,
|
list: (resource: FleetResourceSlug | string) =>
|
||||||
|
["fleet", "list", resource] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
VEHICLES: {
|
VEHICLES: {
|
||||||
ROOT: ["vehicles"] as const,
|
ROOT: ["vehicles"] as const,
|
||||||
list: (filter?: Record<string, unknown>) => ["vehicles", "list", filter ?? {}] as const,
|
list: (filter?: Record<string, unknown>) =>
|
||||||
|
["vehicles", "list", filter ?? {}] as const,
|
||||||
byId: (id: string) => ["vehicles", "detail", id] as const,
|
byId: (id: string) => ["vehicles", "detail", id] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
FIRST_MILE: {
|
FIRST_MILE: {
|
||||||
ROOT: ["first-mile"] as const,
|
ROOT: ["first-mile"] as const,
|
||||||
list: (filter?: Record<string, unknown>) => ["first-mile", "list", filter ?? {}] as const,
|
list: (filter?: Record<string, unknown>) =>
|
||||||
|
["first-mile", "list", filter ?? {}] as const,
|
||||||
byId: (id: string) => ["first-mile", "detail", id] as const,
|
byId: (id: string) => ["first-mile", "detail", id] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
LAST_MILE: {
|
LAST_MILE: {
|
||||||
ROOT: ["last-mile"] as const,
|
ROOT: ["last-mile"] as const,
|
||||||
list: (filter?: Record<string, unknown>) => ["last-mile", "list", filter ?? {}] as const,
|
list: (filter?: Record<string, unknown>) =>
|
||||||
|
["last-mile", "list", filter ?? {}] as const,
|
||||||
byId: (id: string) => ["last-mile", "detail", id] as const,
|
byId: (id: string) => ["last-mile", "detail", id] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
RULE_ENGINE: {
|
RULE_ENGINE: {
|
||||||
ROOT: ["rule-engine"] as const,
|
ROOT: ["rule-engine"] as const,
|
||||||
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>
|
list: (
|
||||||
["rule-engine", "list", resource, params ?? {}] as const,
|
resource: RuleEngineResourceSlug | string,
|
||||||
|
params?: RuleEngineListParams,
|
||||||
|
) => ["rule-engine", "list", resource, params ?? {}] as const,
|
||||||
detail: (resource: RuleEngineResourceSlug | string, id: string) =>
|
detail: (resource: RuleEngineResourceSlug | string, id: string) =>
|
||||||
["rule-engine", "detail", resource, id] as const,
|
["rule-engine", "detail", resource, id] as const,
|
||||||
chain: ["rule-engine", "approval-rules", "chain"] as const,
|
chain: ["rule-engine", "approval-rules", "chain"] as const,
|
||||||
@@ -136,27 +157,39 @@ export const QUERY_KEYS = {
|
|||||||
|
|
||||||
OVERVIEW: {
|
OVERVIEW: {
|
||||||
ROOT: ["overview"] as const,
|
ROOT: ["overview"] as const,
|
||||||
dashboard: (range?: string) => ["overview", "dashboard", range ?? "30d"] as const,
|
dashboard: (range?: string) =>
|
||||||
bookingsTab: (range?: string) => ["overview", "bookings", range ?? "30d"] as const,
|
["overview", "dashboard", range ?? "30d"] as const,
|
||||||
contractsTab: (range?: string) => ["overview", "contracts", range ?? "30d"] as const,
|
bookingsTab: (range?: string) =>
|
||||||
billingTab: (range?: string) => ["overview", "billing", range ?? "30d"] as const,
|
["overview", "bookings", range ?? "30d"] as const,
|
||||||
|
contractsTab: (range?: string) =>
|
||||||
|
["overview", "contracts", range ?? "30d"] as const,
|
||||||
|
billingTab: (range?: string) =>
|
||||||
|
["overview", "billing", range ?? "30d"] as const,
|
||||||
operationsTab: () => ["overview", "operations"] as const,
|
operationsTab: () => ["overview", "operations"] as const,
|
||||||
customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const,
|
customersTab: (range?: string) =>
|
||||||
staffTab: (range?: string) => ["overview", "staff", range ?? "30d"] as const,
|
["overview", "customers", range ?? "30d"] as const,
|
||||||
|
staffTab: (range?: string) =>
|
||||||
|
["overview", "staff", range ?? "30d"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
FUEL: {
|
FUEL: {
|
||||||
ROOT: ["fuel"] as const,
|
ROOT: ["fuel"] as const,
|
||||||
purchases: (vehicleId?: string) => ["fuel", "purchases", vehicleId ?? "all"] as const,
|
purchases: (vehicleId?: string) =>
|
||||||
stats: (vehicleId?: string) => ["fuel", "stats", vehicleId ?? "all"] as const,
|
["fuel", "purchases", vehicleId ?? "all"] as const,
|
||||||
|
stats: (vehicleId?: string) =>
|
||||||
|
["fuel", "stats", vehicleId ?? "all"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
MAINTENANCE: {
|
MAINTENANCE: {
|
||||||
ROOT: ["maintenance"] as const,
|
ROOT: ["maintenance"] as const,
|
||||||
schedules: (vehicleId?: string) => ["maintenance", "schedules", vehicleId ?? "all"] as const,
|
schedules: (vehicleId?: string) =>
|
||||||
upcoming: (vehicleId?: string) => ["maintenance", "upcoming", vehicleId ?? "all"] as const,
|
["maintenance", "schedules", vehicleId ?? "all"] as const,
|
||||||
history: (vehicleId?: string) => ["maintenance", "history", vehicleId ?? "all"] as const,
|
upcoming: (vehicleId?: string) =>
|
||||||
stats: (vehicleId?: string) => ["maintenance", "stats", vehicleId ?? "all"] as const,
|
["maintenance", "upcoming", vehicleId ?? "all"] as const,
|
||||||
|
history: (vehicleId?: string) =>
|
||||||
|
["maintenance", "history", vehicleId ?? "all"] as const,
|
||||||
|
stats: (vehicleId?: string) =>
|
||||||
|
["maintenance", "stats", vehicleId ?? "all"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
FINANCIAL_REPORTS: {
|
FINANCIAL_REPORTS: {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const URL_CONSTANTS = {
|
|||||||
BASE: "/users",
|
BASE: "/users",
|
||||||
BY_ID: (id: string | number) => `/users/${id}`,
|
BY_ID: (id: string | number) => `/users/${id}`,
|
||||||
SET_PASSWORD: "/api/auth/set-password",
|
SET_PASSWORD: "/api/auth/set-password",
|
||||||
ME: "/api/auth/me"
|
ME: "/api/auth/me",
|
||||||
},
|
},
|
||||||
|
|
||||||
ROLES: {
|
ROLES: {
|
||||||
@@ -74,9 +74,18 @@ export const URL_CONSTANTS = {
|
|||||||
STATS: "/companies/stats",
|
STATS: "/companies/stats",
|
||||||
BY_ID: (id: string | number) => `/companies/${id}`,
|
BY_ID: (id: string | number) => `/companies/${id}`,
|
||||||
DOCUMENTS: (id: string) => `/companies/${id}/documents`,
|
DOCUMENTS: (id: string) => `/companies/${id}/documents`,
|
||||||
PROFILE_STATUS: (profileId: string) => `/companies/company-profiles/${profileId}/status`,
|
PROFILE_STATUS: (profileId: string) =>
|
||||||
BOOKINGS_CUSTOMER_VIEW: (id: string) => `/bookings/by-company/${id}/customer-view`,
|
`/companies/company-profiles/${profileId}/status`,
|
||||||
PAYMENTS_CUSTOMER_VIEW: (id: string) => `/payments/by-company/${id}/customer-view`,
|
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
|
||||||
|
`/bookings/by-company/${id}/customer-view`,
|
||||||
|
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
|
||||||
|
`/payments/by-company/${id}/customer-view`,
|
||||||
|
},
|
||||||
|
|
||||||
|
BILLING: {
|
||||||
|
INVOICES: "/billing/invoices",
|
||||||
|
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
|
||||||
|
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
|
||||||
},
|
},
|
||||||
|
|
||||||
CUSTOMERS_API: {
|
CUSTOMERS_API: {
|
||||||
@@ -124,15 +133,21 @@ export const URL_CONSTANTS = {
|
|||||||
CANCEL: (id: string) => `/bookings/${id}/cancel`,
|
CANCEL: (id: string) => `/bookings/${id}/cancel`,
|
||||||
CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`,
|
CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`,
|
||||||
CLEARANCE: (id: string) => `/bookings/${id}/clearance`,
|
CLEARANCE: (id: string) => `/bookings/${id}/clearance`,
|
||||||
CLEARANCE_DECLARATION: (id: string) => `/bookings/${id}/clearance/declaration`,
|
CLEARANCE_DECLARATION: (id: string) =>
|
||||||
|
`/bookings/${id}/clearance/declaration`,
|
||||||
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
|
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
|
||||||
CLEARANCE_FINALIZE_PRE: (id: string) =>
|
CLEARANCE_FINALIZE_PRE: (id: string) =>
|
||||||
`/bookings/${id}/clearance/finalize-pre-clearance`,
|
`/bookings/${id}/clearance/finalize-pre-clearance`,
|
||||||
CLEARANCE_TRANSIT_PERMIT: (id: string) => `/bookings/${id}/clearance/transit-permit`,
|
CLEARANCE_TRANSIT_PERMIT: (id: string) =>
|
||||||
CLEARANCE_DELIVERY_ORDER: (id: string) => `/bookings/${id}/clearance/delivery-order`,
|
`/bookings/${id}/clearance/transit-permit`,
|
||||||
CLEARANCE_RELEASE_ORDER: (id: string) => `/bookings/${id}/clearance/release-order`,
|
CLEARANCE_DELIVERY_ORDER: (id: string) =>
|
||||||
CLEARANCE_RO_AMENDMENT: (id: string) => `/bookings/${id}/clearance/ro-amendment`,
|
`/bookings/${id}/clearance/delivery-order`,
|
||||||
CLEARANCE_EXPORT_RELEASE: (id: string) => `/bookings/${id}/clearance/export-release`,
|
CLEARANCE_RELEASE_ORDER: (id: string) =>
|
||||||
|
`/bookings/${id}/clearance/release-order`,
|
||||||
|
CLEARANCE_RO_AMENDMENT: (id: string) =>
|
||||||
|
`/bookings/${id}/clearance/ro-amendment`,
|
||||||
|
CLEARANCE_EXPORT_RELEASE: (id: string) =>
|
||||||
|
`/bookings/${id}/clearance/export-release`,
|
||||||
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
|
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
|
||||||
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
|
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
|
||||||
},
|
},
|
||||||
@@ -157,7 +172,8 @@ export const URL_CONSTANTS = {
|
|||||||
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
|
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
|
||||||
`/contracts/${id}/clearance/output-documents`,
|
`/contracts/${id}/clearance/output-documents`,
|
||||||
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
|
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
|
||||||
CLEARANCE_DECLARATION: (id: string) => `/contracts/${id}/clearance/declaration`,
|
CLEARANCE_DECLARATION: (id: string) =>
|
||||||
|
`/contracts/${id}/clearance/declaration`,
|
||||||
CLEARANCE_DUTY: (id: string) => `/contracts/${id}/clearance/duty`,
|
CLEARANCE_DUTY: (id: string) => `/contracts/${id}/clearance/duty`,
|
||||||
CLEARANCE_DUTY_SLIP: (id: string) => `/contracts/${id}/clearance/duty-slip`,
|
CLEARANCE_DUTY_SLIP: (id: string) => `/contracts/${id}/clearance/duty-slip`,
|
||||||
CLEARANCE_FINALIZE_PRE: (id: string) =>
|
CLEARANCE_FINALIZE_PRE: (id: string) =>
|
||||||
@@ -247,7 +263,7 @@ export const URL_CONSTANTS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
ROUTES: {
|
ROUTES: {
|
||||||
BASE: '/routes',
|
BASE: "/routes",
|
||||||
BY_ID: (id: string) => `/routes/${id}`,
|
BY_ID: (id: string) => `/routes/${id}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -261,12 +277,15 @@ export const URL_CONSTANTS = {
|
|||||||
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
||||||
`/train-scheduling/batch-board/${scheduleId}`,
|
`/train-scheduling/batch-board/${scheduleId}`,
|
||||||
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
|
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
|
||||||
|
RUN_ALLOCATION: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/run-allocation`,
|
||||||
DOC_REVIEW_COMPLETE: (id: string) =>
|
DOC_REVIEW_COMPLETE: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/doc-review-complete`,
|
`/train-scheduling/schedules/${id}/doc-review-complete`,
|
||||||
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
|
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
|
||||||
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
|
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
|
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
|
||||||
BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`,
|
BOOKING_WINDOW: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/booking-window`,
|
||||||
MARK_BOOKING_PAID: (bookingId: string) =>
|
MARK_BOOKING_PAID: (bookingId: string) =>
|
||||||
`/train-scheduling/bookings/${bookingId}/mark-paid`,
|
`/train-scheduling/bookings/${bookingId}/mark-paid`,
|
||||||
EXPIRE_BOOKING: (bookingId: string) =>
|
EXPIRE_BOOKING: (bookingId: string) =>
|
||||||
@@ -275,12 +294,14 @@ export const URL_CONSTANTS = {
|
|||||||
`/train-scheduling/bookings/${bookingId}/move-schedule`,
|
`/train-scheduling/bookings/${bookingId}/move-schedule`,
|
||||||
GLOBAL_RULES: "/train-scheduling/global-rules",
|
GLOBAL_RULES: "/train-scheduling/global-rules",
|
||||||
PREVIEW: "/train-scheduling/preview",
|
PREVIEW: "/train-scheduling/preview",
|
||||||
ASSIGN_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/assign-bookings`,
|
ASSIGN_BOOKINGS: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/assign-bookings`,
|
||||||
CONTAINER: {
|
CONTAINER: {
|
||||||
ELIGIBLE_BOOKINGS: "/train-scheduling/container/eligible-bookings",
|
ELIGIBLE_BOOKINGS: "/train-scheduling/container/eligible-bookings",
|
||||||
PREVIEW: "/train-scheduling/container/preview",
|
PREVIEW: "/train-scheduling/container/preview",
|
||||||
SCHEDULES: "/train-scheduling/container/schedules",
|
SCHEDULES: "/train-scheduling/container/schedules",
|
||||||
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
|
SCHEDULE_BY_ID: (id: string) =>
|
||||||
|
`/train-scheduling/container/schedules/${id}`,
|
||||||
ASSIGN_BOOKINGS: (id: string) =>
|
ASSIGN_BOOKINGS: (id: string) =>
|
||||||
`/train-scheduling/container/schedules/${id}/assign-bookings`,
|
`/train-scheduling/container/schedules/${id}/assign-bookings`,
|
||||||
CANCEL_SCHEDULE: (id: string) =>
|
CANCEL_SCHEDULE: (id: string) =>
|
||||||
@@ -319,15 +340,18 @@ export const URL_CONSTANTS = {
|
|||||||
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
|
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
|
||||||
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
|
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/export/load-list/document`,
|
`/train-scheduling/schedules/${id}/export/load-list/document`,
|
||||||
CHECKPOINTS: (id: string) => `/train-scheduling/schedules/${id}/checkpoints`,
|
CHECKPOINTS: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/checkpoints`,
|
||||||
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
||||||
RESCHEDULE_PREVIEW: (id: string) =>
|
RESCHEDULE_PREVIEW: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/reschedule/preview`,
|
`/train-scheduling/schedules/${id}/reschedule/preview`,
|
||||||
RESCHEDULE_EXECUTE: (id: string) =>
|
RESCHEDULE_EXECUTE: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/reschedule/execute`,
|
`/train-scheduling/schedules/${id}/reschedule/execute`,
|
||||||
MAINTENANCE: (id: string) => `/train-scheduling/schedules/${id}/maintenance`,
|
MAINTENANCE: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/maintenance`,
|
||||||
SCHEDULES: "/train-scheduling/container/schedules",
|
SCHEDULES: "/train-scheduling/container/schedules",
|
||||||
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
|
SCHEDULE_BY_ID: (id: string) =>
|
||||||
|
`/train-scheduling/container/schedules/${id}`,
|
||||||
CANCEL_SCHEDULE: (id: string) =>
|
CANCEL_SCHEDULE: (id: string) =>
|
||||||
`/train-scheduling/container/schedules/${id}/cancel`,
|
`/train-scheduling/container/schedules/${id}/cancel`,
|
||||||
REMOVE_WAGON_SLOT: (scheduleId: string, wagonId: string) =>
|
REMOVE_WAGON_SLOT: (scheduleId: string, wagonId: string) =>
|
||||||
@@ -375,175 +399,193 @@ export const URL_CONSTANTS = {
|
|||||||
APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`,
|
APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`,
|
||||||
APPROVAL_RULES_CHAIN: "/approval-rules/chain",
|
APPROVAL_RULES_CHAIN: "/approval-rules/chain",
|
||||||
},
|
},
|
||||||
RATE_MATRIX: {
|
RATE_MATRIX: {
|
||||||
BASE: '/api/rate-matrices',
|
BASE: "/api/rate-matrices",
|
||||||
DRAFT: '/api/rate-matrices/draft',
|
DRAFT: "/api/rate-matrices/draft",
|
||||||
SUBMIT: (id: string) => `/api/rate-matrices/${id}/submit`,
|
SUBMIT: (id: string) => `/api/rate-matrices/${id}/submit`,
|
||||||
AUTHORIZE: (id: string) => `/api/rate-matrices/${id}/authorize`,
|
AUTHORIZE: (id: string) => `/api/rate-matrices/${id}/authorize`,
|
||||||
LIST: '/api/rate-matrices',
|
LIST: "/api/rate-matrices",
|
||||||
DETAIL: (id: string) => `/api/rate-matrices/${id}`,
|
DETAIL: (id: string) => `/api/rate-matrices/${id}`,
|
||||||
},
|
},
|
||||||
REFERENCE: {
|
REFERENCE: {
|
||||||
PORTS: '/api/reference/ports',
|
PORTS: "/api/reference/ports",
|
||||||
CITIES: '/api/reference/cities',
|
CITIES: "/api/reference/cities",
|
||||||
CONTAINER_TYPES: '/api/reference/container-types',
|
CONTAINER_TYPES: "/api/reference/container-types",
|
||||||
CURRENCIES: '/api/reference/currencies',
|
CURRENCIES: "/api/reference/currencies",
|
||||||
},
|
},
|
||||||
|
|
||||||
FACILITIES: {
|
FACILITIES: {
|
||||||
BASE: '/facilities',
|
BASE: "/facilities",
|
||||||
BY_ID: (id: string) => `/facilities/${id}`,
|
BY_ID: (id: string) => `/facilities/${id}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSES: {
|
WAREHOUSES: {
|
||||||
BASE: '/warehouses',
|
BASE: "/warehouses",
|
||||||
DASHBOARD: '/warehouses/dashboard',
|
DASHBOARD: "/warehouses/dashboard",
|
||||||
BY_ID: (id: string) => `/warehouses/${id}`,
|
BY_ID: (id: string) => `/warehouses/${id}`,
|
||||||
YARDS: (warehouseId: string) => `/warehouses/${warehouseId}/yards`,
|
YARDS: (warehouseId: string) => `/warehouses/${warehouseId}/yards`,
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_YARDS: {
|
WAREHOUSE_YARDS: {
|
||||||
BASE: '/warehouse-yards',
|
BASE: "/warehouse-yards",
|
||||||
BY_ID: (id: string) => `/warehouse-yards/${id}`,
|
BY_ID: (id: string) => `/warehouse-yards/${id}`,
|
||||||
ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`,
|
ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`,
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_ZONES: {
|
WAREHOUSE_ZONES: {
|
||||||
BASE: '/warehouse-zones',
|
BASE: "/warehouse-zones",
|
||||||
BY_ID: (id: string) => `/warehouse-zones/${id}`,
|
BY_ID: (id: string) => `/warehouse-zones/${id}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_INVENTORY: {
|
WAREHOUSE_INVENTORY: {
|
||||||
BASE: '/warehouse-inventory',
|
BASE: "/warehouse-inventory",
|
||||||
RECEIVE: '/warehouse-inventory/receive',
|
RECEIVE: "/warehouse-inventory/receive",
|
||||||
DASHBOARD_SUMMARY: '/warehouse-inventory/dashboard/summary',
|
DASHBOARD_SUMMARY: "/warehouse-inventory/dashboard/summary",
|
||||||
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
|
READY_FOR_LOADING: "/warehouse-inventory/ready-for-loading",
|
||||||
INQUIRY: '/warehouse-inventory/inquiry',
|
INQUIRY: "/warehouse-inventory/inquiry",
|
||||||
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
|
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
|
||||||
INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`,
|
INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`,
|
||||||
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
|
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
|
||||||
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
|
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
|
||||||
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
|
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
|
||||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||||
RESERVE: '/warehouse-inventory/reserve',
|
RESERVE: "/warehouse-inventory/reserve",
|
||||||
ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue',
|
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
||||||
AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived',
|
AUTO_UNLOAD_ARRIVED: "/warehouse-inventory/auto-unload-arrived",
|
||||||
AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready',
|
AUTO_LOAD_READY: "/warehouse-inventory/auto-load-ready",
|
||||||
UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`,
|
UNLOAD_BOOKING: (bookingId: string) =>
|
||||||
INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`,
|
`/warehouse-inventory/bookings/${bookingId}/unload`,
|
||||||
LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons',
|
INSPECTION_REPORTS: (inventoryId: string) =>
|
||||||
BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`,
|
`/warehouse-inventory/${inventoryId}/inspection-reports`,
|
||||||
|
LOADABLE_WAGONS: "/warehouse-inventory/loadable-wagons",
|
||||||
|
BOOKING_SCHEDULE: (bookingId: string) =>
|
||||||
|
`/warehouse-inventory/booking/${bookingId}/schedule`,
|
||||||
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
|
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
|
||||||
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
|
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
|
||||||
LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`,
|
LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`,
|
||||||
// Import branch
|
// Import branch
|
||||||
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
|
MARK_READY_PICKUP: (id: string) =>
|
||||||
|
`/warehouse-inventory/${id}/ready-for-pickup`,
|
||||||
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
||||||
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
|
RELEASE_DOCUMENT: (id: string) =>
|
||||||
|
`/warehouse-inventory/${id}/release-document`,
|
||||||
GRN_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/grn-document`,
|
GRN_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/grn-document`,
|
||||||
HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`,
|
HANDOVER_DOCUMENT: (id: string) =>
|
||||||
|
`/warehouse-inventory/${id}/handover-document`,
|
||||||
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
||||||
// Receive (Import/Export bulk)
|
// Receive (Import/Export bulk)
|
||||||
ELIGIBLE_BOOKINGS: (direction?: string) =>
|
ELIGIBLE_BOOKINGS: (direction?: string) =>
|
||||||
direction
|
direction
|
||||||
? `/warehouse-inventory/eligible-bookings?direction=${direction}`
|
? `/warehouse-inventory/eligible-bookings?direction=${direction}`
|
||||||
: `/warehouse-inventory/eligible-bookings`,
|
: `/warehouse-inventory/eligible-bookings`,
|
||||||
RECEIVE_BULK: '/warehouse-inventory/receive-bulk',
|
RECEIVE_BULK: "/warehouse-inventory/receive-bulk",
|
||||||
LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export',
|
LOAD_PASSED_EXPORT: "/warehouse-inventory/load-passed-export",
|
||||||
BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected',
|
BULK_MARK_INSPECTED: "/warehouse-inventory/bulk-mark-inspected",
|
||||||
RECEIVED_EXPORT: '/warehouse-inventory/received-export',
|
RECEIVED_EXPORT: "/warehouse-inventory/received-export",
|
||||||
READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export',
|
READY_TO_LOAD_EXPORT: "/warehouse-inventory/ready-to-load-export",
|
||||||
LOADED_EXPORT: '/warehouse-inventory/loaded-export',
|
LOADED_EXPORT: "/warehouse-inventory/loaded-export",
|
||||||
BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export',
|
BULK_DISPATCH_EXPORT: "/warehouse-inventory/bulk-dispatch-export",
|
||||||
IMPORT_ARRIVE_QUEUE: '/warehouse-inventory/import/arrive-queue',
|
IMPORT_ARRIVE_QUEUE: "/warehouse-inventory/import/arrive-queue",
|
||||||
IMPORT_TRAIN_ITEMS: (scheduleId: string) =>
|
IMPORT_TRAIN_ITEMS: (scheduleId: string) =>
|
||||||
`/warehouse-inventory/import/trains/${scheduleId}/items`,
|
`/warehouse-inventory/import/trains/${scheduleId}/items`,
|
||||||
IMPORT_AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/import/auto-unload-arrived-bookings',
|
IMPORT_AUTO_UNLOAD_ARRIVED:
|
||||||
IMPORT_UNLOADED_QUEUE: '/warehouse-inventory/import/unloaded-queue',
|
"/warehouse-inventory/import/auto-unload-arrived-bookings",
|
||||||
IMPORT_PICKUP_READY_QUEUE: '/warehouse-inventory/import/pickup-ready-queue',
|
IMPORT_UNLOADED_QUEUE: "/warehouse-inventory/import/unloaded-queue",
|
||||||
EXPORT_DJIBOUTI_ARRIVAL_QUEUE: '/warehouse-inventory/export/djibouti-arrival-queue',
|
IMPORT_PICKUP_READY_QUEUE: "/warehouse-inventory/import/pickup-ready-queue",
|
||||||
|
EXPORT_DJIBOUTI_ARRIVAL_QUEUE:
|
||||||
|
"/warehouse-inventory/export/djibouti-arrival-queue",
|
||||||
EXPORT_DJIBOUTI_TRAIN_ITEMS: (scheduleId: string) =>
|
EXPORT_DJIBOUTI_TRAIN_ITEMS: (scheduleId: string) =>
|
||||||
`/warehouse-inventory/export/djibouti-trains/${scheduleId}/items`,
|
`/warehouse-inventory/export/djibouti-trains/${scheduleId}/items`,
|
||||||
EXPORT_AUTO_UNLOAD_AT_DJIBOUTI: '/warehouse-inventory/export/auto-unload-at-djibouti',
|
EXPORT_AUTO_UNLOAD_AT_DJIBOUTI:
|
||||||
|
"/warehouse-inventory/export/auto-unload-at-djibouti",
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_LOADINGS: {
|
WAREHOUSE_LOADINGS: {
|
||||||
BASE: '/warehouse-loadings',
|
BASE: "/warehouse-loadings",
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_INSPECTION: {
|
WAREHOUSE_INSPECTION: {
|
||||||
BY_ID: (id: string) => `/warehouse-inspection-reports/${id}`,
|
BY_ID: (id: string) => `/warehouse-inspection-reports/${id}`,
|
||||||
ATTACHMENTS: (id: string) => `/warehouse-inspection-reports/${id}/attachments`,
|
ATTACHMENTS: (id: string) =>
|
||||||
|
`/warehouse-inspection-reports/${id}/attachments`,
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_RULES: {
|
WAREHOUSE_RULES: {
|
||||||
ALLOCATION: '/warehouse-allocation-rules',
|
ALLOCATION: "/warehouse-allocation-rules",
|
||||||
ALLOCATION_BY_ID: (id: string) => `/warehouse-allocation-rules/${id}`,
|
ALLOCATION_BY_ID: (id: string) => `/warehouse-allocation-rules/${id}`,
|
||||||
ALLOCATION_PREVIEW: '/warehouse-allocation/preview',
|
ALLOCATION_PREVIEW: "/warehouse-allocation/preview",
|
||||||
FEES: '/warehouse-fee-rules',
|
FEES: "/warehouse-fee-rules",
|
||||||
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
|
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
|
||||||
FEE_PREVIEW: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-preview`,
|
FEE_PREVIEW: (inventoryId: string) =>
|
||||||
|
`/warehouse-inventory/${inventoryId}/fee-preview`,
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_INVOICES: {
|
WAREHOUSE_INVOICES: {
|
||||||
BASE: '/warehouse-fee-invoices',
|
BASE: "/warehouse-fee-invoices",
|
||||||
BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`,
|
BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`,
|
||||||
DOCUMENT: (id: string) => `/warehouse-fee-invoices/${id}/document`,
|
DOCUMENT: (id: string) => `/warehouse-fee-invoices/${id}/document`,
|
||||||
RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`,
|
RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`,
|
||||||
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
|
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
|
||||||
PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`,
|
PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`,
|
||||||
PAY_ONLINE: (id: string) => `/warehouse-fee-invoices/${id}/pay-online`,
|
PAY_ONLINE: (id: string) => `/warehouse-fee-invoices/${id}/pay-online`,
|
||||||
GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
|
GENERATE: (inventoryId: string) =>
|
||||||
FOR_INVENTORY: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-invoices`,
|
`/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
|
||||||
FOR_BOOKING: (bookingId: string) => `/bookings/${bookingId}/warehouse-fee-invoices`,
|
FOR_INVENTORY: (inventoryId: string) =>
|
||||||
GATE_CLEARANCE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/gate-clearance`,
|
`/warehouse-inventory/${inventoryId}/fee-invoices`,
|
||||||
|
FOR_BOOKING: (bookingId: string) =>
|
||||||
|
`/bookings/${bookingId}/warehouse-fee-invoices`,
|
||||||
|
GATE_CLEARANCE: (inventoryId: string) =>
|
||||||
|
`/warehouse-inventory/${inventoryId}/gate-clearance`,
|
||||||
},
|
},
|
||||||
|
|
||||||
INTERCHANGE_DOCUMENTS: {
|
INTERCHANGE_DOCUMENTS: {
|
||||||
BASE: '/interchange-documents',
|
BASE: "/interchange-documents",
|
||||||
BY_ID: (id: string) => `/interchange-documents/${id}`,
|
BY_ID: (id: string) => `/interchange-documents/${id}`,
|
||||||
GENERATE_FROM_SCHEDULE: '/interchange-documents/generate-from-schedule',
|
GENERATE_FROM_SCHEDULE: "/interchange-documents/generate-from-schedule",
|
||||||
ACKNOWLEDGE: (id: string) => `/interchange-documents/${id}/acknowledge`,
|
ACKNOWLEDGE: (id: string) => `/interchange-documents/${id}/acknowledge`,
|
||||||
DISPUTE: (id: string) => `/interchange-documents/${id}/dispute`,
|
DISPUTE: (id: string) => `/interchange-documents/${id}/dispute`,
|
||||||
CANCEL: (id: string) => `/interchange-documents/${id}/cancel`,
|
CANCEL: (id: string) => `/interchange-documents/${id}/cancel`,
|
||||||
},
|
},
|
||||||
|
|
||||||
IMPORT_OPERATIONS: {
|
IMPORT_OPERATIONS: {
|
||||||
DJIBOUTI_INCIDENTS: '/import-operations/djibouti-incidents',
|
DJIBOUTI_INCIDENTS: "/import-operations/djibouti-incidents",
|
||||||
CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`,
|
CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`,
|
||||||
CUSTOMS_DOCUMENTS: (bookingId: string) => `/import-operations/customs/${bookingId}/documents`,
|
CUSTOMS_DOCUMENTS: (bookingId: string) =>
|
||||||
CUSTOMS_DECLARATION: (bookingId: string) => `/import-operations/customs/${bookingId}/declaration`,
|
`/import-operations/customs/${bookingId}/documents`,
|
||||||
|
CUSTOMS_DECLARATION: (bookingId: string) =>
|
||||||
|
`/import-operations/customs/${bookingId}/declaration`,
|
||||||
CUSTOMS_NOTIFY_DUTIES_TAXES: (bookingId: string) =>
|
CUSTOMS_NOTIFY_DUTIES_TAXES: (bookingId: string) =>
|
||||||
`/import-operations/customs/${bookingId}/notify-duties-taxes`,
|
`/import-operations/customs/${bookingId}/notify-duties-taxes`,
|
||||||
CUSTOMS_DUTIES_TAXES_PAID: (bookingId: string) =>
|
CUSTOMS_DUTIES_TAXES_PAID: (bookingId: string) =>
|
||||||
`/import-operations/customs/${bookingId}/duties-taxes-paid`,
|
`/import-operations/customs/${bookingId}/duties-taxes-paid`,
|
||||||
CUSTOMS_RISK: (bookingId: string) => `/import-operations/customs/${bookingId}/risk`,
|
CUSTOMS_RISK: (bookingId: string) =>
|
||||||
|
`/import-operations/customs/${bookingId}/risk`,
|
||||||
CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
|
CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
|
||||||
`/import-operations/customs/${bookingId}/release-permitted`,
|
`/import-operations/customs/${bookingId}/release-permitted`,
|
||||||
EMPTY_CONTAINER_RETURNS: '/import-operations/empty-container-returns',
|
EMPTY_CONTAINER_RETURNS: "/import-operations/empty-container-returns",
|
||||||
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
|
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
|
||||||
`/import-operations/empty-container-returns/${id}/status`,
|
`/import-operations/empty-container-returns/${id}/status`,
|
||||||
},
|
},
|
||||||
|
|
||||||
VEHICLES: {
|
VEHICLES: {
|
||||||
BASE: '/vehicles',
|
BASE: "/vehicles",
|
||||||
BY_ID: (id: string) => `/vehicles/${id}`,
|
BY_ID: (id: string) => `/vehicles/${id}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
FIRST_MILE: {
|
FIRST_MILE: {
|
||||||
BASE: '/first-mile',
|
BASE: "/first-mile",
|
||||||
BY_ID: (id: string) => `/first-mile/${id}`,
|
BY_ID: (id: string) => `/first-mile/${id}`,
|
||||||
ACCEPT: (reference: string) => `/first-mile/accept/${reference}`,
|
ACCEPT: (reference: string) => `/first-mile/accept/${reference}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
LAST_MILE: {
|
LAST_MILE: {
|
||||||
BASE: '/last-mile',
|
BASE: "/last-mile",
|
||||||
BY_ID: (id: string) => `/last-mile/${id}`,
|
BY_ID: (id: string) => `/last-mile/${id}`,
|
||||||
ACCEPT: (reference: string) => `/last-mile/accept/${reference}`,
|
ACCEPT: (reference: string) => `/last-mile/accept/${reference}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
DRIVERS: {
|
DRIVERS: {
|
||||||
BASE: '/drivers',
|
BASE: "/drivers",
|
||||||
BY_ID: (id: string) => `/drivers/${id}`,
|
BY_ID: (id: string) => `/drivers/${id}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +1,3 @@
|
|||||||
import { useCallback, useState } from "react";
|
// Re-export of the shared hook, now living in @edr/ui-common. Kept so existing
|
||||||
import { FileViewerModal, type ViewableFile } from "@edr/ui-common";
|
// `@/hooks/useFileViewer` imports keep working.
|
||||||
|
export { useFileViewer } from "@edr/ui-common";
|
||||||
/**
|
|
||||||
* Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
|
|
||||||
* from any file row to open the document inline (pdf / image / video / office /
|
|
||||||
* text); render `viewer` once near the page root.
|
|
||||||
*
|
|
||||||
* const { view, viewer } = useFileViewer();
|
|
||||||
* <Button onClick={() => view({ name, url, mimeType })}>View</Button>
|
|
||||||
* {viewer}
|
|
||||||
*/
|
|
||||||
export function useFileViewer() {
|
|
||||||
const [file, setFile] = useState<ViewableFile | null>(null);
|
|
||||||
|
|
||||||
const view = useCallback((f: ViewableFile) => setFile(f), []);
|
|
||||||
const close = useCallback(() => setFile(null), []);
|
|
||||||
|
|
||||||
const viewer = (
|
|
||||||
<FileViewerModal open={file !== null} file={file} onClose={close} />
|
|
||||||
);
|
|
||||||
|
|
||||||
return { view, close, viewer };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Anchor,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -17,10 +18,13 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
Banknote,
|
Banknote,
|
||||||
Download,
|
Download,
|
||||||
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
IdCard,
|
IdCard,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Package,
|
Package,
|
||||||
|
Paperclip,
|
||||||
|
Receipt,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@@ -30,6 +34,7 @@ import {
|
|||||||
BookingStatusBadge,
|
BookingStatusBadge,
|
||||||
CompanyStatusBadge,
|
CompanyStatusBadge,
|
||||||
CompanyTypeBadge,
|
CompanyTypeBadge,
|
||||||
|
InvoiceStatusBadge,
|
||||||
PaymentStatusBadge,
|
PaymentStatusBadge,
|
||||||
ProfileApprovalActions,
|
ProfileApprovalActions,
|
||||||
ProfileChips,
|
ProfileChips,
|
||||||
@@ -50,7 +55,13 @@ import type {
|
|||||||
CustomerDocument,
|
CustomerDocument,
|
||||||
CustomerPayment,
|
CustomerPayment,
|
||||||
} from "@/types/customer";
|
} from "@/types/customer";
|
||||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
import type { Invoice } from "@/types/invoice";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
useFileViewer,
|
||||||
|
usePagination,
|
||||||
|
type ColumnDef,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
||||||
return (
|
return (
|
||||||
@@ -78,6 +89,7 @@ function tableStatus(query: { isLoading: boolean; isError: boolean }) {
|
|||||||
export default function CustomerDetailPage() {
|
export default function CustomerDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { view, viewer } = useFileViewer();
|
||||||
|
|
||||||
const { data: company, isLoading } = useQuery(
|
const { data: company, isLoading } = useQuery(
|
||||||
api.customers.getById.queryOptions({
|
api.customers.getById.queryOptions({
|
||||||
@@ -104,9 +116,34 @@ export default function CustomerDetailPage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { pagination: invoicePagination, setPagination: setInvoicePagination } =
|
||||||
|
usePagination({
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
const invoiceFilter = useMemo(
|
||||||
|
() => ({
|
||||||
|
companyId: id ?? "",
|
||||||
|
page: invoicePagination.pageIndex + 1,
|
||||||
|
pageSize: invoicePagination.pageSize,
|
||||||
|
}),
|
||||||
|
[id, invoicePagination.pageIndex, invoicePagination.pageSize],
|
||||||
|
);
|
||||||
|
const invoicesQuery = useQuery(
|
||||||
|
api.invoices.list.queryOptions({
|
||||||
|
input: { filter: invoiceFilter },
|
||||||
|
enabled: Boolean(id),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const bookings = bookingsQuery.data ?? [];
|
const bookings = bookingsQuery.data ?? [];
|
||||||
const documents = documentsQuery.data ?? [];
|
const documents = documentsQuery.data ?? [];
|
||||||
const payments = paymentsQuery.data ?? [];
|
const payments = paymentsQuery.data ?? [];
|
||||||
|
const invoices = invoicesQuery.data?.items ?? [];
|
||||||
|
const invoiceTotal = invoicesQuery.data?.total ?? 0;
|
||||||
|
const invoicePageCount = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(invoiceTotal / invoicePagination.pageSize),
|
||||||
|
);
|
||||||
|
|
||||||
const totalPaid = useMemo(
|
const totalPaid = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -285,20 +322,37 @@ export default function CustomerDetailPage() {
|
|||||||
header: "",
|
header: "",
|
||||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<ActionIcon
|
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||||
component="a"
|
<ActionIcon
|
||||||
href={fileViewUrl(row.original.id, true)}
|
variant="subtle"
|
||||||
variant="subtle"
|
color="gray"
|
||||||
color="gray"
|
aria-label="View"
|
||||||
aria-label="Download"
|
data-stop-row-click
|
||||||
data-stop-row-click
|
onClick={() =>
|
||||||
>
|
view({
|
||||||
<Download size={16} />
|
name: row.original.name,
|
||||||
</ActionIcon>
|
url: fileViewUrl(row.original.id),
|
||||||
|
mimeType: row.original.mimeType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Eye size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={fileViewUrl(row.original.id, true)}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label="Download"
|
||||||
|
data-stop-row-click
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[view],
|
||||||
);
|
);
|
||||||
|
|
||||||
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
|
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
|
||||||
@@ -358,6 +412,59 @@ export default function CustomerDetailPage() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const invoiceColumns: ColumnDef<Invoice>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "invoiceNumber",
|
||||||
|
header: "Invoice",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{row.original.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "source",
|
||||||
|
header: "Source",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{humanize(row.original.source)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => <InvoiceStatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "amount",
|
||||||
|
header: "Amount",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{formatMoney(row.original.totalAmount, row.original.currency)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dueAt",
|
||||||
|
header: "Due",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{formatDate(row.original.dueAt)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const licenseProfiles = (company?.companyProfiles ?? []).filter(
|
||||||
|
(p) => p.licenseFiles && p.licenseFiles.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Center mih="60vh">
|
<Center mih="60vh">
|
||||||
@@ -392,8 +499,9 @@ export default function CustomerDetailPage() {
|
|||||||
]}
|
]}
|
||||||
backTo="/dashboard/customers"
|
backTo="/dashboard/customers"
|
||||||
title={company.name}
|
title={company.name}
|
||||||
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
subtitle={`TIN ${company.tin}${
|
||||||
}`}
|
company.country ? ` · ${company.country}` : ""
|
||||||
|
}`}
|
||||||
meta={
|
meta={
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<CompanyTypeBadge type={company.type} />
|
<CompanyTypeBadge type={company.type} />
|
||||||
@@ -416,6 +524,9 @@ export default function CustomerDetailPage() {
|
|||||||
<Tabs.Tab value="payments" leftSection={<Banknote size={16} />}>
|
<Tabs.Tab value="payments" leftSection={<Banknote size={16} />}>
|
||||||
Payments
|
Payments
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
||||||
|
Invoices
|
||||||
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
{/* OVERVIEW */}
|
{/* OVERVIEW */}
|
||||||
@@ -528,9 +639,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
bookingsQuery.isError
|
bookingsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load bookings.",
|
message: "Failed to load bookings.",
|
||||||
onRetry: () => void bookingsQuery.refetch(),
|
onRetry: () => void bookingsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -539,23 +650,63 @@ export default function CustomerDetailPage() {
|
|||||||
|
|
||||||
{/* DOCUMENTS */}
|
{/* DOCUMENTS */}
|
||||||
<Tabs.Panel value="documents" pt="lg">
|
<Tabs.Panel value="documents" pt="lg">
|
||||||
<TableCard minWidth={760}>
|
<Stack gap="lg">
|
||||||
<DataTable
|
<TableCard minWidth={760}>
|
||||||
columns={documentColumns}
|
<DataTable
|
||||||
data={documents}
|
columns={documentColumns}
|
||||||
status={tableStatus(documentsQuery)}
|
data={documents}
|
||||||
emptyMessage="No documents uploaded."
|
status={tableStatus(documentsQuery)}
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
emptyMessage="No documents uploaded."
|
||||||
error={
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
documentsQuery.isError
|
error={
|
||||||
? {
|
documentsQuery.isError
|
||||||
message: "Failed to load documents.",
|
? {
|
||||||
onRetry: () => void documentsQuery.refetch(),
|
message: "Failed to load documents.",
|
||||||
}
|
onRetry: () => void documentsQuery.refetch(),
|
||||||
: undefined
|
}
|
||||||
}
|
: undefined
|
||||||
/>
|
}
|
||||||
</TableCard>
|
/>
|
||||||
|
</TableCard>
|
||||||
|
|
||||||
|
{licenseProfiles.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
Business licenses
|
||||||
|
</Text>
|
||||||
|
<Stack gap="md">
|
||||||
|
{licenseProfiles.map((p) => (
|
||||||
|
<Stack key={p.id} gap={4}>
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{humanize(p.type)} · {p.reference}
|
||||||
|
</Text>
|
||||||
|
{(p.licenseFiles ?? []).map((f) => (
|
||||||
|
<Group key={f.url} gap={6} wrap="nowrap">
|
||||||
|
<Paperclip size={13} className="text-edr-muted" />
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
view({
|
||||||
|
name: f.name,
|
||||||
|
url: f.url,
|
||||||
|
mimeType: f.mimeType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
{/* PAYMENTS */}
|
{/* PAYMENTS */}
|
||||||
@@ -570,15 +721,53 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
paymentsQuery.isError
|
paymentsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load payments.",
|
message: "Failed to load payments.",
|
||||||
onRetry: () => void paymentsQuery.refetch(),
|
onRetry: () => void paymentsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TableCard>
|
</TableCard>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{/* INVOICES */}
|
||||||
|
<Tabs.Panel value="invoices" pt="lg">
|
||||||
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
|
<Box miw={860}>
|
||||||
|
<DataTable
|
||||||
|
columns={invoiceColumns}
|
||||||
|
data={invoices}
|
||||||
|
status={tableStatus(invoicesQuery)}
|
||||||
|
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
||||||
|
emptyMessage="No invoices for this customer."
|
||||||
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
|
error={
|
||||||
|
invoicesQuery.isError
|
||||||
|
? {
|
||||||
|
message: "Failed to load invoices.",
|
||||||
|
onRetry: () => void invoicesQuery.refetch(),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: invoicePagination.pageIndex,
|
||||||
|
pageSize: invoicePagination.pageSize,
|
||||||
|
pageCount: invoicePageCount,
|
||||||
|
totalCount: invoiceTotal,
|
||||||
|
}}
|
||||||
|
tableOptions={{
|
||||||
|
state: { pagination: invoicePagination },
|
||||||
|
onPaginationChange: setInvoicePagination,
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount: invoicePageCount,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{viewer}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Center,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ArrowLeft, Download } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
InvoiceStatusBadge,
|
||||||
|
formatDate,
|
||||||
|
formatMoney,
|
||||||
|
humanize,
|
||||||
|
} from "@/components/customers";
|
||||||
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { invoicesService } from "@/services/invoices.service";
|
||||||
|
|
||||||
|
function openPdfBlob(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const opened = window.open(url, "_blank");
|
||||||
|
if (!opened) {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
}
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
||||||
|
return (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={600}
|
||||||
|
c="edr-muted"
|
||||||
|
tt="uppercase"
|
||||||
|
style={{ letterSpacing: "0.04em" }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="edr-text">
|
||||||
|
{value && value.trim() ? value : "—"}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InvoiceDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
||||||
|
const { data: invoice, isLoading } = useQuery(
|
||||||
|
api.invoices.getById.queryOptions({
|
||||||
|
input: { id: id ?? "" },
|
||||||
|
enabled: Boolean(id),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadDocument = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await invoicesService.downloadDocument(id);
|
||||||
|
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}.pdf`);
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Center mih="60vh">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return (
|
||||||
|
<Container size="sm" py="xl">
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<Text fw={700}>Invoice not found</Text>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
leftSection={<ArrowLeft size={16} />}
|
||||||
|
onClick={() => navigate("/dashboard/invoices")}
|
||||||
|
>
|
||||||
|
Back to invoices
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Invoices", href: "/dashboard/invoices" },
|
||||||
|
{ label: invoice.invoiceNumber },
|
||||||
|
]}
|
||||||
|
backTo="/dashboard/invoices"
|
||||||
|
title={invoice.invoiceNumber}
|
||||||
|
subtitle={`${humanize(invoice.source)} · ${invoice.sourceId}`}
|
||||||
|
meta={<InvoiceStatusBadge status={invoice.status} />}
|
||||||
|
action={
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
aria-label="Download invoice"
|
||||||
|
loading={downloading}
|
||||||
|
onClick={() => void downloadDocument()}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Card>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
Summary
|
||||||
|
</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
||||||
|
<InfoField label="Billed to" value={invoice.company?.name} />
|
||||||
|
<InfoField
|
||||||
|
label="Profile"
|
||||||
|
value={invoice.companyProfile?.reference}
|
||||||
|
/>
|
||||||
|
<InfoField label="Type" value={humanize(invoice.type)} />
|
||||||
|
<InfoField label="Currency" value={invoice.currency} />
|
||||||
|
<InfoField label="Issued" value={formatDate(invoice.issuedAt)} />
|
||||||
|
<InfoField label="Due" value={formatDate(invoice.dueAt)} />
|
||||||
|
<InfoField
|
||||||
|
label="Total"
|
||||||
|
value={formatMoney(invoice.totalAmount, invoice.currency)}
|
||||||
|
/>
|
||||||
|
<InfoField
|
||||||
|
label="Balance"
|
||||||
|
value={formatMoney(invoice.balanceAmount, invoice.currency)}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
Line items
|
||||||
|
</Text>
|
||||||
|
<Table striped withRowBorders={false} verticalSpacing="xs">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Description</Table.Th>
|
||||||
|
<Table.Th>Charge type</Table.Th>
|
||||||
|
<Table.Th ta="right">Quantity</Table.Th>
|
||||||
|
<Table.Th ta="right">Unit rate</Table.Th>
|
||||||
|
<Table.Th ta="right">Amount</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{(invoice.lines ?? []).map((line) => (
|
||||||
|
<Table.Tr key={line.id}>
|
||||||
|
<Table.Td>{line.description ?? line.chargeType}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{humanize(line.chargeType)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">{line.quantity}</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
{formatMoney(line.unitRate, line.currency)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
{formatMoney(line.amount, line.currency)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
{(invoice.lines ?? []).length === 0 && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={5}>
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||||
|
No line items.
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<Group
|
||||||
|
justify="flex-end"
|
||||||
|
gap="xl"
|
||||||
|
pt="sm"
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={2} align="flex-end">
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
Subtotal
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
{formatMoney(invoice.subtotalAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={2} align="flex-end">
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
Tax
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
{formatMoney(invoice.taxAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={2} align="flex-end">
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
Paid
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
{formatMoney(invoice.paidAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={2} align="flex-end">
|
||||||
|
<Text size="xs" fw={700}>
|
||||||
|
Total
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={700}>
|
||||||
|
{formatMoney(invoice.totalAmount, invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
SegmentedControl,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { RefreshCw, Search, X } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
InvoiceStatusBadge,
|
||||||
|
formatDate,
|
||||||
|
formatMoney,
|
||||||
|
humanize,
|
||||||
|
} from "@/components/customers";
|
||||||
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type { Invoice } from "@/types/invoice";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableFooter,
|
||||||
|
usePagination,
|
||||||
|
type ColumnDef,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
export default function InvoicesPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
const filter = useMemo(
|
||||||
|
() => ({
|
||||||
|
page: pagination.pageIndex + 1,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
search: debouncedQuery,
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
}),
|
||||||
|
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch, isFetching } = useQuery(
|
||||||
|
api.invoices.list.queryOptions({ input: { filter } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
|
|
||||||
|
const columns: ColumnDef<Invoice>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "invoiceNumber",
|
||||||
|
header: "Invoice",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{row.original.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "billedTo",
|
||||||
|
header: "Billed to",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="edr-text">
|
||||||
|
{row.original.company?.name ?? "—"}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "source",
|
||||||
|
header: "Source",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{humanize(row.original.source)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => <InvoiceStatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "amount",
|
||||||
|
header: "Amount",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{formatMoney(row.original.totalAmount, row.original.currency)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "balance",
|
||||||
|
header: "Balance",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{formatMoney(row.original.balanceAmount, row.original.currency)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dueAt",
|
||||||
|
header: "Due",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{formatDate(row.original.dueAt)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title="Invoices"
|
||||||
|
subtitle="Every invoice issued across bookings, warehouse fees and clearance charges."
|
||||||
|
action={
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
aria-label="Refresh"
|
||||||
|
loading={isFetching}
|
||||||
|
onClick={() => void refetch()}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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 by invoice number…"
|
||||||
|
leftSection={<Search size={18} />}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
rightSection={
|
||||||
|
query ? (
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
variant="transparent"
|
||||||
|
onClick={() => setQuery("")}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
style={{ flex: 1, minWidth: "240px" }}
|
||||||
|
radius="lg"
|
||||||
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
value={statusFilter || "all"}
|
||||||
|
onChange={(v) => {
|
||||||
|
setStatusFilter(
|
||||||
|
v === "all" ? "" : (v as Freight.InvoiceStatus),
|
||||||
|
);
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||||
|
}}
|
||||||
|
data={[
|
||||||
|
{ label: "All", value: "all" },
|
||||||
|
{ label: "Pending", value: "PENDING" },
|
||||||
|
{ label: "Paid", value: "PAID" },
|
||||||
|
{ label: "Overdue", value: "OVERDUE" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{total} record{total !== 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
|
<Box miw={920}>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||||
|
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
||||||
|
emptyMessage={
|
||||||
|
debouncedQuery
|
||||||
|
? "No invoices match your search."
|
||||||
|
: "No invoices yet."
|
||||||
|
}
|
||||||
|
error={
|
||||||
|
isError
|
||||||
|
? {
|
||||||
|
message: "Failed to load invoices.",
|
||||||
|
onRetry: () => void refetch(),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount,
|
||||||
|
totalCount: total,
|
||||||
|
}}
|
||||||
|
tableOptions={{
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount,
|
||||||
|
}}
|
||||||
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
|
footer={DataTableFooter}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ import type {
|
|||||||
UpdateFileUploadFieldDto,
|
UpdateFileUploadFieldDto,
|
||||||
UpdateFileUploadSettingDto,
|
UpdateFileUploadSettingDto,
|
||||||
} from "@/types/fileUploadSettings";
|
} from "@/types/fileUploadSettings";
|
||||||
|
import type {
|
||||||
|
Invoice,
|
||||||
|
InvoiceListFilter,
|
||||||
|
PaginatedInvoices,
|
||||||
|
} from "@/types/invoice";
|
||||||
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
||||||
import {
|
import {
|
||||||
RuleEngineListResult,
|
RuleEngineListResult,
|
||||||
@@ -128,6 +133,7 @@ import {
|
|||||||
import { containerTypesService } from "./container-types.service";
|
import { containerTypesService } from "./container-types.service";
|
||||||
import { containerService, type Container } from "./containerService";
|
import { containerService, type Container } from "./containerService";
|
||||||
import { customersService } from "./customers.service";
|
import { customersService } from "./customers.service";
|
||||||
|
import { invoicesService } from "./invoices.service";
|
||||||
import { dropdownSettingsService } from "./dropdownSettings.service";
|
import { dropdownSettingsService } from "./dropdownSettings.service";
|
||||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||||
import {
|
import {
|
||||||
@@ -197,7 +203,10 @@ const TRAIN_SCHEDULING_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
|
|||||||
export const api = {
|
export const api = {
|
||||||
trainScheduling: {
|
trainScheduling: {
|
||||||
// ── Queries ────────────────────────────────────────────────────────────
|
// ── Queries ────────────────────────────────────────────────────────────
|
||||||
scheduleList: endpoint<{ freightType?: FreightType }, TrainScheduleListItem[]>(
|
scheduleList: endpoint<
|
||||||
|
{ freightType?: FreightType },
|
||||||
|
TrainScheduleListItem[]
|
||||||
|
>(
|
||||||
"train-scheduling",
|
"train-scheduling",
|
||||||
"schedules",
|
"schedules",
|
||||||
({ freightType }) => trainSchedulingService.listSchedules(freightType),
|
({ freightType }) => trainSchedulingService.listSchedules(freightType),
|
||||||
@@ -211,11 +220,16 @@ export const api = {
|
|||||||
() => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
|
() => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
|
||||||
),
|
),
|
||||||
|
|
||||||
batchBoardDetail: endpoint<{ scheduleId: string }, BatchBoardScheduleDetail>(
|
batchBoardDetail: endpoint<
|
||||||
|
{ scheduleId: string },
|
||||||
|
BatchBoardScheduleDetail
|
||||||
|
>(
|
||||||
"train-scheduling",
|
"train-scheduling",
|
||||||
"batch-board-detail",
|
"batch-board-detail",
|
||||||
({ scheduleId }) => trainSchedulingService.getBatchBoardDetail(scheduleId),
|
({ scheduleId }) =>
|
||||||
({ scheduleId }) => QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
|
trainSchedulingService.getBatchBoardDetail(scheduleId),
|
||||||
|
({ scheduleId }) =>
|
||||||
|
QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
|
||||||
),
|
),
|
||||||
|
|
||||||
scheduleDetail: endpoint<
|
scheduleDetail: endpoint<
|
||||||
@@ -344,7 +358,10 @@ export const api = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// ── Mutations ──────────────────────────────────────────────────────────
|
// ── Mutations ──────────────────────────────────────────────────────────
|
||||||
runAllocation: endpoint<{ scheduleId: string }, WagonAllocationAttemptResult>(
|
runAllocation: endpoint<
|
||||||
|
{ scheduleId: string },
|
||||||
|
WagonAllocationAttemptResult
|
||||||
|
>(
|
||||||
"train-scheduling",
|
"train-scheduling",
|
||||||
"run-allocation",
|
"run-allocation",
|
||||||
({ scheduleId }) => trainSchedulingService.runAllocation(scheduleId),
|
({ scheduleId }) => trainSchedulingService.runAllocation(scheduleId),
|
||||||
@@ -462,7 +479,10 @@ export const api = {
|
|||||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
|
|
||||||
pinWagons: endpoint<{ id: string; payload: PinWagonsPayload }, TrainScheduleDetail>(
|
pinWagons: endpoint<
|
||||||
|
{ id: string; payload: PinWagonsPayload },
|
||||||
|
TrainScheduleDetail
|
||||||
|
>(
|
||||||
"train-scheduling",
|
"train-scheduling",
|
||||||
"pin-wagons",
|
"pin-wagons",
|
||||||
({ id, payload }) => trainSchedulingService.pinWagons(id, payload),
|
({ id, payload }) => trainSchedulingService.pinWagons(id, payload),
|
||||||
@@ -558,8 +578,10 @@ export const api = {
|
|||||||
({ id }) => warehouseService.getById(id).then((r) => r.data),
|
({ id }) => warehouseService.getById(id).then((r) => r.data),
|
||||||
),
|
),
|
||||||
|
|
||||||
dashboard: endpoint<void, WarehouseDashboard>("warehouses", "dashboard", () =>
|
dashboard: endpoint<void, WarehouseDashboard>(
|
||||||
warehouseService.dashboard().then((r) => r.data),
|
"warehouses",
|
||||||
|
"dashboard",
|
||||||
|
() => warehouseService.dashboard().then((r) => r.data),
|
||||||
),
|
),
|
||||||
|
|
||||||
create: endpoint<SaveWarehousePayload, Warehouse>(
|
create: endpoint<SaveWarehousePayload, Warehouse>(
|
||||||
@@ -650,10 +672,8 @@ export const api = {
|
|||||||
listInventory: endpoint<
|
listInventory: endpoint<
|
||||||
{ filter?: InventoryFilter },
|
{ filter?: InventoryFilter },
|
||||||
WarehouseInventoryItem[]
|
WarehouseInventoryItem[]
|
||||||
>(
|
>("warehouse-inventory", "list", ({ filter }) =>
|
||||||
"warehouse-inventory",
|
warehouseService.listInventory(filter).then((r) => r.data),
|
||||||
"list",
|
|
||||||
({ filter }) => warehouseService.listInventory(filter).then((r) => r.data),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
inquiry: endpoint<
|
inquiry: endpoint<
|
||||||
@@ -666,11 +686,19 @@ export const api = {
|
|||||||
({ filter }) => ["warehouse-inventory", "inquiry", filter],
|
({ filter }) => ["warehouse-inventory", "inquiry", filter],
|
||||||
),
|
),
|
||||||
|
|
||||||
eligibleBookings: endpoint<{ direction?: 'IMPORT' | 'EXPORT' } | void, EligibleBooking[]>(
|
eligibleBookings: endpoint<
|
||||||
|
{ direction?: "IMPORT" | "EXPORT" } | void,
|
||||||
|
EligibleBooking[]
|
||||||
|
>(
|
||||||
"warehouse-inventory",
|
"warehouse-inventory",
|
||||||
"eligible-bookings",
|
"eligible-bookings",
|
||||||
(input) => warehouseService.eligibleBookings(input?.direction).then((r) => r.data),
|
(input) =>
|
||||||
(input) => ["warehouse-inventory", "eligible-bookings", input?.direction ?? "ALL"],
|
warehouseService.eligibleBookings(input?.direction).then((r) => r.data),
|
||||||
|
(input) => [
|
||||||
|
"warehouse-inventory",
|
||||||
|
"eligible-bookings",
|
||||||
|
input?.direction ?? "ALL",
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
readyToLoadExport: endpoint<void, ReadyToLoadRow[]>(
|
readyToLoadExport: endpoint<void, ReadyToLoadRow[]>(
|
||||||
@@ -706,7 +734,11 @@ export const api = {
|
|||||||
"import-train-items",
|
"import-train-items",
|
||||||
({ scheduleId }) =>
|
({ scheduleId }) =>
|
||||||
warehouseService.importTrainItems(scheduleId).then((r) => r.data),
|
warehouseService.importTrainItems(scheduleId).then((r) => r.data),
|
||||||
({ scheduleId }) => ["warehouse-inventory", "import-train-items", scheduleId],
|
({ scheduleId }) => [
|
||||||
|
"warehouse-inventory",
|
||||||
|
"import-train-items",
|
||||||
|
scheduleId,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
importUnloadedQueue: endpoint<void, ImportUnloadedItem[]>(
|
importUnloadedQueue: endpoint<void, ImportUnloadedItem[]>(
|
||||||
@@ -774,8 +806,11 @@ export const api = {
|
|||||||
"inspection-reports",
|
"inspection-reports",
|
||||||
({ inventoryId }) =>
|
({ inventoryId }) =>
|
||||||
warehouseService.listInspectionReports(inventoryId).then((r) => r.data),
|
warehouseService.listInspectionReports(inventoryId).then((r) => r.data),
|
||||||
({ inventoryId }) =>
|
({ inventoryId }) => [
|
||||||
["warehouse-inventory", inventoryId, "inspection-reports"],
|
"warehouse-inventory",
|
||||||
|
inventoryId,
|
||||||
|
"inspection-reports",
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
allocationRules: endpoint<void, AllocationRule[]>(
|
allocationRules: endpoint<void, AllocationRule[]>(
|
||||||
@@ -792,15 +827,28 @@ export const api = {
|
|||||||
() => ["warehouse-fee-rules"],
|
() => ["warehouse-fee-rules"],
|
||||||
),
|
),
|
||||||
|
|
||||||
feePreview: endpoint<{ inventoryId: string; billingCurrency?: 'ETB' | 'USD' }, FeePreview[]>(
|
feePreview: endpoint<
|
||||||
|
{ inventoryId: string; billingCurrency?: "ETB" | "USD" },
|
||||||
|
FeePreview[]
|
||||||
|
>(
|
||||||
"warehouse-inventory",
|
"warehouse-inventory",
|
||||||
"fee-preview",
|
"fee-preview",
|
||||||
({ inventoryId, billingCurrency }) =>
|
({ inventoryId, billingCurrency }) =>
|
||||||
warehouseService.feePreview(inventoryId, billingCurrency).then((r) => r.data),
|
warehouseService
|
||||||
({ inventoryId, billingCurrency }) => ["warehouse-inventory", inventoryId, "fee-preview", billingCurrency ?? 'USD'],
|
.feePreview(inventoryId, billingCurrency)
|
||||||
|
.then((r) => r.data),
|
||||||
|
({ inventoryId, billingCurrency }) => [
|
||||||
|
"warehouse-inventory",
|
||||||
|
inventoryId,
|
||||||
|
"fee-preview",
|
||||||
|
billingCurrency ?? "USD",
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
invoices: endpoint<{ filter?: WarehouseInvoiceFilter }, WarehouseFeeInvoice[]>(
|
invoices: endpoint<
|
||||||
|
{ filter?: WarehouseInvoiceFilter },
|
||||||
|
WarehouseFeeInvoice[]
|
||||||
|
>(
|
||||||
"warehouse-fee-invoices",
|
"warehouse-fee-invoices",
|
||||||
"list",
|
"list",
|
||||||
({ filter }) => warehouseService.listInvoices(filter).then((r) => r.data),
|
({ filter }) => warehouseService.listInvoices(filter).then((r) => r.data),
|
||||||
@@ -829,7 +877,8 @@ export const api = {
|
|||||||
receiveInventory: endpoint<ReceiveInventoryPayload, WarehouseInventoryItem>(
|
receiveInventory: endpoint<ReceiveInventoryPayload, WarehouseInventoryItem>(
|
||||||
"warehouse-inventory",
|
"warehouse-inventory",
|
||||||
"receive",
|
"receive",
|
||||||
(payload) => warehouseService.receiveInventory(payload).then((r) => r.data),
|
(payload) =>
|
||||||
|
warehouseService.receiveInventory(payload).then((r) => r.data),
|
||||||
undefined,
|
undefined,
|
||||||
() => [["warehouse-inventory"], ["warehouses"]],
|
() => [["warehouse-inventory"], ["warehouses"]],
|
||||||
),
|
),
|
||||||
@@ -864,7 +913,8 @@ export const api = {
|
|||||||
>(
|
>(
|
||||||
"warehouse-inventory",
|
"warehouse-inventory",
|
||||||
"load",
|
"load",
|
||||||
({ id, payload }) => warehouseService.load(id, payload).then((r) => r.data),
|
({ id, payload }) =>
|
||||||
|
warehouseService.load(id, payload).then((r) => r.data),
|
||||||
undefined,
|
undefined,
|
||||||
() => INVENTORY_INVALIDATIONS,
|
() => INVENTORY_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
@@ -883,7 +933,8 @@ export const api = {
|
|||||||
>(
|
>(
|
||||||
"warehouse-inventory",
|
"warehouse-inventory",
|
||||||
"move",
|
"move",
|
||||||
({ id, payload }) => warehouseService.move(id, payload).then((r) => r.data),
|
({ id, payload }) =>
|
||||||
|
warehouseService.move(id, payload).then((r) => r.data),
|
||||||
undefined,
|
undefined,
|
||||||
() => INVENTORY_INVALIDATIONS,
|
() => INVENTORY_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
@@ -939,7 +990,8 @@ export const api = {
|
|||||||
bulkMarkInspected: endpoint<BulkInspectPayload, BulkInspectResult>(
|
bulkMarkInspected: endpoint<BulkInspectPayload, BulkInspectResult>(
|
||||||
"warehouse-inventory",
|
"warehouse-inventory",
|
||||||
"bulk-mark-inspected",
|
"bulk-mark-inspected",
|
||||||
(payload) => warehouseService.bulkMarkInspected(payload).then((r) => r.data),
|
(payload) =>
|
||||||
|
warehouseService.bulkMarkInspected(payload).then((r) => r.data),
|
||||||
undefined,
|
undefined,
|
||||||
() => INVENTORY_INVALIDATIONS,
|
() => INVENTORY_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
@@ -957,7 +1009,12 @@ export const api = {
|
|||||||
{
|
{
|
||||||
scheduleId: string;
|
scheduleId: string;
|
||||||
warehouseId?: string;
|
warehouseId?: string;
|
||||||
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
assignments?: {
|
||||||
|
bookingId: string;
|
||||||
|
warehouseId: string;
|
||||||
|
yardId: string;
|
||||||
|
zoneId: string;
|
||||||
|
}[];
|
||||||
},
|
},
|
||||||
AutoUnloadArrivedResult
|
AutoUnloadArrivedResult
|
||||||
>(
|
>(
|
||||||
@@ -1029,11 +1086,11 @@ export const api = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// ── Allocation + fee rules ─────────────────────────────────────────────
|
// ── Allocation + fee rules ─────────────────────────────────────────────
|
||||||
previewAllocation: endpoint<AllocationCriteria, AllocationPreviewResult | null>(
|
previewAllocation: endpoint<
|
||||||
"warehouse-allocation-rules",
|
AllocationCriteria,
|
||||||
"preview",
|
AllocationPreviewResult | null
|
||||||
(criteria) =>
|
>("warehouse-allocation-rules", "preview", (criteria) =>
|
||||||
warehouseService.previewAllocation(criteria).then((r) => r.data),
|
warehouseService.previewAllocation(criteria).then((r) => r.data),
|
||||||
),
|
),
|
||||||
|
|
||||||
createAllocationRule: endpoint<SaveAllocationRulePayload, AllocationRule>(
|
createAllocationRule: endpoint<SaveAllocationRulePayload, AllocationRule>(
|
||||||
@@ -1095,7 +1152,11 @@ export const api = {
|
|||||||
|
|
||||||
// ── Invoices ───────────────────────────────────────────────────────────
|
// ── Invoices ───────────────────────────────────────────────────────────
|
||||||
generateInvoice: endpoint<
|
generateInvoice: endpoint<
|
||||||
{ inventoryId: string; confirmZero?: boolean; billingCurrency?: 'ETB' | 'USD' },
|
{
|
||||||
|
inventoryId: string;
|
||||||
|
confirmZero?: boolean;
|
||||||
|
billingCurrency?: "ETB" | "USD";
|
||||||
|
},
|
||||||
WarehouseFeeInvoice
|
WarehouseFeeInvoice
|
||||||
>(
|
>(
|
||||||
"warehouse-fee-invoices",
|
"warehouse-fee-invoices",
|
||||||
@@ -1151,7 +1212,10 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
routes: {
|
routes: {
|
||||||
list: endpoint<{ status?: import("./routes.service").RouteStatus } | void, RouteRecord[]>(
|
list: endpoint<
|
||||||
|
{ status?: import("./routes.service").RouteStatus } | void,
|
||||||
|
RouteRecord[]
|
||||||
|
>(
|
||||||
"routes",
|
"routes",
|
||||||
"list",
|
"list",
|
||||||
(input) =>
|
(input) =>
|
||||||
@@ -1176,7 +1240,10 @@ export const api = {
|
|||||||
() => [["routes"]],
|
() => [["routes"]],
|
||||||
),
|
),
|
||||||
|
|
||||||
update: endpoint<{ id: string; data: Partial<SaveRoutePayload> }, RouteRecord>(
|
update: endpoint<
|
||||||
|
{ id: string; data: Partial<SaveRoutePayload> },
|
||||||
|
RouteRecord
|
||||||
|
>(
|
||||||
"routes",
|
"routes",
|
||||||
"update",
|
"update",
|
||||||
({ id, data }) => routesService.update(id, data).then((r) => r.data),
|
({ id, data }) => routesService.update(id, data).then((r) => r.data),
|
||||||
@@ -1289,10 +1356,8 @@ export const api = {
|
|||||||
({ trainId }) => ["wagons", "train", trainId],
|
({ trainId }) => ["wagons", "train", trainId],
|
||||||
),
|
),
|
||||||
|
|
||||||
getById: endpoint<{ id: string }, Wagon>(
|
getById: endpoint<{ id: string }, Wagon>("wagons", "getById", ({ id }) =>
|
||||||
"wagons",
|
wagonService.getById(id).then((r) => r.data),
|
||||||
"getById",
|
|
||||||
({ id }) => wagonService.getById(id).then((r) => r.data),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
assignToTrain: endpoint<
|
assignToTrain: endpoint<
|
||||||
@@ -1671,7 +1736,8 @@ export const api = {
|
|||||||
>(
|
>(
|
||||||
"file-upload-settings",
|
"file-upload-settings",
|
||||||
"addField",
|
"addField",
|
||||||
({ settingId, dto }) => fileUploadSettingsService.addField(settingId, dto),
|
({ settingId, dto }) =>
|
||||||
|
fileUploadSettingsService.addField(settingId, dto),
|
||||||
undefined,
|
undefined,
|
||||||
() => [["file-upload-settings"]],
|
() => [["file-upload-settings"]],
|
||||||
),
|
),
|
||||||
@@ -1770,7 +1836,8 @@ export const api = {
|
|||||||
>(
|
>(
|
||||||
"dropdown-settings",
|
"dropdown-settings",
|
||||||
"updateOption",
|
"updateOption",
|
||||||
({ optionId, dto }) => dropdownSettingsService.updateOption(optionId, dto),
|
({ optionId, dto }) =>
|
||||||
|
dropdownSettingsService.updateOption(optionId, dto),
|
||||||
undefined,
|
undefined,
|
||||||
() => [["dropdown-settings"]],
|
() => [["dropdown-settings"]],
|
||||||
),
|
),
|
||||||
@@ -1823,11 +1890,10 @@ export const api = {
|
|||||||
ruleEngineService.update(resource, id, payload),
|
ruleEngineService.update(resource, id, payload),
|
||||||
),
|
),
|
||||||
|
|
||||||
remove: endpoint<
|
remove: endpoint<{ resource: RuleEngineResourceSlug; id: string }, void>(
|
||||||
{ resource: RuleEngineResourceSlug; id: string },
|
"rule-engine",
|
||||||
void
|
"remove",
|
||||||
>("rule-engine", "remove", ({ resource, id }) =>
|
({ resource, id }) => ruleEngineService.remove(resource, id),
|
||||||
ruleEngineService.remove(resource, id),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
submitRate: endpoint<{ id: string }, RuleEngineRecord>(
|
submitRate: endpoint<{ id: string }, RuleEngineRecord>(
|
||||||
@@ -1850,14 +1916,21 @@ export const api = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
reorder: endpoint<
|
reorder: endpoint<
|
||||||
{ resource: RuleEngineResourceSlug; payload: { ids: string[]; requiresDirectorApproval?: boolean } },
|
{
|
||||||
|
resource: RuleEngineResourceSlug;
|
||||||
|
payload: { ids: string[]; requiresDirectorApproval?: boolean };
|
||||||
|
},
|
||||||
void
|
void
|
||||||
>("rule-engine", "reorder", ({ resource, payload }) =>
|
>("rule-engine", "reorder", ({ resource, payload }) =>
|
||||||
ruleEngineService.reorder(resource, payload),
|
ruleEngineService.reorder(resource, payload),
|
||||||
),
|
),
|
||||||
|
|
||||||
moveOrder: endpoint<
|
moveOrder: endpoint<
|
||||||
{ resource: RuleEngineResourceSlug; id: string; direction: "up" | "down" },
|
{
|
||||||
|
resource: RuleEngineResourceSlug;
|
||||||
|
id: string;
|
||||||
|
direction: "up" | "down";
|
||||||
|
},
|
||||||
void
|
void
|
||||||
>("rule-engine", "moveOrder", ({ resource, id, direction }) =>
|
>("rule-engine", "moveOrder", ({ resource, id, direction }) =>
|
||||||
ruleEngineService.moveOrder(resource, id, direction),
|
ruleEngineService.moveOrder(resource, id, direction),
|
||||||
@@ -1879,10 +1952,8 @@ export const api = {
|
|||||||
({ id }) => QUERY_KEYS.BOOKINGS.byId(id),
|
({ id }) => QUERY_KEYS.BOOKINGS.byId(id),
|
||||||
),
|
),
|
||||||
|
|
||||||
remove: endpoint<{ id: string }, void>(
|
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
|
||||||
"bookings",
|
bookingsService.remove(id),
|
||||||
"remove",
|
|
||||||
({ id }) => bookingsService.remove(id),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
staffAccept: endpoint<{ id: string; validityDays: number }, BookingDetail>(
|
staffAccept: endpoint<{ id: string; validityDays: number }, BookingDetail>(
|
||||||
@@ -1932,10 +2003,11 @@ export const api = {
|
|||||||
({ id }) => bookingsService.generateContract(id),
|
({ id }) => bookingsService.generateContract(id),
|
||||||
),
|
),
|
||||||
|
|
||||||
getContractView: endpoint<{ id: string }, import("./bookings.service").ContractView>(
|
getContractView: endpoint<
|
||||||
"bookings",
|
{ id: string },
|
||||||
"getContractView",
|
import("./bookings.service").ContractView
|
||||||
({ id }) => bookingsService.getContractView(id),
|
>("bookings", "getContractView", ({ id }) =>
|
||||||
|
bookingsService.getContractView(id),
|
||||||
),
|
),
|
||||||
|
|
||||||
signContract: endpoint<
|
signContract: endpoint<
|
||||||
@@ -2019,7 +2091,8 @@ export const api = {
|
|||||||
>(
|
>(
|
||||||
"customers",
|
"customers",
|
||||||
"setProfileStatus",
|
"setProfileStatus",
|
||||||
({ profileId, status }) => customersService.setProfileStatus(profileId, status),
|
({ profileId, status }) =>
|
||||||
|
customersService.setProfileStatus(profileId, status),
|
||||||
undefined,
|
undefined,
|
||||||
(_input, data) => [
|
(_input, data) => [
|
||||||
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
||||||
@@ -2040,6 +2113,22 @@ export const api = {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
invoices: {
|
||||||
|
list: endpoint<{ filter: InvoiceListFilter }, PaginatedInvoices>(
|
||||||
|
"invoices",
|
||||||
|
"list",
|
||||||
|
({ filter }) => invoicesService.list(filter),
|
||||||
|
({ filter }) => QUERY_KEYS.INVOICES.list(filter),
|
||||||
|
),
|
||||||
|
|
||||||
|
getById: endpoint<{ id: string }, Invoice>(
|
||||||
|
"invoices",
|
||||||
|
"getById",
|
||||||
|
({ id }) => invoicesService.getById(id),
|
||||||
|
({ id }) => QUERY_KEYS.INVOICES.byId(id),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
overview: {
|
overview: {
|
||||||
get: endpoint<{ range?: OverviewRange }, IOverviewDashboard>(
|
get: endpoint<{ range?: OverviewRange }, IOverviewDashboard>(
|
||||||
"overview",
|
"overview",
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { api as apiClient } from "@/auth/http";
|
||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import type {
|
||||||
|
Invoice,
|
||||||
|
InvoiceListFilter,
|
||||||
|
PaginatedInvoices,
|
||||||
|
} from "@/types/invoice";
|
||||||
|
|
||||||
|
const cleanParams = (params: object) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(params).filter(
|
||||||
|
([, value]) => value !== undefined && value !== "" && value !== null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const invoicesService = {
|
||||||
|
list(filter: InvoiceListFilter): Promise<PaginatedInvoices> {
|
||||||
|
return apiClient
|
||||||
|
.get<PaginatedInvoices>(URL_CONSTANTS.BILLING.INVOICES, {
|
||||||
|
params: cleanParams(filter),
|
||||||
|
})
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getById(id: string): Promise<Invoice> {
|
||||||
|
return apiClient
|
||||||
|
.get<Invoice>(URL_CONSTANTS.BILLING.INVOICE_BY_ID(id))
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadDocument(id: string) {
|
||||||
|
return apiClient.get<Blob>(URL_CONSTANTS.BILLING.INVOICE_DOCUMENT(id), {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -32,6 +32,14 @@ export type ProfileType =
|
|||||||
/** Mirrors backend `ProfileStatus`. */
|
/** Mirrors backend `ProfileStatus`. */
|
||||||
export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted";
|
export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted";
|
||||||
|
|
||||||
|
/** A business-license document uploaded for a company profile. */
|
||||||
|
export interface LicenseFile {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
mimeType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** A single role a company is registered for, with its reference code. */
|
/** A single role a company is registered for, with its reference code. */
|
||||||
export interface CompanyProfile {
|
export interface CompanyProfile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -39,7 +47,10 @@ export interface CompanyProfile {
|
|||||||
type: ProfileType;
|
type: ProfileType;
|
||||||
reference: string;
|
reference: string;
|
||||||
status: ProfileStatus;
|
status: ProfileStatus;
|
||||||
|
/** @deprecated Superseded by licenseFiles (file model). */
|
||||||
businessLicense?: string | null;
|
businessLicense?: string | null;
|
||||||
|
/** Business-license documents uploaded for this profile. */
|
||||||
|
licenseFiles?: LicenseFile[];
|
||||||
attributes?: Record<string, unknown> | null;
|
attributes?: Record<string, unknown> | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
22
apps/edr-freight-web/backoffice/src/types/invoice.ts
Normal file
22
apps/edr-freight-web/backoffice/src/types/invoice.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
/** Mirrors backend `Invoice` (the shared `Freight.IInvoice` omits a couple of raw entity columns). */
|
||||||
|
export interface Invoice extends Freight.IInvoice {
|
||||||
|
subtotalAmount: number;
|
||||||
|
taxAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query parameters for the invoice list. */
|
||||||
|
export interface InvoiceListFilter {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
companyId?: string;
|
||||||
|
status?: Freight.InvoiceStatus;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standard paginated list envelope (matches the customers/bookings service shape). */
|
||||||
|
export interface PaginatedInvoices {
|
||||||
|
items: Invoice[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
@@ -1,24 +1,3 @@
|
|||||||
import { useCallback, useState } from "react";
|
// Re-export of the shared hook, now living in @edr/ui-common. Kept so existing
|
||||||
import { FileViewerModal, type ViewableFile } from "@edr/ui-common";
|
// `@/hooks/useFileViewer` imports keep working.
|
||||||
|
export { useFileViewer } from "@edr/ui-common";
|
||||||
/**
|
|
||||||
* Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
|
|
||||||
* from any file row to open the document inline (pdf / image / video / office /
|
|
||||||
* text); render `viewer` once near the page root.
|
|
||||||
*
|
|
||||||
* const { view, viewer } = useFileViewer();
|
|
||||||
* <Button onClick={() => view({ name, url, mimeType })}>View</Button>
|
|
||||||
* {viewer}
|
|
||||||
*/
|
|
||||||
export function useFileViewer() {
|
|
||||||
const [file, setFile] = useState<ViewableFile | null>(null);
|
|
||||||
|
|
||||||
const view = useCallback((f: ViewableFile) => setFile(f), []);
|
|
||||||
const close = useCallback(() => setFile(null), []);
|
|
||||||
|
|
||||||
const viewer = (
|
|
||||||
<FileViewerModal open={file !== null} file={file} onClose={close} />
|
|
||||||
);
|
|
||||||
|
|
||||||
return { view, close, viewer };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { companiesService } from "@/services/companies.service";
|
import { companiesService } from "@/services/companies.service";
|
||||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
import { SmartFileInput } from "@edr/ui-common";
|
import { SmartFileInput, useFileViewer } from "@edr/ui-common";
|
||||||
import {
|
import {
|
||||||
|
Anchor,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Group,
|
Group,
|
||||||
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
@@ -17,10 +20,19 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Paperclip,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
importer: "Importer",
|
||||||
|
exporter: "Exporter",
|
||||||
|
freight_forwarder: "Freight Forwarder",
|
||||||
|
dj_freight_forwarder: "DJ Freight Forwarder",
|
||||||
|
transporter: "Transporter",
|
||||||
|
};
|
||||||
|
|
||||||
interface TabDocumentsProps {
|
interface TabDocumentsProps {
|
||||||
profile: ProfileResponse;
|
profile: ProfileResponse;
|
||||||
@@ -28,21 +40,63 @@ interface TabDocumentsProps {
|
|||||||
onContinue?: () => void;
|
onContinue?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TabDocuments({ profile, mode = "edit", onContinue }: TabDocumentsProps) {
|
function documentSettingCode(nationality: string | null | undefined): string {
|
||||||
|
return nationality === "foreign"
|
||||||
|
? "company_onboarding_documents_foreign"
|
||||||
|
: "company_onboarding_documents_ethiopian";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TabDocuments({
|
||||||
|
profile,
|
||||||
|
mode = "edit",
|
||||||
|
onContinue,
|
||||||
|
}: TabDocumentsProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({});
|
const { view, viewer } = useFileViewer();
|
||||||
|
const [documentFiles, setDocumentFiles] = useState<
|
||||||
|
Record<string, File | File[] | null>
|
||||||
|
>({});
|
||||||
|
|
||||||
const docSettingQuery = useQuery(
|
const docSettingQuery = useQuery(
|
||||||
api.fileUploadSettings.getByCode.queryOptions({
|
api.fileUploadSettings.getByCode.queryOptions({
|
||||||
input: { code: "customer_file_documents" },
|
input: { code: documentSettingCode(profile.nationality) },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const docsQuery = useQuery(
|
||||||
|
api.companies.documents.queryOptions({
|
||||||
|
input: { companyId: profile.companyId },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadedKeys = useMemo(
|
||||||
|
() => (docsQuery.data ?? []).map((d) => d.code),
|
||||||
|
[docsQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingFilesByKey = useMemo(() => {
|
||||||
|
const map: Record<
|
||||||
|
string,
|
||||||
|
{ name: string; url: string; size?: number; mimeType?: string | null }[]
|
||||||
|
> = {};
|
||||||
|
for (const doc of docsQuery.data ?? []) {
|
||||||
|
(map[doc.code] ??= []).push({
|
||||||
|
name: doc.name,
|
||||||
|
url: fileViewUrl(doc.id),
|
||||||
|
size: doc.size,
|
||||||
|
mimeType: doc.mimeType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [docsQuery.data]);
|
||||||
|
|
||||||
const docUploadMutation = useMutation({
|
const docUploadMutation = useMutation({
|
||||||
mutationFn: (files: Record<string, File | File[] | null>) =>
|
mutationFn: (files: Record<string, File | File[] | null>) =>
|
||||||
companiesService.uploadDocuments(profile.companyId, files),
|
companiesService.uploadDocuments(profile.companyId, files),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.getProfile.queryKey(),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,6 +127,7 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
|
|||||||
for (const field of docSettingQuery.data?.fields ?? []) {
|
for (const field of docSettingQuery.data?.fields ?? []) {
|
||||||
const min = getMinFiles(field);
|
const min = getMinFiles(field);
|
||||||
if (min <= 0) continue;
|
if (min <= 0) continue;
|
||||||
|
if (uploadedKeys.includes(field.fileKey)) continue;
|
||||||
const v = documentFiles[field.fileKey];
|
const v = documentFiles[field.fileKey];
|
||||||
const count = Array.isArray(v) ? v.length : v ? 1 : 0;
|
const count = Array.isArray(v) ? v.length : v ? 1 : 0;
|
||||||
if (count < min) {
|
if (count < min) {
|
||||||
@@ -82,94 +137,139 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
|
|||||||
return errs;
|
return errs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const licenseProfiles = profile.companyProfiles.filter(
|
||||||
|
(p) => p.licenseFiles && p.licenseFiles.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="lg">
|
<>
|
||||||
<Group gap="sm" mb="xs">
|
<Card padding="lg">
|
||||||
<FileCheck size={20} />
|
<Group gap="sm" mb="xs">
|
||||||
<Title order={3}>Documents</Title>
|
<FileCheck size={20} />
|
||||||
</Group>
|
<Title order={3}>Documents</Title>
|
||||||
<Text c="edr-muted" size="sm" mb="lg">
|
</Group>
|
||||||
Upload and manage required business documents
|
<Text c="edr-muted" size="sm" mb="lg">
|
||||||
</Text>
|
Upload and manage required business documents
|
||||||
|
|
||||||
{docSettingQuery.isLoading ? (
|
|
||||||
<Center py="xl">
|
|
||||||
<Loader2 size={24} className="animate-spin" />
|
|
||||||
</Center>
|
|
||||||
) : !docSettingQuery.data ? (
|
|
||||||
<Text c="edr-muted" size="sm" ta="center" py="md">
|
|
||||||
No document requirements configured for your account.
|
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
|
||||||
<SmartFileInput
|
|
||||||
file={docSettingQuery.data}
|
|
||||||
value={documentFiles}
|
|
||||||
onChange={handleFilesChange}
|
|
||||||
errors={fieldErrors}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{docSettingQuery.data && (
|
{docSettingQuery.isLoading ? (
|
||||||
<Group
|
<Center py="xl">
|
||||||
justify="space-between"
|
<Loader2 size={24} className="animate-spin" />
|
||||||
mt="lg"
|
</Center>
|
||||||
pt="md"
|
) : !docSettingQuery.data ? (
|
||||||
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
<Text c="edr-muted" size="sm" ta="center" py="md">
|
||||||
>
|
No document requirements configured for your account.
|
||||||
<Group gap="xs">
|
</Text>
|
||||||
{docUploadMutation.isSuccess && (
|
) : (
|
||||||
<Group gap={6} c="green">
|
<SmartFileInput
|
||||||
<CheckCircle2 size={16} />
|
file={docSettingQuery.data}
|
||||||
<Text size="sm" fw={500}>
|
value={documentFiles}
|
||||||
{mode === "onboarding" ? "Saved successfully" : "Documents uploaded successfully"}
|
onChange={handleFilesChange}
|
||||||
</Text>
|
errors={fieldErrors}
|
||||||
</Group>
|
uploadedKeys={uploadedKeys}
|
||||||
)}
|
existingFiles={existingFilesByKey}
|
||||||
{docUploadMutation.isError && (
|
onViewFile={view}
|
||||||
<Group gap={6} c="red">
|
/>
|
||||||
<XCircle size={16} />
|
)}
|
||||||
<Text size="sm" fw={500}>Upload failed</Text>
|
|
||||||
</Group>
|
{docSettingQuery.data && (
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
mt="lg"
|
||||||
|
pt="md"
|
||||||
|
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||||
|
>
|
||||||
|
<Group gap="xs">
|
||||||
|
{docUploadMutation.isSuccess && (
|
||||||
|
<Group gap={6} c="green">
|
||||||
|
<CheckCircle2 size={16} />
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{mode === "onboarding"
|
||||||
|
? "Saved successfully"
|
||||||
|
: "Documents uploaded successfully"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{docUploadMutation.isError && (
|
||||||
|
<Group gap={6} c="red">
|
||||||
|
<XCircle size={16} />
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
Upload failed
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{mode === "onboarding" ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
leftSection={<ArrowRight size={16} />}
|
||||||
|
loading={docUploadMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const validationErrors = validateRequired();
|
||||||
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
setFieldErrors(validationErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasFiles) {
|
||||||
|
docUploadMutation.mutate(documentFiles, {
|
||||||
|
onSuccess: () => onContinue?.(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onContinue?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
leftSection={<UploadCloud size={16} />}
|
||||||
|
loading={docUploadMutation.isPending}
|
||||||
|
disabled={!hasFiles}
|
||||||
|
onClick={() => {
|
||||||
|
if (!hasFiles) return;
|
||||||
|
docUploadMutation.mutate(documentFiles);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Upload Documents
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
{mode === "onboarding" ? (
|
)}
|
||||||
<Button
|
</Card>
|
||||||
type="button"
|
|
||||||
leftSection={<ArrowRight size={16} />}
|
{licenseProfiles.length > 0 && (
|
||||||
loading={docUploadMutation.isPending}
|
<Card padding="lg" mt="lg">
|
||||||
onClick={() => {
|
<Group gap="sm" mb="xs">
|
||||||
const validationErrors = validateRequired();
|
<Paperclip size={20} />
|
||||||
if (Object.keys(validationErrors).length > 0) {
|
<Title order={3}>Business licenses</Title>
|
||||||
setFieldErrors(validationErrors);
|
</Group>
|
||||||
return;
|
<Text c="edr-muted" size="sm" mb="lg">
|
||||||
}
|
License documents uploaded per operational profile
|
||||||
if (hasFiles) {
|
</Text>
|
||||||
docUploadMutation.mutate(documentFiles, {
|
|
||||||
onSuccess: () => onContinue?.(),
|
<Stack gap="md">
|
||||||
});
|
{licenseProfiles.map((p) => (
|
||||||
} else {
|
<Stack key={p.id} gap={4}>
|
||||||
onContinue?.();
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
}
|
{ROLE_LABELS[p.type] ?? p.type} · {p.reference}
|
||||||
}}
|
</Text>
|
||||||
>
|
{p.licenseFiles.map((f) => (
|
||||||
Continue
|
<Group key={f.url} gap={6} wrap="nowrap">
|
||||||
</Button>
|
<Paperclip size={13} className="text-edr-muted" />
|
||||||
) : (
|
<Text component="button" type="button" size="xs">
|
||||||
<Button
|
{f.name}
|
||||||
type="button"
|
</Text>
|
||||||
leftSection={<UploadCloud size={16} />}
|
</Group>
|
||||||
loading={docUploadMutation.isPending}
|
))}
|
||||||
disabled={!hasFiles}
|
</Stack>
|
||||||
onClick={() => {
|
))}
|
||||||
if (!hasFiles) return;
|
</Stack>
|
||||||
docUploadMutation.mutate(documentFiles);
|
</Card>
|
||||||
}}
|
|
||||||
>
|
|
||||||
Upload Documents
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
|
||||||
|
{viewer}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
UpdateDropdownSettingDto,
|
UpdateDropdownSettingDto,
|
||||||
} from "@/types/dropdownSettings";
|
} from "@/types/dropdownSettings";
|
||||||
import type {
|
import type {
|
||||||
|
CompanyDocument,
|
||||||
CompanyInfoResponse,
|
CompanyInfoResponse,
|
||||||
CompanyNationality,
|
CompanyNationality,
|
||||||
CompanyProfileResponse,
|
CompanyProfileResponse,
|
||||||
@@ -158,7 +159,11 @@ export const api = {
|
|||||||
createCompanyProfile: endpoint<
|
createCompanyProfile: endpoint<
|
||||||
{ type: ProfileTypeValue; businessLicense?: string },
|
{ type: ProfileTypeValue; businessLicense?: string },
|
||||||
CompanyProfileResponse
|
CompanyProfileResponse
|
||||||
>("companies", "createCompanyProfile", companiesService.createCompanyProfile),
|
>(
|
||||||
|
"companies",
|
||||||
|
"createCompanyProfile",
|
||||||
|
companiesService.createCompanyProfile,
|
||||||
|
),
|
||||||
|
|
||||||
startOnboarding: endpoint<
|
startOnboarding: endpoint<
|
||||||
{
|
{
|
||||||
@@ -192,6 +197,12 @@ export const api = {
|
|||||||
"onboardingRequirements",
|
"onboardingRequirements",
|
||||||
companiesService.getOnboardingRequirements,
|
companiesService.getOnboardingRequirements,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
documents: endpoint<{ companyId: string }, CompanyDocument[]>(
|
||||||
|
"companies",
|
||||||
|
"documents",
|
||||||
|
({ companyId }) => companiesService.getDocuments(companyId),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
bookings: {
|
bookings: {
|
||||||
@@ -228,7 +239,8 @@ export const api = {
|
|||||||
downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>(
|
downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>(
|
||||||
"bookings",
|
"bookings",
|
||||||
"downloadHandoverDocument",
|
"downloadHandoverDocument",
|
||||||
({ inventoryId }) => bookingsService.downloadHandoverDocument(inventoryId),
|
({ inventoryId }) =>
|
||||||
|
bookingsService.downloadHandoverDocument(inventoryId),
|
||||||
),
|
),
|
||||||
|
|
||||||
create: endpoint<
|
create: endpoint<
|
||||||
@@ -312,11 +324,8 @@ export const api = {
|
|||||||
proceedToOperation: endpoint<
|
proceedToOperation: endpoint<
|
||||||
{ id: string; scheduledDate: string },
|
{ id: string; scheduledDate: string },
|
||||||
Freight.IBooking
|
Freight.IBooking
|
||||||
>(
|
>("bookings", "proceedToOperation", ({ id, scheduledDate }) =>
|
||||||
"bookings",
|
bookingsService.proceedToOperation(id, scheduledDate),
|
||||||
"proceedToOperation",
|
|
||||||
({ id, scheduledDate }) =>
|
|
||||||
bookingsService.proceedToOperation(id, scheduledDate),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
checkPayment: endpoint<{ orderId: string }, { status: string }>(
|
checkPayment: endpoint<{ orderId: string }, { status: string }>(
|
||||||
@@ -354,10 +363,11 @@ export const api = {
|
|||||||
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
|
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
|
||||||
),
|
),
|
||||||
|
|
||||||
getAvailableDaysForCargo: endpoint<Freight.AvailableDaysForCargoQuery, string[]>(
|
getAvailableDaysForCargo: endpoint<
|
||||||
"train-scheduling",
|
Freight.AvailableDaysForCargoQuery,
|
||||||
"availableDaysForCargo",
|
string[]
|
||||||
(input) => bookingsService.getAvailableDaysForCargo(input),
|
>("train-scheduling", "availableDaysForCargo", (input) =>
|
||||||
|
bookingsService.getAvailableDaysForCargo(input),
|
||||||
),
|
),
|
||||||
|
|
||||||
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
|
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
|
||||||
|
|||||||
@@ -82,6 +82,18 @@ export interface CompanyInfoResponse {
|
|||||||
company: CompanyResponse;
|
company: CompanyResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A single company-level document uploaded against a `file_upload_settings` field. */
|
||||||
|
export interface CompanyDocument {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** The `fileKey` of the setting field it was uploaded against. */
|
||||||
|
code: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
uploadedAt: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** A single onboarding document field, as resolved and described by the backend. */
|
/** A single onboarding document field, as resolved and described by the backend. */
|
||||||
export interface OnboardingDocumentField {
|
export interface OnboardingDocumentField {
|
||||||
fileKey: string;
|
fileKey: string;
|
||||||
@@ -124,7 +136,12 @@ export interface OnboardingRequirements {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyProfileInput {
|
export interface CompanyProfileInput {
|
||||||
type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter";
|
type:
|
||||||
|
| "importer"
|
||||||
|
| "exporter"
|
||||||
|
| "freight_forwarder"
|
||||||
|
| "dj_freight_forwarder"
|
||||||
|
| "transporter";
|
||||||
businessLicense?: string;
|
businessLicense?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +197,9 @@ export const companiesService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
create: async (payload: CreateCompanyPayload): Promise<CompanyInfoResponse> => {
|
create: async (
|
||||||
|
payload: CreateCompanyPayload,
|
||||||
|
): Promise<CompanyInfoResponse> => {
|
||||||
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
|
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
|
||||||
URL_CONSTANTS.COMPANIES_API.CREATE,
|
URL_CONSTANTS.COMPANIES_API.CREATE,
|
||||||
payload,
|
payload,
|
||||||
@@ -195,7 +214,9 @@ export const companiesService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateProfile: async (payload: UpdateProfilePayload): Promise<ProfileResponse> => {
|
updateProfile: async (
|
||||||
|
payload: UpdateProfilePayload,
|
||||||
|
): Promise<ProfileResponse> => {
|
||||||
const response = await client.patch<ApiResponse<ProfileResponse>>(
|
const response = await client.patch<ApiResponse<ProfileResponse>>(
|
||||||
URL_CONSTANTS.COMPANIES_API.PROFILE,
|
URL_CONSTANTS.COMPANIES_API.PROFILE,
|
||||||
payload,
|
payload,
|
||||||
@@ -293,7 +314,18 @@ export const companiesService = {
|
|||||||
formData.append(fieldName, fileOrFiles);
|
formData.append(fieldName, fileOrFiles);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await client.post(URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), formData);
|
await client.post(
|
||||||
|
URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId),
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** List documents already uploaded for a company (settings-driven, by fileKey). */
|
||||||
|
getDocuments: async (companyId: string): Promise<CompanyDocument[]> => {
|
||||||
|
const response = await client.get<ApiResponse<CompanyDocument[]>>(
|
||||||
|
URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId),
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Upload business-license document(s) for a company profile (multi-file). */
|
/** Upload business-license document(s) for a company profile (multi-file). */
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
|
import type { ViewableFile } from "../FileViewer";
|
||||||
|
|
||||||
export interface SmartFileInputProps {
|
export interface SmartFileInputProps {
|
||||||
/** The settings object containing features and their upload fields config. */
|
/** The settings object containing features and their upload fields config. */
|
||||||
@@ -27,6 +28,26 @@ export interface SmartFileInputProps {
|
|||||||
* no in-memory File is currently selected for them.
|
* no in-memory File is currently selected for them.
|
||||||
*/
|
*/
|
||||||
uploadedKeys?: string[];
|
uploadedKeys?: string[];
|
||||||
|
/**
|
||||||
|
* Metadata for already-uploaded files, keyed by fileKey. When a field has
|
||||||
|
* entries here, they're listed with view/download links (instead of the
|
||||||
|
* generic placeholder text) and the field counts as uploaded even if it's
|
||||||
|
* not also listed in `uploadedKeys`.
|
||||||
|
*/
|
||||||
|
existingFiles?: Record<
|
||||||
|
string,
|
||||||
|
{ name: string; url: string; size?: number; mimeType?: string | null }[]
|
||||||
|
>;
|
||||||
|
/**
|
||||||
|
* When provided, already-uploaded files render as buttons that call this with
|
||||||
|
* the file instead of opening a new browser tab. Wire it to `useFileViewer`'s
|
||||||
|
* `view` to preview documents inline:
|
||||||
|
*
|
||||||
|
* const { view, viewer } = useFileViewer();
|
||||||
|
* <SmartFileInput ... onViewFile={view} />
|
||||||
|
* {viewer}
|
||||||
|
*/
|
||||||
|
onViewFile?: (file: ViewableFile) => void;
|
||||||
/** Disabled state for the entire file input group. */
|
/** Disabled state for the entire file input group. */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */
|
/** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */
|
||||||
@@ -70,12 +91,76 @@ function FileIcon({ name, className }: { name: string; className?: string }) {
|
|||||||
return <File className={cn("text-slate-400", className)} />;
|
return <File className={cn("text-slate-400", className)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExistingFile = {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size?: number;
|
||||||
|
mimeType?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single already-uploaded file. Renders a click-to-view button when
|
||||||
|
* `onViewFile` is set (inline preview via the FileViewer), otherwise a plain
|
||||||
|
* new-tab anchor.
|
||||||
|
*/
|
||||||
|
function ExistingFileLink({
|
||||||
|
file: f,
|
||||||
|
onViewFile,
|
||||||
|
className,
|
||||||
|
showSize = false,
|
||||||
|
}: {
|
||||||
|
file: ExistingFile;
|
||||||
|
onViewFile?: (file: ViewableFile) => void;
|
||||||
|
className?: string;
|
||||||
|
showSize?: boolean;
|
||||||
|
}) {
|
||||||
|
const label =
|
||||||
|
showSize && typeof f.size === "number"
|
||||||
|
? `${f.name} (${formatBytes(f.size)})`
|
||||||
|
: f.name;
|
||||||
|
|
||||||
|
if (onViewFile) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewFile({ name: f.name, url: f.url, mimeType: f.mimeType });
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 text-left text-xs text-primary hover:underline truncate max-w-xs",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={f.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 text-xs text-primary hover:underline truncate max-w-xs",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SmartFileInput({
|
export function SmartFileInput({
|
||||||
file,
|
file,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
errors,
|
errors,
|
||||||
uploadedKeys,
|
uploadedKeys,
|
||||||
|
existingFiles,
|
||||||
|
onViewFile,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
className,
|
className,
|
||||||
@@ -270,9 +355,11 @@ export function SmartFileInput({
|
|||||||
const fieldError =
|
const fieldError =
|
||||||
errors?.[field.fileKey] || localErrors[field.fileKey];
|
errors?.[field.fileKey] || localErrors[field.fileKey];
|
||||||
const isDragOver = dragActive[field.fileKey];
|
const isDragOver = dragActive[field.fileKey];
|
||||||
|
const existingForField = existingFiles?.[field.fileKey] ?? [];
|
||||||
// Already uploaded server-side and nothing newly picked to replace it.
|
// Already uploaded server-side and nothing newly picked to replace it.
|
||||||
const isUploaded =
|
const isUploaded =
|
||||||
(uploadedKeys?.includes(field.fileKey) ?? false) &&
|
((uploadedKeys?.includes(field.fileKey) ?? false) ||
|
||||||
|
existingForField.length > 0) &&
|
||||||
currentFiles.length === 0;
|
currentFiles.length === 0;
|
||||||
|
|
||||||
// Format accepted files for the HTML input element
|
// Format accepted files for the HTML input element
|
||||||
@@ -417,6 +504,17 @@ export function SmartFileInput({
|
|||||||
{field.allowedExtensions.join(", ").toUpperCase() ||
|
{field.allowedExtensions.join(", ").toUpperCase() ||
|
||||||
"All"}
|
"All"}
|
||||||
</span>
|
</span>
|
||||||
|
{existingForField.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1 basis-full">
|
||||||
|
{existingForField.map((f, idx) => (
|
||||||
|
<ExistingFileLink
|
||||||
|
key={`${f.url}-${idx}`}
|
||||||
|
file={f}
|
||||||
|
onViewFile={onViewFile}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : isUploaded ? (
|
) : isUploaded ? (
|
||||||
// Uploaded state: a solid success panel that still doubles as a
|
// Uploaded state: a solid success panel that still doubles as a
|
||||||
@@ -431,7 +529,7 @@ export function SmartFileInput({
|
|||||||
? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10"
|
? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10"
|
||||||
: "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10",
|
: "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10",
|
||||||
disabled &&
|
disabled &&
|
||||||
"opacity-50 pointer-events-none cursor-not-allowed",
|
"opacity-50 pointer-events-none cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -457,11 +555,24 @@ export function SmartFileInput({
|
|||||||
<p className="text-sm font-semibold text-foreground">
|
<p className="text-sm font-semibold text-foreground">
|
||||||
{isDragOver ? "Drop to replace" : "Document uploaded"}
|
{isDragOver ? "Drop to replace" : "Document uploaded"}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
{existingForField.length > 0 ? (
|
||||||
{isDragOver
|
<div className="mt-0.5 flex flex-col gap-0.5">
|
||||||
? "Release to replace the document on file."
|
{existingForField.map((f, idx) => (
|
||||||
: "Saved to your application. Drag a new file here or click to replace it."}
|
<ExistingFileLink
|
||||||
</p>
|
key={`${f.url}-${idx}`}
|
||||||
|
file={f}
|
||||||
|
onViewFile={onViewFile}
|
||||||
|
showSize
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{isDragOver
|
||||||
|
? "Release to replace the document on file."
|
||||||
|
: "Saved to your application. Drag a new file here or click to replace it."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="hidden shrink-0 items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground shadow-2xs transition group-hover:border-primary/50 group-hover:text-primary sm:inline-flex">
|
<span className="hidden shrink-0 items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground shadow-2xs transition group-hover:border-primary/50 group-hover:text-primary sm:inline-flex">
|
||||||
@@ -480,9 +591,9 @@ export function SmartFileInput({
|
|||||||
? "border-primary bg-primary/5 dark:bg-primary/10"
|
? "border-primary bg-primary/5 dark:bg-primary/10"
|
||||||
: "border-border hover:border-primary/50 hover:bg-muted/10",
|
: "border-border hover:border-primary/50 hover:bg-muted/10",
|
||||||
fieldError &&
|
fieldError &&
|
||||||
"border-destructive hover:border-destructive/80",
|
"border-destructive hover:border-destructive/80",
|
||||||
disabled &&
|
disabled &&
|
||||||
"opacity-50 pointer-events-none cursor-not-allowed",
|
"opacity-50 pointer-events-none cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -534,4 +645,4 @@ export function SmartFileInput({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SmartFileInput;
|
export default SmartFileInput;
|
||||||
|
|||||||
27
packages/ui-common/src/hooks/useFileViewer.tsx
Normal file
27
packages/ui-common/src/hooks/useFileViewer.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { FileViewerModal, type ViewableFile } from "../components/FileViewer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
|
||||||
|
* from any file row to open the document inline (pdf / image / video / office /
|
||||||
|
* text); render `viewer` once near the page root.
|
||||||
|
*
|
||||||
|
* const { view, viewer } = useFileViewer();
|
||||||
|
* <Button onClick={() => view({ name, url, mimeType })}>View</Button>
|
||||||
|
* {viewer}
|
||||||
|
*
|
||||||
|
* Pass `view` straight into `SmartFileInput`'s `onViewFile` prop to make its
|
||||||
|
* already-uploaded files open in the viewer instead of a new tab.
|
||||||
|
*/
|
||||||
|
export function useFileViewer() {
|
||||||
|
const [file, setFile] = useState<ViewableFile | null>(null);
|
||||||
|
|
||||||
|
const view = useCallback((f: ViewableFile) => setFile(f), []);
|
||||||
|
const close = useCallback(() => setFile(null), []);
|
||||||
|
|
||||||
|
const viewer = (
|
||||||
|
<FileViewerModal open={file !== null} file={file} onClose={close} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return { view, close, viewer };
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ export type {
|
|||||||
ViewableFile,
|
ViewableFile,
|
||||||
} from "./components/FileViewer";
|
} from "./components/FileViewer";
|
||||||
|
|
||||||
|
export { useFileViewer } from "./hooks/useFileViewer";
|
||||||
|
|
||||||
export { OperationDatePicker } from "./components/OperationDatePicker";
|
export { OperationDatePicker } from "./components/OperationDatePicker";
|
||||||
export type { OperationDatePickerProps } from "./components/OperationDatePicker";
|
export type { OperationDatePickerProps } from "./components/OperationDatePicker";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user