mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 13:05:44 +00:00
per-user trade-direction access scope
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
@@ -139,6 +140,7 @@ export default function BookingRequestsPage() {
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>(() =>
|
||||
paramStatuses.split(",").filter(Boolean),
|
||||
);
|
||||
const { filterOptions } = useMyTradeAccess();
|
||||
const [directionFilter, setDirectionFilter] = useState<string | null>(
|
||||
paramDirection,
|
||||
);
|
||||
@@ -602,7 +604,7 @@ export default function BookingRequestsPage() {
|
||||
/>
|
||||
<Select
|
||||
placeholder="All directions"
|
||||
data={TRADE_DIRECTION_OPTIONS}
|
||||
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
|
||||
value={directionFilter}
|
||||
onChange={(v) => {
|
||||
setDirectionFilter(v);
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
@@ -92,6 +93,7 @@ export default function ClearanceDocumentsPage() {
|
||||
const [bookingStatuses, setBookingStatuses] = useState(
|
||||
BOOKING_STATUS_OPTIONS[0].value,
|
||||
);
|
||||
const { filterOptions } = useMyTradeAccess();
|
||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
|
||||
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
|
||||
@@ -309,7 +311,7 @@ export default function ClearanceDocumentsPage() {
|
||||
<Group gap="sm" mt="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="Direction"
|
||||
data={TRADE_DIRECTION_OPTIONS}
|
||||
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
|
||||
value={directionFilter}
|
||||
onChange={(v) => {
|
||||
setDirectionFilter(v);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { directionLabel } from "@/lib/utils";
|
||||
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
@@ -156,6 +157,7 @@ export default function ContractRequestsPage() {
|
||||
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
|
||||
// Filter controls (empty/null = "all").
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||
const { filterOptions } = useMyTradeAccess();
|
||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(
|
||||
null,
|
||||
@@ -561,7 +563,7 @@ export default function ContractRequestsPage() {
|
||||
/>
|
||||
<Select
|
||||
placeholder="All directions"
|
||||
data={TRADE_DIRECTION_OPTIONS}
|
||||
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
|
||||
value={directionFilter}
|
||||
onChange={(v) => {
|
||||
setDirectionFilter(v);
|
||||
|
||||
Reference in New Issue
Block a user