144
.cursorrules
@ -1,97 +1,63 @@
|
||||
# Contexte projet — Application Marchand de Biens
|
||||
# Application Marchand de Biens — Contexte Cursor
|
||||
|
||||
## 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.).
|
||||
## Infrastructure
|
||||
- Backend : PocketBase dans Docker (docker/docker-compose.dev.yml)
|
||||
- Binaire PocketBase : /usr/local/bin/pocketbase
|
||||
- Données : /pb_data (flag --dir=/pb_data dans docker-compose)
|
||||
- OS dev : Windows Git Bash → toujours utiliser MSYS_NO_PATHCONV=1 pour docker exec
|
||||
- PocketBase version : v0.23+
|
||||
- URL locale : http://localhost:8090
|
||||
- URL prod : https://SOUS_DOMAINE.duckdns.org (NAS Synology)
|
||||
|
||||
## 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
|
||||
- Frontend : React Native avec Expo SDK 51 + Expo Router
|
||||
- SDK PocketBase : npm package "pocketbase"
|
||||
- UI : NativeWind (Tailwind pour React Native)
|
||||
- State : Zustand + React Query (TanStack)
|
||||
- IA : API Anthropic Claude via PocketBase Hook (jamais côté client)
|
||||
- Déploiement mobile : Expo EAS
|
||||
|
||||
## 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
|
||||
## Client PocketBase — pattern obligatoire
|
||||
// /services/pocketbase.ts — singleton, importer partout
|
||||
import PocketBase from 'pocketbase';
|
||||
export const pb = new PocketBase(process.env.EXPO_PUBLIC_PB_URL);
|
||||
|
||||
## 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
|
||||
## Collections PocketBase (toutes créées via migration)
|
||||
etapes_pipeline, contacts, biens, analyses_financieres,
|
||||
visites, taches, notes_biens, documents_biens, devis_travaux
|
||||
|
||||
## 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
|
||||
## Règles de code
|
||||
- TypeScript strict, jamais de any
|
||||
- Appels PocketBase UNIQUEMENT dans les hooks (/hooks/)
|
||||
- Jamais de nouvelle instance PocketBase, toujours importer pb
|
||||
- Commentaires métier en français, code en anglais
|
||||
- Gérer loading + erreur partout
|
||||
|
||||
## 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
|
||||
## Vocabulaire métier
|
||||
- Bien : propriété immobilière prospectée ou acquise
|
||||
- Piste : bien en phase d'analyse
|
||||
- Portage : période entre achat et revente
|
||||
- Marge brute : prix revente - prix achat - travaux - frais notaire
|
||||
- Marge nette : marge brute - portage - frais agence - impôts
|
||||
|
||||
## Formules financières
|
||||
frais_notaire = prix_achat * 0.075 (ancien) ou * 0.02 (neuf)
|
||||
frais_portage_total = (prix_achat * taux_credit/100/12 + taxe_fonciere/12 + charges) * duree_mois
|
||||
travaux_total = budget_travaux * (1 + reserve_pct/100)
|
||||
prix_revient = prix_achat + frais_notaire + frais_agence_achat + travaux_total + frais_portage_total
|
||||
marge_brute = prix_revente - prix_revient
|
||||
marge_nette = marge_brute - (prix_revente * frais_agence_vente_pct/100) - (marge_brute * taux_impot/100)
|
||||
|
||||
## Structure dossiers
|
||||
/docker → docker-compose dev + prod
|
||||
/pocketbase
|
||||
/pb_data → données (dans .gitignore)
|
||||
/pb_hooks → hooks JS côté serveur (IA)
|
||||
/pb_migrations → migrations auto au démarrage
|
||||
/app → code Expo React Native
|
||||
/app → écrans Expo Router
|
||||
/components → composants UI
|
||||
/hooks → hooks métier
|
||||
/services → pocketbase.ts
|
||||
/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
|
||||
```
|
||||
/constants → constantes métier
|
||||
|
||||
12
.env.example
Normal file
@ -0,0 +1,12 @@
|
||||
# Dev local
|
||||
EXPO_PUBLIC_PB_URL=http://localhost:8090
|
||||
|
||||
# Production NAS (décommenter)
|
||||
# EXPO_PUBLIC_PB_URL=https://VOTRE_SOUS_DOMAINE.duckdns.org
|
||||
|
||||
# Clé IA (jamais dans Git)
|
||||
ANTHROPIC_API_KEY=sk-ant-VOTRE_CLE
|
||||
|
||||
# DuckDNS (production)
|
||||
DUCKDNS_SUBDOMAINS=VOTRE_SOUS_DOMAINE
|
||||
DUCKDNS_TOKEN=VOTRE_TOKEN
|
||||
43
.gitignore
vendored
@ -1,41 +1,8 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
pocketbase/pb_data/
|
||||
.env.local
|
||||
.env.production
|
||||
*.env
|
||||
docker/ssl/
|
||||
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
|
||||
|
||||
146
AGENTS.md
@ -1,127 +1,23 @@
|
||||
# AGENTS — Suivi des sessions Cursor
|
||||
# AGENTS — Suivi du projet
|
||||
|
||||
> 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 actuel
|
||||
- [x] Docker + PocketBase configuré et lancé
|
||||
- [x] Migration collections créée
|
||||
- [ ] Migration appliquée avec succès
|
||||
- [ ] App Expo initialisée
|
||||
- [ ] Auth fonctionnelle
|
||||
- [ ] Navigation complète
|
||||
- [ ] Module Prospection
|
||||
- [ ] Module Fiche bien + Calculateur
|
||||
- [ ] Module Contacts
|
||||
- [ ] Module Visites + IA
|
||||
- [ ] Module Agenda
|
||||
- [ ] Dashboard
|
||||
|
||||
---
|
||||
|
||||
## É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)_
|
||||
|
||||
-
|
||||
## Infos techniques
|
||||
- PocketBase : http://localhost:8090
|
||||
- Admin : http://localhost:8090/_/ (admin@mdb.fr)
|
||||
- Binaire : /usr/local/bin/pocketbase
|
||||
- Données : /pb_data
|
||||
- OS : Windows Git Bash (MSYS_NO_PATHCONV=1)
|
||||
- PocketBase : v0.23+
|
||||
|
||||
116
Makefile
@ -1,116 +0,0 @@
|
||||
# ============================================================
|
||||
# Makefile — Raccourcis pour le projet mb-app
|
||||
# Usage : make <commande>
|
||||
# ============================================================
|
||||
|
||||
.PHONY: help dev dev-stop prod prod-stop logs status setup-nas deploy
|
||||
|
||||
# Affiche l'aide
|
||||
help:
|
||||
@echo ""
|
||||
@echo " Développement local"
|
||||
@echo " ─────────────────────────────────────────"
|
||||
@echo " make dev → Lance PocketBase en local"
|
||||
@echo " make dev-stop → Arrête le conteneur local"
|
||||
@echo " make logs → Logs PocketBase en temps réel"
|
||||
@echo " make status → État des conteneurs"
|
||||
@echo ""
|
||||
@echo " Production NAS"
|
||||
@echo " ─────────────────────────────────────────"
|
||||
@echo " make prod → Lance la stack complète NAS"
|
||||
@echo " make prod-stop → Arrête tout"
|
||||
@echo " make deploy → git pull + restart (sur le NAS)"
|
||||
@echo ""
|
||||
@echo " Setup"
|
||||
@echo " ─────────────────────────────────────────"
|
||||
@echo " make setup → Crée les dossiers manquants"
|
||||
@echo " make ssl → Obtient le certificat SSL (1ère fois)"
|
||||
@echo " make renew-ssl → Renouvelle le certificat SSL"
|
||||
@echo ""
|
||||
|
||||
# ── DÉVELOPPEMENT LOCAL ──────────────────────────────────────
|
||||
|
||||
dev:
|
||||
@echo "🚀 Lancement PocketBase en local..."
|
||||
@[ -f .env.local ] || (echo "❌ Fichier .env.local manquant ! Copier .env.example" && exit 1)
|
||||
docker compose -f docker/docker-compose.dev.yml up -d
|
||||
@echo "✅ PocketBase : http://localhost:8090"
|
||||
@echo "✅ Admin : http://localhost:8090/_/"
|
||||
|
||||
dev-stop:
|
||||
docker compose -f docker/docker-compose.dev.yml down
|
||||
|
||||
dev-reset:
|
||||
@echo "⚠️ Supprime les données locales de dev !"
|
||||
@read -p "Confirmer ? (oui/non) : " c; [ "$$c" = "oui" ] || exit 1
|
||||
docker compose -f docker/docker-compose.dev.yml down
|
||||
rm -rf pocketbase/pb_data
|
||||
@echo "✅ Données supprimées"
|
||||
|
||||
# ── PRODUCTION NAS ───────────────────────────────────────────
|
||||
|
||||
prod:
|
||||
@echo "🚀 Lancement stack production..."
|
||||
@[ -f .env.production ] || (echo "❌ Fichier .env.production manquant !" && exit 1)
|
||||
docker compose -f docker/docker-compose.prod.yml up -d
|
||||
@echo "✅ Stack lancée"
|
||||
|
||||
prod-stop:
|
||||
docker compose -f docker/docker-compose.prod.yml down
|
||||
|
||||
deploy:
|
||||
@echo "📦 Déploiement en cours..."
|
||||
git pull origin main
|
||||
docker compose -f docker/docker-compose.prod.yml restart pocketbase
|
||||
@echo "✅ Déployé"
|
||||
|
||||
# ── LOGS & STATUS ────────────────────────────────────────────
|
||||
|
||||
logs:
|
||||
docker compose -f docker/docker-compose.dev.yml logs -f pocketbase
|
||||
|
||||
logs-prod:
|
||||
docker compose -f docker/docker-compose.prod.yml logs -f pocketbase
|
||||
|
||||
status:
|
||||
docker compose -f docker/docker-compose.dev.yml ps
|
||||
|
||||
# ── SETUP ────────────────────────────────────────────────────
|
||||
|
||||
setup:
|
||||
@echo "📁 Création des dossiers..."
|
||||
mkdir -p pocketbase/pb_data
|
||||
mkdir -p pocketbase/pb_hooks
|
||||
mkdir -p pocketbase/pb_migrations
|
||||
@[ -f .env.local ] || cp .env.example .env.local
|
||||
@echo "✅ Structure prête"
|
||||
@echo "👉 Éditer .env.local avec votre clé Anthropic"
|
||||
|
||||
setup-nas:
|
||||
@echo "📁 Création des dossiers sur le NAS..."
|
||||
mkdir -p /volume1/docker/mb-app/pb_data
|
||||
mkdir -p /volume1/docker/mb-app/ssl
|
||||
mkdir -p /volume1/docker/mb-app/duckdns
|
||||
@[ -f .env.production ] || cp .env.example .env.production
|
||||
@echo "✅ Dossiers NAS créés"
|
||||
@echo "👉 Éditer .env.production"
|
||||
|
||||
ssl:
|
||||
@echo "🔐 Obtention certificat SSL..."
|
||||
@[ -f .env.production ] || (echo "❌ .env.production manquant" && exit 1)
|
||||
@source .env.production && docker run --rm \
|
||||
-v /volume1/docker/mb-app/ssl:/etc/letsencrypt \
|
||||
-v /tmp/certbot-webroot:/var/www/certbot \
|
||||
-p 80:80 \
|
||||
certbot/certbot certonly --standalone \
|
||||
-d $$DUCKDNS_SUBDOMAINS.duckdns.org \
|
||||
--non-interactive --agree-tos \
|
||||
--email admin@example.com
|
||||
@echo "✅ Certificat obtenu"
|
||||
|
||||
renew-ssl:
|
||||
docker run --rm \
|
||||
-v /volume1/docker/mb-app/ssl:/etc/letsencrypt \
|
||||
certbot/certbot renew --quiet
|
||||
docker compose -f docker/docker-compose.prod.yml restart nginx
|
||||
@echo "✅ Certificat renouvelé"
|
||||
@ -1,690 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
163
README.md
@ -1,163 +0,0 @@
|
||||
# mb-app — Application Marchand de Biens
|
||||
|
||||
Application mobile et web pour la gestion quotidienne d'une activité de marchand de biens immobiliers.
|
||||
|
||||
**Stack :** React Native (Expo) + PocketBase (self-hosted sur NAS Synology) + IA Claude
|
||||
|
||||
---
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
### Prérequis
|
||||
- Docker Desktop (Mac/Windows) ou Docker Engine (Linux/NAS)
|
||||
- Node.js 18+
|
||||
- Un compte DuckDNS (gratuit) pour l'accès distant
|
||||
|
||||
### 1. Cloner le projet
|
||||
|
||||
```bash
|
||||
git clone https://github.com/VOUS/mb-app.git
|
||||
cd mb-app
|
||||
```
|
||||
|
||||
### 2. Setup initial
|
||||
|
||||
```bash
|
||||
make setup
|
||||
# → Crée les dossiers nécessaires
|
||||
# → Copie .env.example en .env.local
|
||||
```
|
||||
|
||||
Éditer `.env.local` :
|
||||
```
|
||||
EXPO_PUBLIC_PB_URL=http://localhost:8090
|
||||
ANTHROPIC_API_KEY=sk-ant-VOTRE_CLE
|
||||
```
|
||||
|
||||
### 3. Lancer en développement
|
||||
|
||||
```bash
|
||||
# Terminal 1 — Backend PocketBase
|
||||
make dev
|
||||
# → PocketBase sur http://localhost:8090
|
||||
# → Admin sur http://localhost:8090/_/
|
||||
|
||||
# Terminal 2 — App Expo
|
||||
cd app
|
||||
npm install
|
||||
npx expo start
|
||||
```
|
||||
|
||||
**Première fois :** Aller sur http://localhost:8090/_/ → créer le compte admin
|
||||
→ Settings → Import collections → coller le contenu de `pocketbase/pb_collections.json`
|
||||
|
||||
---
|
||||
|
||||
## Déploiement sur le NAS Synology
|
||||
|
||||
### 1. Cloner sur le NAS
|
||||
|
||||
```bash
|
||||
# Se connecter en SSH au NAS
|
||||
ssh admin@IP_DU_NAS
|
||||
|
||||
# Cloner le projet
|
||||
git clone https://github.com/VOUS/mb-app.git /volume1/docker/mb-app
|
||||
cd /volume1/docker/mb-app
|
||||
```
|
||||
|
||||
### 2. Configurer l'environnement production
|
||||
|
||||
```bash
|
||||
make setup-nas
|
||||
# Puis éditer .env.production :
|
||||
nano .env.production
|
||||
```
|
||||
|
||||
```
|
||||
EXPO_PUBLIC_PB_URL=https://mon-sous-domaine.duckdns.org
|
||||
ANTHROPIC_API_KEY=sk-ant-VOTRE_CLE
|
||||
DUCKDNS_SUBDOMAINS=mon-sous-domaine
|
||||
DUCKDNS_TOKEN=VOTRE_TOKEN_DUCKDNS
|
||||
```
|
||||
|
||||
### 3. Ouvrir les ports sur votre box internet
|
||||
- Port 80 → IP du NAS, port 80
|
||||
- Port 443 → IP du NAS, port 443
|
||||
|
||||
### 4. Obtenir le certificat SSL
|
||||
|
||||
```bash
|
||||
make ssl
|
||||
```
|
||||
|
||||
### 5. Lancer la stack
|
||||
|
||||
```bash
|
||||
make prod
|
||||
```
|
||||
|
||||
### Mettre à jour après un git push
|
||||
|
||||
```bash
|
||||
# Sur le NAS :
|
||||
make deploy
|
||||
# → git pull + restart PocketBase (les hooks sont rechargés)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workflow de développement
|
||||
|
||||
```
|
||||
[Local] Coder + tester
|
||||
↓
|
||||
git add . && git commit -m "feat: module visites"
|
||||
↓
|
||||
git push origin main
|
||||
↓
|
||||
[NAS] make deploy
|
||||
```
|
||||
|
||||
Les **données** (`pb_data/`) restent sur chaque machine et ne sont jamais dans Git.
|
||||
Le **code** (hooks, migrations, app) est versionné et déployé via Git.
|
||||
|
||||
---
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```
|
||||
mb-app/
|
||||
├── app/ ← Code React Native (Expo Router)
|
||||
├── pocketbase/
|
||||
│ ├── pb_hooks/ ← Hooks JS côté serveur (IA, etc.)
|
||||
│ ├── pb_migrations/ ← Migrations auto PocketBase
|
||||
│ └── pb_collections.json ← Schéma des collections
|
||||
├── docker/
|
||||
│ ├── docker-compose.dev.yml
|
||||
│ ├── docker-compose.prod.yml
|
||||
│ └── nginx.prod.conf
|
||||
├── .cursorrules ← Contexte pour Cursor AI
|
||||
├── AGENTS.md ← Suivi des sessions de développement
|
||||
└── Makefile ← Raccourcis commandes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
| Commande | Description |
|
||||
|---|---|
|
||||
| `make dev` | Lance PocketBase en local |
|
||||
| `make dev-stop` | Arrête le dev |
|
||||
| `make logs` | Logs en temps réel |
|
||||
| `make prod` | Lance la stack NAS |
|
||||
| `make deploy` | git pull + redémarre (sur NAS) |
|
||||
| `make renew-ssl` | Renouvelle le certificat SSL |
|
||||
|
||||
---
|
||||
|
||||
## Backup des données
|
||||
|
||||
Les données PocketBase sont dans `pb_data/` (exclu du Git).
|
||||
Configurer une tâche Synology Hyper Backup sur `/volume1/docker/mb-app/pb_data/`.
|
||||
42
app.json
@ -1,42 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "MDB-Turbo",
|
||||
"slug": "mdb-turbo",
|
||||
"scheme": "mdb-turbo",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/icon.png",
|
||||
"color": "#3d8bfd"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,297 +0,0 @@
|
||||
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 },
|
||||
},
|
||||
});
|
||||
@ -1,234 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@ -1,112 +0,0 @@
|
||||
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 },
|
||||
});
|
||||
@ -1,29 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
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 },
|
||||
});
|
||||
@ -1,103 +0,0 @@
|
||||
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 },
|
||||
});
|
||||
@ -1,567 +0,0 @@
|
||||
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
@ -1,112 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
BIN
assets/icon.png
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 17 KiB |
@ -1,7 +0,0 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: ['expo-router/babel'],
|
||||
};
|
||||
};
|
||||
@ -1,32 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
# ============================================================
|
||||
# DÉVELOPPEMENT LOCAL — docker compose -f docker/docker-compose.dev.yml up
|
||||
# PocketBase accessible sur http://localhost:8090
|
||||
# Admin PocketBase : http://localhost:8090/_/
|
||||
# ============================================================
|
||||
|
||||
services:
|
||||
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
container_name: mb-pocketbase-dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8090:8090" # Accès direct sans Nginx en dev
|
||||
volumes:
|
||||
# Données locales (dans .gitignore)
|
||||
- ../pocketbase/pb_data:/pb/pb_data
|
||||
# Hooks JS versionnés dans Git ✅
|
||||
- ../pocketbase/pb_hooks:/pb/pb_hooks
|
||||
# Migrations versionnées dans Git ✅
|
||||
- ../pocketbase/pb_migrations:/pb/pb_migrations
|
||||
env_file:
|
||||
- ../.env.local # Clé Anthropic en local
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8090/api/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@ -1,59 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
# ============================================================
|
||||
# PRODUCTION NAS — docker compose -f docker/docker-compose.prod.yml up -d
|
||||
# Accessible sur https://VOTRE_SOUS_DOMAINE.duckdns.org
|
||||
# ============================================================
|
||||
|
||||
services:
|
||||
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
container_name: mb-pocketbase
|
||||
restart: unless-stopped
|
||||
# Pas de port exposé directement : Nginx fait le proxy
|
||||
expose:
|
||||
- "8090"
|
||||
volumes:
|
||||
# Données persistantes NAS (dans .gitignore)
|
||||
- /volume1/docker/mb-app/pb_data:/pb/pb_data
|
||||
# Hooks et migrations versionnés dans Git ✅
|
||||
- ../pocketbase/pb_hooks:/pb/pb_hooks
|
||||
- ../pocketbase/pb_migrations:/pb/pb_migrations
|
||||
env_file:
|
||||
- ../.env.production
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8090/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: mb-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.prod.conf:/etc/nginx/nginx.conf:ro
|
||||
- /volume1/docker/mb-app/ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
|
||||
duckdns:
|
||||
image: lscr.io/linuxserver/duckdns:latest
|
||||
container_name: mb-duckdns
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
- SUBDOMAINS=${DUCKDNS_SUBDOMAINS}
|
||||
- TOKEN=${DUCKDNS_TOKEN}
|
||||
- LOG_FILE=true
|
||||
env_file:
|
||||
- ../.env.production
|
||||
volumes:
|
||||
- /volume1/docker/mb-app/duckdns:/config
|
||||
18
docker/docker-compose.dev.yml
Normal file
@ -0,0 +1,18 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
container_name: mdb-pocketbase-dev
|
||||
restart: unless-stopped
|
||||
command: --dir=/pb_data
|
||||
ports:
|
||||
- "8090:8090"
|
||||
volumes:
|
||||
- ../pocketbase/pb_data:/pb_data
|
||||
- ../pocketbase/pb_hooks:/pb_hooks
|
||||
- ../pocketbase/pb_migrations:/pb_migrations
|
||||
env_file:
|
||||
- ../.env.local
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
43
docker/docker-compose.prod.yml
Normal file
@ -0,0 +1,43 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
container_name: mdb-pocketbase
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "8090"
|
||||
volumes:
|
||||
- /volume1/docker/mdb/pocketbase/pb_data:/pb/pb_data
|
||||
- ../pocketbase/pb_hooks:/pb/pb_hooks
|
||||
- ../pocketbase/pb_migrations:/pb/pb_migrations
|
||||
env_file:
|
||||
- ../.env.production
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: mdb-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.prod.conf:/etc/nginx/nginx.conf:ro
|
||||
- /volume1/docker/mdb/ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
- pocketbase
|
||||
|
||||
duckdns:
|
||||
image: lscr.io/linuxserver/duckdns:latest
|
||||
container_name: mdb-duckdns
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
- SUBDOMAINS=${DUCKDNS_SUBDOMAINS}
|
||||
- TOKEN=${DUCKDNS_TOKEN}
|
||||
env_file:
|
||||
- ../.env.production
|
||||
volumes:
|
||||
- /volume1/docker/mdb/duckdns:/config
|
||||
@ -1,20 +0,0 @@
|
||||
# ============================================================
|
||||
# .env.example — Copier en .env.local (dev) ou .env.production
|
||||
# NE PAS mettre de vraies valeurs dans ce fichier
|
||||
# ============================================================
|
||||
|
||||
# ── URL PocketBase ───────────────────────────────────────────
|
||||
# Dev local :
|
||||
EXPO_PUBLIC_PB_URL=http://localhost:8090
|
||||
# Production NAS :
|
||||
# EXPO_PUBLIC_PB_URL=https://VOTRE_SOUS_DOMAINE.duckdns.org
|
||||
|
||||
# ── PocketBase (côté serveur Docker) ─────────────────────────
|
||||
ANTHROPIC_API_KEY=sk-ant-VOTRE_CLE_ICI
|
||||
|
||||
# ── DuckDNS (production uniquement) ──────────────────────────
|
||||
DUCKDNS_SUBDOMAINS=VOTRE_SOUS_DOMAINE
|
||||
DUCKDNS_TOKEN=VOTRE_TOKEN_DUCKDNS
|
||||
|
||||
# ── Expo (optionnel, pour EAS Build) ─────────────────────────
|
||||
# EXPO_TOKEN=
|
||||
41
mb-app/.gitignore
vendored
@ -1,41 +0,0 @@
|
||||
# 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
@ -1 +0,0 @@
|
||||
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||
7
mb-app/.vscode/settings.json
vendored
@ -1,7 +0,0 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
}
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
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%',
|
||||
},
|
||||
});
|
||||
@ -1,31 +0,0 @@
|
||||
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%',
|
||||
},
|
||||
});
|
||||
@ -1,38 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}`;
|
||||
@ -1,40 +0,0 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
@ -1,88 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: 'fade',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,127 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
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" />;
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
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%',
|
||||
},
|
||||
});
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 17 KiB |
@ -1,9 +0,0 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
|
||||
'nativewind/babel',
|
||||
],
|
||||
};
|
||||
};
|
||||
@ -1,77 +0,0 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
@ -1,24 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import { Text, TextProps } from './Themed';
|
||||
|
||||
export function MonoText(props: TextProps) {
|
||||
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
/**
|
||||
* 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} />;
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
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();
|
||||
});
|
||||
@ -1,4 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
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 +0,0 @@
|
||||
export { useColorScheme } from 'react-native';
|
||||
@ -1,8 +0,0 @@
|
||||
// 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';
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
@ -1,4 +0,0 @@
|
||||
/** 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;
|
||||
@ -1,121 +0,0 @@
|
||||
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
@ -1,6 +0,0 @@
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
EXPO_PUBLIC_SUPABASE_URL?: string;
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY?: string;
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@ -1,10 +0,0 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -1,6 +0,0 @@
|
||||
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
@ -1 +0,0 @@
|
||||
/// <reference types="nativewind/types" />
|
||||
11091
mb-app/package-lock.json
generated
@ -1,52 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@ -1,11 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type UiState = {
|
||||
lastAuthEmail: string;
|
||||
setLastAuthEmail: (email: string) => void;
|
||||
};
|
||||
|
||||
export const useUiStore = create<UiState>((set) => ({
|
||||
lastAuthEmail: '',
|
||||
setLastAuthEmail: (email) => set({ lastAuthEmail: email }),
|
||||
}));
|
||||
@ -1,20 +0,0 @@
|
||||
/** @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: [],
|
||||
};
|
||||
@ -1,19 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
/**
|
||||
* 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
@ -1,41 +0,0 @@
|
||||
# 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 +0,0 @@
|
||||
legacy-peer-deps=true
|
||||
@ -1,32 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
import { FieldVisitChecklist } from '../src/components/FieldVisitChecklist';
|
||||
|
||||
const DEMO_PROPERTY_ID = 'local-demo';
|
||||
|
||||
export default function FieldScreen() {
|
||||
return <FieldVisitChecklist propertyId={DEMO_PROPERTY_ID} />;
|
||||
}
|
||||
@ -1,146 +0,0 @@
|
||||
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 },
|
||||
});
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 17 KiB |
@ -1,7 +0,0 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: ['expo-router/babel'],
|
||||
};
|
||||
};
|
||||
9378
mdb-predator/package-lock.json
generated
@ -1,38 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
}));
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,150 +0,0 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
@ -1,148 +0,0 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@ -1,21 +0,0 @@
|
||||
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));
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
/**
|
||||
* 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 [];
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
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, '"');
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
/** 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;
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
/** Pappers — surveillance SCI / procédures (clé API côté serveur uniquement). */
|
||||
|
||||
export async function searchCompanySignalsStub(_siren: string): Promise<unknown[]> {
|
||||
return [];
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
export const colors = {
|
||||
bg: '#070b10',
|
||||
card: '#101820',
|
||||
border: '#1f2a36',
|
||||
text: '#f2f6fb',
|
||||
muted: '#8b9bb0',
|
||||
accent: '#ff4d6d',
|
||||
danger: '#ff3355',
|
||||
ok: '#3ecf8e',
|
||||
};
|
||||
@ -1,100 +0,0 @@
|
||||
-- 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);
|
||||
@ -1,6 +0,0 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||