Files
mdb/app/dossier/[id].tsx
Bastien COIGNOUX bd325fe456 init
2026-05-03 20:18:33 +02:00

568 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Ionicons } from '@expo/vector-icons';
import { router, 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 },
});