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

@@ -86,6 +86,7 @@ import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPa
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard";
import ManualPaymentSettingsCard from "./pages/settings/ManualPaymentSettingsCard";
import FirstMilePage from "./pages/operations/FirstMilePage";
import LastMilePage from "./pages/operations/LastMilePage";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
@@ -1156,6 +1157,18 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="configuration/manual-payments"
element={
<RequirePermission
permission={FREIGHT_PERMS.settings.manualPayment.view}
>
<div className="p-4">
<ManualPaymentSettingsCard />
</div>
</RequirePermission>
}
/>
<Route
path="configuration/exchange-rate"
element={

View File

@@ -562,6 +562,11 @@ export const buildSidebarSections = (
href: "/dashboard/configuration/exchange-rate",
permission: FREIGHT_PERMS.settings.exchangeRate.view,
},
{
label: "Manual payments",
href: "/dashboard/configuration/manual-payments",
permission: FREIGHT_PERMS.settings.manualPayment.view,
},
],
},
{

View File

@@ -4,34 +4,40 @@ import {
Button,
Checkbox,
Group,
Pagination,
ScrollArea,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import { Plus, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { MapPin, Plus, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
const PAGE_SIZE = 20;
import { api } from "@/services/api";
/**
* AVAILABLE wagons standing in the train's own yard — the only ones that can
* be coupled. Pick any number and append them to the consist.
* AVAILABLE, unassigned wagons from every yard — filtered and paged on the API,
* so the picker never page-walks the whole fleet into the browser.
*/
export default function AvailableWagonsPanel({
yardId,
yardLabel,
homeYardId,
onAssign,
assigning,
exportTrainNumber,
importTrainNumber,
}: AvailableWagonsPanelProps) {
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [typeFilter, setTypeFilter] = useState<string>("ALL");
const [yardFilter, setYardFilter] = useState<string>("ALL");
const [runOnly, setRunOnly] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [page, setPage] = useState(1);
// The train's own run, e.g. "8001-8002" — only offered when the train has one.
const runLabel = exportTrainNumber
@@ -39,59 +45,65 @@ export default function AvailableWagonsPanel({
: null;
const wagonsQuery = useQuery(
api.wagons.list.queryOptions({
api.wagons.listPaged.queryOptions({
input: {
filters: {
status: Freight.WagonStatus.Available,
currentYardId: yardId,
// Loose wagons only — one already on another train cannot be coupled.
unassigned: true,
search: debouncedSearch.trim() || undefined,
currentYardId: yardFilter === "ALL" ? undefined : yardFilter,
wagonTypeId: typeFilter === "ALL" ? undefined : typeFilter,
// Rostered to this train's run — the API matches either run column.
trainNumber: runOnly && exportTrainNumber ? exportTrainNumber : undefined,
page,
pageSize: PAGE_SIZE,
},
},
enabled: Boolean(yardId),
}),
);
const wagons = useMemo(() => {
const q = search.trim().toLowerCase();
return (wagonsQuery.data ?? []).filter((wagon) => {
if (typeFilter !== "ALL" && wagon.wagonTypeId !== typeFilter) return false;
// Rostered to this train's run — match on the export run, which fixes the
// import run anyway.
if (runOnly && wagon.exportTrainNumber !== exportTrainNumber) return false;
if (q && !wagon.wagonNumber.toLowerCase().includes(q)) return false;
return true;
});
}, [wagonsQuery.data, search, typeFilter, runOnly, exportTrainNumber]);
const wagons = wagonsQuery.data?.items ?? [];
const total = wagonsQuery.data?.meta.total ?? 0;
const totalPages = Math.max(1, wagonsQuery.data?.meta.totalPages ?? 1);
const runMatchCount = useMemo(
() =>
exportTrainNumber
? (wagonsQuery.data ?? []).filter(
(w) => w.exportTrainNumber === exportTrainNumber,
).length
: 0,
[wagonsQuery.data, exportTrainNumber],
);
// Filters change → back to page 1 (and clamp when the list shrinks).
useEffect(() => {
setPage(1);
}, [debouncedSearch, typeFilter, yardFilter, runOnly]);
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
const typeOptions = useMemo(() => {
const byId = new Map<string, string>();
for (const wagon of wagonsQuery.data ?? []) {
if (wagon.wagonType) {
// e.g. "Flat wagon (NW5)" — name with its type code.
byId.set(
wagon.wagonType.id,
wagon.wagonType.code
? `${wagon.wagonType.name} (${wagon.wagonType.code})`
: wagon.wagonType.name,
);
}
}
// Dropdowns come from the reference lists, not the current page — a yard or
// type must stay pickable even when this page holds none of it.
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
const wagonTypesQuery = useQuery(api.wagonTypes.list.queryOptions({ staleTime: 5 * 60_000 }));
const yardOptions = useMemo(() => {
const yards = [...(yardsQuery.data ?? [])].sort((a, b) =>
a.id === homeYardId ? -1 : b.id === homeYardId ? 1 : a.label.localeCompare(b.label),
);
return [
{ value: "ALL", label: "All types" },
...[...byId.entries()].map(([value, label]) => ({ value, label })),
{ value: "ALL", label: "All yards" },
...yards.map((yard) => ({
value: yard.id,
label: `${yard.label}${yard.id === homeYardId ? " · train's yard" : ""}`,
})),
];
}, [wagonsQuery.data]);
}, [yardsQuery.data, homeYardId]);
const typeOptions = useMemo(
() => [
{ value: "ALL", label: "All types" },
// e.g. "Flat wagon (NW5)" — name with its type code.
...(wagonTypesQuery.data ?? []).map((type) => ({
value: type.id,
label: type.code ? `${type.name} (${type.code})` : type.name,
})),
],
[wagonTypesQuery.data],
);
const toggle = (wagonId: string, checked: boolean) => {
setSelected((prev) =>
@@ -99,8 +111,8 @@ export default function AvailableWagonsPanel({
);
};
const allSelected =
wagons.length > 0 && wagons.every((w) => selected.includes(w.id));
// Select-all covers this page only — the rest of the matches are not loaded.
const allSelected = wagons.length > 0 && wagons.every((w) => selected.includes(w.id));
const someSelected = wagons.some((w) => selected.includes(w.id));
const toggleAll = (checked: boolean) => {
@@ -138,24 +150,40 @@ export default function AvailableWagonsPanel({
onChange={(v) => setTypeFilter(v ?? "ALL")}
/>
</Group>
<Select
size="sm"
leftSection={<MapPin size={14} />}
data={yardOptions}
value={yardFilter}
onChange={(v) => setYardFilter(v ?? "ALL")}
searchable
aria-label="Filter by yard"
/>
{runLabel ? (
<Checkbox
size="sm"
label={`Only wagons on this train's run (${runLabel})${runMatchCount} here`}
label={`Only wagons on this train's run (${runLabel})`}
checked={runOnly}
onChange={(e) => setRunOnly(e.currentTarget.checked)}
/>
) : null}
{wagons.length ? (
<Checkbox
size="sm"
label={`Select all (${wagons.length})`}
checked={allSelected}
indeterminate={!allSelected && someSelected}
onChange={(e) => toggleAll(e.currentTarget.checked)}
/>
<Group justify="space-between" wrap="nowrap">
<Checkbox
size="sm"
label={`Select all on this page (${wagons.length})`}
checked={allSelected}
indeterminate={!allSelected && someSelected}
onChange={(e) => toggleAll(e.currentTarget.checked)}
/>
{selected.length ? (
<Text size="xs" c="dimmed">
{selected.length} selected
</Text>
) : null}
</Group>
) : null}
<ScrollArea.Autosize mah={380} type="auto">
@@ -166,7 +194,7 @@ export default function AvailableWagonsPanel({
</Text>
) : !wagons.length ? (
<Text py="md" ta="center" c="dimmed" size="sm">
No available wagons in {yardLabel ?? "this yard"}
No available wagons match
</Text>
) : (
wagons.map((wagon) => (
@@ -191,6 +219,15 @@ export default function AvailableWagonsPanel({
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
<Badge
size="xs"
radius="sm"
variant="outline"
color={wagon.currentYardId === homeYardId ? "edr-green" : "gray"}
leftSection={<MapPin size={10} />}
>
{wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard"}
</Badge>
{wagon.exportTrainNumber ? (
<Badge
size="xs"
@@ -217,6 +254,15 @@ export default function AvailableWagonsPanel({
</Stack>
</ScrollArea.Autosize>
{totalPages > 1 ? (
<Group justify="space-between" wrap="nowrap">
<Text size="xs" c="dimmed">
{(page - 1) * PAGE_SIZE + 1}{Math.min(page * PAGE_SIZE, total)} of {total}
</Text>
<Pagination size="sm" value={page} onChange={setPage} total={totalPages} />
</Group>
) : null}
<Button
leftSection={<Plus size={16} />}
disabled={!selected.length}
@@ -230,8 +276,8 @@ export default function AvailableWagonsPanel({
}
export interface AvailableWagonsPanelProps {
yardId: string;
yardLabel?: string | null;
/** The train's own yard — sorted first and highlighted; not a restriction. */
homeYardId: string | null;
onAssign: (wagonIds: string[]) => void;
assigning: boolean;
/** This train's odd EXPORT run — drives the "only this run" filter. */

View File

@@ -7,7 +7,7 @@ import {
type DropResult,
} from "@hello-pangea/dnd";
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import { GripVertical, Trash2, Wrench } from "lucide-react";
import { GripVertical, MapPin, Trash2, Wrench } from "lucide-react";
import { type ReactNode } from "react";
import { createPortal } from "react-dom";
@@ -191,6 +191,11 @@ function WagonRow({
{wagon.wagonType.code}
</Badge>
) : null}
{wagon.currentYard ? (
<Badge variant="outline" color="gray" size="xs" radius="sm" leftSection={<MapPin size={10} />}>
{wagon.currentYard.label ?? wagon.currentYard.code}
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{wagon.wagonType

View File

@@ -57,6 +57,10 @@ export const URL_CONSTANTS = {
BASE: "/exchange-settings",
},
MANUAL_PAYMENT_SETTINGS: {
BASE: "/payment-settings/manual",
},
AUDIT_LOGS: {
BASE: "/audit",
},

View File

@@ -0,0 +1,39 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
manualPaymentSettingsService,
type ManualPaymentSettings,
} from "@/services/manualPaymentSettings.service";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
export const MANUAL_PAYMENT_SETTINGS_KEY = ["manualPaymentSettings"];
export const useManualPaymentSettingsQuery = () =>
useQuery({
queryKey: MANUAL_PAYMENT_SETTINGS_KEY,
queryFn: () => manualPaymentSettingsService.get(),
staleTime: 60_000,
});
export const useUpdateManualPaymentSettings = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
return useMutation({
mutationFn: (
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>,
) => manualPaymentSettingsService.update(patch),
onSuccess: (data) => {
queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data);
// The Manual Payments worklist only lists enabled currencies.
queryClient.invalidateQueries({ queryKey: ["invoices"] });
toast.success(
t("manualPaymentSettings.updated", "Manual payment settings updated"),
);
},
onError: handleError,
});
};

View File

@@ -376,6 +376,12 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:exchange_rate:view",
manage: "edr_freight_app:settings:exchange_rate:manage",
},
// Whether Finance may settle invoices by hand, per currency. Finance holds
// `view` (the worklist offers only enabled currencies); `manage` is admin.
manualPayment: {
view: "edr_freight_app:settings:manual_payment:view",
manage: "edr_freight_app:settings:manual_payment:manage",
},
contractTemplates: {
view: "edr_freight_app:settings:contract_templates:view",
manage: "edr_freight_app:settings:contract_templates:manage",

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}

View File

@@ -0,0 +1,36 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ApiResponse } from "@/types/apiResponse";
const BASE = URL_CONSTANTS.MANUAL_PAYMENT_SETTINGS.BASE;
/**
* Whether Finance may settle invoices by hand (bank transfer / counter) rather
* than the customer paying online — switched per currency, because the two
* channels are operationally different.
*/
export interface ManualPaymentSettings {
etbEnabled: boolean;
usdEnabled: boolean;
updatedById: string | null;
updatedAt?: string;
}
export const manualPaymentSettingsService = {
get: async (): Promise<ManualPaymentSettings> => {
const response = await client.get<ApiResponse<ManualPaymentSettings>>(BASE);
return unwrap(response.data);
},
/** Partial: an omitted currency keeps its current setting. */
update: async (
patch: Partial<Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled">>,
): Promise<ManualPaymentSettings> => {
const response = await client.patch<ApiResponse<ManualPaymentSettings>>(
BASE,
patch,
);
return unwrap(response.data);
},
};

View File

@@ -67,6 +67,8 @@ export interface TrainCompositionWagon {
wagonNumber: string;
sequenceNumber: number | null;
status: string;
currentYardId: string | null;
currentYard: YardRefLite | null;
wagonType: {
id: string;
code: string;
@@ -77,6 +79,14 @@ export interface TrainCompositionWagon {
} | null;
}
/** Where a built train's wagons physically stand, largest group first. */
export interface TrainWagonYardGroup {
yardId: string | null;
code: string | null;
label: string | null;
wagonCount: number;
}
export interface TrainCompositionTotals {
wagonCount: number;
totalTareTons: number;
@@ -104,6 +114,7 @@ export interface TrainComposition {
currentYard: YardRefLite | null;
locomotives: TrainCompositionLocomotive[];
wagons: TrainCompositionWagon[];
wagonYards: TrainWagonYardGroup[];
totals: TrainCompositionTotals;
activeSchedules: ActiveScheduleRef[];
editable: boolean;

View File

@@ -46,10 +46,19 @@ const useAuth = () => {
}),
);
// `/companies/getInfo` is customer-only (PortalCustomerGuard). A staff
// account signed into the portal 403s on every call, and because a failed
// fetch leaves `company` null the onboarding gate redirects, which remounts
// the route tree, which refires the query — a request storm. Never ask.
const isCustomerAccount =
authQuery.data?.userType === "individual" ||
authQuery.data?.userType === "external_organization";
const companyQuery = useQuery(
api.companies.getInfo.queryOptions({
enabled: !!authQuery.data?.id,
enabled: !!authQuery.data?.id && isCustomerAccount,
retry: false,
refetchOnMount: false,
staleTime(query) {
// Fast-poll while anything is awaiting a backoffice decision: an
@@ -61,7 +70,7 @@ const useAuth = () => {
(p) => p.status !== "active",
)
)
return 60;
return 60_000;
return 10 * 60 * 1000;
},
@@ -308,7 +317,11 @@ const useAuth = () => {
logout,
authQuery,
companyQuery,
customerQuery: companyQuery,
// A disabled query reports `isPending` forever; the route gates read this
// to decide "still loading", so a staff account would sit on a spinner.
customerQuery: isCustomerAccount
? companyQuery
: { ...companyQuery, isPending: false },
};
};

View File

@@ -6,15 +6,20 @@ import {
Box,
Button,
Checkbox,
CloseButton,
Drawer,
Flex,
Group,
Image,
Loader,
Modal,
Paper,
PinInput,
ScrollArea,
Stack,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import {
ArrowLeft,
@@ -24,6 +29,7 @@ import {
RotateCw,
ShieldCheck,
} from "lucide-react";
import { useMediaQuery } from "@mantine/hooks";
import toast from "react-hot-toast";
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
@@ -44,6 +50,9 @@ export default function ContractViewPage() {
const navigate = useNavigate();
const qc = useQueryClient();
const iframeRef = useRef<HTMLIFrameElement>(null);
// Below Mantine's `sm`: the signing surfaces go full-bleed and the consent
// bar stacks, so the checkbox and its button stop crowding each other.
const isMobile = useMediaQuery("(max-width: 48em)");
const [signOpen, setSignOpen] = useState(false);
const [otpOpen, setOtpOpen] = useState(false);
@@ -315,7 +324,7 @@ export default function ContractViewPage() {
</Paper>
</Box>
{data.canSignCustomer && hasScrolledToBottom && (
{data.canSignCustomer && hasScrolledToBottom && !signOpen && !otpOpen && !successOpen && (
<Paper
withBorder
radius="lg"
@@ -325,87 +334,169 @@ export default function ContractViewPage() {
maw={920}
mx="auto"
style={{
// Above SupportWidget's Affix (zIndex 300) — the fixed chat FAB
// shares this bottom-right corner and would otherwise render on
// top of the required agree-and-sign bar.
position: "relative",
zIndex: 301,
// No z-index escalation here: raising this bar above the chat FAB
// is what made it fight every overlay on the page. It keeps clear
// of the fixed FAB by leaving room for it instead (paddingRight on
// wide screens, where the FAB sits beside the bar's right edge).
background: "var(--mantine-color-body)",
}}
pr={{ base: "md", sm: 88 }}
>
<Box maw={920} mx="auto">
<Group justify="flex-start" align="flex-start" wrap="wrap" gap="sm">
<Checkbox
checked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.currentTarget.checked)}
disabled={!hasScrolledToBottom}
label={CONSENT_TEXT}
description={
hasScrolledToBottom
? "You may now sign the contract."
: "Read the full contract above before you can agree and sign."
}
/>
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}
disabled={!canProceedToSign}
onClick={openSign}
>
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
</Group>
</Box>
<Flex
direction={{ base: "column", sm: "row" }}
align={{ base: "stretch", sm: "center" }}
justify="space-between"
gap="sm"
>
<Checkbox
checked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.currentTarget.checked)}
disabled={!hasScrolledToBottom}
label={CONSENT_TEXT}
description={
hasScrolledToBottom
? "You may now sign the contract."
: "Read the full contract above before you can agree and sign."
}
/>
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}
disabled={!canProceedToSign}
onClick={openSign}
fullWidth={isMobile}
style={{ flexShrink: 0 }}
>
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
</Flex>
</Paper>
)}
<Modal
{/* A right-hand drawer, not a centered dialog: the sign step shares the
bottom-right corner with the agree-and-sign bar and the support-chat
FAB, and a full-height side panel simply never contends with them. */}
<Drawer
opened={signOpen}
onClose={() => setSignOpen(false)}
title={usingSaved ? "Approve signature" : "Sign contract"}
centered
radius="lg"
position={isMobile ? "bottom" : "right"}
size={isMobile ? "100%" : "lg"}
padding={0}
withCloseButton={false}
// Clear of the sign bar (301) and the chat FAB's Affix (300).
zIndex={400}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
// Drawer.Body has no intrinsic height, so the inner flex column (pinned
// header / scrolling middle / pinned footer) would collapse without this.
styles={{
content: { display: "flex", flexDirection: "column" },
body: { flex: 1, minHeight: 0, display: "flex", flexDirection: "column" },
}}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{data.reference} your signature is stored securely on the
contract.
</Text>
<>
{/* Header — pinned, so the contract reference stays visible while the
signature and stamp sections scroll. */}
<Group
justify="space-between"
wrap="nowrap"
p="lg"
style={{
borderBottom: "1px solid var(--mantine-color-gray-2)",
flexShrink: 0,
}}
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={38} radius="md" variant="light" color="edr-green">
<FileSignature size={19} />
</ThemeIcon>
<Box>
<Text fw={600}>
{usingSaved ? "Approve signature" : "Sign contract"}
</Text>
<Text size="xs" c="dimmed">
{data.reference}
</Text>
</Box>
</Group>
<CloseButton size="lg" onClick={() => setSignOpen(false)} />
</Group>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<Stack gap="lg" p="lg">
<TextInput
label="Full name"
description="Printed under your signature on the contract."
placeholder="Name as it should appear on the contract"
withAsterisk
value={signerName}
onChange={(e) => setSignerName(e.currentTarget.value)}
/>
{usingSaved ? (
<Stack gap="xs">
<Box>
<Group justify="space-between" align="center" mb={6} wrap="nowrap">
<Text size="sm" fw={500}>
Signature{" "}
<Text span c="red">
*
</Text>
</Text>
{usingSaved ? (
<Button
variant="subtle"
size="compact-xs"
color="edr-green"
leftSection={<RotateCw size={13} />}
onClick={() => {
setDrawNew(true);
setSignatureData(null);
}}
>
Draw a new one
</Button>
) : savedSignatureImage ? (
<Button
variant="subtle"
size="compact-xs"
color="gray"
onClick={() => {
setDrawNew(false);
setSignatureData(null);
}}
>
Use saved signature
</Button>
) : null}
</Group>
{usingSaved ? (
<Paper
withBorder
radius="md"
p="xs"
style={{ borderStyle: "dashed" }}
p="sm"
style={{
borderStyle: "dashed",
background: "var(--mantine-color-gray-0)",
}}
>
<Image
src={savedSignatureImage ?? undefined}
alt="Saved signature"
fit="contain"
h={140}
h={120}
/>
<Group gap={6} mt="xs" justify="center" wrap="nowrap">
<ShieldCheck
size={13}
color="var(--mantine-color-edr-green-6)"
/>
<Text size="xs" c="dimmed">
Saved signature stored securely on your profile
</Text>
</Group>
</Paper>
<Button
variant="subtle"
size="compact-xs"
color="edr-green"
onClick={() => {
setDrawNew(true);
setSignatureData(null);
}}
>
Draw a new signature instead
</Button>
</Stack>
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
</Box>
<StampUpload
value={stampData}
@@ -413,26 +504,47 @@ export default function ContractViewPage() {
description="Attach your official company stamp or seal — it is applied to the contract next to your signature."
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setSignOpen(false)}>
Cancel
</Button>
<Button
color="edr-green"
loading={sendOtpMutation.isPending}
disabled={
sendOtpMutation.isPending ||
!signerName.trim() ||
(!usingSaved && !signatureData) ||
!stampData
}
onClick={confirmSign}
>
Continue to verification
</Button>
</Group>
</Stack>
</Modal>
</Stack>
</ScrollArea>
{/* Footer — pinned, so the primary action never scrolls out of reach. */}
<Stack
gap="sm"
p="lg"
style={{
borderTop: "1px solid var(--mantine-color-gray-2)",
background: "var(--mantine-color-body)",
flexShrink: 0,
}}
>
<Group gap={6} wrap="nowrap">
<ShieldCheck size={14} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">
We send a 6-digit code to your registered contacts next.
</Text>
</Group>
<Group gap="sm" grow>
<Button variant="default" onClick={() => setSignOpen(false)}>
Cancel
</Button>
<Button
color="edr-green"
leftSection={<ShieldCheck size={16} />}
loading={sendOtpMutation.isPending}
disabled={
sendOtpMutation.isPending ||
!signerName.trim() ||
(!usingSaved && !signatureData) ||
!stampData
}
onClick={confirmSign}
>
Continue to verification
</Button>
</Group>
</Stack>
</>
</Drawer>
<Modal
opened={otpOpen}
@@ -440,6 +552,10 @@ export default function ContractViewPage() {
title="Verify it's you"
centered
radius="lg"
zIndex={400}
// Full-bleed on phones — a centered dialog plus the fixed chat FAB left
// the code entry and its buttons fighting for the same few pixels.
fullScreen={isMobile}
>
<Stack gap="md">
<Group gap="sm" wrap="nowrap">