mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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:
@@ -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={
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -57,6 +57,10 @@ export const URL_CONSTANTS = {
|
||||
BASE: "/exchange-settings",
|
||||
},
|
||||
|
||||
MANUAL_PAYMENT_SETTINGS: {
|
||||
BASE: "/payment-settings/manual",
|
||||
},
|
||||
|
||||
AUDIT_LOGS: {
|
||||
BASE: "/audit",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user