🚀 Initial commit: Versão atual do TrackSteel APP
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { getCurrentSaoPauloTime } from '@/utils/dateTimeUtils';
|
||||
|
||||
export function CalendarClock() {
|
||||
const [currentTime, setCurrentTime] = useState(getCurrentSaoPauloTime());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(getCurrentSaoPauloTime());
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Sao_Paulo'
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'America/Sao_Paulo'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="text-lg font-bold text-blue-500">
|
||||
{formatTime(currentTime)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDate(currentTime)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Bell, CheckCircle, XCircle, X } from 'lucide-react';
|
||||
import { useSugestoes, SugestaoNotification } from '@/hooks/useSugestoes';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
const NotificationsSugestoes: React.FC = () => {
|
||||
const { notifications, markNotificationAsRead } = useSugestoes();
|
||||
|
||||
if (notifications.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getNotificationIcon = (message: string) => {
|
||||
if (message.includes('implementada')) {
|
||||
return <CheckCircle className="h-4 w-4 text-green-400" />;
|
||||
}
|
||||
if (message.includes('rejeitada')) {
|
||||
return <XCircle className="h-4 w-4 text-red-400" />;
|
||||
}
|
||||
return <Bell className="h-4 w-4 text-blue-400" />;
|
||||
};
|
||||
|
||||
const getNotificationColor = (message: string) => {
|
||||
if (message.includes('implementada')) {
|
||||
return 'bg-green-900/10 border-green-400/20';
|
||||
}
|
||||
if (message.includes('rejeitada')) {
|
||||
return 'bg-red-900/10 border-red-400/20';
|
||||
}
|
||||
return 'bg-blue-900/10 border-blue-400/20';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-slate-800/50 border-slate-700">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-white flex items-center gap-2 text-lg">
|
||||
<Bell className="h-5 w-5" />
|
||||
Notificações de Sugestões
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{notifications.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`p-3 rounded-lg border ${getNotificationColor(notification.message)}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
{getNotificationIcon(notification.message)}
|
||||
<div className="flex-1">
|
||||
<p className="text-white text-sm font-medium">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-slate-400 text-xs mt-1">
|
||||
{format(parseISO(notification.created_at), 'dd/MM/yyyy HH:mm', { locale: ptBR })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => markNotificationAsRead(notification.id)}
|
||||
className="h-6 w-6 p-0 text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsSugestoes;
|
||||
@@ -0,0 +1,54 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { UserAvatar } from '@/components/ui/user-avatar';
|
||||
import { Users } from 'lucide-react';
|
||||
import { useSessionLogsSimple } from '@/hooks/useSessionLogsSimple';
|
||||
|
||||
export function OnlineUsers() {
|
||||
const { onlineUsers, fetchOnlineUsers } = useSessionLogsSimple();
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchOnlineUsers();
|
||||
const interval = setInterval(fetchOnlineUsers, 30000); // Atualizar a cada 30 segundos
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchOnlineUsers]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
Usuários Online ({onlineUsers.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{onlineUsers.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Nenhum usuário online</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{onlineUsers.map((user) => (
|
||||
<div key={user.user_id} className="flex items-center space-x-2">
|
||||
<UserAvatar
|
||||
imageUrl={user.avatar_url || undefined}
|
||||
name={user.full_name || user.email}
|
||||
email={user.email}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-foreground truncate">
|
||||
{user.full_name || 'Usuário'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
|
||||
|
||||
const data = [
|
||||
{ name: 'Pendente', value: 0, color: '#f59e0b' },
|
||||
{ name: 'Em Progresso', value: 0, color: '#3b82f6' },
|
||||
{ name: 'Concluída', value: 0, color: '#10b981' },
|
||||
{ name: 'Cancelada', value: 0, color: '#ef4444' },
|
||||
];
|
||||
|
||||
export function TaskStatusChart() {
|
||||
const total = data.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Status das Tarefas</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Total: {total}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px] flex items-center justify-center">
|
||||
{total === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Nenhuma tarefa encontrada</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||
import { useTasksEnhanced } from '@/hooks/useTasksEnhanced';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useSmartPolling } from '@/hooks/useSmartPolling';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface TaskTypeData {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
a_fazer: 'A Fazer',
|
||||
em_andamento: 'Em Andamento',
|
||||
revisao: 'Revisão',
|
||||
pendente: 'Pendente',
|
||||
bloqueado: 'Bloqueado',
|
||||
concluido: 'Concluída',
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
a_fazer: '#c084fc',
|
||||
em_andamento: '#3b82f6',
|
||||
revisao: '#eab308',
|
||||
pendente: '#6b7280',
|
||||
bloqueado: '#ef4444',
|
||||
concluido: '#22c55e',
|
||||
};
|
||||
|
||||
export const TaskTypeChart = memo(() => {
|
||||
const { user } = useAuth();
|
||||
const { fetchMyTasks, fetchAssignedTasks } = useTasksEnhanced();
|
||||
|
||||
const { data: taskTypeData = [], isLoading } = useQuery({
|
||||
queryKey: ['dashboard-task-type'],
|
||||
queryFn: async (): Promise<TaskTypeData[]> => {
|
||||
if (!user) return [];
|
||||
|
||||
try {
|
||||
logger.info('Fetching task type data for dashboard');
|
||||
|
||||
const [myTasks, assignedTasks] = await Promise.all([
|
||||
fetchMyTasks({}),
|
||||
fetchAssignedTasks({})
|
||||
]);
|
||||
|
||||
const allTasks = [...myTasks, ...assignedTasks];
|
||||
|
||||
// Contar por status funcional
|
||||
const statusCount: Record<string, number> = {
|
||||
a_fazer: 0,
|
||||
em_andamento: 0,
|
||||
revisao: 0,
|
||||
pendente: 0,
|
||||
bloqueado: 0,
|
||||
concluido: 0,
|
||||
};
|
||||
|
||||
allTasks.forEach(task => {
|
||||
const status = task.status || 'a_fazer';
|
||||
if (statusCount.hasOwnProperty(status)) {
|
||||
statusCount[status]++;
|
||||
}
|
||||
});
|
||||
|
||||
const result = Object.entries(statusCount)
|
||||
.filter(([_, count]) => count > 0)
|
||||
.map(([status, count]) => ({
|
||||
name: statusLabels[status as keyof typeof statusLabels],
|
||||
value: count,
|
||||
color: statusColors[status as keyof typeof statusColors],
|
||||
}));
|
||||
|
||||
logger.success('Task type data processed successfully');
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching task type data', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: !!user,
|
||||
staleTime: 60000, // 1 minuto
|
||||
gcTime: 300000, // 5 minutos
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
// Polling inteligente apenas quando necessário
|
||||
useSmartPolling({
|
||||
queryKey: ['dashboard-task-type'],
|
||||
interval: 120000, // 2 minutos ao invés de 30 segundos
|
||||
enabled: !!user,
|
||||
onError: (error) => logger.error('Task polling error', error)
|
||||
});
|
||||
|
||||
const totalTasks = taskTypeData.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Distribuição por Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-center py-4 text-sm">Carregando dados...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Distribuição por Status</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Total: {totalTasks}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px] flex items-center justify-center">
|
||||
{taskTypeData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={taskTypeData}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 10,
|
||||
left: 5,
|
||||
bottom: 25,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--muted-foreground))" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={10}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis stroke="hsl(var(--muted-foreground))" fontSize={10} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[2, 2, 0, 0]}>
|
||||
{taskTypeData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-8 text-sm">Nenhuma tarefa encontrada</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
TaskTypeChart.displayName = 'TaskTypeChart';
|
||||
@@ -0,0 +1,174 @@
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { UserAvatar } from '@/components/ui/user-avatar';
|
||||
import { LogoutButton } from '@/components/auth/LogoutButton';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
|
||||
interface UserProfile {
|
||||
full_name: string | null;
|
||||
email: string | null;
|
||||
profile_image_url: string | null;
|
||||
}
|
||||
|
||||
const SAO_PAULO_TIMEZONE = 'America/Sao_Paulo';
|
||||
|
||||
export function UserInfo() {
|
||||
const { user } = useAuth();
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [loginTime, setLoginTime] = useState<string>('00:00:00');
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const loginStartTimeRef = useRef<number>(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchUserProfile();
|
||||
initializeLoginTime(); // Inicializar timer baseado no localStorage
|
||||
} else {
|
||||
// Limpar timer quando não há usuário
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Listener para eventos de autenticação do Supabase
|
||||
useEffect(() => {
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
|
||||
if (event === 'SIGNED_IN' && session?.user) {
|
||||
// Reset timer apenas em login real
|
||||
resetLoginTime();
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
// Inicializar tempo de login baseado no localStorage (preserva tempo entre navegações)
|
||||
const initializeLoginTime = () => {
|
||||
const savedLoginTime = localStorage.getItem('userLoginTime');
|
||||
|
||||
if (savedLoginTime) {
|
||||
// Usar tempo salvo do localStorage
|
||||
loginStartTimeRef.current = parseInt(savedLoginTime);
|
||||
} else {
|
||||
// Se não há tempo salvo, definir tempo atual (primeira vez)
|
||||
const currentTime = Date.now();
|
||||
loginStartTimeRef.current = currentTime;
|
||||
localStorage.setItem('userLoginTime', currentTime.toString());
|
||||
}
|
||||
|
||||
// Limpar interval anterior se existir
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
// Iniciar cronômetro
|
||||
intervalRef.current = setInterval(() => {
|
||||
const elapsed = Date.now() - loginStartTimeRef.current;
|
||||
const hours = Math.floor(elapsed / (1000 * 60 * 60));
|
||||
const minutes = Math.floor(elapsed % (1000 * 60 * 60) / (1000 * 60));
|
||||
const seconds = Math.floor(elapsed % (1000 * 60) / 1000);
|
||||
setLoginTime(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Resetar tempo de login apenas em eventos reais de login
|
||||
const resetLoginTime = () => {
|
||||
// Limpar interval anterior se existir
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
// Definir novo tempo de início
|
||||
const currentTime = Date.now();
|
||||
loginStartTimeRef.current = currentTime;
|
||||
localStorage.setItem('userLoginTime', currentTime.toString());
|
||||
|
||||
// Iniciar novo cronômetro
|
||||
intervalRef.current = setInterval(() => {
|
||||
const elapsed = Date.now() - loginStartTimeRef.current;
|
||||
const hours = Math.floor(elapsed / (1000 * 60 * 60));
|
||||
const minutes = Math.floor(elapsed % (1000 * 60 * 60) / (1000 * 60));
|
||||
const seconds = Math.floor(elapsed % (1000 * 60) / 1000);
|
||||
setLoginTime(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Limpar tempo de login quando usuário sair
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
localStorage.removeItem('userLoginTime');
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchUserProfile = async () => {
|
||||
if (!user) return;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('full_name, email, profile_image_url')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (!error && data) {
|
||||
setProfile(data);
|
||||
} else {
|
||||
// Fallback para dados do usuário auth
|
||||
setProfile({
|
||||
full_name: user.user_metadata?.full_name || null,
|
||||
email: user.email || null,
|
||||
profile_image_url: null
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!user || !profile) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 p-3 rounded-lg bg-green-100 text-gray-800 dark:bg-teal-900 dark:text-white">
|
||||
<div className="flex items-center space-x-3">
|
||||
<UserAvatar
|
||||
imageUrl={profile.profile_image_url || undefined}
|
||||
name={profile.full_name || profile.email || 'Usuário'}
|
||||
email={profile.email || ''}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-800 dark:text-white truncate text-sm">
|
||||
{profile.full_name || 'TrackSteel User'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 dark:text-white/80 truncate">
|
||||
{profile.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-xs text-gray-600 dark:text-white/80">
|
||||
<p>
|
||||
<span>Logado há:</span> {loginTime}
|
||||
</p>
|
||||
<LogoutButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
showText={true}
|
||||
className="w-full text-xs hover:bg-sidebar-accent p-1 h-auto justify-start"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user