mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
Merge branch 'freight_feature/usermanagement' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user