feat: consolidacao matriz de acessos, modais avancados e ocultacao dinamica de menus

This commit is contained in:
2026-09-25 14:58:08 -03:00
parent 6f595d6839
commit 81ba4e8e3e
8 changed files with 1054 additions and 70 deletions
+62 -14
View File
@@ -100,46 +100,66 @@ export function AppSidebar() {
// Admin can always access everything
if (isAdmin) return true;
// Mapeamento de chave de menu/submenu da sidebar para resource_key do BD
// Mapeamento completo de chave de menu/submenu da sidebar para resource_key do BD
const itemToResourceMap: Record<string, string> = {
// PCP & Produção
'pcp': 'producao-pcp',
'dashboard-producao': 'producao-dashboard',
'apontamento-producao': 'producao-apontamento',
'diario-producao': 'diario-producao',
'prioridades-fabricacao': 'prioridades-fabricacao',
'visao-geral': 'producao-visao',
'painel-industrial': 'producao-dashboard',
// Engenharia, 3D & OFs
'visualizador-3d': 'visualizador-3d',
'ordens-fabricacao': 'ofs-lista',
'ofs-concluidas': 'ofs-lista',
'ficha-tecnica-of': 'cadastro-of',
'cronograma': 'ofs-cronograma',
'cadastro-pecas': 'cadastro-pecas',
'equipamentos': 'equipamentos',
// Expedição & Estoque
'expedicao': 'expedicao',
'estoque-main': 'estoque',
'estoque': 'estoque',
'solicitacao-compras': 'estoque-solicitacao-compras',
// Obra & Campo
'dashboard-obras': 'obra-dashboard',
'configuracoes-obra': 'obra-configuracoes',
'obra': 'obra-dashboard',
// Tarefas & Ferramentas
'tarefas': 'tarefas',
'lista-tarefas': 'tarefas',
'historico-tarefas': 'tarefas',
'ferramentas': 'ferramentas',
'conversores': 'ferramentas-inconsistencias',
'inconsistencias': 'ferramentas-inconsistencias',
// Biblioteca & Sistema
'biblioteca': 'sistema',
'catalogos': 'sistema',
'normas': 'sistema',
'referencias': 'sistema',
'sistema': 'sistema',
'sugestoes': 'sugestoes',
'atribuicoes': 'user-management',
'mapa-interativo': 'dashboard',
'dashboard': 'dashboard',
// Administração & Usuários
'gerenciar-usuarios': 'user-management',
'user-management': 'user-management',
'configuracoes-gerais': 'configuracoes-gerais',
'inconsistencias': 'ferramentas-inconsistencias',
'ferramentas': 'ferramentas',
'sistema': 'sistema',
'sugestoes': 'sugestoes',
'personalizacao-tema': 'admin',
'admin': 'admin',
'dashboard': 'dashboard',
};
const resKey = itemToResourceMap[itemKey] || itemKey;
// Se há restrição direta configurada no BD para este recurso
// 1. Se há restrição direta configurada no BD para este recurso
if (resourcePermissions && resourcePermissions[resKey]) {
return resourcePermissions[resKey] !== 'no_access';
}
// Se o usuário tem perfil de "apenas Smart" (tem modo-smart mas não tem esse recurso)
// 2. Se o usuário tem perfil de "apenas Smart" (tem modo-smart mas não tem outros recursos)
const temAcessoSmart =
resourcePermissions &&
resourcePermissions['modo-smart'] &&
@@ -156,8 +176,23 @@ export function AppSidebar() {
if (soTemSmart && !resKey.startsWith('smart') && resKey !== 'modo-smart') {
return false;
}
// 3. Se há permissões individuais salvas na Matriz para este usuário:
// Se a matriz foi configurada para o usuário, qualquer tela que não foi concedida (exceto dashboard básico) fica oculta
if (permsCadastradas.length > 0) {
if (resourcePermissions[resKey] === 'no_access') {
return false;
}
if (resKey !== 'dashboard' && !resKey.startsWith('smart')) {
// Se não consta na lista de permissões ativas concedidas
const perm = resourcePermissions[resKey];
if (!perm || perm === 'no_access') {
return false;
}
}
}
// Se o item requer permissão especial, verificar permissões específicas
// 4. Se o item requer permissão especial, verificar permissões específicas
if (requiresSpecialPermission) {
if (itemKey === 'ferramentas') {
return canAccessTools();
@@ -235,12 +270,25 @@ export function AppSidebar() {
{menuGroups.map(group => {
// Filter admin groups for non-admin users
if (!isAdmin && group.name === 'Administração') {
logger.debug('Ocultando grupo admin para usuário não-admin');
return null;
const hasAnyAdminItem = group.items.some(item => canAccessItem(item.key, item.requiresSpecialPermission));
if (!hasAnyAdminItem) {
return null;
}
}
// Skip groups with no items
if (!group.items || group.items.length === 0) {
// Filtrar itens do grupo permitidos para o usuário logado
const itemsPermitidos = (group.items || []).filter(item => {
if (item.subItems && item.subItems.length > 0) {
return isAdmin || item.subItems.some(sub => {
if (sub.url === "/admin/theme-customization" && !isAdmin) return false;
return canAccessItem(sub.key, sub.requiresSpecialPermission);
});
}
return isAdmin || canAccessItem(item.key, item.requiresSpecialPermission);
});
// Se não houver itens visíveis no grupo, não renderiza o grupo (nem o título)
if (itemsPermitidos.length === 0) {
return null;
}
@@ -254,7 +302,7 @@ export function AppSidebar() {
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => (
{itemsPermitidos.map((item) => (
<AppSidebarMenuItem
key={item.key || item.title}
item={item}
+10 -8
View File
@@ -27,12 +27,6 @@ export const AppSidebarMenuItem: React.FC<SidebarMenuItemProps> = ({
onMenuItemClick,
getIconProps
}) => {
const canAccess = canAccessItem(item.key);
if (!canAccess && !isAdmin) {
return null; // Don't show item if no access
}
// Filtrar subitens permitidos para o usuário logado
const subItemsPermitidos = (item.subItems || []).filter((subItem) => {
if (subItem.url === "/admin/theme-customization" && !isAdmin) {
@@ -42,8 +36,16 @@ export const AppSidebarMenuItem: React.FC<SidebarMenuItemProps> = ({
});
// Se o item for apenas um agrupador de subitens e todos estiverem bloqueados, não renderizar o agrupador
if (item.subItems && item.subItems.length > 0 && subItemsPermitidos.length === 0 && !isAdmin) {
return null;
if (item.subItems && item.subItems.length > 0) {
if (!isAdmin && subItemsPermitidos.length === 0) {
return null;
}
} else {
// Se for item direto sem subitens
const canAccess = canAccessItem(item.key);
if (!canAccess && !isAdmin) {
return null;
}
}
return (
@@ -3,6 +3,7 @@ import { useMatrizAcessos, UsuarioMatriz } from '@/hooks/useMatrizAcessos';
import { MatrizGrid } from './MatrizGrid';
import { MatrizTemplatesModal } from './MatrizTemplatesModal';
import { MatrizSmartAvancadoModal } from './MatrizSmartAvancadoModal';
import { MatrizGrupoAvancadoModal } from './MatrizGrupoAvancadoModal';
import { SimuladorVisaoBar } from './SimuladorVisaoBar';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@@ -30,6 +31,7 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
const [busca, setBusca] = useState('');
const [selectedUserForTemplate, setSelectedUserForTemplate] = useState<UsuarioMatriz | null>(null);
const [smartAvancadoOpen, setSmartAvancadoOpen] = useState(false);
const [grupoAvancadoKey, setGrupoAvancadoKey] = useState<string | null>(null);
// Filtragem por nome, email ou cargo
const usuariosFiltrados = usuarios.filter((u) => {
@@ -152,6 +154,7 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
onToggleNivel={toggleNivel}
onOpenTemplates={(user) => setSelectedUserForTemplate(user)}
onOpenSmartAvancado={() => setSmartAvancadoOpen(true)}
onOpenGrupoAvancado={(key) => setGrupoAvancadoKey(key)}
/>
)}
@@ -168,6 +171,13 @@ export const MatrizAcessosPainel: React.FC<MatrizAcessosPainelProps> = ({
isOpen={smartAvancadoOpen}
onClose={() => setSmartAvancadoOpen(false)}
/>
{/* 7. MODAL DE GESTÃO AVANÇADA POR CATEGORIA / GRUPO */}
<MatrizGrupoAvancadoModal
grupoKey={grupoAvancadoKey}
isOpen={Boolean(grupoAvancadoKey)}
onClose={() => setGrupoAvancadoKey(null)}
/>
</div>
);
};
+24 -2
View File
@@ -12,6 +12,7 @@ interface MatrizGridProps {
onToggleNivel: (userId: string, resourceKey: string, tipo: 'V' | 'C') => void;
onOpenTemplates: (user: UsuarioMatriz) => void;
onOpenSmartAvancado?: () => void;
onOpenGrupoAvancado?: (grupoKey: string) => void;
}
export const MatrizGrid: React.FC<MatrizGridProps> = ({
@@ -20,6 +21,7 @@ export const MatrizGrid: React.FC<MatrizGridProps> = ({
onToggleNivel,
onOpenTemplates,
onOpenSmartAvancado,
onOpenGrupoAvancado,
}) => {
// Cores de fundo e borda para os grupos
const corGrupoHeader: Record<string, string> = {
@@ -31,6 +33,15 @@ export const MatrizGrid: React.FC<MatrizGridProps> = ({
rose: 'bg-rose-500/10 text-rose-400 border-rose-500/30',
};
const corBotaoAvancado: Record<string, string> = {
emerald: 'bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 border border-emerald-500/40',
sky: 'bg-sky-500/20 hover:bg-sky-500/30 text-sky-300 border border-sky-500/40',
indigo: 'bg-indigo-500/20 hover:bg-indigo-500/30 text-indigo-300 border border-indigo-500/40',
amber: 'bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-500/40',
teal: 'bg-teal-500/20 hover:bg-teal-500/30 text-teal-300 border border-teal-500/40',
rose: 'bg-rose-500/20 hover:bg-rose-500/30 text-rose-300 border border-rose-500/40',
};
return (
<div className="rounded-2xl border border-border bg-card overflow-hidden shadow-sm">
<div className="overflow-x-auto max-w-full">
@@ -52,7 +63,7 @@ export const MatrizGrid: React.FC<MatrizGridProps> = ({
>
<div className="flex items-center justify-center gap-2 flex-wrap">
<span>{grupo.titulo}</span>
{grupo.key === 'smart' && onOpenSmartAvancado && (
{grupo.key === 'smart' && onOpenSmartAvancado ? (
<Button
size="sm"
variant="secondary"
@@ -63,7 +74,18 @@ export const MatrizGrid: React.FC<MatrizGridProps> = ({
<SlidersHorizontal className="h-3 w-3" />
Avançado
</Button>
)}
) : onOpenGrupoAvancado ? (
<Button
size="sm"
variant="secondary"
onClick={() => onOpenGrupoAvancado(grupo.key)}
className={`h-6 px-2 py-0 text-[10px] ${corBotaoAvancado[grupo.cor] || 'bg-primary/20 text-primary border-primary/40'} rounded-md font-semibold flex items-center gap-1 shadow-sm transition-transform active:scale-95`}
title={`Abrir Regras & Governança Avançada de ${grupo.titulo}`}
>
<SlidersHorizontal className="h-3 w-3" />
Avançado
</Button>
) : null}
</div>
<span className="text-[10px] font-normal opacity-80 block truncate">
{grupo.descricao}
@@ -0,0 +1,384 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
SlidersHorizontal,
Search,
CheckCircle2,
Save,
RotateCcw,
Shield,
Factory,
Box,
Truck,
HardHat,
Sparkles,
Lock,
ArrowUpDown,
Layers,
AlertTriangle,
Gauge,
Download,
DollarSign,
Network,
UploadCloud,
Compass,
Scale,
Percent,
FileSignature,
EyeOff,
Barcode,
PackageCheck,
CheckSquare,
AlertOctagon,
Activity,
DownloadCloud,
Settings,
Crown,
Wrench,
Calendar,
} from 'lucide-react';
import { useGrupoAvancadoPermissions } from '@/hooks/useGrupoAvancadoPermissions';
interface MatrizGrupoAvancadoModalProps {
grupoKey: string | null;
isOpen: boolean;
onClose: () => void;
}
export const MatrizGrupoAvancadoModal: React.FC<MatrizGrupoAvancadoModalProps> = ({
grupoKey,
isOpen,
onClose,
}) => {
const {
config,
loading,
saving,
users,
selectedUserId,
setSelectedUserId,
selectedUser,
toggleRegra,
aplicarTemplate,
salvarConfiguracaoUsuario,
recarregar,
} = useGrupoAvancadoPermissions(isOpen ? grupoKey : null);
const [busca, setBusca] = useState('');
if (!config) return null;
const usersFiltrados = users.filter((u) => {
const termo = busca.toLowerCase();
return (
u.fullName.toLowerCase().includes(termo) ||
u.email.toLowerCase().includes(termo) ||
u.functionName.toLowerCase().includes(termo)
);
});
const corBadge: Record<string, string> = {
sky: 'bg-sky-500/15 text-sky-400 border-sky-500/40',
indigo: 'bg-indigo-500/15 text-indigo-400 border-indigo-500/40',
amber: 'bg-amber-500/15 text-amber-400 border-amber-500/40',
teal: 'bg-teal-500/15 text-teal-400 border-teal-500/40',
rose: 'bg-rose-500/15 text-rose-400 border-rose-500/40',
};
const corFundoIcone: Record<string, string> = {
sky: 'bg-sky-500/15 text-sky-400 border-sky-500/30',
indigo: 'bg-indigo-500/15 text-indigo-400 border-indigo-500/30',
amber: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
teal: 'bg-teal-500/15 text-teal-400 border-teal-500/30',
rose: 'bg-rose-500/15 text-rose-400 border-rose-500/30',
};
const getIconComponent = (icone: string) => {
switch (icone) {
case 'Calendar':
return <Calendar className="h-4 w-4 text-sky-400" />;
case 'Lock':
return <Lock className="h-4 w-4 text-rose-400" />;
case 'ArrowUpDown':
return <ArrowUpDown className="h-4 w-4 text-amber-400" />;
case 'Layers':
return <Layers className="h-4 w-4 text-indigo-400" />;
case 'AlertTriangle':
return <AlertTriangle className="h-4 w-4 text-orange-400" />;
case 'Gauge':
return <Gauge className="h-4 w-4 text-purple-400" />;
case 'CheckCircle2':
return <CheckCircle2 className="h-4 w-4 text-emerald-400" />;
case 'Download':
return <Download className="h-4 w-4 text-sky-400" />;
case 'DollarSign':
return <DollarSign className="h-4 w-4 text-emerald-500" />;
case 'Network':
return <Network className="h-4 w-4 text-indigo-400" />;
case 'UploadCloud':
return <UploadCloud className="h-4 w-4 text-teal-400" />;
case 'Scale':
return <Scale className="h-4 w-4 text-amber-400" />;
case 'Percent':
return <Percent className="h-4 w-4 text-cyan-400" />;
case 'FileSignature':
return <FileSignature className="h-4 w-4 text-orange-400" />;
case 'EyeOff':
return <EyeOff className="h-4 w-4 text-rose-400" />;
case 'RotateCcw':
return <RotateCcw className="h-4 w-4 text-amber-400" />;
case 'PackageCheck':
return <PackageCheck className="h-4 w-4 text-teal-400" />;
case 'CheckSquare':
return <CheckSquare className="h-4 w-4 text-emerald-400" />;
case 'AlertOctagon':
return <AlertOctagon className="h-4 w-4 text-rose-400" />;
case 'Activity':
return <Activity className="h-4 w-4 text-rose-400" />;
case 'DownloadCloud':
return <DownloadCloud className="h-4 w-4 text-sky-400" />;
case 'Settings':
return <Settings className="h-4 w-4 text-slate-400" />;
case 'Crown':
return <Crown className="h-3.5 w-3.5 text-amber-400" />;
case 'Shield':
return <Shield className="h-3.5 w-3.5 text-sky-400" />;
case 'Wrench':
return <Wrench className="h-3.5 w-3.5 text-emerald-400" />;
default:
return <Sparkles className="h-4 w-4 text-amber-400" />;
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-5xl w-[95vw] max-h-[92vh] h-[850px] p-0 flex flex-col bg-card border-border shadow-2xl rounded-2xl overflow-hidden">
{/* CABEÇALHO DO MODAL */}
<div className="px-6 py-4 border-b border-border bg-muted/40 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-xl border ${corFundoIcone[config.cor]}`}>
<SlidersHorizontal className="h-5 w-5" />
</div>
<div>
<DialogTitle className="text-base sm:text-lg font-bold flex items-center gap-2 text-foreground">
Regras & Governança Avançada
<Badge variant="outline" className={`text-[10px] uppercase tracking-wider font-bold ${corBadge[config.cor]}`}>
{config.titulo}
</Badge>
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
{config.descricao}
</DialogDescription>
</div>
</div>
</div>
{/* CORPO PRINCIPAL: SIDEBAR DE USUÁRIOS + ÁREA DE REGRAS */}
<div className="flex-1 flex overflow-hidden">
{/* SIDEBAR DE USUÁRIOS */}
<div className="w-[280px] sm:w-[310px] border-r border-border bg-background/60 flex flex-col shrink-0">
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Buscar colaborador..."
value={busca}
onChange={(e) => setBusca(e.target.value)}
className="pl-8 text-xs h-8 bg-card border-slate-300 dark:border-border text-foreground font-medium"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border/40 p-1.5 space-y-0.5">
{loading ? (
<div className="py-12 text-center text-xs text-muted-foreground">
Carregando colaboradores...
</div>
) : usersFiltrados.length === 0 ? (
<div className="py-12 text-center text-xs text-muted-foreground">
Nenhum colaborador encontrado.
</div>
) : (
usersFiltrados.map((user) => {
const isSelected = user.userId === selectedUserId;
const totalAtivas = Object.values(user.regras).filter(Boolean).length;
return (
<button
key={user.userId}
onClick={() => setSelectedUserId(user.userId)}
className={`w-full text-left p-2.5 rounded-xl transition-all flex items-center gap-2.5 ${
isSelected
? `${corBadge[config.cor]} border shadow-sm`
: 'hover:bg-muted/60 text-muted-foreground hover:text-foreground'
}`}
>
<Avatar className="h-8 w-8 rounded-lg border border-border shrink-0">
<AvatarImage src={user.profileImageUrl || ''} />
<AvatarFallback className="text-[10px] font-bold bg-primary/10 text-primary">
{user.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-1">
<span className="text-xs font-semibold text-foreground truncate">
{user.fullName}
</span>
<span className="text-[10px] font-bold text-muted-foreground shrink-0">
{totalAtivas}/{config.regras.length}
</span>
</div>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span className="truncate max-w-[120px]">{user.functionName}</span>
<span>•</span>
<span className="truncate">{user.privilegeName}</span>
</div>
</div>
</button>
);
})
)}
</div>
</div>
{/* ÁREA DE CONFIGURAÇÃO DO USUÁRIO */}
<div className="flex-1 flex flex-col bg-card overflow-hidden">
{selectedUser ? (
<div className="flex-1 flex flex-col overflow-hidden">
{/* CABEÇALHO DO USUÁRIO SELECIONADO */}
<div className="p-4 border-b border-border/60 bg-muted/20 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10 rounded-xl border border-border">
<AvatarImage src={selectedUser.profileImageUrl || ''} />
<AvatarFallback className="text-xs font-bold bg-primary/10 text-primary">
{selectedUser.fullName.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<h3 className="text-sm font-bold text-foreground">
{selectedUser.fullName}
</h3>
<p className="text-xs text-muted-foreground flex items-center gap-2">
<span>{selectedUser.email}</span>
<span>•</span>
<Badge variant="secondary" className="text-[10px] py-0 h-4">
{selectedUser.functionName}
</Badge>
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={salvarConfiguracaoUsuario}
disabled={saving}
className="h-8 text-xs font-semibold bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1.5 shadow-sm"
>
<Save className="h-3.5 w-3.5" />
{saving ? 'Gravando...' : 'Salvar Regras'}
</Button>
</div>
</div>
{/* TEMPLATES RÁPIDOS */}
<div className="px-6 py-3 border-b border-border/40 bg-muted/10">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] font-semibold text-muted-foreground mr-1 flex items-center gap-1">
<Sparkles className="h-3.5 w-3.5 text-amber-400" />
Presets Rápidos:
</span>
{config.templates.map((tpl) => (
<Button
key={tpl.id}
size="sm"
variant="outline"
onClick={() => aplicarTemplate(tpl.id)}
className="h-7 text-[11px] px-2.5 py-0 rounded-lg hover:border-primary/50 flex items-center gap-1.5 bg-background/80"
title={tpl.descricao}
>
{getIconComponent(tpl.icone)}
{tpl.nome}
</Button>
))}
</div>
</div>
{/* LISTAGEM DE REGRAS GRANULARES */}
<div className="flex-1 overflow-y-auto p-6 space-y-3">
<div className="grid grid-cols-1 gap-3">
{config.regras.map((regra) => {
const ativo = Boolean(selectedUser.regras[regra.key]);
return (
<div
key={regra.key}
onClick={() => toggleRegra(regra.key)}
className={`p-3.5 rounded-xl border transition-all cursor-pointer flex items-center justify-between gap-4 ${
ativo
? 'bg-muted/40 border-primary/40 shadow-sm'
: 'bg-background border-border/50 opacity-70 hover:opacity-100 hover:border-border'
}`}
>
<div className="flex items-center gap-3.5 min-w-0">
<div
className={`p-2 rounded-lg border shrink-0 ${
ativo
? `${corBadge[config.cor]} border`
: 'bg-muted text-muted-foreground border-border'
}`}
>
{getIconComponent(regra.icone)}
</div>
<div className="space-y-0.5 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-foreground">
{regra.nome}
</span>
{ativo && (
<Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30 text-[9px] h-4 py-0">
LIBERADO
</Badge>
)}
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
{regra.descricao}
</p>
</div>
</div>
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Switch
checked={ativo}
onCheckedChange={() => toggleRegra(regra.key)}
/>
</div>
</div>
);
})}
</div>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-xs">
Selecione um colaborador ao lado para configurar as regras avançadas.
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
};
+233
View File
@@ -0,0 +1,233 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { GRUPOS_AVANCADOS_CONFIG, GrupoAvancadoConfig } from '@/types/grupoAvancadoTypes';
export interface UserGrupoAvancado {
userId: string;
email: string;
fullName: string;
functionName: string;
privilegeName: string;
profileImageUrl: string | null;
regras: Record<string, boolean>; // regraKey -> true/false
}
export function useGrupoAvancadoPermissions(grupoKey: string | null) {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [users, setUsers] = useState<UserGrupoAvancado[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const config: GrupoAvancadoConfig | undefined = grupoKey
? GRUPOS_AVANCADOS_CONFIG[grupoKey]
: undefined;
const carregarDados = useCallback(async () => {
if (!config) return;
try {
setLoading(true);
// 1. Carregar profiles com cargo e privilégio
const { data: profiles, error: profError } = await supabase
.from('profiles')
.select(`
id,
email,
full_name,
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 permissões das regras deste grupo na tabela user_interface_permissions
const chavesRegras = config.regras.map((r) => r.key);
const { data: permsData, error: permsError } = await supabase
.from('user_interface_permissions')
.select('user_id, resource_key, permission')
.in('resource_key', chavesRegras);
if (permsError) {
console.warn('Aviso ao carregar permissões avançadas:', permsError);
}
// Indexar permissões por user_id -> resource_key -> ativo
const userPermsMap: Record<string, Record<string, boolean>> = {};
(permsData || []).forEach((p: any) => {
if (!userPermsMap[p.user_id]) userPermsMap[p.user_id] = {};
userPermsMap[p.user_id][p.resource_key] =
p.permission === 'can_create_update_delete' ||
p.permission === 'can_admin' ||
p.permission === 'can_create_only' ||
p.permission === 'can_view_only';
});
// Mapear colaboradores
const mapped: UserGrupoAvancado[] = (profiles || []).map((p: any) => {
const privName = p.privileges?.name || 'Padrão';
const isAdmin = privName.toLowerCase().includes('admin');
const userRules: Record<string, boolean> = {};
config.regras.forEach((r) => {
if (userPermsMap[p.id] && userPermsMap[p.id][r.key] !== undefined) {
userRules[r.key] = userPermsMap[p.id][r.key];
} else if (isAdmin) {
userRules[r.key] = true;
} else {
userRules[r.key] = Boolean(r.padraoAtivo);
}
});
return {
userId: p.id,
email: p.email || '',
fullName: p.full_name || p.email?.split('@')[0] || 'Usuário',
functionName: p.user_functions?.name || 'Sem Cargo',
privilegeName: privName,
profileImageUrl: p.profile_image_url,
regras: userRules,
};
});
setUsers(mapped);
if (!selectedUserId && mapped.length > 0) {
setSelectedUserId(mapped[0].userId);
}
} catch (err: any) {
console.error('Erro ao carregar permissões avançadas do grupo:', err);
toast.error('Erro ao carregar governança avançada.');
} finally {
setLoading(false);
}
}, [config, selectedUserId]);
useEffect(() => {
if (grupoKey) {
carregarDados();
}
}, [grupoKey]);
const selectedUser = useMemo(
() => users.find((u) => u.userId === selectedUserId) || null,
[users, selectedUserId]
);
// Toggle de uma regra específica para o usuário selecionado
const toggleRegra = (regraKey: string) => {
if (!selectedUserId) return;
setUsers((prev) =>
prev.map((u) => {
if (u.userId !== selectedUserId) return u;
return {
...u,
regras: {
...u.regras,
[regraKey]: !u.regras[regraKey],
},
};
})
);
};
// Aplica um template pré-definido para o usuário selecionado
const aplicarTemplate = (templateId: string) => {
if (!config || !selectedUserId) return;
const tpl = config.templates.find((t) => t.id === templateId);
if (!tpl) return;
const novasRegras: Record<string, boolean> = {};
config.regras.forEach((r) => {
novasRegras[r.key] = tpl.regrasAtivas.includes(r.key);
});
setUsers((prev) =>
prev.map((u) => {
if (u.userId !== selectedUserId) return u;
return {
...u,
regras: novasRegras,
};
})
);
toast.info(`Template "${tpl.nome}" aplicado. Clique em Salvar para gravar.`);
};
// Salvar alterações no banco de dados
const salvarConfiguracaoUsuario = async () => {
if (!config || !selectedUser) return;
try {
setSaving(true);
// Upsert para cada regra na tabela user_interface_permissions
const promises = config.regras.map(async (r) => {
const estaAtivo = Boolean(selectedUser.regras[r.key]);
const permission = estaAtivo ? 'can_create_update_delete' : 'no_access';
// Garantir que a chave exista em interface_resources para evitar erro de Foreign Key
await supabase
.from('interface_resources')
.upsert(
{
resource_key: r.key,
resource_name: r.nome,
is_submenu: false,
},
{ onConflict: 'resource_key' }
);
return supabase
.from('user_interface_permissions')
.upsert(
{
user_id: selectedUser.userId,
resource_key: r.key,
permission: permission as any,
updated_at: new Date().toISOString(),
},
{ onConflict: 'user_id,resource_key' }
);
});
const results = await Promise.all(promises);
const hasError = results.some((res) => res.error);
if (hasError) {
throw new Error('Falha ao persistir algumas regras no banco.');
}
toast.success(`Configurações avançadas salvas para ${selectedUser.fullName}!`);
} catch (err: any) {
console.error('Erro ao salvar regras avançadas:', err);
toast.error('Erro ao salvar alterações no banco.');
} finally {
setSaving(false);
}
};
return {
config,
loading,
saving,
users,
selectedUserId,
setSelectedUserId,
selectedUser,
toggleRegra,
aplicarTemplate,
salvarConfiguracaoUsuario,
recarregar: carregarDados,
};
}
+1 -46
View File
@@ -6,7 +6,6 @@ import { Users, UserPlus, Shield, Settings, Mail, Activity, LayoutGrid } from 'l
import { UsersTable } from '@/components/users/UsersTable';
import { PendingUsersTable } from '@/components/users/PendingUsersTable';
import { FunctionsManager } from '@/components/users/FunctionsManager';
import { PrivilegesManager } from '@/components/users/PrivilegesManager';
import { PasswordResetRequests } from '@/components/users/PasswordResetRequests';
import { SessionLogsSimple } from '@/components/users/SessionLogsSimple';
import { UserModal } from '@/components/users/UserModal';
@@ -40,9 +39,6 @@ const UserManagement = () => {
createFunction,
updateFunction,
deleteFunction,
createPrivilege: createPrivilegeBase,
updatePrivilege: updatePrivilegeBase,
deletePrivilege,
updateUser,
toggleUserStatus,
deleteUser,
@@ -67,15 +63,6 @@ const UserManagement = () => {
);
}
// Wrapper functions to match expected signatures
const createPrivilege = async (data: { name: string; description?: string; permissions: Record<string, boolean> }): Promise<void> => {
await createPrivilegeBase(data);
};
const updatePrivilege = async (id: string, data: { name: string; description?: string; permissions: Record<string, boolean> }): Promise<void> => {
await updatePrivilegeBase(id, data);
};
const handleEditUser = (user: UserProfile) => {
if (!canEdit()) return;
setSelectedUser(user);
@@ -158,7 +145,7 @@ const UserManagement = () => {
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-2 md:grid-cols-7 bg-muted h-auto p-1">
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-6 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"
@@ -210,17 +197,6 @@ const UserManagement = () => {
</TabsTrigger>
)}
{canEdit() && (
<TabsTrigger
value="privileges"
className="flex items-center gap-1 md:gap-2 text-xs md:text-sm p-2 md:p-3"
>
<Shield className="h-3 w-3 md:h-4 md:w-4" />
<span className="hidden sm:inline">Privilégios</span>
<span className="sm:hidden">Priv</span>
</TabsTrigger>
)}
{canView() && (
<TabsTrigger
value="session-logs"
@@ -311,27 +287,6 @@ const UserManagement = () => {
</TabsContent>
)}
{canEdit() && (
<TabsContent value="privileges" className="space-y-4 mt-4">
<Card className="card-mobile">
<CardHeader className="card-header-mobile">
<CardTitle className="text-lg md:text-xl flex items-center gap-2">
<Shield className="h-4 w-4 md:h-5 md:w-5" />
Gerenciar Privilégios
</CardTitle>
</CardHeader>
<CardContent className="card-content-mobile">
<PrivilegesManager
privileges={privileges as any}
onCreate={canCreate() ? createPrivilege : undefined}
onUpdate={canEdit() ? updatePrivilege : undefined}
onDelete={canDelete() ? deletePrivilege : undefined}
/>
</CardContent>
</Card>
</TabsContent>
)}
{canView() && (
<TabsContent value="session-logs" className="space-y-4 mt-4">
<SessionLogsSimple users={users} />
+330
View File
@@ -0,0 +1,330 @@
export interface RegraAvancada {
key: string;
nome: string;
descricao: string;
icone: string;
padraoAtivo?: boolean;
}
export interface GrupoAvancadoConfig {
key: string;
titulo: string;
cor: 'sky' | 'indigo' | 'amber' | 'teal' | 'rose';
icone: string;
descricao: string;
regras: RegraAvancada[];
templates: {
id: string;
nome: string;
descricao: string;
icone: string;
regrasAtivas: string[];
}[];
}
export const GRUPOS_AVANCADOS_CONFIG: Record<string, GrupoAvancadoConfig> = {
producao_pcp: {
key: 'producao_pcp',
titulo: 'Produção & PCP',
cor: 'sky',
icone: 'Factory',
descricao: 'Regras avançadas de apontamentos, tolerâncias fabris, fila de produção e homologação de diários.',
regras: [
{
key: 'pcp_retroativo_permitido',
nome: 'Apontamento Retroativo de Peças',
descricao: 'Permite que o operador ou encarregado aponte produção com datas anteriores ao dia de trabalho atual.',
icone: 'Calendar',
padraoAtivo: false,
},
{
key: 'pcp_fechar_diario',
nome: 'Fechamento & Homologação do Diário',
descricao: 'Autorização para encerrar e consolidar o diário de produção fabril, travando apontamentos do dia.',
icone: 'Lock',
padraoAtivo: false,
},
{
key: 'pcp_reordenar_prioridades',
nome: 'Reordenação da Fila de Fabricação',
descricao: 'Permite alterar a prioridade tática de OFs e bancas, furando filas de corte, montagem ou solda.',
icone: 'ArrowUpDown',
padraoAtivo: false,
},
{
key: 'pcp_apontamento_massa',
nome: 'Apontamento em Lote / Massa',
descricao: 'Libera a ferramenta de avanço conjunto de múltiplas peças de uma vez só.',
icone: 'Layers',
padraoAtivo: true,
},
{
key: 'pcp_liberar_retrabalho',
nome: 'Autorização de Retrabalho & Refugo',
descricao: 'Permite apontar peças refugadas e liberar ordens de retrabalho com consumo extra de matéria-prima.',
icone: 'AlertTriangle',
padraoAtivo: false,
},
{
key: 'pcp_capacidade_excedente',
nome: 'Alocação de Capacidade Excedente (>100%)',
descricao: 'Força o agendamento de máquinas e postos de trabalho acima da capacidade nominal cadastrada.',
icone: 'Gauge',
padraoAtivo: false,
},
],
templates: [
{
id: 'operador_pcp',
nome: 'Apontador Fabril Padrão',
descricao: 'Apenas apontamentos normais e avanço de lotes, sem permissão de retroativo ou travamento.',
icone: 'Wrench',
regrasAtivas: ['pcp_apontamento_massa'],
},
{
id: 'lider_producao',
nome: 'Líder / Encarregado de Fábrica',
descricao: 'Liberação de retroativo, reordenação de prioridades e autorização de retrabalho.',
icone: 'Shield',
regrasAtivas: ['pcp_retroativo_permitido', 'pcp_reordenar_prioridades', 'pcp_apontamento_massa', 'pcp_liberar_retrabalho'],
},
{
id: 'gestor_pcp_total',
nome: 'Planejador PCP Total',
descricao: 'Acesso total com fechamento de diários, capacidade excedente e reordenação.',
icone: 'Crown',
regrasAtivas: ['pcp_retroativo_permitido', 'pcp_fechar_diario', 'pcp_reordenar_prioridades', 'pcp_apontamento_massa', 'pcp_liberar_retrabalho', 'pcp_capacidade_excedente'],
},
],
},
engenharia_3d: {
key: 'engenharia_3d',
titulo: 'Engenharia, OFs & BIM',
cor: 'indigo',
icone: 'Box',
descricao: 'Controle de revisões técnicas, exportação de modelos IFC/BIM, arquivos CAD e fichas com custos.',
regras: [
{
key: 'eng_aprovar_revisoes',
nome: 'Aprovação & Liberação de Revisões de OF',
descricao: 'Autorização formal para aprovar revisões de projetos e emitir novas listas de corte para a fábrica.',
icone: 'CheckCircle2',
padraoAtivo: false,
},
{
key: 'eng_download_ifc',
nome: 'Download de Modelos 3D & Arquivos IFC',
descricao: 'Permite exportar e baixar os modelos tridimensionais brutos e arquivos IFC/BIM do projeto.',
icone: 'Download',
padraoAtivo: true,
},
{
key: 'eng_ver_custos',
nome: 'Visualização de Fichas Técnicas & Custos',
descricao: 'Exibe valores monetários, custo unitário por quilo e tabelas financeiras confidenciais de peças.',
icone: 'DollarSign',
padraoAtivo: false,
},
{
key: 'eng_editar_estruturas',
nome: 'Edição de Estruturas & Subconjuntos',
descricao: 'Permite alterar hierarquias de peças, pesos de projeto e marcas de montagem já cadastradas.',
icone: 'Network',
padraoAtivo: false,
},
{
key: 'eng_upload_cad',
nome: 'Upload de Arquivos CAD/DWG/DSTV',
descricao: 'Permite importar novos arquivos de engenharia, perfis de corte e desenhos 2D na OF.',
icone: 'UploadCloud',
padraoAtivo: true,
},
],
templates: [
{
id: 'desenhista_projetista',
nome: 'Projetista / Detalhista',
descricao: 'Upload de arquivos técnicos e download de IFC para verificação e modelagem.',
icone: 'Compass',
regrasAtivas: ['eng_download_ifc', 'eng_upload_cad'],
},
{
id: 'coordenador_engenharia',
nome: 'Coordenador de Engenharia',
descricao: 'Aprovação de revisões, edição de conjuntos e gestão técnica completa.',
icone: 'Shield',
regrasAtivas: ['eng_aprovar_revisoes', 'eng_download_ifc', 'eng_editar_estruturas', 'eng_upload_cad'],
},
{
id: 'diretoria_tecnica',
nome: 'Diretoria Técnica & Orçamentos',
descricao: 'Controle irrestrito com acesso a custos confidenciais e fichas financeiras.',
icone: 'Crown',
regrasAtivas: ['eng_aprovar_revisoes', 'eng_download_ifc', 'eng_ver_custos', 'eng_editar_estruturas', 'eng_upload_cad'],
},
],
},
expedicao_logistica: {
key: 'expedicao_logistica',
titulo: 'Expedição & Romaneios',
cor: 'amber',
icone: 'Truck',
descricao: 'Regras de pesagem rodoviária, autorização de embarque, conferência cega e estorno de cargas.',
regras: [
{
key: 'exp_sem_pesagem',
nome: 'Liberação de Romaneio sem Pesagem',
descricao: 'Permite despachar carretas sem registrar o ticket físico da balança rodoviária.',
icone: 'Scale',
padraoAtivo: false,
},
{
key: 'exp_tolerancia_alta',
nome: 'Margem Ampliada de Tolerância de Balança',
descricao: 'Aceita diferença de peso acima de ±100kg entre o peso teórico dos romaneios e a balança.',
icone: 'Percent',
padraoAtivo: false,
},
{
key: 'exp_despacho_sem_assinatura',
nome: 'Despacho sem Assinatura Digital do Motorista',
descricao: 'Autoriza a saída de carretas sem a coleta prévia da assinatura presencial do transportador.',
icone: 'FileSignature',
padraoAtivo: false,
},
{
key: 'exp_conferencia_cega',
nome: 'Exigência de Conferência Cega no Embarque',
descricao: 'Força o operador a bipar e contar peça a peça sem visualizar a quantidade esperada na tela.',
icone: 'EyeOff',
padraoAtivo: true,
},
{
key: 'exp_estorno_romaneio',
nome: 'Estorno & Cancelamento de Romaneios Despachados',
descricao: 'Permite cancelar romaneios já faturados/despachados com retorno automático das peças ao estoque.',
icone: 'RotateCcw',
padraoAtivo: false,
},
],
templates: [
{
id: 'conferente_expedicao',
nome: 'Conferente de Pátio',
descricao: 'Focado em conferência cega e bipe de peças no carregamento das carretas.',
icone: 'Barcode',
regrasAtivas: ['exp_conferencia_cega'],
},
{
id: 'lider_logistica',
nome: 'Líder de Expedição / Logística',
descricao: 'Autorização com margem de balança e despacho com assinatura flexível.',
icone: 'Shield',
regrasAtivas: ['exp_tolerancia_alta', 'exp_conferencia_cega', 'exp_sem_pesagem'],
},
{
id: 'gerente_logistica_total',
nome: 'Gerente de Logística Total',
descricao: 'Acesso total com estorno de cargas despachadas e todas as liberações de saída.',
icone: 'Crown',
regrasAtivas: ['exp_sem_pesagem', 'exp_tolerancia_alta', 'exp_despacho_sem_assinatura', 'exp_conferencia_cega', 'exp_estorno_romaneio'],
},
],
},
obra_montagem: {
key: 'obra_montagem',
titulo: 'Obra & Montagem Externa',
cor: 'teal',
icone: 'HardHat',
descricao: 'Controle de descarga no canteiro, liberação de içamento e registro de não-conformidades de campo.',
regras: [
{
key: 'obra_receber_romaneio',
nome: 'Confirmação de Descarga & Recebimento no Canteiro',
descricao: 'Permite registrar o recebimento físico das carretas e atestar o estado das peças entregues.',
icone: 'PackageCheck',
padraoAtivo: true,
},
{
key: 'obra_liberar_montagem',
nome: 'Autorização Formal de Içamento & Fixação',
descricao: 'Libera a equipe de montadores para içar conjuntos e soldar/aparafusar na estrutura definitiva.',
icone: 'CheckSquare',
padraoAtivo: false,
},
{
key: 'obra_apontar_nao_conformidade',
nome: 'Registro de Não-Conformidade de Campo (RNC)',
descricao: 'Permite abrir ocorrências de peças com furo desalinhado, empenamentos ou danos de transporte.',
icone: 'AlertOctagon',
padraoAtivo: true,
},
],
templates: [
{
id: 'montador_campo',
nome: 'Montador / Encarregado de Campo',
descricao: 'Recebimento de romaneios e registro de problemas ou RNCs de campo.',
icone: 'Wrench',
regrasAtivas: ['obra_receber_romaneio', 'obra_apontar_nao_conformidade'],
},
{
id: 'engenheiro_obra_total',
nome: 'Engenheiro Residente da Obra',
descricao: 'Controle irrestrito no canteiro com liberação de içamento e homologação estrutural.',
icone: 'Crown',
regrasAtivas: ['obra_receber_romaneio', 'obra_liberar_montagem', 'obra_apontar_nao_conformidade'],
},
],
},
admin_sistema: {
key: 'admin_sistema',
titulo: 'Administração & Sistema',
cor: 'rose',
icone: 'Shield',
descricao: 'Acesso a auditorias de segurança completas, exportações em massa e parâmetros globais do ERP.',
regras: [
{
key: 'adm_auditoria_completa',
nome: 'Auditoria Completa & Logs de Segurança',
descricao: 'Permite visualizar todos os acessos por IP, histórico de operações críticas e falhas de login.',
icone: 'Activity',
padraoAtivo: false,
},
{
key: 'adm_exportar_dados',
nome: 'Exportação Massiva de Dados (Excel/CSV/JSON)',
descricao: 'Libera a exportação de tabelas completas de clientes, faturamento, peças e ordens de produção.',
icone: 'DownloadCloud',
padraoAtivo: false,
},
{
key: 'adm_configuracoes_globais',
nome: 'Alteração de Parâmetros Globais do Sistema',
descricao: 'Autoriza alterar configurações gerais da empresa, logotipos, regras fiscais e integrações.',
icone: 'Settings',
padraoAtivo: false,
},
],
templates: [
{
id: 'auditor_sistema',
nome: 'Auditor de Segurança',
descricao: 'Acesso de auditoria e exportação para compliance sem alterar parâmetros globais.',
icone: 'Search',
regrasAtivas: ['adm_auditoria_completa', 'adm_exportar_dados'],
},
{
id: 'super_admin_total',
nome: 'Super Administrador Total',
descricao: 'Controle total de governança, parâmetros de infraestrutura e exportações.',
icone: 'Crown',
regrasAtivas: ['adm_auditoria_completa', 'adm_exportar_dados', 'adm_configuracoes_globais'],
},
],
},
};