This commit is contained in:
@ -1,48 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,297 +0,0 @@
|
||||
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 },
|
||||
},
|
||||
});
|
||||
@ -1,234 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@ -1,112 +0,0 @@
|
||||
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 l’appareil.'}
|
||||
{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 l’enregistrement.');
|
||||
} 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 },
|
||||
});
|
||||
@ -1,29 +0,0 @@
|
||||
import 'react-native-gesture-handler';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { AppProvider } from '../src/context/AppContext';
|
||||
import { colors } from '../src/theme/colors';
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<AppProvider>
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bgCard },
|
||||
headerTintColor: colors.text,
|
||||
contentStyle: { backgroundColor: colors.bg },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="auth/login" options={{ title: 'Connexion' }} />
|
||||
<Stack.Screen name="auth/register" options={{ title: 'Inscription' }} />
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="dossier/[id]" options={{ title: 'Dossier' }} />
|
||||
</Stack>
|
||||
</AppProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
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 LoginScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const app = useApp();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (app.runtimeMode !== 'cloud' || !app.supabase) {
|
||||
return (
|
||||
<View style={[styles.box, { paddingTop: insets.top }]}>
|
||||
<Text style={styles.err}>
|
||||
Configurez d’abord Supabase dans Réglages, puis revenez ici.
|
||||
</Text>
|
||||
<PrimaryButton
|
||||
title="Ouvrir Réglages"
|
||||
onPress={() => router.replace('/(tabs)/reglages')}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1, backgroundColor: colors.bg }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={{
|
||||
padding: 20,
|
||||
paddingTop: insets.top + 12,
|
||||
paddingBottom: insets.bottom + 24,
|
||||
}}
|
||||
>
|
||||
<LabeledField
|
||||
label="E-mail"
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Mot de passe"
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
{err ? <Text style={styles.err}>{err}</Text> : null}
|
||||
<PrimaryButton
|
||||
title="Connexion"
|
||||
loading={loading}
|
||||
onPress={async () => {
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
const r = await app.signIn(email.trim(), password);
|
||||
setLoading(false);
|
||||
if (r.error) {
|
||||
setErr(r.error);
|
||||
return;
|
||||
}
|
||||
router.replace('/(tabs)');
|
||||
}}
|
||||
/>
|
||||
<PrimaryButton
|
||||
title="Créer un compte"
|
||||
variant="ghost"
|
||||
onPress={() => router.push('/auth/register')}
|
||||
containerStyle={{ marginTop: 12 }}
|
||||
/>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
box: { flex: 1, padding: 20, backgroundColor: colors.bg, gap: 12 },
|
||||
err: { color: colors.danger, marginBottom: 12 },
|
||||
});
|
||||
@ -1,103 +0,0 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
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 RegisterScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const app = useApp();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
|
||||
if (app.runtimeMode !== 'cloud' || !app.supabase) {
|
||||
return (
|
||||
<View style={[styles.box, { paddingTop: insets.top }]}>
|
||||
<Text style={styles.err}>
|
||||
Configurez d’abord Supabase dans Réglages.
|
||||
</Text>
|
||||
<PrimaryButton
|
||||
title="Ouvrir Réglages"
|
||||
onPress={() => router.replace('/(tabs)/reglages')}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1, backgroundColor: colors.bg }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={{
|
||||
padding: 20,
|
||||
paddingTop: insets.top + 12,
|
||||
paddingBottom: insets.bottom + 24,
|
||||
}}
|
||||
>
|
||||
<LabeledField label="Nom affiché" value={name} onChangeText={setName} />
|
||||
<LabeledField
|
||||
label="E-mail"
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Mot de passe"
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
{err ? <Text style={styles.err}>{err}</Text> : null}
|
||||
{info ? <Text style={styles.info}>{info}</Text> : null}
|
||||
<PrimaryButton
|
||||
title="S’inscrire"
|
||||
loading={loading}
|
||||
onPress={async () => {
|
||||
setErr(null);
|
||||
setInfo(null);
|
||||
setLoading(true);
|
||||
const r = await app.signUp(email.trim(), password, name.trim());
|
||||
setLoading(false);
|
||||
if (r.error) {
|
||||
setErr(r.error);
|
||||
return;
|
||||
}
|
||||
setInfo(
|
||||
'Si la confirmation e-mail est activée sur votre projet, vérifiez votre boîte avant de vous connecter.',
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<PrimaryButton
|
||||
title="J’ai déjà un compte"
|
||||
variant="ghost"
|
||||
onPress={() => router.back()}
|
||||
containerStyle={{ marginTop: 12 }}
|
||||
/>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
box: { flex: 1, padding: 20, backgroundColor: colors.bg, gap: 12 },
|
||||
err: { color: colors.danger, marginBottom: 12 },
|
||||
info: { color: colors.flash, marginBottom: 12, lineHeight: 20 },
|
||||
});
|
||||
@ -1,567 +0,0 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { useLayoutEffect, useMemo, useState, useEffect } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { LabeledField } from '../../src/components/LabeledField';
|
||||
import { PrimaryButton } from '../../src/components/PrimaryButton';
|
||||
import { useApp, useVisitFindings } from '../../src/context/AppContext';
|
||||
import { colors } from '../../src/theme/colors';
|
||||
import type {
|
||||
DossierRow,
|
||||
DossierVisitFindingRow,
|
||||
VisitFindingDefinitionRow,
|
||||
} from '../../src/data/types';
|
||||
import { useDossierJuge } from '../../src/hooks/useDossierJuge';
|
||||
import { matchInvestisseurs } from '../../src/services/matchInvestors';
|
||||
import { shareTeaserPdf } from '../../src/services/teaserPdf';
|
||||
import { MIN_NET_MARGIN_PCT } from '../../src/core/juge';
|
||||
|
||||
type TabKey = 'dash' | 'money' | 'visit' | 'flash';
|
||||
|
||||
const TABS: { key: TabKey; label: string }[] = [
|
||||
{ key: 'dash', label: 'Feu' },
|
||||
{ key: 'money', label: 'Finances' },
|
||||
{ key: 'visit', label: 'Visite' },
|
||||
{ key: 'flash', label: 'Flash' },
|
||||
];
|
||||
|
||||
export default function DossierDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const dossierId = typeof id === 'string' ? id : id?.[0];
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const app = useApp();
|
||||
const [tab, setTab] = useState<TabKey>('dash');
|
||||
|
||||
const dossier = useMemo(
|
||||
() => app.dossiers.find((d) => d.id === dossierId),
|
||||
[app.dossiers, dossierId],
|
||||
);
|
||||
|
||||
const findings = useVisitFindings(dossierId);
|
||||
const juge = useDossierJuge(dossier, findings, app.definitions);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: dossier?.title ?? 'Dossier',
|
||||
headerRight: () => (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
hitSlop={12}
|
||||
onPress={() => {
|
||||
if (!dossierId) return;
|
||||
Alert.alert(
|
||||
'Supprimer le dossier',
|
||||
'Cette action est irréversible.',
|
||||
[
|
||||
{ text: 'Annuler', style: 'cancel' },
|
||||
{
|
||||
text: 'Supprimer',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
void app.deleteDossier(dossierId).then(() => router.back());
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={22} color={colors.danger} />
|
||||
</Pressable>
|
||||
),
|
||||
});
|
||||
}, [navigation, dossier?.title, dossierId, app.deleteDossier]);
|
||||
|
||||
if (!dossierId || !dossier || !juge) {
|
||||
return (
|
||||
<View style={[styles.center, { paddingTop: insets.top }]}>
|
||||
<Text style={styles.muted}>Dossier introuvable.</Text>
|
||||
<PrimaryButton title="Retour" onPress={() => router.back()} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const matches = matchInvestisseurs(dossier, juge.result, app.investisseurs);
|
||||
|
||||
const dashBg =
|
||||
juge.result.trafficLight === 'red'
|
||||
? '#2d1418'
|
||||
: juge.result.trafficLight === 'orange'
|
||||
? '#2a2310'
|
||||
: juge.result.trafficLight === 'green_flash_dvf'
|
||||
? '#102a18'
|
||||
: '#10221c';
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.tabBar}
|
||||
contentContainerStyle={styles.tabBarInner}
|
||||
>
|
||||
{TABS.map((t) => (
|
||||
<Pressable
|
||||
key={t.key}
|
||||
onPress={() => setTab(t.key)}
|
||||
style={[styles.tabChip, tab === t.key && styles.tabChipOn]}
|
||||
>
|
||||
<Text style={[styles.tabText, tab === t.key && styles.tabTextOn]}>
|
||||
{t.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{tab === 'dash' ? (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 24,
|
||||
}}
|
||||
>
|
||||
<View style={[styles.hero, { backgroundColor: dashBg }]}>
|
||||
<Text style={styles.heroLabel}>Score deal</Text>
|
||||
<Text style={styles.heroScore}>{juge.result.scoreDeal}</Text>
|
||||
<Text style={styles.heroSub}>
|
||||
Marge nette : {(juge.result.netMarginPct * 100).toFixed(1)} % (seuil
|
||||
achat : {(MIN_NET_MARGIN_PCT * 100).toFixed(0)} %)
|
||||
</Text>
|
||||
<Text style={styles.heroSub}>
|
||||
Feu : {juge.result.trafficLight} — DVF flash :{' '}
|
||||
{juge.result.dvfUnderMarketFlash ? 'oui' : 'non'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Synthèse</Text>
|
||||
<Row label="Investi (est.)" value={`${Math.round(juge.result.totalInvested).toLocaleString('fr-FR')} €`} />
|
||||
<Row label="Produit net revente" value={`${Math.round(juge.result.netResaleProceeds).toLocaleString('fr-FR')} €`} />
|
||||
<Row label="TVA sur marge (est.)" value={`${Math.round(juge.result.vatOnMargin).toLocaleString('fr-FR')} €`} />
|
||||
<Row label="Marge nette" value={`${Math.round(juge.result.netMarginAfterVat).toLocaleString('fr-FR')} €`} />
|
||||
<Row
|
||||
label="Break-even revente"
|
||||
value={
|
||||
Number.isFinite(juge.result.breakEvenResalePrice)
|
||||
? `${Math.round(juge.result.breakEvenResalePrice).toLocaleString('fr-FR')} €`
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<PrimaryButton
|
||||
title="Marquer « sous promesse »"
|
||||
variant="ghost"
|
||||
onPress={() => void app.setDossierStatus(dossier.id, 'under_promise')}
|
||||
containerStyle={{ marginTop: 8 }}
|
||||
/>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
|
||||
{tab === 'money' ? (
|
||||
<FinancesEditor dossier={dossier} onSave={(patch) => void app.updateDossier(dossier.id, patch)} />
|
||||
) : null}
|
||||
|
||||
{tab === 'visit' ? (
|
||||
<VisiteTab
|
||||
definitions={app.definitions}
|
||||
findings={findings}
|
||||
onToggle={(code, checked) =>
|
||||
void app.toggleFinding(dossier.id, code, checked)
|
||||
}
|
||||
checklistEUR={juge.checklistWorks}
|
||||
maxPurchase={juge.maxPurchase}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{tab === 'flash' ? (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 24,
|
||||
}}
|
||||
>
|
||||
{dossier.status !== 'under_promise' ? (
|
||||
<Text style={styles.muted}>
|
||||
Verrouillez le dossier (« sous promesse ») depuis l’onglet Feu pour
|
||||
activer le teaser investisseur.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.cardTitle}>Investisseurs (top 5 match)</Text>
|
||||
{matches.length === 0 ? (
|
||||
<Text style={styles.muted}>Aucun match — ajustez critères ou dossier.</Text>
|
||||
) : (
|
||||
matches.map((m) => (
|
||||
<View key={m.id} style={styles.matchRow}>
|
||||
<Text style={styles.matchName}>{m.display_name}</Text>
|
||||
{m.email ? (
|
||||
<Text style={styles.muted}>{m.email}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
<PrimaryButton
|
||||
title="Générer & partager le teaser PDF"
|
||||
containerStyle={{ marginTop: 16 }}
|
||||
onPress={() =>
|
||||
void shareTeaserPdf(
|
||||
dossier,
|
||||
juge.result,
|
||||
matches.map((m) => m.display_name),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>{label}</Text>
|
||||
<Text style={styles.rowValue}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function FinancesEditor({
|
||||
dossier,
|
||||
onSave,
|
||||
}: {
|
||||
dossier: DossierRow;
|
||||
onSave: (patch: Partial<DossierRow>) => void;
|
||||
}) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [title, setTitle] = useState(dossier.title);
|
||||
const [address, setAddress] = useState(dossier.address_line ?? '');
|
||||
const [city, setCity] = useState(dossier.city ?? '');
|
||||
const [postal, setPostal] = useState(dossier.postal_code ?? '');
|
||||
const [surface, setSurface] = useState(String(dossier.surface_m2 ?? ''));
|
||||
const [purchase, setPurchase] = useState(String(dossier.purchase_price_target ?? ''));
|
||||
const [resale, setResale] = useState(String(dossier.resale_price_estimate ?? ''));
|
||||
const [dvf, setDvf] = useState(String(dossier.dvf_reference_price_m2 ?? ''));
|
||||
const [works, setWorks] = useState(String(dossier.works_estimate_total ?? ''));
|
||||
const [miscA, setMiscA] = useState(String(dossier.misc_acquisition_cost ?? ''));
|
||||
const [miscS, setMiscS] = useState(String(dossier.misc_sale_cost ?? ''));
|
||||
const [carryM, setCarryM] = useState(String(dossier.carrying_months ?? 6));
|
||||
const [carryR, setCarryR] = useState(String(dossier.carrying_annual_rate ?? 0.05));
|
||||
const [dpe, setDpe] = useState(dossier.dpe_class ?? '');
|
||||
const [pluZone, setPluZone] = useState(dossier.plu_zone_code ?? '');
|
||||
const [pluNotes, setPluNotes] = useState(dossier.plu_notes ?? '');
|
||||
const [parcelDiv, setParcelDiv] = useState(dossier.parcel_subdivision_candidate);
|
||||
const [deficitFoncier, setDeficitFoncier] = useState(
|
||||
dossier.deficit_foncier_candidate,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(dossier.title);
|
||||
setAddress(dossier.address_line ?? '');
|
||||
setCity(dossier.city ?? '');
|
||||
setPostal(dossier.postal_code ?? '');
|
||||
setSurface(String(dossier.surface_m2 ?? ''));
|
||||
setPurchase(String(dossier.purchase_price_target ?? ''));
|
||||
setResale(String(dossier.resale_price_estimate ?? ''));
|
||||
setDvf(String(dossier.dvf_reference_price_m2 ?? ''));
|
||||
setWorks(String(dossier.works_estimate_total ?? ''));
|
||||
setMiscA(String(dossier.misc_acquisition_cost ?? ''));
|
||||
setMiscS(String(dossier.misc_sale_cost ?? ''));
|
||||
setCarryM(String(dossier.carrying_months ?? 6));
|
||||
setCarryR(String(dossier.carrying_annual_rate ?? 0.05));
|
||||
setDpe(dossier.dpe_class ?? '');
|
||||
setPluZone(dossier.plu_zone_code ?? '');
|
||||
setPluNotes(dossier.plu_notes ?? '');
|
||||
setParcelDiv(dossier.parcel_subdivision_candidate);
|
||||
setDeficitFoncier(dossier.deficit_foncier_candidate);
|
||||
}, [
|
||||
dossier.id,
|
||||
dossier.updated_at,
|
||||
dossier.title,
|
||||
dossier.address_line,
|
||||
dossier.city,
|
||||
dossier.postal_code,
|
||||
dossier.surface_m2,
|
||||
dossier.purchase_price_target,
|
||||
dossier.resale_price_estimate,
|
||||
dossier.dvf_reference_price_m2,
|
||||
dossier.works_estimate_total,
|
||||
dossier.misc_acquisition_cost,
|
||||
dossier.misc_sale_cost,
|
||||
dossier.carrying_months,
|
||||
dossier.carrying_annual_rate,
|
||||
dossier.dpe_class,
|
||||
dossier.plu_zone_code,
|
||||
dossier.plu_notes,
|
||||
dossier.parcel_subdivision_candidate,
|
||||
dossier.deficit_foncier_candidate,
|
||||
]);
|
||||
|
||||
const parseNum = (s: string) => Number(s.replace(',', '.').replace(/\s/g, ''));
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 24,
|
||||
}}
|
||||
>
|
||||
<LabeledField label="Titre du dossier" value={title} onChangeText={setTitle} />
|
||||
<LabeledField label="Adresse" value={address} onChangeText={setAddress} />
|
||||
<LabeledField label="Ville" value={city} onChangeText={setCity} />
|
||||
<LabeledField label="Code postal" value={postal} onChangeText={setPostal} />
|
||||
<LabeledField
|
||||
label="Surface (m²)"
|
||||
keyboardType="decimal-pad"
|
||||
value={surface}
|
||||
onChangeText={setSurface}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Prix d'achat cible (€)"
|
||||
keyboardType="number-pad"
|
||||
value={purchase}
|
||||
onChangeText={setPurchase}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Prix de revente estimé (€)"
|
||||
keyboardType="number-pad"
|
||||
value={resale}
|
||||
onChangeText={setResale}
|
||||
/>
|
||||
<LabeledField
|
||||
label="DVF réf. (€/m²)"
|
||||
keyboardType="decimal-pad"
|
||||
value={dvf}
|
||||
onChangeText={setDvf}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Travaux estimés hors checklist (€)"
|
||||
keyboardType="number-pad"
|
||||
value={works}
|
||||
onChangeText={setWorks}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Frais d'achat divers (€)"
|
||||
keyboardType="number-pad"
|
||||
value={miscA}
|
||||
onChangeText={setMiscA}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Frais de vente divers (€)"
|
||||
keyboardType="number-pad"
|
||||
value={miscS}
|
||||
onChangeText={setMiscS}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Portage (mois)"
|
||||
keyboardType="number-pad"
|
||||
value={carryM}
|
||||
onChangeText={setCarryM}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Taux portage annuel (ex: 0.055)"
|
||||
keyboardType="decimal-pad"
|
||||
value={carryR}
|
||||
onChangeText={setCarryR}
|
||||
/>
|
||||
<LabeledField
|
||||
label="DPE (A–G)"
|
||||
autoCapitalize="characters"
|
||||
maxLength={1}
|
||||
value={dpe}
|
||||
onChangeText={(t) => setDpe(t.toUpperCase())}
|
||||
/>
|
||||
<Text style={styles.sectionLabel}>Urbanisme & stratégie</Text>
|
||||
<LabeledField
|
||||
label="Zone PLU (libellé ou code)"
|
||||
value={pluZone}
|
||||
onChangeText={setPluZone}
|
||||
/>
|
||||
<LabeledField
|
||||
label="Notes urbanisme (servitude, COS…)"
|
||||
value={pluNotes}
|
||||
onChangeText={setPluNotes}
|
||||
multiline
|
||||
/>
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.switchLabel}>Piste division parcellaire</Text>
|
||||
<Switch value={parcelDiv} onValueChange={setParcelDiv} />
|
||||
</View>
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.switchLabel}>Piste déficit foncier (passoire)</Text>
|
||||
<Switch value={deficitFoncier} onValueChange={setDeficitFoncier} />
|
||||
</View>
|
||||
<PrimaryButton
|
||||
title="Enregistrer les finances"
|
||||
onPress={() => {
|
||||
onSave({
|
||||
title: title.trim(),
|
||||
address_line: address.trim() || null,
|
||||
city: city.trim() || null,
|
||||
postal_code: postal.trim() || null,
|
||||
surface_m2: parseNum(surface) || null,
|
||||
purchase_price_target: parseNum(purchase) || null,
|
||||
resale_price_estimate: parseNum(resale) || null,
|
||||
dvf_reference_price_m2: parseNum(dvf) || null,
|
||||
works_estimate_total: parseNum(works) || null,
|
||||
misc_acquisition_cost: parseNum(miscA) || null,
|
||||
misc_sale_cost: parseNum(miscS) || null,
|
||||
carrying_months: Math.round(parseNum(carryM) || 6),
|
||||
carrying_annual_rate: parseNum(carryR) || 0.05,
|
||||
dpe_class: dpe || null,
|
||||
plu_zone_code: pluZone.trim() || null,
|
||||
plu_notes: pluNotes.trim() || null,
|
||||
parcel_subdivision_candidate: parcelDiv,
|
||||
deficit_foncier_candidate: deficitFoncier,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function VisiteTab({
|
||||
definitions,
|
||||
findings,
|
||||
onToggle,
|
||||
checklistEUR,
|
||||
maxPurchase,
|
||||
}: {
|
||||
definitions: VisitFindingDefinitionRow[];
|
||||
findings: DossierVisitFindingRow[];
|
||||
onToggle: (code: string, checked: boolean) => void;
|
||||
checklistEUR: number;
|
||||
maxPurchase: number;
|
||||
}) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const rows = definitions.map((def) => {
|
||||
const f = findings.find((x) => x.finding_code === def.code);
|
||||
return { def, f };
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 24,
|
||||
}}
|
||||
>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Anti-erreur visite</Text>
|
||||
<Text style={styles.muted}>
|
||||
Cochez les points noirs : l’app ajoute les travaux associés et recalcule
|
||||
le prix d’achat max pour rester à {(MIN_NET_MARGIN_PCT * 100).toFixed(0)}{' '}
|
||||
% de marge nette.
|
||||
</Text>
|
||||
<Text style={styles.highlight}>
|
||||
Travaux checklist : {checklistEUR.toLocaleString('fr-FR')} €
|
||||
</Text>
|
||||
<Text style={styles.highlight}>
|
||||
Prix d’achat max (cible marge) :{' '}
|
||||
{maxPurchase.toLocaleString('fr-FR')} €
|
||||
</Text>
|
||||
</View>
|
||||
{rows.map(({ def, f }) => {
|
||||
const checked = f?.checked ?? false;
|
||||
return (
|
||||
<View key={def.code} style={styles.visitRow}>
|
||||
<View style={{ flex: 1, paddingRight: 12 }}>
|
||||
<Text style={styles.visitLabel}>{def.label}</Text>
|
||||
<Text style={styles.muted}>
|
||||
+{(f?.works_delta_override_eur ?? def.default_works_delta_eur).toLocaleString('fr-FR')}{' '}
|
||||
€ si coché
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={checked}
|
||||
onValueChange={(v) => onToggle(def.code, v)}
|
||||
trackColor={{ true: colors.accent, false: colors.border }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: colors.bg },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
|
||||
muted: { color: colors.textMuted, lineHeight: 20 },
|
||||
tabBar: { maxHeight: 52, borderBottomWidth: 1, borderBottomColor: colors.border },
|
||||
tabBarInner: { paddingHorizontal: 12, paddingVertical: 10, gap: 8, alignItems: 'center' },
|
||||
tabChip: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 999,
|
||||
backgroundColor: colors.bgCard,
|
||||
marginRight: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
tabChipOn: { borderColor: colors.accent, backgroundColor: '#15233d' },
|
||||
tabText: { color: colors.textMuted, fontWeight: '600' },
|
||||
tabTextOn: { color: colors.text },
|
||||
hero: { borderRadius: 16, padding: 20, marginBottom: 16 },
|
||||
heroLabel: { color: colors.textMuted, fontSize: 12, textTransform: 'uppercase' },
|
||||
heroScore: { fontSize: 44, fontWeight: '800', color: colors.text, marginVertical: 8 },
|
||||
heroSub: { color: colors.textMuted, marginTop: 4 },
|
||||
card: {
|
||||
backgroundColor: colors.bgCard,
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
cardTitle: { color: colors.text, fontSize: 18, fontWeight: '700', marginBottom: 12 },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 10,
|
||||
gap: 12,
|
||||
},
|
||||
rowLabel: { color: colors.textMuted, flex: 1 },
|
||||
rowValue: { color: colors.text, fontWeight: '600' },
|
||||
highlight: { color: colors.flash, marginTop: 8, fontWeight: '600' },
|
||||
visitRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
visitLabel: { color: colors.text, fontWeight: '600' },
|
||||
matchRow: {
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
matchName: { color: colors.text, fontWeight: '700', fontSize: 16 },
|
||||
sectionLabel: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 12,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.06,
|
||||
marginTop: 8,
|
||||
marginBottom: 6,
|
||||
},
|
||||
switchRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 14,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
switchLabel: { color: colors.text, flex: 1, paddingRight: 12 },
|
||||
});
|
||||
112
app/index.tsx
112
app/index.tsx
@ -1,112 +0,0 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
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 { colors } from '../src/theme/colors';
|
||||
|
||||
export default function WelcomeScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const app = useApp();
|
||||
|
||||
useEffect(() => {
|
||||
if (!app.ready) return;
|
||||
if (app.user) {
|
||||
router.replace('/(tabs)');
|
||||
}
|
||||
}, [app.ready, app.user]);
|
||||
|
||||
if (!app.ready) {
|
||||
return (
|
||||
<View style={[styles.center, { paddingTop: insets.top }]}>
|
||||
<ActivityIndicator size="large" color={colors.accent} />
|
||||
<Text style={styles.muted}>Chargement…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (app.user) {
|
||||
return (
|
||||
<View style={[styles.center, { paddingTop: insets.top }]}>
|
||||
<ActivityIndicator size="large" color={colors.accent} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scroll,
|
||||
{ paddingTop: insets.top + 24, paddingBottom: insets.bottom + 24 },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.brand}>MDB-Turbo</Text>
|
||||
<Text style={styles.tagline}>
|
||||
Prospection marchand de biens : marge, visite, investisseurs — sur le
|
||||
terrain.
|
||||
</Text>
|
||||
<PrimaryButton
|
||||
title="Continuer hors-ligne (données sur l’appareil)"
|
||||
onPress={() => {
|
||||
void app.enterLocalMode().then(() => router.replace('/(tabs)'));
|
||||
}}
|
||||
containerStyle={styles.btn}
|
||||
/>
|
||||
<PrimaryButton
|
||||
title="Se connecter (Supabase)"
|
||||
variant="ghost"
|
||||
onPress={() => router.push('/auth/login')}
|
||||
containerStyle={styles.btn}
|
||||
/>
|
||||
<PrimaryButton
|
||||
title="Configurer Supabase"
|
||||
variant="ghost"
|
||||
onPress={() => router.push('/(tabs)/reglages')}
|
||||
containerStyle={styles.btn}
|
||||
/>
|
||||
<Text style={styles.hint}>
|
||||
Le mode hors-ligne fonctionne sans compte. Pour synchroniser plusieurs
|
||||
appareils, renseignez votre projet Supabase dans Réglages puis
|
||||
connectez-vous.
|
||||
</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
center: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
muted: { color: colors.textMuted },
|
||||
scroll: { paddingHorizontal: 22, backgroundColor: colors.bg },
|
||||
brand: {
|
||||
fontSize: 34,
|
||||
fontWeight: '800',
|
||||
color: colors.text,
|
||||
marginBottom: 8,
|
||||
},
|
||||
tagline: {
|
||||
fontSize: 16,
|
||||
color: colors.textMuted,
|
||||
lineHeight: 24,
|
||||
marginBottom: 28,
|
||||
},
|
||||
btn: { marginBottom: 12, width: '100%' },
|
||||
hint: {
|
||||
marginTop: 20,
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user