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

97
.cursorrules Normal file
View File

@ -0,0 +1,97 @@
# Contexte projet — Application Marchand de Biens
## Qui utilise cette app
Marchand de biens professionnel en France. L'app est utilisée au quotidien sur mobile (iOS/Android) et navigateur web. Elle remplace un ensemble d'outils épars (notes, tableurs, contacts téléphoniques, etc.).
## Stack technique
- **Frontend** : React Native avec Expo (SDK 51+)
- **Navigation** : Expo Router (file-based routing)
- **Base de données** : Supabase (PostgreSQL)
- **Auth** : Supabase Auth (email/password)
- **Stockage fichiers** : Supabase Storage (photos, PDFs)
- **IA** : API Anthropic Claude (claude-sonnet-4-20250514)
- **UI** : NativeWind (Tailwind pour React Native) + React Native Paper pour les composants complexes
- **State** : Zustand pour le state global, React Query (TanStack) pour le cache serveur
- **Déploiement mobile** : Expo EAS
- **Déploiement web** : Vercel
## Conventions de code
- TypeScript strict partout, jamais de `any`
- Noms de fichiers : kebab-case pour les fichiers, PascalCase pour les composants
- Toujours utiliser des hooks personnalisés pour la logique métier (ex: `useBiens`, `useContacts`)
- Les appels Supabase se font UNIQUEMENT dans les hooks, jamais dans les composants
- Les types TypeScript sont définis dans `/types/database.ts` (généré depuis Supabase)
- Les constantes métier sont dans `/constants/metier.ts`
- Toujours gérer les états de chargement et d'erreur
- Commentaires en français pour la logique métier, anglais pour le code technique
## Vocabulaire métier (utiliser ces termes précis)
- **Bien** : propriété immobilière prospectée ou acquise
- **Piste** : bien en cours d'analyse, pas encore d'offre
- **Dossier** : bien avec offre en cours ou acte signé
- **Fiche bien** : écran de détail d'un bien
- **Compromis** : avant-contrat de vente (SPC)
- **Acte** : acte authentique de vente chez notaire
- **Portage** : période entre achat et revente (coût = intérêts + taxes)
- **Marge brute** : prix revente - prix achat - travaux - frais notaire achat
- **Marge nette** : marge brute - frais de portage - frais d'agence vente - impôts
- **DPE** : Diagnostic de Performance Énergétique
- **Surface habitable** : surface loi Carrez pour appartements
- **Marchand de biens** = le user, l'utilisateur de cette app
## Modules de l'application
1. **Prospection** : pipeline Kanban des biens (piste → analyse → offre → compromis → acte → revente)
2. **Annuaire** : contacts métier (notaires, artisans, banquiers, agents immo)
3. **Fiches biens** : dossier complet par bien (photos, docs, historique)
4. **Calculateur** : analyse de rentabilité financière
5. **Visites** : compte-rendus de visites avec check-list
6. **Travaux** : suivi de chantier et devis
7. **Administratif** : documents, délais légaux, alertes
8. **Agenda** : tâches et rappels liés aux biens
9. **Dashboard** : vue globale et KPIs
## Structure des dossiers
```
/app → écrans (Expo Router)
/(tabs) → navigation principale
/prospection
/annuaire
/agenda
/dashboard
/bien/[id] → fiche bien
/visite/[id] → rapport de visite
/contact/[id] → fiche contact
/components → composants réutilisables
/ui → composants génériques (Button, Card, Input...)
/biens → composants spécifiques aux biens
/visites → composants spécifiques aux visites
/hooks → hooks personnalisés
/services → appels API (Supabase, Anthropic)
/supabase.ts → client Supabase
/ai.ts → appels Claude API
/types → types TypeScript
/constants → constantes et configuration
/utils → fonctions utilitaires
```
## Variables d'environnement nécessaires
```
EXPO_PUBLIC_SUPABASE_URL=
EXPO_PUBLIC_SUPABASE_ANON_KEY=
ANTHROPIC_API_KEY= ← côté serveur uniquement, jamais exposé côté client
```
## Règles de sécurité importantes
- La clé API Anthropic ne doit JAMAIS être dans le code client
- Créer une Supabase Edge Function pour les appels IA
- Row Level Security (RLS) activé sur toutes les tables Supabase
- Les photos et docs sont dans des buckets Supabase privés
## Calculs financiers — formules exactes
```
frais_notaire_achat = prix_achat * 0.075 (ancien) ou * 0.02 (neuf)
prix_revient = prix_achat + frais_notaire_achat + travaux + frais_portage
marge_brute = prix_revente_cible - prix_revient
frais_portage_mensuel = (prix_achat * taux_credit / 12) + taxe_fonciere_mensuelle
taux_marge_brute = marge_brute / prix_revente_cible * 100
```

1
.npmrc Normal file
View File

@ -0,0 +1 @@
legacy-peer-deps=true

127
AGENTS.md Normal file
View File

@ -0,0 +1,127 @@
# AGENTS — Suivi des sessions Cursor
> Ce fichier est lu au début de chaque session Cursor pour reprendre le contexte.
> Mets à jour la section "État" après chaque session.
---
## État global du projet
- [ ] Agent 0 — Setup initial (Expo + Supabase)
- [ ] Agent 1 — Schéma base de données + types TypeScript
- [ ] Agent 2 — Navigation + écrans vides
- [ ] Agent 3 — Module Prospection (pipeline Kanban)
- [ ] Agent 4 — Module Fiche Bien
- [ ] Agent 5 — Calculateur financier
- [ ] Agent 6 — Module Annuaire contacts
- [ ] Agent 7 — Module Visites (avec IA)
- [ ] Agent 8 — Module Agenda & tâches
- [ ] Agent 9 — Dashboard & KPIs
- [ ] Agent 10 — Module Travaux
- [ ] Agent 11 — Module Administratif (alertes, docs)
- [ ] Agent 12 — Polish mobile (offline, notifications push)
---
## Agent 0 — Setup initial
**Statut** : ⬜ Non démarré
**Objectif** : Projet Expo fonctionnel connecté à Supabase avec auth
**Livrable** : App qui se lance, login qui fonctionne, navigation de base
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Agent 1 — Base de données
**Statut** : ⬜ Non démarré
**Objectif** : Toutes les tables SQL créées dans Supabase avec RLS
**Livrable** : schema.sql exécuté, types TypeScript générés
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Agent 2 — Navigation
**Statut** : ⬜ Non démarré
**Objectif** : Structure de navigation complète avec tous les onglets
**Livrable** : Tous les écrans existent (même vides), navigation fonctionnelle
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Agent 3 — Prospection
**Statut** : ⬜ Non démarré
**Objectif** : Pipeline Kanban des biens
**Livrable** : Vue Kanban, création d'un bien, déplacement entre étapes
**Tables utilisées** : `biens`, `etapes_pipeline`
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Agent 4 — Fiche bien
**Statut** : ⬜ Non démarré
**Objectif** : Écran détail complet d'un bien
**Livrable** : Fiche avec infos, photos, documents, historique
**Tables utilisées** : `biens`, `photos_biens`, `documents_biens`, `notes_biens`
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Agent 5 — Calculateur
**Statut** : ⬜ Non démarré
**Objectif** : Calculateur de rentabilité intégré dans la fiche bien
**Livrable** : Formulaire avec calculs en temps réel, sauvegarde dans Supabase
**Tables utilisées** : `analyses_financieres`
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Agent 6 — Annuaire
**Statut** : ⬜ Non démarré
**Objectif** : Annuaire de contacts professionnels
**Livrable** : Liste, recherche, fiche contact, appel natif
**Tables utilisées** : `contacts`, `categories_contacts`
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Agent 7 — Visites
**Statut** : ⬜ Non démarré
**Objectif** : Module de visite avec CR généré par IA
**Livrable** : Check-list de visite, notes, génération CR via Claude API
**Tables utilisées** : `visites`, `items_checklist`, `rapports_visite`
**Edge Functions** : `generate-rapport-visite`
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Agent 8 — Agenda
**Statut** : ⬜ Non démarré
**Objectif** : Tâches et rappels liés aux biens
**Livrable** : Vue agenda, création tâches, notifications
**Tables utilisées** : `taches`, `rappels`
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Agent 9 — Dashboard
**Statut** : ⬜ Non démarré
**Objectif** : Vue d'ensemble et KPIs
**Livrable** : Chiffres clés, biens en cours, alertes urgentes
**Tables utilisées** : Toutes (requêtes agrégées)
**Dernière session** : —
**Problèmes rencontrés** : —
---
## Notes techniques globales
_(Ajouter ici les décisions d'architecture prises en cours de projet)_
-

20
App.tsx
View File

@ -1,20 +0,0 @@
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',
},
});

690
PROMPTS_CURSOR.md Normal file
View File

@ -0,0 +1,690 @@
# PROMPTS CURSOR — Application Marchand de Biens
# Copier-coller chaque prompt dans Cursor Chat (Cmd+L ou Ctrl+L)
# Lire le fichier .cursorrules avant chaque session
---
## ═══════════════════════════════════════════
## AGENT 0 — Setup initial du projet
## ═══════════════════════════════════════════
## Durée estimée : 1-2h
```
Lis attentivement le fichier .cursorrules pour comprendre le contexte du projet.
Je veux créer une application React Native avec Expo pour un marchand de biens immobiliers.
Crée le projet de zéro avec cette configuration précise :
1. INITIALISATION DU PROJET :
- Initialise un nouveau projet Expo avec Expo Router : `npx create-expo-app@latest mb-app --template tabs`
- TypeScript strict
- NativeWind pour le styling (Tailwind CSS pour React Native)
2. DÉPENDANCES À INSTALLER :
- @supabase/supabase-js
- @tanstack/react-query
- zustand
- react-native-paper
- expo-image-picker
- expo-document-picker
- expo-file-system
- expo-notifications
- react-native-maps
- @react-native-async-storage/async-storage
- react-native-safe-area-context (si pas déjà inclus)
3. CONFIGURATION SUPABASE :
Crée le fichier `/services/supabase.ts` avec :
- Initialisation du client Supabase avec les variables d'env
- Export du client
- Gestion de la session (AsyncStorage pour mobile)
4. CONFIGURATION AUTH :
Crée un contexte d'authentification `/context/AuthContext.tsx` avec :
- Connexion par email/password
- Déconnexion
- État de l'utilisateur connecté
- Redirection automatique si non connecté
5. ÉCRANS D'AUTH :
- `/app/auth/login.tsx` : formulaire email + password, bouton connexion
- `/app/auth/register.tsx` : formulaire inscription (email, password, nom, prénom)
- Style sobre et professionnel, couleur principale #1D4ED8 (bleu)
6. FICHIER .env :
Crée `.env.local` avec les placeholders :
```
EXPO_PUBLIC_SUPABASE_URL=https://VOTRE_PROJET.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=VOTRE_ANON_KEY
```
7. VALIDATION :
L'app doit se lancer avec `npx expo start`, afficher l'écran de login, et permettre la connexion.
Ne génère PAS encore les écrans de contenu, seulement l'authentification qui fonctionne.
Mets à jour AGENTS.md : Agent 0 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## AGENT 1 — Types TypeScript + connexion DB
## ═══════════════════════════════════════════
## Prérequis : Avoir exécuté schema.sql dans Supabase
```
Lis .cursorrules et AGENTS.md.
Le schéma SQL a été exécuté dans Supabase. Je dois maintenant créer les types TypeScript et les services de base.
1. TYPES TYPESCRIPT `/types/database.ts` :
Crée des interfaces TypeScript pour TOUTES les tables du schéma :
- Profile, Bien, EtapePipeline, AnalyseFinanciere
- Contact, BienContact
- Visite, ChecklistItem, ChecklistReponse
- PhotoBien, DocumentBien
- Tache, NoteBien, DevisTravaux
Pour chaque table, crée aussi un type "Insert" (sans id et dates auto) et "Update" (tout optionnel).
Exemple :
```typescript
export interface Bien { id: string; user_id: string; titre?: string; ... }
export type BienInsert = Omit<Bien, 'id' | 'created_at' | 'updated_at'>
export type BienUpdate = Partial<BienInsert>
```
2. CONSTANTES MÉTIER `/constants/metier.ts` :
- ETAPES_DEFAUT : noms des étapes du pipeline
- CATEGORIES_CONTACTS : liste avec labels français
- TYPES_BIENS : appartement, maison, immeuble, etc.
- CATEGORIES_CHECKLIST : avec emojis et labels
- TYPES_DOCUMENTS : compromis, acte, DPE, etc.
3. HOOK DE BASE `/hooks/useBiens.ts` :
- fetchBiens() : récupère tous les biens de l'utilisateur avec l'étape
- createBien(data) : crée un bien, retourne l'id
- updateBien(id, data) : met à jour
- deleteBien(id) : supprime
4. HOOK `/hooks/useContacts.ts` :
- fetchContacts() : avec filtre par catégorie optionnel
- createContact(data)
- updateContact(id, data)
5. REACT QUERY CONFIG `/services/queryClient.ts` :
- Initialisation de QueryClient
- Stale time : 5 minutes
- Retry : 1 fois
Mets à jour AGENTS.md : Agent 1 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## AGENT 2 — Navigation complète
## ═══════════════════════════════════════════
```
Lis .cursorrules et AGENTS.md.
Crée la structure de navigation complète de l'application avec Expo Router.
1. NAVIGATION PAR ONGLETS `/app/(tabs)/` :
5 onglets avec icônes Expo Vector Icons :
- `index.tsx` → Dashboard (icône: grid, label: "Vue d'ensemble")
- `prospection.tsx` → Prospection (icône: search, label: "Biens")
- `visites.tsx` → Visites (icône: clipboard, label: "Visites")
- `annuaire.tsx` → Annuaire (icône: people, label: "Contacts")
- `agenda.tsx` → Agenda (icône: calendar, label: "Agenda")
Couleur active : #1D4ED8, couleur inactive : #9CA3AF
2. ÉCRANS DE DÉTAIL (hors onglets) :
- `/app/bien/[id].tsx` → Fiche bien (en-tête avec titre du bien)
- `/app/bien/nouveau.tsx` → Création d'un bien
- `/app/contact/[id].tsx` → Fiche contact
- `/app/contact/nouveau.tsx` → Nouveau contact
- `/app/visite/[id].tsx` → Rapport de visite
- `/app/visite/nouvelle.tsx` → Nouvelle visite (prend un bien_id en param)
- `/app/calculateur/[bienId].tsx` → Calculateur financier
3. CHAQUE ÉCRAN doit avoir pour l'instant :
- Un titre visible
- Un texte "Module [Nom] — à venir"
- Le bouton de navigation retour (automatique avec Expo Router)
4. HEADER GLOBAL :
- Bouton profil en haut à droite sur les onglets
- Titre dynamique selon l'onglet actif
5. BOUTON FLOATING ACTION BUTTON (FAB) :
Sur l'onglet Prospection et Annuaire : bouton "+" en bas à droite pour ajouter un bien/contact.
Mets à jour AGENTS.md : Agent 2 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## AGENT 3 — Module Prospection (Kanban)
## ═══════════════════════════════════════════
```
Lis .cursorrules et AGENTS.md.
Crée le module Prospection complet : vue pipeline Kanban des biens.
1. ÉCRAN PROSPECTION `/app/(tabs)/prospection.tsx` :
Vue principale avec 2 modes switchables (boutons en haut) :
MODE KANBAN (défaut) :
- Colonnes horizontales scrollables (ScrollView horizontal)
- Chaque colonne = une étape du pipeline (depuis Supabase)
- Chaque carte de bien affiche : titre, ville, surface, prix achat, badge de priorité
- Nombre de biens par colonne dans le header de colonne
- Couleur de colonne selon la couleur définie dans etapes_pipeline
MODE LISTE :
- FlatList avec tri par date, ville, ou priorité
- Même info que les cartes Kanban + statut en badge
2. COMPOSANT CARTE BIEN `/components/biens/CarteBien.tsx` :
- Titre ou "Bien sans titre" si vide
- Ville + code postal
- Surface habitable (si renseignée)
- Prix d'achat formaté en €
- Indicateur priorité (rouge=haute, orange=normale, gris=basse)
- Badge source (off-market, agence, notaire...)
- onPress → navigation vers `/bien/[id]`
- onLongPress → modal rapide (changer étape, appeler contact, supprimer)
3. FORMULAIRE CRÉATION BIEN `/app/bien/nouveau.tsx` :
Formulaire en plusieurs étapes (step 1, step 2, step 3) :
Étape 1 — Localisation :
- Adresse (text input)
- Ville + code postal
- Type de bien (Select : appartement, maison, immeuble, terrain...)
Étape 2 — Caractéristiques :
- Surface habitable
- Nombre de pièces
- Prix d'achat estimé
- Source (Select)
Étape 3 — Résumé + validation
Bouton "Créer le bien" → POST Supabase → redirect vers la fiche bien
4. FILTRES ET RECHERCHE :
- Barre de recherche par ville ou titre
- Filtre rapide : Tous / Actifs / Abandonnés
5. HOOK `/hooks/usePipeline.ts` :
- fetchBiensParEtape() : retourne les biens groupés par étape
- moveToEtape(bienId, etapeId) : change l'étape d'un bien
- fetchEtapes() : récupère les étapes du pipeline
Mets à jour AGENTS.md : Agent 3 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## AGENT 4 — Fiche Bien (détail complet)
## ═══════════════════════════════════════════
```
Lis .cursorrules et AGENTS.md.
Crée l'écran de fiche bien complet : le "dossier" central de l'app.
ÉCRAN `/app/bien/[id].tsx` :
Structure avec ScrollView et sections collapsibles :
1. EN-TÊTE :
- Photo principale (ou placeholder avec icône maison)
- Adresse complète
- Badge étape actuelle (avec bouton pour changer)
- Score d'opportunité visuel (étoiles ou barre)
- Boutons rapides : Appeler vendeur | Calculateur | Nouvelle visite
2. SECTION "INFOS" :
- Type de bien, surface, pièces, étages, année construction
- DPE : badge coloré (A=vert foncé → G=rouge)
- Source de la piste
3. SECTION "FINANCES" (résumé) :
- Prix d'achat, budget travaux estimé
- Marge cible (si analyse créée)
- Bouton → ouvre le calculateur
4. SECTION "VISITES" :
- Liste des visites avec date et avis
- Bouton "Nouvelle visite"
5. SECTION "CONTACTS" :
- Contacts liés (notaire, agent, artisans...)
- Bouton pour lier un contact existant
6. SECTION "DOCUMENTS" :
- Liste des documents (DPE, compromis, etc.)
- Bouton upload (expo-document-picker)
7. SECTION "NOTES" :
- Notes libres avec date
- Champ pour ajouter une note rapide
8. SECTION "TIMELINE" :
- Historique chronologique des actions (création, visite, offre, compromis...)
ÉDITION :
- Bouton "Modifier" en haut à droite → mode édition inline
- Chaque section modifiable directement
- Auto-save avec debounce (500ms)
HOOK `/hooks/useBienDetail.ts` :
- fetchBienComplet(id) : avec toutes les relations (visites, contacts, docs, notes)
- addNote(bienId, contenu)
- linkContact(bienId, contactId, role)
- uploadDocument(bienId, file, type)
Mets à jour AGENTS.md : Agent 4 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## AGENT 5 — Calculateur financier
## ═══════════════════════════════════════════
```
Lis .cursorrules et AGENTS.md.
Crée le calculateur de rentabilité, le cœur financier de l'application.
ÉCRAN `/app/calculateur/[bienId].tsx` :
Ce calculateur utilise les formules exactes définies dans .cursorrules.
1. SECTION ACQUISITION :
- Prix d'achat (€) — input numérique
- Type de bien fiscal : Ancien (7,5%) / Neuf (2%) — toggle
- Frais de notaire : calculés automatiquement + possibilité de saisir manuellement
- Frais d'agence achat (€ ou %)
2. SECTION TRAVAUX :
- Budget travaux estimé (€)
- Réserve imprévus : slider 5-25% (défaut 10%)
- Budget total travaux = estimation + réserve
3. SECTION PORTAGE :
- Durée de portage prévue (slider 6-36 mois)
- Taux de crédit (%) — pré-rempli depuis le profil
- Taxe foncière annuelle (€)
- Charges mensuelles (€)
- Coût de portage total = calculé automatiquement
4. SECTION REVENTE :
- Prix de revente cible (€)
- Frais d'agence vente (% — défaut 5%)
- Taux d'imposition (% — défaut 25%)
5. RÉSULTATS EN TEMPS RÉEL (mis à jour à chaque keystroke) :
Affichés dans des cartes colorées :
- Prix de revient total (€) — gris
- Marge brute (€ et %) — vert si > 15%, orange si 8-15%, rouge si < 8%
- Marge nette après impôts (€ et %) — même code couleur
- ROI (%) — retour sur investissement
- Point mort : prix de revente minimum pour ne pas perdre
6. SCÉNARIOS :
Bouton "Voir les scénarios" → modal avec 3 colonnes :
- Pessimiste (-10% prix revente)
- Réaliste (prix saisi)
- Optimiste (+10% prix revente)
Chaque colonne affiche la marge nette correspondante.
7. SAUVEGARDE :
Bouton "Enregistrer l'analyse" → sauvegarde dans analyses_financieres
8. EXPORT :
Bouton "Partager" → génère un résumé texte formaté à partager par SMS/email
COMPOSANT RÉUTILISABLE `/components/biens/ResultatFinancier.tsx` :
Affiche les 4 KPIs financiers (utilisé aussi dans la fiche bien)
Mets à jour AGENTS.md : Agent 5 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## AGENT 6 — Annuaire contacts
## ═══════════════════════════════════════════
```
Lis .cursorrules et AGENTS.md.
Crée le module Annuaire : carnet d'adresses professionnel du marchand de biens.
1. ÉCRAN ANNUAIRE `/app/(tabs)/annuaire.tsx` :
EN-TÊTE :
- Barre de recherche (prénom, nom, société, ville)
- Filtres par catégorie : tous | notaires | agents | artisans | banques | autres
- Sous-filtre artisans : par corps de métier (plomberie, élec, maçonnerie...)
LISTE :
- FlatList avec sections par catégorie (SectionList si filtre = "tous")
- Chaque item : avatar initiales coloré, nom, société, ville, note étoiles, bouton appel direct
- Swipe gauche pour supprimer, swipe droit pour "Favori"
- Section "Favoris" en haut de liste
2. FICHE CONTACT `/app/contact/[id].tsx` :
- Avatar grande taille avec initiales
- Badges : catégorie + spécialité
- Note (étoiles, modifiable par tap)
- Boutons actions : Appeler | SMS | Email | Copier
- Section "Biens associés" : liste des biens où ce contact intervient
- Section "Historique" : notes d'échanges avec date
- Champ "Notes" libre
- Infos artisan : taux horaire, remise habituelle
3. FORMULAIRE NOUVEAU CONTACT `/app/contact/nouveau.tsx` :
- Catégorie (obligatoire) — change les champs affichés
- Nom, prénom, société
- Téléphone (avec format FR automatique)
- Email
- Ville + zone d'intervention
- Spécialité (si artisan)
- Note initiale
4. FONCTIONNALITÉ APPEL :
Import `{ Linking } from 'react-native'`
`Linking.openURL('tel:' + telephone)` — fonctionne sur iOS et Android
5. HOOK `/hooks/useContacts.ts` (compléter celui de l'Agent 1) :
- searchContacts(query, categorie?)
- getFavoris()
- getBiensByContact(contactId)
- toggleFavori(contactId)
Mets à jour AGENTS.md : Agent 6 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## AGENT 7 — Module Visites (avec IA Claude)
## ═══════════════════════════════════════════
```
Lis .cursorrules et AGENTS.md.
Crée le module Visites avec génération de compte-rendu par IA Claude.
⚠️ IMPORTANT SÉCURITÉ : L'appel à l'API Anthropic doit se faire via une Supabase Edge Function,
jamais directement depuis le client mobile (la clé API ne doit pas être dans le code React Native).
1. EDGE FUNCTION SUPABASE `/supabase/functions/generate-rapport-visite/index.ts` :
```typescript
// Reçoit : { notes_brutes, checklist_reponses, bien_info }
// Appelle l'API Anthropic Claude
// Retourne : { rapport_structure }
const prompt = `Tu es assistant d'un marchand de biens immobiliers français.
À partir des notes de visite et de la check-list ci-dessous, génère un compte-rendu
de visite professionnel et structuré en français.
Informations du bien : ${JSON.stringify(bien_info)}
Notes prises pendant la visite : ${notes_brutes}
Résultats de la check-list : ${JSON.stringify(checklist_reponses)}
Génère un compte-rendu avec ces sections :
1. Résumé exécutif (3-4 phrases)
2. Points positifs (liste)
3. Points négatifs / travaux nécessaires (liste)
4. Estimation des travaux (si des éléments permettent de l'estimer)
5. Recommandation (Coup de cœur / Intéressant / À éviter + justification)
6. Prochaines étapes suggérées
Sois concis, professionnel, et orienté investissement.`;
```
2. ÉCRAN LISTE VISITES `/app/(tabs)/visites.tsx` :
- Visites à venir (avec date et bien concerné)
- Visites passées avec leur rapport
- Bouton "Planifier une visite"
3. ÉCRAN NOUVELLE VISITE `/app/visite/nouvelle.tsx` :
- Sélecteur de bien (liste ou scan QR code si bien a un QR)
- Date et heure de la visite
- Type : première visite / seconde visite / expert
- Redirect vers l'écran de visite en cours
4. ÉCRAN VISITE EN COURS `/app/visite/[id].tsx` :
TAB 1 — CHECK-LIST :
- Sections par catégorie (Structure, Toiture, Électricité, etc.)
- Chaque item avec 4 états : ✅ OK | ⚠️ Attention | ❌ Problème | — Non vérifié
- Champ note par item
- Progression visuelle (X/Y items vérifiés)
TAB 2 — NOTES LIBRES :
- Grande zone de texte pour notes rapides
- Bouton microphone : transcription vocale (expo-av)
- Bouton photo : ajouter une photo directement liée à la visite
TAB 3 — ESTIMATION :
- Budget travaux min/max estimé sur place
- Durée estimée
- Avis global (Coup de cœur / Intéressant / Neutre / À éviter)
- Score d'opportunité (slider 1-10)
BOUTON "GÉNÉRER LE RAPPORT IA" :
- Loading spinner avec message "Analyse en cours..."
- Appel à la Edge Function Supabase
- Affiche le rapport généré
- Bouton "Copier" et "Modifier" le rapport
- Sauvegarde automatique dans visites.rapport_genere
5. HOOK `/hooks/useVisite.ts` :
- startVisite(bienId, dateVisite)
- saveChecklist(visiteId, reponses)
- saveNotes(visiteId, notes)
- generateRapport(visiteId) → appel Edge Function
- fetchVisitesByBien(bienId)
Mets à jour AGENTS.md : Agent 7 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## AGENT 8 — Agenda & tâches
## ═══════════════════════════════════════════
```
Lis .cursorrules et AGENTS.md.
Crée le module Agenda : gestion des tâches quotidiennes du marchand de biens.
1. ÉCRAN AGENDA `/app/(tabs)/agenda.tsx` :
VUE "AUJOURD'HUI" (défaut) :
- Tâches du jour groupées par bien
- Section "En retard" en rouge en haut
- Section "Aujourd'hui"
- Section "Cette semaine"
VUE CALENDRIER :
- react-native-calendars
- Points colorés sur les jours avec tâches
- Tap sur un jour → liste des tâches du jour
2. COMPOSANT TÂCHE `/components/agenda/CarteTache.tsx` :
- Checkbox (tap pour cocher = fait)
- Titre + description courte
- Badge bien associé (avec couleur de l'étape)
- Date + heure
- Badge priorité
- Icône type (téléphone, email, rendez-vous, admin...)
- Swipe droit : Snooze +1 jour
- Swipe gauche : Supprimer
3. CRÉATION TÂCHE :
Modal bottom sheet avec :
- Titre (obligatoire)
- Type de tâche (Select avec icônes)
- Bien associé (optionnel — searchable Select)
- Contact associé (optionnel)
- Date et heure d'échéance (DateTimePicker)
- Priorité (toggle 3 niveaux)
- Rappel (30min avant / 1h / 1 jour / pas de rappel)
4. TÂCHES AUTO-GÉNÉRÉES :
Créer une fonction `genererTachesDelaisLegaux(bienId)` qui génère automatiquement
les tâches importantes lors des changements d'étape :
- Compromis signé → "SRU 10 jours : fin le [date]"
- Compromis signé → "Déblocage financement : échéance [date+45j]"
- Acte signé → "Déclaration IS : rappel [date+3mois]"
5. NOTIFICATIONS :
Configurer expo-notifications pour :
- Rappel de tâche (heure configurée)
- Alerte délai légal (veille)
6. HOOK `/hooks/useTaches.ts` :
- fetchTachesJour(date)
- fetchTachesEnRetard()
- createTache(data)
- toggleTacheStatus(id)
- snooze(id, jours)
- genererTachesDelaisLegaux(bienId, etapeName)
Mets à jour AGENTS.md : Agent 8 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## AGENT 9 — Dashboard
## ═══════════════════════════════════════════
```
Lis .cursorrules et AGENTS.md.
Crée le tableau de bord : vue d'ensemble de l'activité du marchand de biens.
ÉCRAN `/app/(tabs)/index.tsx` :
1. EN-TÊTE :
- "Bonjour [Prénom] 👋"
- Date du jour
- Météo rapide (optionnel, via Open-Meteo API gratuite)
2. ALERTES URGENTES (si existantes) :
Carte rouge en haut : tâches en retard + délais légaux proches (< 3 jours)
3. BARRE KPIs (scroll horizontal, 4 métriques) :
- Biens actifs : nombre total
- En compromis : nombre
- Marge prévue : somme des marges nettes des analyses
- Tâches du jour : nombre (avec badge rouge si en retard)
4. PIPELINE RÉSUMÉ :
Barre horizontale proportionnelle avec chaque étape et son nombre de biens
Tap sur une étape → filtre la liste en dessous
5. BIENS EN COURS :
FlatList horizontale des 5 derniers biens actifs
Chaque carte : titre, étape, date de la dernière action
Tap → fiche bien
6. TÂCHES DU JOUR :
3-5 tâches prioritaires
Bouton "Voir tout" → onglet Agenda
7. DERNIÈRES VISITES :
2-3 dernières visites avec score d'opportunité
Tap → rapport de visite
8. STATISTIQUES MENSUELLES (bottom) :
- Biens analysés ce mois
- Offres faites
- Taux de conversion piste → offre
- Biens vendus (si applicable)
HOOK `/hooks/useDashboard.ts` :
Utilise React Query pour fetcher en parallèle :
- getNombreBiensParEtape()
- getKPIsFinanciers()
- getTachesUrgentes()
- getDernieresVisites()
Toutes les données mises en cache 5 minutes, refresh au focus de l'écran.
Mets à jour AGENTS.md : Agent 9 = ✅ Terminé.
```
---
## ═══════════════════════════════════════════
## PROMPT DE DEBUG UNIVERSEL
## (À utiliser quand quelque chose ne marche pas)
## ═══════════════════════════════════════════
```
Lis .cursorrules et AGENTS.md pour le contexte.
J'ai une erreur dans le module [NOM DU MODULE] :
ERREUR : [coller le message d'erreur exact]
FICHIER CONCERNÉ : [nom du fichier]
CE QUI DEVRAIT SE PASSER : [description du comportement attendu]
CE QUI SE PASSE : [description du bug]
Diagnostique le problème et propose un fix. Tiens compte de la stack technique
définie dans .cursorrules (Expo + Supabase + TypeScript strict).
```
---
## ═══════════════════════════════════════════
## PROMPT D'AMÉLIORATION UI
## (Quand un écran fonctionne mais est laid)
## ═══════════════════════════════════════════
```
Lis .cursorrules.
L'écran [NOM] fonctionne mais l'interface n'est pas assez soignée pour une utilisation
professionnelle quotidienne sur mobile.
Améliore le design avec ces critères :
- Style sobre et professionnel (pas de couleurs vives inutiles)
- Lisible en extérieur (fort contraste)
- Actions principales facilement accessibles en une main (zone du pouce)
- Chargement : skeletons pendant le fetch (pas de spinner seul)
- États vides : message explicatif + bouton d'action (ex: "Aucune visite — Planifier une visite")
- Feedback tactile sur les boutons (Haptics.impactAsync)
- Gestion d'erreur visible (toast ou banner rouge en haut)
Couleurs de l'app :
- Primaire : #1D4ED8 (bleu professionnel)
- Succès : #16A34A
- Attention : #D97706
- Danger : #DC2626
- Fond : #F9FAFB
- Texte : #111827
```

View File

@ -1,7 +1,8 @@
{
"expo": {
"name": "mdb",
"slug": "mdb",
"name": "MDB-Turbo",
"slug": "mdb-turbo",
"scheme": "mdb-turbo",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
@ -25,6 +26,17 @@
},
"web": {
"favicon": "./assets/favicon.png"
}
},
"plugins": [
"expo-router",
"expo-font",
[
"expo-notifications",
{
"icon": "./assets/icon.png",
"color": "#3d8bfd"
}
]
]
}
}

48
app/(tabs)/_layout.tsx Normal file
View File

@ -0,0 +1,48 @@
import { Ionicons } from '@expo/vector-icons';
import { Tabs } from 'expo-router';
import { colors } from '../../src/theme/colors';
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
headerStyle: { backgroundColor: colors.bgCard },
headerTintColor: colors.text,
tabBarStyle: {
backgroundColor: colors.bgCard,
borderTopColor: colors.border,
},
tabBarActiveTintColor: colors.accent,
tabBarInactiveTintColor: colors.textMuted,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Dossiers',
tabBarIcon: ({ color, size }) => (
<Ionicons name="folder-open-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="investisseurs"
options={{
title: 'Investisseurs',
tabBarIcon: ({ color, size }) => (
<Ionicons name="people-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="reglages"
options={{
title: 'Réglages',
tabBarIcon: ({ color, size }) => (
<Ionicons name="settings-outline" size={size} color={color} />
),
}}
/>
</Tabs>
);
}

297
app/(tabs)/index.tsx Normal file
View File

@ -0,0 +1,297 @@
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { useEffect, useMemo, useState } from 'react';
import {
Alert,
Pressable,
SectionList,
StyleSheet,
Text,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { PrimaryButton } from '../../src/components/PrimaryButton';
import { useApp } from '../../src/context/AppContext';
import type { DealSourceRow, DossierRow } from '../../src/data/types';
import {
ensureNotificationPermission,
notifyGradeADealLocal,
useDealsSourcesGradeAAlerts,
} from '../../src/hooks/useDealsSourcesGradeAAlerts';
import { colors } from '../../src/theme/colors';
type SectionRow =
| { kind: 'deal'; deal: DealSourceRow }
| { kind: 'dossier'; dossier: DossierRow };
export default function DossiersListScreen() {
const insets = useSafeAreaInsets();
const app = useApp();
const [scoutBusy, setScoutBusy] = useState(false);
const cloudNeedsAuth =
app.runtimeMode === 'cloud' && !app.user && app.supabase;
const needsSetup = app.runtimeMode === 'none';
const sortedDeals = useMemo(
() =>
[...app.dealSources].sort(
(a, b) => b.opportunity_score - a.opportunity_score,
),
[app.dealSources],
);
const sections = useMemo(() => {
const dealRows: SectionRow[] = sortedDeals.map((deal) => ({
kind: 'deal' as const,
deal,
}));
const dossierRows: SectionRow[] = app.dossiers.map((dossier) => ({
kind: 'dossier' as const,
dossier,
}));
return [
{ title: 'Flux opportunités (Scout)', data: dealRows },
{ title: 'Mes dossiers', data: dossierRows },
];
}, [sortedDeals, app.dossiers]);
useDealsSourcesGradeAAlerts(
app.supabase,
app.user?.id,
app.runtimeMode === 'cloud' && !!app.user && !!app.supabase,
);
useEffect(() => {
if (app.runtimeMode === 'cloud' && app.user) {
void ensureNotificationPermission();
}
}, [app.runtimeMode, app.user]);
return (
<View style={[styles.root, { paddingTop: 8 }]}>
{needsSetup ? (
<View style={styles.banner}>
<Text style={styles.bannerText}>
Choisissez le mode hors-ligne sur laccueil, ou configurez Supabase
dans Réglages.
</Text>
<PrimaryButton
title="Retour accueil"
onPress={() => router.replace('/')}
/>
</View>
) : null}
{cloudNeedsAuth ? (
<View style={styles.banner}>
<Text style={styles.bannerText}>
Connectez-vous pour charger vos dossiers Supabase.
</Text>
<PrimaryButton title="Connexion" onPress={() => router.push('/auth/login')} />
</View>
) : null}
<SectionList
sections={sections}
keyExtractor={(item, index) =>
item.kind === 'deal' ? `deal-${item.deal.id}` : `dossier-${item.dossier.id}-${index}`
}
stickySectionHeadersEnabled={false}
contentContainerStyle={{
paddingHorizontal: 16,
paddingBottom: insets.bottom + 100,
}}
ListHeaderComponent={
<View style={{ marginBottom: 12 }}>
<PrimaryButton
title={scoutBusy ? 'Scout…' : 'Simuler ingest Scout (JSON)'}
loading={scoutBusy}
onPress={async () => {
if (!app.user) {
router.push('/auth/login');
return;
}
setScoutBusy(true);
const r = await app.runScoutSampleBatch();
setScoutBusy(false);
if ('error' in r) {
Alert.alert('Scout', r.error);
return;
}
if (app.runtimeMode === 'local' && r.gradeA > 0) {
notifyGradeADealLocal(
`${r.gradeA} opportunité(s) Grade A (Scout simulé)`,
);
}
Alert.alert(
'Scout',
`Insérés : ${r.inserted} — Grade A : ${r.gradeA}.`,
);
}}
/>
<Text style={styles.hint}>
Filtre : mots-clés succession / urgent / travaux important + prix/m²
sous moyenne simulée (3500 /m²). Cloud : RPC `scout_process_batch`
après migration SQL.
</Text>
</View>
}
renderSectionHeader={({ section: { title, data } }) => (
<Text style={styles.sectionTitle}>
{title}
{title.startsWith('Flux') ? ` (${data.length})` : ` (${data.length})`}
</Text>
)}
renderItem={({ item }) =>
item.kind === 'deal' ? (
<DealSourceCard row={item.deal} />
) : (
<DossierRowCard row={item.dossier} />
)
}
ListEmptyComponent={null}
/>
<View style={[styles.fabWrap, { bottom: insets.bottom + 20 }]}>
<Pressable
accessibilityRole="button"
style={styles.fab}
onPress={async () => {
if (app.runtimeMode === 'none') {
router.replace('/');
return;
}
if (!app.user && app.runtimeMode === 'cloud') {
router.push('/auth/login');
return;
}
const id = await app.createDossier();
if (id) router.push(`/dossier/${id}`);
}}
>
<Ionicons name="add" size={32} color="#fff" />
</Pressable>
</View>
</View>
);
}
function DealSourceCard({ row }: { row: DealSourceRow }) {
const pm = Math.round(row.price_per_m2_eur);
const dotStyle =
row.grade === 'A' ? styles.badgeA : row.grade === 'B' ? styles.badgeB : styles.badgeC;
return (
<View style={styles.dealCard}>
<View style={styles.dealHead}>
<Text style={styles.badgeText}>Grade {row.grade}</Text>
<View style={[styles.badgeDot, dotStyle]} />
<Text style={styles.score}>{row.opportunity_score.toFixed(0)} pts</Text>
</View>
<Text style={styles.cardTitle}>{row.title}</Text>
<Text style={styles.cardSub}>
{row.price_eur != null
? `${row.price_eur.toLocaleString('fr-FR')} € · ${row.surface_m2} m² · ${pm} €/m²`
: `${row.surface_m2}`}
</Text>
{row.distress_keywords?.length ? (
<Text style={[styles.kw, { color: colors.flash }]}>
Mots-clés : {row.distress_keywords.join(', ')}
</Text>
) : null}
{row.source_name ? (
<Text style={styles.cardMeta}>Source : {row.source_name}</Text>
) : null}
</View>
);
}
function DossierRowCard({ row }: { row: DossierRow }) {
const city = [row.postal_code, row.city].filter(Boolean).join(' ');
return (
<Pressable
style={styles.card}
onPress={() => router.push(`/dossier/${row.id}`)}
>
<Text style={styles.cardTitle}>{row.title}</Text>
{city ? <Text style={styles.cardSub}>{city}</Text> : null}
<Text style={styles.cardMeta}>Statut : {row.status}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
banner: {
marginHorizontal: 16,
marginBottom: 12,
padding: 14,
borderRadius: 12,
backgroundColor: colors.bgCard,
borderWidth: 1,
borderColor: colors.border,
gap: 10,
},
bannerText: { color: colors.text, lineHeight: 20 },
hint: {
color: colors.textMuted,
fontSize: 12,
lineHeight: 17,
marginTop: 10,
},
sectionTitle: {
color: colors.text,
fontSize: 15,
fontWeight: '800',
marginTop: 8,
marginBottom: 8,
},
dealCard: {
backgroundColor: '#121a24',
borderRadius: 14,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: colors.border,
},
dealHead: { flexDirection: 'row', alignItems: 'center', marginBottom: 8, gap: 8 },
badgeDot: { width: 8, height: 8, borderRadius: 4 },
badgeA: { backgroundColor: '#3fb950' },
badgeB: { backgroundColor: '#d29922' },
badgeC: { backgroundColor: colors.textMuted },
badgeText: {
color: colors.text,
fontWeight: '900',
fontSize: 13,
marginRight: 4,
},
score: { color: colors.textMuted, fontSize: 12, marginLeft: 'auto' },
kw: { color: colors.flash ?? '#7ee787', marginTop: 6, fontSize: 12 },
card: {
backgroundColor: colors.bgCard,
borderRadius: 14,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: colors.border,
},
cardTitle: { color: colors.text, fontSize: 17, fontWeight: '700' },
cardSub: { color: colors.textMuted, marginTop: 4 },
cardMeta: { color: colors.textMuted, marginTop: 8, fontSize: 12 },
fabWrap: {
position: 'absolute',
right: 20,
},
fab: {
width: 58,
height: 58,
borderRadius: 29,
backgroundColor: colors.accent,
alignItems: 'center',
justifyContent: 'center',
elevation: 4,
shadowColor: '#000',
shadowOpacity: 0.25,
shadowRadius: 6,
shadowOffset: { width: 0, height: 2 },
},
});

View File

@ -0,0 +1,234 @@
import { useState } from 'react';
import {
Alert,
FlatList,
Modal,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LabeledField } from '../../src/components/LabeledField';
import { PrimaryButton } from '../../src/components/PrimaryButton';
import { useApp } from '../../src/context/AppContext';
import { colors } from '../../src/theme/colors';
import type { InvestisseurRow } from '../../src/data/types';
import { router } from 'expo-router';
function parseNum(s: string): number | null {
const v = Number(s.replace(',', '.').replace(/\s/g, ''));
return Number.isFinite(v) ? v : null;
}
export default function InvestisseursScreen() {
const insets = useSafeAreaInsets();
const app = useApp();
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<InvestisseurRow | null>(null);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [minMargin, setMinMargin] = useState('12');
const [maxTicket, setMaxTicket] = useState('');
const [zones, setZones] = useState('');
const cloudNeedsAuth = app.runtimeMode === 'cloud' && !app.user;
const openNew = () => {
setEditing(null);
setName('');
setEmail('');
setPhone('');
setMinMargin('12');
setMaxTicket('');
setZones('');
setOpen(true);
};
const openEdit = (row: InvestisseurRow) => {
setEditing(row);
setName(row.display_name);
setEmail(row.email ?? '');
setPhone(row.phone ?? '');
setMinMargin(String(row.min_margin_pct));
setMaxTicket(row.max_ticket_eur != null ? String(row.max_ticket_eur) : '');
setZones((row.zones ?? []).join(', '));
setOpen(true);
};
const save = async () => {
if (!app.user) {
router.push('/auth/login');
return;
}
const uid = app.user.id;
const mm = parseNum(minMargin) ?? 12;
const mt = maxTicket.trim() ? parseNum(maxTicket) : null;
const z = zones
.split(',')
.map((s) => s.trim())
.filter(Boolean);
await app.upsertInvestisseur({
id: editing?.id,
user_id: uid,
display_name: name.trim() || 'Investisseur',
email: email.trim() || null,
phone: phone.trim() || null,
min_margin_pct: mm,
max_ticket_eur: mt,
zones: z.length ? z : null,
strategies: null,
notes: null,
});
setOpen(false);
};
if (cloudNeedsAuth) {
return (
<View style={[styles.center, { paddingTop: insets.top }]}>
<Text style={styles.muted}>Connectez-vous pour gérer vos investisseurs.</Text>
<PrimaryButton title="Connexion" onPress={() => router.push('/auth/login')} />
</View>
);
}
return (
<View style={styles.root}>
<FlatList
data={app.investisseurs}
keyExtractor={(i) => i.id}
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 80,
}}
ListEmptyComponent={
<Text style={styles.muted}>
Ajoutez des profils pour le module « Investisseur flash » (matching
marge / ticket / zones).
</Text>
}
renderItem={({ item }) => (
<Pressable style={styles.card} onPress={() => openEdit(item)}>
<Text style={styles.name}>{item.display_name}</Text>
<Text style={styles.meta}>
Marge mini {item.min_margin_pct}% ticket max{' '}
{item.max_ticket_eur != null
? `${item.max_ticket_eur.toLocaleString('fr-FR')}`
: '—'}
</Text>
{item.zones?.length ? (
<Text style={styles.meta}>Zones : {item.zones.join(', ')}</Text>
) : null}
</Pressable>
)}
/>
<View style={[styles.fabRow, { bottom: insets.bottom + 16 }]}>
<PrimaryButton title="Nouvel investisseur" onPress={openNew} />
</View>
<Modal visible={open} animationType="slide" transparent>
<View style={styles.modalBackdrop}>
<View style={[styles.modalCard, { paddingBottom: insets.bottom + 16 }]}>
<Text style={styles.modalTitle}>
{editing ? 'Modifier investisseur' : 'Nouvel investisseur'}
</Text>
<LabeledField label="Nom" value={name} onChangeText={setName} />
<LabeledField
label="E-mail"
autoCapitalize="none"
value={email}
onChangeText={setEmail}
/>
<LabeledField label="Téléphone" value={phone} onChangeText={setPhone} />
<LabeledField
label="Marge nette minimum (%)"
keyboardType="decimal-pad"
value={minMargin}
onChangeText={setMinMargin}
/>
<LabeledField
label="Ticket max (€) — optionnel"
keyboardType="number-pad"
value={maxTicket}
onChangeText={setMaxTicket}
/>
<LabeledField
label="Zones (ville ou CP, séparés par des virgules)"
value={zones}
onChangeText={setZones}
/>
<PrimaryButton title="Enregistrer" onPress={() => void save()} />
{editing ? (
<PrimaryButton
title="Supprimer"
variant="danger"
containerStyle={{ marginTop: 10 }}
onPress={() => {
Alert.alert(
'Supprimer',
'Confirmer la suppression ?',
[
{ text: 'Annuler', style: 'cancel' },
{
text: 'Supprimer',
style: 'destructive',
onPress: () => {
void app.deleteInvestisseur(editing.id).then(() =>
setOpen(false),
);
},
},
],
);
}}
/>
) : null}
<PrimaryButton
title="Fermer"
variant="ghost"
containerStyle={{ marginTop: 12 }}
onPress={() => setOpen(false)}
/>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
center: { flex: 1, padding: 20, backgroundColor: colors.bg, gap: 12 },
muted: { color: colors.textMuted, textAlign: 'center', lineHeight: 20 },
card: {
backgroundColor: colors.bgCard,
borderRadius: 14,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: colors.border,
},
name: { color: colors.text, fontSize: 17, fontWeight: '700' },
meta: { color: colors.textMuted, marginTop: 6, fontSize: 13 },
fabRow: { position: 'absolute', left: 16, right: 16 },
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.55)',
justifyContent: 'flex-end',
},
modalCard: {
backgroundColor: colors.bgCard,
borderTopLeftRadius: 18,
borderTopRightRadius: 18,
padding: 20,
borderWidth: 1,
borderColor: colors.border,
},
modalTitle: {
color: colors.text,
fontSize: 20,
fontWeight: '700',
marginBottom: 16,
},
});

112
app/(tabs)/reglages.tsx Normal file
View File

@ -0,0 +1,112 @@
import { router } from 'expo-router';
import { useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LabeledField } from '../../src/components/LabeledField';
import { PrimaryButton } from '../../src/components/PrimaryButton';
import { useApp } from '../../src/context/AppContext';
import { colors } from '../../src/theme/colors';
export default function ReglagesScreen() {
const insets = useSafeAreaInsets();
const app = useApp();
const [url, setUrl] = useState('');
const [key, setKey] = useState('');
const [msg, setMsg] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
return (
<ScrollView
contentContainerStyle={{
padding: 20,
paddingTop: 12,
paddingBottom: insets.bottom + 32,
backgroundColor: colors.bg,
}}
>
<Text style={styles.h2}>Mode actuel</Text>
<Text style={styles.p}>
{app.runtimeMode === 'local' && 'Hors-ligne — données stockées sur lappareil.'}
{app.runtimeMode === 'cloud' && 'Supabase — synchronisation cloud.'}
{app.runtimeMode === 'none' && 'Non initialisé.'}
</Text>
{app.user ? (
<Text style={styles.p}>
Compte : {app.user.email ?? app.user.id}
</Text>
) : null}
<Text style={[styles.h2, { marginTop: 24 }]}>Projet Supabase</Text>
<Text style={styles.p}>
URL et clé « anon » (Settings API). Exécutez aussi la migration SQL du
dépôt sur votre projet.
</Text>
<LabeledField
label="URL du projet"
autoCapitalize="none"
value={url}
onChangeText={setUrl}
placeholder="https://xxxx.supabase.co"
/>
<LabeledField
label="Clé anon (public)"
autoCapitalize="none"
value={key}
onChangeText={setKey}
placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
/>
{msg ? <Text style={styles.msg}>{msg}</Text> : null}
<PrimaryButton
title="Enregistrer et passer en mode cloud"
loading={loading}
onPress={async () => {
setMsg(null);
if (!url.trim() || !key.trim()) {
setMsg('Renseignez URL et clé.');
return;
}
setLoading(true);
try {
await app.saveCloudConfig({
supabaseUrl: url.trim(),
supabaseAnonKey: key.trim(),
});
setMsg('Configuration enregistrée. Connectez-vous ou créez un compte.');
router.push('/auth/login');
} catch {
setMsg('Erreur lors de lenregistrement.');
} finally {
setLoading(false);
}
}}
/>
<PrimaryButton
title="Activer le mode hors-ligne"
variant="ghost"
containerStyle={{ marginTop: 12 }}
onPress={async () => {
await app.enterLocalMode();
setMsg('Mode hors-ligne activé.');
router.replace('/(tabs)');
}}
/>
<PrimaryButton
title="Déconnexion / quitter la session"
variant="ghost"
containerStyle={{ marginTop: 24 }}
onPress={async () => {
await app.signOut();
router.replace('/');
}}
/>
</ScrollView>
);
}
const styles = StyleSheet.create({
h2: { color: colors.text, fontSize: 18, fontWeight: '700', marginBottom: 8 },
p: { color: colors.textMuted, lineHeight: 20, marginBottom: 8 },
msg: { color: colors.flash, marginBottom: 12, lineHeight: 20 },
});

29
app/_layout.tsx Normal file
View File

@ -0,0 +1,29 @@
import 'react-native-gesture-handler';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AppProvider } from '../src/context/AppContext';
import { colors } from '../src/theme/colors';
export default function RootLayout() {
return (
<SafeAreaProvider>
<AppProvider>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bgCard },
headerTintColor: colors.text,
contentStyle: { backgroundColor: colors.bg },
}}
>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="auth/login" options={{ title: 'Connexion' }} />
<Stack.Screen name="auth/register" options={{ title: 'Inscription' }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="dossier/[id]" options={{ title: 'Dossier' }} />
</Stack>
</AppProvider>
</SafeAreaProvider>
);
}

95
app/auth/login.tsx Normal file
View File

@ -0,0 +1,95 @@
import { router } from 'expo-router';
import { useState } from 'react';
import {
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LabeledField } from '../../src/components/LabeledField';
import { PrimaryButton } from '../../src/components/PrimaryButton';
import { useApp } from '../../src/context/AppContext';
import { colors } from '../../src/theme/colors';
export default function LoginScreen() {
const insets = useSafeAreaInsets();
const app = useApp();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
if (app.runtimeMode !== 'cloud' || !app.supabase) {
return (
<View style={[styles.box, { paddingTop: insets.top }]}>
<Text style={styles.err}>
Configurez dabord Supabase dans Réglages, puis revenez ici.
</Text>
<PrimaryButton
title="Ouvrir Réglages"
onPress={() => router.replace('/(tabs)/reglages')}
/>
</View>
);
}
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: colors.bg }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
padding: 20,
paddingTop: insets.top + 12,
paddingBottom: insets.bottom + 24,
}}
>
<LabeledField
label="E-mail"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<LabeledField
label="Mot de passe"
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{err ? <Text style={styles.err}>{err}</Text> : null}
<PrimaryButton
title="Connexion"
loading={loading}
onPress={async () => {
setErr(null);
setLoading(true);
const r = await app.signIn(email.trim(), password);
setLoading(false);
if (r.error) {
setErr(r.error);
return;
}
router.replace('/(tabs)');
}}
/>
<PrimaryButton
title="Créer un compte"
variant="ghost"
onPress={() => router.push('/auth/register')}
containerStyle={{ marginTop: 12 }}
/>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
box: { flex: 1, padding: 20, backgroundColor: colors.bg, gap: 12 },
err: { color: colors.danger, marginBottom: 12 },
});

103
app/auth/register.tsx Normal file
View File

@ -0,0 +1,103 @@
import { router } from 'expo-router';
import { useState } from 'react';
import {
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LabeledField } from '../../src/components/LabeledField';
import { PrimaryButton } from '../../src/components/PrimaryButton';
import { useApp } from '../../src/context/AppContext';
import { colors } from '../../src/theme/colors';
export default function RegisterScreen() {
const insets = useSafeAreaInsets();
const app = useApp();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [info, setInfo] = useState<string | null>(null);
if (app.runtimeMode !== 'cloud' || !app.supabase) {
return (
<View style={[styles.box, { paddingTop: insets.top }]}>
<Text style={styles.err}>
Configurez dabord Supabase dans Réglages.
</Text>
<PrimaryButton
title="Ouvrir Réglages"
onPress={() => router.replace('/(tabs)/reglages')}
/>
</View>
);
}
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: colors.bg }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
padding: 20,
paddingTop: insets.top + 12,
paddingBottom: insets.bottom + 24,
}}
>
<LabeledField label="Nom affiché" value={name} onChangeText={setName} />
<LabeledField
label="E-mail"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<LabeledField
label="Mot de passe"
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{err ? <Text style={styles.err}>{err}</Text> : null}
{info ? <Text style={styles.info}>{info}</Text> : null}
<PrimaryButton
title="Sinscrire"
loading={loading}
onPress={async () => {
setErr(null);
setInfo(null);
setLoading(true);
const r = await app.signUp(email.trim(), password, name.trim());
setLoading(false);
if (r.error) {
setErr(r.error);
return;
}
setInfo(
'Si la confirmation e-mail est activée sur votre projet, vérifiez votre boîte avant de vous connecter.',
);
}}
/>
<PrimaryButton
title="Jai déjà un compte"
variant="ghost"
onPress={() => router.back()}
containerStyle={{ marginTop: 12 }}
/>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
box: { flex: 1, padding: 20, backgroundColor: colors.bg, gap: 12 },
err: { color: colors.danger, marginBottom: 12 },
info: { color: colors.flash, marginBottom: 12, lineHeight: 20 },
});

567
app/dossier/[id].tsx Normal file
View File

@ -0,0 +1,567 @@
import { Ionicons } from '@expo/vector-icons';
import { router, useLocalSearchParams } from 'expo-router';
import { useLayoutEffect, useMemo, useState, useEffect } from 'react';
import {
Alert,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
View,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LabeledField } from '../../src/components/LabeledField';
import { PrimaryButton } from '../../src/components/PrimaryButton';
import { useApp, useVisitFindings } from '../../src/context/AppContext';
import { colors } from '../../src/theme/colors';
import type {
DossierRow,
DossierVisitFindingRow,
VisitFindingDefinitionRow,
} from '../../src/data/types';
import { useDossierJuge } from '../../src/hooks/useDossierJuge';
import { matchInvestisseurs } from '../../src/services/matchInvestors';
import { shareTeaserPdf } from '../../src/services/teaserPdf';
import { MIN_NET_MARGIN_PCT } from '../../src/core/juge';
type TabKey = 'dash' | 'money' | 'visit' | 'flash';
const TABS: { key: TabKey; label: string }[] = [
{ key: 'dash', label: 'Feu' },
{ key: 'money', label: 'Finances' },
{ key: 'visit', label: 'Visite' },
{ key: 'flash', label: 'Flash' },
];
export default function DossierDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const dossierId = typeof id === 'string' ? id : id?.[0];
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const app = useApp();
const [tab, setTab] = useState<TabKey>('dash');
const dossier = useMemo(
() => app.dossiers.find((d) => d.id === dossierId),
[app.dossiers, dossierId],
);
const findings = useVisitFindings(dossierId);
const juge = useDossierJuge(dossier, findings, app.definitions);
useLayoutEffect(() => {
navigation.setOptions({
title: dossier?.title ?? 'Dossier',
headerRight: () => (
<Pressable
accessibilityRole="button"
hitSlop={12}
onPress={() => {
if (!dossierId) return;
Alert.alert(
'Supprimer le dossier',
'Cette action est irréversible.',
[
{ text: 'Annuler', style: 'cancel' },
{
text: 'Supprimer',
style: 'destructive',
onPress: () => {
void app.deleteDossier(dossierId).then(() => router.back());
},
},
],
);
}}
>
<Ionicons name="trash-outline" size={22} color={colors.danger} />
</Pressable>
),
});
}, [navigation, dossier?.title, dossierId, app.deleteDossier]);
if (!dossierId || !dossier || !juge) {
return (
<View style={[styles.center, { paddingTop: insets.top }]}>
<Text style={styles.muted}>Dossier introuvable.</Text>
<PrimaryButton title="Retour" onPress={() => router.back()} />
</View>
);
}
const matches = matchInvestisseurs(dossier, juge.result, app.investisseurs);
const dashBg =
juge.result.trafficLight === 'red'
? '#2d1418'
: juge.result.trafficLight === 'orange'
? '#2a2310'
: juge.result.trafficLight === 'green_flash_dvf'
? '#102a18'
: '#10221c';
return (
<View style={styles.root}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tabBar}
contentContainerStyle={styles.tabBarInner}
>
{TABS.map((t) => (
<Pressable
key={t.key}
onPress={() => setTab(t.key)}
style={[styles.tabChip, tab === t.key && styles.tabChipOn]}
>
<Text style={[styles.tabText, tab === t.key && styles.tabTextOn]}>
{t.label}
</Text>
</Pressable>
))}
</ScrollView>
{tab === 'dash' ? (
<ScrollView
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 24,
}}
>
<View style={[styles.hero, { backgroundColor: dashBg }]}>
<Text style={styles.heroLabel}>Score deal</Text>
<Text style={styles.heroScore}>{juge.result.scoreDeal}</Text>
<Text style={styles.heroSub}>
Marge nette : {(juge.result.netMarginPct * 100).toFixed(1)} % (seuil
achat : {(MIN_NET_MARGIN_PCT * 100).toFixed(0)} %)
</Text>
<Text style={styles.heroSub}>
Feu : {juge.result.trafficLight} DVF flash :{' '}
{juge.result.dvfUnderMarketFlash ? 'oui' : 'non'}
</Text>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>Synthèse</Text>
<Row label="Investi (est.)" value={`${Math.round(juge.result.totalInvested).toLocaleString('fr-FR')}`} />
<Row label="Produit net revente" value={`${Math.round(juge.result.netResaleProceeds).toLocaleString('fr-FR')}`} />
<Row label="TVA sur marge (est.)" value={`${Math.round(juge.result.vatOnMargin).toLocaleString('fr-FR')}`} />
<Row label="Marge nette" value={`${Math.round(juge.result.netMarginAfterVat).toLocaleString('fr-FR')}`} />
<Row
label="Break-even revente"
value={
Number.isFinite(juge.result.breakEvenResalePrice)
? `${Math.round(juge.result.breakEvenResalePrice).toLocaleString('fr-FR')}`
: '—'
}
/>
</View>
<PrimaryButton
title="Marquer « sous promesse »"
variant="ghost"
onPress={() => void app.setDossierStatus(dossier.id, 'under_promise')}
containerStyle={{ marginTop: 8 }}
/>
</ScrollView>
) : null}
{tab === 'money' ? (
<FinancesEditor dossier={dossier} onSave={(patch) => void app.updateDossier(dossier.id, patch)} />
) : null}
{tab === 'visit' ? (
<VisiteTab
definitions={app.definitions}
findings={findings}
onToggle={(code, checked) =>
void app.toggleFinding(dossier.id, code, checked)
}
checklistEUR={juge.checklistWorks}
maxPurchase={juge.maxPurchase}
/>
) : null}
{tab === 'flash' ? (
<ScrollView
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 24,
}}
>
{dossier.status !== 'under_promise' ? (
<Text style={styles.muted}>
Verrouillez le dossier (« sous promesse ») depuis longlet Feu pour
activer le teaser investisseur.
</Text>
) : (
<>
<Text style={styles.cardTitle}>Investisseurs (top 5 match)</Text>
{matches.length === 0 ? (
<Text style={styles.muted}>Aucun match ajustez critères ou dossier.</Text>
) : (
matches.map((m) => (
<View key={m.id} style={styles.matchRow}>
<Text style={styles.matchName}>{m.display_name}</Text>
{m.email ? (
<Text style={styles.muted}>{m.email}</Text>
) : null}
</View>
))
)}
<PrimaryButton
title="Générer & partager le teaser PDF"
containerStyle={{ marginTop: 16 }}
onPress={() =>
void shareTeaserPdf(
dossier,
juge.result,
matches.map((m) => m.display_name),
)
}
/>
</>
)}
</ScrollView>
) : null}
</View>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<View style={styles.row}>
<Text style={styles.rowLabel}>{label}</Text>
<Text style={styles.rowValue}>{value}</Text>
</View>
);
}
function FinancesEditor({
dossier,
onSave,
}: {
dossier: DossierRow;
onSave: (patch: Partial<DossierRow>) => void;
}) {
const insets = useSafeAreaInsets();
const [title, setTitle] = useState(dossier.title);
const [address, setAddress] = useState(dossier.address_line ?? '');
const [city, setCity] = useState(dossier.city ?? '');
const [postal, setPostal] = useState(dossier.postal_code ?? '');
const [surface, setSurface] = useState(String(dossier.surface_m2 ?? ''));
const [purchase, setPurchase] = useState(String(dossier.purchase_price_target ?? ''));
const [resale, setResale] = useState(String(dossier.resale_price_estimate ?? ''));
const [dvf, setDvf] = useState(String(dossier.dvf_reference_price_m2 ?? ''));
const [works, setWorks] = useState(String(dossier.works_estimate_total ?? ''));
const [miscA, setMiscA] = useState(String(dossier.misc_acquisition_cost ?? ''));
const [miscS, setMiscS] = useState(String(dossier.misc_sale_cost ?? ''));
const [carryM, setCarryM] = useState(String(dossier.carrying_months ?? 6));
const [carryR, setCarryR] = useState(String(dossier.carrying_annual_rate ?? 0.05));
const [dpe, setDpe] = useState(dossier.dpe_class ?? '');
const [pluZone, setPluZone] = useState(dossier.plu_zone_code ?? '');
const [pluNotes, setPluNotes] = useState(dossier.plu_notes ?? '');
const [parcelDiv, setParcelDiv] = useState(dossier.parcel_subdivision_candidate);
const [deficitFoncier, setDeficitFoncier] = useState(
dossier.deficit_foncier_candidate,
);
useEffect(() => {
setTitle(dossier.title);
setAddress(dossier.address_line ?? '');
setCity(dossier.city ?? '');
setPostal(dossier.postal_code ?? '');
setSurface(String(dossier.surface_m2 ?? ''));
setPurchase(String(dossier.purchase_price_target ?? ''));
setResale(String(dossier.resale_price_estimate ?? ''));
setDvf(String(dossier.dvf_reference_price_m2 ?? ''));
setWorks(String(dossier.works_estimate_total ?? ''));
setMiscA(String(dossier.misc_acquisition_cost ?? ''));
setMiscS(String(dossier.misc_sale_cost ?? ''));
setCarryM(String(dossier.carrying_months ?? 6));
setCarryR(String(dossier.carrying_annual_rate ?? 0.05));
setDpe(dossier.dpe_class ?? '');
setPluZone(dossier.plu_zone_code ?? '');
setPluNotes(dossier.plu_notes ?? '');
setParcelDiv(dossier.parcel_subdivision_candidate);
setDeficitFoncier(dossier.deficit_foncier_candidate);
}, [
dossier.id,
dossier.updated_at,
dossier.title,
dossier.address_line,
dossier.city,
dossier.postal_code,
dossier.surface_m2,
dossier.purchase_price_target,
dossier.resale_price_estimate,
dossier.dvf_reference_price_m2,
dossier.works_estimate_total,
dossier.misc_acquisition_cost,
dossier.misc_sale_cost,
dossier.carrying_months,
dossier.carrying_annual_rate,
dossier.dpe_class,
dossier.plu_zone_code,
dossier.plu_notes,
dossier.parcel_subdivision_candidate,
dossier.deficit_foncier_candidate,
]);
const parseNum = (s: string) => Number(s.replace(',', '.').replace(/\s/g, ''));
return (
<ScrollView
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 24,
}}
>
<LabeledField label="Titre du dossier" value={title} onChangeText={setTitle} />
<LabeledField label="Adresse" value={address} onChangeText={setAddress} />
<LabeledField label="Ville" value={city} onChangeText={setCity} />
<LabeledField label="Code postal" value={postal} onChangeText={setPostal} />
<LabeledField
label="Surface (m²)"
keyboardType="decimal-pad"
value={surface}
onChangeText={setSurface}
/>
<LabeledField
label="Prix d'achat cible (€)"
keyboardType="number-pad"
value={purchase}
onChangeText={setPurchase}
/>
<LabeledField
label="Prix de revente estimé (€)"
keyboardType="number-pad"
value={resale}
onChangeText={setResale}
/>
<LabeledField
label="DVF réf. (€/m²)"
keyboardType="decimal-pad"
value={dvf}
onChangeText={setDvf}
/>
<LabeledField
label="Travaux estimés hors checklist (€)"
keyboardType="number-pad"
value={works}
onChangeText={setWorks}
/>
<LabeledField
label="Frais d'achat divers (€)"
keyboardType="number-pad"
value={miscA}
onChangeText={setMiscA}
/>
<LabeledField
label="Frais de vente divers (€)"
keyboardType="number-pad"
value={miscS}
onChangeText={setMiscS}
/>
<LabeledField
label="Portage (mois)"
keyboardType="number-pad"
value={carryM}
onChangeText={setCarryM}
/>
<LabeledField
label="Taux portage annuel (ex: 0.055)"
keyboardType="decimal-pad"
value={carryR}
onChangeText={setCarryR}
/>
<LabeledField
label="DPE (AG)"
autoCapitalize="characters"
maxLength={1}
value={dpe}
onChangeText={(t) => setDpe(t.toUpperCase())}
/>
<Text style={styles.sectionLabel}>Urbanisme & stratégie</Text>
<LabeledField
label="Zone PLU (libellé ou code)"
value={pluZone}
onChangeText={setPluZone}
/>
<LabeledField
label="Notes urbanisme (servitude, COS…)"
value={pluNotes}
onChangeText={setPluNotes}
multiline
/>
<View style={styles.switchRow}>
<Text style={styles.switchLabel}>Piste division parcellaire</Text>
<Switch value={parcelDiv} onValueChange={setParcelDiv} />
</View>
<View style={styles.switchRow}>
<Text style={styles.switchLabel}>Piste déficit foncier (passoire)</Text>
<Switch value={deficitFoncier} onValueChange={setDeficitFoncier} />
</View>
<PrimaryButton
title="Enregistrer les finances"
onPress={() => {
onSave({
title: title.trim(),
address_line: address.trim() || null,
city: city.trim() || null,
postal_code: postal.trim() || null,
surface_m2: parseNum(surface) || null,
purchase_price_target: parseNum(purchase) || null,
resale_price_estimate: parseNum(resale) || null,
dvf_reference_price_m2: parseNum(dvf) || null,
works_estimate_total: parseNum(works) || null,
misc_acquisition_cost: parseNum(miscA) || null,
misc_sale_cost: parseNum(miscS) || null,
carrying_months: Math.round(parseNum(carryM) || 6),
carrying_annual_rate: parseNum(carryR) || 0.05,
dpe_class: dpe || null,
plu_zone_code: pluZone.trim() || null,
plu_notes: pluNotes.trim() || null,
parcel_subdivision_candidate: parcelDiv,
deficit_foncier_candidate: deficitFoncier,
});
}}
/>
</ScrollView>
);
}
function VisiteTab({
definitions,
findings,
onToggle,
checklistEUR,
maxPurchase,
}: {
definitions: VisitFindingDefinitionRow[];
findings: DossierVisitFindingRow[];
onToggle: (code: string, checked: boolean) => void;
checklistEUR: number;
maxPurchase: number;
}) {
const insets = useSafeAreaInsets();
const rows = definitions.map((def) => {
const f = findings.find((x) => x.finding_code === def.code);
return { def, f };
});
return (
<ScrollView
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 24,
}}
>
<View style={styles.card}>
<Text style={styles.cardTitle}>Anti-erreur visite</Text>
<Text style={styles.muted}>
Cochez les points noirs : lapp ajoute les travaux associés et recalcule
le prix dachat max pour rester à {(MIN_NET_MARGIN_PCT * 100).toFixed(0)}{' '}
% de marge nette.
</Text>
<Text style={styles.highlight}>
Travaux checklist : {checklistEUR.toLocaleString('fr-FR')}
</Text>
<Text style={styles.highlight}>
Prix dachat max (cible marge) :{' '}
{maxPurchase.toLocaleString('fr-FR')}
</Text>
</View>
{rows.map(({ def, f }) => {
const checked = f?.checked ?? false;
return (
<View key={def.code} style={styles.visitRow}>
<View style={{ flex: 1, paddingRight: 12 }}>
<Text style={styles.visitLabel}>{def.label}</Text>
<Text style={styles.muted}>
+{(f?.works_delta_override_eur ?? def.default_works_delta_eur).toLocaleString('fr-FR')}{' '}
si coché
</Text>
</View>
<Switch
value={checked}
onValueChange={(v) => onToggle(def.code, v)}
trackColor={{ true: colors.accent, false: colors.border }}
/>
</View>
);
})}
</ScrollView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
muted: { color: colors.textMuted, lineHeight: 20 },
tabBar: { maxHeight: 52, borderBottomWidth: 1, borderBottomColor: colors.border },
tabBarInner: { paddingHorizontal: 12, paddingVertical: 10, gap: 8, alignItems: 'center' },
tabChip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 999,
backgroundColor: colors.bgCard,
marginRight: 8,
borderWidth: 1,
borderColor: colors.border,
},
tabChipOn: { borderColor: colors.accent, backgroundColor: '#15233d' },
tabText: { color: colors.textMuted, fontWeight: '600' },
tabTextOn: { color: colors.text },
hero: { borderRadius: 16, padding: 20, marginBottom: 16 },
heroLabel: { color: colors.textMuted, fontSize: 12, textTransform: 'uppercase' },
heroScore: { fontSize: 44, fontWeight: '800', color: colors.text, marginVertical: 8 },
heroSub: { color: colors.textMuted, marginTop: 4 },
card: {
backgroundColor: colors.bgCard,
borderRadius: 14,
padding: 16,
borderWidth: 1,
borderColor: colors.border,
},
cardTitle: { color: colors.text, fontSize: 18, fontWeight: '700', marginBottom: 12 },
row: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 10,
gap: 12,
},
rowLabel: { color: colors.textMuted, flex: 1 },
rowValue: { color: colors.text, fontWeight: '600' },
highlight: { color: colors.flash, marginTop: 8, fontWeight: '600' },
visitRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
visitLabel: { color: colors.text, fontWeight: '600' },
matchRow: {
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
matchName: { color: colors.text, fontWeight: '700', fontSize: 16 },
sectionLabel: {
color: colors.textMuted,
fontSize: 12,
textTransform: 'uppercase',
letterSpacing: 0.06,
marginTop: 8,
marginBottom: 6,
},
switchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 14,
paddingVertical: 4,
},
switchLabel: { color: colors.text, flex: 1, paddingRight: 12 },
});

112
app/index.tsx Normal file
View File

@ -0,0 +1,112 @@
import { router } from 'expo-router';
import { useEffect } from 'react';
import {
ActivityIndicator,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { PrimaryButton } from '../src/components/PrimaryButton';
import { useApp } from '../src/context/AppContext';
import { colors } from '../src/theme/colors';
export default function WelcomeScreen() {
const insets = useSafeAreaInsets();
const app = useApp();
useEffect(() => {
if (!app.ready) return;
if (app.user) {
router.replace('/(tabs)');
}
}, [app.ready, app.user]);
if (!app.ready) {
return (
<View style={[styles.center, { paddingTop: insets.top }]}>
<ActivityIndicator size="large" color={colors.accent} />
<Text style={styles.muted}>Chargement</Text>
</View>
);
}
if (app.user) {
return (
<View style={[styles.center, { paddingTop: insets.top }]}>
<ActivityIndicator size="large" color={colors.accent} />
</View>
);
}
return (
<ScrollView
contentContainerStyle={[
styles.scroll,
{ paddingTop: insets.top + 24, paddingBottom: insets.bottom + 24 },
]}
>
<Text style={styles.brand}>MDB-Turbo</Text>
<Text style={styles.tagline}>
Prospection marchand de biens : marge, visite, investisseurs sur le
terrain.
</Text>
<PrimaryButton
title="Continuer hors-ligne (données sur lappareil)"
onPress={() => {
void app.enterLocalMode().then(() => router.replace('/(tabs)'));
}}
containerStyle={styles.btn}
/>
<PrimaryButton
title="Se connecter (Supabase)"
variant="ghost"
onPress={() => router.push('/auth/login')}
containerStyle={styles.btn}
/>
<PrimaryButton
title="Configurer Supabase"
variant="ghost"
onPress={() => router.push('/(tabs)/reglages')}
containerStyle={styles.btn}
/>
<Text style={styles.hint}>
Le mode hors-ligne fonctionne sans compte. Pour synchroniser plusieurs
appareils, renseignez votre projet Supabase dans Réglages puis
connectez-vous.
</Text>
</ScrollView>
);
}
const styles = StyleSheet.create({
center: {
flex: 1,
backgroundColor: colors.bg,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
muted: { color: colors.textMuted },
scroll: { paddingHorizontal: 22, backgroundColor: colors.bg },
brand: {
fontSize: 34,
fontWeight: '800',
color: colors.text,
marginBottom: 8,
},
tagline: {
fontSize: 16,
color: colors.textMuted,
lineHeight: 24,
marginBottom: 28,
},
btn: { marginBottom: 12, width: '100%' },
hint: {
marginTop: 20,
fontSize: 13,
color: colors.textMuted,
lineHeight: 20,
},
});

7
babel.config.js Normal file
View File

@ -0,0 +1,7 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['expo-router/babel'],
};
};

View File

@ -1,8 +0,0 @@
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);

41
mb-app/.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

1
mb-app/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

7
mb-app/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

45
mb-app/app.json Normal file
View File

@ -0,0 +1,45 @@
{
"expo": {
"name": "mb-app",
"slug": "mb-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "mbapp",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"image": "./assets/images/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-notifications",
{
"color": "#1D4ED8"
}
]
],
"experiments": {
"typedRoutes": true
}
}
}

View File

@ -0,0 +1,59 @@
import React from 'react';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { Link, Tabs } from 'expo-router';
import { Pressable } from 'react-native';
import Colors from '@/constants/Colors';
import { useColorScheme } from '@/components/useColorScheme';
import { useClientOnlyValue } from '@/components/useClientOnlyValue';
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
function TabBarIcon(props: {
name: React.ComponentProps<typeof FontAwesome>['name'];
color: string;
}) {
return <FontAwesome size={28} style={{ marginBottom: -3 }} {...props} />;
}
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
// Disable the static render of the header on web
// to prevent a hydration error in React Navigation v6.
headerShown: useClientOnlyValue(false, true),
}}>
<Tabs.Screen
name="index"
options={{
title: 'Tab One',
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
headerRight: () => (
<Link href="/modal" asChild>
<Pressable>
{({ pressed }) => (
<FontAwesome
name="info-circle"
size={25}
color={Colors[colorScheme ?? 'light'].text}
style={{ marginRight: 15, opacity: pressed ? 0.5 : 1 }}
/>
)}
</Pressable>
</Link>
),
}}
/>
<Tabs.Screen
name="two"
options={{
title: 'Tab Two',
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
}}
/>
</Tabs>
);
}

View File

@ -0,0 +1,31 @@
import { StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function TabOneScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Tab One</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/(tabs)/index.tsx" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});

31
mb-app/app/(tabs)/two.tsx Normal file
View File

@ -0,0 +1,31 @@
import { StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function TabTwoScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Tab Two</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/(tabs)/two.tsx" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});

38
mb-app/app/+html.tsx Normal file
View File

@ -0,0 +1,38 @@
import { ScrollViewStyleReset } from 'expo-router/html';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

40
mb-app/app/+not-found.tsx Normal file
View File

@ -0,0 +1,40 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native';
import { Text, View } from '@/components/Themed';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View style={styles.container}>
<Text style={styles.title}>This screen doesn't exist.</Text>
<Link href="/" style={styles.link}>
<Text style={styles.linkText}>Go to home screen!</Text>
</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
marginTop: 15,
paddingVertical: 15,
},
linkText: {
fontSize: 14,
color: '#2e78b7',
},
});

88
mb-app/app/_layout.tsx Normal file
View File

@ -0,0 +1,88 @@
import 'react-native-gesture-handler';
import '../global.css';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { QueryClientProvider } from '@tanstack/react-query';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { MD3DarkTheme, MD3LightTheme, PaperProvider } from 'react-native-paper';
import 'react-native-reanimated';
import { useColorScheme } from '@/components/useColorScheme';
import { AuthProvider } from '@/context/AuthContext';
import { queryClient } from '@/lib/query-client';
export { ErrorBoundary } from 'expo-router';
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded, error] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
...FontAwesome.font,
});
useEffect(() => {
if (error) throw error;
}, [error]);
useEffect(() => {
if (loaded) {
void SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return null;
}
return <RootLayoutNav />;
}
function RootLayoutNav() {
const colorScheme = useColorScheme();
const navigationTheme = colorScheme === 'dark' ? DarkTheme : DefaultTheme;
const paperTheme =
colorScheme === 'dark'
? {
...MD3DarkTheme,
colors: {
...MD3DarkTheme.colors,
primary: '#3B82F6',
primaryContainer: '#1E3A8A',
},
}
: {
...MD3LightTheme,
colors: {
...MD3LightTheme.colors,
primary: '#1D4ED8',
primaryContainer: '#DBEAFE',
},
};
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}>
<PaperProvider theme={paperTheme}>
<AuthProvider>
<ThemeProvider value={navigationTheme}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="auth" />
<Stack.Screen
name="modal"
options={{ presentation: 'modal', headerShown: true, title: 'Info' }}
/>
</Stack>
</ThemeProvider>
</AuthProvider>
</PaperProvider>
</QueryClientProvider>
</GestureHandlerRootView>
);
}

View File

@ -0,0 +1,12 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
animation: 'fade',
}}
/>
);
}

96
mb-app/app/auth/login.tsx Normal file
View File

@ -0,0 +1,96 @@
import { Link } from 'expo-router';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform, Pressable, ScrollView, Text, View } from 'react-native';
import { Button, TextInput } from 'react-native-paper';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAuth } from '@/context/AuthContext';
import { useUiStore } from '@/stores/ui-store';
export default function LoginScreen() {
const { signIn } = useAuth();
const lastEmail = useUiStore((s) => s.lastAuthEmail);
const [email, setEmail] = useState(lastEmail);
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<SafeAreaView className="flex-1 bg-slate-50">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1"
>
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerClassName="flex-grow justify-center px-6 py-10"
>
<View className="mb-10">
<Text className="text-3xl font-bold text-slate-900">Connexion</Text>
<Text className="mt-2 text-base text-slate-600">
Espace marchand de biens accès sécurisé
</Text>
</View>
{error ? (
<Text className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</Text>
) : null}
<TextInput
label="E-mail"
mode="outlined"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
className="mb-3 bg-white"
style={{ backgroundColor: '#fff' }}
/>
<TextInput
label="Mot de passe"
mode="outlined"
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="password"
className="mb-6 bg-white"
style={{ backgroundColor: '#fff' }}
/>
<Button
mode="contained"
onPress={async () => {
setError(null);
setLoading(true);
const r = await signIn(email.trim(), password);
setLoading(false);
if (r.error) {
setError(r.error);
return;
}
useUiStore.getState().setLastAuthEmail(email.trim());
}}
loading={loading}
disabled={loading}
buttonColor="#1D4ED8"
textColor="#ffffff"
style={{ borderRadius: 10, paddingVertical: 4 }}
>
Se connecter
</Button>
<View className="mt-8 items-center">
<Text className="text-slate-600">Pas encore de compte ?</Text>
<Link href="/auth/register" asChild>
<Pressable className="mt-2 py-2">
<Text className="font-semibold text-primary">Créer un compte</Text>
</Pressable>
</Link>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View File

@ -0,0 +1,127 @@
import { Link } from 'expo-router';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform, Pressable, ScrollView, Text, View } from 'react-native';
import { Button, TextInput } from 'react-native-paper';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAuth } from '@/context/AuthContext';
export default function RegisterScreen() {
const { signUp } = useAuth();
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
return (
<SafeAreaView className="flex-1 bg-slate-50">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1"
>
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerClassName="flex-grow px-6 py-8"
>
<View className="mb-8">
<Text className="text-3xl font-bold text-slate-900">Inscription</Text>
<Text className="mt-2 text-base text-slate-600">
Créez votre accès professionnel
</Text>
</View>
{error ? (
<Text className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</Text>
) : null}
{info ? (
<Text className="mb-3 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
{info}
</Text>
) : null}
<TextInput
label="Prénom"
mode="outlined"
value={firstName}
onChangeText={setFirstName}
autoComplete="given-name"
className="mb-3"
style={{ backgroundColor: '#fff' }}
/>
<TextInput
label="Nom"
mode="outlined"
value={lastName}
onChangeText={setLastName}
autoComplete="family-name"
className="mb-3"
style={{ backgroundColor: '#fff' }}
/>
<TextInput
label="E-mail"
mode="outlined"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
className="mb-3"
style={{ backgroundColor: '#fff' }}
/>
<TextInput
label="Mot de passe"
mode="outlined"
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="new-password"
className="mb-6"
style={{ backgroundColor: '#fff' }}
/>
<Button
mode="contained"
onPress={async () => {
setError(null);
setInfo(null);
setLoading(true);
const r = await signUp({
email: email.trim(),
password,
firstName: firstName.trim(),
lastName: lastName.trim(),
});
setLoading(false);
if (r.error) {
setError(r.error);
return;
}
setInfo(
'Si la confirmation e-mail est activée sur votre projet Supabase, vérifiez votre boîte avant de vous connecter.',
);
}}
loading={loading}
disabled={loading}
buttonColor="#1D4ED8"
textColor="#ffffff"
style={{ borderRadius: 10, paddingVertical: 4 }}
>
S&apos;inscrire
</Button>
<View className="mt-8 items-center">
<Link href="/auth/login" asChild>
<Pressable className="py-2">
<Text className="font-semibold text-primary">Retour à la connexion</Text>
</Pressable>
</Link>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

21
mb-app/app/index.tsx Normal file
View File

@ -0,0 +1,21 @@
import { Redirect } from 'expo-router';
import { ActivityIndicator, View } from 'react-native';
import { useAuth } from '@/context/AuthContext';
export default function Index() {
const { initialized, session } = useAuth();
if (!initialized) {
return (
<View className="flex-1 items-center justify-center bg-slate-50">
<ActivityIndicator size="large" color="#1D4ED8" />
</View>
);
}
if (session?.user) {
return <Redirect href="/(tabs)" />;
}
return <Redirect href="/auth/login" />;
}

35
mb-app/app/modal.tsx Normal file
View File

@ -0,0 +1,35 @@
import { StatusBar } from 'expo-status-bar';
import { Platform, StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function ModalScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Modal</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/modal.tsx" />
{/* Use a light status bar on iOS to account for the black space above the modal */}
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

9
mb-app/babel.config.js Normal file
View File

@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
'nativewind/babel',
],
};
};

View File

@ -0,0 +1,77 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import { ExternalLink } from './ExternalLink';
import { MonoText } from './StyledText';
import { Text, View } from './Themed';
import Colors from '@/constants/Colors';
export default function EditScreenInfo({ path }: { path: string }) {
return (
<View>
<View style={styles.getStartedContainer}>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)">
Open up the code for this screen:
</Text>
<View
style={[styles.codeHighlightContainer, styles.homeScreenFilename]}
darkColor="rgba(255,255,255,0.05)"
lightColor="rgba(0,0,0,0.05)">
<MonoText>{path}</MonoText>
</View>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)">
Change any of the text, save the file, and your app will automatically update.
</Text>
</View>
<View style={styles.helpContainer}>
<ExternalLink
style={styles.helpLink}
href="https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet">
<Text style={styles.helpLinkText} lightColor={Colors.light.tint}>
Tap here if your app doesn't automatically update after making changes
</Text>
</ExternalLink>
</View>
</View>
);
}
const styles = StyleSheet.create({
getStartedContainer: {
alignItems: 'center',
marginHorizontal: 50,
},
homeScreenFilename: {
marginVertical: 7,
},
codeHighlightContainer: {
borderRadius: 3,
paddingHorizontal: 4,
},
getStartedText: {
fontSize: 17,
lineHeight: 24,
textAlign: 'center',
},
helpContainer: {
marginTop: 15,
marginHorizontal: 20,
alignItems: 'center',
},
helpLink: {
paddingVertical: 15,
},
helpLinkText: {
textAlign: 'center',
},
});

View File

@ -0,0 +1,24 @@
import { Link } from 'expo-router';
import * as WebBrowser from 'expo-web-browser';
import React from 'react';
import { Platform } from 'react-native';
export function ExternalLink(
props: Omit<React.ComponentProps<typeof Link>, 'href'> & { href: string }
) {
return (
<Link
target="_blank"
{...props}
href={props.href}
onPress={(e) => {
if (Platform.OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
e.preventDefault();
// Open the link in an in-app browser.
WebBrowser.openBrowserAsync(props.href as string);
}
}}
/>
);
}

View File

@ -0,0 +1,5 @@
import { Text, TextProps } from './Themed';
export function MonoText(props: TextProps) {
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
}

View File

@ -0,0 +1,45 @@
/**
* Learn more about Light and Dark modes:
* https://docs.expo.io/guides/color-schemes/
*/
import { Text as DefaultText, View as DefaultView } from 'react-native';
import Colors from '@/constants/Colors';
import { useColorScheme } from './useColorScheme';
type ThemeProps = {
lightColor?: string;
darkColor?: string;
};
export type TextProps = ThemeProps & DefaultText['props'];
export type ViewProps = ThemeProps & DefaultView['props'];
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
export function Text(props: TextProps) {
const { style, lightColor, darkColor, ...otherProps } = props;
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return <DefaultText style={[{ color }, style]} {...otherProps} />;
}
export function View(props: ViewProps) {
const { style, lightColor, darkColor, ...otherProps } = props;
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />;
}

View File

@ -0,0 +1,10 @@
import * as React from 'react';
import renderer from 'react-test-renderer';
import { MonoText } from '../StyledText';
it(`renders correctly`, () => {
const tree = renderer.create(<MonoText>Snapshot test!</MonoText>).toJSON();
expect(tree).toMatchSnapshot();
});

View File

@ -0,0 +1,4 @@
// This function is web-only as native doesn't currently support server (or build-time) rendering.
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
return client;
}

View File

@ -0,0 +1,12 @@
import React from 'react';
// `useEffect` is not invoked during server rendering, meaning
// we can use this to determine if we're on the server or not.
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
const [value, setValue] = React.useState<S | C>(server);
React.useEffect(() => {
setValue(client);
}, [client]);
return value;
}

View File

@ -0,0 +1 @@
export { useColorScheme } from 'react-native';

View File

@ -0,0 +1,8 @@
// NOTE: The default React Native styling doesn't support server rendering.
// Server rendered styles should not change between the first render of the HTML
// and the first render on the client. Typically, web developers will use CSS media queries
// to render different styles on the client and server, these aren't directly supported in React Native
// but can be achieved using a styling library like Nativewind.
export function useColorScheme() {
return 'light';
}

View File

@ -0,0 +1,19 @@
const tintColorLight = '#2f95dc';
const tintColorDark = '#fff';
export default {
light: {
text: '#000',
background: '#fff',
tint: tintColorLight,
tabIconDefault: '#ccc',
tabIconSelected: tintColorLight,
},
dark: {
text: '#fff',
background: '#000',
tint: tintColorDark,
tabIconDefault: '#ccc',
tabIconSelected: tintColorDark,
},
};

View File

@ -0,0 +1,4 @@
/** Constantes métier marchand de biens (France) — à enrichir avec le métier. */
export const FRAIS_NOTAIRE_ANCIEN_RATIO = 0.075;
export const FRAIS_NOTAIRE_NEUF_RATIO = 0.02;

View File

@ -0,0 +1,121 @@
import type { Session, User } from '@supabase/supabase-js';
import { useRouter, useSegments } from 'expo-router';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { supabase } from '@/services/supabase';
type AuthContextValue = {
initialized: boolean;
session: Session | null;
user: User | null;
signIn: (email: string, password: string) => Promise<{ error?: string }>;
signUp: (params: {
email: string;
password: string;
firstName: string;
lastName: string;
}) => Promise<{ error?: string }>;
signOut: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [initialized, setInitialized] = useState(false);
const [session, setSession] = useState<Session | null>(null);
const segments = useSegments();
const router = useRouter();
useEffect(() => {
let cancelled = false;
void supabase.auth.getSession().then(({ data }) => {
if (!cancelled) {
setSession(data.session ?? null);
setInitialized(true);
}
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, nextSession) => {
setSession(nextSession);
});
return () => {
cancelled = true;
subscription.unsubscribe();
};
}, []);
useEffect(() => {
if (!initialized) return;
const root = segments[0];
if (!session?.user && root === '(tabs)') {
router.replace('/auth/login');
return;
}
if (session?.user && root === 'auth') {
router.replace('/(tabs)');
}
}, [initialized, session?.user, segments, router]);
const signIn = useCallback(async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) return { error: error.message };
return {};
}, []);
const signUp = useCallback(
async (params: {
email: string;
password: string;
firstName: string;
lastName: string;
}) => {
const { error } = await supabase.auth.signUp({
email: params.email,
password: params.password,
options: {
data: {
first_name: params.firstName,
last_name: params.lastName,
full_name: `${params.firstName} ${params.lastName}`.trim(),
},
},
});
if (error) return { error: error.message };
return {};
},
[],
);
const signOut = useCallback(async () => {
await supabase.auth.signOut();
}, []);
const value = useMemo<AuthContextValue>(
() => ({
initialized,
session,
user: session?.user ?? null,
signIn,
signUp,
signOut,
}),
[initialized, session, signIn, signUp, signOut],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth doit être utilisé dans un AuthProvider');
}
return ctx;
}

6
mb-app/env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare namespace NodeJS {
interface ProcessEnv {
EXPO_PUBLIC_SUPABASE_URL?: string;
EXPO_PUBLIC_SUPABASE_ANON_KEY?: string;
}
}

3
mb-app/global.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,10 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
},
},
});

6
mb-app/metro.config.js Normal file
View File

@ -0,0 +1,6 @@
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: './global.css' });

1
mb-app/nativewind-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="nativewind/types" />

11091
mb-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
mb-app/package.json Normal file
View File

@ -0,0 +1,52 @@
{
"name": "mb-app",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/native": "^7.1.8",
"@supabase/supabase-js": "^2.105.1",
"@tanstack/react-query": "^5.100.9",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.22",
"expo-font": "~14.0.11",
"expo-image-picker": "~17.0.11",
"expo-linking": "~8.0.11",
"expo-notifications": "~0.32.17",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.10",
"nativewind": "^4.2.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-maps": "1.20.1",
"react-native-paper": "^5.15.1",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-url-polyfill": "^3.0.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
"tailwindcss": "^3.4.19",
"zustand": "^5.0.12"
},
"devDependencies": {
"@types/react": "~19.1.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"react-test-renderer": "19.1.0",
"typescript": "~5.9.2"
},
"private": true
}

View File

@ -0,0 +1,22 @@
import 'react-native-url-polyfill/auto';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
import Constants from 'expo-constants';
const supabaseUrl =
process.env.EXPO_PUBLIC_SUPABASE_URL ??
Constants.expoConfig?.extra?.supabaseUrl ??
'';
const supabaseAnonKey =
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ??
Constants.expoConfig?.extra?.supabaseAnonKey ??
'';
export const supabase: SupabaseClient = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
});

11
mb-app/stores/ui-store.ts Normal file
View File

@ -0,0 +1,11 @@
import { create } from 'zustand';
type UiState = {
lastAuthEmail: string;
setLastAuthEmail: (email: string) => void;
};
export const useUiStore = create<UiState>((set) => ({
lastAuthEmail: '',
setLastAuthEmail: (email) => set({ lastAuthEmail: email }),
}));

20
mb-app/tailwind.config.js Normal file
View File

@ -0,0 +1,20 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,jsx,ts,tsx}',
'./components/**/*.{js,jsx,ts,tsx}',
'./context/**/*.{js,jsx,ts,tsx}',
'./services/**/*.{js,jsx,ts,tsx}',
'./stores/**/*.{js,jsx,ts,tsx}',
'./lib/**/*.{js,jsx,ts,tsx}',
],
presets: [require('nativewind/preset')],
theme: {
extend: {
colors: {
primary: '#1D4ED8',
},
},
},
plugins: [],
};

19
mb-app/tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts",
"env.d.ts"
]
}

11
mb-app/types/database.ts Normal file
View File

@ -0,0 +1,11 @@
/**
* Types générés Supabase — remplacer par `supabase gen types typescript`
* une fois le schéma défini (cf. .cursorrules).
*/
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[];

41
mdb-predator/.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

1
mdb-predator/.npmrc Normal file
View File

@ -0,0 +1 @@
legacy-peer-deps=true

32
mdb-predator/app.json Normal file
View File

@ -0,0 +1,32 @@
{
"expo": {
"name": "MDB-PREDATOR",
"slug": "mdb-predator",
"scheme": "mdb-predator",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#0a0a0a"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0a0a0a"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": ["expo-router", "expo-font"]
}
}

View File

@ -0,0 +1,26 @@
import 'react-native-gesture-handler';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { colors } from '../src/theme/colors';
export default function RootLayout() {
return (
<SafeAreaProvider>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.card },
headerTintColor: colors.text,
contentStyle: { backgroundColor: colors.bg },
}}
>
<Stack.Screen name="index" options={{ title: 'MDB-PREDATOR' }} />
<Stack.Screen
name="field"
options={{ title: 'Field visit' }}
/>
</Stack>
</SafeAreaProvider>
);
}

View File

@ -0,0 +1,7 @@
import { FieldVisitChecklist } from '../src/components/FieldVisitChecklist';
const DEMO_PROPERTY_ID = 'local-demo';
export default function FieldScreen() {
return <FieldVisitChecklist propertyId={DEMO_PROPERTY_ID} />;
}

146
mdb-predator/app/index.tsx Normal file
View File

@ -0,0 +1,146 @@
import { router } from 'expo-router';
import { useMemo, useState } from 'react';
import {
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { runFinancialAgent } from '../src/agents/orchestrator';
import { analyzeDeal, RED_FLAG_MARGIN_PCT, type DealAnalysisInput } from '../src/core/dealAnalysis';
import { sharePurchaseOfferPdf } from '../src/services/offerPdf';
import { colors } from '../src/theme/colors';
const DEMO_PROPERTY_ID = 'local-demo';
export default function RedFlagDashboard() {
const [asking, setAsking] = useState('185000');
const [resale, setResale] = useState('268000');
const [surface, setSurface] = useState('88');
const [works, setWorks] = useState('42000');
const input: DealAnalysisInput = useMemo(
() => ({
resaleEstimateTtc: Number(resale.replace(/\s/g, '')) || 0,
surfaceM2: Number(surface.replace(',', '.')) || 1,
worksTotalEur: Number(works.replace(/\s/g, '')) || 0,
notaryRateOnPurchase: 0.077,
saleAgencyRateOnResale: 0.05,
miscAcquisitionEur: 2500,
miscSaleEur: 1800,
holdingMonths: 6,
holdingAnnualRateOnPrincipal: 0.055,
}),
[resale, surface, works],
);
const analysis = useMemo(() => analyzeDeal(input), [input]);
const askNum = Number(asking.replace(/\s/g, '')) || 0;
const fin = useMemo(
() => runFinancialAgent(input, askNum),
[input, askNum],
);
const red = analysis.isRedFlag(askNum);
return (
<ScrollView contentContainerStyle={styles.scroll}>
<View style={[styles.banner, red ? styles.bannerBad : styles.bannerOk]}>
<Text style={styles.bannerTitle}>{red ? 'RED FLAG' : 'GO'}</Text>
<Text style={styles.bannerSub}>
Marge nette à prix affiché : {(analysis.netMarginPct(askNum) * 100).toFixed(1)} %
{' — '}seuil {(RED_FLAG_MARGIN_PCT * 100).toFixed(0)} %
</Text>
<Text style={styles.bannerSub}>
Prix dachat max (algo) :{' '}
{fin.maxBuyingPriceEur.toLocaleString('fr-FR')}
</Text>
</View>
<Text style={styles.h}>Hypothèses deal</Text>
<Field label="Prix affiché / offre actuelle (€)" value={asking} onChange={setAsking} />
<Field label="Prix de revente estimé TTC (€)" value={resale} onChange={setResale} />
<Field label="Surface (m²)" value={surface} onChange={setSurface} />
<Field label="Travaux estimés (€)" value={works} onChange={setWorks} />
<Pressable style={styles.btn} onPress={() => router.push('/field')}>
<Text style={styles.btnText}>Field visit checklist</Text>
</Pressable>
<Pressable
style={[styles.btn, styles.btnGhost]}
onPress={() =>
void sharePurchaseOfferPdf({
propertyTitle: 'Bien cible',
address: 'À compléter',
maxBuyPriceEur: fin.maxBuyingPriceEur,
})
}
>
<Text style={styles.btnTextGhost}>One-click offer (PDF)</Text>
</Pressable>
<Text style={styles.foot}>
Agents SCOUT / ENGINEER / APIs : brancher Edge Functions + clés serveur.
Dossier checklist : id « {DEMO_PROPERTY_ID} ».
</Text>
</ScrollView>
);
}
function Field({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (s: string) => void;
}) {
return (
<View style={{ marginBottom: 12 }}>
<Text style={styles.lab}>{label}</Text>
<TextInput
keyboardType="decimal-pad"
value={value}
onChangeText={onChange}
style={styles.inp}
placeholderTextColor={colors.muted}
/>
</View>
);
}
const styles = StyleSheet.create({
scroll: { padding: 16, paddingBottom: 48 },
banner: { borderRadius: 16, padding: 18, marginBottom: 20 },
bannerBad: { backgroundColor: '#3a121c' },
bannerOk: { backgroundColor: '#0f2a1c' },
bannerTitle: { color: colors.text, fontSize: 28, fontWeight: '900' },
bannerSub: { color: colors.muted, marginTop: 8, lineHeight: 20 },
h: { color: colors.text, fontSize: 16, fontWeight: '700', marginBottom: 10 },
lab: { color: colors.muted, fontSize: 12, marginBottom: 6 },
inp: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 10,
padding: 12,
color: colors.text,
backgroundColor: colors.card,
},
btn: {
backgroundColor: colors.accent,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
marginTop: 10,
},
btnGhost: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: colors.border,
},
btnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
btnTextGhost: { color: colors.text, fontWeight: '700', fontSize: 16 },
foot: { color: colors.muted, marginTop: 24, fontSize: 12, lineHeight: 18 },
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,7 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['expo-router/babel'],
};
};

9378
mdb-predator/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
mdb-predator/package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "mdb-predator",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@supabase/supabase-js": "^2.49.1",
"babel-preset-expo": "~54.0.10",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.11",
"expo-linking": "~8.0.12",
"expo-print": "~15.0.8",
"expo-router": "~6.0.23",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-url-polyfill": "^3.0.0",
"react-native-web": "^0.21.0"
},
"devDependencies": {
"@types/react": "~19.1.0",
"typescript": "~5.9.2"
},
"private": true
}

View File

@ -0,0 +1,63 @@
/**
* Orchestration multi-agents — appels OpenAI / Edge Functions à brancher ici.
* Les implémentations réelles ne doivent pas exposer la clé API dans l'app mobile.
*/
import type {
DealMatch,
EngineerEstimate,
FinancialSnapshot,
ScoutSignal,
} from './types';
import { analyzeDeal, type DealAnalysisInput } from '../core/dealAnalysis';
const DISTRESS = [
'succession',
'urgent',
'travaux',
'dpe g',
'sous compromis',
'divorce',
];
export function runScoutStub(listingText: string): ScoutSignal {
const lower = listingText.toLowerCase();
const hits = DISTRESS.filter((k) => lower.includes(k));
return {
source: 'manual',
distressKeywords: hits,
score: Math.min(100, hits.length * 25 + 10),
};
}
export function runEngineerStub(surfaceM2: number, tier: 'light' | 'medium' | 'heavy'): EngineerEstimate {
const perM2 = tier === 'light' ? 450 : tier === 'medium' ? 950 : 1800;
const base = surfaceM2 * perM2;
return {
photoRefs: [],
notes: tier,
worksLowEur: Math.round(base * 0.85),
worksHighEur: Math.round(base * 1.15),
costPerM2Assumption: perM2,
};
}
export function runFinancialAgent(
input: DealAnalysisInput,
askingPriceTtc: number,
): FinancialSnapshot {
const r = analyzeDeal(input);
return {
maxBuyingPriceEur: r.maxBuyingPriceEur,
netMarginPctAtAsk: r.netMarginPct(askingPriceTtc),
redFlag: r.isRedFlag(askingPriceTtc),
};
}
export function runDealMakerStub(buyers: { id: string; name: string }[]): DealMatch[] {
return buyers.slice(0, 5).map((b, i) => ({
buyerId: b.id,
buyerName: b.name,
fitScore: 90 - i * 5,
}));
}

View File

@ -0,0 +1,28 @@
export type AgentId = 'SCOUT' | 'ENGINEER' | 'FINANCIAL' | 'DEAL_MAKER';
export interface ScoutSignal {
source: 'apify' | 'manual' | 'zendesk';
listingUrl?: string;
distressKeywords: string[];
score: number;
}
export interface EngineerEstimate {
photoRefs: string[];
notes: string;
worksLowEur: number;
worksHighEur: number;
costPerM2Assumption: number;
}
export interface FinancialSnapshot {
maxBuyingPriceEur: number;
netMarginPctAtAsk: number;
redFlag: boolean;
}
export interface DealMatch {
buyerId: string;
buyerName: string;
fitScore: number;
}

View File

@ -0,0 +1,150 @@
import { useCallback, useEffect, useState } from 'react';
import {
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
View,
} from 'react-native';
import { loadFieldVisit, saveFieldVisit } from '../offline/fieldVisitStorage';
import { colors } from '../theme/colors';
export type FieldItemKey = 'roof' | 'structure' | 'humidity' | 'electricity';
export interface FieldChecklistState {
items: Record<FieldItemKey, boolean>;
notes: string;
updatedAt: string;
}
const ITEMS: { key: FieldItemKey; label: string; hint: string }[] = [
{ key: 'roof', label: 'Toiture', hint: 'Fuites, charpente, couverture' },
{ key: 'structure', label: 'Structure', hint: 'Murs porteurs, plancher, fissures' },
{ key: 'humidity', label: 'Humidité', hint: 'Infiltrations, remontées, cave' },
{ key: 'electricity', label: 'Électricité', hint: 'Tableau, mise aux normes' },
];
const defaultState = (): FieldChecklistState => ({
items: {
roof: false,
structure: false,
humidity: false,
electricity: false,
},
notes: '',
updatedAt: new Date().toISOString(),
});
type Props = {
propertyId: string;
};
export function FieldVisitChecklist({ propertyId }: Props) {
const [state, setState] = useState<FieldChecklistState>(defaultState);
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
const saved = await loadFieldVisit(propertyId);
if (!cancelled) {
if (saved) setState(saved);
setHydrated(true);
}
})();
return () => {
cancelled = true;
};
}, [propertyId]);
const persist = useCallback(
async (next: FieldChecklistState) => {
const stamped = { ...next, updatedAt: new Date().toISOString() };
setState(stamped);
await saveFieldVisit(propertyId, stamped);
},
[propertyId],
);
const toggle = (key: FieldItemKey, value: boolean) => {
void persist({
...state,
items: { ...state.items, [key]: value },
});
};
if (!hydrated) {
return (
<View style={styles.center}>
<Text style={styles.muted}>Chargement checklist</Text>
</View>
);
}
return (
<ScrollView contentContainerStyle={styles.scroll}>
<Text style={styles.title}>Field mode</Text>
<Text style={styles.sub}>
Hors-ligne : les coches sont enregistrées sur lappareil (AsyncStorage).
</Text>
{ITEMS.map((row) => (
<View key={row.key} style={styles.row}>
<View style={{ flex: 1, paddingRight: 12 }}>
<Text style={styles.label}>{row.label}</Text>
<Text style={styles.hint}>{row.hint}</Text>
</View>
<Switch
value={state.items[row.key]}
onValueChange={(v) => toggle(row.key, v)}
trackColor={{ true: colors.accent, false: colors.border }}
/>
</View>
))}
<Text style={styles.section}>Notes terrain</Text>
<TextInput
style={styles.notes}
multiline
placeholder="Observations, photos référencées…"
placeholderTextColor={colors.muted}
value={state.notes}
onChangeText={(t) => void persist({ ...state, notes: t })}
/>
</ScrollView>
);
}
const styles = StyleSheet.create({
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
muted: { color: colors.muted },
scroll: { padding: 16, paddingBottom: 40 },
title: { color: colors.text, fontSize: 22, fontWeight: '800', marginBottom: 6 },
sub: { color: colors.muted, marginBottom: 20, lineHeight: 20 },
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
label: { color: colors.text, fontWeight: '700', fontSize: 16 },
hint: { color: colors.muted, fontSize: 13, marginTop: 4 },
section: {
color: colors.muted,
marginTop: 20,
marginBottom: 8,
textTransform: 'uppercase',
fontSize: 12,
letterSpacing: 0.08,
},
notes: {
minHeight: 120,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
padding: 12,
color: colors.text,
backgroundColor: colors.card,
textAlignVertical: 'top',
},
});

View File

@ -0,0 +1,148 @@
/**
* MDB-PREDATOR — Deal Analysis (Financial agent logic, client-side).
* Résout le prix d'achat maximum pour tenir une marge nette cible après
* frais de notaire, travaux, portage, TVA sur marge (estimation) et frais de vente.
*/
export const RED_FLAG_MARGIN_PCT = 0.15;
export const DEFAULT_VAT_ON_MARGIN_RATE = 0.2;
export interface DealAnalysisInput {
/** Prix de cession estimé (TTC marché). */
resaleEstimateTtc: number;
surfaceM2: number;
/** Travaux + imprévus (hors checklist terrain = déjà inclus ici si besoin). */
worksTotalEur: number;
notaryRateOnPurchase: number;
saleAgencyRateOnResale: number;
miscAcquisitionEur: number;
miscSaleEur: number;
holdingMonths: number;
holdingAnnualRateOnPrincipal: number;
vatOnMarginRate?: number;
}
export interface DealAnalysisResult {
/** Coût total engagé pour un prix d'achat donné (hors achat = frais annexes). */
totalCashOutForPurchase: (purchaseTtc: number) => number;
/** Marge nette après TVA sur marge (€). */
netProfitEur: (purchaseTtc: number) => number;
/** Marge nette / investissement total. */
netMarginPct: (purchaseTtc: number) => number;
/** true si marge < 15 % (RED FLAG dashboard). */
isRedFlag: (purchaseTtc: number) => boolean;
/** Prix d'achat max pour respecter RED_FLAG_MARGIN_PCT sur l'enveloppe investie. */
maxBuyingPriceEur: number;
/** Prix d'achat au m² implicite pour maxBuyingPriceEur. */
maxBuyingPricePerM2: number;
/** Seuil de revente TTC break-even simplifié (hors effet TVA marginale). */
breakEvenResaleTtc: (purchaseTtc: number) => number;
}
function holdingCostEur(
principal: number,
months: number,
annualRate: number,
): number {
return principal * annualRate * (months / 12);
}
function netResaleTtc(input: DealAnalysisInput): number {
const agency = input.resaleEstimateTtc * input.saleAgencyRateOnResale;
return input.resaleEstimateTtc - agency - input.miscSaleEur;
}
/**
* Pour un prix d'achat candidat P :
* investissement = P + notaire(P) + travaux + frais achat + portage(P)
* marge brute avant TVA = produit_net_revente - investissement
* TVA sur marge ≈ taux × max(0, marge_brute)
* marge nette = marge_brute - TVA
*/
export function analyzeDeal(input: DealAnalysisInput): DealAnalysisResult {
const vatRate = input.vatOnMarginRate ?? DEFAULT_VAT_ON_MARGIN_RATE;
const resaleNet = netResaleTtc(input);
const totalCashOutForPurchase = (P: number): number => {
const notary = P * input.notaryRateOnPurchase;
const carry = holdingCostEur(
P,
input.holdingMonths,
input.holdingAnnualRateOnPrincipal,
);
return P + notary + input.worksTotalEur + input.miscAcquisitionEur + carry;
};
const grossBeforeVat = (P: number) => resaleNet - totalCashOutForPurchase(P);
const vatOnMargin = (P: number) => Math.max(0, grossBeforeVat(P)) * vatRate;
const netProfitEur = (P: number) => grossBeforeVat(P) - vatOnMargin(P);
const netMarginPct = (P: number) => {
const inv = totalCashOutForPurchase(P);
return inv > 0 ? netProfitEur(P) / inv : 0;
};
const isRedFlag = (P: number) => netMarginPct(P) < RED_FLAG_MARGIN_PCT;
const maxBuyingPriceEur = solveMaxPurchaseForMargin(
input,
vatRate,
resaleNet,
RED_FLAG_MARGIN_PCT,
);
const maxBuyingPricePerM2 =
input.surfaceM2 > 0 ? maxBuyingPriceEur / input.surfaceM2 : 0;
const breakEvenResaleTtc = (purchaseTtc: number): number => {
const inv = totalCashOutForPurchase(purchaseTtc);
const coeff = 1 - input.saleAgencyRateOnResale;
if (coeff <= 0) return Number.POSITIVE_INFINITY;
return (inv + input.miscSaleEur) / coeff;
};
return {
totalCashOutForPurchase,
netProfitEur,
netMarginPct,
isRedFlag,
maxBuyingPriceEur,
maxBuyingPricePerM2,
breakEvenResaleTtc,
};
}
function solveMaxPurchaseForMargin(
input: DealAnalysisInput,
vatRate: number,
resaleNet: number,
targetNetMarginPct: number,
): number {
let low = 0;
let high = Math.max(input.resaleEstimateTtc * 1.2, 1);
for (let i = 0; i < 64; i++) {
const mid = (low + high) / 2;
const notary = mid * input.notaryRateOnPurchase;
const carry = holdingCostEur(
mid,
input.holdingMonths,
input.holdingAnnualRateOnPrincipal,
);
const inv =
mid +
notary +
input.worksTotalEur +
input.miscAcquisitionEur +
carry;
const gross = resaleNet - inv;
const vat = Math.max(0, gross) * vatRate;
const net = gross - vat;
const margin = inv > 0 ? net / inv : 0;
if (margin >= targetNetMarginPct) {
low = mid;
} else {
high = mid;
}
}
return Math.max(0, Math.round(low));
}

View File

@ -0,0 +1,15 @@
import 'react-native-url-polyfill/auto';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient } from '@supabase/supabase-js';
const url = process.env.EXPO_PUBLIC_SUPABASE_URL ?? '';
const key = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ?? '';
export const supabase = createClient(url, key, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
});

View File

@ -0,0 +1,21 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { FieldChecklistState } from '../components/FieldVisitChecklist';
const key = (propertyId: string) => `mdb-predator:field:${propertyId}`;
export async function loadFieldVisit(propertyId: string): Promise<FieldChecklistState | null> {
const raw = await AsyncStorage.getItem(key(propertyId));
if (!raw) return null;
try {
return JSON.parse(raw) as FieldChecklistState;
} catch {
return null;
}
}
export async function saveFieldVisit(
propertyId: string,
state: FieldChecklistState,
): Promise<void> {
await AsyncStorage.setItem(key(propertyId), JSON.stringify(state));
}

View File

@ -0,0 +1,17 @@
/**
* Apify actors (SeLoger / PAP / LBC) — déclencher depuis n8n ou Edge Function.
* Retourne uniquement des annonces pré-scorées côté cloud.
*/
export interface ScrapedListing {
url: string;
title: string;
priceEur: number;
city: string;
rawText: string;
grade: 'A' | 'B' | 'C';
}
export async function pullGradeADealsStub(): Promise<ScrapedListing[]> {
return [];
}

View File

@ -0,0 +1,16 @@
/**
* DVF (data.gouv) — à appeler depuis une Edge Function Supabase pour éviter CORS
* et mutualiser le cache. Signature prête pour lAPI tabulaire.
*/
export interface DvfStreetContext {
inseeCode: string;
streetNormalized: string;
yearMin?: number;
}
export async function fetchDvfMedianPriceM2Stub(
_ctx: DvfStreetContext,
): Promise<number | null> {
return null;
}

View File

@ -0,0 +1,32 @@
import * as Print from 'expo-print';
import * as Sharing from 'expo-sharing';
import { Platform } from 'react-native';
export async function sharePurchaseOfferPdf(params: {
propertyTitle: string;
address: string;
maxBuyPriceEur: number;
sellerName?: string;
}): Promise<void> {
const html = `<!DOCTYPE html><html lang="fr"><head><meta charset="utf-8"/>
<style>body{font-family:system-ui;padding:32px;color:#111}
h1{font-size:22px} .box{border:1px solid #ccc;padding:16px;border-radius:8px;margin-top:16px}
</style></head><body>
<h1>Offre d'achat — ${escape(params.propertyTitle)}</h1>
<p>${escape(params.address)}</p>
<div class="box">
<p>Montant de l'offre : <strong>${params.maxBuyPriceEur.toLocaleString('fr-FR')} €</strong></p>
<p>(${params.sellerName ? `Destinataire : ${escape(params.sellerName)}` : 'À compléter'})</p>
</div>
<p style="margin-top:24px;font-size:11px;color:#666">MDB-PREDATOR — modèle indicatif, valider avec votre notaire / juriste.</p>
</body></html>`;
const { uri } = await Print.printToFileAsync({ html });
if (Platform.OS === 'web') return;
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(uri, { mimeType: 'application/pdf', dialogTitle: 'Offre dachat' });
}
}
function escape(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
}

View File

@ -0,0 +1,10 @@
/** Nominatim / OSM — respecter la politique dusage ; préférer backend pour prod. */
export interface GeoPoint {
lat: number;
lon: number;
}
export async function reverseGeocodeStub(_p: GeoPoint): Promise<string | null> {
return null;
}

View File

@ -0,0 +1,5 @@
/** Pappers — surveillance SCI / procédures (clé API côté serveur uniquement). */
export async function searchCompanySignalsStub(_siren: string): Promise<unknown[]> {
return [];
}

View File

@ -0,0 +1,10 @@
export const colors = {
bg: '#070b10',
card: '#101820',
border: '#1f2a36',
text: '#f2f6fb',
muted: '#8b9bb0',
accent: '#ff4d6d',
danger: '#ff3355',
ok: '#3ecf8e',
};

View File

@ -0,0 +1,100 @@
-- MDB-PREDATOR — cœur données (Supabase)
create type public.property_status as enum (
'sourcing',
'visited',
'under_offer',
'sold'
);
create table if not exists public.properties (
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 'Property',
address_line text,
city text,
postal_code text,
insee_code text,
latitude double precision,
longitude double precision,
surface_m2 numeric(12, 2),
status public.property_status not null default 'sourcing',
asking_price_eur numeric(14, 2),
resale_estimate_eur numeric(14, 2),
dvf_street_median_m2 numeric(14, 2),
works_estimate_eur numeric(14, 2) not null default 0,
distress_score smallint,
scout_payload jsonb,
engineer_payload jsonb,
financial_payload jsonb,
deal_maker_payload jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists properties_user_idx on public.properties (user_id);
create index if not exists properties_status_idx on public.properties (status);
create table if not exists public.renovation_templates (
id uuid primary key default gen_random_uuid(),
slug text not null unique,
label text not null,
tier text not null check (tier in ('light', 'medium', 'heavy')),
cost_per_m2_eur numeric(12, 2) not null,
default_misc_eur numeric(12, 2) not null default 0,
sort_order int not null default 0
);
insert into public.renovation_templates (slug, label, tier, cost_per_m2_eur, default_misc_eur, sort_order)
values
('cosmetic', 'Rafraîchissement / léger', 'light', 450, 5000, 10),
('standard', 'Rénovation standard', 'medium', 950, 12000, 20),
('structural', 'Lourd / structure + toiture', 'heavy', 1800, 35000, 30)
on conflict (slug) do nothing;
create table if not exists public.buyers_portfolio (
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,
budget_min_eur numeric(14, 2),
budget_max_eur numeric(14, 2),
sectors jsonb,
min_yield_pct numeric(6, 3),
min_net_margin_pct numeric(5, 2) default 12,
notes text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists buyers_user_idx on public.buyers_portfolio (user_id);
create or replace function public.predator_set_updated_at()
returns trigger language plpgsql as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists tr_properties_updated on public.properties;
create trigger tr_properties_updated
before update on public.properties
for each row execute procedure public.predator_set_updated_at();
drop trigger if exists tr_buyers_updated on public.buyers_portfolio;
create trigger tr_buyers_updated
before update on public.buyers_portfolio
for each row execute procedure public.predator_set_updated_at();
alter table public.properties enable row level security;
alter table public.buyers_portfolio enable row level security;
alter table public.renovation_templates enable row level security;
create policy properties_owner on public.properties
for all using (auth.uid() = user_id) with check (auth.uid() = user_id);
create policy buyers_owner on public.buyers_portfolio
for all using (auth.uid() = user_id) with check (auth.uid() = user_id);
create policy renovation_read on public.renovation_templates
for select to authenticated using (true);

View File

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

2316
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,38 @@
{
"name": "mdb",
"name": "mdb-turbo",
"version": "1.0.0",
"main": "index.ts",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"start:tunnel": "expo start --tunnel --port 8082",
"start:lan": "expo start --lan --port 8082",
"start:web": "expo start --web --port 8082",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@supabase/supabase-js": "^2.105.1",
"babel-preset-expo": "~54.0.10",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.11",
"expo-linking": "~8.0.12",
"expo-notifications": "~0.32.17",
"expo-print": "~15.0.8",
"expo-router": "~6.0.23",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "0.81.5"
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-url-polyfill": "^3.0.0",
"react-native-web": "^0.21.0"
},
"devDependencies": {
"@types/react": "~19.1.0",

489
schema.sql Normal file
View File

@ -0,0 +1,489 @@
-- ============================================================
-- SCHÉMA SUPABASE — Application Marchand de Biens
-- À exécuter dans Supabase > SQL Editor
-- ============================================================
-- Extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "unaccent";
-- ============================================================
-- TABLES PRINCIPALES
-- ============================================================
-- Profil utilisateur (complète auth.users de Supabase)
CREATE TABLE public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
nom TEXT,
prenom TEXT,
telephone TEXT,
raison_sociale TEXT, -- Nom de la société (SCI, SARL, etc.)
siret TEXT,
taux_credit_defaut DECIMAL(5,3) DEFAULT 3.5, -- Taux crédit par défaut en %
taux_impot_defaut DECIMAL(5,3) DEFAULT 25.0, -- Taux IS par défaut en %
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- MODULE PROSPECTION / BIENS
-- ============================================================
-- Étapes du pipeline (customisables)
CREATE TABLE public.etapes_pipeline (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
nom TEXT NOT NULL,
ordre INTEGER NOT NULL,
couleur TEXT DEFAULT '#6B7280',
is_terminal BOOLEAN DEFAULT FALSE, -- Étape finale (Vendu, Abandonné)
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Biens immobiliers
CREATE TABLE public.biens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
etape_id UUID REFERENCES public.etapes_pipeline(id),
-- Identification
titre TEXT, -- Nom court ex: "T3 Bordeaux Caudéran"
reference TEXT UNIQUE, -- Référence interne auto-générée
type_bien TEXT, -- appartement, maison, immeuble, terrain, local_commercial, parking
-- Localisation
adresse TEXT,
code_postal TEXT,
ville TEXT,
departement TEXT,
latitude DECIMAL(10,7),
longitude DECIMAL(10,7),
-- Caractéristiques physiques
surface_habitable DECIMAL(8,2), -- m² loi Carrez
surface_totale DECIMAL(8,2), -- m² totaux
nb_pieces INTEGER,
nb_chambres INTEGER,
nb_etages INTEGER,
etage INTEGER,
annee_construction INTEGER,
dpe_lettre TEXT, -- A, B, C, D, E, F, G
dpe_valeur INTEGER, -- kWh/m²/an
ges_lettre TEXT,
ges_valeur INTEGER,
-- Source
source TEXT, -- particulier, agence, notaire, tribunal, succession, autre
source_contact_id UUID, -- Lien vers contact (notaire, agent...)
url_annonce TEXT,
-- Statut
statut TEXT DEFAULT 'actif', -- actif, abandonné, vendu
priorite INTEGER DEFAULT 2, -- 1=haute, 2=normale, 3=basse
is_off_market BOOLEAN DEFAULT FALSE,
-- Dates importantes
date_premiere_visite DATE,
date_offre DATE,
date_compromis DATE,
date_acte DATE,
date_mise_en_vente DATE,
date_acte_revente DATE,
-- Notes
description TEXT,
points_forts TEXT,
points_faibles TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Analyse financière par bien
CREATE TABLE public.analyses_financieres (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bien_id UUID NOT NULL REFERENCES public.biens(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Acquisition
prix_achat DECIMAL(12,2),
type_bien_fiscal TEXT DEFAULT 'ancien', -- ancien (7.5%) ou neuf (2%)
frais_notaire DECIMAL(12,2), -- calculé ou saisi manuellement
frais_agence_achat DECIMAL(12,2),
frais_agence_achat_pct DECIMAL(5,2),
-- Financement
apport DECIMAL(12,2),
montant_emprunt DECIMAL(12,2),
taux_credit DECIMAL(5,3),
duree_credit_mois INTEGER,
mensualite DECIMAL(10,2),
-- Travaux
budget_travaux DECIMAL(12,2),
reserve_imprevus_pct DECIMAL(5,2) DEFAULT 10,
-- Portage
duree_portage_mois INTEGER DEFAULT 12,
taxe_fonciere_annuelle DECIMAL(10,2),
charges_copropriete_mensuelle DECIMAL(10,2),
assurance_mensuelle DECIMAL(10,2),
frais_divers_mensuel DECIMAL(10,2),
-- Revente
prix_revente_cible DECIMAL(12,2),
frais_agence_vente_pct DECIMAL(5,2) DEFAULT 5,
frais_agence_vente DECIMAL(12,2),
taux_impot DECIMAL(5,2) DEFAULT 25,
-- Résultats calculés (mis à jour automatiquement)
prix_revient DECIMAL(12,2),
frais_portage_total DECIMAL(12,2),
marge_brute DECIMAL(12,2),
marge_brute_pct DECIMAL(7,2),
marge_nette DECIMAL(12,2),
marge_nette_pct DECIMAL(7,2),
-- Scénarios
scenario_pessimiste_prix DECIMAL(12,2),
scenario_optimiste_prix DECIMAL(12,2),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- MODULE CONTACTS / ANNUAIRE
-- ============================================================
CREATE TABLE public.contacts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Identité
nom TEXT NOT NULL,
prenom TEXT,
societe TEXT,
poste TEXT,
-- Catégorie
categorie TEXT NOT NULL, -- notaire, agent_immo, artisan_gros_oeuvre, artisan_second_oeuvre,
-- artisan_finitions, banquier, courtier, diagnostiqueur,
-- geometre, avocat, comptable, vendeur, acheteur, autre
specialite TEXT, -- ex: "Plomberie chauffage", "Électricité", "Charpente"
-- Coordonnées
telephone TEXT,
telephone_2 TEXT,
email TEXT,
adresse TEXT,
code_postal TEXT,
ville TEXT,
zone_intervention TEXT, -- ex: "Bordeaux 33000-33100"
-- Qualité
note INTEGER CHECK (note BETWEEN 1 AND 5), -- Note de 1 à 5 étoiles
fiabilite TEXT, -- excellent, bon, moyen, mauvais
recommande BOOLEAN DEFAULT FALSE,
-- Tarification artisans
taux_horaire DECIMAL(8,2),
remise_habituelle_pct DECIMAL(5,2),
notes TEXT,
is_favori BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Lien bien ↔ contact (ex: notaire assigné à ce dossier)
CREATE TABLE public.biens_contacts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bien_id UUID NOT NULL REFERENCES public.biens(id) ON DELETE CASCADE,
contact_id UUID NOT NULL REFERENCES public.contacts(id) ON DELETE CASCADE,
role TEXT, -- notaire_vendeur, notaire_acheteur, agent_vendeur, artisan_principal, banque
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(bien_id, contact_id, role)
);
-- ============================================================
-- MODULE VISITES
-- ============================================================
CREATE TABLE public.visites (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bien_id UUID NOT NULL REFERENCES public.biens(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
date_visite TIMESTAMPTZ NOT NULL,
duree_minutes INTEGER DEFAULT 60,
type_visite TEXT DEFAULT 'premiere', -- premiere, seconde, expert, contradiction
-- Notes brutes pendant la visite
notes_brutes TEXT,
-- Rapport généré par IA
rapport_genere TEXT,
rapport_genere_at TIMESTAMPTZ,
-- Estimations sur place
estimation_travaux_min DECIMAL(12,2),
estimation_travaux_max DECIMAL(12,2),
estimation_duree_travaux_mois INTEGER,
-- Avis global
avis_global TEXT, -- coup_de_coeur, interessant, neutre, a_eviter
score_opportunite INTEGER CHECK (score_opportunite BETWEEN 1 AND 10),
contacts_presents TEXT[], -- Noms des personnes présentes
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Check-list de visite (items configurables)
CREATE TABLE public.checklist_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
categorie TEXT NOT NULL, -- structure, toiture, electricite, plomberie, isolation, humidite, exterieur, administratif
question TEXT NOT NULL,
description TEXT, -- Aide / explication
ordre INTEGER DEFAULT 0,
is_actif BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Réponses à la check-list pour une visite
CREATE TABLE public.checklist_reponses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
visite_id UUID NOT NULL REFERENCES public.visites(id) ON DELETE CASCADE,
item_id UUID NOT NULL REFERENCES public.checklist_items(id),
reponse TEXT, -- ok, attention, probleme, non_verifie
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(visite_id, item_id)
);
-- ============================================================
-- MODULE DOCUMENTS & MÉDIAS
-- ============================================================
CREATE TABLE public.photos_biens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bien_id UUID NOT NULL REFERENCES public.biens(id) ON DELETE CASCADE,
visite_id UUID REFERENCES public.visites(id),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
storage_path TEXT NOT NULL, -- chemin dans Supabase Storage
nom TEXT,
legende TEXT,
categorie TEXT, -- facade, interieur, travaux, avant, apres
ordre INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE public.documents_biens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bien_id UUID NOT NULL REFERENCES public.biens(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
storage_path TEXT NOT NULL,
nom TEXT NOT NULL,
type_document TEXT, -- compromis, acte, dpe, diagnostics, devis, facture, titre_propriete, autre
date_document DATE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- MODULE AGENDA / TÂCHES
-- ============================================================
CREATE TABLE public.taches (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
bien_id UUID REFERENCES public.biens(id) ON DELETE SET NULL,
contact_id UUID REFERENCES public.contacts(id) ON DELETE SET NULL,
titre TEXT NOT NULL,
description TEXT,
type_tache TEXT, -- visite, appel, email, relance, administratif, travaux, banque, autre
priorite INTEGER DEFAULT 2,
statut TEXT DEFAULT 'a_faire', -- a_faire, en_cours, fait, annule
date_echeance TIMESTAMPTZ,
date_rappel TIMESTAMPTZ,
date_realisation TIMESTAMPTZ,
recurrence TEXT, -- null, quotidien, hebdomadaire, mensuel
is_urgent BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Notes libres
CREATE TABLE public.notes_biens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bien_id UUID NOT NULL REFERENCES public.biens(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
contenu TEXT NOT NULL,
type_note TEXT DEFAULT 'libre', -- libre, contact_vendeur, negociation, info_marche
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- MODULE TRAVAUX
-- ============================================================
CREATE TABLE public.devis_travaux (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bien_id UUID NOT NULL REFERENCES public.biens(id) ON DELETE CASCADE,
contact_id UUID REFERENCES public.contacts(id), -- L'artisan
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
lot TEXT NOT NULL, -- Gros œuvre, Électricité, Plomberie, Menuiserie, etc.
description TEXT,
montant_ht DECIMAL(12,2),
taux_tva DECIMAL(5,2) DEFAULT 10,
montant_ttc DECIMAL(12,2),
statut TEXT DEFAULT 'en_attente', -- en_attente, refuse, accepte, en_cours, termine, paye
date_devis DATE,
date_debut_prevu DATE,
date_fin_prevu DATE,
date_fin_reel DATE,
storage_path_pdf TEXT,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- DONNÉES PAR DÉFAUT
-- ============================================================
-- Étapes pipeline par défaut (seront copiées pour chaque nouvel utilisateur)
-- (géré via trigger ou Edge Function à l'inscription)
-- Check-list de visite par défaut
INSERT INTO public.checklist_items (user_id, categorie, question, description, ordre)
VALUES
-- Sera remplacé par un trigger, voici la structure des items par défaut
-- Ces items sont créés via la Edge Function on-user-create
('00000000-0000-0000-0000-000000000000', 'structure', 'Fissures visibles sur les murs porteurs ?', 'Observer les fissures en H ou diagonales, signe de mouvement de structure', 1),
('00000000-0000-0000-0000-000000000000', 'toiture', 'État général de la toiture ?', 'Tuiles manquantes, mousse, faîtage, chéneaux', 2),
('00000000-0000-0000-0000-000000000000', 'electricite', 'Tableau électrique aux normes ?', 'Vérifier disjoncteurs, présence de terre, type de tableau', 3),
('00000000-0000-0000-0000-000000000000', 'plomberie', 'Type de canalisations (plomb, cuivre, PVC) ?', 'Canalisations en plomb = remplacement obligatoire', 4),
('00000000-0000-0000-0000-000000000000', 'humidite', 'Traces d''humidité ou de moisissures ?', 'Vérifier les angles, sous les fenêtres, la cave', 5),
('00000000-0000-0000-0000-000000000000', 'isolation', 'Type et état de l''isolation ?', 'Combles, murs, sol. DPE cohérent avec le ressenti', 6),
('00000000-0000-0000-0000-000000000000', 'exterieur', 'État des menuiseries extérieures ?', 'Simple ou double vitrage, état des joints, volets', 7),
('00000000-0000-0000-0000-000000000000', 'administratif', 'Charges de copropriété et procédures en cours ?', 'Demander les 3 derniers PV d''AG, montant des charges', 8);
-- ============================================================
-- ROW LEVEL SECURITY (RLS)
-- ============================================================
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.biens ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.etapes_pipeline ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.analyses_financieres ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.contacts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.biens_contacts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.visites ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.checklist_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.checklist_reponses ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.photos_biens ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.documents_biens ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.taches ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.notes_biens ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.devis_travaux ENABLE ROW LEVEL SECURITY;
-- Politique : chaque utilisateur voit uniquement ses propres données
CREATE POLICY "user_own_data" ON public.profiles FOR ALL USING (auth.uid() = id);
CREATE POLICY "user_own_data" ON public.biens FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "user_own_data" ON public.etapes_pipeline FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "user_own_data" ON public.analyses_financieres FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "user_own_data" ON public.contacts FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "user_own_data" ON public.visites FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "user_own_data" ON public.taches FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "user_own_data" ON public.notes_biens FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "user_own_data" ON public.devis_travaux FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "user_own_data" ON public.photos_biens FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "user_own_data" ON public.documents_biens FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "user_own_data" ON public.checklist_items FOR ALL USING (auth.uid() = user_id);
-- biens_contacts : visible si l'utilisateur possède le bien
CREATE POLICY "user_own_biens_contacts" ON public.biens_contacts FOR ALL
USING (EXISTS (SELECT 1 FROM public.biens WHERE id = bien_id AND user_id = auth.uid()));
-- checklist_reponses : visible si l'utilisateur possède la visite
CREATE POLICY "user_own_checklist_reponses" ON public.checklist_reponses FOR ALL
USING (EXISTS (SELECT 1 FROM public.visites WHERE id = visite_id AND user_id = auth.uid()));
-- ============================================================
-- TRIGGER : updated_at automatique
-- ============================================================
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = NOW(); RETURN NEW; END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.biens FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.contacts FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.visites FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.taches FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.analyses_financieres FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.devis_travaux FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.profiles FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- ============================================================
-- TRIGGER : Créer profil + données par défaut à l'inscription
-- ============================================================
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER AS $$
DECLARE
etape_ids UUID[];
BEGIN
-- Créer le profil
INSERT INTO public.profiles (id) VALUES (NEW.id);
-- Créer les étapes du pipeline par défaut
INSERT INTO public.etapes_pipeline (user_id, nom, ordre, couleur) VALUES
(NEW.id, 'Piste', 1, '#6B7280'),
(NEW.id, 'En analyse', 2, '#3B82F6'),
(NEW.id, 'Offre faite', 3, '#F59E0B'),
(NEW.id, 'Compromis signé', 4, '#8B5CF6'),
(NEW.id, 'Acte signé', 5, '#10B981'),
(NEW.id, 'En travaux', 6, '#F97316'),
(NEW.id, 'En vente', 7, '#EC4899'),
(NEW.id, 'Vendu', 8, '#22C55E'),
(NEW.id, 'Abandonné', 9, '#EF4444');
-- Copier la check-list de visite par défaut
INSERT INTO public.checklist_items (user_id, categorie, question, description, ordre)
SELECT NEW.id, categorie, question, description, ordre
FROM public.checklist_items
WHERE user_id = '00000000-0000-0000-0000-000000000000';
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
-- ============================================================
-- INDEX pour les performances
-- ============================================================
CREATE INDEX idx_biens_user_id ON public.biens(user_id);
CREATE INDEX idx_biens_etape_id ON public.biens(etape_id);
CREATE INDEX idx_biens_ville ON public.biens(ville);
CREATE INDEX idx_contacts_user_id ON public.contacts(user_id);
CREATE INDEX idx_contacts_categorie ON public.contacts(categorie);
CREATE INDEX idx_taches_user_id ON public.taches(user_id);
CREATE INDEX idx_taches_date_echeance ON public.taches(date_echeance);
CREATE INDEX idx_visites_bien_id ON public.visites(bien_id);

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,
}));
}

Some files were not shown because too many files have changed in this diff Show More