Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx
2026-08-02 22:29:58 +00:00

201 lines
6.7 KiB
TypeScript

import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { useAuth } from "@/auth/useAuth";
import { useEmployees } from "@/user-management/hooks/useEmployees";
import {
ALL_TRADE_DIRECTIONS,
TRADE_DIRECTION_LABELS,
userTradeAccessService,
type TradeDirection,
} from "@/services/userTradeAccess.service";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const QUERY_KEY = ["user-trade-access", "list"] as const;
type EmployeeRow = {
userId: string;
name: string;
email: string;
};
/**
* Per-user trade-direction access (Import / Export / Intercity checkboxes).
* All three checked (or never configured) = unrestricted; unchecking limits
* the user's contracts, bookings, schedules, batch board, payments, invoices
* and overview to the checked directions. Admins always bypass the scope.
*/
export default function TradeAccessPage() {
const { user } = useAuth();
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const organizationId =
user?.employee && user.employee.length > 0
? user.employee[0].organizationId
: undefined;
const { employeesResponseByOrg, isLoadingEmployeesByOrg } = useEmployees({
organizationId,
});
const { data: configs, isLoading: configsLoading } = useQuery({
queryKey: QUERY_KEY,
queryFn: userTradeAccessService.list,
});
const saveMutation = useMutation({
mutationFn: ({
userId,
directions,
}: {
userId: string;
directions: TradeDirection[];
}) => userTradeAccessService.set(userId, directions),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
void queryClient.invalidateQueries({
queryKey: ["user-trade-access", "me"],
});
toast.success("Trade access updated");
},
});
const configByUser = useMemo(() => {
const map = new Map<string, TradeDirection[]>();
for (const row of configs ?? []) map.set(row.userId, row.directions);
return map;
}, [configs]);
const rows: EmployeeRow[] = useMemo(() => {
const items = employeesResponseByOrg?.items ?? [];
const mapped = items
.map((item: { user?: { id?: string; name?: { en?: string }; email?: string; username?: string } }) => ({
userId: item.user?.id ?? "",
name: item.user?.name?.en ?? item.user?.username ?? "—",
email: item.user?.email ?? "",
}))
.filter((r: EmployeeRow) => r.userId);
const term = search.trim().toLowerCase();
if (!term) return mapped;
return mapped.filter(
(r: EmployeeRow) =>
r.name.toLowerCase().includes(term) ||
r.email.toLowerCase().includes(term),
);
}, [employeesResponseByOrg, search]);
// No row yet = unrestricted, so render as all three checked.
const directionsFor = (userId: string): TradeDirection[] =>
configByUser.get(userId) ?? [...ALL_TRADE_DIRECTIONS];
const toggle = (userId: string, direction: TradeDirection) => {
const current = directionsFor(userId);
const next = current.includes(direction)
? current.filter((d) => d !== direction)
: [...current, direction];
saveMutation.mutate({ userId, directions: next });
};
const loading = isLoadingEmployeesByOrg || configsLoading;
return (
<div className="space-y-4 p-4">
<div>
<h1 className="text-xl font-bold">Trade direction access</h1>
<p className="text-sm text-muted-foreground">
Choose which trade directions each backoffice user can see. This
filters their contracts, bookings, schedules, batch board, payments,
invoices and overview. All three checked means full access; super and
organization admins are never restricted.
</p>
</div>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name or email…"
className="w-full max-w-sm rounded-md border px-3 py-2 text-sm"
/>
{loading ? (
<p className="text-sm text-muted-foreground">Loading users</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
{ALL_TRADE_DIRECTIONS.map((d) => (
<TableHead key={d} className="text-center">
{TRADE_DIRECTION_LABELS[d]}
</TableHead>
))}
<TableHead>Access</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const dirs = directionsFor(row.userId);
const unrestricted = dirs.length === ALL_TRADE_DIRECTIONS.length;
return (
<TableRow key={row.userId}>
<TableCell className="font-medium">{row.name}</TableCell>
<TableCell>{row.email}</TableCell>
{ALL_TRADE_DIRECTIONS.map((d) => (
<TableCell key={d} className="text-center">
<input
type="checkbox"
className="h-4 w-4 accent-primary"
checked={dirs.includes(d)}
disabled={saveMutation.isPending}
onChange={() => toggle(row.userId, d)}
aria-label={`${row.name}${TRADE_DIRECTION_LABELS[d]}`}
/>
</TableCell>
))}
<TableCell>
{unrestricted ? (
<span className="text-xs text-muted-foreground">
Full access
</span>
) : dirs.length === 0 ? (
<span className="text-xs font-medium text-red-600">
No data
</span>
) : (
<span className="text-xs font-medium text-amber-600">
{dirs.map((d) => TRADE_DIRECTION_LABELS[d]).join(" + ")}{" "}
only
</span>
)}
</TableCell>
</TableRow>
);
})}
{rows.length === 0 && (
<TableRow>
<TableCell
colSpan={3 + ALL_TRADE_DIRECTIONS.length}
className="text-center text-sm text-muted-foreground"
>
No users found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
);
}