568 lines
19 KiB
TypeScript
568 lines
19 KiB
TypeScript
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 },
|
||
});
|