mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 08:25:43 +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;
|
||||
|
||||
Reference in New Issue
Block a user