🚀 Initial commit: Versão atual do TrackSteel APP
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Plus, Edit, Trash2 } from 'lucide-react';
|
||||
import { UserFunction } from '@/hooks/useUserManagement';
|
||||
|
||||
interface FunctionsManagerProps {
|
||||
functions: UserFunction[];
|
||||
onCreate: (data: { name: string; description?: string }) => void;
|
||||
onUpdate: (id: string, data: { name: string; description?: string }) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function FunctionsManager({ functions, onCreate, onUpdate, onDelete }: FunctionsManagerProps) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingFunction, setEditingFunction] = useState<UserFunction | null>(null);
|
||||
const [formData, setFormData] = useState({ name: '', description: '' });
|
||||
|
||||
const handleOpenModal = (func?: UserFunction) => {
|
||||
if (func) {
|
||||
setEditingFunction(func);
|
||||
setFormData({ name: func.name, description: func.description || '' });
|
||||
} else {
|
||||
setEditingFunction(null);
|
||||
setFormData({ name: '', description: '' });
|
||||
}
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowModal(false);
|
||||
setEditingFunction(null);
|
||||
setFormData({ name: '', description: '' });
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (editingFunction) {
|
||||
onUpdate(editingFunction.id, formData);
|
||||
} else {
|
||||
onCreate(formData);
|
||||
}
|
||||
|
||||
handleCloseModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">Gerenciar Funções</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gerencie as funções disponíveis no sistema
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleOpenModal()}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nova Função
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Descrição</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Criado em</TableHead>
|
||||
<TableHead>Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{functions.map((func) => (
|
||||
<TableRow key={func.id}>
|
||||
<TableCell className="min-w-0">
|
||||
<div className="font-medium text-xs sm:text-sm">{func.name}</div>
|
||||
{/* Mobile: Show description and date on small screens */}
|
||||
<div className="sm:hidden space-y-1 mt-1">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{func.description || 'Sem descrição'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground md:hidden">
|
||||
Criado: {new Date(func.created_at).toLocaleDateString('pt-BR')}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
<span className="text-xs text-muted-foreground">{func.description || '-'}</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<span className="text-xs text-muted-foreground">{new Date(func.created_at).toLocaleDateString('pt-BR')}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleOpenModal(func)}
|
||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:text-blue-400 dark:hover:text-blue-300 dark:hover:bg-blue-900/50 p-1 h-6 w-6"
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onDelete(func.id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-900/50 p-1 h-6 w-6"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<Dialog open={true} onOpenChange={handleCloseModal}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingFunction ? 'Editar Função' : 'Nova Função'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label>Nome</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Digite o nome da função"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Descrição</Label>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Digite a descrição da função"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button type="submit">
|
||||
Salvar
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={handleCloseModal}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Mail, Clock, User } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
export const PasswordResetRequests = () => {
|
||||
const { data: passwordResetRequests = [], isLoading } = useQuery({
|
||||
queryKey: ['password-reset-requests'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('password_reset_requests')
|
||||
.select('*')
|
||||
.order('requested_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
Solicitações de Redefinição de Senha
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="text-sm text-muted-foreground">Carregando...</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
Solicitações de Redefinição de Senha
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{passwordResetRequests.length === 0 ? (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
Nenhuma solicitação de redefinição de senha encontrada.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{passwordResetRequests.map((request) => (
|
||||
<div
|
||||
key={request.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{request.email}</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{format(new Date(request.requested_at), "dd/MM/yyyy 'às' HH:mm", {
|
||||
locale: ptBR,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={request.status === 'pending' ? 'default' : 'secondary'}
|
||||
>
|
||||
{request.status === 'pending' ? 'Pendente' : request.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/table';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { UserProfile } from '@/hooks/useUserManagement';
|
||||
import { UserAvatar } from '@/components/ui/user-avatar';
|
||||
|
||||
interface PendingUsersTableProps {
|
||||
users: UserProfile[];
|
||||
onApprove: (userId: string) => void;
|
||||
onReject: (userId: string) => void;
|
||||
}
|
||||
|
||||
export function PendingUsersTable({ users, onApprove, onReject }: PendingUsersTableProps) {
|
||||
if (users.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
Nenhuma solicitação pendente
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-700">
|
||||
<TableHead className="text-slate-300">Usuário</TableHead>
|
||||
<TableHead className="text-slate-300 hidden sm:table-cell">Data da Solicitação</TableHead>
|
||||
<TableHead className="text-slate-300">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id} className="border-slate-700">
|
||||
<TableCell className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
imageUrl={user.profile_image_url}
|
||||
name={user.full_name}
|
||||
email={user.email}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-white font-medium truncate text-xs sm:text-sm">
|
||||
{user.full_name || 'Nome não informado'}
|
||||
</div>
|
||||
<div className="text-slate-400 text-xs truncate">{user.email}</div>
|
||||
{/* Mobile: Show date on small screens */}
|
||||
<div className="sm:hidden text-xs text-slate-300 mt-1">
|
||||
Solicitação: {new Date(user.requested_at).toLocaleDateString('pt-BR')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-300 hidden sm:table-cell">
|
||||
<span className="text-xs">{new Date(user.requested_at).toLocaleDateString('pt-BR')}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1 flex-col sm:flex-row">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onApprove(user.id)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white h-6 text-xs px-2"
|
||||
>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
<span className="hidden sm:inline">Aprovar</span>
|
||||
<span className="sm:hidden">✓</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => onReject(user.id)}
|
||||
className="bg-red-600 hover:bg-red-700 h-6 text-xs px-2"
|
||||
>
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
<span className="hidden sm:inline">Rejeitar</span>
|
||||
<span className="sm:hidden">✗</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Privilege, FUNCTIONAL_PERMISSIONS } from './privileges/types';
|
||||
import { PrivilegeForm } from './privileges/PrivilegeForm';
|
||||
import { PrivilegeCard } from './privileges/PrivilegeCard';
|
||||
|
||||
interface PrivilegesManagerProps {
|
||||
privileges: Privilege[];
|
||||
onCreate: (data: { name: string; description?: string; permissions: Record<string, boolean> }) => Promise<void>;
|
||||
onUpdate: (id: string, data: { name: string; description?: string; permissions: Record<string, boolean> }) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function PrivilegesManager({ privileges, onCreate, onUpdate, onDelete }: PrivilegesManagerProps) {
|
||||
const [editingPrivilege, setEditingPrivilege] = useState<Privilege | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
permissions: {} as Record<string, boolean>
|
||||
});
|
||||
const { toast } = useToast();
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
permissions: {}
|
||||
});
|
||||
setEditingPrivilege(null);
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
// Function to clean permissions and keep only valid functional ones
|
||||
const cleanPermissions = (permissions: Record<string, boolean>): Record<string, boolean> => {
|
||||
const cleanedPermissions: Record<string, boolean> = {};
|
||||
|
||||
// Keep only valid functional permissions
|
||||
FUNCTIONAL_PERMISSIONS.forEach(perm => {
|
||||
cleanedPermissions[perm.key] = permissions[perm.key] || false;
|
||||
});
|
||||
|
||||
return cleanedPermissions;
|
||||
};
|
||||
|
||||
const handleEdit = (privilege: Privilege) => {
|
||||
console.log('Editing privilege:', privilege.name);
|
||||
setEditingPrivilege(privilege);
|
||||
|
||||
// Clean old permissions and keep only functional ones
|
||||
const cleanedPermissions = cleanPermissions(privilege.permissions || {});
|
||||
|
||||
setFormData({
|
||||
name: privilege.name,
|
||||
description: privilege.description || '',
|
||||
permissions: cleanedPermissions
|
||||
});
|
||||
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Ensure only one functional permission is active
|
||||
const activePermissions = Object.entries(formData.permissions).filter(([, value]) => value);
|
||||
if (activePermissions.length > 1) {
|
||||
toast({
|
||||
title: "Erro de validação",
|
||||
description: "Apenas uma permissão funcional pode ser selecionada por privilégio.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (activePermissions.length === 0) {
|
||||
toast({
|
||||
title: "Erro de validação",
|
||||
description: "Pelo menos uma permissão funcional deve ser selecionada.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Submitting privilege:', {
|
||||
name: formData.name,
|
||||
functionalPermissions: formData.permissions
|
||||
});
|
||||
|
||||
try {
|
||||
if (editingPrivilege) {
|
||||
await onUpdate(editingPrivilege.id, formData);
|
||||
toast({ title: "Privilégio atualizado com sucesso!" });
|
||||
} else {
|
||||
await onCreate(formData);
|
||||
toast({ title: "Privilégio criado com sucesso!" });
|
||||
}
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
console.error('Error saving privilege:', error);
|
||||
toast({
|
||||
title: "Erro ao salvar privilégio",
|
||||
description: "Verifique os dados e tente novamente.",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm('Tem certeza que deseja excluir este privilégio?')) {
|
||||
try {
|
||||
await onDelete(id);
|
||||
toast({ title: "Privilégio excluído com sucesso!" });
|
||||
} catch (error) {
|
||||
console.error('Error deleting privilege:', error);
|
||||
toast({
|
||||
title: "Erro ao excluir privilégio",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePermissionChange = (permissionKey: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
// Implement radio button behavior - only one functional permission at a time
|
||||
const newPermissions = FUNCTIONAL_PERMISSIONS.reduce((acc, perm) => {
|
||||
acc[perm.key] = perm.key === permissionKey;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
permissions: newPermissions
|
||||
}));
|
||||
} else {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
permissions: {
|
||||
...prev.permissions,
|
||||
[permissionKey]: false
|
||||
}
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Privilégios do Sistema</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gerencie os privilégios funcionais dos usuários (sistema simplificado)
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Novo Privilégio
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Privilégios</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PrivilegeForm
|
||||
formData={formData}
|
||||
onFormDataChange={setFormData}
|
||||
onPermissionChange={handlePermissionChange}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={resetForm}
|
||||
isEditing={!!editingPrivilege}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4">
|
||||
{privileges.map((privilege) => (
|
||||
<PrivilegeCard
|
||||
key={privilege.id}
|
||||
privilege={privilege}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Download, FileText, BarChart3, Users, Clock, Activity, AlertCircle } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { useSessionLogs } from '@/hooks/useSessionLogs';
|
||||
import { UserProfile } from '@/hooks/useUserManagement';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line } from 'recharts';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface SessionLogsManagerProps {
|
||||
users: UserProfile[];
|
||||
}
|
||||
|
||||
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8'];
|
||||
|
||||
export function SessionLogsManager({ users }: SessionLogsManagerProps) {
|
||||
const {
|
||||
sessionLogs,
|
||||
sessionStats,
|
||||
loading,
|
||||
fetchSessionLogs,
|
||||
fetchSessionStats,
|
||||
exportReport
|
||||
} = useSessionLogs();
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
userId: '',
|
||||
period: 'week' as 'day' | 'week' | 'month',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
});
|
||||
|
||||
const [activeView, setActiveView] = useState<'logs' | 'dashboard'>('logs');
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
await fetchSessionLogs();
|
||||
await fetchSessionStats({ period: filters.period });
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar dados dos logs de sessão:', error);
|
||||
toast.error('Erro ao carregar logs de sessão');
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleFilterChange = (key: string, value: string) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const applyFilters = async () => {
|
||||
try {
|
||||
const filterParams = {
|
||||
userId: filters.userId || undefined,
|
||||
startDate: filters.startDate || undefined,
|
||||
endDate: filters.endDate || undefined
|
||||
};
|
||||
|
||||
await fetchSessionLogs(filterParams);
|
||||
await fetchSessionStats({
|
||||
userId: filters.userId || undefined,
|
||||
period: filters.period
|
||||
});
|
||||
|
||||
toast.success('Filtros aplicados com sucesso');
|
||||
} catch (error) {
|
||||
console.error('Erro ao aplicar filtros:', error);
|
||||
toast.error('Erro ao aplicar filtros');
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = async () => {
|
||||
try {
|
||||
setFilters({
|
||||
userId: '',
|
||||
period: 'week',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
});
|
||||
await fetchSessionLogs();
|
||||
await fetchSessionStats({ period: 'week' });
|
||||
toast.success('Filtros limpos com sucesso');
|
||||
} catch (error) {
|
||||
console.error('Erro ao limpar filtros:', error);
|
||||
toast.error('Erro ao limpar filtros');
|
||||
}
|
||||
};
|
||||
|
||||
const getUserName = (userId: string) => {
|
||||
const user = users.find(u => u.id === userId);
|
||||
return user?.full_name || user?.email || 'Usuário desconhecido';
|
||||
};
|
||||
|
||||
const formatDuration = (minutes: number | null) => {
|
||||
if (!minutes) return '0min';
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = Math.round(minutes % 60);
|
||||
return hours > 0 ? `${hours}h ${mins}min` : `${mins}min`;
|
||||
};
|
||||
|
||||
// Dados para gráficos
|
||||
const getDashboardData = () => {
|
||||
if (!sessionLogs.length) {
|
||||
return { userSessions: [], dailyActivity: [], timeRangeData: [] };
|
||||
}
|
||||
|
||||
// Sessões por usuário
|
||||
const userSessions = users.map(user => {
|
||||
const userLogs = sessionLogs.filter(log => log.user_id === user.id);
|
||||
return {
|
||||
name: user.full_name?.split(' ')[0] || user.email.split('@')[0],
|
||||
sessions: userLogs.length,
|
||||
totalTime: userLogs.reduce((acc, log) => acc + (log.duration_minutes || 0), 0)
|
||||
};
|
||||
}).filter(data => data.sessions > 0);
|
||||
|
||||
// Atividade por dia (últimos 7 dias)
|
||||
const last7Days = Array.from({ length: 7 }, (_, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - i);
|
||||
return date.toISOString().split('T')[0];
|
||||
}).reverse();
|
||||
|
||||
const dailyActivity = last7Days.map(date => {
|
||||
const dayLogs = sessionLogs.filter(log =>
|
||||
log.session_start.split('T')[0] === date
|
||||
);
|
||||
return {
|
||||
date: format(new Date(date), 'dd/MM', { locale: ptBR }),
|
||||
sessions: dayLogs.length,
|
||||
users: new Set(dayLogs.map(log => log.user_id)).size
|
||||
};
|
||||
});
|
||||
|
||||
// Duração média por período do dia
|
||||
const timeRanges = [
|
||||
{ name: 'Manhã (6-12h)', start: 6, end: 12 },
|
||||
{ name: 'Tarde (12-18h)', start: 12, end: 18 },
|
||||
{ name: 'Noite (18-24h)', start: 18, end: 24 },
|
||||
{ name: 'Madrugada (0-6h)', start: 0, end: 6 }
|
||||
];
|
||||
|
||||
const timeRangeData = timeRanges.map(range => {
|
||||
const rangeLogs = sessionLogs.filter(log => {
|
||||
const hour = new Date(log.session_start).getHours();
|
||||
return hour >= range.start && hour < range.end;
|
||||
});
|
||||
const avgDuration = rangeLogs.length > 0
|
||||
? rangeLogs.reduce((acc, log) => acc + (log.duration_minutes || 0), 0) / rangeLogs.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
name: range.name,
|
||||
avgDuration: Math.round(avgDuration),
|
||||
sessions: rangeLogs.length
|
||||
};
|
||||
});
|
||||
|
||||
return { userSessions, dailyActivity, timeRangeData };
|
||||
};
|
||||
|
||||
const { userSessions, dailyActivity, timeRangeData } = getDashboardData();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p className="text-muted-foreground">Carregando logs de sessão...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Estatísticas Rápidas */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Users className="h-4 w-4 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total de Usuários</p>
|
||||
<p className="text-2xl font-bold">{sessionStats?.total_users || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Activity className="h-4 w-4 text-green-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Sessões Ativas</p>
|
||||
<p className="text-2xl font-bold">{sessionStats?.active_sessions || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<BarChart3 className="h-4 w-4 text-orange-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total de Sessões</p>
|
||||
<p className="text-2xl font-bold">{sessionStats?.total_sessions || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4 text-purple-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Duração Média</p>
|
||||
<p className="text-2xl font-bold">{formatDuration(sessionStats?.average_duration || 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Navegação entre Views */}
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={activeView === 'logs' ? 'default' : 'outline'}
|
||||
onClick={() => setActiveView('logs')}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Logs de Sessão
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeView === 'dashboard' ? 'default' : 'outline'}
|
||||
onClick={() => setActiveView('dashboard')}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filtros</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="user-select">Usuário</Label>
|
||||
<Select value={filters.userId} onValueChange={(value) => handleFilterChange('userId', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Todos os usuários" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Todos os usuários</SelectItem>
|
||||
{users.map(user => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.full_name || user.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="period-select">Período</Label>
|
||||
<Select value={filters.period} onValueChange={(value) => handleFilterChange('period', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="day">Último dia</SelectItem>
|
||||
<SelectItem value="week">Última semana</SelectItem>
|
||||
<SelectItem value="month">Último mês</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="start-date">Data Início</Label>
|
||||
<Input
|
||||
id="start-date"
|
||||
type="date"
|
||||
value={filters.startDate}
|
||||
onChange={(e) => handleFilterChange('startDate', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="end-date">Data Fim</Label>
|
||||
<Input
|
||||
id="end-date"
|
||||
type="date"
|
||||
value={filters.endDate}
|
||||
onChange={(e) => handleFilterChange('endDate', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end space-x-2">
|
||||
<Button onClick={applyFilters}>Aplicar</Button>
|
||||
<Button variant="outline" onClick={resetFilters}>Limpar</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Botões de Exportação */}
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => exportReport('csv', {
|
||||
userId: filters.userId || undefined,
|
||||
startDate: filters.startDate || undefined,
|
||||
endDate: filters.endDate || undefined
|
||||
})}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Exportar CSV
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => exportReport('json', {
|
||||
userId: filters.userId || undefined,
|
||||
startDate: filters.startDate || undefined,
|
||||
endDate: filters.endDate || undefined
|
||||
})}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Exportar JSON
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Conteúdo Principal */}
|
||||
{activeView === 'logs' ? (
|
||||
// Tabela de Logs
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Logs de Sessão</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sessionLogs.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">Nenhum log de sessão encontrado</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Usuário</TableHead>
|
||||
<TableHead>Início da Sessão</TableHead>
|
||||
<TableHead>Fim da Sessão</TableHead>
|
||||
<TableHead>Duração</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>User Agent</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessionLogs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell>{getUserName(log.user_id)}</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(log.session_start), 'dd/MM/yyyy HH:mm', { locale: ptBR })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.session_end
|
||||
? format(new Date(log.session_end), 'dd/MM/yyyy HH:mm', { locale: ptBR })
|
||||
: '-'
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>{formatDuration(log.duration_minutes)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={log.is_active ? 'default' : 'secondary'}>
|
||||
{log.is_active ? 'Ativa' : 'Finalizada'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate">
|
||||
{log.user_agent}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
// Dashboard com Gráficos
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Sessões por Usuário */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sessões por Usuário</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{userSessions.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={userSessions}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="sessions" fill="#8884d8" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
Nenhum dado disponível
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Atividade Diária */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Atividade dos Últimos 7 Dias</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{dailyActivity.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={dailyActivity}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="sessions" stroke="#8884d8" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="users" stroke="#82ca9d" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
Nenhum dado disponível
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tempo por Usuário */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tempo Total por Usuário</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{userSessions.length > 0 && userSessions.some(u => u.totalTime > 0) ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={userSessions.filter(u => u.totalTime > 0)}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, totalTime }) => `${name}: ${formatDuration(totalTime)}`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="totalTime"
|
||||
>
|
||||
{userSessions.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value) => formatDuration(value as number)} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
Nenhum dado disponível
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Atividade por Período do Dia */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Duração Média por Período</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{timeRangeData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={timeRangeData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip formatter={(value) => `${value} min`} />
|
||||
<Bar dataKey="avgDuration" fill="#82ca9d" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
Nenhum dado disponível
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Download, Users, Activity, Clock, BarChart3, RefreshCw } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { useSessionLogsSimple } from '@/hooks/useSessionLogsSimple';
|
||||
import { UserProfile } from '@/hooks/useUserManagement';
|
||||
|
||||
interface SessionLogsSimpleProps {
|
||||
users: UserProfile[];
|
||||
}
|
||||
|
||||
export function SessionLogsSimple({ users }: SessionLogsSimpleProps) {
|
||||
const {
|
||||
sessionLogs,
|
||||
onlineUsers,
|
||||
loading,
|
||||
fetchSessionLogs,
|
||||
fetchOnlineUsers,
|
||||
exportToCsv
|
||||
} = useSessionLogsSimple();
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
userId: '',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
});
|
||||
|
||||
// Carregar dados iniciais
|
||||
useEffect(() => {
|
||||
fetchSessionLogs();
|
||||
fetchOnlineUsers();
|
||||
|
||||
// Atualizar usuários online a cada 30 segundos
|
||||
const interval = setInterval(fetchOnlineUsers, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleFilterChange = (key: string, value: string) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
const filterParams = {
|
||||
userId: filters.userId || undefined,
|
||||
startDate: filters.startDate || undefined,
|
||||
endDate: filters.endDate || undefined
|
||||
};
|
||||
fetchSessionLogs(filterParams);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilters({ userId: '', startDate: '', endDate: '' });
|
||||
fetchSessionLogs();
|
||||
};
|
||||
|
||||
const getUserName = (userId: string) => {
|
||||
const user = users.find(u => u.id === userId);
|
||||
return user?.full_name || user?.email || 'Usuário desconhecido';
|
||||
};
|
||||
|
||||
const formatDuration = (minutes: number | null) => {
|
||||
if (!minutes) return '0min';
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = Math.round(minutes % 60);
|
||||
return hours > 0 ? `${hours}h ${mins}min` : `${mins}min`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
return format(new Date(dateString), 'dd/MM/yyyy HH:mm', { locale: ptBR });
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
// Estatísticas simples
|
||||
const stats = {
|
||||
totalSessions: sessionLogs.length,
|
||||
activeSessions: sessionLogs.filter(log => log.is_active).length,
|
||||
onlineUsers: onlineUsers.length,
|
||||
avgDuration: sessionLogs.length > 0
|
||||
? Math.round(sessionLogs.reduce((acc, log) => acc + (log.duration_minutes || 0), 0) / sessionLogs.length)
|
||||
: 0
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Estatísticas */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Users className="h-4 w-4 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Usuários Online</p>
|
||||
<p className="text-2xl font-bold">{stats.onlineUsers}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Activity className="h-4 w-4 text-green-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Sessões Ativas</p>
|
||||
<p className="text-2xl font-bold">{stats.activeSessions}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<BarChart3 className="h-4 w-4 text-orange-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total de Sessões</p>
|
||||
<p className="text-2xl font-bold">{stats.totalSessions}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4 text-purple-500" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Duração Média</p>
|
||||
<p className="text-2xl font-bold">{formatDuration(stats.avgDuration)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Usuários Online */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Usuários Online Agora
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchOnlineUsers}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{onlineUsers.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-4">
|
||||
Nenhum usuário online no momento
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{onlineUsers.map((user) => (
|
||||
<div key={user.user_id} className="flex items-center space-x-3 p-3 border rounded-lg">
|
||||
<div className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
|
||||
{user.full_name?.charAt(0) || user.email.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{user.full_name || user.email}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Online desde {formatDate(user.session_start)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Filtros */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filtros</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="user-select">Usuário</Label>
|
||||
<Select value={filters.userId} onValueChange={(value) => handleFilterChange('userId', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Todos os usuários" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos os usuários</SelectItem>
|
||||
{users.map(user => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.full_name || user.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="start-date">Data Início</Label>
|
||||
<Input
|
||||
id="start-date"
|
||||
type="date"
|
||||
value={filters.startDate}
|
||||
onChange={(e) => handleFilterChange('startDate', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="end-date">Data Fim</Label>
|
||||
<Input
|
||||
id="end-date"
|
||||
type="date"
|
||||
value={filters.endDate}
|
||||
onChange={(e) => handleFilterChange('endDate', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end space-x-2">
|
||||
<Button onClick={applyFilters} disabled={loading}>
|
||||
{loading ? 'Carregando...' : 'Aplicar'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={resetFilters}>
|
||||
Limpar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Botão de Exportação */}
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => exportToCsv({
|
||||
userId: filters.userId === 'all' ? undefined : filters.userId || undefined,
|
||||
startDate: filters.startDate || undefined,
|
||||
endDate: filters.endDate || undefined
|
||||
})}
|
||||
disabled={loading}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Exportar CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabela de Logs */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Histórico de Sessões</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p className="text-muted-foreground">Carregando logs...</p>
|
||||
</div>
|
||||
) : sessionLogs.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">Nenhum log de sessão encontrado</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Usuário</TableHead>
|
||||
<TableHead>Início da Sessão</TableHead>
|
||||
<TableHead>Fim da Sessão</TableHead>
|
||||
<TableHead>Duração</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessionLogs.slice(0, 50).map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell>{getUserName(log.user_id)}</TableCell>
|
||||
<TableCell>{formatDate(log.session_start)}</TableCell>
|
||||
<TableCell>
|
||||
{log.session_end ? formatDate(log.session_end) : '-'}
|
||||
</TableCell>
|
||||
<TableCell>{formatDuration(log.duration_minutes)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={log.is_active ? 'default' : 'secondary'}>
|
||||
{log.is_active ? 'Ativa' : 'Finalizada'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{sessionLogs.length > 50 && (
|
||||
<p className="text-center text-sm text-muted-foreground mt-4">
|
||||
Mostrando os primeiros 50 registros de {sessionLogs.length} total
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, AlertTriangle, User, Database } from 'lucide-react';
|
||||
import { UserProfile, UserDependency } from '@/hooks/useUserManagement';
|
||||
|
||||
interface UserDeleteDialogProps {
|
||||
user: UserProfile | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirmDelete: (userId: string, replaceReferences: boolean) => Promise<boolean>;
|
||||
canDeleteUser: (userId: string) => Promise<boolean>;
|
||||
getUserDependencies: (userId: string) => Promise<UserDependency[]>;
|
||||
}
|
||||
|
||||
export function UserDeleteDialog({
|
||||
user,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirmDelete,
|
||||
canDeleteUser,
|
||||
getUserDependencies
|
||||
}: UserDeleteDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [canDelete, setCanDelete] = useState(false);
|
||||
const [dependencies, setDependencies] = useState<UserDependency[]>([]);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && user) {
|
||||
loadUserData();
|
||||
}
|
||||
}, [isOpen, user]);
|
||||
|
||||
const loadUserData = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const [canDeleteResult, dependenciesResult] = await Promise.all([
|
||||
canDeleteUser(user.id),
|
||||
getUserDependencies(user.id)
|
||||
]);
|
||||
|
||||
setCanDelete(canDeleteResult);
|
||||
setDependencies(dependenciesResult.filter(dep => dep.count > 0));
|
||||
} catch (error) {
|
||||
console.error('Error loading user data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (replaceReferences: boolean = false) => {
|
||||
if (!user) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
const success = await onConfirmDelete(user.id, replaceReferences);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!deleting) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const criticalDependencies = dependencies.filter(dep =>
|
||||
['ficha_tecnica_contratos', 'ordens_fabricacao', 'pecas', 'apontamentos_producao'].includes(dep.table_name)
|
||||
);
|
||||
|
||||
const nonCriticalDependencies = dependencies.filter(dep =>
|
||||
!['ficha_tecnica_contratos', 'ordens_fabricacao', 'pecas', 'apontamentos_producao'].includes(dep.table_name)
|
||||
);
|
||||
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={handleClose}>
|
||||
<AlertDialogContent className="max-w-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Excluir usuário
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Análise dos vínculos do usuário <strong>{user.full_name || user.email}</strong>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="ml-2">Verificando vínculos...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{dependencies.length === 0 ? (
|
||||
<div className="text-center py-4 text-green-600">
|
||||
<Database className="h-8 w-8 mx-auto mb-2" />
|
||||
<p>Este usuário não possui vínculos no sistema e pode ser excluído com segurança.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-orange-600">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<span className="font-medium">Vínculos encontrados:</span>
|
||||
</div>
|
||||
|
||||
{criticalDependencies.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-red-600">Vínculos críticos (impedem exclusão):</h4>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{criticalDependencies.map((dep, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-2 bg-red-50 rounded-lg border border-red-200">
|
||||
<span className="text-sm">{dep.description}</span>
|
||||
<Badge variant="destructive">{dep.count}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nonCriticalDependencies.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-orange-600">Vínculos não críticos (podem ser transferidos):</h4>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{nonCriticalDependencies.map((dep, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-2 bg-orange-50 rounded-lg border border-orange-200">
|
||||
<span className="text-sm">{dep.description}</span>
|
||||
<Badge variant="secondary">{dep.count}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canDelete && nonCriticalDependencies.length > 0 && (
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Opção disponível:</strong> Os vínculos não críticos podem ser transferidos para um usuário especial "UserDel"
|
||||
para preservar a integridade do banco de dados e permitir a exclusão do usuário.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Excluindo...
|
||||
</>
|
||||
) : (
|
||||
'Excluir usuário'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!canDelete && nonCriticalDependencies.length > 0 && criticalDependencies.length === 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(true)}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Processando...
|
||||
</>
|
||||
) : (
|
||||
'Transferir vínculos e excluir'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { UserProfile, UserFunction, UserPrivilege } from '@/hooks/useUserManagement';
|
||||
import { AvatarUpload } from '@/components/ui/avatar-upload';
|
||||
import { useProfileImage } from '@/hooks/useProfileImage';
|
||||
import { usePasswordManagement } from '@/hooks/usePasswordManagement';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
interface UserModalProps {
|
||||
user: UserProfile | null;
|
||||
functions: UserFunction[];
|
||||
privileges: UserPrivilege[];
|
||||
onSave?: (userId: string, data: Partial<UserProfile>) => void;
|
||||
onCreate?: (data: { email: string; full_name?: string; function_id?: string; privilege_id?: string }) => void;
|
||||
onClose: () => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function UserModal({ user, functions, privileges, onSave, onCreate, onClose, readOnly = false }: UserModalProps) {
|
||||
const { updateProfileImage, removeProfileImage, updating } = useProfileImage();
|
||||
const { changeUserPassword, isChangingPassword } = usePasswordManagement();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
full_name: '',
|
||||
function_id: '',
|
||||
privilege_id: '',
|
||||
status: 'active' as 'pending' | 'active' | 'inactive' | 'rejected',
|
||||
profile_image_url: null as string | null,
|
||||
password: ''
|
||||
});
|
||||
|
||||
const isCreateMode = !user;
|
||||
const isReadOnly = readOnly || (!onSave && !onCreate);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setFormData({
|
||||
email: user.email || '',
|
||||
full_name: user.full_name || '',
|
||||
function_id: user.function_id || '',
|
||||
privilege_id: user.privilege_id || '',
|
||||
status: user.status,
|
||||
profile_image_url: user.profile_image_url,
|
||||
password: '' // Não mostramos a senha atual por segurança
|
||||
});
|
||||
} else {
|
||||
// Reset form for create mode
|
||||
setFormData({
|
||||
email: '',
|
||||
full_name: '',
|
||||
function_id: '',
|
||||
privilege_id: '',
|
||||
status: 'active',
|
||||
profile_image_url: null,
|
||||
password: 'usuario' // Senha padrão para novos usuários
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleImageUpload = async (imageUrl: string) => {
|
||||
if (!user || isReadOnly) return;
|
||||
|
||||
const success = await updateProfileImage(user.id, imageUrl);
|
||||
if (success) {
|
||||
setFormData(prev => ({ ...prev, profile_image_url: imageUrl }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageRemove = async () => {
|
||||
if (!user || isReadOnly) return;
|
||||
|
||||
const success = await removeProfileImage(user.id);
|
||||
if (success) {
|
||||
setFormData(prev => ({ ...prev, profile_image_url: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChange = async () => {
|
||||
if (!user || isReadOnly || !formData.password.trim()) return;
|
||||
|
||||
await changeUserPassword(user.id, formData.password);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isReadOnly) return;
|
||||
|
||||
if (isCreateMode && onCreate) {
|
||||
// Create new user
|
||||
onCreate({
|
||||
email: formData.email,
|
||||
full_name: formData.full_name || undefined,
|
||||
function_id: formData.function_id || undefined,
|
||||
privilege_id: formData.privilege_id || undefined
|
||||
});
|
||||
} else if (user && onSave) {
|
||||
// Update existing user
|
||||
const updateData: Partial<UserProfile> = {
|
||||
full_name: formData.full_name,
|
||||
function_id: formData.function_id || null,
|
||||
privilege_id: formData.privilege_id || null,
|
||||
status: formData.status,
|
||||
profile_image_url: formData.profile_image_url
|
||||
};
|
||||
|
||||
onSave(user.id, updateData);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent className="bg-slate-800 border-slate-700 text-white max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isReadOnly ? 'Visualizar Usuário' : isCreateMode ? 'Novo Usuário' : 'Editar Usuário'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Profile Image Upload - Only for edit mode */}
|
||||
{user && (
|
||||
<div>
|
||||
<Label className="text-slate-300 text-sm font-medium">Foto de Perfil</Label>
|
||||
<div className="mt-2">
|
||||
<AvatarUpload
|
||||
currentImageUrl={formData.profile_image_url}
|
||||
onImageUpload={isReadOnly ? undefined : handleImageUpload}
|
||||
onImageRemove={isReadOnly ? undefined : handleImageRemove}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-slate-300">Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
|
||||
disabled={!isCreateMode || isReadOnly}
|
||||
className="bg-slate-700 border-slate-600 text-white mt-1"
|
||||
placeholder="Digite o email"
|
||||
required={!isReadOnly}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Campo de Senha */}
|
||||
<div>
|
||||
<Label className="text-slate-300">Senha</Label>
|
||||
<div className="relative mt-1">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
|
||||
className="bg-slate-700 border-slate-600 text-slate-400 pr-10"
|
||||
placeholder={isCreateMode ? "usuario" : "Digite a nova senha"}
|
||||
disabled={isReadOnly}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-slate-400" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-slate-400" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{!isCreateMode && !isReadOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 text-xs"
|
||||
onClick={handlePasswordChange}
|
||||
disabled={isChangingPassword || !formData.password.trim()}
|
||||
>
|
||||
{isChangingPassword ? 'Alterando...' : 'Alterar Senha'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-slate-300">Nome Completo</Label>
|
||||
<Input
|
||||
value={formData.full_name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, full_name: e.target.value }))}
|
||||
className="bg-slate-700 border-slate-600 text-white mt-1"
|
||||
placeholder="Digite o nome completo"
|
||||
disabled={isReadOnly}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-slate-300">Função</Label>
|
||||
<Select
|
||||
value={formData.function_id}
|
||||
onValueChange={isReadOnly ? undefined : (value) => setFormData(prev => ({ ...prev, function_id: value }))}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger className="bg-slate-700 border-slate-600 text-white mt-1">
|
||||
<SelectValue placeholder="Selecione uma função" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-700 border-slate-600">
|
||||
{functions.map((func) => (
|
||||
<SelectItem key={func.id} value={func.id} className="text-white hover:bg-slate-600">
|
||||
{func.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-slate-300">Privilégio</Label>
|
||||
<Select
|
||||
value={formData.privilege_id}
|
||||
onValueChange={isReadOnly ? undefined : (value) => setFormData(prev => ({ ...prev, privilege_id: value }))}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger className="bg-slate-700 border-slate-600 text-white mt-1">
|
||||
<SelectValue placeholder="Selecione um privilégio" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-700 border-slate-600">
|
||||
{privileges.map((privilege) => (
|
||||
<SelectItem key={privilege.id} value={privilege.id} className="text-white hover:bg-slate-600">
|
||||
{privilege.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Status - Only for edit mode */}
|
||||
{!isCreateMode && (
|
||||
<div>
|
||||
<Label className="text-slate-300">Status</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={isReadOnly ? undefined : (value) => setFormData(prev => ({ ...prev, status: value as typeof formData.status }))}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger className="bg-slate-700 border-slate-600 text-white mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-700 border-slate-600">
|
||||
<SelectItem value="active" className="text-white hover:bg-slate-600">Ativo</SelectItem>
|
||||
<SelectItem value="inactive" className="text-white hover:bg-slate-600">Inativo</SelectItem>
|
||||
<SelectItem value="pending" className="text-white hover:bg-slate-600">Pendente</SelectItem>
|
||||
<SelectItem value="rejected" className="text-white hover:bg-slate-600">Rejeitado</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password info for create mode */}
|
||||
{isCreateMode && !isReadOnly && (
|
||||
<div className="bg-slate-700 p-3 rounded border border-slate-600">
|
||||
<p className="text-sm text-slate-300">
|
||||
<strong>Senha padrão:</strong> usuario
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
O usuário poderá alterar a senha após o primeiro login.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
disabled={updating || (isCreateMode && !formData.email)}
|
||||
>
|
||||
{isCreateMode ? 'Criar Usuário' : 'Salvar'}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{isReadOnly ? 'Fechar' : 'Cancelar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/table';
|
||||
import { Edit, Power, PowerOff, Trash2 } from 'lucide-react';
|
||||
import { UserProfile, UserFunction, UserPrivilege, UserDependency } from '@/hooks/useUserManagement';
|
||||
import { UserAvatar } from '@/components/ui/user-avatar';
|
||||
import { UserDeleteDialog } from './UserDeleteDialog';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface UsersTableProps {
|
||||
users: UserProfile[];
|
||||
functions: UserFunction[];
|
||||
privileges: UserPrivilege[];
|
||||
onEditUser: (user: UserProfile) => void;
|
||||
onToggleStatus: (userId: string, currentStatus: string) => void;
|
||||
onDeleteUser?: (userId: string, replaceReferences: boolean) => Promise<boolean>;
|
||||
canDeleteUser?: (userId: string) => Promise<boolean>;
|
||||
getUserDependencies?: (userId: string) => Promise<UserDependency[]>;
|
||||
}
|
||||
|
||||
export function UsersTable({
|
||||
users,
|
||||
functions,
|
||||
privileges,
|
||||
onEditUser,
|
||||
onToggleStatus,
|
||||
onDeleteUser,
|
||||
canDeleteUser,
|
||||
getUserDependencies
|
||||
}: UsersTableProps) {
|
||||
const [selectedUserForDeletion, setSelectedUserForDeletion] = useState<UserProfile | null>(null);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
const getFunctionName = (functionId: string | null) => {
|
||||
if (!functionId) return '-';
|
||||
const func = functions.find(f => f.id === functionId);
|
||||
return func?.name || '-';
|
||||
};
|
||||
|
||||
const getPrivilegeName = (privilegeId: string | null) => {
|
||||
if (!privilegeId) return '-';
|
||||
const privilege = privileges.find(p => p.id === privilegeId);
|
||||
return privilege?.name || '-';
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
active: { label: 'Ativo', className: 'bg-green-600 text-white' },
|
||||
inactive: { label: 'Inativo', className: 'bg-red-600 text-white' },
|
||||
rejected: { label: 'Rejeitado', className: 'bg-gray-600 text-white' }
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.inactive;
|
||||
|
||||
return (
|
||||
<Badge className={config.className}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (user: UserProfile) => {
|
||||
setSelectedUserForDeletion(user);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDeleteDialog = () => {
|
||||
setSelectedUserForDeletion(null);
|
||||
setIsDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async (userId: string, replaceReferences: boolean = false) => {
|
||||
if (onDeleteUser) {
|
||||
return await onDeleteUser(userId, replaceReferences);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (users.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
Nenhum usuário encontrado
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-700">
|
||||
<TableHead className="text-slate-300">Usuário</TableHead>
|
||||
<TableHead className="text-slate-300 hidden sm:table-cell">Função</TableHead>
|
||||
<TableHead className="text-slate-300 hidden md:table-cell">Privilégio</TableHead>
|
||||
<TableHead className="text-slate-300">Status</TableHead>
|
||||
<TableHead className="text-slate-300 hidden lg:table-cell">Inclusão</TableHead>
|
||||
<TableHead className="text-slate-300">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id} className="border-slate-700">
|
||||
<TableCell className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
imageUrl={user.profile_image_url}
|
||||
name={user.full_name}
|
||||
email={user.email}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-white font-medium truncate text-xs sm:text-sm">
|
||||
{user.full_name || 'Nome não informado'}
|
||||
</div>
|
||||
<div className="text-slate-400 text-xs truncate">{user.email}</div>
|
||||
{/* Mobile: Show function and privilege on small screens */}
|
||||
<div className="sm:hidden space-y-1 mt-1">
|
||||
<div className="text-xs text-slate-300">
|
||||
Função: {getFunctionName(user.function_id)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-300 md:hidden">
|
||||
Privilégio: {getPrivilegeName(user.privilege_id)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-300 hidden sm:table-cell">
|
||||
<span className="text-xs">{getFunctionName(user.function_id)}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-300 hidden md:table-cell">
|
||||
<span className="text-xs">{getPrivilegeName(user.privilege_id)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(user.status)}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-300 hidden lg:table-cell">
|
||||
<span className="text-xs">{new Date(user.created_at).toLocaleDateString('pt-BR')}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onEditUser(user)}
|
||||
className="text-blue-400 hover:text-blue-300 hover:bg-blue-900/50 p-1 h-6 w-6"
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onToggleStatus(user.id, user.status)}
|
||||
className={`p-1 h-6 w-6 ${user.status === 'active'
|
||||
? "text-red-400 hover:text-red-300 hover:bg-red-900/50"
|
||||
: "text-green-400 hover:text-green-300 hover:bg-green-900/50"
|
||||
}`}
|
||||
>
|
||||
{user.status === 'active' ? (
|
||||
<PowerOff className="w-3 h-3" />
|
||||
) : (
|
||||
<Power className="w-3 h-3" />
|
||||
)}
|
||||
</Button>
|
||||
{onDeleteUser && canDeleteUser && getUserDependencies && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteClick(user)}
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-900/50 p-1 h-6 w-6"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<UserDeleteDialog
|
||||
user={selectedUserForDeletion}
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onClose={handleCloseDeleteDialog}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
canDeleteUser={canDeleteUser || (async () => false)}
|
||||
getUserDependencies={getUserDependencies || (async () => [])}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Trash2, Edit } from 'lucide-react';
|
||||
import { Privilege, FUNCTIONAL_PERMISSIONS } from './types';
|
||||
|
||||
interface PrivilegeCardProps {
|
||||
privilege: Privilege;
|
||||
onEdit: (privilege: Privilege) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export const PrivilegeCard: React.FC<PrivilegeCardProps> = ({
|
||||
privilege,
|
||||
onEdit,
|
||||
onDelete
|
||||
}) => {
|
||||
const getFunctionalPermissionCount = (privilege: Privilege) => {
|
||||
if (!privilege.permissions) return 0;
|
||||
|
||||
// Count only valid functional permissions
|
||||
const validPermissions = FUNCTIONAL_PERMISSIONS.map(p => p.key);
|
||||
return validPermissions.filter(key => privilege.permissions[key]).length;
|
||||
};
|
||||
|
||||
const getActiveFunctionalPermissions = (privilege: Privilege) => {
|
||||
if (!privilege.permissions) return [];
|
||||
|
||||
// Show only valid functional permissions
|
||||
return FUNCTIONAL_PERMISSIONS
|
||||
.filter(perm => privilege.permissions[perm.key])
|
||||
.map(perm => perm.label);
|
||||
};
|
||||
|
||||
const functionalCount = getFunctionalPermissionCount(privilege);
|
||||
const activeFunctionalPermissions = getActiveFunctionalPermissions(privilege);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h4 className="font-semibold">{privilege.name}</h4>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="secondary">
|
||||
{functionalCount} permissão funcional
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{privilege.description && (
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{privilege.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{activeFunctionalPermissions.map((permName) => (
|
||||
<Badge key={permName} variant="outline" className="text-xs">
|
||||
{permName}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(privilege)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDelete(privilege.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { FUNCTIONAL_PERMISSIONS } from './types';
|
||||
|
||||
interface PrivilegeFormProps {
|
||||
formData: {
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: Record<string, boolean>;
|
||||
};
|
||||
onFormDataChange: (data: any) => void;
|
||||
onPermissionChange: (permissionKey: string, checked: boolean) => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onCancel: () => void;
|
||||
isEditing: boolean;
|
||||
}
|
||||
|
||||
export const PrivilegeForm: React.FC<PrivilegeFormProps> = ({
|
||||
formData,
|
||||
onFormDataChange,
|
||||
onPermissionChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isEditing
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
{isEditing ? 'Editar Privilégio' : 'Novo Privilégio'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Nome *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => onFormDataChange(prev => ({ ...prev, name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Descrição</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => onFormDataChange(prev => ({ ...prev, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-base font-medium">Permissões Funcionais</Label>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Selecione o nível de acesso funcional (apenas uma opção por privilégio)
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{FUNCTIONAL_PERMISSIONS.map((perm) => (
|
||||
<div key={perm.key} className="flex items-start space-x-3 p-3 border rounded-lg">
|
||||
<Checkbox
|
||||
id={perm.key}
|
||||
checked={formData.permissions[perm.key] || false}
|
||||
onCheckedChange={(checked) => onPermissionChange(perm.key, checked as boolean)}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={perm.key} className="font-medium">
|
||||
{perm.label}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{perm.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit">
|
||||
{isEditing ? 'Atualizar' : 'Criar'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
export interface Privilege {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
permissions: Record<string, boolean>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const FUNCTIONAL_PERMISSIONS = [
|
||||
{ key: 'can_admin', label: 'Administrador Total', description: 'Acesso completo ao sistema' },
|
||||
{ key: 'can_create_update_delete', label: 'Criar/Editar/Excluir', description: 'Pode criar, editar e excluir dados' },
|
||||
{ key: 'can_create_only', label: 'Apenas Criar', description: 'Pode criar e visualizar dados' },
|
||||
{ key: 'can_view_only', label: 'Apenas Visualizar', description: 'Pode apenas visualizar dados' }
|
||||
];
|
||||
Reference in New Issue
Block a user