🚀 Initial commit: Versão atual do TrackSteel APP

This commit is contained in:
2026-03-18 21:17:53 +00:00
commit bde410c9ad
633 changed files with 108150 additions and 0 deletions
@@ -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;
+54
View File
@@ -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>
);
}
+169
View File
@@ -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';
+174
View File
@@ -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>
);
}