Files
avante-movil/src/screens/ProjectsScreen.tsx
T

214 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
FlatList,
RefreshControl,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import * as api from '../api/endpoints';
import { Project } from '../api/types';
import { useSession } from '../auth/session';
import { getProjects, saveProjectList, saveTemplates, setActiveProjectId } from '../db/repositories';
import { isOnline } from '../net/connectivity';
import { runSync } from '../sync/engine';
import { RootStackParamList } from '../navigation/types';
import { COLORS, PrimaryButton } from '../ui/components';
type Props = NativeStackScreenProps<RootStackParamList, 'Projects'>;
/** Tolerante con la forma de la respuesta: `{projects}` (actual), `{data}` o array. */
function normalize(res: unknown): Project[] {
if (Array.isArray(res)) return res as Project[];
const obj = (res ?? {}) as { projects?: Project[]; data?: Project[] };
return obj.projects ?? obj.data ?? [];
}
export function ProjectsScreen({ navigation }: Props) {
const { user, signOut } = useSession();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false);
const [opening, setOpening] = useState<number | null>(null);
const [showMenu, setShowMenu] = useState(false);
const dropdownRef = useRef<View>(null);
const load = useCallback(async () => {
setLoading(true);
try {
if (await isOnline()) {
const list = normalize(await api.listProjects());
await saveProjectList(list);
// Prefetch del catálogo global de plantillas: así quedan SIEMPRE en
// local y se puede inspeccionar offline aunque el proyecto no se haya
// abierto todavía. No bloquea la lista si /templates falla.
try {
const { templates } = await api.getTemplates();
await saveTemplates(templates);
} catch (e) {
console.warn('No se pudieron prefetchar las plantillas:', e);
}
}
setProjects(await getProjects());
} catch (e) {
console.warn('No se pudo refrescar /projects, usando datos locales:', e);
setProjects(await getProjects()); // fallback offline
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const openProject = useCallback(
async (p: Project) => {
setOpening(p.id);
try {
await setActiveProjectId(p.id);
// runSync ya resuelve ambos casos: sin cursor baja el snapshot completo,
// con cursor baja el delta (y vacía primero el outbox, vacío en la 1ª vez).
if (await isOnline()) await runSync(p.id);
navigation.navigate('ProjectDetail', { projectId: p.id, name: p.name });
} finally {
setOpening(null);
}
},
[navigation],
);
const handleLogout = () => {
setShowMenu(false);
signOut();
};
const handleSettings = () => {
setShowMenu(false);
navigation.navigate('Settings');
};
return (
<View style={styles.container}>
{/* Header with dropdown menu */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Proyectos</Text>
<TouchableOpacity
ref={dropdownRef}
style={styles.dropdownWrapper}
onPress={() => setShowMenu(!showMenu)}
activeOpacity={1}
>
<Text style={styles.dropdownLabel}>
{user?.name ?? 'Usuario'}
<Text style={styles.chevron}> </Text>
</Text>
{showMenu && (
<View style={styles.dropdownMenu} pointerEvents="box-none">
<TouchableOpacity style={styles.dropdownItem} onPress={handleSettings} activeOpacity={0.7}>
<Text style={styles.dropdownItemText}> Configuración</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.dropdownItem, styles.dropdownItemDanger]} onPress={handleLogout} activeOpacity={0.7}>
<Text style={styles.dropdownItemText}>Salir</Text>
</TouchableOpacity>
</View>
)}
</TouchableOpacity>
</View>
{/* Close dropdown when tapping outside */}
<TouchableOpacity onPress={() => setShowMenu(false)} style={styles.touchOutside}>
<FlatList
data={projects}
keyExtractor={(p) => String(p.id)}
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
ListEmptyComponent={loading ? null : <Text style={styles.empty}>No hay proyectos.</Text>}
renderItem={({ item }) => (
<TouchableOpacity style={styles.card} onPress={() => openProject(item)} activeOpacity={0.7}>
<View style={styles.cardContent}>
<Text style={styles.cardName}>{item.name}</Text>
{item.reference ? <Text style={styles.cardMeta}>Ref: {item.reference}</Text> : null}
{item.address ? <Text style={styles.cardMeta}>📍 {item.address}</Text> : null}
{item.status ? <Text style={styles.cardMeta}>Estado: {item.status}</Text> : null}
</View>
{opening === item.id ? (
<ActivityIndicator />
) : (
<Text style={styles.chevron}></Text>
)}
</TouchableOpacity>
)}
/>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
},
headerTitle: { fontSize: 20, fontWeight: '700', color: COLORS.primary },
dropdownWrapper: {
position: 'relative',
},
dropdownLabel: {
fontSize: 16,
fontWeight: '600',
color: '#333',
},
chevron: { fontSize: 14, color: '#666' },
dropdownMenu: {
position: 'absolute',
top: 40,
right: 0,
backgroundColor: '#fff',
borderRadius: 8,
borderWidth: 1,
borderColor: COLORS.border,
overflow: 'hidden',
minWidth: 180,
elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
zIndex: 100,
},
dropdownItem: {
paddingHorizontal: 16,
paddingVertical: 12,
},
dropdownItemDanger: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: COLORS.border,
},
dropdownItemText: {
fontSize: 15,
color: '#333',
},
touchOutside: {
flex: 1,
},
card: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 16,
borderTopWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border,
backgroundColor: '#fff',
},
cardContent: { flex: 1 },
cardName: { fontSize: 16, fontWeight: '600', color: '#111' },
cardMeta: { fontSize: 13, color: COLORS.muted, marginTop: 4 },
empty: { textAlign: 'center', marginTop: 40, color: '#888' },
});