mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Merge pull request #1329 from Tria-plc/freight_feature/usermanagement
feat(freight): offer built-train wagons per boarding yard on multi-ya…
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { Tabs } from "@mantine/core";
|
||||
import { Landmark, Receipt } from "lucide-react";
|
||||
import { Banknote, DollarSign, Receipt } from "lucide-react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
@@ -16,7 +17,9 @@ import UsdPaymentsPanel from "./UsdPaymentsPage";
|
||||
* before, and just doesn't render if the user lacks it.
|
||||
*
|
||||
* The Payments tab was removed; its summary (total collected, ETB/USD) now
|
||||
* lives as a card at the top of the Invoices tab instead.
|
||||
* lives as a card at the top of the Invoices tab instead. Manual payments are
|
||||
* split into one tab per currency — ETB keeps the original `?tab=manual-payments`
|
||||
* key so existing links and the old redirect still land somewhere valid.
|
||||
*/
|
||||
const TABS = [
|
||||
{
|
||||
@@ -30,13 +33,25 @@ const TABS = [
|
||||
},
|
||||
{
|
||||
key: "manual-payments",
|
||||
label: "Manual Payments",
|
||||
icon: Landmark,
|
||||
label: "Manual Payments (ETB)",
|
||||
icon: Banknote,
|
||||
// Same gate as Invoices, not a dedicated key — mirrors the old route.
|
||||
permission: FREIGHT_PERMS.invoices.view,
|
||||
/** Hidden unless manual settlement is switched on for this currency. */
|
||||
manualCurrency: "ETB",
|
||||
subtitle:
|
||||
"Import and export invoices in USD or ETB that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
|
||||
Panel: UsdPaymentsPanel,
|
||||
"Import and export invoices in ETB that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
|
||||
Panel: () => <UsdPaymentsPanel currency="ETB" />,
|
||||
},
|
||||
{
|
||||
key: "manual-payments-usd",
|
||||
label: "Manual Payments (USD)",
|
||||
icon: DollarSign,
|
||||
permission: FREIGHT_PERMS.invoices.view,
|
||||
manualCurrency: "USD",
|
||||
subtitle:
|
||||
"Import and export invoices in USD that Finance settles by hand (bank transfer or counter). Upload the customer's slip and confirm the payment before the pay window closes.",
|
||||
Panel: () => <UsdPaymentsPanel currency="USD" />,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -46,7 +61,18 @@ export default function FinanceHubPage() {
|
||||
const { user } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const visibleTabs = TABS.filter((tab) => hasPermission(user, tab.permission));
|
||||
// A currency whose manual-payment channel is switched off has no tab at all
|
||||
// — the list would be empty and every confirmation refused.
|
||||
const { data: manualSettings } = useManualPaymentSettingsQuery();
|
||||
const manualEnabled = (currency: "ETB" | "USD") =>
|
||||
!manualSettings ||
|
||||
(currency === "ETB" ? manualSettings.etbEnabled : manualSettings.usdEnabled);
|
||||
|
||||
const visibleTabs = TABS.filter(
|
||||
(tab) =>
|
||||
hasPermission(user, tab.permission) &&
|
||||
(!("manualCurrency" in tab) || manualEnabled(tab.manualCurrency)),
|
||||
);
|
||||
const requested = searchParams.get("tab");
|
||||
const active: TabKey =
|
||||
visibleTabs.find((tab) => tab.key === requested)?.key ??
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/components/customers";
|
||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type { OfflineUsdInvoice } from "@/types/invoice";
|
||||
@@ -144,7 +145,11 @@ function ConfirmCell({
|
||||
* Finance settles by hand; confirming records the payment the same way an
|
||||
* online payment would, so the booking advances identically.
|
||||
*/
|
||||
export default function UsdPaymentsPanel() {
|
||||
export default function UsdPaymentsPanel({
|
||||
currency,
|
||||
}: {
|
||||
currency: "USD" | "ETB";
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -152,7 +157,6 @@ export default function UsdPaymentsPanel() {
|
||||
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
|
||||
"",
|
||||
);
|
||||
const [currency, setCurrency] = useState<"" | "USD" | "ETB">("");
|
||||
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
|
||||
const [slip, setSlip] = useState<File | null>(null);
|
||||
const [reference, setReference] = useState("");
|
||||
@@ -163,13 +167,23 @@ export default function UsdPaymentsPanel() {
|
||||
FREIGHT_PERMS.invoices.confirmOffline,
|
||||
);
|
||||
|
||||
// Manual settlement is switched on per currency in Configuration → Manual
|
||||
// payments. FinanceHubPage hides the tab for a disabled currency; this is
|
||||
// the fallback for a direct `?tab=` link, and the API refuses regardless.
|
||||
const { data: manualSettings } = useManualPaymentSettingsQuery();
|
||||
const currencyEnabled = manualSettings
|
||||
? currency === "ETB"
|
||||
? manualSettings.etbEnabled
|
||||
: manualSettings.usdEnabled
|
||||
: true;
|
||||
|
||||
const filter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
status: statusFilter || undefined,
|
||||
currency: currency || undefined,
|
||||
currency,
|
||||
}),
|
||||
[
|
||||
pagination.pageIndex,
|
||||
@@ -180,9 +194,10 @@ export default function UsdPaymentsPanel() {
|
||||
],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery(
|
||||
api.invoices.listOfflineUsd.queryOptions({ input: { filter } }),
|
||||
);
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery({
|
||||
...api.invoices.listOfflineUsd.queryOptions({ input: { filter } }),
|
||||
enabled: currencyEnabled,
|
||||
});
|
||||
|
||||
const confirm = useMutation(api.invoices.confirmOffline.mutationOptions());
|
||||
|
||||
@@ -289,20 +304,6 @@ export default function UsdPaymentsPanel() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "currency",
|
||||
header: "Currency",
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
radius="sm"
|
||||
color={row.original.currency?.toUpperCase() === "USD" ? "blue" : "teal"}
|
||||
>
|
||||
{row.original.currency}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
@@ -342,11 +343,12 @@ export default function UsdPaymentsPanel() {
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => {
|
||||
if (row.original.status === "PAID" || !canConfirm) return null;
|
||||
if (!currencyEnabled) return null;
|
||||
return <ConfirmCell row={row.original} onConfirm={setConfirming} />;
|
||||
},
|
||||
},
|
||||
],
|
||||
[canConfirm, navigate],
|
||||
[canConfirm, currencyEnabled, navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -376,20 +378,6 @@ export default function UsdPaymentsPanel() {
|
||||
style={{ flex: 1, minWidth: "240px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={currency || "all"}
|
||||
onChange={(v) => {
|
||||
setCurrency(v === "all" ? "" : (v as "USD" | "ETB"));
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={[
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "ETB", value: "ETB" },
|
||||
{ label: "USD", value: "USD" },
|
||||
]}
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
@@ -427,9 +415,11 @@ export default function UsdPaymentsPanel() {
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
||||
emptyMessage={
|
||||
debouncedQuery
|
||||
? "No invoices match your search."
|
||||
: "No invoices awaiting manual payment confirmation."
|
||||
!currencyEnabled
|
||||
? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Manual payments.`
|
||||
: debouncedQuery
|
||||
? "No invoices match your search."
|
||||
: `No ${currency} invoices awaiting manual payment confirmation.`
|
||||
}
|
||||
error={
|
||||
isError
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { Switch } from "@/shared/common/ui/switch";
|
||||
import { Skeleton } from "@/shared/common/ui/skeleton";
|
||||
import { AlertTriangle, Banknote, Landmark } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import {
|
||||
useManualPaymentSettingsQuery,
|
||||
useUpdateManualPaymentSettings,
|
||||
} from "@/hooks/useManualPaymentSettings";
|
||||
|
||||
type Currency = "ETB" | "USD";
|
||||
|
||||
const CURRENCIES: {
|
||||
code: Currency;
|
||||
field: "etbEnabled" | "usdEnabled";
|
||||
icon: typeof Banknote;
|
||||
title: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
code: "ETB",
|
||||
field: "etbEnabled",
|
||||
icon: Banknote,
|
||||
title: "Birr (ETB) invoices",
|
||||
description:
|
||||
"ETB invoices are normally paid online by the customer. Switch this on when Finance also needs to settle them by hand — a bank transfer or a payment at the counter.",
|
||||
},
|
||||
{
|
||||
code: "USD",
|
||||
field: "usdEnabled",
|
||||
icon: Landmark,
|
||||
title: "Dollar (USD) invoices",
|
||||
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.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Switches the manual (offline) payment channel on or off per currency.
|
||||
*
|
||||
* Off means gone, not greyed out: the Manual Payments worklist lists only
|
||||
* enabled currencies, and the API refuses a confirmation in a disabled one —
|
||||
* so a stale tab or a direct call cannot slip a payment through.
|
||||
*/
|
||||
export default function ManualPaymentSettingsCard() {
|
||||
const { user } = useAuth();
|
||||
const canManage =
|
||||
hasPermission(user, FREIGHT_PERMS.settings.manualPayment.manage) ||
|
||||
hasPermission(user, FREIGHT_PERMS.admin);
|
||||
|
||||
const { data, isLoading } = useManualPaymentSettingsQuery();
|
||||
const update = useUpdateManualPaymentSettings();
|
||||
|
||||
const noneEnabled = Boolean(data && !data.etbEnabled && !data.usdEnabled);
|
||||
|
||||
return (
|
||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle>Manual payments</CardTitle>
|
||||
<CardDescription>
|
||||
Whether Finance staff may mark invoices as paid by hand, from
|
||||
Invoices → Manual Payments. Each currency is switched separately.
|
||||
Confirming still requires the payment slip and the booking's pay
|
||||
window to be open.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{noneEnabled && (
|
||||
<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
|
||||
Finance cannot settle any invoice by hand.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading || !data
|
||||
? CURRENCIES.map((c) => (
|
||||
<Skeleton key={c.code} className="h-[86px] w-full rounded-md" />
|
||||
))
|
||||
: CURRENCIES.map(({ code, field, icon: Icon, title, description }) => {
|
||||
const enabled = data[field];
|
||||
return (
|
||||
<div
|
||||
key={code}
|
||||
className="flex items-start justify-between gap-4 rounded-md border p-4 dark:border-gray-700"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="font-medium">{title}</p>
|
||||
<Badge variant={enabled ? "default" : "secondary"}>
|
||||
{enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={!canManage || update.isPending}
|
||||
aria-label={`Allow manual payment for ${code} invoices`}
|
||||
onCheckedChange={(checked) =>
|
||||
update.mutate({ [field]: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!canManage && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can see these settings but not change them — that needs the
|
||||
manual-payment settings permission.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -278,6 +278,29 @@ export default function TrainBuilderDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{composition.wagonYards.length > 1 ? (
|
||||
<Alert color="blue" icon={<MapPin size={16} />}>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={600}>
|
||||
This train's wagons stand in {composition.wagonYards.length} yards
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{composition.wagonYards.map((group) => (
|
||||
<Badge key={group.yardId ?? "none"} variant="light" color="blue">
|
||||
{group.label ?? group.code ?? "No yard"} · {group.wagonCount} wagon
|
||||
{group.wagonCount === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
The train collects each group when it reaches that yard, so the schedule's route
|
||||
must pass through every one of them before its destination. Customers boarding at
|
||||
a yard can only book the wagons standing there.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!composition.editable ? (
|
||||
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
||||
This train is out on a dispatched run — its composition is frozen until arrival.
|
||||
@@ -379,13 +402,13 @@ export default function TrainBuilderDetailPage() {
|
||||
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||
<Card h="100%">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600}>Available wagons — {yard?.label ?? "yard"}</Text>
|
||||
<Text fw={600}>Available wagons — all yards</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Only AVAILABLE wagons standing in the train's own yard can be coupled.
|
||||
AVAILABLE, unassigned wagons from every yard can be coupled. The schedule's
|
||||
route must pass through each wagon's yard before its destination.
|
||||
</Text>
|
||||
<AvailableWagonsPanel
|
||||
yardId={yard?.id ?? ""}
|
||||
yardLabel={yard?.label}
|
||||
homeYardId={yard?.id ?? null}
|
||||
exportTrainNumber={composition.exportTrainNumber}
|
||||
importTrainNumber={composition.importTrainNumber}
|
||||
assigning={assignWagons.isPending}
|
||||
|
||||
Reference in New Issue
Block a user