Initial commit

Generated by create-expo-app 3.5.3.
This commit is contained in:
Bastien COIGNOUX
2026-04-29 19:48:53 +02:00
commit ffc2e6b895
16 changed files with 9467 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

20
App.tsx Normal file
View File

@ -0,0 +1,20 @@
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, Text, View } from 'react-native';
export default function App() {
return (
<View style={styles.container}>
<Text>Open up App.tsx to start working on your app!</Text>
<StatusBar style="auto" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});

30
app.json Normal file
View File

@ -0,0 +1,30 @@
{
"expo": {
"name": "mdb",
"slug": "mdb",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
}
}
}

BIN
assets/adaptive-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
assets/splash-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

8
index.ts Normal file
View File

@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

8807
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "mdb",
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"expo": "~54.0.33",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "0.81.5"
},
"devDependencies": {
"@types/react": "~19.1.0",
"typescript": "~5.9.2"
},
"private": true
}

View File

@ -0,0 +1,259 @@
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 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;
saleAgencyFeeRate: number;
miscAcquisitionCost: number;
miscSaleCost: number;
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;
}
export interface JugeResult {
totalInvested: number;
netResaleProceeds: number;
grossMarginBeforeVat: number;
vatOnMargin: number;
netMarginAfterVat: number;
netMarginPct: number;
breakEvenResalePrice: number;
purchasePricePerM2: number;
dvfDiscountPct: number | null;
trafficLight: DealTrafficLight;
/** 0100 : marge nette normalisée vs seuil + bonus sous-cotation DVF. */
scoreDeal: number;
}
function carryingCostEUR(input: JugeInputs): number {
const principal =
input.carryingPrincipal != null && input.carryingPrincipal > 0
? input.carryingPrincipal
: input.purchasePrice;
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;
const notaryFees = input.purchasePrice * input.notaryFeeRate;
const carrying = carryingCostEUR(input);
const totalInvested =
input.purchasePrice +
notaryFees +
input.worksTotal +
input.miscAcquisitionCost +
carrying;
const agencyFees = input.resalePrice * input.saleAgencyFeeRate;
const netResaleProceeds =
input.resalePrice - agencyFees - input.miscSaleCost;
const grossMarginBeforeVat = netResaleProceeds - totalInvested;
const vatOnMargin = Math.max(0, grossMarginBeforeVat) * vatRate;
const netMarginAfterVat = grossMarginBeforeVat - vatOnMargin;
const netMarginPct =
totalInvested > 0 ? netMarginAfterVat / totalInvested : 0;
const purchasePricePerM2 =
input.surfaceM2 > 0 ? input.purchasePrice / input.surfaceM2 : 0;
let dvfDiscountPct: number | null = null;
if (
input.dvfReferencePriceM2 != null &&
input.dvfReferencePriceM2 > 0 &&
input.surfaceM2 > 0
) {
dvfDiscountPct =
(input.dvfReferencePriceM2 - purchasePricePerM2) /
input.dvfReferencePriceM2;
}
const trafficLight = resolveTrafficLight(
netMarginPct,
dvfDiscountPct,
purchasePricePerM2,
input.dvfReferencePriceM2,
);
const scoreDeal = computeScoreDeal(netMarginPct, dvfDiscountPct);
return {
totalInvested,
netResaleProceeds,
grossMarginBeforeVat,
vatOnMargin,
netMarginAfterVat,
netMarginPct,
breakEvenResalePrice: computeBreakEvenResale(input, vatRate),
purchasePricePerM2,
dvfDiscountPct,
trafficLight,
scoreDeal,
};
}
function resolveTrafficLight(
netMarginPct: number,
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';
}
if (underDvfFlash) {
return 'green_flash_dvf';
}
if (dvfDiscountPct != null && dvfDiscountPct > 0.1) {
return 'green';
}
return 'orange';
}
function computeScoreDeal(
netMarginPct: number,
dvfDiscountPct: number | null,
): number {
const marginScore = Math.min(
100,
Math.max(0, (netMarginPct / MIN_NET_MARGIN_PCT) * 60),
);
const dvfBonus =
dvfDiscountPct != null && dvfDiscountPct > 0
? Math.min(40, dvfDiscountPct * 100)
: 0;
return Math.round(Math.min(100, marginScore + dvfBonus));
}
function computeBreakEvenResale(input: JugeInputs, vatRate: number): number {
const notaryFees = input.purchasePrice * input.notaryFeeRate;
const carrying = carryingCostEUR(input);
const fixedCosts =
input.purchasePrice +
notaryFees +
input.worksTotal +
input.miscAcquisitionCost +
carrying +
input.miscSaleCost;
const agencyRate = input.saleAgencyFeeRate;
const effectiveCoeff = 1 - agencyRate - vatRate * (1 - agencyRate);
if (effectiveCoeff <= 0) {
return Number.POSITIVE_INFINITY;
}
return fixedCosts / effectiveCoeff;
}
/**
* 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.
*/
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++) {
const mid = (low + high) / 2;
const { netMarginPct } = evaluateDeal({
...input,
purchasePrice: mid,
carryingPrincipal: input.carryingPrincipal ?? mid,
vatOnMarginRate: vatRate,
});
if (netMarginPct >= targetNetMarginPct) {
low = mid;
} else {
high = mid;
}
}
return Math.max(0, Math.round(low));
}

View File

@ -0,0 +1,8 @@
/** Marge nette cible minimum après TVA sur marge (Feu rouge en dessous). */
export const MIN_NET_MARGIN_PCT = 0.15;
/** Achat considéré "flash vert DVF" si prix / m² ≤ référence × (1 - seuil). */
export const DVF_DISCOUNT_FLASH_PCT = 0.2;
/** TVA sur marge (taux légal standard, ajustable par dossier). */
export const DEFAULT_VAT_ON_MARGIN_RATE = 0.2;

View File

@ -0,0 +1,2 @@
/** Synthèse visuelle Dashboard (Feu vert / rouge + opportunité DVF). */
export type DealTrafficLight = 'red' | 'orange' | 'green' | 'green_flash_dvf';

40
src/domain/dossier.ts Normal file
View File

@ -0,0 +1,40 @@
import type { DealTrafficLight } from './dealSignals';
/** Statuts alignés sur `public.dossier_status` (Supabase). */
export type DossierStatus =
| 'draft'
| 'sourcing'
| 'analysis'
| 'visit'
| 'offer'
| 'under_promise'
| 'resale'
| 'closed_won'
| 'closed_lost';
/** Sous-ensemble des champs `dossiers` utiles au front et au Juge côté client. */
export interface DossierFinancialInputs {
purchasePriceTarget: number;
resalePriceEstimate: number;
surfaceM2: number;
dvfReferencePriceM2?: number | null;
worksEstimateTotal: number;
worksVisitAdjustment: number;
notaryFeeRate: number;
saleAgencyFeeRate: number;
miscAcquisitionCost: number;
miscSaleCost: number;
carryingMonths: number;
carryingAnnualRate: number;
carryingPrincipal?: number | null;
}
export interface DossierDealSnapshot {
trafficLight: DealTrafficLight;
netMarginPct: number;
purchasePricePerM2: number;
dvfDiscountPct: number | null;
scoreDeal: number;
}
export type { DealTrafficLight };

View File

@ -0,0 +1,224 @@
-- MDB-Turbo : cœur métier dossiers + checklist visite + base investisseurs
-- Exécuter après création du projet Supabase (auth.users existe déjà).
-- ---------------------------------------------------------------------------
-- Profil utilisateur (lien auth)
-- ---------------------------------------------------------------------------
create table if not exists public.profiles (
id uuid primary key references auth.users (id) on delete cascade,
full_name text,
company_name text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- ---------------------------------------------------------------------------
-- Dossier = opportunité / deal en cours
-- ---------------------------------------------------------------------------
create type public.dossier_status as enum (
'draft',
'sourcing',
'analysis',
'visit',
'offer',
'under_promise',
'resale',
'closed_won',
'closed_lost'
);
create table if not exists public.dossiers (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
title text not null default 'Nouveau dossier',
status public.dossier_status not null default 'draft',
-- Localisation
address_line text,
city text,
postal_code text,
insee_code text,
latitude double precision,
longitude double precision,
-- Physique
surface_m2 numeric(12, 2),
land_surface_m2 numeric(12, 2),
rooms_count smallint,
dpe_class text check (dpe_class is null or dpe_class ~ '^[ABCDEFG]$'),
-- Financier (saisie terrain / desk)
purchase_price_target numeric(14, 2),
resale_price_estimate numeric(14, 2),
dvf_reference_price_m2 numeric(14, 2),
works_estimate_total numeric(14, 2) not null default 0,
works_visit_adjustment numeric(14, 2) not null default 0,
notary_fee_rate numeric(6, 5) not null default 0.077,
sale_agency_fee_rate numeric(6, 5) not null default 0.05,
misc_acquisition_cost numeric(14, 2) not null default 0,
misc_sale_cost numeric(14, 2) not null default 0,
carrying_months smallint not null default 6,
carrying_annual_rate numeric(6, 5) not null default 0.05,
carrying_principal numeric(14, 2),
-- Urbanisme & stratégies "quick win"
plu_zone_code text,
plu_notes text,
parcel_subdivision_candidate boolean not null default false,
deficit_foncier_candidate boolean not null default false,
-- IA (agents) — JSON pour itérer vite sans migrations à chaque prompt
sourcing_agent_output jsonb,
tech_estimator_output jsonb,
financier_agent_output jsonb,
deal_maker_output jsonb,
under_promise_at timestamptz,
teaser_pdf_url text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists dossiers_user_id_idx on public.dossiers (user_id);
create index if not exists dossiers_status_idx on public.dossiers (status);
-- ---------------------------------------------------------------------------
-- Catalogue "points noirs" visite (anti-erreur)
-- ---------------------------------------------------------------------------
create table if not exists public.visit_finding_definitions (
code text primary key,
label text not null,
default_works_delta_eur numeric(14, 2) not null default 0,
severity smallint not null default 1,
sort_order int not null default 0
);
insert into public.visit_finding_definitions (code, label, default_works_delta_eur, severity, sort_order)
values
('structural_crack', 'Fissure structurelle / désordre porteur', 15000, 5, 10),
('roof_full_replace', 'Toiture à refaire (complète)', 35000, 5, 20),
('roof_partial', 'Toiture partielle / zinguerie lourde', 8000, 3, 30),
('humidity_basement', 'Infiltrations cave / vide sanitaire', 12000, 3, 40),
('electrical_rewire', 'Rénovation électrique complète', 15000, 4, 50),
('asbestos', 'Présence amiante / désamiantage à prévoir', 10000, 4, 60),
('septic_non_conform', 'Assainissement non conforme', 12000, 3, 70),
('facade_insulation', 'ITE / ravalement lourd', 25000, 3, 80),
('heat_pump_full', 'Chauffage à refaire (pompe à chaleur + réseau)', 18000, 2, 90)
on conflict (code) do nothing;
create table if not exists public.dossier_visit_findings (
id uuid primary key default gen_random_uuid(),
dossier_id uuid not null references public.dossiers (id) on delete cascade,
finding_code text not null references public.visit_finding_definitions (code),
checked boolean not null default false,
works_delta_override_eur numeric(14, 2),
checked_at timestamptz,
unique (dossier_id, finding_code)
);
create index if not exists dossier_visit_findings_dossier_idx
on public.dossier_visit_findings (dossier_id);
-- ---------------------------------------------------------------------------
-- Investisseurs (module "Flash" — critères en JSON)
-- ---------------------------------------------------------------------------
create table if not exists public.investisseurs (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
display_name text not null,
email text,
phone text,
min_margin_pct numeric(5, 2) not null default 12,
max_ticket_eur numeric(14, 2),
zones jsonb,
strategies jsonb,
notes text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists investisseurs_user_id_idx on public.investisseurs (user_id);
-- ---------------------------------------------------------------------------
-- updated_at trigger
-- ---------------------------------------------------------------------------
create or replace function public.set_updated_at()
returns trigger language plpgsql as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists set_profiles_updated_at on public.profiles;
create trigger set_profiles_updated_at
before update on public.profiles
for each row execute function public.set_updated_at();
drop trigger if exists set_dossiers_updated_at on public.dossiers;
create trigger set_dossiers_updated_at
before update on public.dossiers
for each row execute function public.set_updated_at();
drop trigger if exists set_investisseurs_updated_at on public.investisseurs;
create trigger set_investisseurs_updated_at
before update on public.investisseurs
for each row execute function public.set_updated_at();
-- ---------------------------------------------------------------------------
-- RLS
-- ---------------------------------------------------------------------------
alter table public.profiles enable row level security;
alter table public.dossiers enable row level security;
alter table public.dossier_visit_findings enable row level security;
alter table public.investisseurs enable row level security;
-- lecture / écriture : uniquement son user_id
create policy "profiles_self" on public.profiles
for all using (auth.uid() = id) with check (auth.uid() = id);
create policy "dossiers_self" on public.dossiers
for all using (auth.uid() = user_id) with check (auth.uid() = user_id);
create policy "visit_findings_via_dossier" on public.dossier_visit_findings
for all using (
exists (
select 1 from public.dossiers d
where d.id = dossier_id and d.user_id = auth.uid()
)
)
with check (
exists (
select 1 from public.dossiers d
where d.id = dossier_id and d.user_id = auth.uid()
)
);
create policy "investisseurs_self" on public.investisseurs
for all using (auth.uid() = user_id) with check (auth.uid() = user_id);
-- Catalogue visites : lecture pour tous utilisateurs authentifiés
alter table public.visit_finding_definitions enable row level security;
create policy "visit_finding_definitions_read" on public.visit_finding_definitions
for select to authenticated using (true);
-- ---------------------------------------------------------------------------
-- Auto-création profil à l'inscription (optionnel)
-- ---------------------------------------------------------------------------
create or replace function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = public as $$
begin
insert into public.profiles (id, full_name)
values (new.id, coalesce(new.raw_user_meta_data->>'full_name', ''))
on conflict (id) do nothing;
return new;
end;
$$;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();

6
tsconfig.json Normal file
View File

@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}