This commit is contained in:
Bastien COIGNOUX
2026-05-03 20:18:33 +02:00
parent ffc2e6b895
commit bd325fe456
113 changed files with 29532 additions and 220 deletions

48
app/(tabs)/_layout.tsx Normal file
View File

@ -0,0 +1,48 @@
import { Ionicons } from '@expo/vector-icons';
import { Tabs } from 'expo-router';
import { colors } from '../../src/theme/colors';
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
headerStyle: { backgroundColor: colors.bgCard },
headerTintColor: colors.text,
tabBarStyle: {
backgroundColor: colors.bgCard,
borderTopColor: colors.border,
},
tabBarActiveTintColor: colors.accent,
tabBarInactiveTintColor: colors.textMuted,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Dossiers',
tabBarIcon: ({ color, size }) => (
<Ionicons name="folder-open-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="investisseurs"
options={{
title: 'Investisseurs',
tabBarIcon: ({ color, size }) => (
<Ionicons name="people-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="reglages"
options={{
title: 'Réglages',
tabBarIcon: ({ color, size }) => (
<Ionicons name="settings-outline" size={size} color={color} />
),
}}
/>
</Tabs>
);
}

297
app/(tabs)/index.tsx Normal file
View 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 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 },
},
});

View File

@ -0,0 +1,234 @@
import { useState } from 'react';
import {
Alert,
FlatList,
Modal,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LabeledField } from '../../src/components/LabeledField';
import { PrimaryButton } from '../../src/components/PrimaryButton';
import { useApp } from '../../src/context/AppContext';
import { colors } from '../../src/theme/colors';
import type { InvestisseurRow } from '../../src/data/types';
import { router } from 'expo-router';
function parseNum(s: string): number | null {
const v = Number(s.replace(',', '.').replace(/\s/g, ''));
return Number.isFinite(v) ? v : null;
}
export default function InvestisseursScreen() {
const insets = useSafeAreaInsets();
const app = useApp();
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<InvestisseurRow | null>(null);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [minMargin, setMinMargin] = useState('12');
const [maxTicket, setMaxTicket] = useState('');
const [zones, setZones] = useState('');
const cloudNeedsAuth = app.runtimeMode === 'cloud' && !app.user;
const openNew = () => {
setEditing(null);
setName('');
setEmail('');
setPhone('');
setMinMargin('12');
setMaxTicket('');
setZones('');
setOpen(true);
};
const openEdit = (row: InvestisseurRow) => {
setEditing(row);
setName(row.display_name);
setEmail(row.email ?? '');
setPhone(row.phone ?? '');
setMinMargin(String(row.min_margin_pct));
setMaxTicket(row.max_ticket_eur != null ? String(row.max_ticket_eur) : '');
setZones((row.zones ?? []).join(', '));
setOpen(true);
};
const save = async () => {
if (!app.user) {
router.push('/auth/login');
return;
}
const uid = app.user.id;
const mm = parseNum(minMargin) ?? 12;
const mt = maxTicket.trim() ? parseNum(maxTicket) : null;
const z = zones
.split(',')
.map((s) => s.trim())
.filter(Boolean);
await app.upsertInvestisseur({
id: editing?.id,
user_id: uid,
display_name: name.trim() || 'Investisseur',
email: email.trim() || null,
phone: phone.trim() || null,
min_margin_pct: mm,
max_ticket_eur: mt,
zones: z.length ? z : null,
strategies: null,
notes: null,
});
setOpen(false);
};
if (cloudNeedsAuth) {
return (
<View style={[styles.center, { paddingTop: insets.top }]}>
<Text style={styles.muted}>Connectez-vous pour gérer vos investisseurs.</Text>
<PrimaryButton title="Connexion" onPress={() => router.push('/auth/login')} />
</View>
);
}
return (
<View style={styles.root}>
<FlatList
data={app.investisseurs}
keyExtractor={(i) => i.id}
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 80,
}}
ListEmptyComponent={
<Text style={styles.muted}>
Ajoutez des profils pour le module « Investisseur flash » (matching
marge / ticket / zones).
</Text>
}
renderItem={({ item }) => (
<Pressable style={styles.card} onPress={() => openEdit(item)}>
<Text style={styles.name}>{item.display_name}</Text>
<Text style={styles.meta}>
Marge mini {item.min_margin_pct}% ticket max{' '}
{item.max_ticket_eur != null
? `${item.max_ticket_eur.toLocaleString('fr-FR')}`
: '—'}
</Text>
{item.zones?.length ? (
<Text style={styles.meta}>Zones : {item.zones.join(', ')}</Text>
) : null}
</Pressable>
)}
/>
<View style={[styles.fabRow, { bottom: insets.bottom + 16 }]}>
<PrimaryButton title="Nouvel investisseur" onPress={openNew} />
</View>
<Modal visible={open} animationType="slide" transparent>
<View style={styles.modalBackdrop}>
<View style={[styles.modalCard, { paddingBottom: insets.bottom + 16 }]}>
<Text style={styles.modalTitle}>
{editing ? 'Modifier investisseur' : 'Nouvel investisseur'}
</Text>
<LabeledField label="Nom" value={name} onChangeText={setName} />
<LabeledField
label="E-mail"
autoCapitalize="none"
value={email}
onChangeText={setEmail}
/>
<LabeledField label="Téléphone" value={phone} onChangeText={setPhone} />
<LabeledField
label="Marge nette minimum (%)"
keyboardType="decimal-pad"
value={minMargin}
onChangeText={setMinMargin}
/>
<LabeledField
label="Ticket max (€) — optionnel"
keyboardType="number-pad"
value={maxTicket}
onChangeText={setMaxTicket}
/>
<LabeledField
label="Zones (ville ou CP, séparés par des virgules)"
value={zones}
onChangeText={setZones}
/>
<PrimaryButton title="Enregistrer" onPress={() => void save()} />
{editing ? (
<PrimaryButton
title="Supprimer"
variant="danger"
containerStyle={{ marginTop: 10 }}
onPress={() => {
Alert.alert(
'Supprimer',
'Confirmer la suppression ?',
[
{ text: 'Annuler', style: 'cancel' },
{
text: 'Supprimer',
style: 'destructive',
onPress: () => {
void app.deleteInvestisseur(editing.id).then(() =>
setOpen(false),
);
},
},
],
);
}}
/>
) : null}
<PrimaryButton
title="Fermer"
variant="ghost"
containerStyle={{ marginTop: 12 }}
onPress={() => setOpen(false)}
/>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
center: { flex: 1, padding: 20, backgroundColor: colors.bg, gap: 12 },
muted: { color: colors.textMuted, textAlign: 'center', lineHeight: 20 },
card: {
backgroundColor: colors.bgCard,
borderRadius: 14,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: colors.border,
},
name: { color: colors.text, fontSize: 17, fontWeight: '700' },
meta: { color: colors.textMuted, marginTop: 6, fontSize: 13 },
fabRow: { position: 'absolute', left: 16, right: 16 },
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.55)',
justifyContent: 'flex-end',
},
modalCard: {
backgroundColor: colors.bgCard,
borderTopLeftRadius: 18,
borderTopRightRadius: 18,
padding: 20,
borderWidth: 1,
borderColor: colors.border,
},
modalTitle: {
color: colors.text,
fontSize: 20,
fontWeight: '700',
marginBottom: 16,
},
});

112
app/(tabs)/reglages.tsx Normal file
View File

@ -0,0 +1,112 @@
import { router } from 'expo-router';
import { useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LabeledField } from '../../src/components/LabeledField';
import { PrimaryButton } from '../../src/components/PrimaryButton';
import { useApp } from '../../src/context/AppContext';
import { colors } from '../../src/theme/colors';
export default function ReglagesScreen() {
const insets = useSafeAreaInsets();
const app = useApp();
const [url, setUrl] = useState('');
const [key, setKey] = useState('');
const [msg, setMsg] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
return (
<ScrollView
contentContainerStyle={{
padding: 20,
paddingTop: 12,
paddingBottom: insets.bottom + 32,
backgroundColor: colors.bg,
}}
>
<Text style={styles.h2}>Mode actuel</Text>
<Text style={styles.p}>
{app.runtimeMode === 'local' && 'Hors-ligne — données stockées sur lappareil.'}
{app.runtimeMode === 'cloud' && 'Supabase — synchronisation cloud.'}
{app.runtimeMode === 'none' && 'Non initialisé.'}
</Text>
{app.user ? (
<Text style={styles.p}>
Compte : {app.user.email ?? app.user.id}
</Text>
) : null}
<Text style={[styles.h2, { marginTop: 24 }]}>Projet Supabase</Text>
<Text style={styles.p}>
URL et clé « anon » (Settings API). Exécutez aussi la migration SQL du
dépôt sur votre projet.
</Text>
<LabeledField
label="URL du projet"
autoCapitalize="none"
value={url}
onChangeText={setUrl}
placeholder="https://xxxx.supabase.co"
/>
<LabeledField
label="Clé anon (public)"
autoCapitalize="none"
value={key}
onChangeText={setKey}
placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
/>
{msg ? <Text style={styles.msg}>{msg}</Text> : null}
<PrimaryButton
title="Enregistrer et passer en mode cloud"
loading={loading}
onPress={async () => {
setMsg(null);
if (!url.trim() || !key.trim()) {
setMsg('Renseignez URL et clé.');
return;
}
setLoading(true);
try {
await app.saveCloudConfig({
supabaseUrl: url.trim(),
supabaseAnonKey: key.trim(),
});
setMsg('Configuration enregistrée. Connectez-vous ou créez un compte.');
router.push('/auth/login');
} catch {
setMsg('Erreur lors de lenregistrement.');
} finally {
setLoading(false);
}
}}
/>
<PrimaryButton
title="Activer le mode hors-ligne"
variant="ghost"
containerStyle={{ marginTop: 12 }}
onPress={async () => {
await app.enterLocalMode();
setMsg('Mode hors-ligne activé.');
router.replace('/(tabs)');
}}
/>
<PrimaryButton
title="Déconnexion / quitter la session"
variant="ghost"
containerStyle={{ marginTop: 24 }}
onPress={async () => {
await app.signOut();
router.replace('/');
}}
/>
</ScrollView>
);
}
const styles = StyleSheet.create({
h2: { color: colors.text, fontSize: 18, fontWeight: '700', marginBottom: 8 },
p: { color: colors.textMuted, lineHeight: 20, marginBottom: 8 },
msg: { color: colors.flash, marginBottom: 12, lineHeight: 20 },
});