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('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: () => ( { 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()); }, }, ], ); }} > ), }); }, [navigation, dossier?.title, dossierId, app.deleteDossier]); if (!dossierId || !dossier || !juge) { return ( Dossier introuvable. router.back()} /> ); } 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 ( {TABS.map((t) => ( setTab(t.key)} style={[styles.tabChip, tab === t.key && styles.tabChipOn]} > {t.label} ))} {tab === 'dash' ? ( Score deal {juge.result.scoreDeal} Marge nette : {(juge.result.netMarginPct * 100).toFixed(1)} % (seuil achat : {(MIN_NET_MARGIN_PCT * 100).toFixed(0)} %) Feu : {juge.result.trafficLight} — DVF flash :{' '} {juge.result.dvfUnderMarketFlash ? 'oui' : 'non'} Synthèse void app.setDossierStatus(dossier.id, 'under_promise')} containerStyle={{ marginTop: 8 }} /> ) : null} {tab === 'money' ? ( void app.updateDossier(dossier.id, patch)} /> ) : null} {tab === 'visit' ? ( void app.toggleFinding(dossier.id, code, checked) } checklistEUR={juge.checklistWorks} maxPurchase={juge.maxPurchase} /> ) : null} {tab === 'flash' ? ( {dossier.status !== 'under_promise' ? ( Verrouillez le dossier (« sous promesse ») depuis l’onglet Feu pour activer le teaser investisseur. ) : ( <> Investisseurs (top 5 match) {matches.length === 0 ? ( Aucun match — ajustez critères ou dossier. ) : ( matches.map((m) => ( {m.display_name} {m.email ? ( {m.email} ) : null} )) )} void shareTeaserPdf( dossier, juge.result, matches.map((m) => m.display_name), ) } /> )} ) : null} ); } function Row({ label, value }: { label: string; value: string }) { return ( {label} {value} ); } function FinancesEditor({ dossier, onSave, }: { dossier: DossierRow; onSave: (patch: Partial) => 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 ( setDpe(t.toUpperCase())} /> Urbanisme & stratégie Piste division parcellaire Piste déficit foncier (passoire) { 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, }); }} /> ); } 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 ( Anti-erreur visite 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. Travaux checklist : {checklistEUR.toLocaleString('fr-FR')} € Prix d’achat max (cible marge) :{' '} {maxPurchase.toLocaleString('fr-FR')} € {rows.map(({ def, f }) => { const checked = f?.checked ?? false; return ( {def.label} +{(f?.works_delta_override_eur ?? def.default_works_delta_eur).toLocaleString('fr-FR')}{' '} € si coché onToggle(def.code, v)} trackColor={{ true: colors.accent, false: colors.border }} /> ); })} ); } 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 }, });