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

214 lines
6.9 KiB
TypeScript
Raw Normal View History

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' },
});