atualizacoes
This commit is contained in:
15
app.json
15
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<Record<string, boolean>>({});
|
||||
const [faltas, setFaltas] = useState<Record<string, boolean>>({});
|
||||
const [sumarios, setSumarios] = useState<Record<string, string>>({});
|
||||
const [faltasJustificadas, setFaltasJustificadas] = useState<Record<string, any>>({});
|
||||
const [urlsJustificacao, setUrlsJustificacao] = useState<Record<string, string>>({});
|
||||
|
||||
const [pdf, setPdf] = useState<any>(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<string, boolean> = {};
|
||||
const objFaltas: Record<string, boolean> = {};
|
||||
const objSumarios: Record<string, string> = {};
|
||||
const objUrls: Record<string, string> = {};
|
||||
|
||||
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 (
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: themeStyles.fundo }]}>
|
||||
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
|
||||
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity onPress={() => router.push('/Aluno/perfil')}>
|
||||
<Ionicons name="person-circle-outline" size={32} color={themeStyles.texto} />
|
||||
@@ -228,14 +301,14 @@ const AlunoHome = memo(() => {
|
||||
|
||||
<View style={styles.botoesLinha}>
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, styles.btnPresenca, (!infoData.podeMarcarPresenca || presencas[selectedDate] || faltas[selectedDate]) && styles.disabled]}
|
||||
style={[styles.btn, { backgroundColor: themeStyles.azul }, (!infoData.podeMarcarPresenca || presencas[selectedDate] || faltas[selectedDate]) && styles.disabled]}
|
||||
onPress={handlePresenca}
|
||||
disabled={!infoData.podeMarcarPresenca || !!presencas[selectedDate] || !!faltas[selectedDate] || isLocating}
|
||||
>
|
||||
{isLocating ? <ActivityIndicator color="#fff" /> : <Text style={styles.txtBtn}>Marcar Presença</Text>}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, styles.btnFalta, (!infoData.valida || presencas[selectedDate] || faltas[selectedDate]) && styles.disabled]}
|
||||
style={[styles.btn, { backgroundColor: themeStyles.vermelho }, (!infoData.valida || presencas[selectedDate] || faltas[selectedDate]) && styles.disabled]}
|
||||
onPress={handleFalta}
|
||||
disabled={!infoData.valida || !!presencas[selectedDate] || !!faltas[selectedDate]}
|
||||
>
|
||||
@@ -243,31 +316,41 @@ const AlunoHome = memo(() => {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={[styles.cardCalendar, { backgroundColor: themeStyles.card }]}>
|
||||
<View style={[styles.cardCalendar, { backgroundColor: themeStyles.card, borderColor: themeStyles.borda }]}>
|
||||
{isLoadingDB && (
|
||||
<View style={styles.loaderOverlay}>
|
||||
<ActivityIndicator size="large" color={themeStyles.azul} />
|
||||
</View>
|
||||
)}
|
||||
<Calendar
|
||||
key={isDarkMode ? 'dark' : 'light'}
|
||||
theme={{ calendarBackground: themeStyles.card, dayTextColor: themeStyles.texto, monthTextColor: themeStyles.texto, todayTextColor: '#0d6efd', arrowColor: '#0d6efd' }}
|
||||
theme={{
|
||||
calendarBackground: themeStyles.card,
|
||||
dayTextColor: themeStyles.texto,
|
||||
monthTextColor: themeStyles.texto,
|
||||
todayTextColor: themeStyles.azul,
|
||||
arrowColor: themeStyles.azul,
|
||||
selectedDayBackgroundColor: themeStyles.azul
|
||||
}}
|
||||
markedDates={diasMarcados}
|
||||
onDayPress={(day) => { setSelectedDate(day.dateString); setEditandoSumario(false); }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{infoData.nomeFeriado && (
|
||||
<View style={styles.cardFeriado}>
|
||||
<Ionicons name="gift-outline" size={18} color="#0dcaf0" />
|
||||
<Text style={styles.txtFeriado}>
|
||||
{selectedDate.endsWith('-06-24') ? "É Feriado em Vila do Conde: São João" : `Feriado: ${infoData.nomeFeriado}`}
|
||||
</Text>
|
||||
<View style={[styles.cardFeriado, { backgroundColor: themeStyles.azul + '15' }]}>
|
||||
<Ionicons name="gift-outline" size={18} color={themeStyles.azul} />
|
||||
<Text style={[styles.txtFeriado, { color: themeStyles.azul }]}>{infoData.nomeFeriado}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{presencas[selectedDate] && (
|
||||
<View style={[styles.card, { backgroundColor: themeStyles.card }]}>
|
||||
<View style={[styles.card, { backgroundColor: themeStyles.card, borderColor: themeStyles.borda }]}>
|
||||
<View style={styles.rowTitle}>
|
||||
<Text style={[styles.cardTitulo, { color: themeStyles.texto }]}>Sumário do Dia</Text>
|
||||
{!editandoSumario && (
|
||||
<TouchableOpacity onPress={() => setEditandoSumario(true)}>
|
||||
<Ionicons name="create-outline" size={22} color="#0d6efd" />
|
||||
<Ionicons name="create-outline" size={22} color={themeStyles.azul} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
@@ -280,7 +363,7 @@ const AlunoHome = memo(() => {
|
||||
placeholderTextColor={themeStyles.textoSecundario}
|
||||
/>
|
||||
{editandoSumario && (
|
||||
<TouchableOpacity style={styles.btnGuardar} onPress={guardarSumario}>
|
||||
<TouchableOpacity style={[styles.btnAcao, { backgroundColor: themeStyles.verde }]} onPress={guardarSumario}>
|
||||
<Text style={styles.txtBtn}>Guardar Sumário</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
@@ -288,20 +371,24 @@ const AlunoHome = memo(() => {
|
||||
)}
|
||||
|
||||
{faltas[selectedDate] && (
|
||||
<View style={[styles.card, { backgroundColor: themeStyles.card }]}>
|
||||
<View style={[styles.card, { backgroundColor: themeStyles.card, borderColor: themeStyles.borda }]}>
|
||||
<Text style={[styles.cardTitulo, { color: themeStyles.texto, marginBottom: 15 }]}>Justificação de Falta</Text>
|
||||
{faltasJustificadas[selectedDate] ? (
|
||||
<TouchableOpacity style={styles.btnVer} onPress={() => visualizarDocumento(faltasJustificadas[selectedDate].uri)}>
|
||||
{urlsJustificacao[selectedDate] ? (
|
||||
<TouchableOpacity style={[styles.btnVer, { backgroundColor: themeStyles.textoSecundario }]} onPress={() => visualizarDocumento(urlsJustificacao[selectedDate])}>
|
||||
<Ionicons name="document-text-outline" size={20} color="#fff" />
|
||||
<Text style={[styles.txtBtn, { marginLeft: 8 }]}>Ver Justificação (PDF)</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View>
|
||||
<TouchableOpacity style={[styles.btnAnexar, { borderColor: themeStyles.borda }]} onPress={escolherPDF}>
|
||||
<Text style={{ color: themeStyles.texto }}>{pdf ? pdf.name : 'Anexar PDF'}</Text>
|
||||
<Text style={{ color: themeStyles.texto }}>{pdf ? pdf.name : 'Selecionar PDF'}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.btnGuardar, !pdf && styles.disabled]} onPress={enviarJustificacao} disabled={!pdf}>
|
||||
<Text style={styles.txtBtn}>Enviar Justificação</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.btnAcao, { backgroundColor: themeStyles.verde }, (!pdf || isUploading) && styles.disabled]}
|
||||
onPress={enviarJustificacao}
|
||||
disabled={!pdf || isUploading}
|
||||
>
|
||||
{isUploading ? <ActivityIndicator color="#fff" /> : <Text style={styles.txtBtn}>Enviar para o Servidor</Text>}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
@@ -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;
|
||||
@@ -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<any>(null);
|
||||
const [estagio, setEstagio] = useState<any>(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 <ActivityIndicator style={{flex:1}} color="#0d6efd" />;
|
||||
// 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 <View style={[styles.centered, { backgroundColor: themeStyles.fundo }]}><ActivityIndicator size="large" color={themeStyles.azul} /></View>;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.safe, { backgroundColor: themeStyles.fundo }]}>
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<Text style={[styles.tituloGeral, { color: themeStyles.texto }]}>O Meu Perfil</Text>
|
||||
|
||||
{/* Dados Pessoais vindos da tabela PROFILES */}
|
||||
<View style={[styles.card, { backgroundColor: themeStyles.card }]}>
|
||||
<Text style={styles.tituloCard}>Dados Pessoais</Text>
|
||||
<LabelValor label="Nome" valor={perfil?.nome} theme={themeStyles} />
|
||||
<LabelValor label="Idade" valor={`${perfil?.idade} anos`} theme={themeStyles} />
|
||||
<LabelValor label="Residência" valor={perfil?.residencia} theme={themeStyles} />
|
||||
<LabelValor label="Telemóvel" valor={perfil?.telefone} theme={themeStyles} />
|
||||
</View>
|
||||
|
||||
{/* Dados da Empresa vindos da tabela EMPRESAS via Estágio */}
|
||||
<View style={[styles.card, { backgroundColor: themeStyles.card }]}>
|
||||
<Text style={styles.tituloCard}>Local de Estágio</Text>
|
||||
<LabelValor label="Empresa" valor={estagio?.empresas?.nome} theme={themeStyles} />
|
||||
<LabelValor label="Tutor" valor={estagio?.empresas?.tutor_nome} theme={themeStyles} />
|
||||
<LabelValor label="Contacto Tutor" valor={estagio?.empresas?.tutor_telefone} theme={themeStyles} />
|
||||
<LabelValor label="Morada" valor={estagio?.empresas?.morada} theme={themeStyles} />
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.btnSair}
|
||||
onPress={async () => { await supabase.auth.signOut(); router.replace('/'); }}
|
||||
>
|
||||
<Text style={{color: '#fff', fontWeight: 'bold'}}>Sair da Conta</Text>
|
||||
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
|
||||
|
||||
<View style={styles.topNav}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||
<Ionicons name="arrow-back" size={24} color={themeStyles.texto} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
|
||||
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.avatarCircle, { backgroundColor: themeStyles.azul }]}>
|
||||
<Text style={styles.avatarText}>{perfil?.nome?.charAt(0).toUpperCase()}</Text>
|
||||
</View>
|
||||
<Text style={[styles.userName, { color: themeStyles.texto }]}>{perfil?.nome}</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.btnEdit, { backgroundColor: isEditing ? '#10B981' : themeStyles.card, borderColor: themeStyles.borda }]}
|
||||
onPress={isEditing ? salvarPerfil : () => setIsEditing(true)}
|
||||
>
|
||||
<Ionicons name={isEditing ? "checkmark-outline" : "create-outline"} size={18} color={isEditing ? "#fff" : themeStyles.azul} />
|
||||
<Text style={[styles.btnEditText, { color: isEditing ? "#fff" : themeStyles.texto }]}>
|
||||
{isEditing ? "Guardar" : "Editar Perfil"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* PROGRESSO */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: themeStyles.secundario }]}>Progresso do Estágio</Text>
|
||||
<View style={[styles.card, { backgroundColor: themeStyles.card, borderColor: themeStyles.borda }]}>
|
||||
<View style={styles.statsGrid}>
|
||||
<StatBox label="Realizadas" valor={`${horasRealizadas}h`} cor={themeStyles.azul} theme={themeStyles} />
|
||||
<StatBox label="Faltam" valor={`${horasRestantes}h`} cor="#F59E0B" theme={themeStyles} />
|
||||
<StatBox label="Faltas" valor={contagemFaltas.toString()} cor={themeStyles.vermelho} theme={themeStyles} />
|
||||
</View>
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={[styles.progressBarBase, { backgroundColor: themeStyles.fundo }]}>
|
||||
<View style={[styles.progressBarFill, { width: `${progresso * 100}%`, backgroundColor: themeStyles.azul }]} />
|
||||
</View>
|
||||
<Text style={[styles.progressText, { color: themeStyles.secundario }]}>
|
||||
{Math.round(progresso * 100)}% das {horasTotais}h concluídas (Dias Úteis)
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* DADOS ACADÉMICOS */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: themeStyles.secundario }]}>Dados de Aluno</Text>
|
||||
<View style={[styles.card, { backgroundColor: themeStyles.card, borderColor: themeStyles.borda }]}>
|
||||
<InfoRow icon="id-card-outline" label="Nº de Aluno" valor={perfil?.n_escola || '---'} theme={themeStyles} />
|
||||
<Divider theme={themeStyles} />
|
||||
<InfoRow icon="mail-outline" label="E-mail Institucional" valor={perfil?.email} theme={themeStyles} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* INFORMAÇÃO PESSOAL */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: themeStyles.secundario }]}>Informação Pessoal</Text>
|
||||
<View style={[styles.card, { backgroundColor: themeStyles.card, borderColor: themeStyles.borda }]}>
|
||||
<EditableRow icon="person-outline" label="Nome Completo" value={perfil?.nome} isEditing={isEditing} onChange={(t: string) => setPerfil({...perfil, nome: t})} theme={themeStyles} />
|
||||
<Divider theme={themeStyles} />
|
||||
|
||||
<EditableRow
|
||||
icon="calendar-outline"
|
||||
label="Data de Nascimento"
|
||||
value={perfil?.data_nascimento}
|
||||
isEditing={isEditing}
|
||||
onChange={(t: string) => setPerfil({...perfil, data_nascimento: aplicarMascaraData(t)})}
|
||||
theme={themeStyles}
|
||||
keyboard="numeric"
|
||||
limit={10}
|
||||
/>
|
||||
|
||||
<Divider theme={themeStyles} />
|
||||
|
||||
<InfoRow
|
||||
icon="hourglass-outline"
|
||||
label="Idade Atual"
|
||||
valor={calcularIdade(perfil?.data_nascimento) !== '--' ? `${calcularIdade(perfil?.data_nascimento)} anos` : '---'}
|
||||
theme={themeStyles}
|
||||
/>
|
||||
|
||||
<Divider theme={themeStyles} />
|
||||
<EditableRow icon="call-outline" label="Telemóvel" value={perfil?.telefone} isEditing={isEditing} onChange={(t: string) => setPerfil({...perfil, telefone: t})} theme={themeStyles} keyboard="phone-pad" />
|
||||
<Divider theme={themeStyles} />
|
||||
<EditableRow icon="location-outline" label="Residência" value={perfil?.residencia} isEditing={isEditing} onChange={(t: string) => setPerfil({...perfil, residencia: t})} theme={themeStyles} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* EMPRESA E DATAS DO ESTÁGIO */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: themeStyles.secundario }]}>Horário e Local</Text>
|
||||
<View style={[styles.card, { backgroundColor: themeStyles.card, borderColor: themeStyles.borda }]}>
|
||||
<InfoRow icon="calendar-outline" label="Início do Estágio" valor={formatarParaExibir(estagio?.data_inicio)} theme={themeStyles} />
|
||||
<Divider theme={themeStyles} />
|
||||
<InfoRow icon="flag-outline" label="Fim do Estágio" valor={formatarParaExibir(estagio?.data_fim)} theme={themeStyles} />
|
||||
<Divider theme={themeStyles} />
|
||||
<InfoRow icon="time-outline" label="Horário Diário" valor={estagio?.horario || "09:00 - 17:00"} theme={themeStyles} />
|
||||
<Divider theme={themeStyles} />
|
||||
<InfoRow icon="business-outline" label="Empresa" valor={estagio?.empresas?.nome} theme={themeStyles} />
|
||||
<Divider theme={themeStyles} />
|
||||
<InfoRow icon="person-outline" label="Tutor" valor={estagio?.empresas?.tutor_nome} theme={themeStyles} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function LabelValor({ label, valor, theme }: any) {
|
||||
// --- AUXILIARES (IGUAIS) ---
|
||||
function StatBox({ label, valor, cor, theme }: any) {
|
||||
return (
|
||||
<View style={{ marginBottom: 12 }}>
|
||||
<Text style={{ fontSize: 11, color: theme.textoSecundario, textTransform: 'uppercase' }}>{label}</Text>
|
||||
<Text style={{ fontSize: 16, color: theme.texto, fontWeight: '600' }}>{valor || '---'}</Text>
|
||||
<View style={styles.statBox}>
|
||||
<Text style={[styles.statValor, { color: cor }]}>{valor}</Text>
|
||||
<Text style={[styles.statLabel, { color: theme.secundario }]}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableRow({ icon, label, value, isEditing, onChange, theme, keyboard = "default", limit }: any) {
|
||||
return (
|
||||
<View style={styles.infoRow}>
|
||||
<Ionicons name={icon} size={20} color={theme.azul} style={{ width: 30 }} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.infoLabel, { color: theme.secundario }]}>{label}</Text>
|
||||
{isEditing ? (
|
||||
<TextInput
|
||||
style={[styles.input, { color: theme.texto, borderBottomColor: theme.azul }]}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
keyboardType={keyboard}
|
||||
maxLength={limit}
|
||||
placeholder={label.includes("Data") ? "DD-MM-AAAA" : ""}
|
||||
placeholderTextColor={theme.secundario}
|
||||
/>
|
||||
) : (
|
||||
<Text style={[styles.infoValor, { color: theme.texto }]}>{value || '---'}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ icon, label, valor, theme }: any) {
|
||||
return (
|
||||
<View style={styles.infoRow}>
|
||||
<Ionicons name={icon} size={20} color={theme.azul} style={{ width: 30 }} />
|
||||
<View style={styles.infoContent}>
|
||||
<Text style={[styles.infoLabel, { color: theme.secundario }]}>{label}</Text>
|
||||
<Text style={[styles.infoValor, { color: theme.texto }]}>{valor || '---'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const Divider = ({ theme }: any) => <View style={[styles.divider, { backgroundColor: theme.borda }]} />;
|
||||
|
||||
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 }
|
||||
});
|
||||
@@ -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<Presenca[]>([]);
|
||||
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 (
|
||||
<SafeAreaView style={[styles.safe, { backgroundColor: cores.fundo }]}>
|
||||
{/* StatusBar configurada para não sobrepor o conteúdo */}
|
||||
<StatusBar
|
||||
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
|
||||
translucent
|
||||
backgroundColor="transparent"
|
||||
/>
|
||||
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} translucent backgroundColor="transparent" />
|
||||
|
||||
{/* Header com design idêntico ao anterior */}
|
||||
{/* HEADER PREMIUM */}
|
||||
<View style={styles.headerFixed}>
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||
<Ionicons name="arrow-back" size={24} color={cores.texto} />
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={22} color={cores.texto} />
|
||||
</TouchableOpacity>
|
||||
<View style={{ alignItems: 'center' }}>
|
||||
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[styles.title, { color: cores.texto }]}>Histórico</Text>
|
||||
<Text style={[styles.subtitle, { color: cores.secundario }]}>{nome}</Text>
|
||||
<Text style={[styles.subtitle, { color: cores.secundario }]} numberOfLines={1}>{nomeStr}</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={fetchHistorico} style={styles.refreshBtn}>
|
||||
<Ionicons name="refresh" size={22} color={cores.azul} />
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={fetchHistorico}
|
||||
style={[styles.refreshBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]}
|
||||
>
|
||||
<Ionicons name="refresh" size={20} color={cores.azul} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -97,27 +136,30 @@ export default function HistoricoPresencas() {
|
||||
<ActivityIndicator size="large" color={cores.azul} />
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
{presencas.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Ionicons name="calendar-outline" size={48} color={cores.secundario} style={{ opacity: 0.5 }} />
|
||||
<Text style={[styles.emptyText, { color: cores.secundario }]}>
|
||||
Sem registos reais para este aluno.
|
||||
</Text>
|
||||
<Text style={[styles.emptyText, { color: cores.secundario }]}>Sem registos para este aluno.</Text>
|
||||
</View>
|
||||
) : (
|
||||
presencas.map((item) => {
|
||||
const isPresente = item.estado === 'presente';
|
||||
|
||||
return (
|
||||
<View key={item.id} style={[styles.card, { backgroundColor: cores.card, borderColor: cores.borda }]}>
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => handleNavigation(item)}
|
||||
style={[styles.card, { backgroundColor: cores.card, borderColor: cores.borda }]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
{/* Ícone de Calendário no lugar do Avatar para manter o peso visual */}
|
||||
<View style={[styles.iconBox, { backgroundColor: cores.azulSuave }]}>
|
||||
<Ionicons name="calendar" size={20} color={cores.azul} />
|
||||
<View style={[styles.iconBox, { backgroundColor: isPresente ? cores.azulSuave : 'rgba(239, 68, 68, 0.1)' }]}>
|
||||
<Ionicons
|
||||
name={isPresente ? "book-outline" : "alert-circle-outline"}
|
||||
size={20}
|
||||
color={isPresente ? cores.azul : cores.vermelho}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.info}>
|
||||
@@ -127,24 +169,30 @@ export default function HistoricoPresencas() {
|
||||
<View style={styles.statusBadge}>
|
||||
<View style={[styles.dot, { backgroundColor: isPresente ? cores.verde : cores.vermelho }]} />
|
||||
<Text style={[styles.subText, { color: cores.secundario }]}>
|
||||
{item.estado.toUpperCase()}
|
||||
{item.estado ? item.estado.toUpperCase() : '---'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{item.lat && (
|
||||
<Ionicons name="location-sharp" size={18} color={cores.azul} />
|
||||
)}
|
||||
<View style={styles.actionRow}>
|
||||
{item.lat && item.lng && (
|
||||
<TouchableOpacity
|
||||
onPress={() => abrirMapa(item.lat!, item.lng!)}
|
||||
style={styles.locationBtn}
|
||||
>
|
||||
<Ionicons name="location-sharp" size={20} color={cores.azul} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<Ionicons name="chevron-forward" size={18} color={cores.secundario} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Área do Sumário com o estilo de "nota" do ecrã anterior */}
|
||||
<View style={[styles.sumarioBox, { backgroundColor: isDarkMode ? 'rgba(255,255,255,0.03)' : '#F1F5F9' }]}>
|
||||
<Text style={[styles.sumarioLabel, { color: cores.secundario }]}>Sumário do Dia</Text>
|
||||
<Text style={[styles.sumarioText, { color: cores.texto }]}>
|
||||
{item.sumario || "O aluno ainda não preencheu o sumário deste dia."}
|
||||
<View style={[styles.footerCard, { borderTopColor: isDarkMode ? '#2D2D2D' : '#F1F5F9' }]}>
|
||||
<Text style={[styles.tapInfo, { color: isPresente ? cores.azul : cores.vermelho }]}>
|
||||
Ver {isPresente ? 'sumário detalhado' : 'comprovativo de falta'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})
|
||||
)}
|
||||
@@ -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' },
|
||||
|
||||
@@ -55,7 +55,6 @@ export default function Estagios() {
|
||||
borda: isDarkMode ? '#2D2D2D' : '#E2E8F0',
|
||||
}), [isDarkMode]);
|
||||
|
||||
// --- Estados ---
|
||||
const [estagios, setEstagios] = useState<Estagio[]>([]);
|
||||
const [alunos, setAlunos] = useState<Aluno[]>([]);
|
||||
const [empresas, setEmpresas] = useState<Empresa[]>([]);
|
||||
@@ -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<string, Empresa[]> = {};
|
||||
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<string, Estagio[]> = {};
|
||||
|
||||
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() {
|
||||
|
||||
<SafeAreaView style={{ flex: 1 }} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity style={[styles.btnCircle, { backgroundColor: cores.card }]} onPress={() => router.back()}>
|
||||
<Ionicons name="arrow-back" size={22} color={cores.texto}/>
|
||||
<TouchableOpacity
|
||||
style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={22} color={cores.texto}/>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.tituloGeral, { color: cores.texto }]}>Estágios</Text>
|
||||
<TouchableOpacity style={[styles.btnCircle, { backgroundColor: cores.card }]} onPress={fetchDados}>
|
||||
<TouchableOpacity
|
||||
style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]}
|
||||
onPress={fetchDados}
|
||||
>
|
||||
<Ionicons name="refresh" size={20} color={cores.azul}/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -241,7 +246,6 @@ export default function Estagios() {
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* RENDERING AGRUPADO AQUI */}
|
||||
{estagiosAgrupados.map(grupo => (
|
||||
<View key={grupo.titulo} style={{ marginBottom: 20 }}>
|
||||
<View style={[styles.turmaSectionHeader, { backgroundColor: cores.azulSuave }]}>
|
||||
@@ -296,19 +300,22 @@ export default function Estagios() {
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
|
||||
<Modal visible={modalVisible} animationType="fade" transparent>
|
||||
<Modal visible={modalVisible} animationType="slide" transparent>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={[styles.modalContent, { backgroundColor: cores.card }]}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={[styles.modalTitle, { color: cores.texto }]}>
|
||||
{passo === 1 ? "Seleção de Aluno/Empresa" : "Configuração de Estágio"}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleFecharModal}>
|
||||
<Ionicons name="close-circle" size={28} color={cores.secundario} />
|
||||
<View>
|
||||
<Text style={[styles.modalTitle, { color: cores.texto }]}>
|
||||
{passo === 1 ? "Seleção de Aluno/Empresa" : "Configuração de Estágio"}
|
||||
</Text>
|
||||
{passo === 2 && <Text style={[styles.miniLabel, {marginTop: 2}]}>{alunoSelecionado?.nome}</Text>}
|
||||
</View>
|
||||
<TouchableOpacity onPress={handleFecharModal} style={[styles.closeBtn, {backgroundColor: cores.fundo}]}>
|
||||
<Ionicons name="close" size={22} color={cores.texto} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{paddingBottom: 40}}>
|
||||
{passo === 1 ? (
|
||||
<View style={{ gap: 15 }}>
|
||||
<Text style={[styles.sectionLabel, { color: cores.secundario }]}>SELECIONE O ALUNO</Text>
|
||||
@@ -337,10 +344,10 @@ export default function Estagios() {
|
||||
<Text style={[styles.sectionLabel, { color: cores.secundario, marginTop: 10 }]}>SELECIONE A EMPRESA</Text>
|
||||
<View style={[styles.selectorContainer, { borderColor: cores.borda }]}>
|
||||
<ScrollView nestedScrollEnabled style={{maxHeight: 200}}>
|
||||
{Object.keys(empresasAgrupadas).map(c => (
|
||||
<View key={c}>
|
||||
<Text style={[styles.groupHead, { color: cores.azul, backgroundColor: cores.azulSuave }]}>{c}</Text>
|
||||
{empresasAgrupadas[c].map(emp => (
|
||||
{Object.keys(empresasAgrupadas).sort().map(curso => (
|
||||
<View key={curso}>
|
||||
<Text style={[styles.groupHead, { color: cores.azul, backgroundColor: cores.azulSuave }]}>{curso.toUpperCase()}</Text>
|
||||
{empresasAgrupadas[curso].map(emp => (
|
||||
<TouchableOpacity
|
||||
key={emp.id}
|
||||
style={[styles.item, empresaSelecionada?.id === emp.id && { backgroundColor: cores.azulSuave }]}
|
||||
@@ -364,11 +371,11 @@ export default function Estagios() {
|
||||
<View style={{flexDirection: 'row', gap: 10, marginTop: 8}}>
|
||||
<View style={{flex:1}}>
|
||||
<Text style={styles.miniLabel}>INÍCIO</Text>
|
||||
<TextInput style={[styles.editInput, { color: cores.texto }]} value={dataInicio} onChangeText={setDataInicio} placeholder="2024-09-01"/>
|
||||
<TextInput style={[styles.editInput, { color: cores.texto }]} value={dataInicio} onChangeText={setDataInicio} placeholder="AAAA-MM-DD"/>
|
||||
</View>
|
||||
<View style={{flex:1}}>
|
||||
<Text style={styles.miniLabel}>FIM</Text>
|
||||
<TextInput style={[styles.editInput, { color: cores.texto }]} value={dataFim} onChangeText={setDataFim} placeholder="2025-06-30"/>
|
||||
<TextInput style={[styles.editInput, { color: cores.texto }]} value={dataFim} onChangeText={setDataFim} placeholder="AAAA-MM-DD"/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.badge, { backgroundColor: cores.azul, marginTop: 12, alignSelf: 'flex-start' }]}>
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<Aluno | null>(null);
|
||||
const [faltas, setFaltas] = useState<Falta[]>([]);
|
||||
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<string, Aluno[]> = {};
|
||||
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<Aluno | null>(null);
|
||||
const [pdfModalVisible, setPdfModalVisible] = useState(false);
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(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 (
|
||||
<SafeAreaView style={[styles.safe, { backgroundColor: cores.fundo }]}>
|
||||
<StatusBar
|
||||
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
|
||||
backgroundColor={cores.fundo}
|
||||
/>
|
||||
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} translucent backgroundColor="transparent" />
|
||||
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
{/* Top Bar */}
|
||||
<View style={styles.headerFixed}>
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (alunoSelecionado) {
|
||||
setAlunoSelecionado(null);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
}}
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]}
|
||||
>
|
||||
<Ionicons name="arrow-back-outline" size={26} color={cores.azul} />
|
||||
<Ionicons name="chevron-back" size={22} color={cores.texto} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={[styles.topTitle, { color: cores.texto }]}>
|
||||
{!alunoSelecionado ? 'Faltas dos Alunos' : alunoSelecionado.nome}
|
||||
</Text>
|
||||
|
||||
<View style={{ width: 26 }} />
|
||||
<Text style={[styles.title, { color: cores.texto }]}>Gestão de Faltas</Text>
|
||||
<View style={{ width: 42 }} />
|
||||
</View>
|
||||
|
||||
{/* Lista de alunos */}
|
||||
{!alunoSelecionado && (
|
||||
<View style={{ gap: 12 }}>
|
||||
{alunosData.map(aluno => (
|
||||
<TouchableOpacity
|
||||
key={aluno.id}
|
||||
style={[styles.alunoCard, { backgroundColor: cores.card }]}
|
||||
onPress={() => setAlunoSelecionado(aluno)}
|
||||
>
|
||||
<Ionicons name="person-outline" size={28} color={cores.azul} />
|
||||
<Text style={[styles.alunoName, { color: cores.texto }]}>
|
||||
{aluno.nome}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-forward-outline"
|
||||
size={20}
|
||||
color={cores.textoSecundario}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
<View style={[styles.searchBox, { backgroundColor: cores.card, borderColor: cores.borda }]}>
|
||||
<Ionicons name="search" size={20} color={cores.secundario} />
|
||||
<TextInput
|
||||
placeholder="Procurar aluno..."
|
||||
placeholderTextColor={cores.secundario}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
style={[styles.searchInput, { color: cores.texto }]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Faltas do aluno */}
|
||||
{alunoSelecionado && (
|
||||
<View style={{ marginTop: 10 }}>
|
||||
{alunoSelecionado.faltas.map((falta, idx) => (
|
||||
<View
|
||||
key={idx}
|
||||
style={[styles.faltaCard, { backgroundColor: cores.card }]}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.dia, { color: cores.azul }]}>
|
||||
{falta.dia}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
styles.status,
|
||||
{
|
||||
color: falta.pdfUrl
|
||||
? cores.verde
|
||||
: cores.vermelho,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{falta.pdfUrl
|
||||
? 'Falta justificada'
|
||||
: 'Falta não justificada'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{falta.pdfUrl && (
|
||||
<TouchableOpacity onPress={() => verPdf(falta)}>
|
||||
<Ionicons
|
||||
name="document-text-outline"
|
||||
size={24}
|
||||
color={cores.azul}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{loading ? (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color={cores.azul} />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={filteredTurmas}
|
||||
keyExtractor={item => item.nome}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.section}>
|
||||
<View style={[styles.turmaBadge, { backgroundColor: cores.azulSuave }]}>
|
||||
<Text style={[styles.turmaLabel, { color: cores.azul }]}>{item.nome}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Modal PDF */}
|
||||
<Modal visible={pdfModalVisible} animationType="slide">
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: cores.fundo }}>
|
||||
<View
|
||||
style={[
|
||||
styles.pdfHeader,
|
||||
{ borderBottomColor: cores.textoSecundario },
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity onPress={() => setPdfModalVisible(false)}>
|
||||
<Ionicons name="close-outline" size={28} color={cores.azul} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.pdfTitle, { color: cores.texto }]}>
|
||||
Visualizador de PDF
|
||||
</Text>
|
||||
<View style={{ width: 28 }} />
|
||||
</View>
|
||||
{item.alunos.map(aluno => (
|
||||
<TouchableOpacity
|
||||
key={aluno.id}
|
||||
activeOpacity={0.7}
|
||||
style={[styles.card, { backgroundColor: cores.card }]}
|
||||
onPress={() => abrirFaltas(aluno)}
|
||||
>
|
||||
<View style={[styles.avatar, { backgroundColor: cores.azulSuave }]}>
|
||||
<Text style={[styles.avatarText, { color: cores.azul }]}>{aluno.nome.charAt(0)}</Text>
|
||||
</View>
|
||||
<View style={styles.info}>
|
||||
<Text style={[styles.nome, { color: cores.texto }]}>{aluno.nome}</Text>
|
||||
<Text style={[styles.subText, { color: cores.secundario }]}>Nº {aluno.n_escola}</Text>
|
||||
</View>
|
||||
<Ionicons name="alert-circle-outline" size={20} color={cores.vermelho} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pdfUrl && <WebView source={{ uri: pdfUrl }} style={{ flex: 1 }} />}
|
||||
</SafeAreaView>
|
||||
<Modal
|
||||
visible={modalFaltasVisible}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={() => setModalFaltasVisible(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={[styles.modalContent, { backgroundColor: cores.card }]}>
|
||||
<View style={[styles.modalHeader, { borderBottomColor: cores.borda }]}>
|
||||
<View>
|
||||
<Text style={[styles.modalTitle, { color: cores.texto }]}>Histórico de Faltas</Text>
|
||||
<Text style={[styles.modalSubtitle, { color: cores.secundario }]}>{alunoSelecionado?.nome}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => setModalFaltasVisible(false)}
|
||||
style={[styles.closeBtn, { backgroundColor: cores.fundo }]}
|
||||
>
|
||||
<Ionicons name="close" size={22} color={cores.texto} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{loadingFaltas ? (
|
||||
<ActivityIndicator style={{ marginTop: 50 }} color={cores.azul} />
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={{ padding: 20 }} showsVerticalScrollIndicator={false}>
|
||||
{faltas.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="checkmark-circle-outline" size={48} color={cores.verde} style={{ opacity: 0.3 }} />
|
||||
<Text style={[styles.emptyText, { color: cores.secundario }]}>Nenhuma falta registada.</Text>
|
||||
</View>
|
||||
) : (
|
||||
faltas.map(f => (
|
||||
<View key={f.id} style={[styles.faltaCard, { backgroundColor: cores.fundo, borderColor: cores.borda }]}>
|
||||
<View style={styles.faltaHeader}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Ionicons name="calendar-outline" size={16} color={cores.azul} style={{ marginRight: 8 }} />
|
||||
<Text style={[styles.faltaData, { color: cores.texto }]}>
|
||||
{new Date(f.data).toLocaleDateString('pt-PT', { day: '2-digit', month: 'long', year: 'numeric' })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.statusBadge, { backgroundColor: f.justificacao_url ? cores.verde + '20' : cores.vermelho + '20' }]}>
|
||||
<Text style={[styles.statusText, { color: f.justificacao_url ? cores.verde : cores.vermelho }]}>
|
||||
{f.justificacao_url ? 'Justificada' : 'Injustificada'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{f.justificacao_url ? (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
style={[styles.pdfBtn, { backgroundColor: cores.azulSuave }]}
|
||||
onPress={() => verDocumento(f.justificacao_url!)}
|
||||
>
|
||||
<Ionicons name="document-attach" size={18} color={cores.azul} />
|
||||
<Text style={[styles.pdfBtnText, { color: cores.azul }]}>Ver Comprovativo</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.noJustifContainer}>
|
||||
<Ionicons name="warning-outline" size={14} color={cores.vermelho} />
|
||||
<Text style={[styles.noJustifText, { color: cores.vermelho }]}>Aguardando justificação</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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' }
|
||||
});
|
||||
@@ -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(() => {
|
||||
<SafeAreaView style={[styles.safe, { backgroundColor: cores.fundo }]}>
|
||||
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} translucent backgroundColor="transparent" />
|
||||
|
||||
{/* HEADER FIXO ESTILO PREMIUM */}
|
||||
{/* HEADER FIXO */}
|
||||
<View style={styles.headerFixed}>
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||
<Ionicons name="arrow-back" size={24} color={cores.texto} />
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={22} color={cores.texto} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.title, { color: cores.texto }]}>Alunos</Text>
|
||||
<View style={{ width: 40 }} />
|
||||
<View style={{ width: 42 }} />
|
||||
</View>
|
||||
|
||||
<View style={[styles.searchBox, { backgroundColor: cores.card, borderColor: cores.borda }]}>
|
||||
@@ -134,7 +135,6 @@ const ListaAlunosProfessor = memo(() => {
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.section}>
|
||||
{/* BADGE DA TURMA */}
|
||||
<View style={[styles.turmaBadge, { backgroundColor: cores.azulSuave }]}>
|
||||
<Text style={[styles.turmaLabel, { color: cores.azul }]}>
|
||||
{item.nome} • {item.alunos.length} Alunos
|
||||
@@ -157,12 +157,10 @@ const ListaAlunosProfessor = memo(() => {
|
||||
{aluno.nome.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.info}>
|
||||
<Text style={[styles.nome, { color: cores.texto }]}>{aluno.nome}</Text>
|
||||
<Text style={[styles.subText, { color: cores.secundario }]}>Nº Escola: {aluno.n_escola}</Text>
|
||||
</View>
|
||||
|
||||
<Ionicons name="chevron-forward" size={18} color={cores.secundario} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
@@ -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' },
|
||||
});
|
||||
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;
|
||||
@@ -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<Aluno[]>([]);
|
||||
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<string, Aluno[]> = {};
|
||||
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 (
|
||||
<SafeAreaView style={[styles.safe, { backgroundColor: cores.fundo }]}>
|
||||
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} translucent backgroundColor="transparent" />
|
||||
|
||||
{/* HEADER PREMIUM */}
|
||||
<View style={styles.headerFixed}>
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||
<Ionicons name="arrow-back" size={24} color={cores.texto} />
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={22} color={cores.texto} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={[styles.title, { color: cores.texto }]}>Presenças</Text>
|
||||
<View style={{ width: 40 }} />
|
||||
<View style={{ width: 42 }} />
|
||||
</View>
|
||||
|
||||
<View style={[styles.searchBox, { backgroundColor: cores.card, borderColor: cores.borda }]}>
|
||||
<Ionicons name="search" size={20} color={cores.secundario} />
|
||||
<TextInput
|
||||
placeholder="Pesquisar aluno..."
|
||||
placeholder="Procurar aluno ou Nº..."
|
||||
placeholderTextColor={cores.secundario}
|
||||
value={pesquisa}
|
||||
onChangeText={setPesquisa}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
style={[styles.searchInput, { color: cores.texto }]}
|
||||
/>
|
||||
</View>
|
||||
@@ -102,43 +131,53 @@ export default function Presencas() {
|
||||
<ActivityIndicator size="large" color={cores.azul} />
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
{alunosFiltrados.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={{ color: cores.secundario }}>Nenhum aluno encontrado.</Text>
|
||||
<FlatList
|
||||
data={filteredTurmas}
|
||||
keyExtractor={item => item.nome}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.section}>
|
||||
<View style={[styles.turmaBadge, { backgroundColor: cores.azulSuave }]}>
|
||||
<Text style={[styles.turmaLabel, { color: cores.azul }]}>
|
||||
{item.nome} • {item.alunos.length} Alunos
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{item.alunos.map(aluno => (
|
||||
<TouchableOpacity
|
||||
key={aluno.id}
|
||||
style={[styles.card, { backgroundColor: cores.card }]}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/Professor/Alunos/CalendarioPresencas',
|
||||
params: { alunoId: aluno.id, nome: aluno.nome },
|
||||
})
|
||||
}
|
||||
>
|
||||
<View style={[styles.avatar, { backgroundColor: cores.azulSuave }]}>
|
||||
<Text style={[styles.avatarText, { color: cores.azul }]}>
|
||||
{aluno.nome.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.info}>
|
||||
<Text style={[styles.nome, { color: cores.texto }]}>{aluno.nome}</Text>
|
||||
<Text style={[styles.subText, { color: cores.secundario }]}>Ver registo de presenças</Text>
|
||||
</View>
|
||||
|
||||
<Ionicons name="chevron-forward" size={18} color={cores.secundario} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
alunosFiltrados.map(aluno => (
|
||||
<TouchableOpacity
|
||||
key={aluno.id}
|
||||
style={[styles.card, { backgroundColor: cores.card }]}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/Professor/Alunos/CalendarioPresencas',
|
||||
params: { alunoId: aluno.id, nome: aluno.nome },
|
||||
})
|
||||
}
|
||||
>
|
||||
<View style={[styles.avatar, { backgroundColor: cores.azulSuave }]}>
|
||||
<Text style={[styles.avatarText, { color: cores.azul }]}>
|
||||
{aluno.nome.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.info}>
|
||||
<Text style={[styles.nome, { color: cores.texto }]}>{aluno.nome}</Text>
|
||||
<Text style={[styles.subText, { color: cores.secundario }]}>Ver registo de presenças</Text>
|
||||
</View>
|
||||
|
||||
<Ionicons name="chevron-forward" size={18} color={cores.secundario} />
|
||||
</TouchableOpacity>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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 },
|
||||
});
|
||||
@@ -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<Aluno | null>(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<Aluno | null>(null);
|
||||
const [sumarios, setSumarios] = useState<Sumario[]>([]);
|
||||
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<string, Aluno[]> = {};
|
||||
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 (
|
||||
<SafeAreaView style={[styles.safe, { backgroundColor: cores.fundo }]}>
|
||||
<StatusBar
|
||||
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
|
||||
backgroundColor={cores.fundo}
|
||||
/>
|
||||
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} translucent backgroundColor="transparent" />
|
||||
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
{/* Cabeçalho */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
onPress={() => (alunoSelecionado ? setAlunoSelecionado(null) : router.back())}
|
||||
style={styles.backButtonHeader}
|
||||
<View style={styles.headerFixed}>
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={[styles.backBtnPremium, { backgroundColor: cores.card, borderColor: cores.borda }]}
|
||||
>
|
||||
<Ionicons name="arrow-back" size={24} color={cores.azul} />
|
||||
<Ionicons name="chevron-back" size={22} color={cores.texto} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={[styles.title, { color: cores.texto }]}>Sumários de Estágio</Text>
|
||||
<Text style={[styles.title, { color: cores.texto }]}>Sumários</Text>
|
||||
<View style={{ width: 42 }} />
|
||||
</View>
|
||||
|
||||
{/* Lista de alunos */}
|
||||
{!alunoSelecionado && (
|
||||
<View style={styles.alunosList}>
|
||||
{alunosData.map(aluno => (
|
||||
<TouchableOpacity
|
||||
key={aluno.id}
|
||||
style={[styles.alunoCard, { backgroundColor: cores.card }]}
|
||||
onPress={() => setAlunoSelecionado(aluno)}
|
||||
>
|
||||
<Ionicons name="person-outline" size={28} color={cores.azul} />
|
||||
<Text style={[styles.alunoName, { color: cores.texto }]}>{aluno.nome}</Text>
|
||||
<Ionicons name="chevron-forward-outline" size={20} color={cores.textoSecundario} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
<View style={[styles.searchBox, { backgroundColor: cores.card, borderColor: cores.borda }]}>
|
||||
<Ionicons name="search" size={20} color={cores.secundario} />
|
||||
<TextInput
|
||||
placeholder="Procurar aluno..."
|
||||
placeholderTextColor={cores.secundario}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
style={[styles.searchInput, { color: cores.texto }]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sumários do aluno */}
|
||||
{alunoSelecionado && (
|
||||
<View style={styles.sumariosContainer}>
|
||||
<Text style={[styles.alunoTitle, { color: cores.texto }]}>{alunoSelecionado.nome}</Text>
|
||||
|
||||
{alunoSelecionado.sumarios.map((s, idx) => (
|
||||
<View
|
||||
key={idx}
|
||||
style={[styles.sumarioCard, { backgroundColor: cores.card }]}
|
||||
>
|
||||
<Text style={[styles.dia, { color: cores.azul }]}>{s.dia}</Text>
|
||||
<Text style={[styles.conteudo, { color: cores.texto }]}>{s.conteudo}</Text>
|
||||
{loading ? (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color={cores.azul} />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={filteredTurmas}
|
||||
keyExtractor={item => item.nome}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.section}>
|
||||
<View style={[styles.turmaBadge, { backgroundColor: cores.azulSuave }]}>
|
||||
<Text style={[styles.turmaLabel, { color: cores.azul }]}>{item.nome}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{item.alunos.map(aluno => (
|
||||
<TouchableOpacity
|
||||
key={aluno.id}
|
||||
activeOpacity={0.7}
|
||||
style={[styles.card, { backgroundColor: cores.card }]}
|
||||
onPress={() => abrirSumarios(aluno)}
|
||||
>
|
||||
<View style={[styles.avatar, { backgroundColor: cores.azulSuave }]}>
|
||||
<Text style={[styles.avatarText, { color: cores.azul }]}>{aluno.nome.charAt(0)}</Text>
|
||||
</View>
|
||||
<View style={styles.info}>
|
||||
<Text style={[styles.nome, { color: cores.texto }]}>{aluno.nome}</Text>
|
||||
<Text style={[styles.subText, { color: cores.secundario }]}>Nº {aluno.n_escola}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={cores.secundario} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
visible={modalVisible}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={() => setModalVisible(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={[styles.modalContent, { backgroundColor: cores.card }]}>
|
||||
<View style={[styles.modalHeader, { borderBottomColor: cores.borda }]}>
|
||||
<View>
|
||||
<Text style={[styles.modalTitle, { color: cores.texto }]}>Sumários</Text>
|
||||
<Text style={[styles.modalSubtitle, { color: cores.secundario }]}>{alunoSelecionado?.nome}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => setModalVisible(false)}
|
||||
style={[styles.closeBtn, { backgroundColor: cores.fundo }]}
|
||||
>
|
||||
<Ionicons name="close" size={22} color={cores.texto} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{loadingSumarios ? (
|
||||
<ActivityIndicator style={{ marginTop: 50 }} color={cores.azul} />
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={{ padding: 20 }} showsVerticalScrollIndicator={false}>
|
||||
{sumarios.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="document-text-outline" size={48} color={cores.secundario} style={{ opacity: 0.3 }} />
|
||||
<Text style={[styles.emptyText, { color: cores.secundario }]}>Sem registos de sumários.</Text>
|
||||
</View>
|
||||
) : (
|
||||
sumarios.map(s => (
|
||||
<View key={s.id} style={[styles.sumarioCard, { backgroundColor: cores.fundo, borderColor: cores.borda }]}>
|
||||
<View style={styles.sumarioHeader}>
|
||||
<Ionicons name="calendar-outline" size={14} color={cores.azul} />
|
||||
<Text style={[styles.sumarioData, { color: cores.azul }]}>
|
||||
{new Date(s.data).toLocaleDateString('pt-PT', { day: '2-digit', month: '2-digit', year: 'numeric' })}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.sumarioTexto, { color: cores.texto }]}>{s.sumario}</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
);
|
||||
});
|
||||
|
||||
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' }
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user