feat: Implantação da Matriz Central de Acessos e Permissões Unificada com templates em 1 clique

This commit is contained in:
2026-09-22 14:13:40 +00:00
parent 53deebe67a
commit f095737d53
7 changed files with 947 additions and 8 deletions
@@ -0,0 +1,153 @@
import React, { useState } from 'react';
import { useMatrizAcessos, UsuarioMatriz } from '@/hooks/useMatrizAcessos';
import { MatrizGrid } from './MatrizGrid';
import { MatrizTemplatesModal } from './MatrizTemplatesModal';
import { SimuladorVisaoBar } from './SimuladorVisaoBar';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Search, RefreshCw, Users, Shield, Factory, Sparkles } from 'lucide-react';
interface MatrizAcessosPainelProps {
onOpenNovoUsuario?: () => void;
}
export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
onOpenNovoUsuario,
}) => {
const {
loading,
usuarios,
gruposModulos,
usuarioSimuladoId,
setUsuarioSimuladoId,
togglePermissao,
aplicarTemplate,
recarregar,
} = useMatrizAcessos();
const [busca, setBusca] = useState('');
const [selectedUserForTemplate, setSelectedUserForTemplate] = useState<UsuarioMatriz | null>(null);
// Filtragem por nome, email ou cargo
const usuariosFiltrados = usuarios.filter((u) => {
const termo = busca.toLowerCase();
return (
u.fullName.toLowerCase().includes(termo) ||
u.email.toLowerCase().includes(termo) ||
u.functionName.toLowerCase().includes(termo)
);
});
// Métricas rápidas
const totalUsuarios = usuarios.length;
const totalSmartExclusivo = usuarios.filter(
(u) => u.recursosPermitidos.has('modo-smart') && u.recursosPermitidos.size <= 4
).length;
const totalAdmins = usuarios.filter((u) =>
u.privilegeName.toLowerCase().includes('admin')
).length;
return (
<div className="space-y-4">
{/* 1. CARDS DE RESUMO RÁPIDO DO ACESSO */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="bg-card border border-border rounded-xl p-3.5 flex items-center justify-between">
<div className="space-y-0.5">
<span className="text-[11px] text-muted-foreground">Total de Usuários</span>
<p className="text-xl font-bold text-foreground">{totalUsuarios}</p>
</div>
<div className="p-2.5 rounded-xl bg-primary/10 text-primary">
<Users className="h-5 w-5" />
</div>
</div>
<div className="bg-card border border-border rounded-xl p-3.5 flex items-center justify-between">
<div className="space-y-0.5">
<span className="text-[11px] text-muted-foreground">Operadores Modo Smart</span>
<p className="text-xl font-bold text-emerald-400">{totalSmartExclusivo}</p>
</div>
<div className="p-2.5 rounded-xl bg-emerald-500/10 text-emerald-400">
<Factory className="h-5 w-5" />
</div>
</div>
<div className="bg-card border border-border rounded-xl p-3.5 flex items-center justify-between">
<div className="space-y-0.5">
<span className="text-[11px] text-muted-foreground">Administradores</span>
<p className="text-xl font-bold text-rose-400">{totalAdmins}</p>
</div>
<div className="p-2.5 rounded-xl bg-rose-500/10 text-rose-400">
<Shield className="h-5 w-5" />
</div>
</div>
</div>
{/* 2. SIMULADOR DE VISÃO */}
<SimuladorVisaoBar
usuarios={usuarios}
grupos={gruposModulos}
usuarioSimuladoId={usuarioSimuladoId}
onSelectUsuarioSimulado={setUsuarioSimuladoId}
/>
{/* 3. BARRA DE CONTROLE E BUSCA */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 bg-card border border-border rounded-xl p-3">
<div className="relative w-full sm:w-[320px]">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar por colaborador, email ou cargo..."
value={busca}
onChange={(e) => setBusca(e.target.value)}
className="pl-8 text-xs h-9 bg-background"
/>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto justify-end">
<Button
size="sm"
variant="outline"
onClick={recarregar}
disabled={loading}
className="text-xs h-9 text-muted-foreground hover:text-foreground"
>
<RefreshCw className={`h-3.5 w-3.5 mr-1.5 ${loading ? 'animate-spin' : ''}`} />
Atualizar
</Button>
{onOpenNovoUsuario && (
<Button
size="sm"
onClick={onOpenNovoUsuario}
className="text-xs h-9 bg-primary hover:bg-primary/90 text-primary-foreground font-semibold"
>
+ Novo Usuário
</Button>
)}
</div>
</div>
{/* 4. A GRADE INTERATIVA (MATRIZ) */}
{loading ? (
<div className="py-16 text-center text-xs text-muted-foreground">
Carregando matriz de permissões e usuários...
</div>
) : (
<MatrizGrid
usuarios={usuariosFiltrados}
grupos={gruposModulos}
onTogglePermissao={togglePermissao}
onOpenTemplates={(user) => setSelectedUserForTemplate(user)}
/>
)}
{/* 5. MODAL DE TEMPLATES RÁPIDOS */}
<MatrizTemplatesModal
user={selectedUserForTemplate}
isOpen={Boolean(selectedUserForTemplate)}
onClose={() => setSelectedUserForTemplate(null)}
onSelectTemplate={aplicarTemplate}
/>
</div>
);
};
+179
View File
@@ -0,0 +1,179 @@
import React from 'react';
import { UsuarioMatriz, GrupoRecurso } from '@/hooks/useMatrizAcessos';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Check, X, Shield, Sparkles, User, HelpCircle } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface MatrizGridProps {
usuarios: UsuarioMatriz[];
grupos: GrupoRecurso[];
onTogglePermissao: (userId: string, resourceKey: string) => void;
onOpenTemplates: (user: UsuarioMatriz) => void;
}
export const MatrizGrid: React.FC<MatrizGridProps> = ({
usuarios,
grupos,
onTogglePermissao,
onOpenTemplates,
}) => {
// Cores de fundo e borda para os grupos
const corGrupoHeader: Record<string, string> = {
emerald: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30',
sky: 'bg-sky-500/10 text-sky-400 border-sky-500/30',
indigo: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/30',
amber: 'bg-amber-500/10 text-amber-400 border-amber-500/30',
teal: 'bg-teal-500/10 text-teal-400 border-teal-500/30',
rose: 'bg-rose-500/10 text-rose-400 border-rose-500/30',
};
return (
<div className="rounded-2xl border border-border bg-card overflow-hidden shadow-sm">
<div className="overflow-x-auto max-w-full">
<table className="w-full text-left border-collapse min-w-[900px]">
{/* CABEÇALHO DE GRUPOS DE MÓDULOS */}
<thead>
<tr className="border-b border-border/80 bg-muted/40">
<th className="p-3 w-[260px] sticky left-0 bg-card z-20 border-r border-border/60">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Usuário / Colaborador
</span>
</th>
{grupos.map((grupo) => (
<th
key={grupo.key}
colSpan={grupo.recursos.length}
className={`p-2.5 text-center border-r border-border/40 text-xs font-bold ${corGrupoHeader[grupo.cor]}`}
>
<div className="flex items-center justify-center gap-1.5">
<span>{grupo.titulo}</span>
</div>
<span className="text-[10px] font-normal opacity-80 block truncate">
{grupo.descricao}
</span>
</th>
))}
</tr>
{/* SUB-CABEÇALHO: CADA TELA/RECURSO ESPECÍFICO */}
<tr className="border-b border-border bg-muted/20 text-[11px] text-muted-foreground">
<th className="p-2.5 sticky left-0 bg-card z-20 border-r border-border/60 font-semibold">
Perfil & Templates
</th>
{grupos.map((grupo) =>
grupo.recursos.map((rec) => (
<th
key={rec.key}
className="p-2 text-center border-r border-border/30 font-medium whitespace-nowrap min-w-[110px]"
title={rec.nome}
>
<span className="truncate block max-w-[120px] mx-auto">
{rec.nome}
</span>
</th>
))
)}
</tr>
</thead>
{/* LINHAS: UM USUÁRIO POR LINHA */}
<tbody className="divide-y divide-border/40 text-xs">
{usuarios.length === 0 ? (
<tr>
<td colSpan={25} className="py-12 text-center text-muted-foreground text-xs">
Nenhum usuário encontrado.
</td>
</tr>
) : (
usuarios.map((user) => (
<tr
key={user.id}
className="hover:bg-muted/30 transition-colors group"
>
{/* Coluna Fixa do Usuário */}
<td className="p-3 sticky left-0 bg-card z-10 border-r border-border/60 group-hover:bg-accent/40 transition-colors">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<Avatar className="h-8 w-8 rounded-lg border border-border">
<AvatarImage src={user.profileImageUrl || ''} />
<AvatarFallback className="rounded-lg bg-primary/10 text-primary text-xs font-bold">
{user.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="font-bold text-foreground truncate text-xs" title={user.fullName}>
{user.fullName}
</p>
<p className="text-[10px] text-muted-foreground truncate" title={user.email}>
{user.functionName}
</p>
</div>
</div>
{/* Botão de Template em 1 Clique */}
<Button
size="sm"
variant="ghost"
onClick={() => onOpenTemplates(user)}
className="h-7 px-2 text-[10px] font-semibold text-primary hover:bg-primary/10"
title="Aplicar template pré-configurado"
>
<Sparkles className="h-3 w-3 mr-1 text-primary" />
Template
</Button>
</div>
</td>
{/* Células Clicáveis da Matriz */}
{grupos.map((grupo) =>
grupo.recursos.map((rec) => {
const temAcesso = user.recursosPermitidos.has(rec.key);
return (
<td
key={`${user.id}_${rec.key}`}
className="p-1.5 text-center border-r border-border/30"
>
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onTogglePermissao(user.id, rec.key)}
className={`w-full py-1.5 rounded-lg font-medium text-xs flex items-center justify-center transition-all ${
temAcesso
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25 border border-emerald-500/30'
: 'bg-muted/40 text-muted-foreground/40 hover:bg-muted/70 hover:text-muted-foreground border border-transparent'
}`}
>
{temAcesso ? (
<Check className="h-4 w-4 stroke-[2.5]" />
) : (
<X className="h-3.5 w-3.5 opacity-40" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
<p className="font-bold">{rec.nome}</p>
<p className="text-muted-foreground">
{temAcesso ? 'Clique para Bloquear' : 'Clique para Liberar'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</td>
);
})
)}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
};
@@ -0,0 +1,120 @@
import React from 'react';
import { UsuarioMatriz, TemplateAcesso, TEMPLATES_ACESSO } from '@/hooks/useMatrizAcessos';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Factory,
Layers,
Truck,
HardHat,
Crown,
Sparkles,
CheckCircle2,
} from 'lucide-react';
interface MatrizTemplatesModalProps {
user: UsuarioMatriz | null;
isOpen: boolean;
onClose: () => void;
onSelectTemplate: (userId: string, templateId: string) => void;
}
export const MatrizTemplatesModal: React.FC<MatrizTemplatesModalProps> = ({
user,
isOpen,
onClose,
onSelectTemplate,
}) => {
if (!user) return null;
const getIcone = (icone: string) => {
switch (icone) {
case 'Factory':
return Factory;
case 'Layers':
return Layers;
case 'Truck':
return Truck;
case 'HardHat':
return HardHat;
case 'Crown':
return Crown;
default:
return Sparkles;
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[620px] rounded-2xl">
<DialogHeader className="pb-2 border-b border-border/50">
<div className="flex items-center gap-2">
<div className="p-2 rounded-xl bg-primary/10 text-primary">
<Sparkles className="h-5 w-5" />
</div>
<div>
<DialogTitle className="text-base md:text-lg">
Templates de Acesso em 1 Clique
</DialogTitle>
<DialogDescription className="text-xs">
Selecione um perfil pronto para aplicar instantaneamente a{' '}
<strong className="text-foreground">{user.fullName}</strong>.
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-3 py-2">
{TEMPLATES_ACESSO.map((template) => {
const Icon = getIcone(template.icone);
return (
<div
key={template.id}
onClick={() => {
onSelectTemplate(user.id, template.id);
onClose();
}}
className="group relative rounded-xl border border-border/80 bg-card hover:border-primary/50 hover:bg-accent/40 p-3.5 transition-all cursor-pointer flex items-center justify-between gap-4"
>
<div className="flex items-start gap-3">
<div className="p-2.5 rounded-xl bg-muted group-hover:bg-primary/10 group-hover:text-primary transition-colors text-muted-foreground mt-0.5">
<Icon className="h-5 w-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="font-bold text-xs md:text-sm text-foreground group-hover:text-primary transition-colors">
{template.nome}
</h4>
<Badge variant="outline" className="text-[10px] py-0 px-1.5 border-border">
{template.badge}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
{template.descricao}
</p>
</div>
</div>
<Button
size="sm"
variant="outline"
className="shrink-0 h-8 text-xs font-semibold group-hover:bg-primary group-hover:text-primary-foreground group-hover:border-primary transition-all"
>
Aplicar
</Button>
</div>
);
})}
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,90 @@
import React from 'react';
import { UsuarioMatriz, GrupoRecurso } from '@/hooks/useMatrizAcessos';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Eye, EyeOff, ShieldCheck, CheckCircle2, XCircle, RotateCcw } from 'lucide-react';
interface SimuladorVisaoBarProps {
usuarios: UsuarioMatriz[];
grupos: GrupoRecurso[];
usuarioSimuladoId: string | null;
onSelectUsuarioSimulado: (id: string | null) => void;
}
export const SimuladorVisaoBar: React.FC<SimuladorVisaoBarProps> = ({
usuarios,
grupos,
usuarioSimuladoId,
onSelectUsuarioSimulado,
}) => {
const usuarioSimulado = usuarios.find((u) => u.id === usuarioSimuladoId);
// Total de módulos disponíveis
const totalModulos = grupos.reduce((acc, g) => acc + g.recursos.length, 0);
const totalPermitidos = usuarioSimulado ? usuarioSimulado.recursosPermitidos.size : 0;
return (
<div className="rounded-xl border border-border/80 bg-muted/30 p-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-xs">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
<Eye className="h-4 w-4" />
</div>
<div>
<span className="font-bold text-foreground block">
Simulador de Visão do Usuário
</span>
<span className="text-muted-foreground text-[11px]">
Selecione um usuário para auditar instantaneamente o que ele enxerga no sistema.
</span>
</div>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto justify-end">
<Select
value={usuarioSimuladoId || 'nenhum'}
onValueChange={(val) => onSelectUsuarioSimulado(val === 'nenhum' ? null : val)}
>
<SelectTrigger className="w-full sm:w-[220px] h-8 text-xs bg-background">
<SelectValue placeholder="Selecione um usuário..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="nenhum">Nenhum (Visão do Admin)</SelectItem>
{usuarios.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.fullName} ({u.functionName})
</SelectItem>
))}
</SelectContent>
</Select>
{usuarioSimulado && (
<>
<Badge
variant="outline"
className={`h-7 px-2 border font-semibold flex items-center gap-1 ${
usuarioSimulado.recursosPermitidos.has('modo-smart') && totalPermitidos <= 4
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-400'
: 'border-sky-500/40 bg-sky-500/10 text-sky-400'
}`}
>
<CheckCircle2 className="h-3 w-3" />
{usuarioSimulado.recursosPermitidos.has('modo-smart') && totalPermitidos <= 4
? 'Modo Smart Exclusivo'
: `${totalPermitidos}/${totalModulos} Módulos`}
</Badge>
<Button
size="sm"
variant="ghost"
onClick={() => onSelectUsuarioSimulado(null)}
className="h-8 text-xs text-muted-foreground hover:text-foreground"
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
);
};