mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 03:38:17 +00:00
Merge branch 'freight_feature/usermanagement' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -60,6 +60,7 @@ import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage"
|
||||
import LogoSettingsPage from "./pages/settings/LogoSettingsPage";
|
||||
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
||||
import PortalContentPage from "./pages/portal_content/PortalContentPage";
|
||||
import PublicationsPage from "./pages/publications/PublicationsPage";
|
||||
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||
import WagonPerformancePage from "./pages/wagon-performance/WagonPerformancePage";
|
||||
@@ -1211,6 +1212,19 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="publications"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.settings.publications.view,
|
||||
FREIGHT_PERMS.settings.publications.manage,
|
||||
]}
|
||||
>
|
||||
<PublicationsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="configuration"
|
||||
|
||||
@@ -36,7 +36,7 @@ import { downloadBookingFile, fetchViewableFile } from "@/services/files.service
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { extractErrorMessage } from "@/utils/errorExtractor";
|
||||
|
||||
const CURRENCIES = ["ETB", "USD"];
|
||||
const CURRENCIES = ["ETB", "USD", "DJF"];
|
||||
|
||||
const STATUS_META: Record<Freight.AdditionalChargeStatus, { label: string; color: string }> = {
|
||||
DRAFT: { label: "Draft", color: "gray" },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core";
|
||||
import { Coins, Truck } from "lucide-react";
|
||||
import { currencyDecimals } from "@edr/ui-common";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal";
|
||||
@@ -12,8 +13,8 @@ import { MetricTile } from "./MetricTile";
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: currencyDecimals(currency),
|
||||
maximumFractionDigits: currencyDecimals(currency),
|
||||
})} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import { OperationDatePicker } from "@edr/ui-common";
|
||||
import { OperationDatePicker, currencyDecimals } from "@edr/ui-common";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
import { api as rpc } from "@/services/api";
|
||||
@@ -223,7 +223,7 @@ export function RebookWagonCancellationModal({
|
||||
<Text size="sm">
|
||||
{cancellation.booking?.reference ?? cancellation.bookingId} ·{" "}
|
||||
{cancellation.wagonsCancelled} wagon(s) · credit{" "}
|
||||
{formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)}
|
||||
{formatMoney(cancellation.creditAmount, cancellation.feeCurrency, currencyDecimals(cancellation.feeCurrency))}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
Shipment day
|
||||
|
||||
@@ -8,6 +8,7 @@ import { api } from "@/auth/http";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { formatDate, formatMoney } from "@/lib/format";
|
||||
import { currencyDecimals } from "@edr/ui-common";
|
||||
import { RebookWagonCancellationModal } from "./RebookWagonCancellationModal";
|
||||
import {
|
||||
canRebookWagonCancellations,
|
||||
@@ -73,7 +74,7 @@ export function WagonCancellationCreditCard({
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{Number(r.wagonsCancelled)} wagon(s) · credit{" "}
|
||||
{formatMoney(Number(r.creditAmount), r.feeCurrency, 2)}
|
||||
{formatMoney(Number(r.creditAmount), r.feeCurrency, currencyDecimals(r.feeCurrency))}
|
||||
</Text>
|
||||
<Badge color={chip.color} variant="light" size="sm" radius="md">
|
||||
{chip.label}
|
||||
@@ -83,7 +84,7 @@ export function WagonCancellationCreditCard({
|
||||
Cancelled {formatDate(r.createdAt)}
|
||||
{r.fault ? ` · ${r.fault === "EDR" ? "EDR fault (no fee)" : "customer fault"}` : ""}
|
||||
{Number(r.feeAmount) > 0
|
||||
? ` · fee ${formatMoney(Number(r.feeAmount), r.feeCurrency, 2)}${
|
||||
? ` · fee ${formatMoney(Number(r.feeAmount), r.feeCurrency, currencyDecimals(r.feeCurrency))}${
|
||||
r.feePaidAt ? " paid" : " unpaid"
|
||||
}`
|
||||
: ""}
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { extractErrorMessage } from "@/utils/errorExtractor";
|
||||
|
||||
const CURRENCIES = ["ETB", "USD"];
|
||||
const CURRENCIES = ["ETB", "USD", "DJF"];
|
||||
|
||||
const STATUS_META: Record<
|
||||
Freight.ClearanceChargeStatus,
|
||||
|
||||
@@ -345,7 +345,7 @@ export default function GlCreateBookingForm() {
|
||||
const [notes, setNotes] = useState("");
|
||||
// IMPORT bookings pick ETB or USD — starts empty so the choice is
|
||||
// deliberate (required before pricing). Everything else is forced to ETB.
|
||||
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "">("");
|
||||
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "DJF" | "">("");
|
||||
// What the containers carry — captured per booking (moved off the contract).
|
||||
const [cargoDescription, setCargoDescription] = useState("");
|
||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||
@@ -1144,7 +1144,7 @@ export default function GlCreateBookingForm() {
|
||||
]);
|
||||
|
||||
// Only IMPORT actually chooses — the rest bill ETB regardless of the state.
|
||||
const effectiveCurrency: "USD" | "ETB" =
|
||||
const effectiveCurrency: "USD" | "ETB" | "DJF" =
|
||||
isImport && paymentCurrency ? paymentCurrency : "ETB";
|
||||
const currencyError =
|
||||
isImport && !paymentCurrency
|
||||
@@ -2351,7 +2351,7 @@ export default function GlCreateBookingForm() {
|
||||
{requestCurrencyLocked
|
||||
? "The customer chose the billing currency on the shipment request — it cannot be changed."
|
||||
: isImport
|
||||
? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online."
|
||||
? "Import shipments may be invoiced in ETB, USD or DJF. USD is paid by bank transfer, not online."
|
||||
: "Shipments are invoiced in ETB."}
|
||||
</Text>
|
||||
<CurrencySelector
|
||||
@@ -2359,6 +2359,7 @@ export default function GlCreateBookingForm() {
|
||||
onChange={setPaymentCurrency}
|
||||
disabled={!isImport || requestCurrencyLocked}
|
||||
allowUsd={isImport}
|
||||
allowDjf={isImport}
|
||||
error={currencyError}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -1554,7 +1554,7 @@ function SecondDutyStep({
|
||||
/>
|
||||
<Select
|
||||
label="Currency"
|
||||
data={["ETB", "USD"]}
|
||||
data={["ETB", "USD", "DJF"]}
|
||||
value={currency}
|
||||
onChange={(v) => setCurrency(v ?? "ETB")}
|
||||
size="sm"
|
||||
@@ -1944,7 +1944,7 @@ function DraftDeclarationStep({
|
||||
/>
|
||||
<Select
|
||||
label="Currency"
|
||||
data={["ETB", "USD"]}
|
||||
data={["ETB", "USD", "DJF"]}
|
||||
value={currency}
|
||||
onChange={(v) => setCurrency(v ?? "ETB")}
|
||||
size="sm"
|
||||
@@ -2063,7 +2063,7 @@ function DutyStep({
|
||||
/>
|
||||
<Select
|
||||
label="Currency"
|
||||
data={["ETB", "USD"]}
|
||||
data={["ETB", "USD", "DJF"]}
|
||||
value={currency}
|
||||
onChange={(v) => setCurrency(v ?? "ETB")}
|
||||
size="sm"
|
||||
|
||||
@@ -54,7 +54,7 @@ export function AdviseDutyCard({
|
||||
/>
|
||||
<Select
|
||||
label="Currency"
|
||||
data={["ETB", "USD"]}
|
||||
data={["ETB", "USD", "DJF"]}
|
||||
value={currency}
|
||||
onChange={(v) => setCurrency(v ?? "ETB")}
|
||||
size="sm"
|
||||
|
||||
@@ -582,6 +582,15 @@ export const buildSidebarSections = (
|
||||
FREIGHT_PERMS.settings.supportContent.manage,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Publications",
|
||||
href: "/dashboard/publications",
|
||||
icon: <FileText />,
|
||||
permission: [
|
||||
FREIGHT_PERMS.settings.publications.view,
|
||||
FREIGHT_PERMS.settings.publications.manage,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Audit logs",
|
||||
href: "/dashboard/audit-logs",
|
||||
|
||||
@@ -18,7 +18,7 @@ function formatDateLabel(date: string) {
|
||||
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function formatAmount(value: number, currency: "ETB" | "USD") {
|
||||
function formatAmount(value: number, currency: string) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
|
||||
@@ -9,10 +9,9 @@ import { SummaryCard } from "./summary/SummaryCard";
|
||||
|
||||
function formatAmount(amount: number | null, currency: string | null) {
|
||||
if (amount == null) return "—";
|
||||
const code = currency === "USD" ? "USD" : "ETB";
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: code,
|
||||
currency: currency || "ETB",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { KpiStrip, type KpiItem } from "@/components/page";
|
||||
import type { IOverviewKpis, IOverviewPeriodTotals } from "@/types/overview";
|
||||
import { CountUp } from "./CountUp";
|
||||
|
||||
function formatCurrency(amount: number, currency: "ETB" | "USD") {
|
||||
function formatCurrency(amount: number, currency: string) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
@@ -13,7 +13,7 @@ function formatCurrency(amount: number, currency: "ETB" | "USD") {
|
||||
}
|
||||
|
||||
/** Compact form ("ETB 58.6M") — the hero cell is too narrow for nine digits. */
|
||||
function formatCompactCurrency(amount: number, currency: "ETB" | "USD") {
|
||||
function formatCompactCurrency(amount: number, currency: string) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { OverviewKpiStrip } from "../OverviewKpiStrip";
|
||||
import { OverviewPaymentChart } from "../OverviewPaymentChart";
|
||||
import { overviewChartColors } from "../overview.styles";
|
||||
|
||||
function formatCurrency(amount: number, currency: "ETB" | "USD") {
|
||||
function formatCurrency(amount: number, currency: string) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
import { ActionIcon, Badge, Card, Group, Loader, Menu, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react';
|
||||
import { currencyDecimals } from '@edr/ui-common';
|
||||
|
||||
import { useAccrualDashboard } from '@/hooks/useWarehouses';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
@@ -15,7 +16,8 @@ const ALERT_META: Record<AccrualAlert, { color: string; label: string }> = {
|
||||
};
|
||||
|
||||
function money(amount: number, currency: string): string {
|
||||
return `${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
|
||||
const decimals = currencyDecimals(currency);
|
||||
return `${amount.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })} ${currency}`;
|
||||
}
|
||||
|
||||
function freeDaysLabel(row: AccrualDashboardRow): string {
|
||||
|
||||
@@ -113,7 +113,7 @@ function Row({ label, value }: { label: string; value: string }) {
|
||||
/** Batch 5 fee preview + Batch 6 invoice generation / gate clearance for an inventory item. */
|
||||
export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [billingCurrency, setBillingCurrency] = useState<'ETB' | 'USD'>('USD');
|
||||
const [billingCurrency, setBillingCurrency] = useState<'ETB' | 'USD' | 'DJF'>('USD');
|
||||
const enabledId = opened ? inventoryId ?? undefined : undefined;
|
||||
const { data, isLoading } = useQuery(
|
||||
api.warehouses.feePreview.queryOptions({
|
||||
@@ -211,10 +211,11 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={billingCurrency}
|
||||
onChange={(value) => setBillingCurrency(value as 'ETB' | 'USD')}
|
||||
onChange={(value) => setBillingCurrency(value as 'ETB' | 'USD' | 'DJF')}
|
||||
data={[
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'ETB', label: 'Birr' },
|
||||
{ value: 'DJF', label: 'DJF' },
|
||||
]}
|
||||
disabled={Boolean(activeInvoice)}
|
||||
/>
|
||||
|
||||
@@ -7,10 +7,11 @@ import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
const QUERY_KEY = ["exchangeSettings"];
|
||||
|
||||
/** One row per foreign currency (USD, DJF, …) — see `exchangeSettingsService.list`. */
|
||||
export const useExchangeSettingsQuery = () =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: () => exchangeSettingsService.get(),
|
||||
queryFn: () => exchangeSettingsService.list(),
|
||||
// Feed health is only interesting while it is being looked at.
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: true,
|
||||
@@ -22,7 +23,8 @@ export const useSetExchangeFallbackRate = () => {
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (rate: number) => exchangeSettingsService.setFallbackRate(rate),
|
||||
mutationFn: ({ currency, rate }: { currency: string; rate: number }) =>
|
||||
exchangeSettingsService.setFallbackRate(currency, rate),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
toast.success(
|
||||
|
||||
@@ -24,7 +24,9 @@ export const useUpdateManualPaymentSettings = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (
|
||||
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>,
|
||||
patch: Partial<
|
||||
Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">
|
||||
>,
|
||||
) => manualPaymentSettingsService.update(patch),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data);
|
||||
|
||||
@@ -221,7 +221,7 @@ export function useOnTimeDispatch() {
|
||||
}
|
||||
|
||||
/** Live per-item fee accrual (storage/demurrage) with alerts. */
|
||||
export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
|
||||
export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD' | 'DJF') {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
|
||||
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
|
||||
@@ -598,7 +598,7 @@ export const useUpdateFeeRule = () =>
|
||||
export const useDeleteFeeRule = () =>
|
||||
useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']);
|
||||
|
||||
export function useFeePreview(inventoryId?: string, billingCurrency: 'ETB' | 'USD' = 'USD') {
|
||||
export function useFeePreview(inventoryId?: string, billingCurrency: 'ETB' | 'USD' | 'DJF' = 'USD') {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', inventoryId, 'fee-preview', billingCurrency],
|
||||
queryFn: () => warehouseService.feePreview(inventoryId as string, billingCurrency).then((r) => r.data),
|
||||
@@ -649,7 +649,7 @@ export function useGenerateInvoice() {
|
||||
}: {
|
||||
inventoryId: string;
|
||||
confirmZero?: boolean;
|
||||
billingCurrency?: 'ETB' | 'USD';
|
||||
billingCurrency?: 'ETB' | 'USD' | 'DJF';
|
||||
}) => warehouseService.generateInvoice(inventoryId, confirmZero, billingCurrency).then((r) => r.data),
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
@@ -433,6 +433,10 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:settings:support_content:view",
|
||||
manage: "edr_freight_app:settings:support_content:manage",
|
||||
},
|
||||
publications: {
|
||||
view: "edr_freight_app:settings:publications:view",
|
||||
manage: "edr_freight_app:settings:publications:manage",
|
||||
},
|
||||
},
|
||||
staff: {
|
||||
roles: {
|
||||
|
||||
@@ -72,6 +72,7 @@ import { AdditionalPaymentsTab } from "@/components/bookings/AdditionalPaymentsT
|
||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import { formatDateTime, formatMoney } from "@/lib/format";
|
||||
import { currencyDecimals } from "@edr/ui-common";
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import {
|
||||
@@ -254,7 +255,7 @@ export default function BookingRequestDetailPage() {
|
||||
const kpis: KpiItem[] = [
|
||||
{
|
||||
label: "Total value",
|
||||
value: formatMoney(amount, booking.paymentCurrency, 2),
|
||||
value: formatMoney(amount, booking.paymentCurrency, currencyDecimals(booking.paymentCurrency)),
|
||||
hint: booking.paymentStatus,
|
||||
icon: Wallet,
|
||||
color: "edr-green",
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useAuth } from "@/auth/useAuth";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { toDayString } from "@/hooks/useListControls";
|
||||
import { formatDate, formatMoney } from "@/lib/format";
|
||||
import { currencyDecimals } from "@edr/ui-common";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import {
|
||||
DataTable,
|
||||
@@ -166,7 +167,7 @@ export default function WagonCancellationsPage() {
|
||||
header: () => <span>Fee</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{formatMoney(row.original.feeAmount, row.original.feeCurrency, 2)}
|
||||
{formatMoney(row.original.feeAmount, row.original.feeCurrency, currencyDecimals(row.original.feeCurrency))}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
@@ -175,7 +176,7 @@ export default function WagonCancellationsPage() {
|
||||
header: () => <span>Credit</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{formatMoney(row.original.creditAmount, row.original.feeCurrency, 2)}
|
||||
{formatMoney(row.original.creditAmount, row.original.feeCurrency, currencyDecimals(row.original.feeCurrency))}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
@@ -347,7 +348,7 @@ export default function WagonCancellationsPage() {
|
||||
<Text size="sm">
|
||||
{voiding.booking?.reference ?? voiding.bookingId} ·{" "}
|
||||
{voiding.wagonsCancelled} wagon(s) · fee{" "}
|
||||
{formatMoney(voiding.feeAmount, voiding.feeCurrency, 2)}
|
||||
{formatMoney(voiding.feeAmount, voiding.feeCurrency, currencyDecimals(voiding.feeCurrency))}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
The pending fee is dropped and the wagons stay on the booking.
|
||||
|
||||
@@ -82,6 +82,7 @@ const CONTRACT_KIND_OPTIONS = [
|
||||
const CURRENCY_OPTIONS = [
|
||||
{ value: "ETB", label: "ETB" },
|
||||
{ value: "USD", label: "USD" },
|
||||
{ value: "DJF", label: "DJF" },
|
||||
];
|
||||
|
||||
/** value = `${sortBy}:${sortOrder}` for the sort Select. */
|
||||
|
||||
@@ -279,6 +279,7 @@ const OperationsTab = ({ vehicle }: { vehicle: Vehicle }) => {
|
||||
<Group gap="lg" mt={6}>
|
||||
<Radio value="ETB" label="ETB" />
|
||||
<Radio value="USD" label="USD" />
|
||||
<Radio value="DJF" label="DJF" />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -66,6 +66,7 @@ const INVOICE_FILTER_DEFS: FilterDef[] = [
|
||||
options: [
|
||||
{ value: "ETB", label: "ETB" },
|
||||
{ value: "USD", label: "USD" },
|
||||
{ value: "DJF", label: "DJF" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -235,8 +236,23 @@ export default function InvoicesPanel() {
|
||||
const { data: exchangeSettings } = useExchangeSettingsQuery();
|
||||
const etbCollected = summary?.ETB ?? 0;
|
||||
const usdCollected = summary?.USD ?? 0;
|
||||
const rate = exchangeSettings?.feed?.rate ?? exchangeSettings?.fallbackRate;
|
||||
const etbFromUsd = rate ? usdCollected * rate : null;
|
||||
const djfCollected = summary?.DJF ?? 0;
|
||||
const rateFor = (currency: string) => {
|
||||
const setting = exchangeSettings?.find((s) => s.currency === currency);
|
||||
return setting?.feed?.rate ?? setting?.fallbackRate ?? null;
|
||||
};
|
||||
const usdRate = rateFor("USD");
|
||||
const djfRate = rateFor("DJF");
|
||||
const etbFromUsd = usdRate ? usdCollected * usdRate : null;
|
||||
const etbFromDjf = djfRate ? djfCollected * djfRate : null;
|
||||
const totalEtb = etbCollected + (etbFromUsd ?? 0) + (etbFromDjf ?? 0);
|
||||
const totalHint = [
|
||||
"ETB",
|
||||
etbFromUsd !== null ? "USD" : null,
|
||||
etbFromDjf !== null ? "DJF" : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" + ");
|
||||
|
||||
const columns: ColumnDef<Invoice>[] = useMemo(
|
||||
() => [
|
||||
@@ -356,8 +372,8 @@ export default function InvoicesPanel() {
|
||||
items={[
|
||||
{
|
||||
label: "Total collected",
|
||||
hint: etbFromUsd !== null ? "ETB + USD" : "ETB only",
|
||||
value: formatMoney(etbCollected + (etbFromUsd ?? 0), "ETB"),
|
||||
hint: totalHint,
|
||||
value: formatMoney(totalEtb, "ETB"),
|
||||
icon: CircleDollarSign,
|
||||
color: "edr-green",
|
||||
},
|
||||
@@ -373,6 +389,12 @@ export default function InvoicesPanel() {
|
||||
icon: Landmark,
|
||||
color: "violet",
|
||||
},
|
||||
{
|
||||
label: "Collected in DJF",
|
||||
value: formatMoney(djfCollected, "DJF"),
|
||||
icon: Landmark,
|
||||
color: "orange",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -274,7 +274,7 @@ function ConfirmCell({
|
||||
export default function UsdPaymentsPanel({
|
||||
currency,
|
||||
}: {
|
||||
currency: "USD" | "ETB";
|
||||
currency: "USD" | "ETB" | "DJF";
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
// Namespaced: the ETB and USD tabs share this panel and live on the same URL
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { KpiStrip } from "@/components/page";
|
||||
import { ExportButton } from "@/components/export/ExportButton";
|
||||
import { formatDate, formatMoney } from "@/lib/format";
|
||||
import { currencyDecimals } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
|
||||
import {
|
||||
@@ -149,7 +150,7 @@ export default function PaymentsPanel() {
|
||||
header: () => <span className={tableHeader}>Amount</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
|
||||
{formatMoney(row.original.amount, row.original.currency, 2)}
|
||||
{formatMoney(row.original.amount, row.original.currency, currencyDecimals(row.original.currency))}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export interface DeletePublicationDialogProps {
|
||||
title: string;
|
||||
onConfirm?: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function DeletePublicationDialog({
|
||||
title,
|
||||
onConfirm,
|
||||
children,
|
||||
}: DeletePublicationDialogProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-md rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">Delete publication?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will remove{" "}
|
||||
<span className="font-semibold text-slate-900">{title}</span> from the
|
||||
public library. It stops being downloadable immediately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
|
||||
<DialogClose asChild>
|
||||
<Button onClick={onConfirm} className="bg-red-600 text-white hover:bg-red-700">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { Publication } from "@edr/types";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Loader2, UploadCloud } from "lucide-react";
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
export interface EditPublicationDialogProps {
|
||||
mode?: "create" | "edit";
|
||||
publication?: Publication;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const ACCEPT =
|
||||
".pdf,.md,.markdown,.ppt,.pptx,application/pdf,text/markdown,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||
|
||||
export default function EditPublicationDialog({
|
||||
mode = "create",
|
||||
publication,
|
||||
children,
|
||||
}: EditPublicationDialogProps) {
|
||||
const isEdit = mode === "edit";
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState(publication?.title ?? "");
|
||||
const [description, setDescription] = useState(publication?.description ?? "");
|
||||
const [category, setCategory] = useState(publication?.category ?? "");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [progress, setProgress] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createMutation = useMutation(api.publications.create.mutationOptions());
|
||||
const updateMutation = useMutation(api.publications.update.mutationOptions());
|
||||
const replaceFileMutation = useMutation(api.publications.replaceFile.mutationOptions());
|
||||
const pending =
|
||||
createMutation.isPending || updateMutation.isPending || replaceFileMutation.isPending;
|
||||
|
||||
const reset = () => {
|
||||
setTitle(publication?.title ?? "");
|
||||
setDescription(publication?.description ?? "");
|
||||
setCategory(publication?.category ?? "");
|
||||
setFile(null);
|
||||
setProgress(null);
|
||||
setError(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError(null);
|
||||
if (!title.trim()) {
|
||||
setError("Title is required.");
|
||||
return;
|
||||
}
|
||||
if (!isEdit && !file) {
|
||||
setError("Choose a file to upload.");
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
category: category.trim() || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEdit && publication) {
|
||||
await updateMutation.mutateAsync({ id: publication.id, dto: meta });
|
||||
if (file) {
|
||||
await replaceFileMutation.mutateAsync({
|
||||
id: publication.id,
|
||||
file,
|
||||
onProgress: setProgress,
|
||||
});
|
||||
}
|
||||
} else if (file) {
|
||||
await createMutation.mutateAsync({ file, meta, onProgress: setProgress });
|
||||
}
|
||||
setOpen(false);
|
||||
if (!isEdit) reset();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong. Try again.");
|
||||
} finally {
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) reset();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
{isEdit ? "Edit publication" : "New publication"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? "Update this document's title, description or category, or replace its file."
|
||||
: "Upload a PDF, Markdown or PowerPoint file for the public library."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-5 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Title *</Label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. EDR Freight Platform Guide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Input
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
placeholder="e.g. Guides, Reports"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What this document covers…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{isEdit ? "Replace file (optional)" : "File *"}</Label>
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="cursor-pointer rounded-xl border-2 border-dashed border-slate-300 px-4 py-6 text-center hover:bg-slate-50"
|
||||
>
|
||||
{progress !== null ? (
|
||||
<p className="text-sm text-slate-500">Uploading… {progress}%</p>
|
||||
) : file ? (
|
||||
<p className="text-sm font-medium text-slate-700">{file.name}</p>
|
||||
) : isEdit && publication ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
Currently <span className="font-medium">{publication.fileName}</span> —
|
||||
click to replace
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-slate-500">
|
||||
<UploadCloud className="h-6 w-6" />
|
||||
<span className="text-sm">Click to choose a PDF, Markdown or PowerPoint file</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPT}
|
||||
hidden
|
||||
onChange={(e) => setFile(e.currentTarget.files?.[0] ?? null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2 flex justify-end gap-3">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={pending}
|
||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : isEdit ? (
|
||||
"Save changes"
|
||||
) : (
|
||||
"Upload"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { Publication } from "@edr/types";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { FileText, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { formatBytes, formatDate } from "@/lib/format";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
import DeletePublicationDialog from "./DeletePublicationDialog";
|
||||
import EditPublicationDialog from "./EditPublicationDialog";
|
||||
|
||||
/** Short label from a mime type, for the file-type badge. */
|
||||
function fileKindLabel(mime: string): string {
|
||||
if (mime === "application/pdf") return "PDF";
|
||||
if (mime.includes("markdown")) return "Markdown";
|
||||
if (mime.includes("powerpoint") || mime.includes("presentationml")) return "PowerPoint";
|
||||
return "File";
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoffice admin for the freight portal's public /publications page —
|
||||
* upload, edit, reorder-by-hand and unpublish PDFs, Markdown write-ups and
|
||||
* PowerPoint decks about the platform.
|
||||
*/
|
||||
export default function PublicationsPage() {
|
||||
const { data, isLoading, isError } = useQuery(api.publications.list.queryOptions());
|
||||
const updateMutation = useMutation(api.publications.update.mutationOptions());
|
||||
const removeMutation = useMutation(api.publications.remove.mutationOptions());
|
||||
|
||||
const publications = [...(data ?? [])].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={4}>
|
||||
<Title order={2}>Publications</Title>
|
||||
<Text size="sm" c="dimmed" maw={560}>
|
||||
PDFs, Markdown write-ups and PowerPoint decks shown on the public
|
||||
/publications page — no login required to view them.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<EditPublicationDialog>
|
||||
<Button leftSection={<Plus size={16} />} color="edr-green">
|
||||
New publication
|
||||
</Button>
|
||||
</EditPublicationDialog>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
) : isError ? (
|
||||
<Text c="dimmed">Could not load publications.</Text>
|
||||
) : publications.length === 0 ? (
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<FileText size={32} color="var(--mantine-color-gray-5)" />
|
||||
<Text c="dimmed">No publications yet.</Text>
|
||||
<EditPublicationDialog>
|
||||
<Button variant="light" color="edr-green">
|
||||
Upload the first one
|
||||
</Button>
|
||||
</EditPublicationDialog>
|
||||
</Stack>
|
||||
) : (
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Title</Table.Th>
|
||||
<Table.Th>Category</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Size</Table.Th>
|
||||
<Table.Th>Published</Table.Th>
|
||||
<Table.Th>Updated</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{publications.map((pub: Publication) => (
|
||||
<Table.Tr key={pub.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{pub.title}
|
||||
</Text>
|
||||
{pub.description ? (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{pub.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{pub.category ? (
|
||||
<Badge variant="light" color="gray">
|
||||
{pub.category}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color="edr-green">
|
||||
{fileKindLabel(pub.fileMimeType)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{formatBytes(pub.fileSizeBytes)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label={pub.published ? "Visible on the public page" : "Hidden from the public page"}>
|
||||
<Switch
|
||||
checked={pub.published}
|
||||
color="edr-green"
|
||||
onChange={(e) =>
|
||||
updateMutation.mutate({
|
||||
id: pub.id,
|
||||
dto: { published: e.currentTarget.checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatDate(pub.updatedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<EditPublicationDialog mode="edit" publication={pub}>
|
||||
<ActionIcon variant="subtle" color="gray" aria-label="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</EditPublicationDialog>
|
||||
<DeletePublicationDialog
|
||||
title={pub.title}
|
||||
onConfirm={() => removeMutation.mutate({ id: pub.id })}
|
||||
>
|
||||
<ActionIcon variant="subtle" color="red" aria-label="Delete">
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</DeletePublicationDialog>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -206,6 +206,10 @@ export const LEGACY_APPROVAL_ROLES = [
|
||||
const RATE_APPLIES_TO = [
|
||||
{ label: "Bulk (base freight)", value: "BULK" },
|
||||
{ label: "Container (base freight)", value: "CONTAINER" },
|
||||
{
|
||||
label: "Empty container (base freight, import)",
|
||||
value: "EMPTY_CONTAINER",
|
||||
},
|
||||
{ label: "Intercity (base freight)", value: "INTERCITY" },
|
||||
{ label: "First mile", value: "FIRST_MILE" },
|
||||
{ label: "Last mile", value: "LAST_MILE" },
|
||||
@@ -290,7 +294,9 @@ const SHIPPING_LINE_CARGO_KINDS = [
|
||||
|
||||
/** True when the rate being edited is base rail freight, which is priced per leg. */
|
||||
const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
|
||||
["BULK", "CONTAINER", "EMPTY_CONTAINER", "INTERCITY"].includes(
|
||||
String(values.appliesTo ?? ""),
|
||||
);
|
||||
|
||||
/** Surcharges sold per origin → destination leg (mirrors RatesService.isRouteScoped). */
|
||||
export const ROUTE_SCOPED_TRIGGERS = [
|
||||
@@ -388,6 +394,9 @@ const unitsForShape = (
|
||||
switch (appliesTo) {
|
||||
case "CONTAINER":
|
||||
return ["PER_CONTAINER", "PER_WAGON"];
|
||||
case "EMPTY_CONTAINER":
|
||||
// No cargo to weigh — only the box and the wagon it rides on.
|
||||
return ["PER_CONTAINER", "PER_WAGON"];
|
||||
case "BULK":
|
||||
return ["PER_TON", "PER_WAGON"];
|
||||
case "INTERCITY":
|
||||
@@ -450,6 +459,7 @@ export const rateUnitOptions = (
|
||||
const CURRENCIES = [
|
||||
{ label: "ETB (Birr)", value: "ETB" },
|
||||
{ label: "USD", value: "USD" },
|
||||
{ label: "DJF", value: "DJF" },
|
||||
];
|
||||
|
||||
const PRIORITY_CONFIG_TYPES = [
|
||||
@@ -1143,6 +1153,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
},
|
||||
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER", isShippingLineRate: "false" } },
|
||||
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK", isShippingLineRate: "false" } },
|
||||
{
|
||||
key: "empty-container",
|
||||
label: "Empty container",
|
||||
filters: { appliesTo: "EMPTY_CONTAINER", isShippingLineRate: "false" },
|
||||
},
|
||||
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY", isShippingLineRate: "false" } },
|
||||
{
|
||||
key: "trucking",
|
||||
@@ -1300,8 +1315,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
type: "select",
|
||||
required: true,
|
||||
optionsFromValues: (v: Record<string, unknown>) =>
|
||||
String(v.appliesTo ?? "") === "OTHER" &&
|
||||
String(v.trigger ?? "") === "WITH_RETURN"
|
||||
// Empty freight and the empty-return surcharge are both import-only.
|
||||
String(v.appliesTo ?? "") === "EMPTY_CONTAINER" ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
String(v.trigger ?? "") === "WITH_RETURN")
|
||||
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
|
||||
: String(v.appliesTo ?? "") === "OTHER" &&
|
||||
String(v.trigger ?? "") === "FUEL"
|
||||
@@ -1309,7 +1326,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||
showIf: (v) =>
|
||||
!isShippingLineRate(v) &&
|
||||
(["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||
(["BULK", "CONTAINER", "EMPTY_CONTAINER"].includes(
|
||||
String(v.appliesTo ?? ""),
|
||||
) ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
[
|
||||
"CUSTOMS_CLEARANCE",
|
||||
@@ -1512,6 +1531,19 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
|
||||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN")),
|
||||
},
|
||||
// Empty freight has no cargo to narrow by, so the box size IS the scope —
|
||||
// required here, unlike the laden catch-all above. The API rejects an
|
||||
// unscoped empty rate for the same reason.
|
||||
{
|
||||
name: "containerTypeId",
|
||||
label: "Container type",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Which container type this rate covers",
|
||||
description: "20ft and 40ft price differently — one rate per size per lane.",
|
||||
showIf: (v) =>
|
||||
!isShippingLineRate(v) && v.appliesTo === "EMPTY_CONTAINER",
|
||||
},
|
||||
// Container type for a shipping-line base-freight rate. Required here,
|
||||
// unlike the customer form's optional catch-all: a line negotiates a
|
||||
// price per box size, so an unscoped line rate has no meaning.
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
useExchangeSettingsQuery,
|
||||
useSetExchangeFallbackRate,
|
||||
} from "@/hooks/useExchangeSettings";
|
||||
import type { ExchangeRateSource } from "@/services/exchangeSettings.service";
|
||||
import type {
|
||||
ExchangeRateSource,
|
||||
ExchangeSetting,
|
||||
} from "@/services/exchangeSettings.service";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
|
||||
/** Feed health, phrased for an operator rather than a developer. */
|
||||
@@ -38,40 +41,121 @@ function feedLabel(source: ExchangeRateSource | null): {
|
||||
const formatTime = (value: string | null) =>
|
||||
value ? formatDateTime(value) : "never";
|
||||
|
||||
/**
|
||||
* USD→ETB fallback used when the CBE exchange-rate endpoint is unreachable.
|
||||
* The live CBE rate always wins; every successful fetch overwrites the stored
|
||||
* value, so it tracks the last known good rate on its own. Editing here is for
|
||||
* a prolonged outage — the next successful CBE fetch replaces it.
|
||||
*/
|
||||
export default function ExchangeRateSettingsCard() {
|
||||
const { data, isLoading, refetch, isFetching } = useExchangeSettingsQuery();
|
||||
/** One currency's fallback row — its own draft, its own save. */
|
||||
function ExchangeRateRow({
|
||||
setting,
|
||||
disabled,
|
||||
}: {
|
||||
setting: ExchangeSetting;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const setRate = useSetExchangeFallbackRate();
|
||||
const [draft, setDraft] = useState<string>("");
|
||||
|
||||
const value = draft !== "" ? draft : (data?.fallbackRate?.toString() ?? "");
|
||||
const value = draft !== "" ? draft : (setting.fallbackRate?.toString() ?? "");
|
||||
const parsed = Number(value);
|
||||
const invalid = !Number.isFinite(parsed) || parsed < 1 || parsed > 10_000;
|
||||
const dirty = draft !== "" && parsed !== data?.fallbackRate;
|
||||
const invalid = !Number.isFinite(parsed) || parsed <= 0;
|
||||
const dirty = draft !== "" && parsed !== setting.fallbackRate;
|
||||
|
||||
const feed = feedLabel(data?.feed?.source ?? null);
|
||||
const feed = feedLabel(setting.feed?.source ?? null);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (invalid) return;
|
||||
await setRate.mutateAsync(parsed);
|
||||
await setRate.mutateAsync({ currency: setting.currency, rate: parsed });
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 border-t pt-4 first:border-t-0 first:pt-0">
|
||||
<div
|
||||
className={`flex items-start gap-2 rounded-md border p-3 text-sm ${
|
||||
feed.live
|
||||
? "border-green-200 bg-green-50 text-green-900 dark:border-green-900 dark:bg-green-950 dark:text-green-100"
|
||||
: "border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100"
|
||||
}`}
|
||||
>
|
||||
{feed.live ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">
|
||||
{setting.currency} → ETB — {feed.text}
|
||||
</p>
|
||||
{setting.feed?.rate != null && (
|
||||
<p>
|
||||
Rate in use: {setting.feed.rate} ETB per {setting.currency}
|
||||
</p>
|
||||
)}
|
||||
<p className="opacity-80">
|
||||
Last successful update: {formatTime(setting.feed?.lastSuccessAt ?? null)}
|
||||
</p>
|
||||
{setting.feed?.lastError && (
|
||||
<p className="opacity-80">Last error: {setting.feed.lastError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor={`fallback-rate-${setting.currency}`}
|
||||
>
|
||||
Fallback rate (ETB per {setting.currency})
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id={`fallback-rate-${setting.currency}`}
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min={0}
|
||||
className="max-w-[220px]"
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || invalid || setRate.isPending}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{invalid && draft !== "" && (
|
||||
<p className="text-sm text-red-600">Enter a rate greater than 0.</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{setting.fallbackSource === "MANUAL"
|
||||
? "Set manually. The next successful CBE update will replace it."
|
||||
: `Synced automatically from CBE (${formatTime(setting.lastSyncedAt)}).`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* X→ETB fallback used when the CBE exchange-rate endpoint is unreachable for
|
||||
* that currency — one row per foreign currency (USD, DJF). The live CBE rate
|
||||
* always wins; every successful fetch overwrites the stored value, so it
|
||||
* tracks the last known good rate on its own. Editing here is for a
|
||||
* prolonged outage — the next successful CBE fetch replaces it.
|
||||
*/
|
||||
export default function ExchangeRateSettingsCard() {
|
||||
const { data, isLoading, refetch, isFetching } = useExchangeSettingsQuery();
|
||||
|
||||
return (
|
||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Exchange rate (USD → ETB)</CardTitle>
|
||||
<CardTitle>Exchange rates (→ ETB)</CardTitle>
|
||||
<CardDescription>
|
||||
Rates come from the Commercial Bank of Ethiopia. The fallback
|
||||
below is used only when CBE cannot be reached, and is refreshed
|
||||
automatically after every successful update.
|
||||
Rates come from the Commercial Bank of Ethiopia. Each fallback
|
||||
below is used only when CBE cannot be reached for that currency,
|
||||
and is refreshed automatically after every successful update.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
@@ -88,69 +172,13 @@ export default function ExchangeRateSettingsCard() {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div
|
||||
className={`flex items-start gap-2 rounded-md border p-3 text-sm ${
|
||||
feed.live
|
||||
? "border-green-200 bg-green-50 text-green-900 dark:border-green-900 dark:bg-green-950 dark:text-green-100"
|
||||
: "border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100"
|
||||
}`}
|
||||
>
|
||||
{feed.live ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{feed.text}</p>
|
||||
{data?.feed?.rate != null && (
|
||||
<p>Rate in use: {data.feed.rate} ETB per USD</p>
|
||||
)}
|
||||
<p className="opacity-80">
|
||||
Last successful update: {formatTime(data?.feed?.lastSuccessAt ?? null)}
|
||||
</p>
|
||||
{data?.feed?.lastError && (
|
||||
<p className="opacity-80">Last error: {data.feed.lastError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="fallback-rate">
|
||||
Fallback rate (ETB per USD)
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="fallback-rate"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min={1}
|
||||
max={10000}
|
||||
className="max-w-[220px]"
|
||||
disabled={isLoading}
|
||||
value={value}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || invalid || setRate.isPending}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{invalid && draft !== "" && (
|
||||
<p className="text-sm text-red-600">
|
||||
Enter a rate between 1 and 10,000.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data?.fallbackSource === "MANUAL"
|
||||
? "Set manually. The next successful CBE update will replace it."
|
||||
: `Synced automatically from CBE (${formatTime(
|
||||
data?.lastSyncedAt ?? null,
|
||||
)}).`}
|
||||
</p>
|
||||
</div>
|
||||
{(data ?? []).map((setting) => (
|
||||
<ExchangeRateRow
|
||||
key={setting.currency}
|
||||
setting={setting}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -17,11 +17,11 @@ import {
|
||||
useUpdateManualPaymentSettings,
|
||||
} from "@/hooks/useManualPaymentSettings";
|
||||
|
||||
type Currency = "ETB" | "USD";
|
||||
type Currency = "ETB" | "USD" | "DJF";
|
||||
|
||||
const CURRENCIES: {
|
||||
code: Currency;
|
||||
field: "etbEnabled" | "usdEnabled";
|
||||
field: "etbEnabled" | "usdEnabled" | "djfEnabled";
|
||||
icon: typeof Banknote;
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -42,6 +42,14 @@ const CURRENCIES: {
|
||||
description:
|
||||
"USD invoices are paid by bank transfer and have no online channel. Switching this off leaves USD customers with no way to be marked as paid.",
|
||||
},
|
||||
{
|
||||
code: "DJF",
|
||||
field: "djfEnabled",
|
||||
icon: Landmark,
|
||||
title: "Djibouti Franc (DJF) invoices",
|
||||
description:
|
||||
"DJF invoices can be paid online (Waafi / CAC Bank) or by bank transfer. Switch this off if Finance should stop accepting DJF payments by hand.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -60,7 +68,9 @@ export default function ManualPaymentSettingsCard() {
|
||||
const { data, isLoading } = useManualPaymentSettingsQuery();
|
||||
const update = useUpdateManualPaymentSettings();
|
||||
|
||||
const noneEnabled = Boolean(data && !data.etbEnabled && !data.usdEnabled);
|
||||
const noneEnabled = Boolean(
|
||||
data && !data.etbEnabled && !data.usdEnabled && !data.djfEnabled,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||
@@ -79,7 +89,7 @@ export default function ManualPaymentSettingsCard() {
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>
|
||||
Both currencies are off — the Manual Payments list is empty and
|
||||
Every currency is off — the Manual Payments list is empty and
|
||||
Finance cannot settle any invoice by hand.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
import { DataTable, type ColumnDef, currencyDecimals } from "@edr/ui-common";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
@@ -45,7 +45,7 @@ const STATUS_META: Record<EmptyReturnRequestStatus, { label: string; color: stri
|
||||
const money = (amount: number | null | undefined, currency: string | null | undefined) =>
|
||||
amount == null
|
||||
? "—"
|
||||
: `${Number(amount).toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency ?? ""}`.trim();
|
||||
: `${Number(amount).toLocaleString(undefined, { minimumFractionDigits: currencyDecimals(currency) })} ${currency ?? ""}`.trim();
|
||||
|
||||
/**
|
||||
* The queue for customer-initiated empty container returns: a booking sold
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { currencyDecimals } from "@edr/ui-common";
|
||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -78,7 +79,7 @@ const TRUCK_COLUMNS = [
|
||||
] as const;
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`;
|
||||
`${Number(amount).toLocaleString(undefined, { maximumFractionDigits: currencyDecimals(currency) })} ${currency === "ETB" ? "ETB" : currency}`;
|
||||
|
||||
|
||||
export interface BookingGroup {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import { DataTable, type ColumnDef, currencyDecimals } from '@edr/ui-common';
|
||||
import { applyClientFilters, FilterBar, useFilters, type FilterDef } from '@/components/filters';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
@@ -50,7 +50,7 @@ const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
CANCELLED: 'gray',
|
||||
};
|
||||
|
||||
const fmt = (n: number, c: string) => formatMoney(n, c, 2);
|
||||
const fmt = (n: number, c: string) => formatMoney(n, c, currencyDecimals(c));
|
||||
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
|
||||
|
||||
const INVOICE_FILTER_DEFS: FilterDef[] = [
|
||||
@@ -204,7 +204,8 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
|
||||
|
||||
useEffect(() => {
|
||||
setGatewayMethod(inv?.currency === 'USD' ? 'WAAFI' : 'TELEBIRR');
|
||||
// WAAFI settles USD and DJF; TELEBIRR is ETB-only.
|
||||
setGatewayMethod(inv?.currency !== 'ETB' ? 'WAAFI' : 'TELEBIRR');
|
||||
setPayerAccount('');
|
||||
}, [inv?.id, inv?.currency]);
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ const TRADE = [
|
||||
const CURRENCIES = [
|
||||
{ value: 'USD', label: 'USD - Dollar' },
|
||||
{ value: 'ETB', label: 'ETB - Birr' },
|
||||
{ value: 'DJF', label: 'DJF - Djibouti Franc' },
|
||||
];
|
||||
|
||||
const clean = (s: string) => s.trim() || undefined;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Freight, PaginatedResponse } from "@edr/types";
|
||||
import type { Freight, PaginatedResponse, Publication } from "@edr/types";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||
@@ -191,6 +191,7 @@ import type { EimsInvoiceStatusView, EimsModeOfPayment, EimsReceiptView, EimsVer
|
||||
import { invoicesService } from "./invoices.service";
|
||||
import { dropdownSettingsService } from "./dropdownSettings.service";
|
||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||
import { publicationsService, type UpdatePublicationPayload } from "./publications.service";
|
||||
import {
|
||||
fleetService,
|
||||
type FleetListFilters,
|
||||
@@ -1532,7 +1533,7 @@ export const api = {
|
||||
),
|
||||
|
||||
feePreview: endpoint<
|
||||
{ inventoryId: string; billingCurrency?: "ETB" | "USD" },
|
||||
{ inventoryId: string; billingCurrency?: "ETB" | "USD" | "DJF" },
|
||||
FeePreview[]
|
||||
>(
|
||||
"warehouse-inventory",
|
||||
@@ -1884,7 +1885,7 @@ export const api = {
|
||||
{
|
||||
inventoryId: string;
|
||||
confirmZero?: boolean;
|
||||
billingCurrency?: "ETB" | "USD";
|
||||
billingCurrency?: "ETB" | "USD" | "DJF";
|
||||
},
|
||||
WarehouseFeeInvoice
|
||||
>(
|
||||
@@ -2934,6 +2935,52 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
publications: {
|
||||
list: endpoint<void, Publication[]>(
|
||||
"publications",
|
||||
"list",
|
||||
publicationsService.list,
|
||||
),
|
||||
|
||||
create: endpoint<
|
||||
{ file: File; meta: UpdatePublicationPayload & { title: string }; onProgress?: (percent: number | null) => void },
|
||||
Publication
|
||||
>(
|
||||
"publications",
|
||||
"create",
|
||||
({ file, meta, onProgress }) => publicationsService.create(file, meta, onProgress),
|
||||
undefined,
|
||||
() => [["publications"]],
|
||||
),
|
||||
|
||||
update: endpoint<{ id: string; dto: UpdatePublicationPayload }, Publication>(
|
||||
"publications",
|
||||
"update",
|
||||
({ id, dto }) => publicationsService.update(id, dto),
|
||||
undefined,
|
||||
() => [["publications"]],
|
||||
),
|
||||
|
||||
replaceFile: endpoint<
|
||||
{ id: string; file: File; onProgress?: (percent: number | null) => void },
|
||||
Publication
|
||||
>(
|
||||
"publications",
|
||||
"replaceFile",
|
||||
({ id, file, onProgress }) => publicationsService.replaceFile(id, file, onProgress),
|
||||
undefined,
|
||||
() => [["publications"]],
|
||||
),
|
||||
|
||||
remove: endpoint<{ id: string }, void>(
|
||||
"publications",
|
||||
"remove",
|
||||
({ id }) => publicationsService.remove(id),
|
||||
undefined,
|
||||
() => [["publications"]],
|
||||
),
|
||||
},
|
||||
|
||||
dropdownSettings: {
|
||||
list: endpoint<void, DropdownSetting[]>(
|
||||
"dropdown-settings",
|
||||
|
||||
@@ -11,7 +11,7 @@ const BASE = URL_CONSTANTS.EXCHANGE_SETTINGS.BASE;
|
||||
*/
|
||||
export type ExchangeRateSource = "live" | "stored";
|
||||
|
||||
/** Health of the CBE exchange-rate feed. */
|
||||
/** Health of the CBE exchange-rate feed for one currency. */
|
||||
export interface ExchangeFeedStatus {
|
||||
rate: number | null;
|
||||
source: ExchangeRateSource | null;
|
||||
@@ -19,25 +19,31 @@ export interface ExchangeFeedStatus {
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface ExchangeSettings {
|
||||
fallbackRate: number;
|
||||
/** `AUTO` when synced from CBE, `MANUAL` when set here. */
|
||||
fallbackSource: "AUTO" | "MANUAL";
|
||||
/** One currency's X→ETB fallback settings — the API returns one per foreign currency. */
|
||||
export interface ExchangeSetting {
|
||||
currency: string;
|
||||
fallbackRate: number | null;
|
||||
/** `AUTO` when synced from CBE, `MANUAL` when set here. `null` before the row exists. */
|
||||
fallbackSource: "AUTO" | "MANUAL" | null;
|
||||
lastSyncedAt: string | null;
|
||||
updatedById: string | null;
|
||||
feed?: ExchangeFeedStatus;
|
||||
}
|
||||
|
||||
export const exchangeSettingsService = {
|
||||
get: async (): Promise<ExchangeSettings> => {
|
||||
const response = await client.get<ApiResponse<ExchangeSettings>>(BASE);
|
||||
list: async (): Promise<ExchangeSetting[]> => {
|
||||
const response = await client.get<ApiResponse<ExchangeSetting[]>>(BASE);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
setFallbackRate: async (fallbackRate: number): Promise<ExchangeSettings> => {
|
||||
const response = await client.patch<ApiResponse<ExchangeSettings>>(BASE, {
|
||||
fallbackRate,
|
||||
});
|
||||
setFallbackRate: async (
|
||||
currency: string,
|
||||
fallbackRate: number,
|
||||
): Promise<ExchangeSetting> => {
|
||||
const response = await client.patch<ApiResponse<ExchangeSetting>>(
|
||||
`${BASE}/${currency}`,
|
||||
{ fallbackRate },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ const BASE = URL_CONSTANTS.MANUAL_PAYMENT_SETTINGS.BASE;
|
||||
export interface ManualPaymentSettings {
|
||||
etbEnabled: boolean;
|
||||
usdEnabled: boolean;
|
||||
djfEnabled: boolean;
|
||||
updatedById: string | null;
|
||||
updatedAt?: string;
|
||||
}
|
||||
@@ -25,7 +26,9 @@ export const manualPaymentSettingsService = {
|
||||
|
||||
/** Partial: an omitted currency keeps its current setting. */
|
||||
update: async (
|
||||
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>,
|
||||
patch: Partial<
|
||||
Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">
|
||||
>,
|
||||
): Promise<ManualPaymentSettings> => {
|
||||
const response = await client.patch<ApiResponse<ManualPaymentSettings>>(
|
||||
BASE,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Publication } from "@edr/types";
|
||||
|
||||
import { api as client } from "../auth/http";
|
||||
|
||||
const BASE = "/publications";
|
||||
|
||||
export interface UpdatePublicationPayload {
|
||||
title?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
sortOrder?: number;
|
||||
published?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The freight portal's public document library (/publications), managed here.
|
||||
* Every write is multipart because create/replaceFile carry a real file — the
|
||||
* client's response interceptor already unwraps the `{ success, data }`
|
||||
* envelope, so each method stays a one-liner.
|
||||
*/
|
||||
export const publicationsService = {
|
||||
async list(): Promise<Publication[]> {
|
||||
const { data } = await client.get<Publication[]>(`${BASE}/admin`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async create(
|
||||
file: File,
|
||||
meta: UpdatePublicationPayload & { title: string },
|
||||
onProgress?: (percent: number | null) => void,
|
||||
): Promise<Publication> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
Object.entries(meta).forEach(([key, value]) => {
|
||||
if (value !== undefined) form.append(key, String(value));
|
||||
});
|
||||
|
||||
const { data } = await client.post<Publication>(BASE, form, {
|
||||
timeout: 2 * 60 * 1000,
|
||||
onUploadProgress: (event) =>
|
||||
onProgress?.(
|
||||
event.total ? Math.round((event.loaded / event.total) * 100) : null,
|
||||
),
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async update(id: string, dto: UpdatePublicationPayload): Promise<Publication> {
|
||||
const { data } = await client.patch<Publication>(`${BASE}/${id}`, dto);
|
||||
return data;
|
||||
},
|
||||
|
||||
async replaceFile(
|
||||
id: string,
|
||||
file: File,
|
||||
onProgress?: (percent: number | null) => void,
|
||||
): Promise<Publication> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
|
||||
const { data } = await client.post<Publication>(`${BASE}/${id}/file`, form, {
|
||||
timeout: 2 * 60 * 1000,
|
||||
onUploadProgress: (event) =>
|
||||
onProgress?.(
|
||||
event.total ? Math.round((event.loaded / event.total) * 100) : null,
|
||||
),
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await client.delete(`${BASE}/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -558,11 +558,11 @@ export const warehouseService = {
|
||||
updateFeeRule: (id: string, payload: Partial<SaveFeeRulePayload>) =>
|
||||
apiClient.patch<FeeRule>(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id), payload),
|
||||
deleteFeeRule: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id)),
|
||||
feePreview: (inventoryId: string, billingCurrency?: 'ETB' | 'USD') =>
|
||||
feePreview: (inventoryId: string, billingCurrency?: 'ETB' | 'USD' | 'DJF') =>
|
||||
apiClient.get<FeePreview[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId), {
|
||||
params: cleanParams({ billingCurrency }),
|
||||
}),
|
||||
accrualDashboard: (billingCurrency?: 'ETB' | 'USD') =>
|
||||
accrualDashboard: (billingCurrency?: 'ETB' | 'USD' | 'DJF') =>
|
||||
apiClient.get<AccrualDashboardRow[]>(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, {
|
||||
params: cleanParams({ billingCurrency }),
|
||||
}),
|
||||
@@ -592,7 +592,7 @@ export const warehouseService = {
|
||||
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)),
|
||||
invoicesForBooking: (bookingId: string) =>
|
||||
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_BOOKING(bookingId)),
|
||||
generateInvoice: (inventoryId: string, confirmZero = false, billingCurrency?: 'ETB' | 'USD') =>
|
||||
generateInvoice: (inventoryId: string, confirmZero = false, billingCurrency?: 'ETB' | 'USD' | 'DJF') =>
|
||||
apiClient.post<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.GENERATE(inventoryId), {
|
||||
confirmZero,
|
||||
billingCurrency,
|
||||
|
||||
@@ -420,7 +420,7 @@ export interface CustomerBooking {
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
totalAmount: number;
|
||||
currency: "ETB" | "USD";
|
||||
currency: "ETB" | "USD" | "DJF";
|
||||
scheduledDate?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -469,7 +469,7 @@ export interface CustomerPayment {
|
||||
/** Booking reference the payment settles. */
|
||||
bookingReference: string;
|
||||
amount: number;
|
||||
currency: "ETB" | "USD";
|
||||
currency: "ETB" | "USD" | "DJF";
|
||||
method: CustomerPaymentMethod;
|
||||
status: CustomerPaymentStatus;
|
||||
paidAt?: string | null;
|
||||
|
||||
@@ -77,7 +77,7 @@ export interface InvoiceListFilter {
|
||||
/** CSV of normalised UPPER_SNAKE payment methods (see `PAYMENT_METHOD_OPTIONS`). */
|
||||
paymentMethods?: string;
|
||||
search?: string;
|
||||
currency?: "USD" | "ETB";
|
||||
currency?: "USD" | "ETB" | "DJF";
|
||||
/** ISO instants — inclusive bounds on `issuedAt` / `dueAt`. */
|
||||
issuedFrom?: string;
|
||||
issuedTo?: string;
|
||||
|
||||
@@ -78,6 +78,7 @@ import FaqPage from "./pages/support/FaqPage";
|
||||
import HelpPage from "./pages/support/HelpPage";
|
||||
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
|
||||
import TermsPage from "./pages/support/TermsPage";
|
||||
import PublicationsPage from "./pages/publications/PublicationsPage";
|
||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||
|
||||
function FullScreenSpinner() {
|
||||
@@ -427,6 +428,7 @@ const App = () => {
|
||||
<Route path="/faq" element={<FaqPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/terms" element={<TermsPage />} />
|
||||
<Route path="/publications" element={<PublicationsPage />} />
|
||||
|
||||
{/* Auth pages — inaccessible once logged in */}
|
||||
<Route element={<RedirectIfAuthed />}>
|
||||
|
||||
101
apps/edr-freight-web/portal/src/components/PublicNavbar.tsx
Normal file
101
apps/edr-freight-web/portal/src/components/PublicNavbar.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { ArrowRight, Menu, TrainFront } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Top-level navigation for the public marketing pages. Entries beginning with
|
||||
* `#` scroll within the landing page; entries beginning with `/` are real
|
||||
* routes and need router navigation.
|
||||
*/
|
||||
export const navLinks = [
|
||||
{ label: "Features", href: "#features" },
|
||||
{ label: "Live ops", href: "#showcase" },
|
||||
{ label: "Corridors", href: "#corridors" },
|
||||
{ label: "How it works", href: "#how" },
|
||||
{ label: "Publications", href: "/publications" },
|
||||
{ label: "Contact", href: "#contact" },
|
||||
];
|
||||
|
||||
/**
|
||||
* The dark navbar shared by every public page that is not behind the app
|
||||
* shell — the landing page and /publications. Extracted from the landing page
|
||||
* so the two cannot drift: a link added here shows up on both.
|
||||
*
|
||||
* The anchor entries only resolve on the landing page itself, so away from it
|
||||
* they are rendered as links back to the homepage's section instead of as
|
||||
* same-page anchors that would go nowhere.
|
||||
*/
|
||||
export function PublicNavbar({ onLanding = false }: { onLanding?: boolean }) {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-white/10 bg-edr-ink/90 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-20 max-w-7xl items-center justify-between px-6">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<span className="flex size-10 items-center justify-center rounded-xl bg-edr-primary text-white shadow-lg shadow-emerald-500/25">
|
||||
<TrainFront className="size-5" />
|
||||
</span>
|
||||
|
||||
<span className="leading-tight">
|
||||
<span className="block text-lg font-bold text-white">EDR Freight</span>
|
||||
<span className="block text-[11px] tracking-wide text-slate-400">
|
||||
Rail Logistics Platform
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-9 lg:flex">
|
||||
{navLinks.map((link) => {
|
||||
const className =
|
||||
"text-sm font-medium text-slate-300 transition hover:text-white";
|
||||
|
||||
// A route always navigates. An anchor only works on the landing
|
||||
// page; elsewhere it has to go home first, or clicking it does
|
||||
// nothing at all.
|
||||
if (link.href.startsWith("/")) {
|
||||
return (
|
||||
<Link key={link.href} to={link.href} className={className}>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return onLanding ? (
|
||||
<a key={link.href} href={link.href} className={className}>
|
||||
{link.label}
|
||||
</a>
|
||||
) : (
|
||||
<Link key={link.href} to={`/${link.href}`} className={className}>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/login"
|
||||
className="hidden rounded-lg border border-white/20 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-white/10 md:block"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/signup"
|
||||
className="hidden items-center gap-2 rounded-lg bg-edr-primary px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-emerald-500/25 transition hover:-translate-y-0.5 hover:bg-edr-primary-dark md:flex"
|
||||
>
|
||||
Get started
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open menu"
|
||||
className="rounded-lg border border-white/20 p-2 text-white lg:hidden"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicNavbar;
|
||||
@@ -229,6 +229,10 @@ export const URL_CONSTANTS = {
|
||||
PUBLIC: "/api/support-content",
|
||||
},
|
||||
|
||||
PUBLICATIONS: {
|
||||
PUBLIC: "/api/publications",
|
||||
},
|
||||
|
||||
EMPTY_RETURN_REQUESTS: {
|
||||
BASE: "/api/empty-return-requests",
|
||||
ELIGIBILITY: (bookingId: string) => `/api/empty-return-requests/eligibility/${bookingId}`,
|
||||
|
||||
@@ -11,3 +11,13 @@ export function fileViewUrl(fileId: string, download = false): string {
|
||||
const base = `${API_BASE_URL}/api/files/${fileId}`;
|
||||
return download ? `${base}?download=1` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL that streams a public publication's file through the API by its UUID.
|
||||
* Same reasoning as `fileViewUrl`: a presigned MinIO URL is not reachable from
|
||||
* the browser here, so the bytes are streamed through the API instead.
|
||||
*/
|
||||
export function publicationFileUrl(id: string, download = false): string {
|
||||
const base = `${API_BASE_URL}/api/publications/${id}/file`;
|
||||
return download ? `${base}?download=1` : base;
|
||||
}
|
||||
|
||||
25
apps/edr-freight-web/portal/src/hooks/usePublications.ts
Normal file
25
apps/edr-freight-web/portal/src/hooks/usePublications.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { PublicationSummary } from "@edr/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type { ApiResponse } from "@/types/apiResponse";
|
||||
import { client } from "@/utils/api";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
|
||||
/**
|
||||
* The public /publications library — PDFs, Markdown write-ups and PowerPoint
|
||||
* decks about the platform. Unauthenticated, same as `usePortalContent`; the
|
||||
* shared axios client only attaches a token when the cookie exists.
|
||||
*/
|
||||
export function usePublications() {
|
||||
return useQuery({
|
||||
queryKey: ["publications"],
|
||||
queryFn: async (): Promise<PublicationSummary[]> => {
|
||||
const response = await client.get<ApiResponse<PublicationSummary[]>>(
|
||||
URL_CONSTANTS.PUBLICATIONS.PUBLIC,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -1,23 +1,8 @@
|
||||
/** Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …). */
|
||||
export type Currency = string;
|
||||
|
||||
const SYMBOLS: Record<string, string> = {
|
||||
USD: "$",
|
||||
ETB: "Br",
|
||||
DJF: "DJF",
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a money amount with its currency symbol, e.g. `Br 12,500.00`.
|
||||
* Unknown currency codes fall back to printing the raw code.
|
||||
* Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …).
|
||||
* Re-exports the shared `@edr/ui-common` currency module so every currency
|
||||
* gets the same symbol, decimals rule and formatting across both freight web
|
||||
* apps — see that module for the source of truth.
|
||||
*/
|
||||
export function formatCurrency(
|
||||
amount: number,
|
||||
currency: Currency = "ETB",
|
||||
): string {
|
||||
const symbol = SYMBOLS[currency] ?? currency;
|
||||
return `${symbol} ${Number(amount ?? 0).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
export type { SupportedCurrency as Currency } from "@edr/ui-common";
|
||||
export { formatCurrency, currencySymbol, currencyDecimals, CURRENCY_CODES } from "@edr/ui-common";
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Mail,
|
||||
Map as MapIcon,
|
||||
MapPin,
|
||||
Menu,
|
||||
Package,
|
||||
PackageSearch,
|
||||
Phone,
|
||||
@@ -30,6 +29,8 @@ import {
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
|
||||
import { PublicNavbar } from "@/components/PublicNavbar";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Motion helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -137,14 +138,6 @@ function CountUp({
|
||||
/* Content */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const navLinks = [
|
||||
{ label: "Features", href: "#features" },
|
||||
{ label: "Live ops", href: "#showcase" },
|
||||
{ label: "Corridors", href: "#corridors" },
|
||||
{ label: "How it works", href: "#how" },
|
||||
{ label: "Contact", href: "#contact" },
|
||||
];
|
||||
|
||||
const heroTrust = [
|
||||
"Telebirr & CBE Birr payments",
|
||||
"Fayda ID verified",
|
||||
@@ -517,60 +510,7 @@ export default function EDRFreightLandingPage() {
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<LandingStyles />
|
||||
|
||||
{/* Navbar */}
|
||||
<header className="sticky top-0 z-50 border-b border-white/10 bg-edr-ink/90 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-20 max-w-7xl items-center justify-between px-6">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<span className="flex size-10 items-center justify-center rounded-xl bg-edr-primary text-white shadow-lg shadow-emerald-500/25">
|
||||
<TrainFront className="size-5" />
|
||||
</span>
|
||||
|
||||
<span className="leading-tight">
|
||||
<span className="block text-lg font-bold text-white">EDR Freight</span>
|
||||
<span className="block text-[11px] tracking-wide text-slate-400">
|
||||
Rail Logistics Platform
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-9 lg:flex">
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm font-medium text-slate-300 transition hover:text-white"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/login"
|
||||
className="hidden rounded-lg border border-white/20 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-white/10 md:block"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/signup"
|
||||
className="hidden items-center gap-2 rounded-lg bg-edr-primary px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-emerald-500/25 transition hover:-translate-y-0.5 hover:bg-edr-primary-dark md:flex"
|
||||
>
|
||||
Get started
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open menu"
|
||||
className="rounded-lg border border-white/20 p-2 text-white lg:hidden"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<PublicNavbar onLanding />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="edr-hero relative overflow-hidden bg-edr-ink">
|
||||
@@ -1248,6 +1188,7 @@ export default function EDRFreightLandingPage() {
|
||||
links: [
|
||||
{ label: "Help & Support", to: "/help" },
|
||||
{ label: "FAQ", to: "/faq" },
|
||||
{ label: "Publications", to: "/publications" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment";
|
||||
import { bookingCanPayOnline } from "@/pages/bookings/payments/offline-payment";
|
||||
|
||||
/** A pending customer action surfaced on the home "needs attention" card. */
|
||||
export interface ActionItem {
|
||||
@@ -64,7 +64,10 @@ export function deriveActionItems(
|
||||
? b.status === "FULLY_EXECUTED"
|
||||
: b.status === "SELECTED_FOR_BATCH");
|
||||
if (canPay) {
|
||||
const offlinePay = isUsdOfflineBooking(b);
|
||||
// Online-only description unless the booking's currency has no online
|
||||
// rail at all (USD) — DJF supports both, so it reads as a normal
|
||||
// "payment due" like ETB rather than bank-transfer-only.
|
||||
const offlinePay = !bookingCanPayOnline(b);
|
||||
items.push({
|
||||
id: `pay-${b.id}`,
|
||||
kind: "pay",
|
||||
|
||||
@@ -32,7 +32,7 @@ import { invoicesService } from "@/services/invoices.service";
|
||||
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
|
||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||
import { isUsdCurrency } from "@/pages/bookings/payments/offline-payment";
|
||||
import { canPayOffline, canPayOnline } from "@/pages/bookings/payments/offline-payment";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
|
||||
@@ -232,7 +232,7 @@ export default function InvoiceDetailPage() {
|
||||
Receipt
|
||||
</Button>
|
||||
)}
|
||||
{payable && !isUsdCurrency(invoice.currency) && (
|
||||
{payable && canPayOnline(invoice.currency) && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
@@ -247,7 +247,7 @@ export default function InvoiceDetailPage() {
|
||||
Pay {formatCurrency(amountDue, invoice.currency)}
|
||||
</Button>
|
||||
)}
|
||||
{payable && isUsdCurrency(invoice.currency) && (
|
||||
{payable && canPayOffline(invoice.currency) && (
|
||||
<Badge
|
||||
size="lg"
|
||||
radius="md"
|
||||
|
||||
@@ -32,7 +32,7 @@ import { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import { isUsdCurrency } from "@/pages/bookings/payments/offline-payment";
|
||||
import { canPayOnline } from "@/pages/bookings/payments/offline-payment";
|
||||
import {
|
||||
BORDER,
|
||||
GREEN,
|
||||
@@ -277,10 +277,11 @@ export default function InvoicesList() {
|
||||
{!isLoading &&
|
||||
!isError &&
|
||||
pageRows.map((inv) => {
|
||||
// USD invoices are paid by bank transfer — the detail page
|
||||
// shows the instructions, so the row action reads "View".
|
||||
// A currency with no online rail (USD) is paid by bank
|
||||
// transfer — the detail page shows the instructions, so the
|
||||
// row action reads "View".
|
||||
const payable =
|
||||
isPayable(inv.status) && !isUsdCurrency(inv.currency);
|
||||
isPayable(inv.status) && canPayOnline(inv.currency);
|
||||
return (
|
||||
<Table.Tr
|
||||
key={inv.id}
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from "@/services/bookings.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { REQUIRED_DOC_FIELDS } from "./constants";
|
||||
import { requiredDocFieldsFor } from "./constants";
|
||||
import { CardTitle, PageShell, SectionCard } from "./components/layout";
|
||||
import { CompanyInfoCard } from "./components/CompanyInfoCard";
|
||||
import { ContainersCard } from "./components/ContainersCard";
|
||||
@@ -73,10 +73,13 @@ export function DraftBookingView({
|
||||
() => new Set(booking.files?.map((f) => f.code) ?? []),
|
||||
[booking.files],
|
||||
);
|
||||
const uploadedCount = REQUIRED_DOC_FIELDS.filter((d) =>
|
||||
// An empty booking is measured against the equipment documents, not the
|
||||
// trade documents a laden shipment carries.
|
||||
const requiredDocs = requiredDocFieldsFor(booking.cargoCondition);
|
||||
const uploadedCount = requiredDocs.filter((d) =>
|
||||
uploadedCodes.has(d.key),
|
||||
).length;
|
||||
const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length;
|
||||
const allDocsUploaded = uploadedCount === requiredDocs.length;
|
||||
|
||||
const { data: generatedPricing } = useQuery(
|
||||
api.bookings.generatePrice.queryOptions({
|
||||
@@ -135,7 +138,7 @@ export function DraftBookingView({
|
||||
|
||||
function handleUploadAll() {
|
||||
const filesToUpload: Record<string, File | null> = {};
|
||||
for (const doc of REQUIRED_DOC_FIELDS) {
|
||||
for (const doc of requiredDocs) {
|
||||
if (selectedFiles[doc.key])
|
||||
filesToUpload[doc.key] = selectedFiles[doc.key]!;
|
||||
}
|
||||
@@ -144,7 +147,7 @@ export function DraftBookingView({
|
||||
}
|
||||
|
||||
function handleSubmitRequest() {
|
||||
const missing = REQUIRED_DOC_FIELDS.filter(
|
||||
const missing = requiredDocs.filter(
|
||||
(doc) => !uploadedCodes.has(doc.key),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
@@ -212,7 +215,7 @@ export function DraftBookingView({
|
||||
? "Required documents"
|
||||
: "Upload required documents"
|
||||
}
|
||||
desc={`${uploadedCount} of ${REQUIRED_DOC_FIELDS.length} uploaded.`}
|
||||
desc={`${uploadedCount} of ${requiredDocs.length} uploaded.`}
|
||||
action={
|
||||
<StepGhostButton
|
||||
icon={<Upload size={16} color="#334155" />}
|
||||
@@ -264,7 +267,7 @@ export function DraftBookingView({
|
||||
<CardTitle>Documents</CardTitle>
|
||||
<CountChip
|
||||
uploaded={uploadedCount}
|
||||
total={REQUIRED_DOC_FIELDS.length}
|
||||
total={requiredDocs.length}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -280,7 +283,7 @@ export function DraftBookingView({
|
||||
)}
|
||||
|
||||
<Box>
|
||||
{REQUIRED_DOC_FIELDS.map((doc, i) => {
|
||||
{requiredDocs.map((doc, i) => {
|
||||
const isUploaded = uploadedCodes.has(doc.key);
|
||||
const selected = selectedFiles[doc.key];
|
||||
const file = booking.files?.find((f) => f.code === doc.key);
|
||||
@@ -290,7 +293,7 @@ export function DraftBookingView({
|
||||
return (
|
||||
<DocRow
|
||||
key={doc.key}
|
||||
last={i === REQUIRED_DOC_FIELDS.length - 1}
|
||||
last={i === requiredDocs.length - 1}
|
||||
title={doc.label}
|
||||
meta={
|
||||
isUploaded
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { Freight } from "@edr/types";
|
||||
import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
|
||||
import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui";
|
||||
import { paymentStatusLabel } from "@/pages/bookings/booking-display";
|
||||
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment";
|
||||
import { bookingCanPayOffline, bookingCanPayOnline } from "@/pages/bookings/payments/offline-payment";
|
||||
import { PayerAccountNote } from "@/pages/bookings/payments/PayerAccountNote";
|
||||
import { payWindowState } from "@/pages/bookings/payments/payment-drain";
|
||||
import { PaymentProcessingNotice } from "@/pages/bookings/payments/PaymentProcessingNotice";
|
||||
@@ -200,7 +200,8 @@ export function BookingPaymentPanel({
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const paid = booking.paymentStatus === "PAID";
|
||||
const offlineUsd = isUsdOfflineBooking(booking);
|
||||
const payOnline = bookingCanPayOnline(booking);
|
||||
const payOffline = bookingCanPayOffline(booking);
|
||||
const isAdjusted =
|
||||
booking.adjustedTotalAmount !== null &&
|
||||
booking.adjustedTotalAmount !== undefined;
|
||||
@@ -293,7 +294,7 @@ export function BookingPaymentPanel({
|
||||
<PaymentProcessingNotice drainEndsAt={payWindow.drainEndsAt} />
|
||||
)}
|
||||
|
||||
{!paid && !draining && offlineUsd && (
|
||||
{!paid && !draining && payOffline && (
|
||||
<Box
|
||||
mt={14}
|
||||
p={14}
|
||||
@@ -307,10 +308,9 @@ export function BookingPaymentPanel({
|
||||
Pay by bank transfer
|
||||
</Text>
|
||||
<Text mt={4} fz="12.5px" c="#7A5A1E" lh={1.55}>
|
||||
Online payment isn't available for USD bookings. Transfer the
|
||||
total amount to EDR's bank account before the payment deadline,
|
||||
then send the payment slip to the EDR Finance department — they
|
||||
will confirm your payment.
|
||||
{payOnline
|
||||
? "You can also transfer the total amount to EDR\u2019s bank account before the payment deadline, then send the payment slip to the EDR Finance department \u2014 they will confirm your payment."
|
||||
: "Online payment isn\u2019t available for this booking\u2019s currency. Transfer the total amount to EDR\u2019s bank account before the payment deadline, then send the payment slip to the EDR Finance department \u2014 they will confirm your payment."}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
@@ -319,7 +319,7 @@ export function BookingPaymentPanel({
|
||||
<Box mt={16}>
|
||||
<Countdown
|
||||
deadline={booking.paymentDeadline}
|
||||
onPay={offlineUsd ? undefined : onPay}
|
||||
onPay={payOnline ? onPay : undefined}
|
||||
paying={paying}
|
||||
/>
|
||||
{!paid && <PayerAccountNote />}
|
||||
|
||||
@@ -44,16 +44,16 @@ const PROVIDERS: ProviderOption[] = [
|
||||
{
|
||||
method: "WAAFI",
|
||||
label: "Waafi",
|
||||
description: "Djibouti mobile money · USD",
|
||||
description: "Djibouti mobile money · USD or DJF",
|
||||
logo: "/assets/waafi.jpeg",
|
||||
currencies: ["USD"],
|
||||
currencies: ["USD", "DJF"],
|
||||
accent: "#2E5B96",
|
||||
},
|
||||
{
|
||||
method: "CAC_BANK",
|
||||
label: "CAC Bank",
|
||||
description: "Djibouti bank debit · confirmed by SMS OTP",
|
||||
currencies: ["USD"],
|
||||
currencies: ["USD", "DJF"],
|
||||
accent: "#8A5A17",
|
||||
},
|
||||
{
|
||||
@@ -74,14 +74,16 @@ const OTP_LENGTH = 4;
|
||||
const isBillMethod = (method: PaymentMethod) => method === "CBE_BILL";
|
||||
|
||||
/**
|
||||
* Pick the provider that settles in the booking's currency. USD → Waafi/CAC,
|
||||
* ETB → CBE bill. Falls back to the full list when unknown.
|
||||
* Pick the provider(s) that settle in the booking's currency: ETB → CBE
|
||||
* bill, USD/DJF → Waafi/CAC. Falls back to the full list when the currency
|
||||
* is unknown, but a *known* currency with no matching provider (ETB and USD
|
||||
* never share one) returns nothing rather than every provider — offering
|
||||
* CBE bill for a DJF invoice, say, would settle it in the wrong currency.
|
||||
*/
|
||||
function providersForCurrency(currency?: string | null): ProviderOption[] {
|
||||
const cur = currency?.trim().toUpperCase();
|
||||
if (!cur) return PROVIDERS;
|
||||
const matched = PROVIDERS.filter((p) => p.currencies.includes(cur));
|
||||
return matched.length > 0 ? matched : PROVIDERS;
|
||||
return PROVIDERS.filter((p) => p.currencies.includes(cur));
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
@@ -213,14 +215,14 @@ export function PaymentMethodModal({
|
||||
),
|
||||
[currency, otp, bill],
|
||||
);
|
||||
const [method, setMethod] = useState<PaymentMethod>(providers[0].method);
|
||||
const [method, setMethod] = useState<PaymentMethod>(providers[0]?.method ?? PROVIDERS[0].method);
|
||||
const [mobile, setMobile] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Keep the selection valid when the currency (and therefore provider list) changes.
|
||||
useEffect(() => {
|
||||
if (!providers.some((p) => p.method === method)) {
|
||||
if (providers.length > 0 && !providers.some((p) => p.method === method)) {
|
||||
setMethod(providers[0].method);
|
||||
}
|
||||
}, [providers, method]);
|
||||
@@ -462,14 +464,21 @@ export function PaymentMethodModal({
|
||||
Payment method
|
||||
</Text>
|
||||
<Stack gap={10}>
|
||||
{providers.map((option) => (
|
||||
<ProviderRow
|
||||
key={option.method}
|
||||
option={option}
|
||||
selected={method === option.method}
|
||||
onSelect={() => setMethod(option.method)}
|
||||
/>
|
||||
))}
|
||||
{providers.length === 0 ? (
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
No online payment method is available for this currency yet — use bank
|
||||
transfer instead.
|
||||
</Text>
|
||||
) : (
|
||||
providers.map((option) => (
|
||||
<ProviderRow
|
||||
key={option.method}
|
||||
option={option}
|
||||
selected={method === option.method}
|
||||
onSelect={() => setMethod(option.method)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{needsMobile && (
|
||||
|
||||
@@ -459,3 +459,22 @@ export const REQUIRED_DOC_FIELDS = [
|
||||
{ key: "certificate_of_origin", label: "Certificate of Origin" },
|
||||
{ key: "letter_of_credit", label: "Letter of Credit / LC" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Empty container import moves bare equipment: there is no sale behind it, so
|
||||
* none of the trade documents above exist. What EDR needs instead is the
|
||||
* instruction authorising the move and the interchange record for the boxes.
|
||||
*/
|
||||
export const EMPTY_REQUIRED_DOC_FIELDS = [
|
||||
{ key: "container_release_order", label: "Container Release Order" },
|
||||
{ key: "equipment_interchange_receipt", label: "Equipment Interchange Receipt (EIR)" },
|
||||
];
|
||||
|
||||
/** The document checklist a booking is measured against, by cargo condition. */
|
||||
export function requiredDocFieldsFor(
|
||||
cargoCondition?: string | null,
|
||||
): Array<{ key: string; label: string }> {
|
||||
return cargoCondition === "EMPTY"
|
||||
? EMPTY_REQUIRED_DOC_FIELDS
|
||||
: REQUIRED_DOC_FIELDS;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
getRouteDirection,
|
||||
initialBookingFormValues,
|
||||
isForwarderOperation,
|
||||
operationToCargoCondition,
|
||||
operationToProfileType,
|
||||
operationToTradeDirection,
|
||||
stepFields,
|
||||
@@ -554,6 +555,9 @@ export default function NewBookingPage() {
|
||||
data.cargoType === "container"
|
||||
? ("CONTAINER" as const)
|
||||
: ("BULK" as const),
|
||||
// Bare equipment: the box is the shipment. The API prices it off the
|
||||
// EMPTY_CONTAINER_IMPORT tariff and skips customs entirely.
|
||||
cargoCondition: operationToCargoCondition(data.operationType),
|
||||
containers:
|
||||
data.cargoType === "container"
|
||||
? data.containers.map((c) => ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { Banknote, Check, DollarSign } from "lucide-react";
|
||||
import { Banknote, Check, Coins, DollarSign } from "lucide-react";
|
||||
import { Controller, type Control } from "react-hook-form";
|
||||
import {
|
||||
PAYMENT_CURRENCY_OPTIONS,
|
||||
@@ -15,6 +15,7 @@ const CURRENCY_ICONS: Record<
|
||||
> = {
|
||||
USD: { icon: DollarSign, color: "#4F46E5" },
|
||||
ETB: { icon: Banknote, color: "#0A6F4D" },
|
||||
DJF: { icon: Coins, color: "#B45309" },
|
||||
};
|
||||
|
||||
export function PaymentCurrencyField({
|
||||
@@ -28,9 +29,10 @@ export function PaymentCurrencyField({
|
||||
*/
|
||||
allowUsd?: boolean;
|
||||
}) {
|
||||
// DJF is offered wherever USD is — both are import-shipment-only currencies.
|
||||
const options = allowUsd
|
||||
? PAYMENT_CURRENCY_OPTIONS
|
||||
: PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value !== "USD");
|
||||
: PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB");
|
||||
return (
|
||||
<Box mt={24}>
|
||||
<StepLabel>Payment currency</StepLabel>
|
||||
|
||||
@@ -21,9 +21,25 @@ export const OPERATION_TYPES = [
|
||||
// direct importer/exporter profile.
|
||||
"import_ff",
|
||||
"export_ff",
|
||||
// Empty container import: bare equipment railed north from Djibouti for
|
||||
// repositioning. IMPORT direction, container freight, no cargo — priced per
|
||||
// box by size off its own tariff.
|
||||
"empty_import",
|
||||
] as const;
|
||||
export type OperationType = (typeof OPERATION_TYPES)[number];
|
||||
|
||||
/** Whether the operation moves cargo or bare equipment. */
|
||||
export function operationToCargoCondition(
|
||||
op: OperationType | undefined,
|
||||
): "LADEN" | "EMPTY" {
|
||||
return op === "empty_import" ? "EMPTY" : "LADEN";
|
||||
}
|
||||
|
||||
/** True when the wizard should collect equipment only — no cargo, no customs. */
|
||||
export function isEmptyOperation(op: OperationType | undefined): boolean {
|
||||
return operationToCargoCondition(op) === "EMPTY";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shipment documents collected during booking creation. The fileKeys mirror
|
||||
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts.
|
||||
@@ -73,7 +89,7 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
|
||||
|
||||
export type BookingDocuments = Record<string, File | File[] | null>;
|
||||
|
||||
export const PAYMENT_CURRENCIES = ["USD", "ETB"] as const;
|
||||
export const PAYMENT_CURRENCIES = ["USD", "ETB", "DJF"] as const;
|
||||
export type PaymentCurrency = (typeof PAYMENT_CURRENCIES)[number];
|
||||
|
||||
export const PAYMENT_CURRENCY_OPTIONS: Array<{
|
||||
@@ -92,6 +108,12 @@ export const PAYMENT_CURRENCY_OPTIONS: Array<{
|
||||
label: "USD",
|
||||
description: "US Dollar — paid by bank transfer, slip sent to Finance.",
|
||||
},
|
||||
// Import shipments only, same as USD.
|
||||
{
|
||||
value: "DJF",
|
||||
label: "DJF",
|
||||
description: "Djibouti Franc — paid online, or by bank transfer.",
|
||||
},
|
||||
];
|
||||
|
||||
export const BOOKING_TYPES = ["one_time", "general_contract"] as const;
|
||||
@@ -546,8 +568,13 @@ export function allowedOperationsForProfiles(
|
||||
}
|
||||
}
|
||||
|
||||
// Any customer-side profile can also run domestic (intercity).
|
||||
if (isForwarder || isDirect) ops.add("intercity");
|
||||
// Any customer-side profile can also run domestic (intercity) and buy empty
|
||||
// equipment repositioning — an exporter needs boxes inland to stuff, an
|
||||
// importer and a forwarder both reposition on a client's behalf.
|
||||
if (isForwarder || isDirect) {
|
||||
ops.add("intercity");
|
||||
ops.add("empty_import");
|
||||
}
|
||||
|
||||
// Preserve a stable display order.
|
||||
return OPERATION_TYPES.filter((o) => ops.has(o));
|
||||
@@ -576,7 +603,9 @@ export function isForwarderOperation(
|
||||
export function operationToTradeDirection(
|
||||
op: OperationType,
|
||||
): Freight.ScheduleTradeDirection {
|
||||
if (op === "import" || op === "import_ff") return "IMPORT";
|
||||
if (op === "import" || op === "import_ff" || op === "empty_import") {
|
||||
return "IMPORT";
|
||||
}
|
||||
if (op === "export" || op === "export_ff") return "EXPORT";
|
||||
return "DOMESTIC";
|
||||
}
|
||||
@@ -591,6 +620,11 @@ export function operationToProfileType(
|
||||
): string {
|
||||
if (op === "import_ff" || op === "export_ff") return "freight_forwarder";
|
||||
if (isForwarderOperation(op, profileTypes)) return "freight_forwarder";
|
||||
// Empties are bought by importers, exporters restocking equipment and
|
||||
// forwarders alike; stamp it to whichever direct profile the company holds.
|
||||
if (op === "empty_import") {
|
||||
return profileTypes.includes("importer") ? "importer" : "freight_forwarder";
|
||||
}
|
||||
if (op === "import") return "importer";
|
||||
if (op === "export") return "exporter";
|
||||
return "freight_forwarder";
|
||||
@@ -610,6 +644,8 @@ export function filterBookableServices(
|
||||
return services.filter((s) => {
|
||||
if (!s.canBeBookedAlone) return false;
|
||||
if (operationType === "intercity" && s.includesCustoms) return false;
|
||||
// An empty box carries no declaration, so there is no clearance to sell.
|
||||
if (isEmptyOperation(operationType) && s.includesCustoms) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
Container,
|
||||
PackageCheck,
|
||||
PackageOpen,
|
||||
Truck,
|
||||
@@ -74,6 +75,15 @@ const OPTIONS: Array<{
|
||||
iconBg: "#EAF1FB",
|
||||
iconColor: "#2E5B96",
|
||||
},
|
||||
{
|
||||
value: "empty_import",
|
||||
title: "Empty Container Import",
|
||||
description:
|
||||
"Empty containers railed from Djibouti for repositioning. No cargo, no customs.",
|
||||
icon: <Container className="h-5 w-5" />,
|
||||
iconBg: "#FBF3E7",
|
||||
iconColor: "#A05A00",
|
||||
},
|
||||
];
|
||||
|
||||
export function Step0OperationType({
|
||||
@@ -119,6 +129,16 @@ export function Step0OperationType({
|
||||
description={opt.description}
|
||||
onClick={() => {
|
||||
field.onChange(opt.value);
|
||||
// Empty equipment is always containerised — set the cargo
|
||||
// kind here so the cargo step (which hides the picker) and
|
||||
// the submitted payload agree without the customer
|
||||
// choosing something that has no alternative.
|
||||
if (opt.value === "empty_import") {
|
||||
form.setValue("cargoType", "container", {
|
||||
shouldDirty: true,
|
||||
});
|
||||
form.setValue("cargoTypePath", [], { shouldDirty: true });
|
||||
}
|
||||
onSelect?.(opt.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Freight } from "@edr/types";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
calcWagons,
|
||||
isEmptyOperation,
|
||||
type BookingFormValues,
|
||||
} from "./schema";
|
||||
import {
|
||||
@@ -46,6 +47,10 @@ export function Step5CargoDetails({
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const cargoType = form.watch("cargoType");
|
||||
// Empty container import moves bare equipment: there is no commodity to pick
|
||||
// and bulk is not on offer, so the wizard locks the cargo kind to container
|
||||
// and asks only for sizes and counts.
|
||||
const isEmpty = isEmptyOperation(form.watch("operationType"));
|
||||
const cargoTypePath = form.watch("cargoTypePath") ?? [];
|
||||
const parentId = cargoTypePath[0];
|
||||
const childId = cargoTypePath[1];
|
||||
@@ -193,12 +198,16 @@ export function Step5CargoDetails({
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Package size={22} />}
|
||||
title="Cargo Details"
|
||||
description="Choose your cargo type and configuration. Container weight is captured later in operations."
|
||||
title={isEmpty ? "Container Details" : "Cargo Details"}
|
||||
description={
|
||||
isEmpty
|
||||
? "Tell us how many empty containers you are moving, by size. Empty containers carry no cargo, so no weight or commodity is collected."
|
||||
: "Choose your cargo type and configuration. Container weight is captured later in operations."
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Cargo Type */}
|
||||
<div className="space-y-3">
|
||||
{/* Cargo Type — hidden for empty equipment, which is always containers. */}
|
||||
<div className={isEmpty ? "hidden" : "space-y-3"}>
|
||||
<StepLabel>Cargo Type *</StepLabel>
|
||||
<Controller
|
||||
name="cargoType"
|
||||
@@ -239,7 +248,7 @@ export function Step5CargoDetails({
|
||||
|
||||
{/* Bulk freight type — pick the commodity FIRST so we know whether the
|
||||
cargo is measured in tons or items before asking for the quantity. */}
|
||||
{cargoType === "bulk" && (
|
||||
{cargoType === "bulk" && !isEmpty && (
|
||||
<div className="space-y-3">
|
||||
{freightTypeOptions.length > 0 ? (
|
||||
<Controller
|
||||
@@ -486,7 +495,7 @@ export function Step5CargoDetails({
|
||||
)}
|
||||
|
||||
{/* Container list — type + quantity only; no weight is collected here. */}
|
||||
{cargoType === "container" && (
|
||||
{(cargoType === "container" || isEmpty) && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
/**
|
||||
* USD bookings are never paid online: the customer pays by bank transfer and
|
||||
* the Finance department confirms the payment from the slip. Phase 1 is
|
||||
* portal-only — Finance's confirm flow lands in the backoffice later.
|
||||
* Which payment rails a currency supports. ETB settles online only (the
|
||||
* payment gateway); USD is bank-transfer-only (never through the online
|
||||
* gateway) — the customer pays by bank transfer and the Finance department
|
||||
* confirms the payment from the slip. DJF supports BOTH: it settles through
|
||||
* a Djibouti gateway (WAAFI / CAC Bank) as well as by bank transfer.
|
||||
*
|
||||
* These two predicates are intentionally independent, not opposites of one
|
||||
* currency check — DJF is neither purely online nor purely offline.
|
||||
*/
|
||||
export function isUsdCurrency(currency?: string | null): boolean {
|
||||
return currency?.toUpperCase() === "USD";
|
||||
export function canPayOnline(currency?: string | null): boolean {
|
||||
const c = currency?.toUpperCase();
|
||||
return c === "ETB" || c === "DJF";
|
||||
}
|
||||
|
||||
export function isUsdOfflineBooking(booking: Freight.IBooking): boolean {
|
||||
return isUsdCurrency(
|
||||
booking.pricingBreakdown?.currency ?? booking.paymentCurrency,
|
||||
);
|
||||
export function canPayOffline(currency?: string | null): boolean {
|
||||
const c = currency?.toUpperCase();
|
||||
return c === "USD" || c === "DJF";
|
||||
}
|
||||
|
||||
function bookingCurrency(booking: Freight.IBooking): string | null | undefined {
|
||||
return booking.pricingBreakdown?.currency ?? booking.paymentCurrency;
|
||||
}
|
||||
|
||||
export function bookingCanPayOnline(booking: Freight.IBooking): boolean {
|
||||
return canPayOnline(bookingCurrency(booking));
|
||||
}
|
||||
|
||||
export function bookingCanPayOffline(booking: Freight.IBooking): boolean {
|
||||
return canPayOffline(bookingCurrency(booking));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isPayable } from "@/pages/billing/invoice-ui";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
|
||||
import { isUsdOfflineBooking } from "./offline-payment";
|
||||
import { bookingCanPayOnline } from "./offline-payment";
|
||||
import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "./useBookingPayment";
|
||||
|
||||
export type PayableAction =
|
||||
@@ -77,7 +77,11 @@ export function useBookingPayables(booking: Freight.IBooking) {
|
||||
|
||||
const items = useMemo(() => {
|
||||
const out: PayableItem[] = [];
|
||||
const offline = isUsdOfflineBooking(booking);
|
||||
// The strip's single action per item picks online when it's available at
|
||||
// all (DJF supports both rails; the freight-payment card itself offers
|
||||
// both) and falls back to the bank-transfer instructions only when
|
||||
// online isn't an option (USD).
|
||||
const offline = !bookingCanPayOnline(booking);
|
||||
|
||||
for (const inv of invoicesQ.data ?? []) {
|
||||
const balance = Number(inv.balanceAmount ?? 0);
|
||||
|
||||
@@ -310,7 +310,9 @@ function mapBookingToShipmentValues(
|
||||
// Resubmit keeps the currency the customer already chose on this booking;
|
||||
// a missing value falls back to empty so the choice is made deliberately.
|
||||
paymentCurrency:
|
||||
booking.paymentCurrency === "USD" || booking.paymentCurrency === "ETB"
|
||||
booking.paymentCurrency === "USD" ||
|
||||
booking.paymentCurrency === "ETB" ||
|
||||
booking.paymentCurrency === "DJF"
|
||||
? booking.paymentCurrency
|
||||
: "",
|
||||
withReturn: booking.equipmentReturn === "WITH_RETURN",
|
||||
@@ -1464,7 +1466,7 @@ function ScheduleStep({
|
||||
<StepLabel>Billing currency *</StepLabel>
|
||||
<Text fz={12.5} c="dimmed" mt={4} mb={10}>
|
||||
{isImport
|
||||
? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online."
|
||||
? "Import shipments may be invoiced in ETB, USD or DJF. USD is paid by bank transfer, not online."
|
||||
: "Shipments are invoiced in ETB."}
|
||||
</Text>
|
||||
<CurrencySelector
|
||||
@@ -1472,6 +1474,7 @@ function ScheduleStep({
|
||||
onChange={(v) => field.onChange(v)}
|
||||
error={fieldState.error?.message}
|
||||
allowUsd={isImport}
|
||||
allowDjf={isImport}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function NewShipmentRequestPage() {
|
||||
// to be invoiced in has to be stated here — the contract itself quotes USD.
|
||||
// Starts empty so the billing-currency choice is deliberate — required at
|
||||
// submit. Intercity/export are forced to ETB (server-enforced too).
|
||||
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "">("");
|
||||
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "DJF" | "">("");
|
||||
const [currencyError, setCurrencyError] = useState<string | undefined>();
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
@@ -125,7 +125,7 @@ export default function NewShipmentRequestPage() {
|
||||
contractRouteId: route?.id,
|
||||
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
|
||||
paymentCurrency:
|
||||
isIntercity || isExport ? "ETB" : (paymentCurrency as "USD" | "ETB"),
|
||||
isIntercity || isExport ? "ETB" : (paymentCurrency as "USD" | "ETB" | "DJF"),
|
||||
notes: notes.trim() || undefined,
|
||||
};
|
||||
|
||||
@@ -267,6 +267,7 @@ export default function NewShipmentRequestPage() {
|
||||
}}
|
||||
disabled={isIntercity || isExport}
|
||||
allowUsd={!isIntercity && !isExport}
|
||||
allowDjf={!isIntercity && !isExport}
|
||||
error={currencyError}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -75,7 +75,7 @@ export const CONTRACT_KIND_OPTIONS: Array<{
|
||||
|
||||
export type ContractDocuments = Record<string, File | File[] | null>;
|
||||
|
||||
export const PAYMENT_CURRENCIES = ["USD", "ETB"] as const;
|
||||
export const PAYMENT_CURRENCIES = ["USD", "ETB", "DJF"] as const;
|
||||
export type PaymentCurrency = (typeof PAYMENT_CURRENCIES)[number];
|
||||
|
||||
export const PAYMENT_CURRENCY_OPTIONS: Array<{
|
||||
@@ -93,6 +93,11 @@ export const PAYMENT_CURRENCY_OPTIONS: Array<{
|
||||
label: "ETB",
|
||||
description: "Ethiopian Birr — local pricing and invoicing.",
|
||||
},
|
||||
{
|
||||
value: "DJF",
|
||||
label: "DJF",
|
||||
description: "Djibouti Franc — Djibouti-side pricing and invoicing.",
|
||||
},
|
||||
];
|
||||
|
||||
// one_time → ContractKind.OneTime; general_contract → ContractKind.General.
|
||||
|
||||
@@ -101,7 +101,7 @@ const shipmentFormBase = z.object({
|
||||
// The contract quotes in USD; the customer picks the billing currency for
|
||||
// THIS shipment. Starts empty so the choice is deliberate — validated as
|
||||
// required below. Intercity is forced to ETB (server-enforced too).
|
||||
paymentCurrency: z.enum(["USD", "ETB", ""]).default(""),
|
||||
paymentCurrency: z.enum(["USD", "ETB", "DJF", ""]).default(""),
|
||||
// Container contracts only: return the empty container(s) to EDR after
|
||||
// unloading. Seeded from the contract's equipment return; bulk ignores it.
|
||||
withReturn: z.boolean().default(false),
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import type { PublicationSummary } from "@edr/types";
|
||||
import { useFileViewer } from "@edr/ui-common";
|
||||
import {
|
||||
Download,
|
||||
FileText,
|
||||
Library,
|
||||
Presentation,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PublicNavbar } from "@/components/PublicNavbar";
|
||||
import { publicationFileUrl } from "@/constants/apiConfig";
|
||||
import { usePublications } from "@/hooks/usePublications";
|
||||
|
||||
import { Markdown } from "../support/Markdown";
|
||||
import { DocFooter } from "../support/DocShell";
|
||||
|
||||
const MARKDOWN_MIMES = new Set(["text/markdown", "text/x-markdown"]);
|
||||
|
||||
type PublicationKind = "pdf" | "markdown" | "slides" | "other";
|
||||
|
||||
function kindOf(pub: PublicationSummary): PublicationKind {
|
||||
if (MARKDOWN_MIMES.has(pub.fileMimeType) || pub.fileName.toLowerCase().endsWith(".md")) {
|
||||
return "markdown";
|
||||
}
|
||||
if (pub.fileMimeType === "application/pdf") return "pdf";
|
||||
if (
|
||||
pub.fileMimeType.includes("powerpoint") ||
|
||||
pub.fileMimeType.includes("presentationml")
|
||||
) {
|
||||
return "slides";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
const KIND_META: Record<
|
||||
PublicationKind,
|
||||
{ label: string; icon: typeof FileText; accent: string }
|
||||
> = {
|
||||
pdf: { label: "PDF", icon: FileText, accent: "from-rose-500/15 to-rose-500/5" },
|
||||
markdown: { label: "Markdown", icon: FileText, accent: "from-sky-500/15 to-sky-500/5" },
|
||||
slides: {
|
||||
label: "PowerPoint",
|
||||
icon: Presentation,
|
||||
accent: "from-amber-500/15 to-amber-500/5",
|
||||
},
|
||||
other: { label: "Document", icon: FileText, accent: "from-slate-500/15 to-slate-500/5" },
|
||||
};
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
return new Date(value).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** True once the element has scrolled into view — and stays true afterwards. */
|
||||
function useInView<T extends HTMLElement>() {
|
||||
const ref = useRef<T | null>(null);
|
||||
const [seen, setSeen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node || seen) return;
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setSeen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setSeen(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [seen]);
|
||||
|
||||
return { ref, seen };
|
||||
}
|
||||
|
||||
/**
|
||||
* The card's preview panel.
|
||||
*
|
||||
* A PDF renders its own first page in a muted, non-interactive iframe, and a
|
||||
* Markdown file shows the opening lines of its actual text. Both only load
|
||||
* once the card is near the viewport — a grid of publications would otherwise
|
||||
* pull every file on first paint.
|
||||
*
|
||||
* Slides get a drawn cover rather than a real thumbnail: the Office Online
|
||||
* viewer is the only thing that can rasterise a .pptx here, and embedding it
|
||||
* per card is far too heavy for a listing. Clicking through still opens the
|
||||
* real thing.
|
||||
*/
|
||||
function PublicationPreview({ pub }: { pub: PublicationSummary }) {
|
||||
const kind = kindOf(pub);
|
||||
const { ref, seen } = useInView<HTMLDivElement>();
|
||||
const [excerpt, setExcerpt] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (kind !== "markdown" || !seen || excerpt !== null) return;
|
||||
let cancelled = false;
|
||||
|
||||
void fetch(publicationFileUrl(pub.id))
|
||||
.then((response) => response.text())
|
||||
.then((text) => {
|
||||
if (!cancelled) setExcerpt(text.slice(0, 600));
|
||||
})
|
||||
.catch(() => {
|
||||
// A failed preview is cosmetic — the card still opens and downloads.
|
||||
if (!cancelled) setExcerpt("");
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [kind, seen, excerpt, pub.id]);
|
||||
|
||||
const { icon: Icon, accent } = KIND_META[kind];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative h-44 overflow-hidden border-b border-border bg-gradient-to-br ${accent}`}
|
||||
>
|
||||
{kind === "pdf" && seen ? (
|
||||
<iframe
|
||||
// Chrome's built-in PDF viewer honours these; the fragment keeps the
|
||||
// toolbar and scrollbars out of what is meant to read as a cover.
|
||||
src={`${publicationFileUrl(pub.id)}#page=1&toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
|
||||
title={`${pub.title} preview`}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
// The iframe is decoration: clicks belong to the card's buttons.
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[220%] w-full origin-top scale-[0.62] border-0"
|
||||
/>
|
||||
) : kind === "markdown" ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 origin-top-left scale-[0.78] overflow-hidden p-4"
|
||||
>
|
||||
{excerpt ? (
|
||||
<Markdown>{excerpt}</Markdown>
|
||||
) : (
|
||||
<div className="space-y-2 pt-2">
|
||||
{[92, 78, 85, 60].map((width, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-2.5 rounded-full bg-foreground/10"
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Icon className="size-14 text-foreground/25" strokeWidth={1.25} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fades the preview into the card body so a clipped page doesn't end
|
||||
on a hard edge. */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-background to-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicationCard({
|
||||
pub,
|
||||
onOpen,
|
||||
}: {
|
||||
pub: PublicationSummary;
|
||||
onOpen: (pub: PublicationSummary) => void;
|
||||
}) {
|
||||
const kind = kindOf(pub);
|
||||
const meta = KIND_META[kind];
|
||||
const published = formatDate(pub.publishedAt);
|
||||
|
||||
return (
|
||||
<article className="group flex flex-col overflow-hidden rounded-3xl border border-border bg-background shadow-sm transition hover:-translate-y-1 hover:border-primary/40 hover:shadow-xl">
|
||||
<PublicationPreview pub={pub} />
|
||||
|
||||
<div className="flex flex-1 flex-col p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<meta.icon className="size-3" />
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{pub.category ? <Badge variant="secondary">{pub.category}</Badge> : null}
|
||||
</div>
|
||||
|
||||
<h3 className="mt-3 text-lg font-bold leading-snug tracking-tight">
|
||||
{pub.title}
|
||||
</h3>
|
||||
|
||||
{pub.description ? (
|
||||
<p className="mt-2 line-clamp-3 text-sm leading-6 text-muted-foreground">
|
||||
{pub.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{formatSize(pub.fileSizeBytes)}</span>
|
||||
{published ? (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{published}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-2 pt-0">
|
||||
<Button className="flex-1" onClick={() => onOpen(pub)}>
|
||||
{kind === "markdown" ? "Read" : "Preview"}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" asChild>
|
||||
<a
|
||||
href={publicationFileUrl(pub.id, true)}
|
||||
aria-label={`Download ${pub.title}`}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public library of platform documentation: PDFs, Markdown write-ups and
|
||||
* PowerPoint decks, curated from the backoffice. No login required.
|
||||
*
|
||||
* Carries the marketing navbar rather than {@link DocShell}'s plain doc
|
||||
* header — this page is something a prospect is pointed at, so it should sit
|
||||
* inside the same chrome as the landing page it is linked from.
|
||||
*
|
||||
* Markdown opens in an in-page reader using the same renderer the legal pages
|
||||
* use; everything else goes through the shared `FileViewerModal`, whose
|
||||
* "text" kind is a raw iframe with no markdown rendering.
|
||||
*/
|
||||
export default function PublicationsPage() {
|
||||
const { data: publications, isLoading } = usePublications();
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [reading, setReading] = useState<PublicationSummary | null>(null);
|
||||
const [markdownText, setMarkdownText] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q || !publications) return publications ?? [];
|
||||
return publications.filter(
|
||||
(pub) =>
|
||||
pub.title.toLowerCase().includes(q) ||
|
||||
(pub.description ?? "").toLowerCase().includes(q) ||
|
||||
(pub.category ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [publications, query]);
|
||||
|
||||
const openMarkdown = async (pub: PublicationSummary) => {
|
||||
setReading(pub);
|
||||
setMarkdownText(null);
|
||||
try {
|
||||
const response = await fetch(publicationFileUrl(pub.id));
|
||||
setMarkdownText(await response.text());
|
||||
} catch {
|
||||
setMarkdownText("Sorry — this document could not be loaded. Try downloading it.");
|
||||
}
|
||||
};
|
||||
|
||||
const open = (pub: PublicationSummary) => {
|
||||
if (kindOf(pub) === "markdown") {
|
||||
void openMarkdown(pub);
|
||||
return;
|
||||
}
|
||||
view({
|
||||
name: pub.fileName,
|
||||
url: publicationFileUrl(pub.id),
|
||||
mimeType: pub.fileMimeType,
|
||||
});
|
||||
};
|
||||
|
||||
// Escape closes the markdown reader, like the shared viewer's modal.
|
||||
useEffect(() => {
|
||||
if (!reading) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setReading(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [reading]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<PublicNavbar />
|
||||
|
||||
{/* Hero, in the landing page's dark band so the navbar sits on the tone
|
||||
it was designed for. */}
|
||||
<section className="border-b border-white/10 bg-edr-ink">
|
||||
<div className="mx-auto max-w-6xl px-6 py-16">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 py-1.5 pl-2 pr-3 text-[13px] font-medium text-white">
|
||||
<Library className="size-4" />
|
||||
Resource library
|
||||
</span>
|
||||
|
||||
<h1 className="mt-5 text-4xl font-black tracking-tight text-white sm:text-5xl">
|
||||
Publications
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-lg leading-8 text-slate-300">
|
||||
Guides, reports and presentations about the EDR Freight platform —
|
||||
read them here or download a copy.
|
||||
</p>
|
||||
|
||||
<div className="relative mt-8 max-w-md">
|
||||
<Search className="pointer-events-none absolute left-4 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search publications…"
|
||||
aria-label="Search publications"
|
||||
className="w-full rounded-xl border border-white/15 bg-white/5 py-3 pl-11 pr-4 text-sm text-white placeholder:text-slate-400 focus:border-edr-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||
{isLoading ? (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-96 animate-pulse rounded-3xl border border-border bg-accent/40"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-border py-20 text-center">
|
||||
<Library className="mx-auto size-10 text-muted-foreground/50" strokeWidth={1.25} />
|
||||
<p className="mt-4 font-semibold">
|
||||
{query.trim() ? "No publications match that search." : "Nothing published yet."}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{query.trim() ? "Try a different word." : "Check back soon."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((pub) => (
|
||||
<PublicationCard key={pub.id} pub={pub} onOpen={open} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<DocFooter current="/publications" />
|
||||
|
||||
{/* PDF / slide preview for everything except Markdown. */}
|
||||
{viewer}
|
||||
|
||||
{reading ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={reading.title}
|
||||
onClick={() => setReading(null)}
|
||||
>
|
||||
<div
|
||||
className="flex max-h-[85vh] w-full max-w-3xl flex-col overflow-hidden rounded-3xl bg-background shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 border-b border-border px-6 py-4">
|
||||
<h2 className="truncate font-bold">{reading.title}</h2>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={publicationFileUrl(reading.id, true)}>
|
||||
<Download className="size-4" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReading(null)}
|
||||
aria-label="Close"
|
||||
className="rounded-full p-1.5 transition hover:bg-accent"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto px-8 py-6">
|
||||
{markdownText === null ? (
|
||||
<p className="text-muted-foreground">Loading…</p>
|
||||
) : (
|
||||
<Markdown>{markdownText}</Markdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Markdown } from "./Markdown";
|
||||
const DOC_LINKS = [
|
||||
{ to: "/help", label: "Help & Support" },
|
||||
{ to: "/faq", label: "FAQ" },
|
||||
{ to: "/publications", label: "Publications" },
|
||||
{ to: "/privacy", label: "Privacy Policy" },
|
||||
{ to: "/terms", label: "Terms of Service" },
|
||||
];
|
||||
@@ -78,26 +79,37 @@ export function DocShell({
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-border py-8">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 px-6 text-sm text-muted-foreground md:flex-row">
|
||||
<span>© 2026 EDR Freight. All rights reserved.</span>
|
||||
<nav className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
|
||||
{DOC_LINKS.filter((l) => l.to !== current).map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="font-semibold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
<DocFooter current={current} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer shared by every public doc page, cross-linking the others. Exported
|
||||
* so /publications can carry it under the marketing navbar without also
|
||||
* inheriting {@link DocShell}'s own header.
|
||||
*/
|
||||
export function DocFooter({ current }: { current: string }) {
|
||||
return (
|
||||
<footer className="border-t border-border py-8">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 px-6 text-sm text-muted-foreground md:flex-row">
|
||||
<span>© 2026 EDR Freight. All rights reserved.</span>
|
||||
<nav className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
|
||||
{DOC_LINKS.filter((l) => l.to !== current).map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="font-semibold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a legal document's numbered sections. Bodies are markdown, so the
|
||||
* paragraph and bullet arrays this used to walk are one string now — keyed by
|
||||
|
||||
Reference in New Issue
Block a user