init
97
.cursorrules
Normal 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
|
||||||
|
```
|
||||||
127
AGENTS.md
Normal 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
@ -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
@ -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
|
||||||
|
```
|
||||||
18
app.json
@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "mdb",
|
"name": "MDB-Turbo",
|
||||||
"slug": "mdb",
|
"slug": "mdb-turbo",
|
||||||
|
"scheme": "mdb-turbo",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
@ -25,6 +26,17 @@
|
|||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
}
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"expo-font",
|
||||||
|
[
|
||||||
|
"expo-notifications",
|
||||||
|
{
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"color": "#3d8bfd"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
app/(tabs)/_layout.tsx
Normal 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
@ -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 l’accueil, 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} m²`}
|
||||||
|
</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 },
|
||||||
|
},
|
||||||
|
});
|
||||||
234
app/(tabs)/investisseurs.tsx
Normal 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
@ -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 l’appareil.'}
|
||||||
|
{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 l’enregistrement.');
|
||||||
|
} 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
@ -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
@ -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 d’abord 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
@ -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 d’abord 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="S’inscrire"
|
||||||
|
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="J’ai 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
@ -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 l’onglet Feu pour
|
||||||
|
activer le teaser investisseur.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.cardTitle}>Investisseurs (top 5 match)</Text>
|
||||||
|
{matches.length === 0 ? (
|
||||||
|
<Text style={styles.muted}>Aucun match — ajustez critères ou dossier.</Text>
|
||||||
|
) : (
|
||||||
|
matches.map((m) => (
|
||||||
|
<View key={m.id} style={styles.matchRow}>
|
||||||
|
<Text style={styles.matchName}>{m.display_name}</Text>
|
||||||
|
{m.email ? (
|
||||||
|
<Text style={styles.muted}>{m.email}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<PrimaryButton
|
||||||
|
title="Générer & partager le teaser PDF"
|
||||||
|
containerStyle={{ marginTop: 16 }}
|
||||||
|
onPress={() =>
|
||||||
|
void shareTeaserPdf(
|
||||||
|
dossier,
|
||||||
|
juge.result,
|
||||||
|
matches.map((m) => m.display_name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.rowLabel}>{label}</Text>
|
||||||
|
<Text style={styles.rowValue}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FinancesEditor({
|
||||||
|
dossier,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
dossier: DossierRow;
|
||||||
|
onSave: (patch: Partial<DossierRow>) => void;
|
||||||
|
}) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [title, setTitle] = useState(dossier.title);
|
||||||
|
const [address, setAddress] = useState(dossier.address_line ?? '');
|
||||||
|
const [city, setCity] = useState(dossier.city ?? '');
|
||||||
|
const [postal, setPostal] = useState(dossier.postal_code ?? '');
|
||||||
|
const [surface, setSurface] = useState(String(dossier.surface_m2 ?? ''));
|
||||||
|
const [purchase, setPurchase] = useState(String(dossier.purchase_price_target ?? ''));
|
||||||
|
const [resale, setResale] = useState(String(dossier.resale_price_estimate ?? ''));
|
||||||
|
const [dvf, setDvf] = useState(String(dossier.dvf_reference_price_m2 ?? ''));
|
||||||
|
const [works, setWorks] = useState(String(dossier.works_estimate_total ?? ''));
|
||||||
|
const [miscA, setMiscA] = useState(String(dossier.misc_acquisition_cost ?? ''));
|
||||||
|
const [miscS, setMiscS] = useState(String(dossier.misc_sale_cost ?? ''));
|
||||||
|
const [carryM, setCarryM] = useState(String(dossier.carrying_months ?? 6));
|
||||||
|
const [carryR, setCarryR] = useState(String(dossier.carrying_annual_rate ?? 0.05));
|
||||||
|
const [dpe, setDpe] = useState(dossier.dpe_class ?? '');
|
||||||
|
const [pluZone, setPluZone] = useState(dossier.plu_zone_code ?? '');
|
||||||
|
const [pluNotes, setPluNotes] = useState(dossier.plu_notes ?? '');
|
||||||
|
const [parcelDiv, setParcelDiv] = useState(dossier.parcel_subdivision_candidate);
|
||||||
|
const [deficitFoncier, setDeficitFoncier] = useState(
|
||||||
|
dossier.deficit_foncier_candidate,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTitle(dossier.title);
|
||||||
|
setAddress(dossier.address_line ?? '');
|
||||||
|
setCity(dossier.city ?? '');
|
||||||
|
setPostal(dossier.postal_code ?? '');
|
||||||
|
setSurface(String(dossier.surface_m2 ?? ''));
|
||||||
|
setPurchase(String(dossier.purchase_price_target ?? ''));
|
||||||
|
setResale(String(dossier.resale_price_estimate ?? ''));
|
||||||
|
setDvf(String(dossier.dvf_reference_price_m2 ?? ''));
|
||||||
|
setWorks(String(dossier.works_estimate_total ?? ''));
|
||||||
|
setMiscA(String(dossier.misc_acquisition_cost ?? ''));
|
||||||
|
setMiscS(String(dossier.misc_sale_cost ?? ''));
|
||||||
|
setCarryM(String(dossier.carrying_months ?? 6));
|
||||||
|
setCarryR(String(dossier.carrying_annual_rate ?? 0.05));
|
||||||
|
setDpe(dossier.dpe_class ?? '');
|
||||||
|
setPluZone(dossier.plu_zone_code ?? '');
|
||||||
|
setPluNotes(dossier.plu_notes ?? '');
|
||||||
|
setParcelDiv(dossier.parcel_subdivision_candidate);
|
||||||
|
setDeficitFoncier(dossier.deficit_foncier_candidate);
|
||||||
|
}, [
|
||||||
|
dossier.id,
|
||||||
|
dossier.updated_at,
|
||||||
|
dossier.title,
|
||||||
|
dossier.address_line,
|
||||||
|
dossier.city,
|
||||||
|
dossier.postal_code,
|
||||||
|
dossier.surface_m2,
|
||||||
|
dossier.purchase_price_target,
|
||||||
|
dossier.resale_price_estimate,
|
||||||
|
dossier.dvf_reference_price_m2,
|
||||||
|
dossier.works_estimate_total,
|
||||||
|
dossier.misc_acquisition_cost,
|
||||||
|
dossier.misc_sale_cost,
|
||||||
|
dossier.carrying_months,
|
||||||
|
dossier.carrying_annual_rate,
|
||||||
|
dossier.dpe_class,
|
||||||
|
dossier.plu_zone_code,
|
||||||
|
dossier.plu_notes,
|
||||||
|
dossier.parcel_subdivision_candidate,
|
||||||
|
dossier.deficit_foncier_candidate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const parseNum = (s: string) => Number(s.replace(',', '.').replace(/\s/g, ''));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
padding: 16,
|
||||||
|
paddingBottom: insets.bottom + 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LabeledField label="Titre du dossier" value={title} onChangeText={setTitle} />
|
||||||
|
<LabeledField label="Adresse" value={address} onChangeText={setAddress} />
|
||||||
|
<LabeledField label="Ville" value={city} onChangeText={setCity} />
|
||||||
|
<LabeledField label="Code postal" value={postal} onChangeText={setPostal} />
|
||||||
|
<LabeledField
|
||||||
|
label="Surface (m²)"
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
value={surface}
|
||||||
|
onChangeText={setSurface}
|
||||||
|
/>
|
||||||
|
<LabeledField
|
||||||
|
label="Prix d'achat cible (€)"
|
||||||
|
keyboardType="number-pad"
|
||||||
|
value={purchase}
|
||||||
|
onChangeText={setPurchase}
|
||||||
|
/>
|
||||||
|
<LabeledField
|
||||||
|
label="Prix de revente estimé (€)"
|
||||||
|
keyboardType="number-pad"
|
||||||
|
value={resale}
|
||||||
|
onChangeText={setResale}
|
||||||
|
/>
|
||||||
|
<LabeledField
|
||||||
|
label="DVF réf. (€/m²)"
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
value={dvf}
|
||||||
|
onChangeText={setDvf}
|
||||||
|
/>
|
||||||
|
<LabeledField
|
||||||
|
label="Travaux estimés hors checklist (€)"
|
||||||
|
keyboardType="number-pad"
|
||||||
|
value={works}
|
||||||
|
onChangeText={setWorks}
|
||||||
|
/>
|
||||||
|
<LabeledField
|
||||||
|
label="Frais d'achat divers (€)"
|
||||||
|
keyboardType="number-pad"
|
||||||
|
value={miscA}
|
||||||
|
onChangeText={setMiscA}
|
||||||
|
/>
|
||||||
|
<LabeledField
|
||||||
|
label="Frais de vente divers (€)"
|
||||||
|
keyboardType="number-pad"
|
||||||
|
value={miscS}
|
||||||
|
onChangeText={setMiscS}
|
||||||
|
/>
|
||||||
|
<LabeledField
|
||||||
|
label="Portage (mois)"
|
||||||
|
keyboardType="number-pad"
|
||||||
|
value={carryM}
|
||||||
|
onChangeText={setCarryM}
|
||||||
|
/>
|
||||||
|
<LabeledField
|
||||||
|
label="Taux portage annuel (ex: 0.055)"
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
value={carryR}
|
||||||
|
onChangeText={setCarryR}
|
||||||
|
/>
|
||||||
|
<LabeledField
|
||||||
|
label="DPE (A–G)"
|
||||||
|
autoCapitalize="characters"
|
||||||
|
maxLength={1}
|
||||||
|
value={dpe}
|
||||||
|
onChangeText={(t) => setDpe(t.toUpperCase())}
|
||||||
|
/>
|
||||||
|
<Text style={styles.sectionLabel}>Urbanisme & stratégie</Text>
|
||||||
|
<LabeledField
|
||||||
|
label="Zone PLU (libellé ou code)"
|
||||||
|
value={pluZone}
|
||||||
|
onChangeText={setPluZone}
|
||||||
|
/>
|
||||||
|
<LabeledField
|
||||||
|
label="Notes urbanisme (servitude, COS…)"
|
||||||
|
value={pluNotes}
|
||||||
|
onChangeText={setPluNotes}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
<View style={styles.switchRow}>
|
||||||
|
<Text style={styles.switchLabel}>Piste division parcellaire</Text>
|
||||||
|
<Switch value={parcelDiv} onValueChange={setParcelDiv} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.switchRow}>
|
||||||
|
<Text style={styles.switchLabel}>Piste déficit foncier (passoire)</Text>
|
||||||
|
<Switch value={deficitFoncier} onValueChange={setDeficitFoncier} />
|
||||||
|
</View>
|
||||||
|
<PrimaryButton
|
||||||
|
title="Enregistrer les finances"
|
||||||
|
onPress={() => {
|
||||||
|
onSave({
|
||||||
|
title: title.trim(),
|
||||||
|
address_line: address.trim() || null,
|
||||||
|
city: city.trim() || null,
|
||||||
|
postal_code: postal.trim() || null,
|
||||||
|
surface_m2: parseNum(surface) || null,
|
||||||
|
purchase_price_target: parseNum(purchase) || null,
|
||||||
|
resale_price_estimate: parseNum(resale) || null,
|
||||||
|
dvf_reference_price_m2: parseNum(dvf) || null,
|
||||||
|
works_estimate_total: parseNum(works) || null,
|
||||||
|
misc_acquisition_cost: parseNum(miscA) || null,
|
||||||
|
misc_sale_cost: parseNum(miscS) || null,
|
||||||
|
carrying_months: Math.round(parseNum(carryM) || 6),
|
||||||
|
carrying_annual_rate: parseNum(carryR) || 0.05,
|
||||||
|
dpe_class: dpe || null,
|
||||||
|
plu_zone_code: pluZone.trim() || null,
|
||||||
|
plu_notes: pluNotes.trim() || null,
|
||||||
|
parcel_subdivision_candidate: parcelDiv,
|
||||||
|
deficit_foncier_candidate: deficitFoncier,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VisiteTab({
|
||||||
|
definitions,
|
||||||
|
findings,
|
||||||
|
onToggle,
|
||||||
|
checklistEUR,
|
||||||
|
maxPurchase,
|
||||||
|
}: {
|
||||||
|
definitions: VisitFindingDefinitionRow[];
|
||||||
|
findings: DossierVisitFindingRow[];
|
||||||
|
onToggle: (code: string, checked: boolean) => void;
|
||||||
|
checklistEUR: number;
|
||||||
|
maxPurchase: number;
|
||||||
|
}) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const rows = definitions.map((def) => {
|
||||||
|
const f = findings.find((x) => x.finding_code === def.code);
|
||||||
|
return { def, f };
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
padding: 16,
|
||||||
|
paddingBottom: insets.bottom + 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Anti-erreur visite</Text>
|
||||||
|
<Text style={styles.muted}>
|
||||||
|
Cochez les points noirs : l’app ajoute les travaux associés et recalcule
|
||||||
|
le prix d’achat max pour rester à {(MIN_NET_MARGIN_PCT * 100).toFixed(0)}{' '}
|
||||||
|
% de marge nette.
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.highlight}>
|
||||||
|
Travaux checklist : {checklistEUR.toLocaleString('fr-FR')} €
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.highlight}>
|
||||||
|
Prix d’achat max (cible marge) :{' '}
|
||||||
|
{maxPurchase.toLocaleString('fr-FR')} €
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{rows.map(({ def, f }) => {
|
||||||
|
const checked = f?.checked ?? false;
|
||||||
|
return (
|
||||||
|
<View key={def.code} style={styles.visitRow}>
|
||||||
|
<View style={{ flex: 1, paddingRight: 12 }}>
|
||||||
|
<Text style={styles.visitLabel}>{def.label}</Text>
|
||||||
|
<Text style={styles.muted}>
|
||||||
|
+{(f?.works_delta_override_eur ?? def.default_works_delta_eur).toLocaleString('fr-FR')}{' '}
|
||||||
|
€ si coché
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={checked}
|
||||||
|
onValueChange={(v) => onToggle(def.code, v)}
|
||||||
|
trackColor={{ true: colors.accent, false: colors.border }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
|
||||||
|
muted: { color: colors.textMuted, lineHeight: 20 },
|
||||||
|
tabBar: { maxHeight: 52, borderBottomWidth: 1, borderBottomColor: colors.border },
|
||||||
|
tabBarInner: { paddingHorizontal: 12, paddingVertical: 10, gap: 8, alignItems: 'center' },
|
||||||
|
tabChip: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: colors.bgCard,
|
||||||
|
marginRight: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
tabChipOn: { borderColor: colors.accent, backgroundColor: '#15233d' },
|
||||||
|
tabText: { color: colors.textMuted, fontWeight: '600' },
|
||||||
|
tabTextOn: { color: colors.text },
|
||||||
|
hero: { borderRadius: 16, padding: 20, marginBottom: 16 },
|
||||||
|
heroLabel: { color: colors.textMuted, fontSize: 12, textTransform: 'uppercase' },
|
||||||
|
heroScore: { fontSize: 44, fontWeight: '800', color: colors.text, marginVertical: 8 },
|
||||||
|
heroSub: { color: colors.textMuted, marginTop: 4 },
|
||||||
|
card: {
|
||||||
|
backgroundColor: colors.bgCard,
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
cardTitle: { color: colors.text, fontSize: 18, fontWeight: '700', marginBottom: 12 },
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 10,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
rowLabel: { color: colors.textMuted, flex: 1 },
|
||||||
|
rowValue: { color: colors.text, fontWeight: '600' },
|
||||||
|
highlight: { color: colors.flash, marginTop: 8, fontWeight: '600' },
|
||||||
|
visitRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
visitLabel: { color: colors.text, fontWeight: '600' },
|
||||||
|
matchRow: {
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
matchName: { color: colors.text, fontWeight: '700', fontSize: 16 },
|
||||||
|
sectionLabel: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.06,
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
switchRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 14,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
switchLabel: { color: colors.text, flex: 1, paddingRight: 12 },
|
||||||
|
});
|
||||||
112
app/index.tsx
Normal 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 l’appareil)"
|
||||||
|
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
@ -0,0 +1,7 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
plugins: ['expo-router/babel'],
|
||||||
|
};
|
||||||
|
};
|
||||||
8
index.ts
@ -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
@ -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
@ -0,0 +1 @@
|
|||||||
|
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||||
7
mb-app/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit",
|
||||||
|
"source.organizeImports": "explicit",
|
||||||
|
"source.sortMembers": "explicit"
|
||||||
|
}
|
||||||
|
}
|
||||||
45
mb-app/app.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
mb-app/app/(tabs)/_layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
mb-app/app/(tabs)/index.tsx
Normal 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
@ -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
@ -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
@ -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
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
mb-app/app/auth/_layout.tsx
Normal 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
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
mb-app/app/auth/register.tsx
Normal 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'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
@ -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
@ -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%',
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
mb-app/assets/fonts/SpaceMono-Regular.ttf
Normal file
BIN
mb-app/assets/images/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
mb-app/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
mb-app/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
mb-app/assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
9
mb-app/babel.config.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: [
|
||||||
|
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
|
||||||
|
'nativewind/babel',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
77
mb-app/components/EditScreenInfo.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
24
mb-app/components/ExternalLink.tsx
Normal 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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
mb-app/components/StyledText.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { Text, TextProps } from './Themed';
|
||||||
|
|
||||||
|
export function MonoText(props: TextProps) {
|
||||||
|
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
|
||||||
|
}
|
||||||
45
mb-app/components/Themed.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
10
mb-app/components/__tests__/StyledText-test.js
Normal 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();
|
||||||
|
});
|
||||||
4
mb-app/components/useClientOnlyValue.ts
Normal 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;
|
||||||
|
}
|
||||||
12
mb-app/components/useClientOnlyValue.web.ts
Normal 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;
|
||||||
|
}
|
||||||
1
mb-app/components/useColorScheme.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { useColorScheme } from 'react-native';
|
||||||
8
mb-app/components/useColorScheme.web.ts
Normal 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';
|
||||||
|
}
|
||||||
19
mb-app/constants/Colors.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
4
mb-app/constants/metier.ts
Normal 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;
|
||||||
121
mb-app/context/AuthContext.tsx
Normal 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
@ -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
@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
10
mb-app/lib/query-client.ts
Normal 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
@ -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
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="nativewind/types" />
|
||||||
11091
mb-app/package-lock.json
generated
Normal file
52
mb-app/package.json
Normal 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
|
||||||
|
}
|
||||||
22
mb-app/services/supabase.ts
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -0,0 +1 @@
|
|||||||
|
legacy-peer-deps=true
|
||||||
32
mdb-predator/app.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
26
mdb-predator/app/_layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
mdb-predator/app/field.tsx
Normal 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
@ -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 d’achat 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 },
|
||||||
|
});
|
||||||
BIN
mdb-predator/assets/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
mdb-predator/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
mdb-predator/assets/icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
mdb-predator/assets/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
7
mdb-predator/babel.config.js
Normal 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
38
mdb-predator/package.json
Normal 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
|
||||||
|
}
|
||||||
63
mdb-predator/src/agents/orchestrator.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
28
mdb-predator/src/agents/types.ts
Normal 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;
|
||||||
|
}
|
||||||
150
mdb-predator/src/components/FieldVisitChecklist.tsx
Normal 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 l’appareil (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',
|
||||||
|
},
|
||||||
|
});
|
||||||
148
mdb-predator/src/core/dealAnalysis.ts
Normal 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));
|
||||||
|
}
|
||||||
15
mdb-predator/src/lib/supabase.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
21
mdb-predator/src/offline/fieldVisitStorage.ts
Normal 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));
|
||||||
|
}
|
||||||
17
mdb-predator/src/services/apifyIntegration.ts
Normal 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 [];
|
||||||
|
}
|
||||||
16
mdb-predator/src/services/dvfDataGouv.ts
Normal 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 l’API tabulaire.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DvfStreetContext {
|
||||||
|
inseeCode: string;
|
||||||
|
streetNormalized: string;
|
||||||
|
yearMin?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDvfMedianPriceM2Stub(
|
||||||
|
_ctx: DvfStreetContext,
|
||||||
|
): Promise<number | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
32
mdb-predator/src/services/offerPdf.ts
Normal 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 d’achat' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escape(s: string): string {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||||
|
}
|
||||||
10
mdb-predator/src/services/openStreetMap.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/** Nominatim / OSM — respecter la politique d’usage ; préférer backend pour prod. */
|
||||||
|
|
||||||
|
export interface GeoPoint {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reverseGeocodeStub(_p: GeoPoint): Promise<string | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
5
mdb-predator/src/services/pappers.ts
Normal 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 [];
|
||||||
|
}
|
||||||
10
mdb-predator/src/theme/colors.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export const colors = {
|
||||||
|
bg: '#070b10',
|
||||||
|
card: '#101820',
|
||||||
|
border: '#1f2a36',
|
||||||
|
text: '#f2f6fb',
|
||||||
|
muted: '#8b9bb0',
|
||||||
|
accent: '#ff4d6d',
|
||||||
|
danger: '#ff3355',
|
||||||
|
ok: '#3ecf8e',
|
||||||
|
};
|
||||||
@ -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);
|
||||||
6
mdb-predator/tsconfig.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
}
|
||||||
2316
package-lock.json
generated
26
package.json
@ -1,18 +1,38 @@
|
|||||||
{
|
{
|
||||||
"name": "mdb",
|
"name": "mdb-turbo",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.ts",
|
"main": "expo-router/entry",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"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",
|
"android": "expo start --android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo start --ios",
|
||||||
"web": "expo start --web"
|
"web": "expo start --web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "~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",
|
"expo-status-bar": "~3.0.9",
|
||||||
"react": "19.1.0",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
|
|||||||
489
schema.sql
Normal 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);
|
||||||
40
src/components/LabeledField.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
77
src/components/PrimaryButton.tsx
Normal 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
@ -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;
|
||||||
|
}
|
||||||
75
src/context/persistence.ts
Normal 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
@ -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';
|
||||||
@ -1,16 +1,16 @@
|
|||||||
|
import type { DealTrafficLight } from '../../domain/dealSignals';
|
||||||
import {
|
import {
|
||||||
DEFAULT_VAT_ON_MARGIN_RATE,
|
DEFAULT_VAT_ON_MARGIN_RATE,
|
||||||
DVF_DISCOUNT_FLASH_PCT,
|
DVF_DISCOUNT_FLASH_PCT,
|
||||||
MIN_NET_MARGIN_PCT,
|
MIN_NET_MARGIN_PCT,
|
||||||
} from './thresholds';
|
} from './thresholds';
|
||||||
|
|
||||||
export type DealTrafficLight = 'red' | 'orange' | 'green' | 'green_flash_dvf';
|
export type { DealTrafficLight };
|
||||||
|
|
||||||
export interface JugeInputs {
|
export interface JugeInputs {
|
||||||
purchasePrice: number;
|
purchasePrice: number;
|
||||||
resalePrice: number;
|
resalePrice: number;
|
||||||
surfaceM2: number;
|
surfaceM2: number;
|
||||||
/** Prix m² de référence marché (DVF / étude locale). */
|
|
||||||
dvfReferencePriceM2?: number | null;
|
dvfReferencePriceM2?: number | null;
|
||||||
worksTotal: number;
|
worksTotal: number;
|
||||||
notaryFeeRate: number;
|
notaryFeeRate: number;
|
||||||
@ -20,11 +20,6 @@ export interface JugeInputs {
|
|||||||
carryingMonths: number;
|
carryingMonths: number;
|
||||||
carryingAnnualRate: number;
|
carryingAnnualRate: number;
|
||||||
carryingPrincipal?: number | null;
|
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;
|
vatOnMarginRate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,8 +33,9 @@ export interface JugeResult {
|
|||||||
breakEvenResalePrice: number;
|
breakEvenResalePrice: number;
|
||||||
purchasePricePerM2: number;
|
purchasePricePerM2: number;
|
||||||
dvfDiscountPct: number | null;
|
dvfDiscountPct: number | null;
|
||||||
|
/** Sous-cotation vs DVF (prix / m² ≤ référence × (1 − 20 %)). */
|
||||||
|
dvfUnderMarketFlash: boolean;
|
||||||
trafficLight: DealTrafficLight;
|
trafficLight: DealTrafficLight;
|
||||||
/** 0–100 : marge nette normalisée vs seuil + bonus sous-cotation DVF. */
|
|
||||||
scoreDeal: number;
|
scoreDeal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,9 +47,6 @@ function carryingCostEUR(input: JugeInputs): number {
|
|||||||
return principal * input.carryingAnnualRate * (input.carryingMonths / 12);
|
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 {
|
export function evaluateDeal(input: JugeInputs): JugeResult {
|
||||||
const vatRate = input.vatOnMarginRate ?? DEFAULT_VAT_ON_MARGIN_RATE;
|
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;
|
input.surfaceM2 > 0 ? input.purchasePrice / input.surfaceM2 : 0;
|
||||||
|
|
||||||
let dvfDiscountPct: number | null = null;
|
let dvfDiscountPct: number | null = null;
|
||||||
|
let dvfUnderMarketFlash = false;
|
||||||
if (
|
if (
|
||||||
input.dvfReferencePriceM2 != null &&
|
input.dvfReferencePriceM2 != null &&
|
||||||
input.dvfReferencePriceM2 > 0 &&
|
input.dvfReferencePriceM2 > 0 &&
|
||||||
@ -89,13 +83,15 @@ export function evaluateDeal(input: JugeInputs): JugeResult {
|
|||||||
dvfDiscountPct =
|
dvfDiscountPct =
|
||||||
(input.dvfReferencePriceM2 - purchasePricePerM2) /
|
(input.dvfReferencePriceM2 - purchasePricePerM2) /
|
||||||
input.dvfReferencePriceM2;
|
input.dvfReferencePriceM2;
|
||||||
|
dvfUnderMarketFlash =
|
||||||
|
purchasePricePerM2 <=
|
||||||
|
input.dvfReferencePriceM2 * (1 - DVF_DISCOUNT_FLASH_PCT);
|
||||||
}
|
}
|
||||||
|
|
||||||
const trafficLight = resolveTrafficLight(
|
const trafficLight = resolveTrafficLight(
|
||||||
netMarginPct,
|
netMarginPct,
|
||||||
|
dvfUnderMarketFlash,
|
||||||
dvfDiscountPct,
|
dvfDiscountPct,
|
||||||
purchasePricePerM2,
|
|
||||||
input.dvfReferencePriceM2,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const scoreDeal = computeScoreDeal(netMarginPct, dvfDiscountPct);
|
const scoreDeal = computeScoreDeal(netMarginPct, dvfDiscountPct);
|
||||||
@ -107,35 +103,34 @@ export function evaluateDeal(input: JugeInputs): JugeResult {
|
|||||||
vatOnMargin,
|
vatOnMargin,
|
||||||
netMarginAfterVat,
|
netMarginAfterVat,
|
||||||
netMarginPct,
|
netMarginPct,
|
||||||
breakEvenResalePrice: computeBreakEvenResale(input, vatRate),
|
breakEvenResalePrice: computeBreakEvenResale(input),
|
||||||
purchasePricePerM2,
|
purchasePricePerM2,
|
||||||
dvfDiscountPct,
|
dvfDiscountPct,
|
||||||
|
dvfUnderMarketFlash,
|
||||||
trafficLight,
|
trafficLight,
|
||||||
scoreDeal,
|
scoreDeal,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Marge : feu rouge sous 15 %. DVF : flash vert indépendant (signal d’achat). */
|
||||||
function resolveTrafficLight(
|
function resolveTrafficLight(
|
||||||
netMarginPct: number,
|
netMarginPct: number,
|
||||||
|
dvfUnderMarketFlash: boolean,
|
||||||
dvfDiscountPct: number | null,
|
dvfDiscountPct: number | null,
|
||||||
purchasePricePerM2: number,
|
|
||||||
dvfReferencePriceM2?: number | null,
|
|
||||||
): DealTrafficLight {
|
): DealTrafficLight {
|
||||||
const underDvfFlash =
|
|
||||||
dvfReferencePriceM2 != null &&
|
|
||||||
dvfReferencePriceM2 > 0 &&
|
|
||||||
purchasePricePerM2 <= dvfReferencePriceM2 * (1 - DVF_DISCOUNT_FLASH_PCT);
|
|
||||||
|
|
||||||
if (netMarginPct < MIN_NET_MARGIN_PCT) {
|
if (netMarginPct < MIN_NET_MARGIN_PCT) {
|
||||||
return underDvfFlash ? 'green_flash_dvf' : 'red';
|
return 'red';
|
||||||
}
|
}
|
||||||
if (underDvfFlash) {
|
if (dvfUnderMarketFlash) {
|
||||||
return 'green_flash_dvf';
|
return 'green_flash_dvf';
|
||||||
}
|
}
|
||||||
if (dvfDiscountPct != null && dvfDiscountPct > 0.1) {
|
if (dvfDiscountPct != null && dvfDiscountPct > 0.1) {
|
||||||
return 'green';
|
return 'green';
|
||||||
}
|
}
|
||||||
return 'orange';
|
if (netMarginPct < 0.18) {
|
||||||
|
return 'orange';
|
||||||
|
}
|
||||||
|
return 'green';
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeScoreDeal(
|
function computeScoreDeal(
|
||||||
@ -153,7 +148,7 @@ function computeScoreDeal(
|
|||||||
return Math.round(Math.min(100, marginScore + dvfBonus));
|
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 notaryFees = input.purchasePrice * input.notaryFeeRate;
|
||||||
const carrying = carryingCostEUR(input);
|
const carrying = carryingCostEUR(input);
|
||||||
const fixedCosts =
|
const fixedCosts =
|
||||||
@ -165,83 +160,25 @@ function computeBreakEvenResale(input: JugeInputs, vatRate: number): number {
|
|||||||
input.miscSaleCost;
|
input.miscSaleCost;
|
||||||
|
|
||||||
const agencyRate = input.saleAgencyFeeRate;
|
const agencyRate = input.saleAgencyFeeRate;
|
||||||
const effectiveCoeff = 1 - agencyRate - vatRate * (1 - agencyRate);
|
const coeff = 1 - agencyRate;
|
||||||
if (effectiveCoeff <= 0) {
|
if (coeff <= 0) {
|
||||||
return Number.POSITIVE_INFINITY;
|
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).
|
* Prix d’achat max pour tenir une marge nette cible (ex. 15 % après TVA sur marge).
|
||||||
* Utile quand la checklist visite augmente les travaux : recalcul offre max.
|
* Recalcul instantané quand la checklist visite augmente les travaux.
|
||||||
*/
|
*/
|
||||||
export function maxPurchaseForTargetNetMarginPct(
|
export function maxPurchaseForTargetNetMarginPct(
|
||||||
input: Omit<JugeInputs, 'purchasePrice'>,
|
input: Omit<JugeInputs, 'purchasePrice'>,
|
||||||
targetNetMarginPct: number,
|
targetNetMarginPct: number,
|
||||||
): number {
|
): number {
|
||||||
const vatRate = input.vatOnMarginRate ?? DEFAULT_VAT_ON_MARGIN_RATE;
|
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 low = 0;
|
||||||
let high = input.resalePrice * 1.5;
|
let high = Math.max(input.resalePrice * 1.5, 1);
|
||||||
for (let i = 0; i < 48; i++) {
|
for (let i = 0; i < 56; i++) {
|
||||||
const mid = (low + high) / 2;
|
const mid = (low + high) / 2;
|
||||||
const { netMarginPct } = evaluateDeal({
|
const { netMarginPct } = evaluateDeal({
|
||||||
...input,
|
...input,
|
||||||
|
|||||||
91
src/data/dealSource.ts
Normal 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
@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||