Initial commit
Generated by create-expo-app 3.5.3.
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal 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
20
App.tsx
Normal 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
30
app.json
Normal 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
BIN
assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/favicon.png
Normal file
BIN
assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/splash-icon.png
Normal file
BIN
assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
8
index.ts
Normal file
8
index.ts
Normal 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
8807
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal 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
|
||||||
|
}
|
||||||
259
src/core/juge/marginCalculator.ts
Normal file
259
src/core/juge/marginCalculator.ts
Normal 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;
|
||||||
|
/** 0–100 : 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));
|
||||||
|
}
|
||||||
8
src/core/juge/thresholds.ts
Normal file
8
src/core/juge/thresholds.ts
Normal 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;
|
||||||
2
src/domain/dealSignals.ts
Normal file
2
src/domain/dealSignals.ts
Normal 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
40
src/domain/dossier.ts
Normal 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 };
|
||||||
224
supabase/migrations/20260429180000_mdb_turbo_dossiers.sql
Normal file
224
supabase/migrations/20260429180000_mdb_turbo_dossiers.sql
Normal 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
6
tsconfig.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user