Merge branch 'freight_feature/usermanagement' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
marshalyordanos
2026-09-06 10:32:09 +03:00
217 changed files with 8668 additions and 804 deletions

View File

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

View File

@@ -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" },

View File

@@ -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}`;
/**

View File

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

View File

@@ -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"
}`
: ""}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)}
/>

View File

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

View File

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

View File

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

View File

@@ -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: {

View File

@@ -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",

View File

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

View File

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

View File

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

View File

@@ -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",
},
]}
/>

View File

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

View File

@@ -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>
),
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

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

View File

@@ -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}`);
},
};

View File

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

View File

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

View File

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