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:
marshal
2026-08-18 11:28:32 +03:00
committed by GitHub
33 changed files with 1234 additions and 231 deletions

View File

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

View File

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

View File

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

View File

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