2026-06-18 17:43:48 +02:00
|
|
|
|
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
2026-07-28 19:42:11 +02:00
|
|
|
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
2026-06-18 17:43:48 +02:00
|
|
|
|
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';
|
2026-07-14 14:08:40 +02:00
|
|
|
|
import { getProjects, saveProjectList, saveTemplates, setActiveProjectId } from '../db/repositories';
|
2026-06-18 17:43:48 +02:00
|
|
|
|
import { isOnline } from '../net/connectivity';
|
2026-07-14 11:58:20 +02:00
|
|
|
|
import { runSync } from '../sync/engine';
|
2026-06-18 17:43:48 +02:00
|
|
|
|
import { RootStackParamList } from '../navigation/types';
|
2026-07-28 19:42:11 +02:00
|
|
|
|
import { COLORS, PrimaryButton } from '../ui/components';
|
2026-06-18 17:43:48 +02:00
|
|
|
|
|
|
|
|
|
|
type Props = NativeStackScreenProps<RootStackParamList, 'Projects'>;
|
|
|
|
|
|
|
2026-07-07 18:03:18 +02:00
|
|
|
|
/** 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 ?? [];
|
2026-06-18 17:43:48 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-07-28 19:42:11 +02:00
|
|
|
|
const [showMenu, setShowMenu] = useState(false);
|
|
|
|
|
|
const dropdownRef = useRef<View>(null);
|
2026-06-18 17:43:48 +02:00
|
|
|
|
|
|
|
|
|
|
const load = useCallback(async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (await isOnline()) {
|
|
|
|
|
|
const list = normalize(await api.listProjects());
|
|
|
|
|
|
await saveProjectList(list);
|
2026-07-14 14:08:40 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-06-18 17:43:48 +02:00
|
|
|
|
}
|
|
|
|
|
|
setProjects(await getProjects());
|
2026-07-07 18:03:18 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn('No se pudo refrescar /projects, usando datos locales:', e);
|
2026-06-18 17:43:48 +02:00
|
|
|
|
setProjects(await getProjects()); // fallback offline
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
void load();
|
|
|
|
|
|
}, [load]);
|
|
|
|
|
|
|
|
|
|
|
|
const openProject = useCallback(
|
|
|
|
|
|
async (p: Project) => {
|
|
|
|
|
|
setOpening(p.id);
|
|
|
|
|
|
try {
|
2026-06-18 18:30:44 +02:00
|
|
|
|
await setActiveProjectId(p.id);
|
2026-07-14 11:58:20 +02:00
|
|
|
|
// 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);
|
2026-06-18 17:43:48 +02:00
|
|
|
|
navigation.navigate('ProjectDetail', { projectId: p.id, name: p.name });
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setOpening(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
[navigation],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-28 19:42:11 +02:00
|
|
|
|
const handleLogout = () => {
|
|
|
|
|
|
setShowMenu(false);
|
|
|
|
|
|
signOut();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSettings = () => {
|
|
|
|
|
|
setShowMenu(false);
|
|
|
|
|
|
navigation.navigate('Settings');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-18 17:43:48 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<View style={styles.container}>
|
2026-07-28 19:42:11 +02:00
|
|
|
|
{/* Header with dropdown menu */}
|
2026-06-18 17:43:48 +02:00
|
|
|
|
<View style={styles.header}>
|
2026-07-28 19:42:11 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-06-18 17:43:48 +02:00
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
|
2026-07-28 19:42:11 +02:00
|
|
|
|
{/* 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>
|
2026-06-18 17:43:48 +02:00
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const styles = StyleSheet.create({
|
|
|
|
|
|
container: { flex: 1 },
|
|
|
|
|
|
header: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
padding: 16,
|
|
|
|
|
|
},
|
2026-07-28 19:42:11 +02:00
|
|
|
|
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: {
|
2026-06-18 17:43:48 +02:00
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
2026-07-28 19:42:11 +02:00
|
|
|
|
justifyContent: 'space-between',
|
2026-06-18 17:43:48 +02:00
|
|
|
|
paddingHorizontal: 16,
|
2026-07-28 19:42:11 +02:00
|
|
|
|
paddingVertical: 16,
|
2026-06-18 17:43:48 +02:00
|
|
|
|
borderTopWidth: StyleSheet.hairlineWidth,
|
2026-07-28 19:42:11 +02:00
|
|
|
|
borderColor: COLORS.border,
|
|
|
|
|
|
backgroundColor: '#fff',
|
2026-06-18 17:43:48 +02:00
|
|
|
|
},
|
2026-07-28 19:42:11 +02:00
|
|
|
|
cardContent: { flex: 1 },
|
|
|
|
|
|
cardName: { fontSize: 16, fontWeight: '600', color: '#111' },
|
|
|
|
|
|
cardMeta: { fontSize: 13, color: COLORS.muted, marginTop: 4 },
|
2026-06-18 17:43:48 +02:00
|
|
|
|
empty: { textAlign: 'center', marginTop: 40, color: '#888' },
|
|
|
|
|
|
});
|