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

567
app/dossier/[id].tsx Normal file
View File

@ -0,0 +1,567 @@
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 longlet 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 (AG)"
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 : lapp ajoute les travaux associés et recalcule
le prix dachat 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 dachat 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 },
});