feat: Implantação da Matriz Central de Acessos e Permissões Unificada com templates em 1 clique
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,378 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface UsuarioMatriz {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
status: 'active' | 'pending' | 'inactive' | 'rejected';
|
||||
privilegeId: string | null;
|
||||
privilegeName: string;
|
||||
functionId: string | null;
|
||||
functionName: string;
|
||||
profileImageUrl: string | null;
|
||||
recursosPermitidos: Set<string>;
|
||||
}
|
||||
|
||||
export interface GrupoRecurso {
|
||||
key: string;
|
||||
titulo: string;
|
||||
cor: string; // Ex: 'emerald', 'sky', 'indigo', 'amber', 'rose'
|
||||
descricao: string;
|
||||
recursos: {
|
||||
key: string;
|
||||
nome: string;
|
||||
rota?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// 6 Grupos Industriais Claros e Intuitivos
|
||||
export const GRUPOS_MODULOS: GrupoRecurso[] = [
|
||||
{
|
||||
key: 'smart',
|
||||
titulo: 'Modo Smart (Chão de Fábrica)',
|
||||
cor: 'emerald',
|
||||
descricao: 'Interface touch para operadores e líderes de linha',
|
||||
recursos: [
|
||||
{ key: 'modo-smart', nome: 'Acesso Geral Modo Smart', rota: '/smart' },
|
||||
{ key: 'smart-apontamento', nome: '1. Apontar Produção', rota: '/smart' },
|
||||
{ key: 'smart-metricas', nome: '2. Ver Minhas Métricas', rota: '/smart' },
|
||||
{ key: 'smart-pdf', nome: '3. Gerar Relatórios e PDFs', rota: '/smart' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'producao_pcp',
|
||||
titulo: 'Produção & PCP',
|
||||
cor: 'sky',
|
||||
descricao: 'Gestão de capacidade, apontamentos e mesa tática',
|
||||
recursos: [
|
||||
{ key: 'producao-pcp', nome: 'Mesa Tática PCP', rota: '/pcp' },
|
||||
{ key: 'producao-dashboard', nome: 'Dashboard de Produção', rota: '/dashboard-producao' },
|
||||
{ key: 'producao-apontamento', nome: 'Apontamento Administrativo', rota: '/apontamento-producao' },
|
||||
{ key: 'diario-producao', nome: 'Diário de Produção', rota: '/diario-producao' },
|
||||
{ key: 'prioridades-fabricacao', nome: 'Prioridades de Fabricação', rota: '/prioridades-fabricacao' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'engenharia_3d',
|
||||
titulo: 'Engenharia, OFs & BIM',
|
||||
cor: 'indigo',
|
||||
descricao: 'Projetos, modelos 3D, fichas técnicas e cronogramas',
|
||||
recursos: [
|
||||
{ key: 'visualizador-3d', nome: 'Visualizador 3D BIM', rota: '/visualizador-3d' },
|
||||
{ key: 'ofs-lista', nome: 'Ordens de Fabricação', rota: '/ofs' },
|
||||
{ key: 'ofs-cronograma', nome: 'Cronogramas de Fabricação', rota: '/ofs/cronograma' },
|
||||
{ key: 'cadastro-pecas', nome: 'Cadastro de Peças & Conjuntos', rota: '/cadastro/pecas' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'expedicao_logistica',
|
||||
titulo: 'Expedição & Romaneios',
|
||||
cor: 'amber',
|
||||
descricao: 'Embarque, pesagem, transporte e romaneios',
|
||||
recursos: [
|
||||
{ key: 'expedicao', nome: 'Painel de Expedição', rota: '/expedicao' },
|
||||
{ key: 'estoque', nome: 'Controle de Estoque', rota: '/estoque' },
|
||||
{ key: 'estoque-solicitacao-compras', nome: 'Solicitação de Compras', rota: '/estoque/solicitacao-compras' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'obra_montagem',
|
||||
titulo: 'Obra & Montagem Externa',
|
||||
cor: 'teal',
|
||||
descricao: 'Acompanhamento do canteiro e montagem',
|
||||
recursos: [
|
||||
{ key: 'obra-dashboard', nome: 'Dashboard da Obra', rota: '/obra' },
|
||||
{ key: 'obra-configuracoes', nome: 'Configurações de Obra', rota: '/obra/configuracoes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'admin_sistema',
|
||||
titulo: 'Administração & Sistema',
|
||||
cor: 'rose',
|
||||
descricao: 'Controle de acessos, usuários e configurações',
|
||||
recursos: [
|
||||
{ key: 'user-management', nome: 'Matriz de Usuários & Acessos', rota: '/user-management' },
|
||||
{ key: 'configuracoes-gerais', nome: 'Configurações Gerais', rota: '/configuracoes' },
|
||||
{ key: 'ferramentas-inconsistencias', nome: 'Auditoria de Inconsistências', rota: '/ferramentas/inconsistencias' },
|
||||
{ key: 'sistema', nome: 'Painel do Sistema', rota: '/sistema' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export interface TemplateAcesso {
|
||||
id: string;
|
||||
nome: string;
|
||||
descricao: string;
|
||||
icone: string;
|
||||
badge: string;
|
||||
recursosPermitidos: string[];
|
||||
}
|
||||
|
||||
export const TEMPLATES_ACESSO: TemplateAcesso[] = [
|
||||
{
|
||||
id: 'modo_smart_exclusivo',
|
||||
nome: 'Operador Chão de Fábrica (Modo Smart)',
|
||||
descricao: 'Acesso 100% restrito ao Modo Smart touch. Não enxerga nenhum menu corporativo do ERP.',
|
||||
icone: 'Factory',
|
||||
badge: 'Chão de Fábrica',
|
||||
recursosPermitidos: [
|
||||
'modo-smart',
|
||||
'smart-apontamento',
|
||||
'smart-metricas',
|
||||
'smart-pdf',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pcp_planejamento',
|
||||
nome: 'PCP & Planejamento Industrial',
|
||||
descricao: 'Acesso completo à Mesa Tática do PCP, Dashboards, Cronogramas, OFs e Visualizador 3D.',
|
||||
icone: 'Layers',
|
||||
badge: 'Planejamento',
|
||||
recursosPermitidos: [
|
||||
'producao-pcp',
|
||||
'producao-dashboard',
|
||||
'diario-producao',
|
||||
'prioridades-fabricacao',
|
||||
'visualizador-3d',
|
||||
'ofs-lista',
|
||||
'ofs-cronograma',
|
||||
'cadastro-pecas',
|
||||
'modo-smart',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'expedicao_logistica',
|
||||
nome: 'Expedição & Almoxarifado',
|
||||
descricao: 'Controle de romaneios, pesagens, expedição de peças e solicitações de compras.',
|
||||
icone: 'Truck',
|
||||
badge: 'Logística',
|
||||
recursosPermitidos: [
|
||||
'expedicao',
|
||||
'estoque',
|
||||
'estoque-solicitacao-compras',
|
||||
'modo-smart',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fiscal_obra',
|
||||
nome: 'Fiscal de Obra & Montagem',
|
||||
descricao: 'Acompanhamento do canteiro de obras, romaneios recebidos e Modo Smart de montagem.',
|
||||
icone: 'HardHat',
|
||||
badge: 'Obra',
|
||||
recursosPermitidos: [
|
||||
'obra-dashboard',
|
||||
'obra-configuracoes',
|
||||
'visualizador-3d',
|
||||
'modo-smart',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'administrador_total',
|
||||
nome: 'Administrador / Gestor Total',
|
||||
descricao: 'Acesso irrestrito a todas as telas, configurações, usuários e ferramentas do sistema.',
|
||||
icone: 'Crown',
|
||||
badge: 'Controle Total',
|
||||
recursosPermitidos: GRUPOS_MODULOS.flatMap((g) => g.recursos.map((r) => r.key)),
|
||||
},
|
||||
];
|
||||
|
||||
export const useMatrizAcessos = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [usuarios, setUsuarios] = useState<UsuarioMatriz[]>([]);
|
||||
const [usuarioSimuladoId, setUsuarioSimuladoId] = useState<string | null>(null);
|
||||
|
||||
const carregarDados = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 1. Carregar profiles com functions e privileges
|
||||
const { data: profiles, error: profError } = await supabase
|
||||
.from('profiles')
|
||||
.select(`
|
||||
id,
|
||||
email,
|
||||
full_name,
|
||||
status,
|
||||
privilege_id,
|
||||
function_id,
|
||||
profile_image_url,
|
||||
privileges:privilege_id (
|
||||
id,
|
||||
name
|
||||
),
|
||||
user_functions:function_id (
|
||||
id,
|
||||
name
|
||||
)
|
||||
`)
|
||||
.order('full_name', { ascending: true });
|
||||
|
||||
if (profError) throw profError;
|
||||
|
||||
// 2. Carregar recursos por privilégio
|
||||
const { data: privResources, error: privResError } = await supabase
|
||||
.from('privilege_interface_resources')
|
||||
.select('privilege_id, resource_key');
|
||||
|
||||
if (privResError) console.warn('Aviso ao carregar privilege_interface_resources:', privResError);
|
||||
|
||||
// Indexar recursos por privilege_id
|
||||
const recursosPorPrivilege: Record<string, Set<string>> = {};
|
||||
(privResources || []).forEach((pr: any) => {
|
||||
if (!recursosPorPrivilege[pr.privilege_id]) {
|
||||
recursosPorPrivilege[pr.privilege_id] = new Set();
|
||||
}
|
||||
recursosPorPrivilege[pr.privilege_id].add(pr.resource_key);
|
||||
});
|
||||
|
||||
// Mapear usuários para a Matriz
|
||||
const mapped: UsuarioMatriz[] = (profiles || []).map((p: any) => {
|
||||
const privId = p.privilege_id;
|
||||
const recursos = privId && recursosPorPrivilege[privId]
|
||||
? new Set(recursosPorPrivilege[privId])
|
||||
: new Set<string>();
|
||||
|
||||
// Se o privilégio tiver nome "Admin" ou similar, já inclui todos
|
||||
const privNome = p.privileges?.name || 'Padrão';
|
||||
if (privNome.toLowerCase().includes('admin')) {
|
||||
GRUPOS_MODULOS.forEach((g) => g.recursos.forEach((r) => recursos.add(r.key)));
|
||||
}
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
email: p.email || '',
|
||||
fullName: p.full_name || p.email?.split('@')[0] || 'Usuário',
|
||||
status: p.status || 'active',
|
||||
privilegeId: privId,
|
||||
privilegeName: privNome,
|
||||
functionId: p.function_id,
|
||||
functionName: p.user_functions?.name || 'Sem Cargo',
|
||||
profileImageUrl: p.profile_image_url,
|
||||
recursosPermitidos: recursos,
|
||||
};
|
||||
});
|
||||
|
||||
setUsuarios(mapped);
|
||||
} catch (err: any) {
|
||||
console.error('Erro ao carregar dados da matriz de acessos:', err);
|
||||
toast.error('Erro ao carregar dados de usuários e permissões.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
carregarDados();
|
||||
}, []);
|
||||
|
||||
// TOGGLE DIRETO DE UMA PERMISSÃO NA MATRIZ
|
||||
const togglePermissao = async (userId: string, resourceKey: string) => {
|
||||
const user = usuarios.find((u) => u.id === userId);
|
||||
if (!user) return;
|
||||
|
||||
const temAcesso = user.recursosPermitidos.has(resourceKey);
|
||||
const novoSet = new Set(user.recursosPermitidos);
|
||||
|
||||
if (temAcesso) {
|
||||
novoSet.delete(resourceKey);
|
||||
} else {
|
||||
novoSet.add(resourceKey);
|
||||
}
|
||||
|
||||
// Atualização otimista na tela
|
||||
setUsuarios((prev) =>
|
||||
prev.map((u) => (u.id === userId ? { ...u, recursosPermitidos: novoSet } : u))
|
||||
);
|
||||
|
||||
try {
|
||||
// Se o usuário tem privilege_id, salvar no banco
|
||||
if (user.privilegeId) {
|
||||
if (temAcesso) {
|
||||
// Remover
|
||||
await supabase
|
||||
.from('privilege_interface_resources')
|
||||
.delete()
|
||||
.eq('privilege_id', user.privilegeId)
|
||||
.eq('resource_key', resourceKey);
|
||||
} else {
|
||||
// Adicionar
|
||||
await supabase
|
||||
.from('privilege_interface_resources')
|
||||
.upsert(
|
||||
{ privilege_id: user.privilegeId, resource_key: resourceKey },
|
||||
{ onConflict: 'privilege_id,resource_key' }
|
||||
);
|
||||
}
|
||||
}
|
||||
toast.success(
|
||||
temAcesso
|
||||
? `Módulo '${resourceKey}' bloqueado para ${user.fullName}.`
|
||||
: `Módulo '${resourceKey}' liberado para ${user.fullName}!`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Erro ao atualizar permissão:', err);
|
||||
toast.error('Erro ao salvar alteração no banco.');
|
||||
carregarDados(); // Reverter se falhar
|
||||
}
|
||||
};
|
||||
|
||||
// APLICAR TEMPLATE PRÉ-CONFIGURADO EM 1 CLIQUE
|
||||
const aplicarTemplate = async (userId: string, templateId: string) => {
|
||||
const user = usuarios.find((u) => u.id === userId);
|
||||
const template = TEMPLATES_ACESSO.find((t) => t.id === templateId);
|
||||
if (!user || !template) return;
|
||||
|
||||
const novoSet = new Set(template.recursosPermitidos);
|
||||
|
||||
// Atualização otimista
|
||||
setUsuarios((prev) =>
|
||||
prev.map((u) => (u.id === userId ? { ...u, recursosPermitidos: novoSet } : u))
|
||||
);
|
||||
|
||||
try {
|
||||
if (user.privilegeId) {
|
||||
// Limpar recursos atuais do privilégio
|
||||
await supabase
|
||||
.from('privilege_interface_resources')
|
||||
.delete()
|
||||
.eq('privilege_id', user.privilegeId);
|
||||
|
||||
// Inserir os novos recursos do template
|
||||
const records = template.recursosPermitidos.map((resKey) => ({
|
||||
privilege_id: user.privilegeId,
|
||||
resource_key: resKey,
|
||||
}));
|
||||
|
||||
if (records.length > 0) {
|
||||
await supabase.from('privilege_interface_resources').insert(records);
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`Template '${template.nome}' aplicado com sucesso a ${user.fullName}!`);
|
||||
} catch (err) {
|
||||
console.error('Erro ao aplicar template:', err);
|
||||
toast.error('Erro ao salvar template.');
|
||||
carregarDados();
|
||||
}
|
||||
};
|
||||
|
||||
// Usuário sendo simulado atualmente (para o visualizador "Ver como este usuário")
|
||||
const usuarioSimulado = useMemo(() => {
|
||||
return usuarios.find((u) => u.id === usuarioSimuladoId) || null;
|
||||
}, [usuarios, usuarioSimuladoId]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
usuarios,
|
||||
gruposModulos: GRUPOS_MODULOS,
|
||||
templates: TEMPLATES_ACESSO,
|
||||
usuarioSimulado,
|
||||
usuarioSimuladoId,
|
||||
setUsuarioSimuladoId,
|
||||
togglePermissao,
|
||||
aplicarTemplate,
|
||||
recarregar: carregarDados,
|
||||
};
|
||||
};
|
||||
+9
-4
@@ -16,7 +16,7 @@ import { Eye, EyeOff, Mail, Lock } from 'lucide-react';
|
||||
|
||||
const Auth = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { signIn, user, loading, isRecoveryFlow, session } = useAuth();
|
||||
const { signIn, user, loading, isRecoveryFlow } = useAuth();
|
||||
const { brandSettings } = useBrandSettings();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -46,11 +46,16 @@ const Auth = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shouldShowReset, showPasswordReset, loading, location.pathname]);
|
||||
|
||||
// Redirecionar usuários autenticados para página principal (exceto em fluxo de recuperação)
|
||||
// Redirecionar usuários autenticados para página principal ou Modo Smart
|
||||
useEffect(() => {
|
||||
if (!loading && user && !showPasswordReset) {
|
||||
console.log('🔄 Redirecionando usuário autenticado para página principal');
|
||||
navigate('/');
|
||||
const isSmartMode = localStorage.getItem('tracksteel_app_mode') === 'smart';
|
||||
console.log('🔄 Redirecionando usuário autenticado:', isSmartMode ? '/smart' : '/');
|
||||
if (isSmartMode) {
|
||||
navigate('/smart');
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
}, [user, loading, navigate, showPasswordReset]);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Users, UserPlus, Shield, Settings, Mail, Activity } from 'lucide-react';
|
||||
import { Users, UserPlus, Shield, Settings, Mail, Activity, LayoutGrid } from 'lucide-react';
|
||||
import { UsersTable } from '@/components/users/UsersTable';
|
||||
import { PendingUsersTable } from '@/components/users/PendingUsersTable';
|
||||
import { FunctionsManager } from '@/components/users/FunctionsManager';
|
||||
@@ -10,13 +10,14 @@ import { PrivilegesManager } from '@/components/users/PrivilegesManager';
|
||||
import { PasswordResetRequests } from '@/components/users/PasswordResetRequests';
|
||||
import { SessionLogsSimple } from '@/components/users/SessionLogsSimple';
|
||||
import { UserModal } from '@/components/users/UserModal';
|
||||
import { MatrizAcessosPainel } from '@/components/users/matriz/MatrizAcessosPainel';
|
||||
import { useUserManagement, UserProfile } from '@/hooks/useUserManagement';
|
||||
import { usePermissionControl } from '@/hooks/usePermissionControl';
|
||||
|
||||
const UserManagement = () => {
|
||||
const [isUserModalOpen, setIsUserModalOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserProfile | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('users');
|
||||
const [activeTab, setActiveTab] = useState('matriz');
|
||||
|
||||
const {
|
||||
canView,
|
||||
@@ -157,7 +158,16 @@ const UserManagement = () => {
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 md:grid-cols-6 bg-muted h-auto p-1">
|
||||
<TabsList className="grid w-full grid-cols-2 md:grid-cols-7 bg-muted h-auto p-1">
|
||||
<TabsTrigger
|
||||
value="matriz"
|
||||
className="flex items-center gap-1 md:gap-2 text-xs md:text-sm p-2 md:p-3 font-semibold text-primary data-[state=active]:bg-primary data-[state=active]:text-primary-foreground"
|
||||
>
|
||||
<LayoutGrid className="h-3 w-3 md:h-4 md:w-4" />
|
||||
<span className="hidden sm:inline">Matriz de Acessos</span>
|
||||
<span className="sm:hidden">Matriz</span>
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value="users"
|
||||
className="flex items-center gap-1 md:gap-2 text-xs md:text-sm p-2 md:p-3"
|
||||
@@ -223,6 +233,10 @@ const UserManagement = () => {
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="matriz" className="space-y-4 mt-4">
|
||||
<MatrizAcessosPainel onOpenNovoUsuario={canCreate() ? handleCreateUser : undefined} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="users" className="space-y-4 mt-4">
|
||||
<Card className="card-mobile">
|
||||
<CardHeader className="card-header-mobile">
|
||||
@@ -308,7 +322,7 @@ const UserManagement = () => {
|
||||
</CardHeader>
|
||||
<CardContent className="card-content-mobile">
|
||||
<PrivilegesManager
|
||||
privileges={privileges}
|
||||
privileges={privileges as any}
|
||||
onCreate={canCreate() ? createPrivilege : undefined}
|
||||
onUpdate={canEdit() ? updatePrivilege : undefined}
|
||||
onDelete={canDelete() ? deletePrivilege : undefined}
|
||||
|
||||
Reference in New Issue
Block a user