From 3404c0044da56f100c1f3974eb023832d28bc447 Mon Sep 17 00:00:00 2001 From: Ricardo Gomes <230413@epvc.pt> Date: Fri, 13 Mar 2026 17:20:44 +0000 Subject: [PATCH] atualizacoes --- app.json | 15 +- app/Aluno/AlunoHome.tsx | 346 ++++++++----- app/Aluno/perfil.tsx | 357 +++++++++++-- app/Professor/Alunos/CalendarioPresencas.tsx | 203 ++++---- app/Professor/Alunos/Estagios.tsx | 78 +-- app/Professor/Alunos/Faltas.tsx | 505 ++++++++++--------- app/Professor/Alunos/ListaAlunos.tsx | 189 ++++--- app/Professor/Alunos/Presencas.tsx | 237 +++++---- app/Professor/Alunos/Sumarios.tsx | 379 +++++++++----- app/lib/supabase.ts | 33 +- package-lock.json | 19 + package.json | 3 + 12 files changed, 1523 insertions(+), 841 deletions(-) diff --git a/app.json b/app.json index 1e1a6ba..ed4b127 100644 --- a/app.json +++ b/app.json @@ -1,7 +1,7 @@ { "expo": { - "name": "estagios_pap", - "slug": "estagios_pap", + "name": "Estágios PAP", + "slug": "estagios-pap", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/images/icon.png", @@ -10,17 +10,16 @@ "newArchEnabled": true, "ios": { "supportsTablet": true, - "bundleIdentifier": "com.anonymous.estagios-pap" + "bundleIdentifier": "com.teu-nome.estagiospap" }, "android": { + "package": "com.teu_nome.estagiospap", "adaptiveIcon": { "backgroundColor": "#E6F4FE", "foregroundImage": "./assets/images/android-icon-foreground.png", - "backgroundImage": "./assets/images/android-icon-background.png", - "monochromeImage": "./assets/images/android-icon-monochrome.png" + "backgroundImage": "./assets/images/android-icon-background.png" }, - "edgeToEdgeEnabled": true, - "predictiveBackGestureEnabled": false + "edgeToEdgeEnabled": true }, "web": { "output": "static", @@ -46,4 +45,4 @@ "reactCompiler": true } } -} +} \ No newline at end of file diff --git a/app/Aluno/AlunoHome.tsx b/app/Aluno/AlunoHome.tsx index fb389e6..81cecbd 100644 --- a/app/Aluno/AlunoHome.tsx +++ b/app/Aluno/AlunoHome.tsx @@ -2,18 +2,20 @@ import { Ionicons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useFocusEffect } from '@react-navigation/native'; import * as DocumentPicker from 'expo-document-picker'; -import * as Location from 'expo-location'; // <-- Adicionado +// ALTERADO: Importação da API Legacy para evitar o erro de depreciação +import { decode } from 'base64-arraybuffer'; +import * as FileSystem from 'expo-file-system/legacy'; +import * as Location from 'expo-location'; import { useRouter } from 'expo-router'; -import * as Sharing from 'expo-sharing'; import { memo, useCallback, useMemo, useState } from 'react'; import { - ActivityIndicator, Alert, Platform, SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TextInput, TouchableOpacity, View + ActivityIndicator, Alert, Linking, Platform, SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; import { Calendar, LocaleConfig } from 'react-native-calendars'; import { useTheme } from '../../themecontext'; -import { supabase } from '../lib/supabase'; // <-- Garante que tens este arquivo configurado +import { supabase } from '../lib/supabase'; -// Configuração PT (Mantida) +// Configuração PT LocaleConfig.locales['pt'] = { monthNames: ['Janeiro','Fevereiro','Março','Abril','Maio','Junho','Julho','Agosto','Setembro','Outubro','Novembro','Dezembro'], monthNamesShort: ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez'], @@ -23,7 +25,7 @@ LocaleConfig.locales['pt'] = { LocaleConfig.defaultLocale = 'pt'; const getFeriadosMap = (ano: number) => { - const f: Record = { + return { [`${ano}-01-01`]: "Ano Novo", [`${ano}-04-25`]: "Dia da Liberdade", [`${ano}-05-01`]: "Dia do Trabalhador", @@ -36,7 +38,6 @@ const getFeriadosMap = (ano: number) => { [`${ano}-12-08`]: "Imaculada Conceição", [`${ano}-12-25`]: "Natal" }; - return f; }; const AlunoHome = memo(() => { @@ -46,42 +47,82 @@ const AlunoHome = memo(() => { const [selectedDate, setSelectedDate] = useState(hojeStr); const [configEstagio, setConfigEstagio] = useState({ inicio: '2026-01-05', fim: '2026-05-30' }); + const [presencas, setPresencas] = useState>({}); const [faltas, setFaltas] = useState>({}); const [sumarios, setSumarios] = useState>({}); - const [faltasJustificadas, setFaltasJustificadas] = useState>({}); + const [urlsJustificacao, setUrlsJustificacao] = useState>({}); + const [pdf, setPdf] = useState(null); const [editandoSumario, setEditandoSumario] = useState(false); - const [isLocating, setIsLocating] = useState(false); // Novo estado para loading + + const [isLoadingDB, setIsLoadingDB] = useState(true); + const [isLocating, setIsLocating] = useState(false); + const [isUploading, setIsUploading] = useState(false); useFocusEffect( useCallback(() => { - const carregarTudo = async () => { - try { - const [config, pres, falt, sums, just] = await Promise.all([ - AsyncStorage.getItem('@dados_estagio'), - AsyncStorage.getItem('@presencas'), - AsyncStorage.getItem('@faltas'), - AsyncStorage.getItem('@sumarios'), - AsyncStorage.getItem('@justificacoes') - ]); - if (config) setConfigEstagio(JSON.parse(config)); - if (pres) setPresencas(JSON.parse(pres)); - if (falt) setFaltas(JSON.parse(falt)); - if (sums) setSumarios(JSON.parse(sums)); - if (just) setFaltasJustificadas(JSON.parse(just)); - } catch (e) { console.error(e); } - }; - carregarTudo(); + carregarConfigLocal(); + fetchDadosSupabase(); }, []) ); + const carregarConfigLocal = async () => { + try { + const config = await AsyncStorage.getItem('@dados_estagio'); + if (config) setConfigEstagio(JSON.parse(config)); + } catch (e) { console.error(e); } + }; + + const fetchDadosSupabase = async () => { + setIsLoadingDB(true); + try { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return; + + const { data, error } = await supabase + .from('presencas') + .select('data, estado, sumario, justificacao_url') + .eq('aluno_id', user.id); + + if (error) throw error; + + const objPresencas: Record = {}; + const objFaltas: Record = {}; + const objSumarios: Record = {}; + const objUrls: Record = {}; + + data?.forEach(item => { + if (item.estado === 'presente') { + objPresencas[item.data] = true; + if (item.sumario) objSumarios[item.data] = item.sumario; + } else if (item.estado === 'faltou') { + objFaltas[item.data] = true; + if (item.justificacao_url) objUrls[item.data] = item.justificacao_url; + } + }); + + setPresencas(objPresencas); + setFaltas(objFaltas); + setSumarios(objSumarios); + setUrlsJustificacao(objUrls); + + } catch (error: any) { + console.error("Erro na BD:", error.message); + } finally { + setIsLoadingDB(false); + } + }; + const themeStyles = useMemo(() => ({ - fundo: isDarkMode ? '#121212' : '#f1f3f5', - card: isDarkMode ? '#1e1e1e' : '#fff', - texto: isDarkMode ? '#fff' : '#000', - textoSecundario: isDarkMode ? '#adb5bd' : '#6c757d', - borda: isDarkMode ? '#333' : '#ddd', + fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC', + card: isDarkMode ? '#1A1A1A' : '#FFFFFF', + texto: isDarkMode ? '#F8FAFC' : '#1E293B', + textoSecundario: isDarkMode ? '#94A3B8' : '#64748B', + borda: isDarkMode ? '#2D2D2D' : '#E2E8F0', + azul: '#3B82F6', + vermelho: '#EF4444', + verde: '#10B981', }), [isDarkMode]); const feriadosMap = useMemo(() => getFeriadosMap(new Date(selectedDate).getFullYear()), [selectedDate]); @@ -106,39 +147,33 @@ const AlunoHome = memo(() => { const diasMarcados: any = useMemo(() => { const marcacoes: any = {}; - listaFeriados.forEach(d => { marcacoes[d] = { marked: true, dotColor: '#0dcaf0' }; }); + listaFeriados.forEach(d => { marcacoes[d] = { marked: true, dotColor: '#3B82F6' }; }); + Object.keys(presencas).forEach((d) => { const temSumario = sumarios[d] && sumarios[d].trim().length > 0; - marcacoes[d] = { marked: true, dotColor: temSumario ? '#198754' : '#ffc107' }; + marcacoes[d] = { marked: true, dotColor: temSumario ? '#10B981' : '#F59E0B' }; }); - Object.keys(faltas).forEach((d) => { - marcacoes[d] = { marked: true, dotColor: faltasJustificadas[d] ? '#6c757d' : '#dc3545' }; - }); - marcacoes[selectedDate] = { ...(marcacoes[selectedDate] || {}), selected: true, selectedColor: '#0d6efd' }; - return marcacoes; - }, [presencas, faltas, sumarios, faltasJustificadas, selectedDate, listaFeriados]); - - // --- LOGICA DE PRESENÇA COM LOCALIZAÇÃO --- - const handlePresenca = async () => { - if (!infoData.podeMarcarPresenca) return Alert.alert("Bloqueado", "A presença só pode ser marcada no próprio dia."); + Object.keys(faltas).forEach((d) => { + marcacoes[d] = { marked: true, dotColor: urlsJustificacao[d] ? '#64748B' : '#EF4444' }; + }); + + marcacoes[selectedDate] = { ...(marcacoes[selectedDate] || {}), selected: true, selectedColor: '#3B82F6' }; + return marcacoes; + }, [presencas, faltas, sumarios, urlsJustificacao, selectedDate, listaFeriados]); + + const handlePresenca = async () => { + if (!infoData.podeMarcarPresenca) return Alert.alert("Bloqueado", "Data inválida."); setIsLocating(true); try { - // 1. Pedir Permissão - let { status } = await Location.requestForegroundPermissionsAsync(); - if (status !== 'granted') { - Alert.alert('Erro', 'Precisas de aceitar a localização para marcar presença.'); - setIsLocating(false); - return; - } + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== 'granted') throw new Error("Permissão de localização necessária."); - // 2. Obter Localização exata - let location = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.High }); + const location = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.High }); const { latitude, longitude } = location.coords; - // 3. Salvar no Supabase (Para o Professor ver) const { data: { user } } = await supabase.auth.getUser(); - if (!user) throw new Error("Usuário não autenticado."); + if (!user) throw new Error("Não autenticado."); const { error } = await supabase.from('presencas').upsert({ aluno_id: user.id, @@ -149,16 +184,10 @@ const AlunoHome = memo(() => { }); if (error) throw error; - - // 4. Salvar Localmente (Como estava no teu código) - const novas = { ...presencas, [selectedDate]: true }; - setPresencas(novas); - await AsyncStorage.setItem('@presencas', JSON.stringify(novas)); - - Alert.alert("Sucesso", "Presença marcada com localização!"); - } catch (e) { - console.error(e); - Alert.alert("Erro", "Não foi possível salvar a presença. Se estiveres sem net, vai dar merda."); + Alert.alert("Sucesso", "Presença registada!"); + fetchDadosSupabase(); + } catch (e: any) { + Alert.alert("Erro", "Falha ao registar presença."); } finally { setIsLocating(false); } @@ -166,33 +195,30 @@ const AlunoHome = memo(() => { const handleFalta = async () => { if (!infoData.valida) return Alert.alert("Bloqueado", "Data inválida."); - - // Para que o professor veja a falta, também temos de mandar para o Supabase - const { data: { user } } = await supabase.auth.getUser(); - if (user) { - await supabase.from('presencas').upsert({ - aluno_id: user.id, - data: selectedDate, - estado: 'faltou' - }); - } - - const novas = { ...faltas, [selectedDate]: true }; - setFaltas(novas); - await AsyncStorage.setItem('@faltas', JSON.stringify(novas)); + try { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return; + const { error } = await supabase.from('presencas').upsert({ + aluno_id: user.id, + data: selectedDate, + estado: 'faltou' + }); + if (error) throw error; + fetchDadosSupabase(); + } catch (e) { Alert.alert("Erro", "Falha ao registar falta."); } }; const guardarSumario = async () => { - // Enviar sumário para o Supabase também - const { data: { user } } = await supabase.auth.getUser(); - if (user) { - await supabase.from('presencas').update({ sumario: sumarios[selectedDate] }) + try { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return; + const { error } = await supabase.from('presencas').update({ sumario: sumarios[selectedDate] }) .match({ aluno_id: user.id, data: selectedDate }); - } - - await AsyncStorage.setItem('@sumarios', JSON.stringify(sumarios)); - setEditandoSumario(false); - Alert.alert("Sucesso", "Sumário guardado!"); + if (error) throw error; + setEditandoSumario(false); + Alert.alert("Sucesso", "Sumário guardado!"); + fetchDadosSupabase(); + } catch (e) { Alert.alert("Erro", "Falha ao guardar sumário."); } }; const escolherPDF = async () => { @@ -201,21 +227,68 @@ const AlunoHome = memo(() => { }; const enviarJustificacao = async () => { - if (!pdf) return Alert.alert('Aviso', 'Anexe um PDF.'); - const novas = { ...faltasJustificadas, [selectedDate]: pdf }; - setFaltasJustificadas(novas); - await AsyncStorage.setItem('@justificacoes', JSON.stringify(novas)); - setPdf(null); + if (!pdf) return; + setIsUploading(true); + try { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error("Utilizador não autenticado"); + + const fileExt = pdf.name.split('.').pop(); + const fileName = `${user.id}/${selectedDate}_${Date.now()}.${fileExt}`; + + // SOLUÇÃO: Agora a API legacy já permite o readAsStringAsync sem avisos + const base64 = await FileSystem.readAsStringAsync(pdf.uri, { + encoding: 'base64' + }); + + const { error: uploadError } = await supabase.storage + .from('justificacoes') + .upload(fileName, decode(base64), { + contentType: 'application/pdf', + upsert: true + }); + + if (uploadError) throw uploadError; + + const { data: { publicUrl } } = supabase.storage + .from('justificacoes') + .getPublicUrl(fileName); + + const { error: dbError } = await supabase + .from('presencas') + .update({ justificacao_url: publicUrl }) + .match({ aluno_id: user.id, data: selectedDate }); + + if (dbError) throw dbError; + + Alert.alert("Sucesso", "Justificação enviada!"); + setPdf(null); + fetchDadosSupabase(); + + } catch (e: any) { + console.error(e); + Alert.alert("Erro", "Se a net falhar, vai dar merda: " + e.message); + } finally { + setIsUploading(false); + } }; - const visualizarDocumento = async (uri: string) => { - if (await Sharing.isAvailableAsync()) await Sharing.shareAsync(uri); + const visualizarDocumento = async (url: string) => { + try { + const supported = await Linking.canOpenURL(url); + if (supported) { + await Linking.openURL(url); + } + } catch (e) { + Alert.alert("Erro", "Não foi possível abrir o documento."); + } }; return ( - + + router.push('/Aluno/perfil')}> @@ -228,14 +301,14 @@ const AlunoHome = memo(() => { {isLocating ? : Marcar Presença} @@ -243,31 +316,41 @@ const AlunoHome = memo(() => { - + + {isLoadingDB && ( + + + + )} { setSelectedDate(day.dateString); setEditandoSumario(false); }} /> {infoData.nomeFeriado && ( - - - - {selectedDate.endsWith('-06-24') ? "É Feriado em Vila do Conde: São João" : `Feriado: ${infoData.nomeFeriado}`} - + + + {infoData.nomeFeriado} )} {presencas[selectedDate] && ( - + Sumário do Dia {!editandoSumario && ( setEditandoSumario(true)}> - + )} @@ -280,7 +363,7 @@ const AlunoHome = memo(() => { placeholderTextColor={themeStyles.textoSecundario} /> {editandoSumario && ( - + Guardar Sumário )} @@ -288,20 +371,24 @@ const AlunoHome = memo(() => { )} {faltas[selectedDate] && ( - + Justificação de Falta - {faltasJustificadas[selectedDate] ? ( - visualizarDocumento(faltasJustificadas[selectedDate].uri)}> + {urlsJustificacao[selectedDate] ? ( + visualizarDocumento(urlsJustificacao[selectedDate])}> Ver Justificação (PDF) ) : ( - {pdf ? pdf.name : 'Anexar PDF'} + {pdf ? pdf.name : 'Selecionar PDF'} - - Enviar Justificação + + {isUploading ? : Enviar para o Servidor} )} @@ -314,25 +401,24 @@ const AlunoHome = memo(() => { const styles = StyleSheet.create({ safeArea: { flex: 1, paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0 }, - container: { padding: 20 }, + container: { padding: 20, paddingBottom: 40 }, topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }, - title: { fontSize: 20, fontWeight: 'bold' }, + title: { fontSize: 22, fontWeight: '800' }, botoesLinha: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20 }, - btn: { padding: 15, borderRadius: 12, width: '48%', alignItems: 'center' }, - btnPresenca: { backgroundColor: '#0d6efd' }, - btnFalta: { backgroundColor: '#dc3545' }, - btnGuardar: { backgroundColor: '#198754', padding: 12, borderRadius: 10, marginTop: 10, alignItems: 'center' }, - btnAnexar: { borderWidth: 1, padding: 12, borderRadius: 10, marginBottom: 10, alignItems: 'center', borderStyle: 'dashed' }, - btnVer: { backgroundColor: '#6c757d', padding: 12, borderRadius: 10, flexDirection: 'row', justifyContent: 'center', alignItems: 'center' }, - disabled: { opacity: 0.3 }, - txtBtn: { color: '#fff', fontWeight: 'bold' }, - cardCalendar: { borderRadius: 16, elevation: 4, padding: 10, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 10 }, - cardFeriado: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginTop: 15, backgroundColor: 'rgba(13, 202, 240, 0.1)', padding: 10, borderRadius: 10 }, - txtFeriado: { color: '#0dcaf0', fontWeight: 'bold', marginLeft: 8 }, - card: { padding: 16, borderRadius: 16, marginTop: 20, elevation: 2 }, - cardTitulo: { fontSize: 16, fontWeight: 'bold' }, - rowTitle: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }, - input: { borderWidth: 1, borderRadius: 10, padding: 12, height: 100, textAlignVertical: 'top' } + btn: { padding: 16, borderRadius: 16, width: '48%', alignItems: 'center', elevation: 2 }, + btnAcao: { padding: 14, borderRadius: 12, marginTop: 10, alignItems: 'center' }, + btnAnexar: { borderWidth: 1, padding: 14, borderRadius: 12, marginBottom: 10, alignItems: 'center', borderStyle: 'dashed' }, + btnVer: { padding: 14, borderRadius: 12, flexDirection: 'row', justifyContent: 'center', alignItems: 'center' }, + disabled: { opacity: 0.4 }, + txtBtn: { color: '#fff', fontWeight: 'bold', fontSize: 14 }, + cardCalendar: { borderRadius: 24, padding: 10, borderWidth: 1, position: 'relative', overflow: 'hidden' }, + loaderOverlay: { ...StyleSheet.absoluteFillObject, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(255,255,255,0.4)', zIndex: 10 }, + cardFeriado: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginTop: 15, padding: 12, borderRadius: 12 }, + txtFeriado: { fontWeight: '700', marginLeft: 8 }, + card: { padding: 20, borderRadius: 24, marginTop: 20, borderWidth: 1 }, + cardTitulo: { fontSize: 17, fontWeight: '700' }, + rowTitle: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }, + input: { borderWidth: 1, borderRadius: 12, padding: 15, height: 120, textAlignVertical: 'top', fontSize: 15 } }); export default AlunoHome; \ No newline at end of file diff --git a/app/Aluno/perfil.tsx b/app/Aluno/perfil.tsx index d08a8d9..335f548 100644 --- a/app/Aluno/perfil.tsx +++ b/app/Aluno/perfil.tsx @@ -1,6 +1,10 @@ +import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { useEffect, useState } from 'react'; -import { ActivityIndicator, SafeAreaView, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { + ActivityIndicator, Alert, Platform, SafeAreaView, ScrollView, StatusBar, + StyleSheet, Text, TextInput, TouchableOpacity, View +} from 'react-native'; import { useTheme } from '../../themecontext'; import { supabase } from '../lib/supabase'; @@ -8,8 +12,79 @@ export default function PerfilAluno() { const { isDarkMode } = useTheme(); const router = useRouter(); const [loading, setLoading] = useState(true); + const [isEditing, setIsEditing] = useState(false); + const [perfil, setPerfil] = useState(null); const [estagio, setEstagio] = useState(null); + const [contagemPresencas, setContagemPresencas] = useState(0); + const [contagemFaltas, setContagemFaltas] = useState(0); + + // --- FUNÇÕES DE FORMATAÇÃO E CÁLCULO --- + + const formatarParaExibir = (data: string) => { + if (!data) return ''; + const [ano, mes, dia] = data.split('-'); + return `${dia}-${mes}-${ano}`; + }; + + const formatarParaSalvar = (data: string) => { + if (!data || data.length < 10) return null; + const [dia, mes, ano] = data.split('-'); + return `${ano}-${mes}-${dia}`; + }; + + const aplicarMascaraData = (text: string) => { + const cleaned = text.replace(/\D/g, ''); + let formatted = cleaned; + if (cleaned.length > 2) formatted = `${cleaned.slice(0, 2)}-${cleaned.slice(2)}`; + if (cleaned.length > 4) formatted = `${cleaned.slice(0, 2)}-${cleaned.slice(2, 4)}-${cleaned.slice(4, 8)}`; + return formatted; + }; + + // NOVA FUNÇÃO: Calcula horas totais excluindo fins de semana e feriados + const calcularHorasTotaisUteis = (dataInicio: string, dataFim: string) => { + if (!dataInicio || !dataFim) return 400; // Fallback caso a BD esteja vazia + + let inicio = new Date(dataInicio); + let fim = new Date(dataFim); + let diasUteis = 0; + + // Lista de feriados 2026 (Exemplo Portugal) + const feriados = [ + '2026-01-01', '2026-04-03', '2026-04-05', '2026-04-25', + '2026-05-01', '2026-06-04', '2026-06-10', '2026-08-15', + '2026-10-05', '2026-11-01', '2026-12-01', '2026-12-08', '2026-12-25' + ]; + + let dataAtual = new Date(inicio); + while (dataAtual <= fim) { + const diaSemana = dataAtual.getDay(); // 0: Domingo, 6: Sábado + const dataIso = dataAtual.toISOString().split('T')[0]; + + if (diaSemana !== 0 && diaSemana !== 6 && !feriados.includes(dataIso)) { + diasUteis++; + } + dataAtual.setDate(dataAtual.getDate() + 1); + } + return diasUteis * 7; // Multiplicado pelas 7h diárias + }; + + const calcularIdade = (dataExibicao: string) => { + if (!dataExibicao || dataExibicao.length < 10) return '--'; + try { + const [dia, mes, ano] = dataExibicao.split('-').map(Number); + const hoje = new Date(); + const nascimento = new Date(ano, mes - 1, dia); + let idade = hoje.getFullYear() - nascimento.getFullYear(); + const m = hoje.getMonth() - nascimento.getMonth(); + if (m < 0 || (m === 0 && hoje.getDate() < nascimento.getDate())) { + idade--; + } + return isNaN(idade) ? '--' : idade; + } catch { + return '--'; + } + }; useEffect(() => { carregarDados(); @@ -20,18 +95,24 @@ export default function PerfilAluno() { const { data: { user } } = await supabase.auth.getUser(); if (!user) return; - // 1. Dados do Perfil - const { data: prof } = await supabase.from('profiles').select('*').eq('id', user.id).single(); - setPerfil(prof); + const [perfilRes, estagioRes, presencasRes] = await Promise.all([ + supabase.from('profiles').select('*').eq('id', user.id).single(), + supabase.from('estagios').select('*, empresas(*)').eq('aluno_id', user.id).single(), + supabase.from('presencas').select('estado').eq('aluno_id', user.id) + ]); - // 2. Dados do Estágio e Empresa (Relacionados) - const { data: est } = await supabase - .from('estagios') - .select('*, empresas(*)') - .eq('aluno_id', user.id) - .single(); - setEstagio(est); + const dadosPerfil = perfilRes.data; + if (dadosPerfil?.data_nascimento) { + dadosPerfil.data_nascimento = formatarParaExibir(dadosPerfil.data_nascimento); + } + setPerfil({ ...dadosPerfil, email: user.email }); + setEstagio(estagioRes.data); + + if (presencasRes.data) { + setContagemPresencas(presencasRes.data.filter((p: any) => p.estado === 'presente').length); + setContagemFaltas(presencasRes.data.filter((p: any) => p.estado === 'faltou').length); + } } catch (e) { console.error(e); } finally { @@ -39,63 +120,233 @@ export default function PerfilAluno() { } } - const themeStyles = { - fundo: isDarkMode ? '#121212' : '#f1f3f5', - card: isDarkMode ? '#1e1e1e' : '#fff', - texto: isDarkMode ? '#fff' : '#000', - textoSecundario: isDarkMode ? '#adb5bd' : '#6c757d', + const salvarPerfil = async () => { + try { + const dataBD = formatarParaSalvar(perfil.data_nascimento); + + const { error } = await supabase.from('profiles').update({ + nome: perfil.nome, + telefone: perfil.telefone, + residencia: perfil.residencia, + data_nascimento: dataBD + }).eq('id', perfil.id); + + if (error) throw error; + setIsEditing(false); + Alert.alert("Sucesso", "Perfil atualizado!"); + } catch (e) { + Alert.alert("Erro", "Verifica se a data está correta (DD-MM-AAAA)."); + } }; - if (loading) return ; + // CÁLCULOS DO DASHBOARD + const horasPorDia = 7; + const horasTotais = calcularHorasTotaisUteis(estagio?.data_inicio, estagio?.data_fim); + const horasRealizadas = contagemPresencas * horasPorDia; + const horasRestantes = Math.max(0, horasTotais - horasRealizadas); + const progresso = horasTotais > 0 ? Math.min(1, horasRealizadas / horasTotais) : 0; + + const themeStyles = { + fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC', + card: isDarkMode ? '#1A1A1A' : '#FFFFFF', + texto: isDarkMode ? '#F8FAFC' : '#1E293B', + secundario: isDarkMode ? '#94A3B8' : '#64748B', + azul: '#3B82F6', + borda: isDarkMode ? '#2D2D2D' : '#E2E8F0', + vermelho: '#EF4444' + }; + + if (loading) return ; return ( - - O Meu Perfil - - {/* Dados Pessoais vindos da tabela PROFILES */} - - Dados Pessoais - - - - - - - {/* Dados da Empresa vindos da tabela EMPRESAS via Estágio */} - - Local de Estágio - - - - - - - { await supabase.auth.signOut(); router.replace('/'); }} - > - Sair da Conta + + + + router.back()} style={styles.backBtn}> + + + + + + + + {perfil?.nome?.charAt(0).toUpperCase()} + + {perfil?.nome} + + setIsEditing(true)} + > + + + {isEditing ? "Guardar" : "Editar Perfil"} + + + + + {/* PROGRESSO */} + + Progresso do Estágio + + + + + + + + + + + + {Math.round(progresso * 100)}% das {horasTotais}h concluídas (Dias Úteis) + + + + + + {/* DADOS ACADÉMICOS */} + + Dados de Aluno + + + + + + + + {/* INFORMAÇÃO PESSOAL */} + + Informação Pessoal + + setPerfil({...perfil, nome: t})} theme={themeStyles} /> + + + setPerfil({...perfil, data_nascimento: aplicarMascaraData(t)})} + theme={themeStyles} + keyboard="numeric" + limit={10} + /> + + + + + + + setPerfil({...perfil, telefone: t})} theme={themeStyles} keyboard="phone-pad" /> + + setPerfil({...perfil, residencia: t})} theme={themeStyles} /> + + + + {/* EMPRESA E DATAS DO ESTÁGIO */} + + Horário e Local + + + + + + + + + + + + + ); } -function LabelValor({ label, valor, theme }: any) { +// --- AUXILIARES (IGUAIS) --- +function StatBox({ label, valor, cor, theme }: any) { return ( - - {label} - {valor || '---'} + + {valor} + {label} ); } +function EditableRow({ icon, label, value, isEditing, onChange, theme, keyboard = "default", limit }: any) { + return ( + + + + {label} + {isEditing ? ( + + ) : ( + {value || '---'} + )} + + + ); +} + +function InfoRow({ icon, label, valor, theme }: any) { + return ( + + + + {label} + {valor || '---'} + + + ); +} + +const Divider = ({ theme }: any) => ; + const styles = StyleSheet.create({ - safe: { flex: 1 }, - container: { padding: 20, gap: 20 }, - tituloGeral: { fontSize: 24, fontWeight: 'bold', marginBottom: 10 }, - card: { padding: 20, borderRadius: 16, elevation: 2 }, - tituloCard: { fontSize: 14, fontWeight: 'bold', color: '#0d6efd', marginBottom: 15, textTransform: 'uppercase' }, - btnSair: { backgroundColor: '#dc3545', padding: 18, borderRadius: 15, alignItems: 'center', marginTop: 10 } + safe: { flex: 1, paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0 }, + topNav: { paddingHorizontal: 20, paddingTop: 10, height: 50, justifyContent: 'center' }, + backBtn: { width: 40, height: 40, justifyContent: 'center' }, + scrollContent: { paddingHorizontal: 20, paddingBottom: 40 }, + centered: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + header: { alignItems: 'center', marginBottom: 30 }, + avatarCircle: { width: 80, height: 80, borderRadius: 40, justifyContent: 'center', alignItems: 'center', marginBottom: 12 }, + avatarText: { fontSize: 32, fontWeight: 'bold', color: '#fff' }, + userName: { fontSize: 20, fontWeight: '800', marginBottom: 15 }, + btnEdit: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20, borderWidth: 1 }, + btnEditText: { marginLeft: 8, fontSize: 13, fontWeight: '600' }, + section: { marginBottom: 25 }, + sectionTitle: { fontSize: 11, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 10, marginLeft: 5 }, + card: { borderRadius: 20, padding: 16, borderWidth: 1 }, + statsGrid: { flexDirection: 'row', justifyContent: 'space-around', marginBottom: 20 }, + statBox: { alignItems: 'center' }, + statValor: { fontSize: 18, fontWeight: 'bold' }, + statLabel: { fontSize: 11, fontWeight: '600', marginTop: 2 }, + progressContainer: { marginTop: 10 }, + progressBarBase: { height: 8, borderRadius: 4, overflow: 'hidden' }, + progressBarFill: { height: '100%', borderRadius: 4 }, + progressText: { fontSize: 11, textAlign: 'center', marginTop: 8, fontWeight: '600' }, + infoRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8 }, + infoContent: { flex: 1 }, + infoLabel: { fontSize: 10, fontWeight: '600', textTransform: 'uppercase' }, + infoValor: { fontSize: 15, fontWeight: '600', marginTop: 2 }, + input: { fontSize: 15, fontWeight: '600', paddingVertical: 2, borderBottomWidth: 1 }, + divider: { height: 1, width: '100%', marginVertical: 8 } }); \ No newline at end of file diff --git a/app/Professor/Alunos/CalendarioPresencas.tsx b/app/Professor/Alunos/CalendarioPresencas.tsx index 246a2a4..770379e 100644 --- a/app/Professor/Alunos/CalendarioPresencas.tsx +++ b/app/Professor/Alunos/CalendarioPresencas.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Alert, + Linking, Platform, SafeAreaView, ScrollView, @@ -18,20 +19,26 @@ import { supabase } from '../../lib/supabase'; interface Presenca { id: string; + aluno_id: string; data: string; estado: string; sumario: string | null; + justificacao_url: string | null; + created_at: string; lat?: number; lng?: number; } export default function HistoricoPresencas() { const router = useRouter(); - const { alunoId, nome } = useLocalSearchParams(); + const params = useLocalSearchParams(); const { isDarkMode } = useTheme(); const [presencas, setPresencas] = useState([]); const [loading, setLoading] = useState(true); + const idStr = Array.isArray(params.alunoId) ? params.alunoId[0] : params.alunoId; + const nomeStr = Array.isArray(params.nome) ? params.nome[0] : params.nome; + const cores = useMemo(() => ({ fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC', card: isDarkMode ? '#1A1A1A' : '#FFFFFF', @@ -45,8 +52,8 @@ export default function HistoricoPresencas() { }), [isDarkMode]); useEffect(() => { - if (alunoId) fetchHistorico(); - }, [alunoId]); + if (idStr) fetchHistorico(); + }, [idStr]); async function fetchHistorico() { try { @@ -54,40 +61,72 @@ export default function HistoricoPresencas() { const { data, error } = await supabase .from('presencas') .select('*') - .eq('aluno_id', alunoId) + .eq('aluno_id', idStr) .order('data', { ascending: false }); if (error) throw error; setPresencas(data || []); } catch (error: any) { - console.error(error.message); - Alert.alert("Erro", "Falha ao carregar o histórico real."); + Alert.alert("Erro", "Não foi possível carregar as presenças."); } finally { setLoading(false); } } + const handleNavigation = (item: Presenca) => { + if (item.estado === 'faltou') { + router.push({ + pathname: '/Professor/Alunos/Faltas', + params: { alunoId: idStr, nome: nomeStr } + }); + } else if (item.estado === 'presente') { + router.push({ + pathname: '/Professor/Alunos/Sumarios', + params: { alunoId: idStr, nome: nomeStr } + }); + } + }; + + const abrirMapa = (lat: number, lng: number) => { + const scheme = Platform.select({ ios: 'maps:0,0?q=', android: 'geo:0,0?q=' }); + const latLng = `${lat},${lng}`; + const label = `Localização de ${nomeStr}`; + const url = Platform.select({ + ios: `${scheme}${label}@${latLng}`, + android: `${scheme}${latLng}(${label})` + }); + + if (url) { + Linking.openURL(url).catch(() => { + Alert.alert("Erro", "Não foi possível abrir o mapa."); + }); + } + }; + return ( - {/* StatusBar configurada para não sobrepor o conteúdo */} - + - {/* Header com design idêntico ao anterior */} + {/* HEADER PREMIUM */} - router.back()} style={styles.backBtn}> - + router.back()} + style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]} + > + - + + Histórico - {nome} + {nomeStr} - - + + + @@ -97,27 +136,30 @@ export default function HistoricoPresencas() { ) : ( - + {presencas.length === 0 ? ( - - Sem registos reais para este aluno. - + Sem registos para este aluno. ) : ( presencas.map((item) => { const isPresente = item.estado === 'presente'; return ( - + handleNavigation(item)} + style={[styles.card, { backgroundColor: cores.card, borderColor: cores.borda }]} + > - {/* Ícone de Calendário no lugar do Avatar para manter o peso visual */} - - + + @@ -127,24 +169,30 @@ export default function HistoricoPresencas() { - {item.estado.toUpperCase()} + {item.estado ? item.estado.toUpperCase() : '---'} - {item.lat && ( - - )} + + {item.lat && item.lng && ( + abrirMapa(item.lat!, item.lng!)} + style={styles.locationBtn} + > + + + )} + + - {/* Área do Sumário com o estilo de "nota" do ecrã anterior */} - - Sumário do Dia - - {item.sumario || "O aluno ainda não preencheu o sumário deste dia."} + + + Ver {isPresente ? 'sumário detalhado' : 'comprovativo de falta'} - + ); }) )} @@ -155,63 +203,40 @@ export default function HistoricoPresencas() { } const styles = StyleSheet.create({ - safe: { - flex: 1, - // Garante que o conteúdo não fica debaixo da barra de notificações (Android) - paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0 + safe: { flex: 1, paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0 }, + headerFixed: { paddingHorizontal: 20, paddingBottom: 10 }, + topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', height: 75 }, + backBtnPremium: { + width: 42, height: 42, borderRadius: 14, + justifyContent: 'center', alignItems: 'center', + borderWidth: 1, elevation: 2, shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2 }, - headerFixed: { - paddingHorizontal: 20, - paddingBottom: 10 + refreshBtnPremium: { + width: 42, height: 42, borderRadius: 14, + justifyContent: 'center', alignItems: 'center', + borderWidth: 1 }, - topBar: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - height: 70 - }, - backBtn: { width: 40, height: 40, justifyContent: 'center' }, - refreshBtn: { width: 40, height: 40, justifyContent: 'center', alignItems: 'flex-end' }, - title: { fontSize: 22, fontWeight: '800' }, - subtitle: { fontSize: 13, marginTop: -2, fontWeight: '600' }, - scrollContent: { padding: 20 }, + titleContainer: { flex: 1, alignItems: 'center', paddingHorizontal: 10 }, + title: { fontSize: 20, fontWeight: '800' }, + subtitle: { fontSize: 12, fontWeight: '600', marginTop: -2 }, + scrollContent: { paddingHorizontal: 20, paddingBottom: 30 }, card: { - borderRadius: 20, - padding: 16, - marginBottom: 15, - borderWidth: 1, - elevation: 2, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.05, - shadowRadius: 5 + borderRadius: 22, padding: 16, marginBottom: 15, + borderWidth: 1, elevation: 3, shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.04, shadowRadius: 10 }, cardHeader: { flexDirection: 'row', alignItems: 'center' }, - iconBox: { - width: 44, - height: 44, - borderRadius: 12, - justifyContent: 'center', - alignItems: 'center' - }, + iconBox: { width: 44, height: 44, borderRadius: 15, justifyContent: 'center', alignItems: 'center' }, info: { flex: 1, marginLeft: 15 }, - dateText: { fontSize: 16, fontWeight: '700' }, + dateText: { fontSize: 15, fontWeight: '700' }, statusBadge: { flexDirection: 'row', alignItems: 'center', marginTop: 4 }, dot: { width: 8, height: 8, borderRadius: 4, marginRight: 6 }, - subText: { fontSize: 11, fontWeight: '800', letterSpacing: 0.5 }, - sumarioBox: { - marginTop: 15, - padding: 12, - borderRadius: 12, - }, - sumarioLabel: { - fontSize: 10, - fontWeight: '800', - textTransform: 'uppercase', - marginBottom: 5, - letterSpacing: 0.5 - }, - sumarioText: { fontSize: 13, lineHeight: 18, opacity: 0.9 }, + subText: { fontSize: 10, fontWeight: '800', letterSpacing: 0.5 }, + actionRow: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + locationBtn: { width: 34, height: 34, justifyContent: 'center', alignItems: 'center' }, + footerCard: { marginTop: 12, paddingTop: 10, borderTopWidth: 1 }, + tapInfo: { fontSize: 10, fontWeight: '800', textAlign: 'right', textTransform: 'uppercase', letterSpacing: 0.5 }, centered: { flex: 1, justifyContent: 'center', alignItems: 'center' }, empty: { alignItems: 'center', marginTop: 60 }, emptyText: { marginTop: 15, fontSize: 14, fontWeight: '600', textAlign: 'center' }, diff --git a/app/Professor/Alunos/Estagios.tsx b/app/Professor/Alunos/Estagios.tsx index 076029c..5d69f8e 100644 --- a/app/Professor/Alunos/Estagios.tsx +++ b/app/Professor/Alunos/Estagios.tsx @@ -55,7 +55,6 @@ export default function Estagios() { borda: isDarkMode ? '#2D2D2D' : '#E2E8F0', }), [isDarkMode]); - // --- Estados --- const [estagios, setEstagios] = useState([]); const [alunos, setAlunos] = useState([]); const [empresas, setEmpresas] = useState([]); @@ -187,26 +186,26 @@ export default function Estagios() { return groups; }, [alunos, searchAluno]); + // CORREÇÃO DO AGRUPAMENTO DE EMPRESAS PARA EVITAR DUPLICADOS const empresasAgrupadas = useMemo(() => { const groups: Record = {}; - empresas.filter(e => e.nome.toLowerCase().includes(searchEmpresa.toLowerCase())).forEach(e => { - const k = e.curso || 'Geral'; - if (!groups[k]) groups[k] = []; - groups[k].push(e); - }); + empresas + .filter(e => e.nome.toLowerCase().includes(searchEmpresa.toLowerCase())) + .forEach(e => { + const k = e.curso ? e.curso.trim() : 'Geral'; + if (!groups[k]) groups[k] = []; + groups[k].push(e); + }); return groups; }, [empresas, searchEmpresa]); - // --- NOVA LÓGICA DE AGRUPAMENTO (APENAS ISTO FOI ADICIONADO PARA A VIEW) --- const estagiosAgrupados = useMemo(() => { const groups: Record = {}; - estagios.filter(e => e.alunos?.nome?.toLowerCase().includes(searchMain.toLowerCase())).forEach(e => { const chave = e.alunos ? `${e.alunos.ano}º ${e.alunos.turma_curso}` : 'Sem Turma'; if (!groups[chave]) groups[chave] = []; groups[chave].push(e); }); - return Object.keys(groups).map(titulo => ({ titulo, dados: groups[titulo] @@ -221,11 +220,17 @@ export default function Estagios() { - router.back()}> - + router.back()} + > + Estágios - + @@ -241,7 +246,6 @@ export default function Estagios() { /> - {/* RENDERING AGRUPADO AQUI */} {estagiosAgrupados.map(grupo => ( @@ -296,19 +300,22 @@ export default function Estagios() { - + - - {passo === 1 ? "Seleção de Aluno/Empresa" : "Configuração de Estágio"} - - - + + + {passo === 1 ? "Seleção de Aluno/Empresa" : "Configuração de Estágio"} + + {passo === 2 && {alunoSelecionado?.nome}} + + + - + {passo === 1 ? ( SELECIONE O ALUNO @@ -337,10 +344,10 @@ export default function Estagios() { SELECIONE A EMPRESA - {Object.keys(empresasAgrupadas).map(c => ( - - {c} - {empresasAgrupadas[c].map(emp => ( + {Object.keys(empresasAgrupadas).sort().map(curso => ( + + {curso.toUpperCase()} + {empresasAgrupadas[curso].map(emp => ( INÍCIO - + FIM - + @@ -436,17 +443,19 @@ export default function Estagios() { const styles = StyleSheet.create({ header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 20, paddingVertical: 15 }, - btnCircle: { width: 42, height: 42, borderRadius: 12, justifyContent: 'center', alignItems: 'center', elevation: 2 }, - tituloGeral: { fontSize: 18, fontWeight: '800' }, + backBtnPremium: { + width: 42, height: 42, borderRadius: 14, + justifyContent: 'center', alignItems: 'center', + borderWidth: 1, elevation: 2, shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2 + }, + tituloGeral: { fontSize: 20, fontWeight: '800' }, scrollContent: { paddingHorizontal: 20, paddingBottom: 40, gap: 15 }, searchContainer: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 15, height: 50, borderRadius: 15, borderWidth: 1, marginBottom: 5 }, searchInput: { flex: 1, marginLeft: 10, fontSize: 14, fontWeight: '500' }, - - // ADICIONADO APENAS PARA O CABEÇALHO DO AGRUPAMENTO: turmaSectionHeader: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 10, alignSelf: 'flex-start', marginBottom: 12 }, turmaSectionText: { fontSize: 11, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.5 }, - - card: { padding: 18, borderRadius: 24, flexDirection: 'row', alignItems: 'center', marginBottom: 2, elevation: 3, shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 10 }, + card: { padding: 18, borderRadius: 24, flexDirection: 'row', alignItems: 'center', marginBottom: 2, elevation: 3, shadowColor: '#000', shadowOpacity: 0.04, shadowRadius: 10 }, cardTitle: { fontSize: 15, fontWeight: '700', marginBottom: 4 }, row: { flexDirection: 'row', alignItems: 'center', gap: 10 }, cardSub: { fontSize: 11, fontWeight: '600' }, @@ -457,9 +466,10 @@ const styles = StyleSheet.create({ btnPrincipal: { height: 56, borderRadius: 16, flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginTop: 10, gap: 10, elevation: 4 }, btnPrincipalText: { color: '#fff', fontSize: 14, fontWeight: '800' }, modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'flex-end' }, - modalContent: { borderTopLeftRadius: 32, borderTopRightRadius: 32, padding: 24, height: '85%' }, + modalContent: { borderTopLeftRadius: 32, borderTopRightRadius: 32, padding: 24, height: '88%' }, modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }, - modalTitle: { fontSize: 17, fontWeight: '800' }, + modalTitle: { fontSize: 18, fontWeight: '800' }, + closeBtn: { width: 36, height: 36, borderRadius: 12, justifyContent: 'center', alignItems: 'center' }, sectionLabel: { fontSize: 10, fontWeight: '900', textTransform: 'uppercase', letterSpacing: 1.2 }, selectorContainer: { borderWidth: 1, borderRadius: 16, overflow: 'hidden', marginTop: 8 }, groupHead: { fontSize: 10, padding: 8, fontWeight: '800', textTransform: 'uppercase' }, diff --git a/app/Professor/Alunos/Faltas.tsx b/app/Professor/Alunos/Faltas.tsx index 8553f45..3283831 100644 --- a/app/Professor/Alunos/Faltas.tsx +++ b/app/Professor/Alunos/Faltas.tsx @@ -1,7 +1,11 @@ import { Ionicons } from '@expo/vector-icons'; -import { useRouter } from 'expo-router'; -import { useState } from 'react'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { memo, useEffect, useMemo, useState } from 'react'; import { + ActivityIndicator, + Alert, + FlatList, + Linking, Modal, Platform, SafeAreaView, @@ -9,262 +13,307 @@ import { StatusBar, StyleSheet, Text, + TextInput, TouchableOpacity, - View, + View } from 'react-native'; -import { WebView } from 'react-native-webview'; import { useTheme } from '../../../themecontext'; +import { supabase } from '../../lib/supabase'; + +export interface Aluno { + id: string; + nome: string; + n_escola: string; + turma: string; +} interface Falta { - dia: string; - pdfUrl?: string; + id: string; + data: string; + justificacao_url: string | null; + estado: string; } -interface Aluno { - id: number; - nome: string; - faltas: Falta[]; -} - -// Dados simulados -const alunosData: Aluno[] = [ - { - id: 1, - nome: 'João Silva', - faltas: [ - { - dia: '2026-01-20', - pdfUrl: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', - }, - { dia: '2026-01-22' }, - ], - }, - { - id: 2, - nome: 'Maria Fernandes', - faltas: [ - { dia: '2026-01-21' }, - { - dia: '2026-01-23', - pdfUrl: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', - }, - ], - }, -]; - -export default function FaltasAlunos() { - const router = useRouter(); +const FaltasAlunos = memo(() => { const { isDarkMode } = useTheme(); + const router = useRouter(); + const params = useLocalSearchParams(); - const cores = { - fundo: isDarkMode ? '#121212' : '#f1f3f5', - card: isDarkMode ? '#1e1e1e' : '#fff', - texto: isDarkMode ? '#fff' : '#212529', - textoSecundario: isDarkMode ? '#adb5bd' : '#6c757d', - azul: '#0d6efd', - verde: '#198754', - vermelho: '#dc3545', + const [search, setSearch] = useState(''); + const [turmas, setTurmas] = useState<{ nome: string; alunos: Aluno[] }[]>([]); + const [loading, setLoading] = useState(true); + + const [modalFaltasVisible, setModalFaltasVisible] = useState(false); + const [alunoSelecionado, setAlunoSelecionado] = useState(null); + const [faltas, setFaltas] = useState([]); + const [loadingFaltas, setLoadingFaltas] = useState(false); + + const cores = useMemo(() => ({ + fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC', + card: isDarkMode ? '#1A1A1A' : '#FFFFFF', + texto: isDarkMode ? '#F8FAFC' : '#1E293B', + secundario: isDarkMode ? '#94A3B8' : '#64748B', + azul: '#3B82F6', + vermelho: '#EF4444', + verde: '#10B981', + azulSuave: isDarkMode ? 'rgba(59, 130, 246, 0.15)' : 'rgba(59, 130, 246, 0.1)', + borda: isDarkMode ? '#2D2D2D' : '#E2E8F0', + }), [isDarkMode]); + + useEffect(() => { fetchAlunos(); }, []); + + useEffect(() => { + if (params.alunoId && typeof params.alunoId === 'string') { + const alunoAuto = { + id: params.alunoId, + nome: (params.nome as string) || 'Aluno', + n_escola: '', + turma: '', + }; + abrirFaltas(alunoAuto); + } + }, [params.alunoId]); + + const fetchAlunos = async () => { + try { + setLoading(true); + const { data, error } = await supabase + .from('alunos') + .select('id, nome, n_escola, ano, turma_curso') + .order('ano', { ascending: false }) + .order('nome', { ascending: true }); + + if (error) throw error; + + const agrupadas: Record = {}; + data?.forEach(item => { + const nomeTurma = `${item.ano}º ${item.turma_curso}`; + if (!agrupadas[nomeTurma]) agrupadas[nomeTurma] = []; + agrupadas[nomeTurma].push({ id: item.id, nome: item.nome, n_escola: item.n_escola, turma: nomeTurma }); + }); + + setTurmas(Object.keys(agrupadas).map(nome => ({ nome, alunos: agrupadas[nome] }))); + } catch (err) { console.error(err); } finally { setLoading(false); } }; - const [alunoSelecionado, setAlunoSelecionado] = useState(null); - const [pdfModalVisible, setPdfModalVisible] = useState(false); - const [pdfUrl, setPdfUrl] = useState(null); + const abrirFaltas = async (aluno: Aluno) => { + setAlunoSelecionado(aluno); + setModalFaltasVisible(true); + setLoadingFaltas(true); + try { + const { data, error } = await supabase + .from('presencas') + .select('id, data, justificacao_url, estado') + .eq('aluno_id', aluno.id) + .eq('estado', 'faltou') + .order('data', { ascending: false }); - const verPdf = (falta: Falta) => { - if (falta.pdfUrl) { - setPdfUrl(falta.pdfUrl); - setPdfModalVisible(true); + if (error) throw error; + setFaltas(data || []); + } catch (err) { console.error(err); } finally { setLoadingFaltas(false); } + }; + + const verDocumento = async (url: string) => { + try { + const supported = await Linking.canOpenURL(url); + if (supported) { + await Linking.openURL(url); + } else { + Alert.alert("Erro", "Não foi possível abrir o link do documento."); + } + } catch (error) { + Alert.alert("Erro", "URL inválida ou documento inacessível."); } }; + const filteredTurmas = turmas + .map(turma => ({ + ...turma, + alunos: turma.alunos.filter(a => a.nome.toLowerCase().includes(search.toLowerCase()) || a.n_escola.includes(search)), + })) + .filter(t => t.alunos.length > 0); + return ( - + - - {/* Top Bar */} + - { - if (alunoSelecionado) { - setAlunoSelecionado(null); - } else { - router.back(); - } - }} + router.back()} + style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]} > - + - - - {!alunoSelecionado ? 'Faltas dos Alunos' : alunoSelecionado.nome} - - - + Gestão de Faltas + - {/* Lista de alunos */} - {!alunoSelecionado && ( - - {alunosData.map(aluno => ( - setAlunoSelecionado(aluno)} - > - - - {aluno.nome} - - - - ))} - - )} + + + + + - {/* Faltas do aluno */} - {alunoSelecionado && ( - - {alunoSelecionado.faltas.map((falta, idx) => ( - - - - {falta.dia} - - - - {falta.pdfUrl - ? 'Falta justificada' - : 'Falta não justificada'} - - - - {falta.pdfUrl && ( - verPdf(falta)}> - - - )} + {loading ? ( + + + + ) : ( + item.nome} + contentContainerStyle={styles.scrollContent} + showsVerticalScrollIndicator={false} + renderItem={({ item }) => ( + + + {item.nome} - ))} - - )} - - {/* Modal PDF */} - - - - setPdfModalVisible(false)}> - - - - Visualizador de PDF - - - + {item.alunos.map(aluno => ( + abrirFaltas(aluno)} + > + + {aluno.nome.charAt(0)} + + + {aluno.nome} + Nº {aluno.n_escola} + + + + ))} + + )} + /> + )} - {pdfUrl && } - + setModalFaltasVisible(false)} + > + + + + + Histórico de Faltas + {alunoSelecionado?.nome} + + setModalFaltasVisible(false)} + style={[styles.closeBtn, { backgroundColor: cores.fundo }]} + > + + + + + {loadingFaltas ? ( + + ) : ( + + {faltas.length === 0 ? ( + + + Nenhuma falta registada. + + ) : ( + faltas.map(f => ( + + + + + + {new Date(f.data).toLocaleDateString('pt-PT', { day: '2-digit', month: 'long', year: 'numeric' })} + + + + + {f.justificacao_url ? 'Justificada' : 'Injustificada'} + + + + + {f.justificacao_url ? ( + verDocumento(f.justificacao_url!)} + > + + Ver Comprovativo + + ) : ( + + + Aguardando justificação + + )} + + )) + )} + + )} + + ); -} +}); + +export default FaltasAlunos; const styles = StyleSheet.create({ - safe: { - flex: 1, - paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0, + safe: { flex: 1, paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0 }, + headerFixed: { paddingHorizontal: 20, paddingBottom: 15 }, + topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', height: 70 }, + backBtnPremium: { + width: 42, height: 42, borderRadius: 14, + justifyContent: 'center', alignItems: 'center', + borderWidth: 1, elevation: 2, shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2 }, - container: { padding: 20, paddingBottom: 40 }, - topBar: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: 16, - }, - topTitle: { - fontSize: 20, - fontWeight: '700', - textAlign: 'center', - flex: 1, - }, - alunoCard: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - padding: 16, - borderRadius: 14, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - }, - alunoName: { - fontSize: 16, - fontWeight: '600', - marginLeft: 12, - flex: 1, - }, - faltaCard: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - padding: 16, - borderRadius: 14, - marginBottom: 12, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - }, - dia: { - fontSize: 14, - fontWeight: '700', - marginBottom: 6, - }, - status: { - fontSize: 13, - fontWeight: '600', - }, - pdfHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - padding: 16, - borderBottomWidth: 1, - }, - pdfTitle: { - fontSize: 18, - fontWeight: '700', - }, -}); + title: { fontSize: 22, fontWeight: '800' }, + searchBox: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 15, paddingHorizontal: 15, height: 50, marginTop: 10 }, + searchInput: { flex: 1, marginLeft: 10, fontSize: 15, fontWeight: '500' }, + scrollContent: { paddingHorizontal: 20, paddingBottom: 30 }, + section: { marginBottom: 25 }, + turmaBadge: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 10, alignSelf: 'flex-start', marginBottom: 12 }, + turmaLabel: { fontSize: 12, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.8 }, + card: { flexDirection: 'row', alignItems: 'center', padding: 14, borderRadius: 22, marginBottom: 10, elevation: 3, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.04, shadowRadius: 10 }, + avatar: { width: 46, height: 46, borderRadius: 15, justifyContent: 'center', alignItems: 'center' }, + avatarText: { fontSize: 18, fontWeight: '800' }, + info: { flex: 1, marginLeft: 15 }, + nome: { fontSize: 16, fontWeight: '700' }, + subText: { fontSize: 12, marginTop: 2, fontWeight: '500' }, + centered: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'flex-end' }, + modalContent: { height: '85%', borderTopLeftRadius: 30, borderTopRightRadius: 30, overflow: 'hidden' }, + modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 25, borderBottomWidth: 1 }, + modalTitle: { fontSize: 20, fontWeight: '800' }, + modalSubtitle: { fontSize: 14, fontWeight: '600', marginTop: 2 }, + closeBtn: { width: 36, height: 36, borderRadius: 12, justifyContent: 'center', alignItems: 'center' }, + faltaCard: { padding: 16, borderRadius: 20, marginBottom: 15, borderWidth: 1 }, + faltaHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }, + faltaData: { fontSize: 15, fontWeight: '700' }, + statusBadge: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 8 }, + statusText: { fontSize: 10, fontWeight: '900', textTransform: 'uppercase' }, + pdfBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', borderRadius: 12, padding: 12 }, + pdfBtnText: { marginLeft: 10, fontSize: 14, fontWeight: '700' }, + noJustifContainer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginTop: 5 }, + noJustifText: { marginLeft: 6, fontSize: 12, fontWeight: '600', fontStyle: 'italic' }, + emptyState: { alignItems: 'center', marginTop: 50 }, + emptyText: { textAlign: 'center', marginTop: 15, fontSize: 14, fontWeight: '600' } +}); \ No newline at end of file diff --git a/app/Professor/Alunos/ListaAlunos.tsx b/app/Professor/Alunos/ListaAlunos.tsx index 2497f99..086f444 100644 --- a/app/Professor/Alunos/ListaAlunos.tsx +++ b/app/Professor/Alunos/ListaAlunos.tsx @@ -16,6 +16,7 @@ import { import { useTheme } from '../../../themecontext'; import { supabase } from '../../lib/supabase'; +// --- INTERFACES --- export interface Aluno { id: string; nome: string; @@ -27,10 +28,12 @@ const ListaAlunosProfessor = memo(() => { const { isDarkMode } = useTheme(); const router = useRouter(); + // --- ESTADOS --- const [search, setSearch] = useState(''); const [turmas, setTurmas] = useState<{ nome: string; alunos: Aluno[] }[]>([]); const [loading, setLoading] = useState(true); + // --- CORES DINÂMICAS --- const cores = useMemo(() => ({ fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC', card: isDarkMode ? '#1A1A1A' : '#FFFFFF', @@ -41,10 +44,7 @@ const ListaAlunosProfessor = memo(() => { borda: isDarkMode ? '#2D2D2D' : '#E2E8F0', }), [isDarkMode]); - useEffect(() => { - fetchAlunos(); - }, []); - + // --- FUNÇÕES --- const fetchAlunos = async () => { try { setLoading(true); @@ -55,7 +55,6 @@ const ListaAlunosProfessor = memo(() => { .order('nome', { ascending: true }); if (error) throw error; - if (!data) { setTurmas([]); return; @@ -73,12 +72,7 @@ const ListaAlunosProfessor = memo(() => { }); }); - setTurmas( - Object.keys(agrupadas).map(nome => ({ - nome, - alunos: agrupadas[nome], - })) - ); + setTurmas(Object.keys(agrupadas).map(nome => ({ nome, alunos: agrupadas[nome] }))); } catch (err) { console.error('Erro ao carregar alunos:', err); } finally { @@ -86,6 +80,10 @@ const ListaAlunosProfessor = memo(() => { } }; + useEffect(() => { + fetchAlunos(); + }, []); + const filteredTurmas = turmas .map(turma => ({ ...turma, @@ -100,14 +98,17 @@ const ListaAlunosProfessor = memo(() => { - {/* HEADER FIXO ESTILO PREMIUM */} + {/* HEADER FIXO */} - router.back()} style={styles.backBtn}> - + router.back()} + style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]} + > + Alunos - + @@ -134,7 +135,6 @@ const ListaAlunosProfessor = memo(() => { showsVerticalScrollIndicator={false} renderItem={({ item }) => ( - {/* BADGE DA TURMA */} {item.nome} • {item.alunos.length} Alunos @@ -157,12 +157,10 @@ const ListaAlunosProfessor = memo(() => { {aluno.nome.charAt(0).toUpperCase()} - {aluno.nome} Nº Escola: {aluno.n_escola} - ))} @@ -174,72 +172,115 @@ const ListaAlunosProfessor = memo(() => { ); }); -export default ListaAlunosProfessor; - +// --- ESTILOS --- const styles = StyleSheet.create({ - safe: { - flex: 1, - paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0, + safe: { + flex: 1, + paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0 }, - headerFixed: { - paddingHorizontal: 20, - paddingBottom: 15, + headerFixed: { + paddingHorizontal: 20, + paddingBottom: 15 }, - topBar: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - height: 60, + topBar: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + height: 70 }, - backBtn: { width: 40, height: 40, justifyContent: 'center' }, - title: { fontSize: 24, fontWeight: '800' }, - searchBox: { - flexDirection: 'row', + backBtnPremium: { + width: 42, + height: 42, + borderRadius: 14, + justifyContent: 'center', alignItems: 'center', borderWidth: 1, - borderRadius: 15, - paddingHorizontal: 15, - height: 48, - marginTop: 5, + elevation: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 2, }, - searchInput: { flex: 1, marginLeft: 10, fontSize: 15 }, - scrollContent: { paddingHorizontal: 20, paddingBottom: 30 }, - section: { marginBottom: 25 }, - turmaBadge: { - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 10, - alignSelf: 'flex-start', - marginBottom: 12, + title: { + fontSize: 22, + fontWeight: '800' + }, + searchBox: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderRadius: 15, + paddingHorizontal: 15, + height: 50, + marginTop: 10 + }, + searchInput: { + flex: 1, + marginLeft: 10, + fontSize: 15, + fontWeight: '500' + }, + scrollContent: { + paddingHorizontal: 20, + paddingBottom: 30 + }, + section: { + marginBottom: 25 + }, + turmaBadge: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 10, + alignSelf: 'flex-start', + marginBottom: 12 }, turmaLabel: { - fontSize: 13, + fontSize: 12, fontWeight: '800', - textTransform: 'uppercase', - letterSpacing: 0.5 + textTransform: 'uppercase', + letterSpacing: 0.8 }, - card: { - flexDirection: 'row', - alignItems: 'center', - padding: 14, - borderRadius: 20, - marginBottom: 10, - elevation: 3, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.05, - shadowRadius: 8, + card: { + flexDirection: 'row', + alignItems: 'center', + padding: 14, + borderRadius: 22, + marginBottom: 10, + elevation: 3, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.04, + shadowRadius: 10 }, - avatar: { - width: 44, - height: 44, - borderRadius: 22, - justifyContent: 'center', - alignItems: 'center', + avatar: { + width: 46, + height: 46, + borderRadius: 15, + justifyContent: 'center', + alignItems: 'center' }, - avatarText: { fontSize: 17, fontWeight: '700' }, - info: { flex: 1, marginLeft: 15 }, - nome: { fontSize: 16, fontWeight: '700' }, - subText: { fontSize: 12, marginTop: 2, fontWeight: '500' }, - centered: { flex: 1, justifyContent: 'center', alignItems: 'center' }, -}); \ No newline at end of file + avatarText: { + fontSize: 18, + fontWeight: '800' + }, + info: { + flex: 1, + marginLeft: 15 + }, + nome: { + fontSize: 16, + fontWeight: '700' + }, + subText: { + fontSize: 12, + marginTop: 2, + fontWeight: '500' + }, + centered: { + flex: 1, + justifyContent: 'center', + alignItems: 'center' + }, +}); + +export default ListaAlunosProfessor; \ No newline at end of file diff --git a/app/Professor/Alunos/Presencas.tsx b/app/Professor/Alunos/Presencas.tsx index fcd52f7..9267ad7 100644 --- a/app/Professor/Alunos/Presencas.tsx +++ b/app/Professor/Alunos/Presencas.tsx @@ -1,11 +1,11 @@ import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; -import { useEffect, useMemo, useState } from 'react'; +import { memo, useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, + FlatList, Platform, SafeAreaView, - ScrollView, StatusBar, StyleSheet, Text, @@ -16,82 +16,111 @@ import { import { useTheme } from '../../../themecontext'; import { supabase } from '../../lib/supabase'; -interface Aluno { +export interface Aluno { id: string; nome: string; + n_escola: string; + turma: string; } -export default function Presencas() { - const router = useRouter(); +const Presencas = memo(() => { const { isDarkMode } = useTheme(); - const [pesquisa, setPesquisa] = useState(''); - const [alunos, setAlunos] = useState([]); + const router = useRouter(); + + const [search, setSearch] = useState(''); + const [turmas, setTurmas] = useState<{ nome: string; alunos: Aluno[] }[]>([]); const [loading, setLoading] = useState(true); - const cores = useMemo( - () => ({ - fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC', - card: isDarkMode ? '#1A1A1A' : '#FFFFFF', - texto: isDarkMode ? '#F8FAFC' : '#1E293B', - secundario: isDarkMode ? '#94A3B8' : '#64748B', - azul: '#3B82F6', - azulSuave: isDarkMode ? 'rgba(59, 130, 246, 0.15)' : 'rgba(59, 130, 246, 0.1)', - borda: isDarkMode ? '#2D2D2D' : '#E2E8F0', - }), - [isDarkMode] - ); + const cores = useMemo(() => ({ + fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC', + card: isDarkMode ? '#1A1A1A' : '#FFFFFF', + texto: isDarkMode ? '#F8FAFC' : '#1E293B', + secundario: isDarkMode ? '#94A3B8' : '#64748B', + azul: '#3B82F6', + azulSuave: isDarkMode ? 'rgba(59, 130, 246, 0.15)' : 'rgba(59, 130, 246, 0.1)', + borda: isDarkMode ? '#2D2D2D' : '#E2E8F0', + }), [isDarkMode]); useEffect(() => { fetchAlunos(); }, []); - async function fetchAlunos() { + const fetchAlunos = async () => { try { setLoading(true); - - // Busca apenas id e nome, filtrando por tipo aluno const { data, error } = await supabase - .from('profiles') - .select('id, nome') - .eq('tipo', 'aluno') + .from('alunos') + .select('id, nome, n_escola, ano, turma_curso') + .order('ano', { ascending: false }) .order('nome', { ascending: true }); if (error) throw error; - if (data) { - setAlunos(data as Aluno[]); + if (!data) { + setTurmas([]); + return; } - } catch (error: any) { - console.error("Erro ao carregar alunos:", error.message); + + const agrupadas: Record = {}; + data.forEach(item => { + const nomeTurma = `${item.ano}º ${item.turma_curso}`; + if (!agrupadas[nomeTurma]) agrupadas[nomeTurma] = []; + agrupadas[nomeTurma].push({ + id: item.id, + nome: item.nome, + n_escola: item.n_escola, + turma: nomeTurma, + }); + }); + + setTurmas( + Object.keys(agrupadas).map(nome => ({ + nome, + alunos: agrupadas[nome], + })) + ); + } catch (err) { + console.error('Erro ao carregar alunos:', err); } finally { setLoading(false); } - } + }; - const alunosFiltrados = alunos.filter(a => - a.nome.toLowerCase().includes(pesquisa.toLowerCase()) - ); + const filteredTurmas = turmas + .map(turma => ({ + ...turma, + alunos: turma.alunos.filter(a => + a.nome.toLowerCase().includes(search.toLowerCase()) || + a.n_escola.includes(search) + ), + })) + .filter(t => t.alunos.length > 0); return ( + {/* HEADER PREMIUM */} - router.back()} style={styles.backBtn}> - + router.back()} + style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]} + > + + Presenças - + @@ -102,43 +131,53 @@ export default function Presencas() { ) : ( - - {alunosFiltrados.length === 0 ? ( - - Nenhum aluno encontrado. + item.nome} + contentContainerStyle={styles.scrollContent} + showsVerticalScrollIndicator={false} + renderItem={({ item }) => ( + + + + {item.nome} • {item.alunos.length} Alunos + + + + {item.alunos.map(aluno => ( + + router.push({ + pathname: '/Professor/Alunos/CalendarioPresencas', + params: { alunoId: aluno.id, nome: aluno.nome }, + }) + } + > + + + {aluno.nome.charAt(0).toUpperCase()} + + + + + {aluno.nome} + Ver registo de presenças + + + + + ))} - ) : ( - alunosFiltrados.map(aluno => ( - - router.push({ - pathname: '/Professor/Alunos/CalendarioPresencas', - params: { alunoId: aluno.id, nome: aluno.nome }, - }) - } - > - - - {aluno.nome.charAt(0).toUpperCase()} - - - - - {aluno.nome} - Ver registo de presenças - - - - - )) )} - + /> )} ); -} +}); + +export default Presencas; const styles = StyleSheet.create({ safe: { @@ -153,9 +192,21 @@ const styles = StyleSheet.create({ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - height: 60, + height: 70, + }, + backBtnPremium: { + width: 42, + height: 42, + borderRadius: 14, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + elevation: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 2, }, - backBtn: { width: 40, height: 40, justifyContent: 'center' }, title: { fontSize: 22, fontWeight: '800' }, searchBox: { flexDirection: 'row', @@ -163,33 +214,47 @@ const styles = StyleSheet.create({ borderWidth: 1, borderRadius: 15, paddingHorizontal: 15, - height: 48, + height: 50, + marginTop: 10, + }, + searchInput: { flex: 1, marginLeft: 10, fontSize: 15, fontWeight: '500' }, + scrollContent: { paddingHorizontal: 20, paddingBottom: 30 }, + section: { marginBottom: 25 }, + turmaBadge: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 10, + alignSelf: 'flex-start', + marginBottom: 12, + }, + turmaLabel: { + fontSize: 12, + fontWeight: '800', + textTransform: 'uppercase', + letterSpacing: 0.8 }, - searchInput: { flex: 1, marginLeft: 10, fontSize: 15 }, - scrollContent: { padding: 20 }, card: { flexDirection: 'row', alignItems: 'center', padding: 14, - borderRadius: 18, + borderRadius: 22, marginBottom: 10, - elevation: 2, + elevation: 3, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.05, - shadowRadius: 5, + shadowOpacity: 0.04, + shadowRadius: 10, }, avatar: { - width: 42, - height: 42, - borderRadius: 21, + width: 46, + height: 46, + borderRadius: 15, justifyContent: 'center', alignItems: 'center', }, - avatarText: { fontSize: 16, fontWeight: '700' }, + avatarText: { fontSize: 18, fontWeight: '800' }, info: { flex: 1, marginLeft: 15 }, nome: { fontSize: 16, fontWeight: '700' }, - subText: { fontSize: 12, marginTop: 2 }, + subText: { fontSize: 12, marginTop: 2, fontWeight: '500' }, centered: { flex: 1, justifyContent: 'center', alignItems: 'center' }, - empty: { alignItems: 'center', marginTop: 40 }, }); \ No newline at end of file diff --git a/app/Professor/Alunos/Sumarios.tsx b/app/Professor/Alunos/Sumarios.tsx index 2ae9c28..2115f81 100644 --- a/app/Professor/Alunos/Sumarios.tsx +++ b/app/Professor/Alunos/Sumarios.tsx @@ -1,156 +1,291 @@ import { Ionicons } from '@expo/vector-icons'; -import { useRouter } from 'expo-router'; -import { memo, useMemo, useState } from 'react'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { memo, useEffect, useMemo, useState } from 'react'; import { + ActivityIndicator, + FlatList, + Modal, Platform, SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, + TextInput, TouchableOpacity, View, } from 'react-native'; -import { useTheme } from '../../../themecontext'; // Contexto global do tema +import { useTheme } from '../../../themecontext'; +import { supabase } from '../../lib/supabase'; + +export interface Aluno { + id: string; + nome: string; + n_escola: string; + turma: string; +} interface Sumario { - dia: string; - conteudo: string; + id: string; + data: string; + sumario: string; } -interface Aluno { - id: number; - nome: string; - sumarios: Sumario[]; -} - -// Dados simulados -const alunosData: Aluno[] = [ - { - id: 1, - nome: 'João Silva', - sumarios: [ - { dia: '2026-01-20', conteudo: 'Aprendeu sobre React Native e navegação.' }, - { dia: '2026-01-21', conteudo: 'Configurou Supabase e salvou dados.' }, - ], - }, - { - id: 2, - nome: 'Maria Fernandes', - sumarios: [ - { dia: '2026-01-20', conteudo: 'Estudou TypeScript e componentes.' }, - { dia: '2026-01-21', conteudo: 'Criou layout de página de empresas.' }, - ], - }, -]; - -export default memo(function SumariosAlunos() { +const SumariosAlunos = memo(() => { + const { isDarkMode } = useTheme(); const router = useRouter(); - const { isDarkMode } = useTheme(); // pega tema global - const [alunoSelecionado, setAlunoSelecionado] = useState(null); + const params = useLocalSearchParams(); - // Cores dinamicas - const cores = useMemo( - () => ({ - fundo: isDarkMode ? '#121212' : '#f1f3f5', - card: isDarkMode ? '#1e1e1e' : '#fff', - texto: isDarkMode ? '#fff' : '#212529', - textoSecundario: isDarkMode ? '#adb5bd' : '#6c757d', - azul: '#0d6efd', - }), - [isDarkMode] - ); + const [search, setSearch] = useState(''); + const [turmas, setTurmas] = useState<{ nome: string; alunos: Aluno[] }[]>([]); + const [loading, setLoading] = useState(true); + + const [modalVisible, setModalVisible] = useState(false); + const [alunoSelecionado, setAlunoSelecionado] = useState(null); + const [sumarios, setSumarios] = useState([]); + const [loadingSumarios, setLoadingSumarios] = useState(false); + + const cores = useMemo(() => ({ + fundo: isDarkMode ? '#0F0F0F' : '#F8FAFC', + card: isDarkMode ? '#1A1A1A' : '#FFFFFF', + texto: isDarkMode ? '#F8FAFC' : '#1E293B', + secundario: isDarkMode ? '#94A3B8' : '#64748B', + azul: '#3B82F6', + azulSuave: isDarkMode ? 'rgba(59, 130, 246, 0.15)' : 'rgba(59, 130, 246, 0.1)', + borda: isDarkMode ? '#2D2D2D' : '#E2E8F0', + }), [isDarkMode]); + + useEffect(() => { + fetchAlunos(); + }, []); + + useEffect(() => { + if (params.alunoId && typeof params.alunoId === 'string') { + const alunoAuto = { + id: params.alunoId, + nome: (params.nome as string) || 'Aluno', + n_escola: '', + turma: '' + }; + abrirSumarios(alunoAuto); + } + }, [params.alunoId]); + + const fetchAlunos = async () => { + try { + setLoading(true); + const { data, error } = await supabase + .from('alunos') + .select('id, nome, n_escola, ano, turma_curso') + .order('ano', { ascending: false }) + .order('nome', { ascending: true }); + + if (error) throw error; + + const agrupadas: Record = {}; + data?.forEach(item => { + const nomeTurma = `${item.ano}º ${item.turma_curso}`; + if (!agrupadas[nomeTurma]) agrupadas[nomeTurma] = []; + agrupadas[nomeTurma].push({ + id: item.id, + nome: item.nome, + n_escola: item.n_escola, + turma: nomeTurma, + }); + }); + + setTurmas(Object.keys(agrupadas).map(nome => ({ nome, alunos: agrupadas[nome] }))); + } catch (err) { + console.error('Erro ao carregar alunos:', err); + } finally { + setLoading(false); + } + }; + + const abrirSumarios = async (aluno: Aluno) => { + setAlunoSelecionado(aluno); + setModalVisible(true); + setLoadingSumarios(true); + try { + const { data, error } = await supabase + .from('presencas') + .select('id, data, sumario') + .eq('aluno_id', aluno.id) + .not('sumario', 'is', null) + .order('data', { ascending: false }); + + if (error) throw error; + setSumarios(data || []); + } catch (err) { + console.error('Erro sumários:', err); + } finally { + setLoadingSumarios(false); + } + }; + + const filteredTurmas = turmas + .map(turma => ({ + ...turma, + alunos: turma.alunos.filter(a => + a.nome.toLowerCase().includes(search.toLowerCase()) || a.n_escola.includes(search) + ), + })) + .filter(t => t.alunos.length > 0); return ( - + - - {/* Cabeçalho */} - - (alunoSelecionado ? setAlunoSelecionado(null) : router.back())} - style={styles.backButtonHeader} + + + router.back()} + style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]} > - + - - Sumários de Estágio + Sumários + - {/* Lista de alunos */} - {!alunoSelecionado && ( - - {alunosData.map(aluno => ( - setAlunoSelecionado(aluno)} - > - - {aluno.nome} - - - ))} - - )} + + + + + - {/* Sumários do aluno */} - {alunoSelecionado && ( - - {alunoSelecionado.nome} - - {alunoSelecionado.sumarios.map((s, idx) => ( - - {s.dia} - {s.conteudo} + {loading ? ( + + + + ) : ( + item.nome} + contentContainerStyle={styles.scrollContent} + showsVerticalScrollIndicator={false} + renderItem={({ item }) => ( + + + {item.nome} - ))} + + {item.alunos.map(aluno => ( + abrirSumarios(aluno)} + > + + {aluno.nome.charAt(0)} + + + {aluno.nome} + Nº {aluno.n_escola} + + + + ))} + + )} + /> + )} + + setModalVisible(false)} + > + + + + + Sumários + {alunoSelecionado?.nome} + + setModalVisible(false)} + style={[styles.closeBtn, { backgroundColor: cores.fundo }]} + > + + + + + {loadingSumarios ? ( + + ) : ( + + {sumarios.length === 0 ? ( + + + Sem registos de sumários. + + ) : ( + sumarios.map(s => ( + + + + + {new Date(s.data).toLocaleDateString('pt-PT', { day: '2-digit', month: '2-digit', year: 'numeric' })} + + + {s.sumario} + + )) + )} + + )} - )} - + + ); }); +export default SumariosAlunos; + const styles = StyleSheet.create({ - safe: { flex: 1, paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0 }, - container: { padding: 20, paddingBottom: 40 }, - - header: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 16, - justifyContent: 'center', - position: 'relative', + safe: { flex: 1, paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0 }, + headerFixed: { paddingHorizontal: 20, paddingBottom: 15 }, + topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', height: 70 }, + backBtnPremium: { + width: 42, height: 42, borderRadius: 14, + justifyContent: 'center', alignItems: 'center', + borderWidth: 1, elevation: 2, shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2 }, - backButtonHeader: { position: 'absolute', left: 0, padding: 4 }, - title: { fontSize: 24, fontWeight: '700', textAlign: 'center' }, - - alunosList: { gap: 12 }, - alunoCard: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - padding: 16, - borderRadius: 14, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - }, - alunoName: { fontSize: 16, fontWeight: '600', marginLeft: 12, flex: 1 }, - - sumariosContainer: { marginTop: 10 }, - alunoTitle: { fontSize: 20, fontWeight: '700', marginBottom: 12 }, - sumarioCard: { padding: 16, borderRadius: 14, marginBottom: 12 }, - dia: { fontSize: 14, fontWeight: '700', marginBottom: 6 }, - conteudo: { fontSize: 14, lineHeight: 20 }, -}); + title: { fontSize: 22, fontWeight: '800' }, + searchBox: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 15, paddingHorizontal: 15, height: 50, marginTop: 10 }, + searchInput: { flex: 1, marginLeft: 10, fontSize: 15, fontWeight: '500' }, + scrollContent: { paddingHorizontal: 20, paddingBottom: 30 }, + section: { marginBottom: 25 }, + turmaBadge: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 10, alignSelf: 'flex-start', marginBottom: 12 }, + turmaLabel: { fontSize: 12, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.8 }, + card: { flexDirection: 'row', alignItems: 'center', padding: 14, borderRadius: 22, marginBottom: 10, elevation: 3, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.04, shadowRadius: 10 }, + avatar: { width: 46, height: 46, borderRadius: 15, justifyContent: 'center', alignItems: 'center' }, + avatarText: { fontSize: 18, fontWeight: '800' }, + info: { flex: 1, marginLeft: 15 }, + nome: { fontSize: 16, fontWeight: '700' }, + subText: { fontSize: 12, marginTop: 2, fontWeight: '500' }, + centered: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'flex-end' }, + modalContent: { height: '85%', borderTopLeftRadius: 30, borderTopRightRadius: 30, overflow: 'hidden' }, + modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 25, borderBottomWidth: 1 }, + modalTitle: { fontSize: 20, fontWeight: '800' }, + modalSubtitle: { fontSize: 14, fontWeight: '600', marginTop: 2 }, + closeBtn: { width: 36, height: 36, borderRadius: 12, justifyContent: 'center', alignItems: 'center' }, + sumarioCard: { padding: 16, borderRadius: 18, marginBottom: 15, borderWidth: 1 }, + sumarioHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 }, + sumarioData: { fontSize: 13, fontWeight: '800', marginLeft: 6 }, + sumarioTexto: { fontSize: 15, lineHeight: 22, fontWeight: '500' }, + emptyState: { alignItems: 'center', marginTop: 50 }, + emptyText: { textAlign: 'center', marginTop: 15, fontSize: 14, fontWeight: '600' } +}); \ No newline at end of file diff --git a/app/lib/supabase.ts b/app/lib/supabase.ts index b6aff2a..4e09074 100644 --- a/app/lib/supabase.ts +++ b/app/lib/supabase.ts @@ -1,32 +1,31 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' -import { createClient, processLock } from '@supabase/supabase-js' -import { AppState, Platform } from 'react-native' -import 'react-native-url-polyfill/auto' +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { createClient } from '@supabase/supabase-js'; +import { AppState, Platform } from 'react-native'; +import 'react-native-url-polyfill/auto'; -const supabaseUrl = 'https://ssorfpctjeujolmtkfib.supabase.co' -const supabaseAnonKey = 'sb_publishable_SDocGprdYkUKi04FyfVqmA_Ykirp9cK' +// Substitui pelas tuas credenciais se necessário (estas são as que enviaste) +const supabaseUrl = 'https://ssorfpctjeujolmtkfib.supabase.co'; +const supabaseAnonKey = 'sb_publishable_SDocGprdYkUKi04FyfVqmA_Ykirp9cK'; export const supabase = createClient(supabaseUrl, supabaseAnonKey, { auth: { - ...(Platform.OS !== "web" ? { storage: AsyncStorage } : {}), + // No React Native usamos o AsyncStorage para persistir o login + storage: AsyncStorage, autoRefreshToken: true, persistSession: true, detectSessionInUrl: false, - lock: processLock, + // Nota: Removido o 'lock' e o 'lockAcquireTimeout' para evitar erros de TS + // O Supabase v2 no mobile já gere o fluxo de tokens de forma mais estável sem eles. }, -}) +}); -// Tells Supabase Auth to continuously refresh the session automatically -// if the app is in the foreground. When this is added, you will continue -// to receive `onAuthStateChange` events with the `TOKEN_REFRESHED` or -// `SIGNED_OUT` event if the user's session is terminated. This should -// only be registered once. +// Garante que o refresh do token só acontece quando a app está visível (Foreground) if (Platform.OS !== "web") { AppState.addEventListener('change', (state) => { if (state === 'active') { - supabase.auth.startAutoRefresh() + supabase.auth.startAutoRefresh(); } else { - supabase.auth.stopAutoRefresh() + supabase.auth.stopAutoRefresh(); } - }) + }); } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8ad5f68..acceb8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,11 @@ "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "@supabase/supabase-js": "^2.91.0", + "base64-arraybuffer": "^1.0.2", "expo": "~54.0.27", "expo-constants": "~18.0.11", "expo-document-picker": "~14.0.8", + "expo-file-system": "~19.0.21", "expo-font": "~14.0.10", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", @@ -43,6 +45,7 @@ "react-native-worklets": "0.5.1" }, "devDependencies": { + "@types/base64-arraybuffer": "^0.1.0", "@types/react": "~19.1.0", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", @@ -3597,6 +3600,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/base64-arraybuffer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@types/base64-arraybuffer/-/base64-arraybuffer-0.1.0.tgz", + "integrity": "sha512-oyV0CGER7tX6OlfnLfGze0XbsA7tfRuTtsQ2JbP8K5KBUzc24yoYRD+0XjMRQgOejvZWeIbtkNaHlE8akzj4aQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4872,6 +4882,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", diff --git a/package.json b/package.json index 13a8849..0b18669 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,11 @@ "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "@supabase/supabase-js": "^2.91.0", + "base64-arraybuffer": "^1.0.2", "expo": "~54.0.27", "expo-constants": "~18.0.11", "expo-document-picker": "~14.0.8", + "expo-file-system": "~19.0.21", "expo-font": "~14.0.10", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", @@ -46,6 +48,7 @@ "react-native-worklets": "0.5.1" }, "devDependencies": { + "@types/base64-arraybuffer": "^0.1.0", "@types/react": "~19.1.0", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0",