142
.cursorrules
@ -1,97 +1,63 @@
|
|||||||
# Contexte projet — Application Marchand de Biens
|
# Application Marchand de Biens — Contexte Cursor
|
||||||
|
|
||||||
## Qui utilise cette app
|
## Infrastructure
|
||||||
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.).
|
- 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
|
## Stack technique
|
||||||
- **Frontend** : React Native avec Expo (SDK 51+)
|
- Frontend : React Native avec Expo SDK 51 + Expo Router
|
||||||
- **Navigation** : Expo Router (file-based routing)
|
- SDK PocketBase : npm package "pocketbase"
|
||||||
- **Base de données** : Supabase (PostgreSQL)
|
- UI : NativeWind (Tailwind pour React Native)
|
||||||
- **Auth** : Supabase Auth (email/password)
|
- State : Zustand + React Query (TanStack)
|
||||||
- **Stockage fichiers** : Supabase Storage (photos, PDFs)
|
- IA : API Anthropic Claude via PocketBase Hook (jamais côté client)
|
||||||
- **IA** : API Anthropic Claude (claude-sonnet-4-20250514)
|
- Déploiement mobile : Expo EAS
|
||||||
- **UI** : NativeWind (Tailwind pour React Native) + React Native Paper pour les composants complexes
|
|
||||||
- **State** : Zustand pour le state global, React Query (TanStack) pour le cache serveur
|
|
||||||
- **Déploiement mobile** : Expo EAS
|
|
||||||
- **Déploiement web** : Vercel
|
|
||||||
|
|
||||||
## Conventions de code
|
## Client PocketBase — pattern obligatoire
|
||||||
- TypeScript strict partout, jamais de `any`
|
// /services/pocketbase.ts — singleton, importer partout
|
||||||
- Noms de fichiers : kebab-case pour les fichiers, PascalCase pour les composants
|
import PocketBase from 'pocketbase';
|
||||||
- Toujours utiliser des hooks personnalisés pour la logique métier (ex: `useBiens`, `useContacts`)
|
export const pb = new PocketBase(process.env.EXPO_PUBLIC_PB_URL);
|
||||||
- Les appels Supabase se font UNIQUEMENT dans les hooks, jamais dans les composants
|
|
||||||
- Les types TypeScript sont définis dans `/types/database.ts` (généré depuis Supabase)
|
|
||||||
- Les constantes métier sont dans `/constants/metier.ts`
|
|
||||||
- Toujours gérer les états de chargement et d'erreur
|
|
||||||
- Commentaires en français pour la logique métier, anglais pour le code technique
|
|
||||||
|
|
||||||
## Vocabulaire métier (utiliser ces termes précis)
|
## Collections PocketBase (toutes créées via migration)
|
||||||
- **Bien** : propriété immobilière prospectée ou acquise
|
etapes_pipeline, contacts, biens, analyses_financieres,
|
||||||
- **Piste** : bien en cours d'analyse, pas encore d'offre
|
visites, taches, notes_biens, documents_biens, devis_travaux
|
||||||
- **Dossier** : bien avec offre en cours ou acte signé
|
|
||||||
- **Fiche bien** : écran de détail d'un bien
|
|
||||||
- **Compromis** : avant-contrat de vente (SPC)
|
|
||||||
- **Acte** : acte authentique de vente chez notaire
|
|
||||||
- **Portage** : période entre achat et revente (coût = intérêts + taxes)
|
|
||||||
- **Marge brute** : prix revente - prix achat - travaux - frais notaire achat
|
|
||||||
- **Marge nette** : marge brute - frais de portage - frais d'agence vente - impôts
|
|
||||||
- **DPE** : Diagnostic de Performance Énergétique
|
|
||||||
- **Surface habitable** : surface loi Carrez pour appartements
|
|
||||||
- **Marchand de biens** = le user, l'utilisateur de cette app
|
|
||||||
|
|
||||||
## Modules de l'application
|
## Règles de code
|
||||||
1. **Prospection** : pipeline Kanban des biens (piste → analyse → offre → compromis → acte → revente)
|
- TypeScript strict, jamais de any
|
||||||
2. **Annuaire** : contacts métier (notaires, artisans, banquiers, agents immo)
|
- Appels PocketBase UNIQUEMENT dans les hooks (/hooks/)
|
||||||
3. **Fiches biens** : dossier complet par bien (photos, docs, historique)
|
- Jamais de nouvelle instance PocketBase, toujours importer pb
|
||||||
4. **Calculateur** : analyse de rentabilité financière
|
- Commentaires métier en français, code en anglais
|
||||||
5. **Visites** : compte-rendus de visites avec check-list
|
- Gérer loading + erreur partout
|
||||||
6. **Travaux** : suivi de chantier et devis
|
|
||||||
7. **Administratif** : documents, délais légaux, alertes
|
|
||||||
8. **Agenda** : tâches et rappels liés aux biens
|
|
||||||
9. **Dashboard** : vue globale et KPIs
|
|
||||||
|
|
||||||
## Structure des dossiers
|
## Vocabulaire métier
|
||||||
```
|
- Bien : propriété immobilière prospectée ou acquise
|
||||||
/app → écrans (Expo Router)
|
- Piste : bien en phase d'analyse
|
||||||
/(tabs) → navigation principale
|
- Portage : période entre achat et revente
|
||||||
/prospection
|
- Marge brute : prix revente - prix achat - travaux - frais notaire
|
||||||
/annuaire
|
- Marge nette : marge brute - portage - frais agence - impôts
|
||||||
/agenda
|
|
||||||
/dashboard
|
|
||||||
/bien/[id] → fiche bien
|
|
||||||
/visite/[id] → rapport de visite
|
|
||||||
/contact/[id] → fiche contact
|
|
||||||
/components → composants réutilisables
|
|
||||||
/ui → composants génériques (Button, Card, Input...)
|
|
||||||
/biens → composants spécifiques aux biens
|
|
||||||
/visites → composants spécifiques aux visites
|
|
||||||
/hooks → hooks personnalisés
|
|
||||||
/services → appels API (Supabase, Anthropic)
|
|
||||||
/supabase.ts → client Supabase
|
|
||||||
/ai.ts → appels Claude API
|
|
||||||
/types → types TypeScript
|
|
||||||
/constants → constantes et configuration
|
|
||||||
/utils → fonctions utilitaires
|
|
||||||
```
|
|
||||||
|
|
||||||
## Variables d'environnement nécessaires
|
## Formules financières
|
||||||
```
|
frais_notaire = prix_achat * 0.075 (ancien) ou * 0.02 (neuf)
|
||||||
EXPO_PUBLIC_SUPABASE_URL=
|
frais_portage_total = (prix_achat * taux_credit/100/12 + taxe_fonciere/12 + charges) * duree_mois
|
||||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=
|
travaux_total = budget_travaux * (1 + reserve_pct/100)
|
||||||
ANTHROPIC_API_KEY= ← côté serveur uniquement, jamais exposé côté client
|
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)
|
||||||
|
|
||||||
## Règles de sécurité importantes
|
## Structure dossiers
|
||||||
- La clé API Anthropic ne doit JAMAIS être dans le code client
|
/docker → docker-compose dev + prod
|
||||||
- Créer une Supabase Edge Function pour les appels IA
|
/pocketbase
|
||||||
- Row Level Security (RLS) activé sur toutes les tables Supabase
|
/pb_data → données (dans .gitignore)
|
||||||
- Les photos et docs sont dans des buckets Supabase privés
|
/pb_hooks → hooks JS côté serveur (IA)
|
||||||
|
/pb_migrations → migrations auto au démarrage
|
||||||
## Calculs financiers — formules exactes
|
/app → code Expo React Native
|
||||||
```
|
/app → écrans Expo Router
|
||||||
frais_notaire_achat = prix_achat * 0.075 (ancien) ou * 0.02 (neuf)
|
/components → composants UI
|
||||||
prix_revient = prix_achat + frais_notaire_achat + travaux + frais_portage
|
/hooks → hooks métier
|
||||||
marge_brute = prix_revente_cible - prix_revient
|
/services → pocketbase.ts
|
||||||
frais_portage_mensuel = (prix_achat * taux_credit / 12) + taxe_fonciere_mensuelle
|
/types → types TypeScript
|
||||||
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
|
pocketbase/pb_data/
|
||||||
|
.env.local
|
||||||
# dependencies
|
.env.production
|
||||||
|
*.env
|
||||||
|
docker/ssl/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Expo
|
|
||||||
.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
|
.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.
|
## État actuel
|
||||||
> Mets à jour la section "État" après chaque session.
|
- [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
|
||||||
|
|
||||||
---
|
## Infos techniques
|
||||||
|
- PocketBase : http://localhost:8090
|
||||||
## État global du projet
|
- Admin : http://localhost:8090/_/ (admin@mdb.fr)
|
||||||
|
- Binaire : /usr/local/bin/pocketbase
|
||||||
- [ ] Agent 0 — Setup initial (Expo + Supabase)
|
- Données : /pb_data
|
||||||
- [ ] Agent 1 — Schéma base de données + types TypeScript
|
- OS : Windows Git Bash (MSYS_NO_PATHCONV=1)
|
||||||
- [ ] Agent 2 — Navigation + écrans vides
|
- PocketBase : v0.23+
|
||||||
- [ ] 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)_
|
|
||||||
|
|
||||||
-
|
|
||||||
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||