init
This commit is contained in:
297
app/(tabs)/index.tsx
Normal file
297
app/(tabs)/index.tsx
Normal file
@ -0,0 +1,297 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { router } from 'expo-router';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Pressable,
|
||||
SectionList,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { PrimaryButton } from '../../src/components/PrimaryButton';
|
||||
import { useApp } from '../../src/context/AppContext';
|
||||
import type { DealSourceRow, DossierRow } from '../../src/data/types';
|
||||
import {
|
||||
ensureNotificationPermission,
|
||||
notifyGradeADealLocal,
|
||||
useDealsSourcesGradeAAlerts,
|
||||
} from '../../src/hooks/useDealsSourcesGradeAAlerts';
|
||||
import { colors } from '../../src/theme/colors';
|
||||
|
||||
type SectionRow =
|
||||
| { kind: 'deal'; deal: DealSourceRow }
|
||||
| { kind: 'dossier'; dossier: DossierRow };
|
||||
|
||||
export default function DossiersListScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const app = useApp();
|
||||
const [scoutBusy, setScoutBusy] = useState(false);
|
||||
|
||||
const cloudNeedsAuth =
|
||||
app.runtimeMode === 'cloud' && !app.user && app.supabase;
|
||||
const needsSetup = app.runtimeMode === 'none';
|
||||
|
||||
const sortedDeals = useMemo(
|
||||
() =>
|
||||
[...app.dealSources].sort(
|
||||
(a, b) => b.opportunity_score - a.opportunity_score,
|
||||
),
|
||||
[app.dealSources],
|
||||
);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const dealRows: SectionRow[] = sortedDeals.map((deal) => ({
|
||||
kind: 'deal' as const,
|
||||
deal,
|
||||
}));
|
||||
const dossierRows: SectionRow[] = app.dossiers.map((dossier) => ({
|
||||
kind: 'dossier' as const,
|
||||
dossier,
|
||||
}));
|
||||
return [
|
||||
{ title: 'Flux opportunités (Scout)', data: dealRows },
|
||||
{ title: 'Mes dossiers', data: dossierRows },
|
||||
];
|
||||
}, [sortedDeals, app.dossiers]);
|
||||
|
||||
useDealsSourcesGradeAAlerts(
|
||||
app.supabase,
|
||||
app.user?.id,
|
||||
app.runtimeMode === 'cloud' && !!app.user && !!app.supabase,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (app.runtimeMode === 'cloud' && app.user) {
|
||||
void ensureNotificationPermission();
|
||||
}
|
||||
}, [app.runtimeMode, app.user]);
|
||||
|
||||
return (
|
||||
<View style={[styles.root, { paddingTop: 8 }]}>
|
||||
{needsSetup ? (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>
|
||||
Choisissez le mode hors-ligne sur l’accueil, ou configurez Supabase
|
||||
dans Réglages.
|
||||
</Text>
|
||||
<PrimaryButton
|
||||
title="Retour accueil"
|
||||
onPress={() => router.replace('/')}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
{cloudNeedsAuth ? (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>
|
||||
Connectez-vous pour charger vos dossiers Supabase.
|
||||
</Text>
|
||||
<PrimaryButton title="Connexion" onPress={() => router.push('/auth/login')} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(item, index) =>
|
||||
item.kind === 'deal' ? `deal-${item.deal.id}` : `dossier-${item.dossier.id}-${index}`
|
||||
}
|
||||
stickySectionHeadersEnabled={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: insets.bottom + 100,
|
||||
}}
|
||||
ListHeaderComponent={
|
||||
<View style={{ marginBottom: 12 }}>
|
||||
<PrimaryButton
|
||||
title={scoutBusy ? 'Scout…' : 'Simuler ingest Scout (JSON)'}
|
||||
loading={scoutBusy}
|
||||
onPress={async () => {
|
||||
if (!app.user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
setScoutBusy(true);
|
||||
const r = await app.runScoutSampleBatch();
|
||||
setScoutBusy(false);
|
||||
if ('error' in r) {
|
||||
Alert.alert('Scout', r.error);
|
||||
return;
|
||||
}
|
||||
if (app.runtimeMode === 'local' && r.gradeA > 0) {
|
||||
notifyGradeADealLocal(
|
||||
`${r.gradeA} opportunité(s) Grade A (Scout simulé)`,
|
||||
);
|
||||
}
|
||||
Alert.alert(
|
||||
'Scout',
|
||||
`Insérés : ${r.inserted} — Grade A : ${r.gradeA}.`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Text style={styles.hint}>
|
||||
Filtre : mots-clés succession / urgent / travaux important + prix/m²
|
||||
sous moyenne simulée (3500 €/m²). Cloud : RPC `scout_process_batch`
|
||||
après migration SQL.
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
renderSectionHeader={({ section: { title, data } }) => (
|
||||
<Text style={styles.sectionTitle}>
|
||||
{title}
|
||||
{title.startsWith('Flux') ? ` (${data.length})` : ` (${data.length})`}
|
||||
</Text>
|
||||
)}
|
||||
renderItem={({ item }) =>
|
||||
item.kind === 'deal' ? (
|
||||
<DealSourceCard row={item.deal} />
|
||||
) : (
|
||||
<DossierRowCard row={item.dossier} />
|
||||
)
|
||||
}
|
||||
ListEmptyComponent={null}
|
||||
/>
|
||||
|
||||
<View style={[styles.fabWrap, { bottom: insets.bottom + 20 }]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
style={styles.fab}
|
||||
onPress={async () => {
|
||||
if (app.runtimeMode === 'none') {
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
if (!app.user && app.runtimeMode === 'cloud') {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
const id = await app.createDossier();
|
||||
if (id) router.push(`/dossier/${id}`);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="add" size={32} color="#fff" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DealSourceCard({ row }: { row: DealSourceRow }) {
|
||||
const pm = Math.round(row.price_per_m2_eur);
|
||||
const dotStyle =
|
||||
row.grade === 'A' ? styles.badgeA : row.grade === 'B' ? styles.badgeB : styles.badgeC;
|
||||
return (
|
||||
<View style={styles.dealCard}>
|
||||
<View style={styles.dealHead}>
|
||||
<Text style={styles.badgeText}>Grade {row.grade}</Text>
|
||||
<View style={[styles.badgeDot, dotStyle]} />
|
||||
<Text style={styles.score}>{row.opportunity_score.toFixed(0)} pts</Text>
|
||||
</View>
|
||||
<Text style={styles.cardTitle}>{row.title}</Text>
|
||||
<Text style={styles.cardSub}>
|
||||
{row.price_eur != null
|
||||
? `${row.price_eur.toLocaleString('fr-FR')} € · ${row.surface_m2} m² · ${pm} €/m²`
|
||||
: `${row.surface_m2} m²`}
|
||||
</Text>
|
||||
{row.distress_keywords?.length ? (
|
||||
<Text style={[styles.kw, { color: colors.flash }]}>
|
||||
Mots-clés : {row.distress_keywords.join(', ')}
|
||||
</Text>
|
||||
) : null}
|
||||
{row.source_name ? (
|
||||
<Text style={styles.cardMeta}>Source : {row.source_name}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DossierRowCard({ row }: { row: DossierRow }) {
|
||||
const city = [row.postal_code, row.city].filter(Boolean).join(' ');
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.card}
|
||||
onPress={() => router.push(`/dossier/${row.id}`)}
|
||||
>
|
||||
<Text style={styles.cardTitle}>{row.title}</Text>
|
||||
{city ? <Text style={styles.cardSub}>{city}</Text> : null}
|
||||
<Text style={styles.cardMeta}>Statut : {row.status}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: colors.bg },
|
||||
banner: {
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 12,
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.bgCard,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
gap: 10,
|
||||
},
|
||||
bannerText: { color: colors.text, lineHeight: 20 },
|
||||
hint: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
marginTop: 10,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
marginTop: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
dealCard: {
|
||||
backgroundColor: '#121a24',
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
dealHead: { flexDirection: 'row', alignItems: 'center', marginBottom: 8, gap: 8 },
|
||||
badgeDot: { width: 8, height: 8, borderRadius: 4 },
|
||||
badgeA: { backgroundColor: '#3fb950' },
|
||||
badgeB: { backgroundColor: '#d29922' },
|
||||
badgeC: { backgroundColor: colors.textMuted },
|
||||
badgeText: {
|
||||
color: colors.text,
|
||||
fontWeight: '900',
|
||||
fontSize: 13,
|
||||
marginRight: 4,
|
||||
},
|
||||
score: { color: colors.textMuted, fontSize: 12, marginLeft: 'auto' },
|
||||
kw: { color: colors.flash ?? '#7ee787', marginTop: 6, fontSize: 12 },
|
||||
card: {
|
||||
backgroundColor: colors.bgCard,
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
cardTitle: { color: colors.text, fontSize: 17, fontWeight: '700' },
|
||||
cardSub: { color: colors.textMuted, marginTop: 4 },
|
||||
cardMeta: { color: colors.textMuted, marginTop: 8, fontSize: 12 },
|
||||
fabWrap: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
},
|
||||
fab: {
|
||||
width: 58,
|
||||
height: 58,
|
||||
borderRadius: 29,
|
||||
backgroundColor: colors.accent,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
elevation: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 6,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user