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

View File

@ -0,0 +1,40 @@
import { StyleSheet, Text, TextInput, View, type TextInputProps } from 'react-native';
import { colors } from '../theme/colors';
type Props = TextInputProps & {
label: string;
};
export function LabeledField({ label, style, ...rest }: Props) {
return (
<View style={styles.wrap}>
<Text style={styles.label}>{label}</Text>
<TextInput
placeholderTextColor={colors.textMuted}
style={[styles.input, style]}
{...rest}
/>
</View>
);
}
const styles = StyleSheet.create({
wrap: { marginBottom: 14 },
label: {
color: colors.textMuted,
fontSize: 12,
marginBottom: 6,
textTransform: 'uppercase',
letterSpacing: 0.06,
},
input: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
color: colors.text,
backgroundColor: colors.bgCard,
fontSize: 16,
},
});

View File

@ -0,0 +1,77 @@
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
type PressableProps,
type ViewStyle,
} from 'react-native';
import { colors } from '../theme/colors';
type Props = Omit<PressableProps, 'style'> & {
title: string;
variant?: 'primary' | 'danger' | 'ghost';
loading?: boolean;
containerStyle?: ViewStyle;
};
export function PrimaryButton({
title,
variant = 'primary',
loading,
disabled,
containerStyle,
...rest
}: Props) {
const dim = disabled || loading;
return (
<Pressable
accessibilityRole="button"
style={({ pressed }) => [
styles.base,
variant === 'primary' && styles.primary,
variant === 'danger' && styles.danger,
variant === 'ghost' && styles.ghost,
(pressed || dim) && styles.dim,
containerStyle,
]}
disabled={dim}
{...rest}
>
{loading ? (
<ActivityIndicator color={variant === 'ghost' ? colors.accent : '#fff'} />
) : (
<Text
style={[
styles.text,
variant === 'ghost' && styles.textGhost,
variant === 'danger' && styles.textDanger,
]}
>
{title}
</Text>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
base: {
paddingVertical: 14,
paddingHorizontal: 18,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
primary: { backgroundColor: colors.accent },
danger: { backgroundColor: colors.danger },
ghost: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: colors.border,
},
dim: { opacity: 0.55 },
text: { color: '#fff', fontWeight: '600', fontSize: 16 },
textGhost: { color: colors.text },
textDanger: { color: '#fff' },
});

655
src/context/AppContext.tsx Normal file
View File

@ -0,0 +1,655 @@
import type { Session, SupabaseClient } from '@supabase/supabase-js';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { makeSupabase } from '../lib/supabaseFactory';
import {
newDossierTemplate,
seedVisitRowsForDossier,
LOCAL_USER_ID,
} from '../data/defaults';
import {
SCOUT_SAMPLE_JSON,
scoutFilterListings,
} from '../data/dealSource';
import type {
DealSourceRow,
DossierRow,
DossierVisitFindingRow,
InvestisseurRow,
LocalDbSnapshot,
VisitFindingDefinitionRow,
} from '../data/types';
import { VISIT_FINDING_SEED } from '../data/visitWorks';
import {
readCloudConfig,
readLocalDb,
readStoredMode,
writeCloudConfig,
writeLocalDb,
writeStoredMode,
type StoredCloudConfig,
} from './persistence';
import { randomUuid } from '../lib/uuid';
export type AppRuntimeMode = 'none' | 'local' | 'cloud';
type AppUser = { id: string; email: string | null };
interface AppContextValue {
ready: boolean;
runtimeMode: AppRuntimeMode;
user: AppUser | null;
supabase: SupabaseClient | null;
dossiers: DossierRow[];
investisseurs: InvestisseurRow[];
definitions: VisitFindingDefinitionRow[];
findingTick: number;
refreshAll: () => Promise<void>;
enterLocalMode: () => Promise<void>;
saveCloudConfig: (cfg: StoredCloudConfig) => Promise<void>;
signIn: (email: string, password: string) => Promise<{ error?: string }>;
signUp: (
email: string,
password: string,
fullName: string,
) => Promise<{ error?: string }>;
signOut: () => Promise<void>;
createDossier: () => Promise<string | null>;
updateDossier: (id: string, patch: Partial<DossierRow>) => Promise<void>;
deleteDossier: (id: string) => Promise<void>;
setDossierStatus: (id: string, status: DossierRow['status']) => Promise<void>;
listFindings: (dossierId: string) => DossierVisitFindingRow[];
toggleFinding: (
dossierId: string,
code: string,
checked: boolean,
) => Promise<void>;
upsertInvestisseur: (
row: Omit<InvestisseurRow, 'created_at' | 'updated_at' | 'id'> & {
id?: string;
},
) => Promise<void>;
deleteInvestisseur: (id: string) => Promise<void>;
dealSources: DealSourceRow[];
runScoutSampleBatch: () => Promise<
{ inserted: number; gradeA: number } | { error: string }
>;
}
const Ctx = createContext<AppContextValue | null>(null);
function normalizeDefinitions(
rows: VisitFindingDefinitionRow[] | null | undefined,
): VisitFindingDefinitionRow[] {
if (rows && rows.length) {
return [...rows].sort((a, b) => a.sort_order - b.sort_order);
}
return VISIT_FINDING_SEED;
}
export function AppProvider({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
const [runtimeMode, setRuntimeMode] = useState<AppRuntimeMode>('none');
const [supabase, setSupabase] = useState<SupabaseClient | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [dossiers, setDossiers] = useState<DossierRow[]>([]);
const [investisseurs, setInvestisseurs] = useState<InvestisseurRow[]>([]);
const [definitions, setDefinitions] = useState<VisitFindingDefinitionRow[]>(
VISIT_FINDING_SEED,
);
const [localDb, setLocalDb] = useState<LocalDbSnapshot>({
dossiers: [],
dossier_visit_findings: [],
investisseurs: [],
deals_sources: [],
});
const [findingTick, setFindingTick] = useState(0);
const [dealSources, setDealSources] = useState<DealSourceRow[]>([]);
const user = useMemo<AppUser | null>(() => {
if (runtimeMode === 'local') {
return { id: LOCAL_USER_ID, email: 'hors-ligne@mdb-turbo' };
}
if (runtimeMode === 'cloud' && session?.user) {
return { id: session.user.id, email: session.user.email ?? null };
}
return null;
}, [runtimeMode, session]);
const refreshAll = useCallback(async () => {
if (runtimeMode === 'local') {
const db = await readLocalDb();
setLocalDb(db);
setDossiers(db.dossiers);
setInvestisseurs(db.investisseurs);
setDealSources(db.deals_sources ?? []);
setDefinitions(VISIT_FINDING_SEED);
return;
}
if (!supabase || !session?.user) {
setDossiers([]);
setInvestisseurs([]);
setDealSources([]);
return;
}
const uid = session.user.id;
const [dRes, iRes, defRes, dealsRes] = await Promise.all([
supabase
.from('dossiers')
.select('*')
.eq('user_id', uid)
.order('updated_at', { ascending: false }),
supabase.from('investisseurs').select('*').eq('user_id', uid),
supabase
.from('visit_finding_definitions')
.select('*')
.order('sort_order', { ascending: true }),
supabase
.from('deals_sources')
.select('*')
.eq('user_id', uid)
.order('opportunity_score', { ascending: false }),
]);
setDossiers((dRes.data as DossierRow[]) ?? []);
setInvestisseurs((iRes.data as InvestisseurRow[]) ?? []);
setDefinitions(normalizeDefinitions(defRes.data as VisitFindingDefinitionRow[]));
setDealSources((dealsRes.data as DealSourceRow[]) ?? []);
}, [runtimeMode, supabase, session]);
useEffect(() => {
let cancelled = false;
(async () => {
const mode = await readStoredMode();
const cfg = await readCloudConfig();
const local = await readLocalDb();
if (cancelled) return;
if (mode === 'local') {
setRuntimeMode('local');
setLocalDb(local);
setDossiers(local.dossiers);
setInvestisseurs(local.investisseurs);
setDealSources(local.deals_sources ?? []);
setDefinitions(VISIT_FINDING_SEED);
setSupabase(null);
setSession(null);
setReady(true);
return;
}
if (mode === 'cloud' && cfg?.supabaseUrl && cfg.supabaseAnonKey) {
const client = makeSupabase(cfg.supabaseUrl, cfg.supabaseAnonKey);
const { data } = await client.auth.getSession();
if (cancelled) return;
setSupabase(client);
setSession(data.session ?? null);
setRuntimeMode('cloud');
setReady(true);
return;
}
setRuntimeMode('none');
setSupabase(null);
setSession(null);
setReady(true);
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!supabase) return;
const { data: sub } = supabase.auth.onAuthStateChange((_event, sess) => {
setSession(sess);
});
return () => sub.subscription.unsubscribe();
}, [supabase]);
useEffect(() => {
if (!ready) return;
void refreshAll();
}, [ready, runtimeMode, session?.user?.id, refreshAll]);
const persistLocal = useCallback(async (next: LocalDbSnapshot) => {
setLocalDb(next);
setDossiers(next.dossiers);
setInvestisseurs(next.investisseurs);
setDealSources(next.deals_sources ?? []);
await writeLocalDb(next);
setFindingTick((t) => t + 1);
}, []);
const enterLocalMode = useCallback(async () => {
await writeStoredMode('local');
const existing = await readLocalDb();
setRuntimeMode('local');
setSupabase(null);
setSession(null);
setLocalDb(existing);
setDossiers(existing.dossiers);
setInvestisseurs(existing.investisseurs);
setDealSources(existing.deals_sources ?? []);
setDefinitions(VISIT_FINDING_SEED);
}, []);
const saveCloudConfig = useCallback(async (cfg: StoredCloudConfig) => {
await writeCloudConfig(cfg);
await writeStoredMode('cloud');
const client = makeSupabase(cfg.supabaseUrl, cfg.supabaseAnonKey);
const { data } = await client.auth.getSession();
setSupabase(client);
setSession(data.session ?? null);
setRuntimeMode('cloud');
}, []);
const signIn = useCallback(
async (email: string, password: string) => {
if (!supabase) return { error: 'Supabase non configuré.' };
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) return { error: error.message };
return {};
},
[supabase],
);
const signUp = useCallback(
async (email: string, password: string, fullName: string) => {
if (!supabase) return { error: 'Supabase non configuré.' };
const { error } = await supabase.auth.signUp({
email,
password,
options: { data: { full_name: fullName } },
});
if (error) return { error: error.message };
return {};
},
[supabase],
);
const signOut = useCallback(async () => {
if (runtimeMode === 'cloud' && supabase) {
await supabase.auth.signOut();
return;
}
if (runtimeMode === 'local') {
await writeStoredMode('none');
setRuntimeMode('none');
setSession(null);
setSupabase(null);
setDossiers([]);
setInvestisseurs([]);
setDealSources([]);
setLocalDb({
dossiers: [],
dossier_visit_findings: [],
investisseurs: [],
deals_sources: [],
});
}
}, [runtimeMode, supabase]);
const createDossier = useCallback(async (): Promise<string | null> => {
const uid = user?.id;
if (!uid) return null;
if (runtimeMode === 'local') {
const snap = await readLocalDb();
const row = newDossierTemplate(uid);
const findings = seedVisitRowsForDossier(row.id);
const next: LocalDbSnapshot = {
dossiers: [row, ...snap.dossiers],
dossier_visit_findings: [...findings, ...snap.dossier_visit_findings],
investisseurs: snap.investisseurs,
deals_sources: snap.deals_sources ?? [],
};
await persistLocal(next);
return row.id;
}
if (!supabase) return null;
const base = newDossierTemplate(uid);
const { data, error } = await supabase
.from('dossiers')
.insert({
user_id: uid,
title: base.title,
status: base.status,
surface_m2: base.surface_m2,
purchase_price_target: base.purchase_price_target,
resale_price_estimate: base.resale_price_estimate,
dvf_reference_price_m2: base.dvf_reference_price_m2,
works_estimate_total: base.works_estimate_total,
works_visit_adjustment: base.works_visit_adjustment,
notary_fee_rate: base.notary_fee_rate,
sale_agency_fee_rate: base.sale_agency_fee_rate,
misc_acquisition_cost: base.misc_acquisition_cost,
misc_sale_cost: base.misc_sale_cost,
carrying_months: base.carrying_months,
carrying_annual_rate: base.carrying_annual_rate,
parcel_subdivision_candidate: base.parcel_subdivision_candidate,
deficit_foncier_candidate: base.deficit_foncier_candidate,
plu_zone_code: base.plu_zone_code,
plu_notes: base.plu_notes,
})
.select('id')
.single();
if (error || !data?.id) {
return null;
}
const id = data.id as string;
const defs = definitions.length ? definitions : VISIT_FINDING_SEED;
const rows = defs.map((d) => ({
dossier_id: id,
finding_code: d.code,
checked: false,
}));
await supabase.from('dossier_visit_findings').insert(rows);
await refreshAll();
return id;
}, [user?.id, runtimeMode, supabase, persistLocal, definitions, refreshAll]);
const updateDossier = useCallback(
async (id: string, patch: Partial<DossierRow>) => {
if (runtimeMode === 'local') {
const snap = await readLocalDb();
const nextDossiers = snap.dossiers.map((d) =>
d.id === id ? { ...d, ...patch, updated_at: new Date().toISOString() } : d,
);
await persistLocal({ ...snap, dossiers: nextDossiers });
return;
}
if (!supabase || !user) return;
const clean: Record<string, unknown> = { ...patch };
delete clean.id;
delete clean.user_id;
delete clean.created_at;
await supabase.from('dossiers').update(clean).eq('id', id).eq('user_id', user.id);
await refreshAll();
},
[runtimeMode, persistLocal, supabase, user, refreshAll],
);
const deleteDossier = useCallback(
async (id: string) => {
if (runtimeMode === 'local') {
const snap = await readLocalDb();
const next: LocalDbSnapshot = {
dossiers: snap.dossiers.filter((d) => d.id !== id),
dossier_visit_findings: snap.dossier_visit_findings.filter(
(f) => f.dossier_id !== id,
),
investisseurs: snap.investisseurs,
deals_sources: snap.deals_sources ?? [],
};
await persistLocal(next);
return;
}
if (!supabase || !user) return;
await supabase.from('dossiers').delete().eq('id', id).eq('user_id', user.id);
await refreshAll();
},
[runtimeMode, persistLocal, supabase, user, refreshAll],
);
const setDossierStatus = useCallback(
async (id: string, status: DossierRow['status']) => {
const extra: Partial<DossierRow> = { status };
if (status === 'under_promise') {
extra.under_promise_at = new Date().toISOString();
}
await updateDossier(id, extra);
},
[updateDossier],
);
const listFindings = useCallback(
(dossierId: string): DossierVisitFindingRow[] => {
if (runtimeMode !== 'local') return [];
return localDb.dossier_visit_findings.filter((f) => f.dossier_id === dossierId);
},
[runtimeMode, localDb.dossier_visit_findings],
);
const toggleFinding = useCallback(
async (dossierId: string, code: string, checked: boolean) => {
const now = new Date().toISOString();
if (runtimeMode === 'local') {
const snap = await readLocalDb();
const nextFindings = snap.dossier_visit_findings.map((f) =>
f.dossier_id === dossierId && f.finding_code === code
? { ...f, checked, checked_at: checked ? now : null }
: f,
);
await persistLocal({ ...snap, dossier_visit_findings: nextFindings });
return;
}
if (!supabase) return;
await supabase
.from('dossier_visit_findings')
.update({ checked, checked_at: checked ? now : null })
.eq('dossier_id', dossierId)
.eq('finding_code', code);
setFindingTick((t) => t + 1);
await refreshAll();
},
[runtimeMode, persistLocal, supabase, refreshAll],
);
const upsertInvestisseur = useCallback(
async (
row: Omit<InvestisseurRow, 'created_at' | 'updated_at' | 'id'> & {
id?: string;
},
) => {
const ts = new Date().toISOString();
if (runtimeMode === 'local') {
const snap = await readLocalDb();
const id = row.id ?? randomUuid();
const existing = snap.investisseurs.find((i) => i.id === id);
const nextRow: InvestisseurRow = {
id,
user_id: row.user_id,
display_name: row.display_name,
email: row.email,
phone: row.phone,
min_margin_pct: row.min_margin_pct,
max_ticket_eur: row.max_ticket_eur,
zones: row.zones,
strategies: row.strategies,
notes: row.notes,
created_at: existing?.created_at ?? ts,
updated_at: ts,
};
const list = existing
? snap.investisseurs.map((i) => (i.id === id ? nextRow : i))
: [nextRow, ...snap.investisseurs];
await persistLocal({ ...snap, investisseurs: list });
return;
}
if (!supabase || !user) return;
if (row.id) {
await supabase
.from('investisseurs')
.update({
display_name: row.display_name,
email: row.email,
phone: row.phone,
min_margin_pct: row.min_margin_pct,
max_ticket_eur: row.max_ticket_eur,
zones: row.zones,
strategies: row.strategies,
notes: row.notes,
})
.eq('id', row.id)
.eq('user_id', user.id);
} else {
await supabase.from('investisseurs').insert({
user_id: user.id,
display_name: row.display_name,
email: row.email,
phone: row.phone,
min_margin_pct: row.min_margin_pct,
max_ticket_eur: row.max_ticket_eur,
zones: row.zones,
strategies: row.strategies,
notes: row.notes,
});
}
await refreshAll();
},
[runtimeMode, persistLocal, supabase, user, refreshAll],
);
const deleteInvestisseur = useCallback(
async (id: string) => {
if (runtimeMode === 'local') {
const snap = await readLocalDb();
await persistLocal({
...snap,
investisseurs: snap.investisseurs.filter((i) => i.id !== id),
});
return;
}
if (!supabase || !user) return;
await supabase.from('investisseurs').delete().eq('id', id).eq('user_id', user.id);
await refreshAll();
},
[runtimeMode, persistLocal, supabase, user, refreshAll],
);
const runScoutSampleBatch = useCallback(async () => {
const uid = user?.id;
if (!uid) {
return { error: 'Session requise.' };
}
if (runtimeMode === 'local') {
const snap = await readLocalDb();
const newOnes = scoutFilterListings(SCOUT_SAMPLE_JSON, uid);
await persistLocal({
...snap,
deals_sources: [...newOnes, ...(snap.deals_sources ?? [])],
});
const gradeA = newOnes.filter((x) => x.grade === 'A').length;
return { inserted: newOnes.length, gradeA };
}
if (!supabase) {
return { error: 'Supabase non configuré.' };
}
const { data, error } = await supabase.rpc('scout_process_batch', {
p_listings: SCOUT_SAMPLE_JSON,
});
if (error) {
return { error: error.message };
}
const row = data as { inserted_count?: number; grade_a_count?: number } | null;
await refreshAll();
return {
inserted: row?.inserted_count ?? 0,
gradeA: row?.grade_a_count ?? 0,
};
}, [user?.id, runtimeMode, supabase, persistLocal, refreshAll]);
const value = useMemo<AppContextValue>(
() => ({
ready,
runtimeMode,
user,
supabase,
dossiers,
investisseurs,
definitions,
findingTick,
dealSources,
refreshAll,
enterLocalMode,
saveCloudConfig,
signIn,
signUp,
signOut,
createDossier,
updateDossier,
deleteDossier,
setDossierStatus,
listFindings,
toggleFinding,
upsertInvestisseur,
deleteInvestisseur,
runScoutSampleBatch,
}),
[
ready,
runtimeMode,
user,
supabase,
dossiers,
investisseurs,
definitions,
findingTick,
dealSources,
refreshAll,
enterLocalMode,
saveCloudConfig,
signIn,
signUp,
signOut,
createDossier,
updateDossier,
deleteDossier,
setDossierStatus,
listFindings,
toggleFinding,
upsertInvestisseur,
deleteInvestisseur,
runScoutSampleBatch,
],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useApp(): AppContextValue {
const v = useContext(Ctx);
if (!v) {
throw new Error('useApp doit être utilisé dans AppProvider');
}
return v;
}
export function useVisitFindings(dossierId: string | undefined) {
const app = useApp();
const [cloudRows, setCloudRows] = useState<DossierVisitFindingRow[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
if (!dossierId || app.runtimeMode !== 'cloud' || !app.supabase) {
setCloudRows([]);
return;
}
const { data } = await app.supabase
.from('dossier_visit_findings')
.select('*')
.eq('dossier_id', dossierId);
if (!cancelled) {
setCloudRows((data as DossierVisitFindingRow[]) ?? []);
}
})();
return () => {
cancelled = true;
};
}, [dossierId, app.runtimeMode, app.supabase, app.findingTick]);
if (!dossierId) return [];
if (app.runtimeMode === 'local') {
return app.listFindings(dossierId);
}
return cloudRows;
}

View File

@ -0,0 +1,75 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { LocalDbSnapshot } from '../data/types';
const KEY_MODE = 'mdb_mode_v1';
const KEY_CONFIG = 'mdb_config_v1';
const KEY_LOCAL_DB = 'mdb_local_db_v1';
export type StoredMode = 'local' | 'cloud' | 'none';
export interface StoredCloudConfig {
supabaseUrl: string;
supabaseAnonKey: string;
}
export async function readStoredMode(): Promise<StoredMode> {
const v = await AsyncStorage.getItem(KEY_MODE);
if (v === 'local' || v === 'cloud') return v;
return 'none';
}
export async function writeStoredMode(mode: StoredMode): Promise<void> {
if (mode === 'none') {
await AsyncStorage.removeItem(KEY_MODE);
return;
}
await AsyncStorage.setItem(KEY_MODE, mode);
}
export async function readCloudConfig(): Promise<StoredCloudConfig | null> {
const raw = await AsyncStorage.getItem(KEY_CONFIG);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as StoredCloudConfig;
if (parsed.supabaseUrl && parsed.supabaseAnonKey) return parsed;
return null;
} catch {
return null;
}
}
export async function writeCloudConfig(cfg: StoredCloudConfig): Promise<void> {
await AsyncStorage.setItem(KEY_CONFIG, JSON.stringify(cfg));
}
export async function readLocalDb(): Promise<LocalDbSnapshot> {
const raw = await AsyncStorage.getItem(KEY_LOCAL_DB);
if (!raw) {
return {
dossiers: [],
dossier_visit_findings: [],
investisseurs: [],
deals_sources: [],
};
}
try {
const parsed = JSON.parse(raw) as LocalDbSnapshot;
return {
dossiers: parsed.dossiers ?? [],
dossier_visit_findings: parsed.dossier_visit_findings ?? [],
investisseurs: parsed.investisseurs ?? [],
deals_sources: parsed.deals_sources ?? [],
};
} catch {
return {
dossiers: [],
dossier_visit_findings: [],
investisseurs: [],
deals_sources: [],
};
}
}
export async function writeLocalDb(db: LocalDbSnapshot): Promise<void> {
await AsyncStorage.setItem(KEY_LOCAL_DB, JSON.stringify(db));
}

10
src/core/juge/index.ts Normal file
View File

@ -0,0 +1,10 @@
export {
evaluateDeal,
maxPurchaseForTargetNetMarginPct,
} from './marginCalculator';
export type { JugeInputs, JugeResult } from './marginCalculator';
export {
DEFAULT_VAT_ON_MARGIN_RATE,
DVF_DISCOUNT_FLASH_PCT,
MIN_NET_MARGIN_PCT,
} from './thresholds';

View File

@ -1,16 +1,16 @@
import type { DealTrafficLight } from '../../domain/dealSignals';
import {
DEFAULT_VAT_ON_MARGIN_RATE,
DVF_DISCOUNT_FLASH_PCT,
MIN_NET_MARGIN_PCT,
} from './thresholds';
export type DealTrafficLight = 'red' | 'orange' | 'green' | 'green_flash_dvf';
export type { DealTrafficLight };
export interface JugeInputs {
purchasePrice: number;
resalePrice: number;
surfaceM2: number;
/** Prix m² de référence marché (DVF / étude locale). */
dvfReferencePriceM2?: number | null;
worksTotal: number;
notaryFeeRate: number;
@ -20,11 +20,6 @@ export interface JugeInputs {
carryingMonths: number;
carryingAnnualRate: number;
carryingPrincipal?: number | null;
/**
* TVA sur marge : approximation prudentielle pour outil terrain.
* Base imposable = marge économique avant TVA (cash hors TVA collectée/déductible).
* Ajustez avec votre expert-comptable selon votre assiette réelle.
*/
vatOnMarginRate?: number;
}
@ -38,8 +33,9 @@ export interface JugeResult {
breakEvenResalePrice: number;
purchasePricePerM2: number;
dvfDiscountPct: number | null;
/** Sous-cotation vs DVF (prix / m² ≤ référence × (1 20 %)). */
dvfUnderMarketFlash: boolean;
trafficLight: DealTrafficLight;
/** 0100 : marge nette normalisée vs seuil + bonus sous-cotation DVF. */
scoreDeal: number;
}
@ -51,9 +47,6 @@ function carryingCostEUR(input: JugeInputs): number {
return principal * input.carryingAnnualRate * (input.carryingMonths / 12);
}
/**
* Le Juge : synthèse financière go / no-go pour marchand de biens.
*/
export function evaluateDeal(input: JugeInputs): JugeResult {
const vatRate = input.vatOnMarginRate ?? DEFAULT_VAT_ON_MARGIN_RATE;
@ -81,6 +74,7 @@ export function evaluateDeal(input: JugeInputs): JugeResult {
input.surfaceM2 > 0 ? input.purchasePrice / input.surfaceM2 : 0;
let dvfDiscountPct: number | null = null;
let dvfUnderMarketFlash = false;
if (
input.dvfReferencePriceM2 != null &&
input.dvfReferencePriceM2 > 0 &&
@ -89,13 +83,15 @@ export function evaluateDeal(input: JugeInputs): JugeResult {
dvfDiscountPct =
(input.dvfReferencePriceM2 - purchasePricePerM2) /
input.dvfReferencePriceM2;
dvfUnderMarketFlash =
purchasePricePerM2 <=
input.dvfReferencePriceM2 * (1 - DVF_DISCOUNT_FLASH_PCT);
}
const trafficLight = resolveTrafficLight(
netMarginPct,
dvfUnderMarketFlash,
dvfDiscountPct,
purchasePricePerM2,
input.dvfReferencePriceM2,
);
const scoreDeal = computeScoreDeal(netMarginPct, dvfDiscountPct);
@ -107,35 +103,34 @@ export function evaluateDeal(input: JugeInputs): JugeResult {
vatOnMargin,
netMarginAfterVat,
netMarginPct,
breakEvenResalePrice: computeBreakEvenResale(input, vatRate),
breakEvenResalePrice: computeBreakEvenResale(input),
purchasePricePerM2,
dvfDiscountPct,
dvfUnderMarketFlash,
trafficLight,
scoreDeal,
};
}
/** Marge : feu rouge sous 15 %. DVF : flash vert indépendant (signal dachat). */
function resolveTrafficLight(
netMarginPct: number,
dvfUnderMarketFlash: boolean,
dvfDiscountPct: number | null,
purchasePricePerM2: number,
dvfReferencePriceM2?: number | null,
): DealTrafficLight {
const underDvfFlash =
dvfReferencePriceM2 != null &&
dvfReferencePriceM2 > 0 &&
purchasePricePerM2 <= dvfReferencePriceM2 * (1 - DVF_DISCOUNT_FLASH_PCT);
if (netMarginPct < MIN_NET_MARGIN_PCT) {
return underDvfFlash ? 'green_flash_dvf' : 'red';
return 'red';
}
if (underDvfFlash) {
if (dvfUnderMarketFlash) {
return 'green_flash_dvf';
}
if (dvfDiscountPct != null && dvfDiscountPct > 0.1) {
return 'green';
}
return 'orange';
if (netMarginPct < 0.18) {
return 'orange';
}
return 'green';
}
function computeScoreDeal(
@ -153,7 +148,7 @@ function computeScoreDeal(
return Math.round(Math.min(100, marginScore + dvfBonus));
}
function computeBreakEvenResale(input: JugeInputs, vatRate: number): number {
function computeBreakEvenResale(input: JugeInputs): number {
const notaryFees = input.purchasePrice * input.notaryFeeRate;
const carrying = carryingCostEUR(input);
const fixedCosts =
@ -165,83 +160,25 @@ function computeBreakEvenResale(input: JugeInputs, vatRate: number): number {
input.miscSaleCost;
const agencyRate = input.saleAgencyFeeRate;
const effectiveCoeff = 1 - agencyRate - vatRate * (1 - agencyRate);
if (effectiveCoeff <= 0) {
const coeff = 1 - agencyRate;
if (coeff <= 0) {
return Number.POSITIVE_INFINITY;
}
return fixedCosts / effectiveCoeff;
return fixedCosts / coeff;
}
/**
* Prix d'achat maximum pour respecter une marge nette cible (linéaire sur coûts).
* Utile quand la checklist visite augmente les travaux : recalcul offre max.
* Prix dachat max pour tenir une marge nette cible (ex. 15 % après TVA sur marge).
* Recalcul instantané quand la checklist visite augmente les travaux.
*/
export function maxPurchaseForTargetNetMarginPct(
input: Omit<JugeInputs, 'purchasePrice'>,
targetNetMarginPct: number,
): number {
const vatRate = input.vatOnMarginRate ?? DEFAULT_VAT_ON_MARGIN_RATE;
const carryingPrincipalFallback = 0;
const agency = input.saleAgencyFeeRate;
const netResaleCoeff = 1 - agency;
const numerator =
netResaleCoeff * input.resalePrice -
input.miscSaleCost -
input.worksTotal -
input.miscAcquisitionCost -
(1 + targetNetMarginPct + vatRate * (1 + targetNetMarginPct)) *
carryingPrincipalFallback;
const denominator =
(1 + input.notaryFeeRate) *
(1 +
targetNetMarginPct +
vatRate * (1 + targetNetMarginPct) +
input.carryingAnnualRate * (input.carryingMonths / 12));
if (denominator <= 0) {
return 0;
}
let maxPurchase = numerator / denominator;
const refine = (candidate: number): number => {
const trial: JugeInputs = {
...input,
purchasePrice: candidate,
carryingPrincipal: input.carryingPrincipal ?? candidate,
};
const { netMarginPct } = evaluateDeal(trial);
return netMarginPct - targetNetMarginPct;
};
maxPurchase = binarySearchPurchase(input, targetNetMarginPct, vatRate);
void refine;
return Math.max(0, Math.round(maxPurchase));
}
/**
* Recherche dichotomique robuste (le lien marge ↔ prix d'achat est affine par morceaux
* mais les arrondis / TVA max(0,·) peuvent créer des irrégularités légères).
*/
export function maxPurchaseForTargetNetMarginPctRobust(
input: Omit<JugeInputs, 'purchasePrice'>,
targetNetMarginPct: number,
): number {
return binarySearchPurchase(input, targetNetMarginPct);
}
function binarySearchPurchase(
input: Omit<JugeInputs, 'purchasePrice'>,
targetNetMarginPct: number,
vatRate: number = input.vatOnMarginRate ?? DEFAULT_VAT_ON_MARGIN_RATE,
): number {
let low = 0;
let high = input.resalePrice * 1.5;
for (let i = 0; i < 48; i++) {
let high = Math.max(input.resalePrice * 1.5, 1);
for (let i = 0; i < 56; i++) {
const mid = (low + high) / 2;
const { netMarginPct } = evaluateDeal({
...input,

91
src/data/dealSource.ts Normal file
View File

@ -0,0 +1,91 @@
import { randomUuid } from '../lib/uuid';
import type { DealSourceRow } from './types';
export const SCOUT_SIMULATED_DVF_AVG_M2 = 3500;
const KEYWORDS = ['succession', 'urgent', 'travaux important'] as const;
export interface RawListingInput {
title?: string;
description?: string;
price_eur?: number;
surface_m2?: number;
url?: string;
source?: string;
}
export function scoutFilterListings(
listings: RawListingInput[],
userId: string,
): DealSourceRow[] {
const out: DealSourceRow[] = [];
const now = new Date().toISOString();
const avg = SCOUT_SIMULATED_DVF_AVG_M2;
for (const el of listings) {
const price = el.price_eur;
const surf = el.surface_m2;
if (price == null || surf == null || surf <= 0) continue;
const txt = `${el.description ?? ''} ${el.title ?? ''}`.toLowerCase();
const matched: string[] = [];
for (const k of KEYWORDS) {
if (txt.includes(k)) matched.push(k);
}
const okKw = matched.length > 0;
const pm2 = price / surf;
const okPm2 = pm2 < avg;
if (!okKw || !okPm2) continue;
const score =
40 +
Math.max(0, ((avg - pm2) / avg) * 50) +
matched.length * 10;
const grade = score >= 80 ? 'A' : score >= 55 ? 'B' : 'C';
out.push({
id: randomUuid(),
user_id: userId,
title: (el.title?.trim() || 'Sans titre').slice(0, 500),
description: el.description ?? null,
source_url: el.url?.trim() || null,
source_name: el.source?.trim() || null,
price_eur: price,
surface_m2: surf,
price_per_m2_eur: pm2,
dvf_avg_m2_simulated: avg,
distress_keywords: matched,
opportunity_score: Math.round(score * 100) / 100,
grade,
raw_payload: el as unknown as Record<string, unknown>,
created_at: now,
});
}
return out;
}
export const SCOUT_SAMPLE_JSON: RawListingInput[] = [
{
title: 'Maison succession à rénover',
description: 'Urgent vente succession, travaux importants à prévoir',
price_eur: 198000,
surface_m2: 95,
url: 'https://example.com/a1',
source: 'simulation',
},
{
title: 'Appartement centre',
description: 'Bel appartement rénové',
price_eur: 320000,
surface_m2: 65,
url: 'https://example.com/a2',
source: 'simulation',
},
{
title: 'Longère DPE G urgent',
description: 'Succession — urgent — gros travaux importants',
price_eur: 142000,
surface_m2: 120,
url: 'https://example.com/a3',
source: 'simulation',
},
];

54
src/data/defaults.ts Normal file
View File

@ -0,0 +1,54 @@
import { randomUuid } from '../lib/uuid';
import type { DossierRow, DossierVisitFindingRow } from './types';
import { VISIT_FINDING_SEED } from './visitWorks';
export const LOCAL_USER_ID = '11111111-1111-1111-1111-111111111111';
export function newDossierTemplate(userId: string): DossierRow {
const now = new Date().toISOString();
return {
id: randomUuid(),
user_id: userId,
title: 'Nouveau dossier',
status: 'draft',
address_line: null,
city: null,
postal_code: null,
insee_code: null,
surface_m2: 90,
land_surface_m2: null,
rooms_count: null,
dpe_class: null,
purchase_price_target: 150_000,
resale_price_estimate: 240_000,
dvf_reference_price_m2: 2800,
works_estimate_total: 25_000,
works_visit_adjustment: 0,
notary_fee_rate: 0.077,
sale_agency_fee_rate: 0.05,
misc_acquisition_cost: 2000,
misc_sale_cost: 1500,
carrying_months: 6,
carrying_annual_rate: 0.055,
carrying_principal: null,
plu_zone_code: null,
plu_notes: null,
parcel_subdivision_candidate: false,
deficit_foncier_candidate: false,
under_promise_at: null,
teaser_pdf_url: null,
created_at: now,
updated_at: now,
};
}
export function seedVisitRowsForDossier(dossierId: string): DossierVisitFindingRow[] {
return VISIT_FINDING_SEED.map((d) => ({
id: randomUuid(),
dossier_id: dossierId,
finding_code: d.code,
checked: false,
works_delta_override_eur: null,
checked_at: null,
}));
}

104
src/data/types.ts Normal file
View File

@ -0,0 +1,104 @@
export type DossierStatusDb =
| 'draft'
| 'sourcing'
| 'analysis'
| 'visit'
| 'offer'
| 'under_promise'
| 'resale'
| 'closed_won'
| 'closed_lost';
export interface VisitFindingDefinitionRow {
code: string;
label: string;
default_works_delta_eur: number;
severity: number;
sort_order: number;
}
export interface DossierVisitFindingRow {
id: string;
dossier_id: string;
finding_code: string;
checked: boolean;
works_delta_override_eur: number | null;
checked_at: string | null;
}
export interface DossierRow {
id: string;
user_id: string;
title: string;
status: DossierStatusDb;
address_line: string | null;
city: string | null;
postal_code: string | null;
insee_code: string | null;
surface_m2: number | null;
land_surface_m2: number | null;
rooms_count: number | null;
dpe_class: string | null;
purchase_price_target: number | null;
resale_price_estimate: number | null;
dvf_reference_price_m2: number | null;
works_estimate_total: number | null;
works_visit_adjustment: number | null;
notary_fee_rate: number | null;
sale_agency_fee_rate: number | null;
misc_acquisition_cost: number | null;
misc_sale_cost: number | null;
carrying_months: number | null;
carrying_annual_rate: number | null;
carrying_principal: number | null;
plu_zone_code: string | null;
plu_notes: string | null;
parcel_subdivision_candidate: boolean;
deficit_foncier_candidate: boolean;
under_promise_at: string | null;
teaser_pdf_url: string | null;
created_at: string;
updated_at: string;
}
export interface InvestisseurRow {
id: string;
user_id: string;
display_name: string;
email: string | null;
phone: string | null;
min_margin_pct: number;
max_ticket_eur: number | null;
zones: string[] | null;
strategies: string[] | null;
notes: string | null;
created_at: string;
updated_at: string;
}
export type DealGrade = 'A' | 'B' | 'C';
export interface DealSourceRow {
id: string;
user_id: string;
title: string;
description: string | null;
source_url: string | null;
source_name: string | null;
price_eur: number | null;
surface_m2: number;
price_per_m2_eur: number;
dvf_avg_m2_simulated: number;
distress_keywords: string[];
opportunity_score: number;
grade: DealGrade;
raw_payload?: Record<string, unknown> | null;
created_at: string;
}
export interface LocalDbSnapshot {
dossiers: DossierRow[];
dossier_visit_findings: DossierVisitFindingRow[];
investisseurs: InvestisseurRow[];
deals_sources: DealSourceRow[];
}

95
src/data/visitWorks.ts Normal file
View File

@ -0,0 +1,95 @@
import type {
DossierVisitFindingRow,
VisitFindingDefinitionRow,
} from './types';
export const VISIT_FINDING_SEED: VisitFindingDefinitionRow[] = [
{
code: 'structural_crack',
label: 'Fissure structurelle / désordre porteur',
default_works_delta_eur: 15000,
severity: 5,
sort_order: 10,
},
{
code: 'roof_full_replace',
label: 'Toiture à refaire (complète)',
default_works_delta_eur: 35000,
severity: 5,
sort_order: 20,
},
{
code: 'roof_partial',
label: 'Toiture partielle / zinguerie lourde',
default_works_delta_eur: 8000,
severity: 3,
sort_order: 30,
},
{
code: 'humidity_basement',
label: 'Infiltrations cave / vide sanitaire',
default_works_delta_eur: 12000,
severity: 3,
sort_order: 40,
},
{
code: 'electrical_rewire',
label: 'Rénovation électrique complète',
default_works_delta_eur: 15000,
severity: 4,
sort_order: 50,
},
{
code: 'asbestos',
label: 'Présence amiante / désamiantage à prévoir',
default_works_delta_eur: 10000,
severity: 4,
sort_order: 60,
},
{
code: 'septic_non_conform',
label: 'Assainissement non conforme',
default_works_delta_eur: 12000,
severity: 3,
sort_order: 70,
},
{
code: 'facade_insulation',
label: 'ITE / ravalement lourd',
default_works_delta_eur: 25000,
severity: 3,
sort_order: 80,
},
{
code: 'heat_pump_full',
label: 'Chauffage à refaire (pompe à chaleur + réseau)',
default_works_delta_eur: 18000,
severity: 2,
sort_order: 90,
},
];
export function definitionMap(
defs: VisitFindingDefinitionRow[],
): Map<string, VisitFindingDefinitionRow> {
return new Map(defs.map((d) => [d.code, d]));
}
export function sumCheckedVisitWorksEUR(
findings: DossierVisitFindingRow[],
defs: VisitFindingDefinitionRow[],
): number {
const map = definitionMap(defs);
let sum = 0;
for (const f of findings) {
if (!f.checked) continue;
const def = map.get(f.finding_code);
if (!def) continue;
const delta =
f.works_delta_override_eur != null
? f.works_delta_override_eur
: def.default_works_delta_eur;
sum += delta;
}
return sum;
}

View File

@ -1,5 +1,7 @@
import type { DealTrafficLight } from './dealSignals';
export type { DealTrafficLight } from './dealSignals';
/** Statuts alignés sur `public.dossier_status` (Supabase). */
export type DossierStatus =
| 'draft'
@ -36,5 +38,3 @@ export interface DossierDealSnapshot {
dvfDiscountPct: number | null;
scoreDeal: number;
}
export type { DealTrafficLight };

View File

@ -0,0 +1,44 @@
import type { DossierFinancialInputs } from './dossier';
import type { DossierRow } from '../data/types';
import type { JugeInputs } from '../core/juge/marginCalculator';
export function dossierFinancialToJugeInput(
row: DossierFinancialInputs,
checklistWorksExtraEur = 0,
): JugeInputs {
return {
purchasePrice: row.purchasePriceTarget,
resalePrice: row.resalePriceEstimate,
surfaceM2: row.surfaceM2,
dvfReferencePriceM2: row.dvfReferencePriceM2 ?? undefined,
worksTotal:
row.worksEstimateTotal +
row.worksVisitAdjustment +
checklistWorksExtraEur,
notaryFeeRate: row.notaryFeeRate,
saleAgencyFeeRate: row.saleAgencyFeeRate,
miscAcquisitionCost: row.miscAcquisitionCost,
miscSaleCost: row.miscSaleCost,
carryingMonths: row.carryingMonths,
carryingAnnualRate: row.carryingAnnualRate,
carryingPrincipal: row.carryingPrincipal ?? row.purchasePriceTarget,
};
}
export function dossierRowToFinancialInputs(row: DossierRow): DossierFinancialInputs {
return {
purchasePriceTarget: row.purchase_price_target ?? 0,
resalePriceEstimate: row.resale_price_estimate ?? 0,
surfaceM2: row.surface_m2 ?? 0,
dvfReferencePriceM2: row.dvf_reference_price_m2,
worksEstimateTotal: row.works_estimate_total ?? 0,
worksVisitAdjustment: row.works_visit_adjustment ?? 0,
notaryFeeRate: row.notary_fee_rate ?? 0.077,
saleAgencyFeeRate: row.sale_agency_fee_rate ?? 0.05,
miscAcquisitionCost: row.misc_acquisition_cost ?? 0,
miscSaleCost: row.misc_sale_cost ?? 0,
carryingMonths: row.carrying_months ?? 6,
carryingAnnualRate: row.carrying_annual_rate ?? 0.05,
carryingPrincipal: row.carrying_principal,
};
}

View File

@ -0,0 +1,74 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import * as Notifications from 'expo-notifications';
import { useEffect } from 'react';
import type { DealSourceRow } from '../data/types';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: false,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
export async function ensureNotificationPermission(): Promise<boolean> {
const { status: existing } = await Notifications.getPermissionsAsync();
if (existing === 'granted') return true;
const { status } = await Notifications.requestPermissionsAsync();
return status === 'granted';
}
export function notifyGradeADealLocal(title: string): void {
void Notifications.scheduleNotificationAsync({
content: {
title: 'Opportunité Grade A',
body: title.slice(0, 160),
},
trigger: null,
});
}
/**
* Abonnement Realtime : alerte locale quand une ligne Grade A est insérée (Supabase).
* Activer Realtime sur `deals_sources` dans le dashboard Supabase.
*/
export function useDealsSourcesGradeAAlerts(
supabase: SupabaseClient | null,
userId: string | undefined,
enabled: boolean,
): void {
useEffect(() => {
if (!enabled || !supabase || !userId) return;
const channel = supabase
.channel(`realtime:deals_sources:${userId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'deals_sources',
filter: `user_id=eq.${userId}`,
},
(payload) => {
const row = payload.new as DealSourceRow;
if (row?.grade === 'A') {
void Notifications.scheduleNotificationAsync({
content: {
title: 'Nouvelle opportunité Grade A',
body: (row.title ?? 'Deal').slice(0, 160),
},
trigger: null,
});
}
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [supabase, userId, enabled]);
}

View File

@ -0,0 +1,39 @@
import { useMemo } from 'react';
import type { DossierRow, DossierVisitFindingRow, VisitFindingDefinitionRow } from '../data/types';
import { sumCheckedVisitWorksEUR } from '../data/visitWorks';
import { evaluateDeal, maxPurchaseForTargetNetMarginPct, MIN_NET_MARGIN_PCT } from '../core/juge';
import {
dossierFinancialToJugeInput,
dossierRowToFinancialInputs,
} from '../domain/mapDossierToJuge';
export function useDossierJuge(
dossier: DossierRow | undefined,
findings: DossierVisitFindingRow[],
definitions: VisitFindingDefinitionRow[],
) {
return useMemo(() => {
if (!dossier) return null;
const checklist = sumCheckedVisitWorksEUR(findings, definitions);
const fin = dossierRowToFinancialInputs(dossier);
const jugeInput = dossierFinancialToJugeInput(fin, checklist);
const result = evaluateDeal(jugeInput);
const maxPurchase = maxPurchaseForTargetNetMarginPct(
{
resalePrice: jugeInput.resalePrice,
surfaceM2: jugeInput.surfaceM2,
dvfReferencePriceM2: jugeInput.dvfReferencePriceM2,
worksTotal: jugeInput.worksTotal,
notaryFeeRate: jugeInput.notaryFeeRate,
saleAgencyFeeRate: jugeInput.saleAgencyFeeRate,
miscAcquisitionCost: jugeInput.miscAcquisitionCost,
miscSaleCost: jugeInput.miscSaleCost,
carryingMonths: jugeInput.carryingMonths,
carryingAnnualRate: jugeInput.carryingAnnualRate,
carryingPrincipal: jugeInput.carryingPrincipal,
},
MIN_NET_MARGIN_PCT,
);
return { result, maxPurchase, checklistWorks: checklist };
}, [dossier, findings, definitions]);
}

View File

@ -0,0 +1,14 @@
import 'react-native-url-polyfill/auto';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
export function makeSupabase(url: string, anonKey: string): SupabaseClient {
return createClient(url, anonKey, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
});
}

7
src/lib/uuid.ts Normal file
View File

@ -0,0 +1,7 @@
export function randomUuid(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

View File

@ -0,0 +1,37 @@
import type { DossierRow, InvestisseurRow } from '../data/types';
import type { JugeResult } from '../core/juge';
export function matchInvestisseurs(
dossier: DossierRow,
juge: JugeResult,
list: InvestisseurRow[],
limit = 5,
): InvestisseurRow[] {
const purchase = dossier.purchase_price_target ?? 0;
const marginPct100 = juge.netMarginPct * 100;
const city = (dossier.city ?? '').toLowerCase().trim();
return list
.filter((inv) => {
if (marginPct100 + 1e-6 < inv.min_margin_pct) {
return false;
}
if (
inv.max_ticket_eur != null &&
purchase > 0 &&
purchase > inv.max_ticket_eur
) {
return false;
}
const zones = inv.zones;
if (zones && zones.length > 0 && city) {
const z = zones.map((x) => x.toLowerCase().trim());
const ok = z.some(
(zone) => city.includes(zone) || zone.includes(city),
);
if (!ok) return false;
}
return true;
})
.slice(0, limit);
}

81
src/services/teaserPdf.ts Normal file
View File

@ -0,0 +1,81 @@
import type { DossierRow } from '../data/types';
import type { JugeResult } from '../core/juge';
import * as Print from 'expo-print';
import * as Sharing from 'expo-sharing';
import { Platform } from 'react-native';
function escapeHtml(s: string): string {
return s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
export function buildTeaserHtml(
dossier: DossierRow,
juge: JugeResult,
investisseurs: string[],
): string {
const title = escapeHtml(dossier.title);
const addr = escapeHtml(
[dossier.address_line, dossier.postal_code, dossier.city]
.filter(Boolean)
.join(', ') || 'Adresse à compléter',
);
const bullets = investisseurs
.map((n) => `<li>${escapeHtml(n)}</li>`)
.join('');
return `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #0b1220; padding: 32px; }
h1 { font-size: 26px; margin-bottom: 8px; }
.tag { display: inline-block; background: #0b1220; color: #fff; padding: 6px 12px; border-radius: 999px; font-size: 12px; letter-spacing: 0.04em; }
.grid { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 24px; }
.card { flex: 1 1 40%; border: 1px solid #dce6ff; border-radius: 12px; padding: 16px; background: #f7f9ff; }
.k { font-size: 11px; text-transform: uppercase; color: #5c6b8a; letter-spacing: 0.06em; }
.v { font-size: 20px; font-weight: 700; margin-top: 4px; }
ul { margin-top: 12px; }
.footer { margin-top: 40px; font-size: 11px; color: #5c6b8a; }
</style>
</head>
<body>
<div class="tag">MDB-Turbo — Teaser investisseur</div>
<h1>${title}</h1>
<p>${addr}</p>
<div class="grid">
<div class="card"><div class="k">Marge nette (estim.)</div><div class="v">${(juge.netMarginPct * 100).toFixed(1)} %</div></div>
<div class="card"><div class="k">Score deal</div><div class="v">${juge.scoreDeal} / 100</div></div>
<div class="card"><div class="k">Prix / m² (achat cible)</div><div class="v">${Math.round(juge.purchasePricePerM2).toLocaleString('fr-FR')} €</div></div>
<div class="card"><div class="k">Break-even revente</div><div class="v">${Number.isFinite(juge.breakEvenResalePrice) ? `${Math.round(juge.breakEvenResalePrice).toLocaleString('fr-FR')}` : '—'}</div></div>
</div>
<h2 style="margin-top:32px;font-size:18px;">Investisseurs ciblés (matching critères)</h2>
<ul>${bullets || '<li>(aucun match — complétez la base investisseurs)</li>'}</ul>
<p class="footer">Document généré par MDB-Turbo — chiffres indicatifs, non contractuels. Validez fiscalité / juridique avec vos conseils.</p>
</body>
</html>`;
}
export async function shareTeaserPdf(
dossier: DossierRow,
juge: JugeResult,
investisseurNames: string[],
): Promise<void> {
const html = buildTeaserHtml(dossier, juge, investisseurNames);
const { uri } = await Print.printToFileAsync({ html });
if (Platform.OS === 'web') {
return;
}
const can = await Sharing.isAvailableAsync();
if (!can) {
return;
}
await Sharing.shareAsync(uri, {
mimeType: 'application/pdf',
dialogTitle: 'Teaser investisseur',
});
}

12
src/theme/colors.ts Normal file
View File

@ -0,0 +1,12 @@
export const colors = {
bg: '#0b1220',
bgCard: '#121c2f',
border: '#243352',
text: '#f4f7ff',
textMuted: '#9fb0d0',
accent: '#3d8bfd',
success: '#3fb950',
warning: '#d29922',
danger: '#f85149',
flash: '#7ee787',
};