From 28ce196ac4149972414c2d82e4516cb91706f076 Mon Sep 17 00:00:00 2001 From: Seu Nome <230413@epvc.pt> Date: Tue, 28 Apr 2026 23:38:16 +0100 Subject: [PATCH] atualizacoes --- .env | 3 + app/Aluno/AlunoHome.tsx | 484 +++++++++++++++++++++++++----- app/Aluno/perfil.tsx | 131 ++++---- app/Professor/Alunos/Estagios.tsx | 311 +++++++++++++++++-- 4 files changed, 750 insertions(+), 179 deletions(-) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 0000000..419f2e0 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +EXPO_PUBLIC_SUPABASE_URL=https://xyz.supabase.co +EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJhbG... (chave anon) +EXPO_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJhbG... (esta é a chave mestra que encontras no painel do Supabase) \ No newline at end of file diff --git a/app/Aluno/AlunoHome.tsx b/app/Aluno/AlunoHome.tsx index 3eeeb2c..7b8b80c 100644 --- a/app/Aluno/AlunoHome.tsx +++ b/app/Aluno/AlunoHome.tsx @@ -6,12 +6,13 @@ import * as DocumentPicker from 'expo-document-picker'; import * as FileSystem from 'expo-file-system/legacy'; import * as Location from 'expo-location'; import { useRouter } from 'expo-router'; -import { memo, useCallback, useMemo, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Animated, Modal, Platform, + RefreshControl, SafeAreaView, ScrollView, StatusBar, @@ -47,17 +48,24 @@ const AlunoHome = memo(() => { const { isDarkMode } = useTheme(); const router = useRouter(); const hojeStr = new Date().toISOString().split('T')[0]; + const scrollViewRef = useRef(null); + + // ESTADO DOS SEPARADORES + const [activeTab, setActiveTab] = useState<'horas' | 'horario' | 'info'>('horas'); const [selectedDate, setSelectedDate] = useState(hojeStr); - const [configEstagio, setConfigEstagio] = useState({ inicio: '', fim: '' }); + const [estagioDetalhes, setEstagioDetalhes] = useState(null); + const [horariosEstagio, setHorariosEstagio] = useState([]); const [presencas, setPresencas] = useState>({}); const [faltas, setFaltas] = useState>({}); const [sumarios, setSumarios] = useState>({}); const [urlsJustificacao, setUrlsJustificacao] = useState>({}); + const [statsFaltas, setStatsFaltas] = useState({ justificadas: 0, injustificadas: 0, totalPresencas: 0 }); const [pdf, setPdf] = useState(null); const [editandoSumario, setEditandoSumario] = useState(false); const [isLoadingDB, setIsLoadingDB] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [isLocating, setIsLocating] = useState(false); const [isUploading, setIsUploading] = useState(false); const [showLocationModal, setShowLocationModal] = useState(false); @@ -74,79 +82,147 @@ const AlunoHome = memo(() => { textoSecundario: isDarkMode ? '#94A3B8' : '#64748B', borda: isDarkMode ? '#2D2D2D' : '#E2E8F0', azul: azulPetroleo, + laranja: laranjaEPVC, + azulSuave: isDarkMode ? 'rgba(35, 144, 166, 0.15)' : 'rgba(35, 144, 166, 0.1)', + vermelhoSuave: isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(239, 68, 68, 0.1)', vermelho: '#EF4444', verde: '#10B981', aviso: isDarkMode ? '#2D2200' : '#FFF9E6', avisoTexto: isDarkMode ? '#FFD700' : '#856404' }), [isDarkMode]); - const showAlert = (msg: string, type: 'success' | 'error' | 'info' = 'info') => { + const showAlert = useCallback((msg: string, type: 'success' | 'error' | 'info' = 'info') => { setAlertConfig({ msg, type }); Animated.sequence([ Animated.timing(alertOpacity, { toValue: 1, duration: 300, useNativeDriver: true }), Animated.delay(3000), Animated.timing(alertOpacity, { toValue: 0, duration: 300, useNativeDriver: true }) ]).start(() => setAlertConfig(null)); + }, [alertOpacity]); + + const fetchDadosSupabase = async (isManualRefresh = false) => { + if (!isManualRefresh) setIsLoadingDB(true); + try { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return; + + const { data: eData } = await supabase + .from('estagios') + .select('id, data_inicio, data_fim, horas_totais, horas_concluidas, horas_diarias, empresas(nome, tutor_nome, tutor_telefone)') + .eq('aluno_id', user.id) + .order('data_fim', { ascending: false }) + .limit(1) + .maybeSingle(); + + setEstagioDetalhes(eData || null); + + if (eData && eData.id) { + const { data: hData } = await supabase + .from('horarios_estagio') + .select('periodo, hora_inicio, hora_fim') + .eq('estagio_id', eData.id); + + setHorariosEstagio(hData || []); + } else { + setHorariosEstagio([]); + } + + if (eData && eData.data_inicio && eData.data_fim) { + const { data, error } = await supabase + .from('presencas') + .select('*') + .eq('aluno_id', user.id) + .gte('data', eData.data_inicio) + .lte('data', eData.data_fim); + + if (error) throw error; + + const p: any = {}, f: any = {}, s: any = {}, u: any = {}; + let countJustificadas = 0; + let countInjustificadas = 0; + + data?.forEach(item => { + if (item.estado === 'presente') { + p[item.data] = true; + s[item.data] = item.sumario || ''; + } else { + f[item.data] = true; + u[item.data] = item.justificacao_url || ''; + if (item.justificacao_url) countJustificadas++; + else countInjustificadas++; + } + }); + + setPresencas(p); setFaltas(f); setSumarios(s); setUrlsJustificacao(u); + setStatsFaltas({ justificadas: countJustificadas, injustificadas: countInjustificadas, totalPresencas: Object.keys(p).length }); + } else { + setPresencas({}); setFaltas({}); setSumarios({}); setUrlsJustificacao({}); + setStatsFaltas({ justificadas: 0, injustificadas: 0, totalPresencas: 0 }); + } + + } catch (error) { + console.error(error); + } finally { + if (!isManualRefresh) setIsLoadingDB(false); + } }; useFocusEffect(useCallback(() => { fetchDadosSupabase(); }, [])); - const fetchDadosSupabase = async () => { - setIsLoadingDB(true); - try { - const { data: { user } } = await supabase.auth.getUser(); - if (!user) return; - const { data: estagio } = await supabase.from('estagios').select('data_inicio, data_fim').eq('aluno_id', user.id).single(); - - if (estagio) { - setConfigEstagio({ inicio: estagio.data_inicio, fim: estagio.data_fim }); - } else { - setConfigEstagio({ inicio: '', fim: '' }); - } + useEffect(() => { + const estagiosSubscription = supabase + .channel('estagios_changes') + .on('postgres_changes', { event: '*', schema: 'public', table: 'estagios' }, () => fetchDadosSupabase()) + .subscribe(); - const { data, error } = await supabase.from('presencas').select('*').eq('aluno_id', user.id); - if (error) throw error; - const p: any = {}, f: any = {}, s: any = {}, u: any = {}; - data?.forEach(item => { - if (item.estado === 'presente') { p[item.data] = true; s[item.data] = item.sumario || ''; } - else { f[item.data] = true; u[item.data] = item.justificacao_url || ''; } - }); - setPresencas(p); setFaltas(f); setSumarios(s); setUrlsJustificacao(u); - } catch (error) { - console.error(error); - } finally { - setIsLoadingDB(false); - } - }; + const presencasSubscription = supabase + .channel('presencas_changes') + .on('postgres_changes', { event: '*', schema: 'public', table: 'presencas' }, () => fetchDadosSupabase()) + .subscribe(); + + return () => { + supabase.removeChannel(estagiosSubscription); + supabase.removeChannel(presencasSubscription); + }; + }, []); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + await fetchDadosSupabase(true); + setRefreshing(false); + }, []); const feriadosMap = useMemo(() => getFeriadosMap(new Date(selectedDate).getFullYear()), [selectedDate]); - // LOGICA CORRIGIDA: Se as datas forem '', o estágio não existe e bloqueia tudo + const statusEstagio = useMemo(() => { + if (!estagioDetalhes || !estagioDetalhes.data_inicio || !estagioDetalhes.data_fim) return null; + if (hojeStr > estagioDetalhes.data_fim) return 'concluido'; + if (hojeStr < estagioDetalhes.data_inicio) return 'agendado'; + return 'ativo'; + }, [estagioDetalhes, hojeStr]); + const infoData = useMemo(() => { const data = new Date(selectedDate); const diaSemana = data.getDay(); const nomeFeriado = feriadosMap[selectedDate]; - const temEstagio = configEstagio.inicio !== '' && configEstagio.fim !== ''; - const antesDoInicio = temEstagio && selectedDate < configEstagio.inicio; - const depoisDoFim = temEstagio && selectedDate > configEstagio.fim; + const temEstagio = !!estagioDetalhes && estagioDetalhes.data_inicio && estagioDetalhes.data_fim; + const antesDoInicio = temEstagio && selectedDate < estagioDetalhes.data_inicio; + const depoisDoFim = temEstagio && selectedDate > estagioDetalhes.data_fim; + const estagioAtivo = statusEstagio === 'ativo'; return { - valida: temEstagio && diaSemana !== 0 && diaSemana !== 6 && !antesDoInicio && !depoisDoFim && !nomeFeriado, - podeMarcar: temEstagio && selectedDate === hojeStr && !antesDoInicio && !depoisDoFim && !nomeFeriado, - nomeFeriado, - antesDoInicio, - depoisDoFim, - foraDeRange: !temEstagio || antesDoInicio || depoisDoFim, - temEstagio + valida: estagioAtivo && diaSemana !== 0 && diaSemana !== 6 && !antesDoInicio && !depoisDoFim && !nomeFeriado, + podeMarcar: estagioAtivo && selectedDate === hojeStr && !antesDoInicio && !depoisDoFim && !nomeFeriado, + nomeFeriado, antesDoInicio, depoisDoFim, foraDeRange: !temEstagio || antesDoInicio || depoisDoFim, + temEstagio, estagioAtivo }; - }, [selectedDate, configEstagio, hojeStr, feriadosMap]); + }, [selectedDate, estagioDetalhes, hojeStr, feriadosMap, statusEstagio]); const handlePresencaClick = async () => { - if (!infoData.temEstagio) { - showAlert("Aguarde pela configuração do estágio.", "error"); - return; - } + if (!infoData.temEstagio) return showAlert("Aguarde pela configuração do estágio.", "error"); + if (!infoData.estagioAtivo) return showAlert("O estágio não está ativo neste momento.", "error"); + const { status } = await Location.getForegroundPermissionsAsync(); if (status === 'granted') { executarMarcacao(); @@ -167,7 +243,6 @@ const AlunoHome = memo(() => { aluno_id: user?.id, data: selectedDate, estado: 'presente', lat: loc.coords.latitude, lng: loc.coords.longitude }); showAlert("Presença marcada!", "success"); - fetchDadosSupabase(); } catch (e: any) { showAlert(e.message, "error"); } finally { setIsLocating(false); } }; @@ -179,7 +254,6 @@ const AlunoHome = memo(() => { const { data: { user } } = await supabase.auth.getUser(); await supabase.from('presencas').upsert({ aluno_id: user?.id, data: selectedDate, estado: 'faltou' }); showAlert("Falta registada.", "info"); - fetchDadosSupabase(); } catch (e) { showAlert("Erro ao registar falta.", "error"); } }; @@ -201,7 +275,6 @@ const AlunoHome = memo(() => { await supabase.from('presencas').update({ justificacao_url: publicUrl }).match({ aluno_id: user?.id, data: selectedDate }); setPdf(null); showAlert("Enviado com sucesso!", "success"); - fetchDadosSupabase(); } catch (e) { showAlert("Erro no upload.", "error"); } finally { setIsUploading(false); } }; @@ -212,10 +285,20 @@ const AlunoHome = memo(() => { await supabase.from('presencas').update({ sumario: sumarios[selectedDate] }).match({ aluno_id: user?.id, data: selectedDate }); setEditandoSumario(false); showAlert("Sumário guardado!", "success"); - fetchDadosSupabase(); } catch (e) { showAlert("Erro ao guardar.", "error"); } }; + const getBadgeStyle = () => { + if (statusEstagio === 'concluido') return { bg: '#E2E8F0', text: '#475569', label: 'CONCLUÍDO' }; + if (statusEstagio === 'agendado') return { bg: '#FEF3C7', text: '#D97706', label: 'AGENDADO' }; + return { bg: themeStyles.verde + '20', text: themeStyles.verde, label: 'A DECORRER' }; + }; + + const badgeObj = getBadgeStyle(); + const horasTotais = estagioDetalhes?.horas_totais || 0; + const horasConcluidas = estagioDetalhes?.horas_concluidas || 0; + const horasEmFalta = Math.max(0, horasTotais - horasConcluidas); + return ( @@ -247,7 +330,14 @@ const AlunoHome = memo(() => { )} - + + } + > Estágios+ @@ -260,34 +350,216 @@ const AlunoHome = memo(() => { - {/* AVISO DE FALTA DE ESTÁGIO - Vai dar merda se o aluno não souber por que está bloqueado */} - {!infoData.temEstagio && !isLoadingDB && ( + {/* 🚀 BOTÕES DE SEPARADOR (TABS) MODERNOS 🚀 */} + + + setActiveTab('horas')} + activeOpacity={0.7} + > + + + Horas + + + + setActiveTab('horario')} + activeOpacity={0.7} + > + + + Horário + + + + setActiveTab('info')} + activeOpacity={0.7} + > + + + Info + + + + + + {/* CARTÃO PRINCIPAL QUE MUDA CONFORME O SEPARADOR ATIVO */} + {!isLoadingDB && ( + estagioDetalhes ? ( + + + {/* O Cabeçalho (Empresa e Status) fica sempre visível */} + + + + + {estagioDetalhes.empresas?.nome || "Empresa não definida"} + + + + {badgeObj.label} + + + + + + {/* CONTEÚDO 1: HORAS */} + {activeTab === 'horas' && ( + + + + REALIZADAS + {horasConcluidas}h + + + + EM FALTA + {horasEmFalta}h + + + + TOTAIS + {horasTotais}h + + + + + + + + PRESENÇAS + {statsFaltas.totalPresencas} + + + + FALTAS JUST. + {statsFaltas.justificadas} + + + + FALTAS INJ. + {statsFaltas.injustificadas} + + + + )} + + {/* CONTEÚDO 2: HORÁRIO DIÁRIO */} + {activeTab === 'horario' && ( + + + Carga Horária + + {estagioDetalhes.horas_diarias ? estagioDetalhes.horas_diarias + '/dia' : 'Não definido'} + + + {/* Mostra a Manhã e a Tarde se existirem na base de dados */} + {horariosEstagio.length > 0 && ( + + + + {horariosEstagio.map((h, index) => ( + + + + {h.periodo} + + + {h.hora_inicio?.slice(0, 5)} - {h.hora_fim?.slice(0, 5)} + + + ))} + + )} + + )} + + {/* CONTEÚDO 3: INFO GERAL */} + {activeTab === 'info' && ( + + {/* Tutor e Contacto */} + + + + Tutor da Empresa + {estagioDetalhes.empresas?.tutor_nome || "N/A"} + + + + + + + Contacto + {estagioDetalhes.empresas?.tutor_telefone || "N/A"} + + + + {/* Datas */} + + + + + Data de Início + {estagioDetalhes.data_inicio} + + + Data de Fim (Prevista) + {estagioDetalhes.data_fim} + + + + )} + + + ) : ( - + - O teu período de estágio ainda não foi configurado pelo professor. + Sem estágio atribuído no sistema. Aguarda indicação do teu professor. + ) )} {isLocating ? : Marcar Presença} @@ -298,26 +570,24 @@ const AlunoHome = memo(() => { ({ ...acc, [d]: { marked: true, dotColor: azulPetroleo } }), {}), - ...Object.keys(presencas).reduce((acc, d) => ({ ...acc, [d]: { marked: true, dotColor: '#10B981' } }), {}), - ...Object.keys(faltas).reduce((acc, d) => ({ ...acc, [d]: { marked: true, dotColor: '#EF4444' } }), {}), + ...Object.keys(presencas).reduce((acc, d) => ({ ...acc, [d]: { marked: true, dotColor: themeStyles.verde } }), {}), + ...Object.keys(faltas).reduce((acc, d) => ({ ...acc, [d]: { marked: true, dotColor: themeStyles.vermelho } }), {}), [selectedDate]: { selected: true, selectedColor: azulPetroleo } }} onDayPress={(day) => setSelectedDate(day.dateString)} /> + {infoData.nomeFeriado && ( + 🎉 {infoData.nomeFeriado} + )} + {presencas[selectedDate] && ( @@ -326,11 +596,9 @@ const AlunoHome = memo(() => { setSumarios({...sumarios, [selectedDate]: txt})} - placeholder="O que fizeste hoje?" - placeholderTextColor="#94A3B8" + placeholder="O que fizeste hoje?" placeholderTextColor="#94A3B8" /> {editandoSumario && Guardar Sumário} @@ -366,14 +634,66 @@ const AlunoHome = memo(() => { const styles = StyleSheet.create({ safeArea: { flex: 1, paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0 }, - container: { padding: 20 }, - topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 25 }, + container: { padding: 20, paddingBottom: 40 }, + topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }, topIcons: { flexDirection: 'row', alignItems: 'center' }, title: { fontSize: 26, fontWeight: '900' }, + + // Estilos das Tabs (Separadores) Modernos + quickActionsContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 20, + borderRadius: 20, + padding: 6, // Cria aquele espaço interior para parecer uma pílula + }, + quickActionBtn: { + flex: 1, + flexDirection: 'row', // Coloca o ícone e o texto lado a lado + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + borderRadius: 16, + gap: 6, // Espaço entre o ícone e o texto + elevation: 0, // Garante que não há sombra base que manche o ecrã + shadowOpacity: 0, + borderWidth: 0, + }, + quickActionBtnActive: { + // 🔥 Removemos as sombras feias daqui! O contraste faz-se pela cor de fundo limpa. + elevation: 0, + shadowOpacity: 0, + borderWidth: 0, + }, + quickActionText: { + fontSize: 13, + fontWeight: '800' + }, + + // Estilos do Cartão que muda + dashboardCard: { padding: 18, borderRadius: 20, borderWidth: 1, borderLeftWidth: 5, marginBottom: 20, elevation: 2, shadowOpacity: 0.05, shadowRadius: 8 }, + dashHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }, + dashEmpresa: { fontSize: 16, fontWeight: '800' }, + statusBadge: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 8 }, + statusBadgeText: { fontSize: 9, fontWeight: '900', letterSpacing: 0.5 }, + + dashGrid: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + dashGridItem: { flex: 1, alignItems: 'center' }, + dashStatLabel: { fontSize: 10, textTransform: 'uppercase', fontWeight: '800', marginBottom: 4, textAlign: 'center' }, + dashStatValue: { fontSize: 17, fontWeight: '900', textAlign: 'center' }, + + dashDividerHorizontal: { height: 1, marginVertical: 12, opacity: 0.6 }, + dashDividerVertical: { width: 1, height: 30, backgroundColor: '#E2E8F0', opacity: 0.6 }, + + // Estilos para a Tab "Info" + infoRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 }, + infoLabel: { fontSize: 11, fontWeight: '800', textTransform: 'uppercase', marginBottom: 2 }, + infoValue: { fontSize: 15, fontWeight: '700' }, + alertBar: { position: 'absolute', top: 50, left: 20, right: 20, padding: 15, borderRadius: 15, zIndex: 1000 }, alertText: { color: '#fff', fontWeight: 'bold', textAlign: 'center' }, - avisoBox: { flexDirection: 'row', alignItems: 'center', gap: 10, padding: 15, borderRadius: 15, marginBottom: 20 }, - avisoTexto: { fontSize: 13, fontWeight: '700', flex: 1 }, + avisoBox: { flexDirection: 'row', alignItems: 'center', gap: 10, padding: 18, borderRadius: 18, marginBottom: 20 }, + avisoTexto: { fontSize: 14, fontWeight: '700', flex: 1, lineHeight: 20 }, botoesLinha: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20 }, btn: { padding: 18, borderRadius: 22, width: '48%', alignItems: 'center', elevation: 3 }, txtBtn: { color: '#fff', fontWeight: '800', fontSize: 14 }, diff --git a/app/Aluno/perfil.tsx b/app/Aluno/perfil.tsx index d369a1c..4019c38 100644 --- a/app/Aluno/perfil.tsx +++ b/app/Aluno/perfil.tsx @@ -1,9 +1,8 @@ import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, - Alert, Animated, KeyboardAvoidingView, Platform, @@ -27,11 +26,11 @@ export default function PerfilAluno() { const [loading, setLoading] = useState(true); const [isEditing, setIsEditing] = useState(false); const [perfil, setPerfil] = useState(null); - const [estagio, setEstagio] = useState(null); const [saving, setSaving] = useState(false); - const [faltasJustificadas, setFaltasJustificadas] = useState(0); - const [faltasInjustificadas, setFaltasInjustificadas] = useState(0); + // --- ESTADOS PARA O ALERTA MODERNO --- + const [alertConfig, setAlertConfig] = useState<{ msg: string, type: 'success' | 'error' } | null>(null); + const alertOpacity = useMemo(() => new Animated.Value(0), []); const fadeAnim = useMemo(() => new Animated.Value(0), []); @@ -48,19 +47,15 @@ export default function PerfilAluno() { verde: '#10B981', }), [isDarkMode]); - // --- LÓGICA DE VERIFICAÇÃO DO ESTÁGIO ATIVO --- - const isEstagioAtivo = useMemo(() => { - if (!estagio) return false; // Se não tem estágio na DB, não está ativo - if (!estagio.data_fim) return true; // Se tem estágio mas sem data de fim, assumimos ativo - - const hoje = new Date(); - hoje.setHours(0, 0, 0, 0); // Reset às horas para comparar só o dia - - const dataFim = new Date(estagio.data_fim); - dataFim.setHours(0, 0, 0, 0); - - return dataFim >= hoje; // Se a data de fim for hoje ou no futuro, está ativo - }, [estagio]); + // --- FUNÇÃO DE MOSTRAR O ALERTA --- + const showAlert = useCallback((msg: string, type: 'success' | 'error') => { + setAlertConfig({ msg, type }); + Animated.sequence([ + Animated.timing(alertOpacity, { toValue: 1, duration: 400, useNativeDriver: true }), + Animated.delay(2500), + Animated.timing(alertOpacity, { toValue: 0, duration: 400, useNativeDriver: true }) + ]).start(() => setAlertConfig(null)); + }, [alertOpacity]); // --- FUNÇÕES DE DATA --- const formatarDataParaUI = (dataDB: string) => { @@ -110,8 +105,6 @@ export default function PerfilAluno() { return formatted; }; - // --- FIM FUNÇÕES DATA --- - const buscarDados = async () => { try { setLoading(true); @@ -126,19 +119,6 @@ export default function PerfilAluno() { if (alunoRes) aData = alunoRes; } - const { data: eData } = await supabase - .from('estagios') - .select(`*, empresas(*), horarios_estagio(*)`) - .eq('aluno_id', user.id) - .maybeSingle(); - - const { data: presencas } = await supabase.from('presencas').select('estado, justificacao_url').eq('aluno_id', user.id); - - if (presencas) { - setFaltasJustificadas(presencas.filter(p => p.estado === 'Falta' && p.justificacao_url).length); - setFaltasInjustificadas(presencas.filter(p => p.estado === 'Falta' && !p.justificacao_url).length); - } - const dataFormatadaUI = formatarDataParaUI(pData?.data_nascimento); const idadeCalculada = dataFormatadaUI ? calcularIdade(dataFormatadaUI) : pData?.idade; @@ -149,8 +129,6 @@ export default function PerfilAluno() { data_nascimento: dataFormatadaUI, idade: idadeCalculada ?? 'N/A' }); - - setEstagio(eData); Animated.timing(fadeAnim, { toValue: 1, duration: 600, useNativeDriver: true }).start(); } catch (err) { @@ -181,14 +159,15 @@ export default function PerfilAluno() { if (error) throw error; if (!data || data.length === 0) { - throw new Error("Erro de RLS. Confirma as tuas políticas no Supabase."); + throw new Error("Erro nas permissões. Confirma as tuas políticas no Supabase."); } setIsEditing(false); - Alert.alert("Sucesso", "Perfil atualizado!"); + // 🔥 O NOSSO NOVO ALERTA MODERNO EM ACÇÃO 🔥 + showAlert("Perfil guardado com sucesso!", "success"); await buscarDados(); } catch (e: any) { - Alert.alert("Erro ao gravar", e.message); + showAlert(e.message || "Erro ao guardar alterações.", "error"); } finally { setSaving(false); } @@ -199,6 +178,22 @@ export default function PerfilAluno() { return ( + + {/* 🟢 COMPONENTE DE ALERTA FLUTUANTE 🟢 */} + {alertConfig && ( + 0 ? insets.top + 10 : 40 + } + ]}> + + {alertConfig.msg} + + )} + @@ -274,39 +269,6 @@ export default function PerfilAluno() { setPerfil({...perfil, residencia: v})} cores={cores} /> - Informação de Estágio - - {/* Aqui entra a condição: Só mostra a info se isEstagioAtivo for verdadeiro */} - {isEstagioAtivo ? ( - - - - - - - - - - - - - JUSTIFICADAS: {faltasJustificadas} - - - INJUSTIFICADAS: {faltasInjustificadas} - - - - ) : ( - - - {/* Mensagem dinâmica se já teve ou se nunca teve estágio */} - - {estagio ? "Sem estágio ativo no momento." : "Sem estágio atribuído no sistema"} - - - )} - router.push('/redefenirsenha')}> @@ -351,6 +313,31 @@ const PerfilInput = ({ label, icon, cores, editable, ...props }: any) => ( const styles = StyleSheet.create({ centered: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + + // ESTILO DO ALERTA MODERNO + modernAlert: { + position: 'absolute', + left: 20, + right: 20, + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 20, + zIndex: 9999, + elevation: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 5 }, + shadowOpacity: 0.2, + shadowRadius: 10 + }, + modernAlertText: { + color: '#fff', + fontWeight: '800', + fontSize: 14, + marginLeft: 10, + flex: 1 + }, + headerContainer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 20, paddingVertical: 15 }, headerTitle: { fontSize: 19, fontWeight: '900' }, roundBtn: { width: 45, height: 45, borderRadius: 14, justifyContent: 'center', alignItems: 'center', elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4 }, @@ -368,8 +355,6 @@ const styles = StyleSheet.create({ inputContainer: { flexDirection: 'row', alignItems: 'center', borderRadius: 14, borderWidth: 1.5, height: 52 }, textInput: { flex: 1, fontSize: 15, fontWeight: '700' }, inputRow: { flexDirection: 'row', justifyContent: 'space-between' }, - miniStatus: { padding: 12, borderRadius: 14, alignItems: 'center', justifyContent: 'center' }, - miniStatusText: { fontSize: 10, fontWeight: '900' }, footer: { marginTop: 25 }, actionMenuItem: { flexDirection: 'row', alignItems: 'center', padding: 16, borderRadius: 20, elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 3 }, actionIcon: { width: 40, height: 40, borderRadius: 14, justifyContent: 'center', alignItems: 'center' }, diff --git a/app/Professor/Alunos/Estagios.tsx b/app/Professor/Alunos/Estagios.tsx index 1df7d01..53cb716 100644 --- a/app/Professor/Alunos/Estagios.tsx +++ b/app/Professor/Alunos/Estagios.tsx @@ -4,7 +4,6 @@ import { useRouter } from 'expo-router'; import { useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, - Alert, Modal, ScrollView, StatusBar, @@ -28,10 +27,21 @@ interface Estagio { data_inicio: string; data_fim: string; horas_diarias?: string; + horas_totais?: number; alunos: { nome: string; turma_curso: string; ano: number }; empresas: { id: string; nome: string; morada: string; tutor_nome: string; tutor_telefone: string; curso: string }; } +// Configuração PT-PT para Feriados e afins +const getFeriadosMap = (ano: number) => ({ + [`${ano}-01-01`]: "Ano Novo", [`${ano}-04-25`]: "Dia da Liberdade", + [`${ano}-05-01`]: "Dia do Trabalhador", [`${ano}-06-10`]: "Dia de Portugal", + [`${ano}-06-24`]: "São João (Vila do Conde)", [`${ano}-08-15`]: "Assunção de Nª Senhora", + [`${ano}-10-05`]: "Implantação da República", [`${ano}-11-01`]: "Todos os Santos", + [`${ano}-12-01`]: "Restauração da Independência", [`${ano}-12-08`]: "Imaculada Conceição", + [`${ano}-12-25`]: "Natal" +}); + export default function Estagios() { const router = useRouter(); const { isDarkMode } = useTheme(); @@ -48,6 +58,8 @@ export default function Estagios() { azul: azulEPVC, laranja: laranjaEPVC, azulSuave: isDarkMode ? 'rgba(35, 144, 166, 0.12)' : '#F0F9FA', + vermelhoSuave: isDarkMode ? 'rgba(239, 68, 68, 0.12)' : '#FFF5F5', + laranjaSuave: isDarkMode ? 'rgba(227, 142, 0, 0.12)' : '#FFF9F0', vermelho: '#EF4444', borda: isDarkMode ? '#2D2D2D' : '#E2E8F0', overlay: 'rgba(26, 54, 93, 0.8)', @@ -60,20 +72,32 @@ export default function Estagios() { const [loading, setLoading] = useState(true); const [modalVisible, setModalVisible] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false); + + // ESTADO DO ALERTA CUSTOMIZADO (AGORA CENTRADO E BONITO) + const [customAlert, setCustomAlert] = useState({ visible: false, title: '', msg: '', tipo: 'warning' as 'warning' | 'error' }); + const [estagioParaApagar, setEstagioParaApagar] = useState<{id: string, nome: string} | null>(null); const [passo, setPasso] = useState(1); const [alunoSelecionado, setAlunoSelecionado] = useState(null); const [empresaSelecionada, setEmpresaSelecionada] = useState(null); const [editandoEstagio, setEditandoEstagio] = useState(null); + + // Datas e Horas Totais const [dataInicio, setDataInicio] = useState(''); const [dataFim, setDataFim] = useState(''); + const [horasTotaisEstagio, setHorasTotaisEstagio] = useState(''); const [searchMain, setSearchMain] = useState(''); + // Horários Diários const [hManhaIni, setHManhaIni] = useState(''); const [hManhaFim, setHManhaFim] = useState(''); const [hTardeIni, setHTardeIni] = useState(''); const [hTardeFim, setHTardeFim] = useState(''); + const showCustomAlert = (title: string, msg: string, tipo: 'warning' | 'error' = 'warning') => { + setCustomAlert({ visible: true, title, msg, tipo }); + }; + useEffect(() => { fetchDados(); }, []); const fetchDados = async () => { @@ -106,6 +130,14 @@ export default function Estagios() { } }; + const aplicarMascaraHora = (value: string) => { + const cleaned = value.replace(/\D/g, ''); + if (cleaned.length >= 3) { + return `${cleaned.slice(0, 2)}:${cleaned.slice(2, 4)}`; + } + return cleaned; + }; + const totalHorasDiarias = useMemo(() => { const calcularMinutos = (ini: string, fim: string) => { if (!ini || !fim || !ini.includes(':') || !fim.includes(':')) return 0; @@ -127,10 +159,93 @@ export default function Estagios() { return `${h.padStart(2, '0')}:${m.padStart(2, '0')}`; }; + const calcularMagia = (tipo: 'dataFim' | 'horasTotais') => { + if (!dataInicio) return showCustomAlert("Atenção", "Preenche a Data de Início primeiro!"); + if (!hManhaIni && !hTardeIni) return showCustomAlert("Atenção", "Preenche primeiro o horário diário (Manhã e/ou Tarde)!"); + + const matchH = totalHorasDiarias.match(/(\d+)h/); + const matchM = totalHorasDiarias.match(/(\d+)m/); + const horasD = (matchH ? parseInt(matchH[1]) : 0) + (matchM ? parseInt(matchM[1]) / 60 : 0); + + if (horasD <= 0) return showCustomAlert("Atenção", "O horário diário tem de ser superior a zero.", 'error'); + + if (tipo === 'dataFim') { + const totais = parseInt(horasTotaisEstagio); + if (!totais || totais <= 0) return showCustomAlert("Atenção", "Preenche as Horas Totais primeiro!"); + + let diasNecessarios = Math.ceil(totais / horasD); + let currentDate = new Date(dataInicio); + let diasContados = 0; + + while (diasContados < diasNecessarios) { + const day = currentDate.getDay(); + const dateStr = currentDate.toISOString().split('T')[0]; + const feriadosMapAno = getFeriadosMap(currentDate.getFullYear()); + + if (day !== 0 && day !== 6 && !feriadosMapAno[dateStr]) { + diasContados++; + } + if (diasContados < diasNecessarios) { + currentDate.setDate(currentDate.getDate() + 1); + } + } + setDataFim(currentDate.toISOString().split('T')[0]); + + } else if (tipo === 'horasTotais') { + if (!dataFim) return showCustomAlert("Atenção", "Preenche a Data Fim (Prev.) primeiro!"); + + let currentDate = new Date(dataInicio); + const endDate = new Date(dataFim); + let diasUteis = 0; + + if (currentDate > endDate) return showCustomAlert("Atenção", "A Data de Início não pode ser depois da Data Fim!", 'error'); + + while (currentDate <= endDate) { + const day = currentDate.getDay(); + const dateStr = currentDate.toISOString().split('T')[0]; + const feriadosMapAno = getFeriadosMap(currentDate.getFullYear()); + + if (day !== 0 && day !== 6 && !feriadosMapAno[dateStr]) { + diasUteis++; + } + currentDate.setDate(currentDate.getDate() + 1); + } + + const calculoFinal = Math.round(diasUteis * horasD); + setHorasTotaisEstagio(calculoFinal.toString()); + } + }; + const salvarEstagio = async () => { + const matchH = totalHorasDiarias.match(/(\d+)h/); + const matchM = totalHorasDiarias.match(/(\d+)m/); + const horasD = (matchH ? parseInt(matchH[1]) : 0) + (matchM ? parseInt(matchM[1]) / 60 : 0); + + if (horasD <= 0) { + return showCustomAlert("Erro de Horário", "O aluno tem de ter um horário válido (maior que 0 horas).", 'error'); + } + + if (horasD > 8) { + return showCustomAlert( + "Limite Legal Excedido", + "Por lei, um aluno em Formação em Contexto de Trabalho (FCT) não pode exceder as 8 horas diárias.", + 'error' + ); + } + + if (dataFim && dataInicio > dataFim) { + return showCustomAlert("Datas Inválidas", "A data de fim do estágio não pode ser anterior à data de início.", 'error'); + } + + const totais = parseInt(horasTotaisEstagio); + if (!totais || totais <= 0) { + return showCustomAlert("Horas Totais", "Indica o número total de horas que o aluno tem de cumprir (Ex: 400).", 'warning'); + } + setLoading(true); try { const { data: { user } } = await supabase.auth.getUser(); + if (empresaSelecionada) { await supabase.from('empresas').update({ tutor_nome: empresaSelecionada.tutor_nome, @@ -145,6 +260,7 @@ export default function Estagios() { data_inicio: dataInicio || new Date().toISOString().split('T')[0], data_fim: dataFim || null, horas_diarias: totalHorasDiarias, + horas_totais: totais, estado: 'Ativo', }; @@ -167,7 +283,11 @@ export default function Estagios() { handleFecharModal(); fetchDados(); - } catch (error: any) { Alert.alert("Erro", error.message); } finally { setLoading(false); } + } catch (error: any) { + showCustomAlert("Erro ao Guardar", error.message, 'error'); + } finally { + setLoading(false); + } }; const confirmarEliminacao = async () => { @@ -180,7 +300,7 @@ export default function Estagios() { const handleFecharModal = () => { setModalVisible(false); setEditandoEstagio(null); setAlunoSelecionado(null); setEmpresaSelecionada(null); - setDataInicio(''); setDataFim(''); setHManhaIni(''); setHManhaFim(''); setHTardeIni(''); setHTardeFim(''); + setDataInicio(''); setDataFim(''); setHorasTotaisEstagio(''); setHManhaIni(''); setHManhaFim(''); setHTardeIni(''); setHTardeFim(''); setPasso(1); }; @@ -199,7 +319,6 @@ export default function Estagios() { - {/* HEADER EPVC */} router.back()}> @@ -214,7 +333,6 @@ export default function Estagios() { - {/* SEARCH MODERNO */} a.id === e.aluno_id) || null); setEmpresaSelecionada(empresas.find(emp => emp.id === e.empresa_id) || null); - setDataInicio(e.data_inicio || ''); setDataFim(e.data_fim || ''); + setDataInicio(e.data_inicio || ''); + setDataFim(e.data_fim || ''); + setHorasTotaisEstagio(e.horas_totais?.toString() || ''); carregarHorariosEdicao(e.id); setPasso(2); setModalVisible(true); }} @@ -264,9 +384,12 @@ export default function Estagios() { {e.empresas?.nome} - - - {e.horas_diarias} + + + + {e.horas_diarias} + + {e.horas_totais || 0}h Totais @@ -275,7 +398,6 @@ export default function Estagios() { ))} - {/* FAB EPVC */} { setPasso(1); setModalVisible(true); }} @@ -358,10 +480,39 @@ export default function Estagios() { ) : ( - Cronograma + Cronograma e Horas + - DATA INÍCIO - DATA FIM (PREV.) + + DATA INÍCIO + + + + + DATA FIM (PREV.) + calcularMagia('dataFim')} hitSlop={{top: 10, bottom: 10, left: 10, right: 10}}> + + + + + + + + + + HORAS TOTAIS DO ESTÁGIO + calcularMagia('horasTotais')} style={{flexDirection: 'row', alignItems: 'center', gap: 4}} hitSlop={{top: 10, bottom: 10, left: 10, right: 10}}> + + AUTO-CALCULAR + + + @@ -372,13 +523,41 @@ export default function Estagios() { Manhã - - + setHManhaIni(aplicarMascaraHora(txt))} + keyboardType="numeric" + maxLength={5} + placeholder="09:00" + /> + setHManhaFim(aplicarMascaraHora(txt))} + keyboardType="numeric" + maxLength={5} + placeholder="13:00" + /> Tarde - - + setHTardeIni(aplicarMascaraHora(txt))} + keyboardType="numeric" + maxLength={5} + placeholder="14:00" + /> + setHTardeFim(aplicarMascaraHora(txt))} + keyboardType="numeric" + maxLength={5} + placeholder="18:00" + /> @@ -401,7 +580,7 @@ export default function Estagios() { onPress={() => { if(passo === 1) { if(alunoSelecionado && empresaSelecionada) setPasso(2); - else Alert.alert("Atenção", "Selecione o aluno e a empresa."); + else showCustomAlert("Atenção", "Selecione o aluno e a empresa antes de avançar."); } else salvarEstagio(); }} style={[styles.btnModalPri, { backgroundColor: cores.azul }]} @@ -413,12 +592,52 @@ export default function Estagios() { - {/* DELETE MODAL */} + {/* CUSTOM ALERT MODAL (CENTRADINHO E IGUAL À FOTO) */} + setCustomAlert({ ...customAlert, visible: false })} + > + + + + {/* Botão de fechar (X vermelho pequeno) centrado no topo */} + setCustomAlert({ ...customAlert, visible: false })} + style={[styles.alertIconBg, { backgroundColor: customAlert.tipo === 'error' ? cores.vermelho : cores.laranja }]} + > + + + + + {customAlert.title.replace(' ⚖️', '')} + + + {/* Mostra a balança apenas se for o erro do Limite Legal */} + {customAlert.title.includes('Excedido') && ( + ⚖️ + )} + + + {customAlert.msg} + + + + + + + {/* DELETE MODAL ORIGINAL */} - - + + Cancelar Estágio? @@ -435,6 +654,7 @@ export default function Estagios() { + ); } @@ -462,7 +682,7 @@ const styles = StyleSheet.create({ timeText: { fontSize: 11, fontWeight: '900' }, fab: { position: 'absolute', right: 24, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 22, paddingVertical: 16, borderRadius: 22, elevation: 8, shadowColor: '#E38E00', shadowOpacity: 0.3, shadowRadius: 10 }, fabText: { color: '#fff', fontSize: 15, fontWeight: '900', marginLeft: 10, textTransform: 'uppercase', letterSpacing: 0.5 }, - modalOverlay: { flex: 1, justifyContent: 'flex-end' }, + modalOverlay: { flex: 1, justifyContent: 'flex-end', alignItems: 'center' }, modalContent: { borderTopLeftRadius: 45, borderTopRightRadius: 45, padding: 28, height: '92%', width: '100%' }, modalIndicator: { width: 45, height: 5, backgroundColor: 'rgba(0,0,0,0.1)', borderRadius: 10, alignSelf: 'center', marginBottom: 20 }, modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 25 }, @@ -470,7 +690,7 @@ const styles = StyleSheet.create({ modalSub: { fontSize: 12, fontWeight: '800', textTransform: 'uppercase', marginTop: 2 }, closeBtn: { width: 40, height: 40, borderRadius: 14, justifyContent: 'center', alignItems: 'center' }, inputLabel: { fontSize: 11, fontWeight: '900', textTransform: 'uppercase', color: '#64748B', marginBottom: 10, marginLeft: 5, letterSpacing: 0.5 }, - pickerContainer: { borderWidth: 1.5, borderRadius: 24, overflow: 'hidden' }, + pickerContainer: { borderWidth: 1.5, borderRadius: 24, overflow: 'hidden', width: '100%' }, groupHeader: { paddingVertical: 10, paddingHorizontal: 18 }, groupHeaderText: { fontSize: 10, fontWeight: '900', textTransform: 'uppercase' }, pickerItem: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 18, borderBottomWidth: 1, borderColor: 'rgba(0,0,0,0.03)' }, @@ -488,7 +708,7 @@ const styles = StyleSheet.create({ modalFooter: { flexDirection: 'row', gap: 15, marginTop: 15 }, btnModalPri: { flex: 2, height: 62, borderRadius: 22, justifyContent: 'center', alignItems: 'center' }, btnModalSec: { flex: 1, height: 62, borderRadius: 22, justifyContent: 'center', alignItems: 'center', borderWidth: 1.5 }, - deleteCard: { width: '85%', borderRadius: 40, padding: 30, alignItems: 'center', borderWidth: 1, alignSelf: 'center' }, + deleteCard: { width: '85%', borderRadius: 40, padding: 30, alignItems: 'center', borderWidth: 1, alignSelf: 'center', marginBottom: 30 }, deleteIconBg: { width: 80, height: 80, borderRadius: 25, justifyContent: 'center', alignItems: 'center', marginBottom: 20 }, deleteTitle: { fontSize: 22, fontWeight: '900', marginBottom: 12 }, deleteSubtitle: { fontSize: 15, textAlign: 'center', lineHeight: 22, marginBottom: 30 }, @@ -496,4 +716,47 @@ const styles = StyleSheet.create({ deleteBtnCancel: { flex: 1, height: 55, borderRadius: 18, justifyContent: 'center', alignItems: 'center' }, deleteBtnConfirm: { flex: 1, height: 55, borderRadius: 18, justifyContent: 'center', alignItems: 'center' }, deleteBtnText: { fontSize: 14, fontWeight: '900' }, + // --- ESTILOS ESPECÍFICOS PARA O AVISO CENTRADO --- + alertOverlay: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(26, 54, 93, 0.8)', + paddingHorizontal: 20 + }, + alertCard: { + width: '85%', + borderRadius: 30, + padding: 30, + alignItems: 'center', + borderWidth: 1, + elevation: 10, + shadowColor: '#000', + shadowOpacity: 0.15, + shadowRadius: 10, + }, + alertIconBg: { + width: 36, + height: 36, + borderRadius: 18, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 15 + }, + alertTitle: { + fontSize: 20, + fontWeight: '900', + textAlign: 'center', + marginBottom: 5 + }, + alertEmoji: { + fontSize: 28, + marginBottom: 10 + }, + alertSubtitle: { + fontSize: 14, + textAlign: 'center', + lineHeight: 22, + fontWeight: '500' + }, }); \ No newline at end of file