feat(freight-backoffice): support DJF in booking, contract and warehouse screens

Currency dropdowns/pickers (AdditionalPaymentsTab, ClearanceChargesTab,
PhasedClearanceActionPanel, AdviseDutyCard, ContractRequestsPage,
ruleEngine/resources, WarehouseRulesPage, VehicleDetailPage,
FeePreviewModal) offer DJF alongside ETB/USD; GlCreateBookingForm's
currency selector gets allowDjf next to allowUsd. Narrow 'ETB'|'USD'
type unions widened to include 'DJF' across the warehouse
billingCurrency plumbing (useWarehouses, warehouse.service, api.ts)
and the customer/invoice types.

Ad-hoc money() formatters (BookingTrucksPanel, AccrualDashboard,
ImportTrucksPage, EmptyReturnRequestsPage) and formatMoney call sites
that hardcoded 2 decimals (wagon-cancellation cards, BookingRequestDetailPage,
WagonCancellationsPage, PaymentsPage, WarehouseInvoicesPage) now use
currencyDecimals() from @edr/ui-common so DJF renders with 0 decimals
instead of forced cents. The 3 duplicate overview formatCurrency/
formatAmount helpers (typed 'ETB'|'USD') widen to accept any currency.

Two correctness fixes: OverviewRecentBookingsTable's currency==='USD'
? 'USD' : 'ETB' was mislabeling every non-USD currency as ETB; and
WarehouseInvoicesPage's gateway-method default now routes any
non-ETB currency (not just USD) to WAAFI, so DJF invoices get a
working default instead of TELEBIRR (ETB-only).

Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
This commit is contained in:
ghost2023
2026-09-04 11:53:56 +03:00
parent 34c49e4c50
commit 214f96dbae
30 changed files with 61 additions and 47 deletions

View File

@@ -36,7 +36,7 @@ import { downloadBookingFile, fetchViewableFile } from "@/services/files.service
import { formatDate, formatDateTime } from "@/lib/format"; import { formatDate, formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor"; import { extractErrorMessage } from "@/utils/errorExtractor";
const CURRENCIES = ["ETB", "USD"]; const CURRENCIES = ["ETB", "USD", "DJF"];
const STATUS_META: Record<Freight.AdditionalChargeStatus, { label: string; color: string }> = { const STATUS_META: Record<Freight.AdditionalChargeStatus, { label: string; color: string }> = {
DRAFT: { label: "Draft", color: "gray" }, DRAFT: { label: "Draft", color: "gray" },

View File

@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { useQueries, useQuery } from "@tanstack/react-query"; import { useQueries, useQuery } from "@tanstack/react-query";
import { Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core"; import { Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core";
import { Coins, Truck } from "lucide-react"; import { Coins, Truck } from "lucide-react";
import { currencyDecimals } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal"; import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal";
@@ -12,8 +13,8 @@ import { MetricTile } from "./MetricTile";
const money = (amount: number, currency: string) => const money = (amount: number, currency: string) =>
`${Number(amount).toLocaleString(undefined, { `${Number(amount).toLocaleString(undefined, {
minimumFractionDigits: 2, minimumFractionDigits: currencyDecimals(currency),
maximumFractionDigits: 2, maximumFractionDigits: currencyDecimals(currency),
})} ${currency === "ETB" ? "Birr (ETB)" : 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 { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast"; 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 } from "@/auth/http";
import { api as rpc } from "@/services/api"; import { api as rpc } from "@/services/api";
@@ -223,7 +223,7 @@ export function RebookWagonCancellationModal({
<Text size="sm"> <Text size="sm">
{cancellation.booking?.reference ?? cancellation.bookingId} ·{" "} {cancellation.booking?.reference ?? cancellation.bookingId} ·{" "}
{cancellation.wagonsCancelled} wagon(s) · credit{" "} {cancellation.wagonsCancelled} wagon(s) · credit{" "}
{formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)} {formatMoney(cancellation.creditAmount, cancellation.feeCurrency, currencyDecimals(cancellation.feeCurrency))}
</Text> </Text>
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
Shipment day Shipment day

View File

@@ -8,6 +8,7 @@ import { api } from "@/auth/http";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { formatDate, formatMoney } from "@/lib/format"; import { formatDate, formatMoney } from "@/lib/format";
import { currencyDecimals } from "@edr/ui-common";
import { RebookWagonCancellationModal } from "./RebookWagonCancellationModal"; import { RebookWagonCancellationModal } from "./RebookWagonCancellationModal";
import { import {
canRebookWagonCancellations, canRebookWagonCancellations,
@@ -73,7 +74,7 @@ export function WagonCancellationCreditCard({
<Group gap={8} wrap="nowrap"> <Group gap={8} wrap="nowrap">
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
{Number(r.wagonsCancelled)} wagon(s) · credit{" "} {Number(r.wagonsCancelled)} wagon(s) · credit{" "}
{formatMoney(Number(r.creditAmount), r.feeCurrency, 2)} {formatMoney(Number(r.creditAmount), r.feeCurrency, currencyDecimals(r.feeCurrency))}
</Text> </Text>
<Badge color={chip.color} variant="light" size="sm" radius="md"> <Badge color={chip.color} variant="light" size="sm" radius="md">
{chip.label} {chip.label}
@@ -83,7 +84,7 @@ export function WagonCancellationCreditCard({
Cancelled {formatDate(r.createdAt)} Cancelled {formatDate(r.createdAt)}
{r.fault ? ` · ${r.fault === "EDR" ? "EDR fault (no fee)" : "customer fault"}` : ""} {r.fault ? ` · ${r.fault === "EDR" ? "EDR fault (no fee)" : "customer fault"}` : ""}
{Number(r.feeAmount) > 0 {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" r.feePaidAt ? " paid" : " unpaid"
}` }`
: ""} : ""}

View File

@@ -39,7 +39,7 @@ import {
import { formatDateTime } from "@/lib/format"; import { formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor"; import { extractErrorMessage } from "@/utils/errorExtractor";
const CURRENCIES = ["ETB", "USD"]; const CURRENCIES = ["ETB", "USD", "DJF"];
const STATUS_META: Record< const STATUS_META: Record<
Freight.ClearanceChargeStatus, Freight.ClearanceChargeStatus,

View File

@@ -345,7 +345,7 @@ export default function GlCreateBookingForm() {
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
// IMPORT bookings pick ETB or USD — starts empty so the choice is // IMPORT bookings pick ETB or USD — starts empty so the choice is
// deliberate (required before pricing). Everything else is forced to ETB. // 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). // What the containers carry — captured per booking (moved off the contract).
const [cargoDescription, setCargoDescription] = useState(""); const [cargoDescription, setCargoDescription] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]); 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. // 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"; isImport && paymentCurrency ? paymentCurrency : "ETB";
const currencyError = const currencyError =
isImport && !paymentCurrency isImport && !paymentCurrency
@@ -2351,7 +2351,7 @@ export default function GlCreateBookingForm() {
{requestCurrencyLocked {requestCurrencyLocked
? "The customer chose the billing currency on the shipment request — it cannot be changed." ? "The customer chose the billing currency on the shipment request — it cannot be changed."
: isImport : 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."} : "Shipments are invoiced in ETB."}
</Text> </Text>
<CurrencySelector <CurrencySelector
@@ -2359,6 +2359,7 @@ export default function GlCreateBookingForm() {
onChange={setPaymentCurrency} onChange={setPaymentCurrency}
disabled={!isImport || requestCurrencyLocked} disabled={!isImport || requestCurrencyLocked}
allowUsd={isImport} allowUsd={isImport}
allowDjf={isImport}
error={currencyError} error={currencyError}
/> />
</Box> </Box>

View File

@@ -1554,7 +1554,7 @@ function SecondDutyStep({
/> />
<Select <Select
label="Currency" label="Currency"
data={["ETB", "USD"]} data={["ETB", "USD", "DJF"]}
value={currency} value={currency}
onChange={(v) => setCurrency(v ?? "ETB")} onChange={(v) => setCurrency(v ?? "ETB")}
size="sm" size="sm"
@@ -1944,7 +1944,7 @@ function DraftDeclarationStep({
/> />
<Select <Select
label="Currency" label="Currency"
data={["ETB", "USD"]} data={["ETB", "USD", "DJF"]}
value={currency} value={currency}
onChange={(v) => setCurrency(v ?? "ETB")} onChange={(v) => setCurrency(v ?? "ETB")}
size="sm" size="sm"
@@ -2063,7 +2063,7 @@ function DutyStep({
/> />
<Select <Select
label="Currency" label="Currency"
data={["ETB", "USD"]} data={["ETB", "USD", "DJF"]}
value={currency} value={currency}
onChange={(v) => setCurrency(v ?? "ETB")} onChange={(v) => setCurrency(v ?? "ETB")}
size="sm" size="sm"

View File

@@ -54,7 +54,7 @@ export function AdviseDutyCard({
/> />
<Select <Select
label="Currency" label="Currency"
data={["ETB", "USD"]} data={["ETB", "USD", "DJF"]}
value={currency} value={currency}
onChange={(v) => setCurrency(v ?? "ETB")} onChange={(v) => setCurrency(v ?? "ETB")}
size="sm" size="sm"

View File

@@ -18,7 +18,7 @@ function formatDateLabel(date: string) {
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" }); 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", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency, currency,

View File

@@ -9,10 +9,9 @@ import { SummaryCard } from "./summary/SummaryCard";
function formatAmount(amount: number | null, currency: string | null) { function formatAmount(amount: number | null, currency: string | null) {
if (amount == null) return "—"; if (amount == null) return "—";
const code = currency === "USD" ? "USD" : "ETB";
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: code, currency: currency || "ETB",
maximumFractionDigits: 0, maximumFractionDigits: 0,
}).format(amount); }).format(amount);
} }

View File

@@ -4,7 +4,7 @@ import { KpiStrip, type KpiItem } from "@/components/page";
import type { IOverviewKpis, IOverviewPeriodTotals } from "@/types/overview"; import type { IOverviewKpis, IOverviewPeriodTotals } from "@/types/overview";
import { CountUp } from "./CountUp"; import { CountUp } from "./CountUp";
function formatCurrency(amount: number, currency: "ETB" | "USD") { function formatCurrency(amount: number, currency: string) {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
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. */ /** 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", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency, currency,

View File

@@ -18,7 +18,7 @@ import { OverviewKpiStrip } from "../OverviewKpiStrip";
import { OverviewPaymentChart } from "../OverviewPaymentChart"; import { OverviewPaymentChart } from "../OverviewPaymentChart";
import { overviewChartColors } from "../overview.styles"; import { overviewChartColors } from "../overview.styles";
function formatCurrency(amount: number, currency: "ETB" | "USD") { function formatCurrency(amount: number, currency: string) {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
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 { ActionIcon, Badge, Card, Group, Loader, Menu, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react'; import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react';
import { currencyDecimals } from '@edr/ui-common';
import { useAccrualDashboard } from '@/hooks/useWarehouses'; import { useAccrualDashboard } from '@/hooks/useWarehouses';
import { warehouseService } from '@/services/warehouse.service'; 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 { 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 { 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. */ /** Batch 5 fee preview + Batch 6 invoice generation / gate clearance for an inventory item. */
export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) { export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) {
const { toast } = useToast(); 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 enabledId = opened ? inventoryId ?? undefined : undefined;
const { data, isLoading } = useQuery( const { data, isLoading } = useQuery(
api.warehouses.feePreview.queryOptions({ api.warehouses.feePreview.queryOptions({
@@ -211,10 +211,11 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
<SegmentedControl <SegmentedControl
size="xs" size="xs"
value={billingCurrency} value={billingCurrency}
onChange={(value) => setBillingCurrency(value as 'ETB' | 'USD')} onChange={(value) => setBillingCurrency(value as 'ETB' | 'USD' | 'DJF')}
data={[ data={[
{ value: 'USD', label: 'USD' }, { value: 'USD', label: 'USD' },
{ value: 'ETB', label: 'Birr' }, { value: 'ETB', label: 'Birr' },
{ value: 'DJF', label: 'DJF' },
]} ]}
disabled={Boolean(activeInvoice)} disabled={Boolean(activeInvoice)}
/> />

View File

@@ -221,7 +221,7 @@ export function useOnTimeDispatch() {
} }
/** Live per-item fee accrual (storage/demurrage) with alerts. */ /** Live per-item fee accrual (storage/demurrage) with alerts. */
export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') { export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD' | 'DJF') {
return useQuery({ return useQuery({
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'], queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data), queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
@@ -598,7 +598,7 @@ export const useUpdateFeeRule = () =>
export const useDeleteFeeRule = () => export const useDeleteFeeRule = () =>
useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']); 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({ return useQuery({
queryKey: ['warehouse-inventory', inventoryId, 'fee-preview', billingCurrency], queryKey: ['warehouse-inventory', inventoryId, 'fee-preview', billingCurrency],
queryFn: () => warehouseService.feePreview(inventoryId as string, billingCurrency).then((r) => r.data), queryFn: () => warehouseService.feePreview(inventoryId as string, billingCurrency).then((r) => r.data),
@@ -649,7 +649,7 @@ export function useGenerateInvoice() {
}: { }: {
inventoryId: string; inventoryId: string;
confirmZero?: boolean; confirmZero?: boolean;
billingCurrency?: 'ETB' | 'USD'; billingCurrency?: 'ETB' | 'USD' | 'DJF';
}) => warehouseService.generateInvoice(inventoryId, confirmZero, billingCurrency).then((r) => r.data), }) => warehouseService.generateInvoice(inventoryId, confirmZero, billingCurrency).then((r) => r.data),
onSuccess, onSuccess,
}); });

View File

@@ -72,6 +72,7 @@ import { AdditionalPaymentsTab } from "@/components/bookings/AdditionalPaymentsT
import { getStatusMeta } from "@/features/bookings/booking-status.config"; import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { formatDateTime, formatMoney } from "@/lib/format"; import { formatDateTime, formatMoney } from "@/lib/format";
import { currencyDecimals } from "@edr/ui-common";
import { cargoTonsAndItems } from "@/utils/cargoWeight"; import { cargoTonsAndItems } from "@/utils/cargoWeight";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { import {
@@ -254,7 +255,7 @@ export default function BookingRequestDetailPage() {
const kpis: KpiItem[] = [ const kpis: KpiItem[] = [
{ {
label: "Total value", label: "Total value",
value: formatMoney(amount, booking.paymentCurrency, 2), value: formatMoney(amount, booking.paymentCurrency, currencyDecimals(booking.paymentCurrency)),
hint: booking.paymentStatus, hint: booking.paymentStatus,
icon: Wallet, icon: Wallet,
color: "edr-green", color: "edr-green",

View File

@@ -25,6 +25,7 @@ import { useAuth } from "@/auth/useAuth";
import { PageContainer, PageHeader } from "@/components/page"; import { PageContainer, PageHeader } from "@/components/page";
import { toDayString } from "@/hooks/useListControls"; import { toDayString } from "@/hooks/useListControls";
import { formatDate, formatMoney } from "@/lib/format"; import { formatDate, formatMoney } from "@/lib/format";
import { currencyDecimals } from "@edr/ui-common";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { import {
DataTable, DataTable,
@@ -166,7 +167,7 @@ export default function WagonCancellationsPage() {
header: () => <span>Fee</span>, header: () => <span>Fee</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}> <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> </Text>
), ),
}, },
@@ -175,7 +176,7 @@ export default function WagonCancellationsPage() {
header: () => <span>Credit</span>, header: () => <span>Credit</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}> <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> </Text>
), ),
}, },
@@ -347,7 +348,7 @@ export default function WagonCancellationsPage() {
<Text size="sm"> <Text size="sm">
{voiding.booking?.reference ?? voiding.bookingId} ·{" "} {voiding.booking?.reference ?? voiding.bookingId} ·{" "}
{voiding.wagonsCancelled} wagon(s) · fee{" "} {voiding.wagonsCancelled} wagon(s) · fee{" "}
{formatMoney(voiding.feeAmount, voiding.feeCurrency, 2)} {formatMoney(voiding.feeAmount, voiding.feeCurrency, currencyDecimals(voiding.feeCurrency))}
</Text> </Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
The pending fee is dropped and the wagons stay on the booking. 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 = [ const CURRENCY_OPTIONS = [
{ value: "ETB", label: "ETB" }, { value: "ETB", label: "ETB" },
{ value: "USD", label: "USD" }, { value: "USD", label: "USD" },
{ value: "DJF", label: "DJF" },
]; ];
/** value = `${sortBy}:${sortOrder}` for the sort Select. */ /** value = `${sortBy}:${sortOrder}` for the sort Select. */

View File

@@ -279,6 +279,7 @@ const OperationsTab = ({ vehicle }: { vehicle: Vehicle }) => {
<Group gap="lg" mt={6}> <Group gap="lg" mt={6}>
<Radio value="ETB" label="ETB" /> <Radio value="ETB" label="ETB" />
<Radio value="USD" label="USD" /> <Radio value="USD" label="USD" />
<Radio value="DJF" label="DJF" />
</Group> </Group>
</Radio.Group> </Radio.Group>
</SimpleGrid> </SimpleGrid>

View File

@@ -274,7 +274,7 @@ function ConfirmCell({
export default function UsdPaymentsPanel({ export default function UsdPaymentsPanel({
currency, currency,
}: { }: {
currency: "USD" | "ETB"; currency: "USD" | "ETB" | "DJF";
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
// Namespaced: the ETB and USD tabs share this panel and live on the same URL // 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 { KpiStrip } from "@/components/page";
import { ExportButton } from "@/components/export/ExportButton"; import { ExportButton } from "@/components/export/ExportButton";
import { formatDate, formatMoney } from "@/lib/format"; import { formatDate, formatMoney } from "@/lib/format";
import { currencyDecimals } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { PaymentMethod, PaymentRow } from "@/services/payments.service"; import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
import { import {
@@ -149,7 +150,7 @@ export default function PaymentsPanel() {
header: () => <span className={tableHeader}>Amount</span>, header: () => <span className={tableHeader}>Amount</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-mono text-sm font-semibold tabular-nums text-foreground"> <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> </span>
), ),
}, },

View File

@@ -450,6 +450,7 @@ export const rateUnitOptions = (
const CURRENCIES = [ const CURRENCIES = [
{ label: "ETB (Birr)", value: "ETB" }, { label: "ETB (Birr)", value: "ETB" },
{ label: "USD", value: "USD" }, { label: "USD", value: "USD" },
{ label: "DJF", value: "DJF" },
]; ];
const PRIORITY_CONFIG_TYPES = [ const PRIORITY_CONFIG_TYPES = [

View File

@@ -16,7 +16,7 @@ import {
Text, Text,
Textarea, Textarea,
} from "@mantine/core"; } 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 { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; 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) => const money = (amount: number | null | undefined, currency: string | null | undefined) =>
amount == null 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 * The queue for customer-initiated empty container returns: a booking sold

View File

@@ -1,4 +1,5 @@
import { Fragment, useMemo, useState } from "react"; import { Fragment, useMemo, useState } from "react";
import { currencyDecimals } from "@edr/ui-common";
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ActionIcon, ActionIcon,
@@ -78,7 +79,7 @@ const TRUCK_COLUMNS = [
] as const; ] as const;
const money = (amount: number, currency: string) => 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 { export interface BookingGroup {

View File

@@ -17,7 +17,7 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react'; import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; 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 { applyClientFilters, FilterBar, useFilters, type FilterDef } from '@/components/filters';
import { PageContainer, PageHeader } from '@/components/page'; import { PageContainer, PageHeader } from '@/components/page';
@@ -50,7 +50,7 @@ const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
CANCELLED: 'gray', 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 fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
const INVOICE_FILTER_DEFS: FilterDef[] = [ 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); const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
useEffect(() => { useEffect(() => {
setGatewayMethod(inv?.currency === 'USD' ? 'WAAFI' : 'TELEBIRR'); // WAAFI settles USD and DJF; TELEBIRR is ETB-only.
setGatewayMethod(inv?.currency !== 'ETB' ? 'WAAFI' : 'TELEBIRR');
setPayerAccount(''); setPayerAccount('');
}, [inv?.id, inv?.currency]); }, [inv?.id, inv?.currency]);

View File

@@ -69,6 +69,7 @@ const TRADE = [
const CURRENCIES = [ const CURRENCIES = [
{ value: 'USD', label: 'USD - Dollar' }, { value: 'USD', label: 'USD - Dollar' },
{ value: 'ETB', label: 'ETB - Birr' }, { value: 'ETB', label: 'ETB - Birr' },
{ value: 'DJF', label: 'DJF - Djibouti Franc' },
]; ];
const clean = (s: string) => s.trim() || undefined; const clean = (s: string) => s.trim() || undefined;

View File

@@ -1532,7 +1532,7 @@ export const api = {
), ),
feePreview: endpoint< feePreview: endpoint<
{ inventoryId: string; billingCurrency?: "ETB" | "USD" }, { inventoryId: string; billingCurrency?: "ETB" | "USD" | "DJF" },
FeePreview[] FeePreview[]
>( >(
"warehouse-inventory", "warehouse-inventory",
@@ -1884,7 +1884,7 @@ export const api = {
{ {
inventoryId: string; inventoryId: string;
confirmZero?: boolean; confirmZero?: boolean;
billingCurrency?: "ETB" | "USD"; billingCurrency?: "ETB" | "USD" | "DJF";
}, },
WarehouseFeeInvoice WarehouseFeeInvoice
>( >(

View File

@@ -558,11 +558,11 @@ export const warehouseService = {
updateFeeRule: (id: string, payload: Partial<SaveFeeRulePayload>) => updateFeeRule: (id: string, payload: Partial<SaveFeeRulePayload>) =>
apiClient.patch<FeeRule>(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id), payload), 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)), 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), { apiClient.get<FeePreview[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId), {
params: cleanParams({ billingCurrency }), params: cleanParams({ billingCurrency }),
}), }),
accrualDashboard: (billingCurrency?: 'ETB' | 'USD') => accrualDashboard: (billingCurrency?: 'ETB' | 'USD' | 'DJF') =>
apiClient.get<AccrualDashboardRow[]>(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, { apiClient.get<AccrualDashboardRow[]>(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, {
params: cleanParams({ billingCurrency }), params: cleanParams({ billingCurrency }),
}), }),
@@ -592,7 +592,7 @@ export const warehouseService = {
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)), apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)),
invoicesForBooking: (bookingId: string) => invoicesForBooking: (bookingId: string) =>
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_BOOKING(bookingId)), 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), { apiClient.post<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.GENERATE(inventoryId), {
confirmZero, confirmZero,
billingCurrency, billingCurrency,

View File

@@ -420,7 +420,7 @@ export interface CustomerBooking {
originLabel: string; originLabel: string;
destinationLabel: string; destinationLabel: string;
totalAmount: number; totalAmount: number;
currency: "ETB" | "USD"; currency: "ETB" | "USD" | "DJF";
scheduledDate?: string | null; scheduledDate?: string | null;
createdAt: string; createdAt: string;
} }
@@ -469,7 +469,7 @@ export interface CustomerPayment {
/** Booking reference the payment settles. */ /** Booking reference the payment settles. */
bookingReference: string; bookingReference: string;
amount: number; amount: number;
currency: "ETB" | "USD"; currency: "ETB" | "USD" | "DJF";
method: CustomerPaymentMethod; method: CustomerPaymentMethod;
status: CustomerPaymentStatus; status: CustomerPaymentStatus;
paidAt?: string | null; paidAt?: string | null;

View File

@@ -77,7 +77,7 @@ export interface InvoiceListFilter {
/** CSV of normalised UPPER_SNAKE payment methods (see `PAYMENT_METHOD_OPTIONS`). */ /** CSV of normalised UPPER_SNAKE payment methods (see `PAYMENT_METHOD_OPTIONS`). */
paymentMethods?: string; paymentMethods?: string;
search?: string; search?: string;
currency?: "USD" | "ETB"; currency?: "USD" | "ETB" | "DJF";
/** ISO instants — inclusive bounds on `issuedAt` / `dueAt`. */ /** ISO instants — inclusive bounds on `issuedAt` / `dueAt`. */
issuedFrom?: string; issuedFrom?: string;
issuedTo?: string; issuedTo?: string;