feat(gps): фильтры heartbeat-эхо/valid=false/spike; доступ к GPS и Финансам — матрица на /users (пользователи+роли)

This commit is contained in:
2026-08-26 17:13:49 +03:00
parent 2fb7ad6606
commit ac4f89d42e
16 changed files with 431 additions and 227 deletions

View File

@@ -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" />

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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>
);
}