From aacd0ecf1890b93420b459ba449b75eddfc7b3c3 Mon Sep 17 00:00:00 2001 From: Ricardo Gomes <230413@epvc.pt> Date: Thu, 30 Apr 2026 10:49:08 +0100 Subject: [PATCH] =?UTF-8?q?cria=C3=A7=C3=A3o=20da=20empresa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Aluno/AlunoHome.tsx | 119 ++++++----- app/Aluno/perfil.tsx | 96 ++++----- app/Empresas/EmpresaHome.tsx | 274 +++++++++++++++++++++++++ app/Professor/Alunos/CriarAluno.tsx | 113 ++++++---- app/Professor/Alunos/DetalhesAluno.tsx | 112 +++++++--- 5 files changed, 537 insertions(+), 177 deletions(-) create mode 100644 app/Empresas/EmpresaHome.tsx diff --git a/app/Aluno/AlunoHome.tsx b/app/Aluno/AlunoHome.tsx index 7b8b80c..5804160 100644 --- a/app/Aluno/AlunoHome.tsx +++ b/app/Aluno/AlunoHome.tsx @@ -56,7 +56,12 @@ const AlunoHome = memo(() => { const [selectedDate, setSelectedDate] = useState(hojeStr); const [estagioDetalhes, setEstagioDetalhes] = useState(null); const [horariosEstagio, setHorariosEstagio] = useState([]); - const [presencas, setPresencas] = useState>({}); + + // 🟢 NOVOS ESTADOS PARA REFLETIR A DECISÃO DO TUTOR + const [presencasPendentes, setPresencasPendentes] = useState>({}); + const [presencasAprovadas, setPresencasAprovadas] = useState>({}); + const [presencasRejeitadas, setPresencasRejeitadas] = useState>({}); + const [faltas, setFaltas] = useState>({}); const [sumarios, setSumarios] = useState>({}); const [urlsJustificacao, setUrlsJustificacao] = useState>({}); @@ -137,13 +142,24 @@ const AlunoHome = memo(() => { if (error) throw error; - const p: any = {}, f: any = {}, s: any = {}, u: any = {}; + // 🟢 SEPARAMOS AS PRESENÇAS PELO ESTADO DO TUTOR + const pPendente: any = {}, pAprovada: any = {}, pRejeitada: any = {}; + const f: any = {}, s: any = {}, u: any = {}; + let countJustificadas = 0; let countInjustificadas = 0; + let countPresencasAprovadas = 0; data?.forEach(item => { if (item.estado === 'presente') { - p[item.data] = true; + // Guarda o estado para o calendário + if (item.estado_tutor === 'pendente') pPendente[item.data] = true; + else if (item.estado_tutor === 'aprovado') { + pAprovada[item.data] = true; + countPresencasAprovadas++; // Só soma se o tutor aprovou + } + else if (item.estado_tutor === 'rejeitado') pRejeitada[item.data] = true; + s[item.data] = item.sumario || ''; } else { f[item.data] = true; @@ -153,10 +169,16 @@ const AlunoHome = memo(() => { } }); - setPresencas(p); setFaltas(f); setSumarios(s); setUrlsJustificacao(u); - setStatsFaltas({ justificadas: countJustificadas, injustificadas: countInjustificadas, totalPresencas: Object.keys(p).length }); + setPresencasPendentes(pPendente); + setPresencasAprovadas(pAprovada); + setPresencasRejeitadas(pRejeitada); + setFaltas(f); + setSumarios(s); + setUrlsJustificacao(u); + setStatsFaltas({ justificadas: countJustificadas, injustificadas: countInjustificadas, totalPresencas: countPresencasAprovadas }); } else { - setPresencas({}); setFaltas({}); setSumarios({}); setUrlsJustificacao({}); + setPresencasPendentes({}); setPresencasAprovadas({}); setPresencasRejeitadas({}); + setFaltas({}); setSumarios({}); setUrlsJustificacao({}); setStatsFaltas({ justificadas: 0, injustificadas: 0, totalPresencas: 0 }); } @@ -219,6 +241,11 @@ const AlunoHome = memo(() => { }; }, [selectedDate, estagioDetalhes, hojeStr, feriadosMap, statusEstagio]); + // 🟢 FUNÇÃO AUXILIAR PARA SABER SE O DIA JÁ TEM REGISTO (PARA DESATIVAR BOTÕES) + const isDiaMarcado = () => { + return !!presencasAprovadas[selectedDate] || !!presencasPendentes[selectedDate] || !!presencasRejeitadas[selectedDate] || !!faltas[selectedDate]; + }; + const handlePresencaClick = async () => { 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"); @@ -240,9 +267,14 @@ const AlunoHome = memo(() => { const loc = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }); const { data: { user } } = await supabase.auth.getUser(); await supabase.from('presencas').upsert({ - aluno_id: user?.id, data: selectedDate, estado: 'presente', lat: loc.coords.latitude, lng: loc.coords.longitude + aluno_id: user?.id, + data: selectedDate, + estado: 'presente', + lat: loc.coords.latitude, + lng: loc.coords.longitude, + estado_tutor: 'pendente' // 🟢 NOVO REGISTO ENTRA COMO PENDENTE }); - showAlert("Presença marcada!", "success"); + showAlert("Presença marcada! A aguardar aprovação.", "success"); } catch (e: any) { showAlert(e.message, "error"); } finally { setIsLocating(false); } }; @@ -299,6 +331,20 @@ const AlunoHome = memo(() => { const horasConcluidas = estagioDetalhes?.horas_concluidas || 0; const horasEmFalta = Math.max(0, horasTotais - horasConcluidas); + // 🟢 FUNÇÃO PARA MOSTRAR AVISO DE ESTADO DO DIA + const renderAvisoEstadoDia = () => { + if (presencasAprovadas[selectedDate]) { + return ✅ Horas validadas pela empresa; + } + if (presencasPendentes[selectedDate]) { + return ⏳ A aguardar validação do tutor; + } + if (presencasRejeitadas[selectedDate]) { + return ❌ O tutor rejeitou este registo. Corrige o sumário.; + } + return null; + }; + return ( @@ -456,7 +502,7 @@ const AlunoHome = memo(() => { - PRESENÇAS + APROVADAS {statsFaltas.totalPresencas} @@ -552,16 +598,16 @@ const AlunoHome = memo(() => { {isLocating ? : Marcar Presença} Marcar Falta @@ -576,7 +622,10 @@ const AlunoHome = memo(() => { }} markedDates={{ ...Object.keys(feriadosMap).reduce((acc, d) => ({ ...acc, [d]: { marked: true, dotColor: azulPetroleo } }), {}), - ...Object.keys(presencas).reduce((acc, d) => ({ ...acc, [d]: { marked: true, dotColor: themeStyles.verde } }), {}), + // 🟢 AS CORES AGORA REFLETEM O ESTADO DO TUTOR + ...Object.keys(presencasAprovadas).reduce((acc, d) => ({ ...acc, [d]: { marked: true, dotColor: themeStyles.verde } }), {}), + ...Object.keys(presencasPendentes).reduce((acc, d) => ({ ...acc, [d]: { marked: true, dotColor: themeStyles.laranja } }), {}), + ...Object.keys(presencasRejeitadas).reduce((acc, d) => ({ ...acc, [d]: { marked: true, dotColor: themeStyles.vermelho } }), {}), ...Object.keys(faltas).reduce((acc, d) => ({ ...acc, [d]: { marked: true, dotColor: themeStyles.vermelho } }), {}), [selectedDate]: { selected: true, selectedColor: azulPetroleo } }} @@ -588,7 +637,11 @@ const AlunoHome = memo(() => { 🎉 {infoData.nomeFeriado} )} - {presencas[selectedDate] && ( + {/* 🟢 MOSTRA O AVISO DO ESTADO DO TUTOR */} + {renderAvisoEstadoDia()} + + {/* SE O ALUNO ESTIVER PRESENTE (Pendente ou Aprovado), MOSTRA O SUMÁRIO */} + {(presencasPendentes[selectedDate] || presencasAprovadas[selectedDate] || presencasRejeitadas[selectedDate]) && ( Sumário @@ -639,38 +692,11 @@ const styles = StyleSheet.create({ 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' - }, + quickActionsContainer: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20, borderRadius: 20, padding: 6 }, + quickActionBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 12, borderRadius: 16, gap: 6, elevation: 0, shadowOpacity: 0, borderWidth: 0 }, + quickActionBtnActive: { 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' }, @@ -685,7 +711,6 @@ const styles = StyleSheet.create({ 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' }, diff --git a/app/Aluno/perfil.tsx b/app/Aluno/perfil.tsx index 4019c38..77d18fc 100644 --- a/app/Aluno/perfil.tsx +++ b/app/Aluno/perfil.tsx @@ -1,11 +1,13 @@ import { Ionicons } from '@expo/vector-icons'; +import { useFocusEffect } from '@react-navigation/native'; import { useRouter } from 'expo-router'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { ActivityIndicator, Animated, KeyboardAvoidingView, Platform, + RefreshControl, ScrollView, StatusBar, StyleSheet, @@ -24,14 +26,13 @@ export default function PerfilAluno() { const insets = useSafeAreaInsets(); const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [isEditing, setIsEditing] = useState(false); const [perfil, setPerfil] = useState(null); const [saving, setSaving] = useState(false); - // --- 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), []); const cores = useMemo(() => ({ @@ -47,7 +48,6 @@ export default function PerfilAluno() { verde: '#10B981', }), [isDarkMode]); - // --- FUNÇÃO DE MOSTRAR O ALERTA --- const showAlert = useCallback((msg: string, type: 'success' | 'error') => { setAlertConfig({ msg, type }); Animated.sequence([ @@ -57,7 +57,6 @@ export default function PerfilAluno() { ]).start(() => setAlertConfig(null)); }, [alertOpacity]); - // --- FUNÇÕES DE DATA --- const formatarDataParaUI = (dataDB: string) => { if (!dataDB) return ''; const parts = dataDB.split('-'); @@ -105,26 +104,22 @@ export default function PerfilAluno() { return formatted; }; - const buscarDados = async () => { + const buscarDados = async (isRefreshing = false) => { try { - setLoading(true); + if (!isRefreshing) setLoading(true); const { data: { user } } = await supabase.auth.getUser(); if (!user) return; + // 🟢 CORREÇÃO: Vamos buscar as DUAS tabelas sempre usando o ID do utilizador (infalível) const { data: pData } = await supabase.from('profiles').select('*').eq('id', user.id).single(); - - let aData = {}; - if (pData?.n_escola) { - const { data: alunoRes } = await supabase.from('alunos').select('*').eq('n_escola', pData.n_escola).single(); - if (alunoRes) aData = alunoRes; - } + const { data: aData } = await supabase.from('alunos').select('*').eq('id', user.id).maybeSingle(); const dataFormatadaUI = formatarDataParaUI(pData?.data_nascimento); const idadeCalculada = dataFormatadaUI ? calcularIdade(dataFormatadaUI) : pData?.idade; setPerfil({ - ...aData, ...pData, + ...aData, // A tabela de alunos (onde está o curso) sobrepõe e preenche o que falta email: user.email, data_nascimento: dataFormatadaUI, idade: idadeCalculada ?? 'N/A' @@ -135,10 +130,20 @@ export default function PerfilAluno() { console.error(err); } finally { setLoading(false); + setRefreshing(false); } }; - useEffect(() => { buscarDados(); }, []); + useFocusEffect( + useCallback(() => { + buscarDados(); + }, []) + ); + + const onRefresh = useCallback(() => { + setRefreshing(true); + buscarDados(true); + }, []); const salvarDados = async () => { try { @@ -148,24 +153,27 @@ export default function PerfilAluno() { const dataProntaParaDB = formatarDataParaDB(perfil.data_nascimento); - const { error, data } = await supabase.from('profiles').update({ + // 1. Atualizar a tabela de Perfis Pessoais + const { error: errorProfile } = await supabase.from('profiles').update({ nome: perfil.nome, residencia: perfil.residencia, data_nascimento: dataProntaParaDB, idade: perfil.idade !== 'N/A' ? Number(perfil.idade) : null, telefone: perfil.telefone - }).eq('id', user.id).select(); + }).eq('id', user.id); - if (error) throw error; - - if (!data || data.length === 0) { - throw new Error("Erro nas permissões. Confirma as tuas políticas no Supabase."); - } + if (errorProfile) throw errorProfile; + + // 2. Atualizar a tabela de Alunos (só o nome, para manter tudo sincronizado) + // Não lançamos erro aqui para não bloquear o utilizador se ele não tiver permissões RLS no momento + await supabase.from('alunos').update({ nome: perfil.nome }).eq('id', user.id); setIsEditing(false); - // 🔥 O NOSSO NOVO ALERTA MODERNO EM ACÇÃO 🔥 showAlert("Perfil guardado com sucesso!", "success"); - await buscarDados(); + + // OBRIGA O ECRÃ A ATUALIZAR OS DADOS VISUAIS FRESQUINHOS + await buscarDados(); + } catch (e: any) { showAlert(e.message || "Erro ao guardar alterações.", "error"); } finally { @@ -173,13 +181,12 @@ export default function PerfilAluno() { } }; - if (loading) return ; + if (loading && !refreshing) return ; return ( - {/* 🟢 COMPONENTE DE ALERTA FLUTUANTE 🟢 */} {alertConfig && ( - + } + > - {perfil?.nome?.charAt(0).toUpperCase()} + {perfil?.nome?.charAt(0)?.toUpperCase() || '?'} - + @@ -313,31 +324,8 @@ 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 - }, - + 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 }, diff --git a/app/Empresas/EmpresaHome.tsx b/app/Empresas/EmpresaHome.tsx new file mode 100644 index 0000000..7ba0174 --- /dev/null +++ b/app/Empresas/EmpresaHome.tsx @@ -0,0 +1,274 @@ +import { Ionicons } from '@expo/vector-icons'; +import { useFocusEffect } from '@react-navigation/native'; +import { useRouter } from 'expo-router'; +import { useCallback, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Alert, + Platform, + RefreshControl, + SafeAreaView, + ScrollView, + StatusBar, + StyleSheet, + Text, + TouchableOpacity, + View +} from 'react-native'; +import { supabase } from '../../lib/supabase'; +import { useTheme } from '../../themecontext'; + +export default function EmpresaHome() { + const { isDarkMode } = useTheme(); + const router = useRouter(); + + const [pendentes, setPendentes] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [empresaNome, setEmpresaNome] = useState(''); + + const themeStyles = useMemo(() => ({ + fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC', + card: isDarkMode ? '#1A1A1A' : '#FFFFFF', + texto: isDarkMode ? '#F8FAFC' : '#1E293B', + textoSecundario: isDarkMode ? '#94A3B8' : '#64748B', + borda: isDarkMode ? '#2D2D2D' : '#E2E8F0', + azul: '#2390a6', + laranja: '#dd8707', + verde: '#10B981', + vermelho: '#EF4444', + vermelhoSuave: isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(239, 68, 68, 0.1)', + inputFundo: isDarkMode ? '#252525' : '#F1F5F9', + }), [isDarkMode]); + + const fetchValidaçõesPendentes = async (isManualRefresh = false) => { + if (!isManualRefresh) setLoading(true); + try { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return; + + // 1. Identificar quem é a empresa que tem o login feito + const { data: empresa } = await supabase + .from('empresas') + .select('id, nome') + .eq('user_id', user.id) + .single(); + + if (!empresa) { + setPendentes([]); + return; + } + setEmpresaNome(empresa.nome); + + // 2. Buscar todos os estágios ligados a esta empresa + const { data: estagios } = await supabase + .from('estagios') + .select('aluno_id') + .eq('empresa_id', empresa.id); + + if (!estagios || estagios.length === 0) { + setPendentes([]); + return; + } + + const alunoIds = estagios.map(e => e.aluno_id); + + // 3. Buscar os nomes dos alunos (para o tutor saber quem está a avaliar) + const { data: alunos } = await supabase + .from('alunos') + .select('id, nome') + .in('id', alunoIds); + + const mapaAlunos: Record = {}; + alunos?.forEach(a => { mapaAlunos[a.id] = a.nome; }); + + // 4. Buscar apenas as presenças que estão PENDENTES para estes alunos + const { data: presencas } = await supabase + .from('presencas') + .select('*') + .in('aluno_id', alunoIds) + .eq('estado', 'presente') + .eq('estado_tutor', 'pendente') + .order('data', { ascending: false }); + + const listaFormatada = presencas?.map(p => ({ + ...p, + aluno_nome: mapaAlunos[p.aluno_id] || 'Aluno Desconhecido' + })) || []; + + setPendentes(listaFormatada); + } catch (error) { + console.error(error); + Alert.alert("Erro", "Falha ao carregar as validações pendentes."); + } finally { + if (!isManualRefresh) setLoading(false); + setRefreshing(false); + } + }; + + // Atualiza sempre que o ecrã ganha foco + useFocusEffect(useCallback(() => { fetchValidaçõesPendentes(); }, [])); + + const onRefresh = useCallback(() => { + setRefreshing(true); + fetchValidaçõesPendentes(true); + }, []); + + // 🟢 FUNÇÃO PARA APROVAR OU REJEITAR + const lidarComPresenca = async (aluno_id: string, data: string, decisao: 'aprovado' | 'rejeitado') => { + try { + const { error } = await supabase + .from('presencas') + .update({ estado_tutor: decisao }) + .match({ aluno_id, data }); // Dá match exato ao aluno e àquele dia + + if (error) throw error; + + // Avisa visualmente do sucesso e limpa aquele cartão da lista + if (decisao === 'aprovado') { + Alert.alert("✅ Validado", "Horas e sumário aprovados com sucesso!"); + } else { + Alert.alert("❌ Rejeitado", "O registo foi devolvido ao aluno para correção."); + } + + // Atualiza a lista removendo o que acabou de ser processado + setPendentes(prev => prev.filter(p => !(p.aluno_id === aluno_id && p.data === data))); + + } catch (e: any) { + Alert.alert("Erro ao validar", e.message); + } + }; + + // Formatar data (AAAA-MM-DD -> DD/MM/AAAA) + const formatarData = (dataStr: string) => { + if (!dataStr) return ''; + const parts = dataStr.split('-'); + if (parts.length !== 3) return dataStr; + return `${parts[2]}/${parts[1]}/${parts[0]}`; + }; + + return ( + + + + + + Painel da Entidade + {empresaNome || 'A carregar...'} + + supabase.auth.signOut().then(() => router.replace('/'))}> + + + + + + Validações Pendentes + + {pendentes.length} + + + + {loading && !refreshing ? ( + + + + ) : ( + } + > + {pendentes.length === 0 ? ( + + + Tudo em dia! + + Não tens sumários ou presenças de alunos a aguardar a tua validação neste momento. + + + ) : ( + pendentes.map((item, index) => ( + + + + + + {item.aluno_nome} + + + {formatarData(item.data)} + + + + POR VALIDAR + + + + + Sumário Submetido: + + {item.sumario ? item.sumario : "O aluno não escreveu sumário para este dia."} + + + + + lidarComPresenca(item.aluno_id, item.data, 'rejeitado')} + > + + Rejeitar + + + lidarComPresenca(item.aluno_id, item.data, 'aprovado')} + > + + Aprovar + + + + + )) + )} + + )} + + ); +} + +const styles = StyleSheet.create({ + safeArea: { flex: 1, paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0 }, + centerBox: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 20, paddingTop: 20, paddingBottom: 10 }, + greeting: { fontSize: 13, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 1 }, + title: { fontSize: 24, fontWeight: '900', marginTop: 2 }, + logoutBtn: { width: 45, height: 45, borderRadius: 14, borderWidth: 1, justifyContent: 'center', alignItems: 'center' }, + + headerTitleContainer: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 20, marginBottom: 15, gap: 10 }, + sectionTitle: { fontSize: 18, fontWeight: '800' }, + badge: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12 }, + badgeText: { fontSize: 12, fontWeight: '900' }, + + scroll: { paddingHorizontal: 20, paddingBottom: 40 }, + + emptyBox: { alignItems: 'center', padding: 40, borderRadius: 24, borderWidth: 1, borderStyle: 'dashed', marginTop: 30 }, + emptyTitle: { fontSize: 20, fontWeight: '900', marginBottom: 8 }, + emptyDesc: { fontSize: 14, textAlign: 'center', lineHeight: 22, fontWeight: '500' }, + + card: { padding: 20, borderRadius: 24, borderWidth: 1, marginBottom: 20, elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.05, shadowRadius: 8 }, + cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 15 }, + alunoName: { fontSize: 17, fontWeight: '900', marginBottom: 4 }, + dataText: { fontSize: 13, fontWeight: '800' }, + statusTag: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6 }, + statusTagText: { fontSize: 9, fontWeight: '900', letterSpacing: 0.5 }, + + sumarioBox: { padding: 15, borderRadius: 16, marginBottom: 15 }, + sumarioLabel: { fontSize: 10, fontWeight: '800', textTransform: 'uppercase', marginBottom: 6 }, + sumarioText: { fontSize: 14, fontWeight: '600', lineHeight: 20 }, + + actionRow: { flexDirection: 'row', gap: 12 }, + btnAction: { flex: 1, flexDirection: 'row', height: 48, borderRadius: 14, justifyContent: 'center', alignItems: 'center', gap: 6 }, + btnActionText: { fontSize: 14, fontWeight: '800' } +}); \ No newline at end of file diff --git a/app/Professor/Alunos/CriarAluno.tsx b/app/Professor/Alunos/CriarAluno.tsx index f0e8e38..68b1d64 100644 --- a/app/Professor/Alunos/CriarAluno.tsx +++ b/app/Professor/Alunos/CriarAluno.tsx @@ -18,11 +18,39 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { supabase } from '../../../lib/supabase'; import { useTheme } from '../../../themecontext'; -// Função para calcular idade automaticamente -const calcularIdade = (data: string): string => { - if (!data || data.length < 10) return ''; +// --- FUNÇÕES DE DATA --- +const formatarDataParaDB = (dataUI: string) => { + if (!dataUI || dataUI.length !== 10) return null; + const parts = dataUI.split('-'); + if (parts.length !== 3) return null; + return `${parts[2]}-${parts[1]}-${parts[0]}`; +}; + +const aplicarMascaraData = (text: string) => { + const cleaned = text.replace(/\D/g, ''); + let formatted = cleaned; + if (cleaned.length > 2 && cleaned.length <= 4) { + formatted = `${cleaned.slice(0, 2)}-${cleaned.slice(2)}`; + } else if (cleaned.length > 4) { + formatted = `${cleaned.slice(0, 2)}-${cleaned.slice(2, 4)}-${cleaned.slice(4, 8)}`; + } + return formatted; +}; + +const calcularIdade = (dataPT: string): string => { + if (!dataPT || dataPT.length !== 10) return ''; + const parts = dataPT.split('-'); + const dia = parseInt(parts[0], 10); + const mes = parseInt(parts[1], 10); + const ano = parseInt(parts[2], 10); + + if (ano < 1950 || ano > new Date().getFullYear()) return ''; + const hoje = new Date(); - const nascimento = new Date(data); + const nascimento = new Date(ano, mes - 1, dia); + + if (nascimento.getDate() !== dia) return ''; + let idade = hoje.getFullYear() - nascimento.getFullYear(); const m = hoje.getMonth() - nascimento.getMonth(); if (m < 0 || (m === 0 && hoje.getDate() < nascimento.getDate())) idade--; @@ -39,27 +67,27 @@ const CriarAluno = () => { const [password, setPassword] = useState(''); const [tipo, setTipo] = useState<'aluno' | 'professor' | 'empresa'>('aluno'); - // ESTADOS DE PERFIL (Comuns) + // ESTADOS DE PERFIL (Utilizador / Tutor) const [nome, setNome] = useState(''); const [residencia, setResidencia] = useState(''); const [telefone, setTelefone] = useState(''); - const [dataNascimento, setDataNascimento] = useState(''); // Formato AAAA-MM-DD + const [dataNascimento, setDataNascimento] = useState(''); // Formato DD-MM-AAAA const [idade, setIdade] = useState(''); - // ESTADOS ESPECÍFICOS + // ESTADOS ESPECÍFICOS (Aluno / Professor) const [ano, setAno] = useState(''); const [nEscola, setNEscola] = useState(''); const [curso, setCurso] = useState(''); - const [setor, setSetor] = useState(''); - // CAMPOS PARA EMPRESA (Tutor) - const [tutorNome, setTutorNome] = useState(''); - const [tutorTelefone, setTutorTelefone] = useState(''); + // CAMPOS PARA EMPRESA + const [nomeEmpresa, setNomeEmpresa] = useState(''); + const [nif, setNif] = useState(''); + const [setor, setSetor] = useState(''); - // Atualiza idade sempre que a data de nascimento mudar useEffect(() => { const novaIdade = calcularIdade(dataNascimento); if (novaIdade) setIdade(novaIdade); + else setIdade(''); }, [dataNascimento]); const cores = useMemo(() => ({ @@ -81,12 +109,14 @@ const CriarAluno = () => { return; } + if (tipo === 'empresa' && (!nomeEmpresa || !nif)) { + Alert.alert("Atenção", "Nome da Entidade e NIF são obrigatórios para Empresas."); + return; + } + setLoading(true); try { - // 1. Criar Auth User - // IMPORTANTE: Se o "Confirm Email" estiver ativo no Supabase, - // o signUp não inicia sessão automaticamente. const { data: authData, error: authError } = await supabase.auth.signUp({ email: emailLimpo, password, @@ -105,25 +135,25 @@ const CriarAluno = () => { return; } - // 2. Inserir em PROFILES + const dataFormatadaDB = formatarDataParaDB(dataNascimento); + const { error: profileError } = await supabase .from('profiles') .insert([{ id: user.id, - nome, + nome, // É o nome da pessoa (Aluno, Prof, ou Tutor) email: emailLimpo, residencia, telefone, idade: idade ? parseInt(idade) : null, - data_nascimento: dataNascimento || null, + data_nascimento: dataFormatadaDB, tipo, - n_escola: tipo !== 'professor' ? nEscola : null, - curso: tipo === 'aluno' ? curso : (tipo === 'professor' ? curso : setor) + n_escola: tipo === 'aluno' ? nEscola : null, // Apenas para aluno + curso: tipo === 'aluno' || tipo === 'professor' ? curso : null }]); if (profileError) throw profileError; - // 3. Inserir na tabela específica de ALUNOS if (tipo === 'aluno') { const { error: alunoError } = await supabase .from('alunos') @@ -137,18 +167,18 @@ const CriarAluno = () => { if (alunoError) throw alunoError; } - // 4. Se for EMPRESA if (tipo === 'empresa') { const { error: empresaError } = await supabase .from('empresas') .insert([{ - nome, - nif: nEscola, - setor, - tutor_nome: tutorNome, - tutor_telefone: tutorTelefone, + nome: nomeEmpresa, + nif: nif, + setor: setor, + tutor_nome: nome, // O nome do perfil é o nome do tutor + tutor_telefone: telefone, // O telefone do perfil é o contacto do tutor user_id: user.id }]); + if (empresaError) throw empresaError; } Alert.alert("Sucesso", "Novo registo concluído com sucesso!"); @@ -156,7 +186,6 @@ const CriarAluno = () => { } catch (err: any) { Alert.alert("Erro ao criar conta", err.message); - console.error(err); } finally { setLoading(false); } @@ -176,7 +205,6 @@ const CriarAluno = () => { - {/* SELETOR DE TIPO */} {(['aluno', 'professor', 'empresa'] as const).map((item) => ( { - + setDataNascimento(aplicarMascaraData(t))} placeholder="Nascimento (DD-MM-AAAA)" maxLength={10} keyboardType="numeric" placeholderTextColor={cores.placeholder} /> {idade ? `${idade} anos` : 'Idade'} @@ -224,10 +252,9 @@ const CriarAluno = () => { /> - {/* CAMPOS DINÂMICOS */} {tipo === 'aluno' && ( <> @@ -260,25 +287,21 @@ const CriarAluno = () => { {tipo === 'empresa' && ( <> - + + - - )} diff --git a/app/Professor/Alunos/DetalhesAluno.tsx b/app/Professor/Alunos/DetalhesAluno.tsx index 2bf95a9..505264c 100644 --- a/app/Professor/Alunos/DetalhesAluno.tsx +++ b/app/Professor/Alunos/DetalhesAluno.tsx @@ -19,7 +19,21 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { supabase } from '../../../lib/supabase'; import { useTheme } from '../../../themecontext'; -// --- UTILITÁRIOS --- +// --- UTILITÁRIOS DE DATA --- +const formatarDataParaUI = (dataDB: string) => { + if (!dataDB) return ''; + const parts = dataDB.split('-'); + if (parts.length !== 3) return dataDB; + return `${parts[2]}-${parts[1]}-${parts[0]}`; +}; + +const formatarDataParaDB = (dataUI: string) => { + if (!dataUI || dataUI.length !== 10) return null; + const parts = dataUI.split('-'); + if (parts.length !== 3) return null; + return `${parts[2]}-${parts[1]}-${parts[0]}`; +}; + const calcularIdade = (dataNascimento: string) => { if (!dataNascimento) return null; const hoje = new Date(); @@ -32,6 +46,17 @@ const calcularIdade = (dataNascimento: string) => { return idade; }; +const aplicarMascaraData = (text: string) => { + const cleaned = text.replace(/\D/g, ''); + let formatted = cleaned; + if (cleaned.length > 2 && cleaned.length <= 4) { + formatted = `${cleaned.slice(0, 2)}-${cleaned.slice(2)}`; + } else if (cleaned.length > 4) { + formatted = `${cleaned.slice(0, 2)}-${cleaned.slice(2, 4)}-${cleaned.slice(4, 8)}`; + } + return formatted; +}; + // --- TIPAGENS --- interface AlunoEditForm { nome: string; @@ -56,13 +81,7 @@ const DetalhesAlunos = memo(() => { const [saving, setSaving] = useState(false); const [editForm, setEditForm] = useState({ - nome: '', - n_escola: '', - turma_curso: '', - telefone: '', - residencia: '', - data_nascimento: '', - email: '' + nome: '', n_escola: '', turma_curso: '', telefone: '', residencia: '', data_nascimento: '', email: '' }); const cores = useMemo(() => ({ @@ -118,7 +137,7 @@ const DetalhesAlunos = memo(() => { turma_curso: alunoData.turma_curso || '', telefone: perfilData?.telefone || '', residencia: perfilData?.residencia || '', - data_nascimento: perfilData?.data_nascimento || '', + data_nascimento: formatarDataParaUI(perfilData?.data_nascimento) || '', email: perfilData?.email || '' }); @@ -133,26 +152,36 @@ const DetalhesAlunos = memo(() => { const handleUpdate = async () => { try { setSaving(true); - const { error: err1 } = await supabase.from('alunos').update({ - nome: editForm.nome, - n_escola: editForm.n_escola, - turma_curso: editForm.turma_curso - }).eq('id', alunoId); + + const dataFormatadaDB = formatarDataParaDB(editForm.data_nascimento); + const novaIdade = calcularIdade(dataFormatadaDB || ''); - const { error: err2 } = await supabase.from('profiles').update({ + // Não enviamos o 'n_escola' para garantir que os dados institucionais não são tocados + const { data: d1, error: err1 } = await supabase.from('alunos').update({ + nome: editForm.nome, + turma_curso: editForm.turma_curso + }).eq('id', alunoId).select(); + + // Não enviamos o 'email' pelo mesmo motivo + const { data: d2, error: err2 } = await supabase.from('profiles').update({ telefone: editForm.telefone, residencia: editForm.residencia, - data_nascimento: editForm.data_nascimento, - email: editForm.email - }).eq('id', alunoId); + data_nascimento: dataFormatadaDB, + idade: novaIdade + }).eq('id', alunoId).select(); - if (err1 || err2) throw new Error("Erro na gravação dos dados"); + if (err1) throw err1; + if (err2) throw err2; - Alert.alert("Sucesso", "Dados atualizados!"); + if (!d1 || d1.length === 0 || !d2 || d2.length === 0) { + throw new Error("Permissão Negada: O professor não tem direitos (RLS) para editar este aluno no Supabase."); + } + + Alert.alert("Sucesso", "Dados atualizados com sucesso!"); setModalVisible(false); - fetchAluno(); + fetchAluno(); } catch (err: any) { - Alert.alert("Erro", "Não foi possível guardar. Verifica a ligação."); + Alert.alert("Erro na Gravação", err.message || "Não foi possível guardar. Verifica a ligação."); } finally { setSaving(false); } @@ -200,7 +229,7 @@ const DetalhesAlunos = memo(() => { { INÍCIO - {aluno?.estagio?.data_inicio || '-'} + {formatarDataParaUI(aluno?.estagio?.data_inicio) || '-'} FIM - {aluno?.estagio?.data_fim || '-'} + {formatarDataParaUI(aluno?.estagio?.data_fim) || '-'} @@ -272,11 +301,21 @@ const DetalhesAlunos = memo(() => { setEditForm({...editForm, nome: t})} cores={cores} /> - setEditForm({...editForm, n_escola: t})} cores={cores} keyboard="numeric" /> + + {/* CAMPOS INSTITUCIONAIS BLOQUEADOS (editable={false}) */} + + + setEditForm({...editForm, turma_curso: t})} cores={cores} /> - setEditForm({...editForm, email: t})} cores={cores} keyboard="email-address" /> setEditForm({...editForm, telefone: t})} cores={cores} keyboard="phone-pad" /> - setEditForm({...editForm, data_nascimento: t})} cores={cores} keyboard="numeric" /> + setEditForm({...editForm, data_nascimento: aplicarMascaraData(t)})} + cores={cores} + keyboard="numeric" + maxLength={10} + /> setEditForm({...editForm, residencia: t})} cores={cores} /> @@ -296,20 +335,31 @@ const DetalhesAlunos = memo(() => { interface EditInputProps { label: string; value: string; - onChange: (t: string) => void; + onChange?: (t: string) => void; cores: any; keyboard?: any; + maxLength?: number; + editable?: boolean; } -const EditInput = ({ label, value, onChange, cores, keyboard = "default" }: EditInputProps) => ( +const EditInput = ({ label, value, onChange, cores, keyboard = "default", maxLength, editable = true }: EditInputProps) => ( {label} );