feat(gps): фильтры heartbeat-эхо/valid=false/spike; доступ к GPS и Финансам — матрица на /users (пользователи+роли)
This commit is contained in:
@@ -380,7 +380,7 @@ export function MobileBottomNav() {
|
||||
Шаблоны полей
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
{isAdmin(user) && (
|
||||
{isAdmin(user) && (user as any)?.moduleAccess?.finance !== false && (
|
||||
<Link href="/finance">
|
||||
<DropdownMenuItem className="text-xs">
|
||||
<PiggyBank className="w-3.5 h-3.5 mr-2" />
|
||||
|
||||
@@ -1086,7 +1086,9 @@ function FinanceNavItem({ collapsed }: { collapsed: boolean }) {
|
||||
const [location] = useLocation();
|
||||
const isActive = location === '/finance' || location.startsWith('/finance/');
|
||||
|
||||
if (!user || (!isAdmin(user) && !hasPermission(user, 'finance.view'))) return null;
|
||||
// Доступ к финансам: app-role право + флаг модуля (user/роль) из /api/auth/me
|
||||
const moduleAllowed = (user as any)?.moduleAccess?.finance !== false;
|
||||
if (!user || !moduleAllowed || (!isAdmin(user) && !hasPermission(user, 'finance.view'))) return null;
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
|
||||
@@ -82,9 +82,10 @@ import {
|
||||
import { useColumnFilters } from '@/hooks/useColumnFilters';
|
||||
import { ColumnFilter } from '@/components/ui/ColumnFilter';
|
||||
import { ActiveFilterBadges } from '@/components/ui/ActiveFilterBadges';
|
||||
import { apiRequest } from '@/lib/queryClient';
|
||||
import { apiRequest, queryClient } from '@/lib/queryClient';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -115,6 +116,9 @@ interface Role {
|
||||
directMemberCount: number;
|
||||
totalMemberCount: number;
|
||||
members: RoleMember[];
|
||||
/** Флаги доступа к модулям (матрица доступа) */
|
||||
gpsAccess?: boolean;
|
||||
financeAccess?: boolean;
|
||||
}
|
||||
|
||||
interface RoleTreeNode extends Role {
|
||||
@@ -132,6 +136,46 @@ interface UserStatus {
|
||||
|
||||
const PARENT_NONE = 'none';
|
||||
|
||||
/**
|
||||
* Ячейка матрицы доступа: чекбокс флага пользователя + пометка,
|
||||
* если доступ унаследован от роли (флаг снят, но вкладка доступна).
|
||||
*/
|
||||
function ModuleAccessCell({
|
||||
checked,
|
||||
inheritedFrom,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
/** Названия ролей, через которые доступ уже есть */
|
||||
inheritedFrom: string[];
|
||||
disabled?: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(v) => onChange(!!v)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
{!checked && inheritedFrom.length > 0 && (
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex text-muted-foreground">
|
||||
<Users className="w-3 h-3" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Доступ через {inheritedFrom.length === 1 ? 'роль' : 'роли'}: {inheritedFrom.join(', ')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildRoleTree(roles: Role[]): RoleTreeNode[] {
|
||||
const map = new Map<number, RoleTreeNode>();
|
||||
roles.forEach((r) => map.set(r.id, { ...r, children: [] }));
|
||||
@@ -153,8 +197,10 @@ function RoleNode({
|
||||
onAddChild,
|
||||
onDelete,
|
||||
onManageMembers,
|
||||
onModuleAccess,
|
||||
isAdmin,
|
||||
canReassign,
|
||||
canEditModuleAccess,
|
||||
}: {
|
||||
node: RoleTreeNode;
|
||||
depth: number;
|
||||
@@ -162,8 +208,11 @@ function RoleNode({
|
||||
onAddChild: (parentId: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onManageMembers: (role: Role) => void;
|
||||
onModuleAccess: (role: Role, field: 'gpsAccess' | 'financeAccess', value: boolean) => void;
|
||||
isAdmin: boolean;
|
||||
canReassign: boolean;
|
||||
/** Матрица доступа — только для администратора приложения (эндпоинт admin-only) */
|
||||
canEditModuleAccess: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(depth < 2);
|
||||
const hasChildren = node.children.length > 0;
|
||||
@@ -252,6 +301,28 @@ function RoleNode({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Доступ роли к модулям (только администратор приложения) */}
|
||||
{canEditModuleAccess && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<label className="flex items-center gap-1 cursor-pointer" title="Доступ к GPS-модулю">
|
||||
<Checkbox
|
||||
checked={!!node.gpsAccess}
|
||||
onCheckedChange={(v) => onModuleAccess(node, 'gpsAccess', !!v)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">GPS</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1 cursor-pointer" title="Доступ к финансам">
|
||||
<Checkbox
|
||||
checked={!!node.financeAccess}
|
||||
onCheckedChange={(v) => onModuleAccess(node, 'financeAccess', !!v)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">Финансы</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Раскрытое содержимое */}
|
||||
@@ -288,8 +359,10 @@ function RoleNode({
|
||||
onAddChild={onAddChild}
|
||||
onDelete={onDelete}
|
||||
onManageMembers={onManageMembers}
|
||||
onModuleAccess={onModuleAccess}
|
||||
isAdmin={isAdmin}
|
||||
canReassign={canReassign}
|
||||
canEditModuleAccess={canEditModuleAccess}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -763,6 +836,50 @@ const UsersPage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// === Матрица доступа к модулям (GPS / Финансы), admin only ===
|
||||
const invalidateModuleAccess = () => {
|
||||
refetchUsers();
|
||||
refetchRoles();
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/gps/config'] });
|
||||
};
|
||||
|
||||
const userModuleAccessMutation = useMutation({
|
||||
mutationFn: async ({ userId, field, value }: { userId: number; field: 'gpsAccess' | 'financeAccess'; value: boolean }) => {
|
||||
const res = await apiRequest('PATCH', `/api/users/${userId}/module-access`, { [field]: value });
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateModuleAccess();
|
||||
toast({ title: 'Доступ обновлён' });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Ошибка', description: 'Не удалось обновить доступ', variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
const roleModuleAccessMutation = useMutation({
|
||||
mutationFn: async ({ roleId, field, value }: { roleId: number; field: 'gpsAccess' | 'financeAccess'; value: boolean }) => {
|
||||
const res = await apiRequest('PATCH', `/api/roles/${roleId}/module-access`, { [field]: value });
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateModuleAccess();
|
||||
toast({ title: 'Доступ обновлён' });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Ошибка', description: 'Не удалось обновить доступ', variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
// Флаги доступа ролей по id — для пометки «доступ через роль» у пользователя
|
||||
const roleAccessById = useMemo(() => {
|
||||
const map = new Map<number, { name: string; gpsAccess: boolean; financeAccess: boolean }>();
|
||||
for (const r of roles) {
|
||||
map.set(r.id, { name: r.name, gpsAccess: !!r.gpsAccess, financeAccess: !!r.financeAccess });
|
||||
}
|
||||
return map;
|
||||
}, [roles]);
|
||||
|
||||
const openCreateStatus = () => {
|
||||
setEditingStatus(null);
|
||||
setStatusName('');
|
||||
@@ -934,6 +1051,12 @@ const UsersPage = () => {
|
||||
<ColumnFilter column="role" label="Роль" filterValue={filters['role'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchUserColumnSuggestions('role')} />
|
||||
</TableHead>
|
||||
<TableHead>Орг. роли</TableHead>
|
||||
{isAdmin(user) && (
|
||||
<>
|
||||
<TableHead className="text-center w-[50px]" title="Доступ к GPS-модулю">GPS</TableHead>
|
||||
<TableHead className="text-center w-[70px]" title="Доступ к финансам">Финансы</TableHead>
|
||||
</>
|
||||
)}
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>
|
||||
<ColumnFilter column="status" label="Активность" filterValue={filters['status'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchUserColumnSuggestions('status')} />
|
||||
@@ -983,6 +1106,32 @@ const UsersPage = () => {
|
||||
{(tableUser.organizationalRoles || []).map((r: any) => r.name).join(', ') || '—'}
|
||||
</span>
|
||||
</TableCell>
|
||||
{isAdmin(user) && (
|
||||
<>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<ModuleAccessCell
|
||||
checked={!!tableUser.gpsAccess}
|
||||
inheritedFrom={(tableUser.organizationalRoles || [])
|
||||
.map((r: any) => roleAccessById.get(r.id))
|
||||
.filter((r: { name: string; gpsAccess: boolean; financeAccess: boolean } | undefined) => r?.gpsAccess)
|
||||
.map((r: { name: string }) => r.name)}
|
||||
disabled={userModuleAccessMutation.isPending}
|
||||
onChange={(v) => userModuleAccessMutation.mutate({ userId: tableUser.id, field: 'gpsAccess', value: v })}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<ModuleAccessCell
|
||||
checked={!!tableUser.financeAccess}
|
||||
inheritedFrom={(tableUser.organizationalRoles || [])
|
||||
.map((r: any) => roleAccessById.get(r.id))
|
||||
.filter((r: { name: string; gpsAccess: boolean; financeAccess: boolean } | undefined) => r?.financeAccess)
|
||||
.map((r: { name: string }) => r.name)}
|
||||
disabled={userModuleAccessMutation.isPending}
|
||||
onChange={(v) => userModuleAccessMutation.mutate({ userId: tableUser.id, field: 'financeAccess', value: v })}
|
||||
/>
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
{hasPermission(user, 'users.manage') ? (
|
||||
<Select
|
||||
@@ -1173,8 +1322,10 @@ const UsersPage = () => {
|
||||
onAddChild={(parentId) => openCreateRole(parentId)}
|
||||
onDelete={handleDeleteRole}
|
||||
onManageMembers={openManageMembers}
|
||||
onModuleAccess={(role, field, value) => roleModuleAccessMutation.mutate({ roleId: role.id, field, value })}
|
||||
isAdmin={hasPermission(user, 'roles.manage')}
|
||||
canReassign={canReassign}
|
||||
canEditModuleAccess={isAdmin(user)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Loader2, MapPin, Save, SlidersHorizontal, ShieldCheck, X } from 'lucide-react';
|
||||
import { Loader2, MapPin, Save, SlidersHorizontal, X } from 'lucide-react';
|
||||
import { apiRequest } from '@/lib/queryClient';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
@@ -19,8 +18,6 @@ interface GpsSettings {
|
||||
defaultLat: number | null;
|
||||
defaultLng: number | null;
|
||||
defaultZoom: number | null;
|
||||
allowedUserIds: number[] | null;
|
||||
allowedRoleIds: number[] | null;
|
||||
}
|
||||
|
||||
interface GeoSuggestion {
|
||||
@@ -173,9 +170,6 @@ export function GpsSettingsTab() {
|
||||
isAdminUser={isAdminUser}
|
||||
onSaved={invalidateSettings}
|
||||
/>
|
||||
|
||||
{/* Доступ к модулю — только admin */}
|
||||
{isAdminUser && <AccessCard settings={data} onSaved={invalidateSettings} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -330,132 +324,3 @@ function DefaultCityCard({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Card «Доступ к GPS-модулю» (только admin): пользователи и роли */
|
||||
function AccessCard({ settings, onSaved }: { settings: GpsSettings | undefined; onSaved: () => void }) {
|
||||
const { toast } = useToast();
|
||||
const [selectedUsers, setSelectedUsers] = useState<Set<number> | null>(null);
|
||||
const [selectedRoles, setSelectedRoles] = useState<Set<number> | null>(null);
|
||||
|
||||
// Инициализация из настроек (один раз при загрузке)
|
||||
useEffect(() => {
|
||||
if (settings && selectedUsers === null) {
|
||||
setSelectedUsers(new Set(settings.allowedUserIds ?? []));
|
||||
setSelectedRoles(new Set(settings.allowedRoleIds ?? []));
|
||||
}
|
||||
}, [settings, selectedUsers]);
|
||||
|
||||
const { data: usersData } = useQuery<{ success: boolean; users: Array<{ id: number; firstName?: string; lastName?: string; email: string; fullName?: string }> }>({
|
||||
queryKey: ['/api/users'],
|
||||
});
|
||||
const { data: rolesData } = useQuery<{ success: boolean; roles: Array<{ id: number; name: string }> }>({
|
||||
queryKey: ['/api/roles'],
|
||||
});
|
||||
const users = usersData?.users || [];
|
||||
const roles = rolesData?.roles || [];
|
||||
|
||||
const toggle = (set: Set<number>, id: number): Set<number> => {
|
||||
const next = new Set(set);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
};
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const userIds = [...(selectedUsers ?? [])];
|
||||
const roleIds = [...(selectedRoles ?? [])];
|
||||
// Семантика бэкенда: оба списка пустые = доступ у всех → шлём null/null
|
||||
const res = await apiRequest('PUT', '/api/gps/settings', {
|
||||
allowedUserIds: userIds.length > 0 ? userIds : null,
|
||||
allowedRoleIds: roleIds.length > 0 ? roleIds : null,
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Доступ сохранён' });
|
||||
onSaved();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({ title: 'Ошибка', description: err?.message || 'Не удалось сохранить доступ', variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
const userLabel = (u: { firstName?: string; lastName?: string; email: string; fullName?: string }) =>
|
||||
u.fullName || `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email;
|
||||
|
||||
if (selectedUsers === null || selectedRoles === null) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center justify-center h-16">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
Доступ к GPS-модулю
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Пусто = доступ у всех. Если выбраны пользователи или роли — модуль виден только им
|
||||
(администраторы видят всегда).
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Пользователи</Label>
|
||||
<div className="max-h-44 overflow-y-auto border border-border rounded p-2 space-y-1">
|
||||
{users.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Нет пользователей</p>
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<label key={u.id} className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={selectedUsers.has(u.id)}
|
||||
onCheckedChange={() => setSelectedUsers(toggle(selectedUsers, u.id))}
|
||||
/>
|
||||
<span className="text-xs truncate">{userLabel(u)}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Роли</Label>
|
||||
<div className="max-h-44 overflow-y-auto border border-border rounded p-2 space-y-1">
|
||||
{roles.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Нет ролей</p>
|
||||
) : (
|
||||
roles.map((r) => (
|
||||
<label key={r.id} className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={selectedRoles.has(r.id)}
|
||||
onCheckedChange={() => setSelectedRoles(toggle(selectedRoles, r.id))}
|
||||
/>
|
||||
<span className="text-xs truncate">{r.name}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="sm" className="h-8" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-3.5 h-3.5 mr-1" />
|
||||
)}
|
||||
Сохранить доступ
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user