Files
mdb/app/(tabs)/index.tsx
Bastien COIGNOUX bd325fe456 init
2026-05-03 20:18:33 +02:00

298 lines
9.0 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 { 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 laccueil, 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}`}
</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 },
},
});