Compare commits

..

3 Commits

Author SHA1 Message Date
432f8ce176 feat: app complète - tous les modules 2026-05-04 09:09:10 +02:00
695d4e76d0 pocket 2026-05-04 08:28:32 +02:00
7f94f83940 pocket 2026-05-04 06:02:10 +02:00
172 changed files with 5511 additions and 29865 deletions

View File

@ -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 ## Formules financières
/bien/[id] → fiche bien frais_notaire = prix_achat * 0.075 (ancien) ou * 0.02 (neuf)
/visite/[id] → rapport de visite frais_portage_total = (prix_achat * taux_credit/100/12 + taxe_fonciere/12 + charges) * duree_mois
/contact/[id] → fiche contact travaux_total = budget_travaux * (1 + reserve_pct/100)
/components → composants réutilisables prix_revient = prix_achat + frais_notaire + frais_agence_achat + travaux_total + frais_portage_total
/ui → composants génériques (Button, Card, Input...) marge_brute = prix_revente - prix_revient
/biens → composants spécifiques aux biens marge_nette = marge_brute - (prix_revente * frais_agence_vente_pct/100) - (marge_brute * taux_impot/100)
/visites → composants spécifiques aux visites
/hooks → hooks personnalisés ## Structure dossiers
/services → appels API (Supabase, Anthropic) /docker → docker-compose dev + prod
/supabase.ts → client Supabase /pocketbase
/ai.ts appels Claude API /pb_datadonnées (dans .gitignore)
/pb_hooks → hooks JS côté serveur (IA)
/pb_migrations → migrations auto au démarrage
/app → code Expo React Native
/app → écrans Expo Router
/components → composants UI
/hooks → hooks métier
/services → pocketbase.ts
/types → types TypeScript /types → types TypeScript
/constants → constantes et configuration /constants → constantes métier
/utils → fonctions utilitaires
```
## Variables d'environnement nécessaires
```
EXPO_PUBLIC_SUPABASE_URL=
EXPO_PUBLIC_SUPABASE_ANON_KEY=
ANTHROPIC_API_KEY= ← côté serveur uniquement, jamais exposé côté client
```
## Règles de sécurité importantes
- La clé API Anthropic ne doit JAMAIS être dans le code client
- Créer une Supabase Edge Function pour les appels IA
- Row Level Security (RLS) activé sur toutes les tables Supabase
- Les photos et docs sont dans des buckets Supabase privés
## Calculs financiers — formules exactes
```
frais_notaire_achat = prix_achat * 0.075 (ancien) ou * 0.02 (neuf)
prix_revient = prix_achat + frais_notaire_achat + travaux + frais_portage
marge_brute = prix_revente_cible - prix_revient
frais_portage_mensuel = (prix_achat * taux_credit / 12) + taxe_fonciere_mensuelle
taux_marge_brute = marge_brute / prix_revente_cible * 100
```

12
.env.example Normal file
View 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
View File

@ -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

1
.npmrc
View File

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

147
AGENTS.md
View File

@ -1,127 +1,24 @@
# 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 (fichiers `pb_migrations`) — à appliquer au démarrage serveur si besoin
- [x] App Expo initialisée
- [x] Auth fonctionnelle
- [x] Navigation complète
- [x] Module Prospection (pipeline / biens)
- [x] Module Fiche bien + Calculateur
- [x] Module Contacts (liste par catégorie, recherche, fiche + biens liés)
- [x] Module Visites + IA (`pb_hooks/generate_rapport.pb.js`, route `POST /api/mdb/generate-rapport`)
- [x] Module Agenda (tâches, snooze, création modal)
- [x] Dashboard (alertes, KPIs, pipeline, derniers biens, tâches du jour)
--- ## 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 - Hooks JS : volume `pb_hooks``--hooksDir=/pb_hooks` (image muchobien)
- [ ] Agent 2 — Navigation + écrans vides - Variable IA : `ANTHROPIC_API_KEY` dans `.env.local` (chargée par Docker pour PocketBase)
- [ ] Agent 3 — Module Prospection (pipeline Kanban) - OS : Windows Git Bash (MSYS_NO_PATHCONV=1)
- [ ] Agent 4 — Module Fiche Bien - PocketBase : v0.23+
- [ ] 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)_
-

193
GUIDE_COMPLET.md Normal file
View File

@ -0,0 +1,193 @@
# GUIDE COMPLET — Prompts Cursor
# App Marchand de Biens — Expo + PocketBase
---
## PROMPT 1 — Fondation (Setup + Auth + Navigation)
Lis .cursorrules et AGENTS.md.
Je crée une app React Native Expo pour marchand de biens immobiliers.
Backend : PocketBase sur http://localhost:8090 (déjà lancé, collections déjà créées).
Collections existantes dans PocketBase :
users, etapes_pipeline, contacts, biens, analyses_financieres,
visites, taches, notes_biens, documents_biens, devis_travaux
PARTIE A — Initialisation :
Dans le dossier actuel, initialise l'app Expo :
npx create-expo-app@latest app --template tabs
cd app
Installe ces dépendances dans app/ :
pocketbase
@tanstack/react-query
zustand
nativewind
tailwindcss
@react-native-async-storage/async-storage
expo-image-picker
expo-document-picker
expo-haptics
PARTIE B — Fichier .env.local à la racine de app/ :
EXPO_PUBLIC_PB_URL=http://localhost:8090
PARTIE C — Service PocketBase app/services/pocketbase.ts :
- Client singleton PocketBase
- Persistance session avec AsyncStorage
- Export : pb, getCurrentUserId(), isAuthenticated()
PARTIE D — Types TypeScript app/types/collections.ts :
Interfaces pour toutes les collections (étendent RecordModel de pocketbase) :
UserRecord, BienRecord, ContactRecord, VisiteRecord, TacheRecord,
EtapePipelineRecord, AnalyseFinanciereRecord, NoteRecord, DocumentRecord, DevisRecord
+ types BienCreate, BienUpdate (Omit + Partial)
PARTIE E — Constantes app/constants/metier.ts :
ETAPES_DEFAUT (9 étapes avec couleurs)
CATEGORIES_CONTACTS avec labels français
TYPES_BIENS avec labels français
AVIS_VISITE avec labels et couleurs
PARTIE F — Auth :
app/context/AuthContext.tsx : login, logout, user courant, redirect auto
app/app/auth/login.tsx : email + password, couleur primaire #1D4ED8
app/app/auth/register.tsx : email, password, nom, prénom
PARTIE G — Navigation :
5 onglets dans app/app/(tabs)/ :
- index.tsx → Dashboard (icône grid)
- biens.tsx → Biens (icône home)
- visites.tsx → Visites (icône clipboard)
- contacts.tsx → Contacts (icône people)
- agenda.tsx → Agenda (icône calendar)
Écrans de détail :
- app/bien/[id].tsx
- app/bien/nouveau.tsx
- app/contact/[id].tsx
- app/visite/[id].tsx
- app/calculateur/[bienId].tsx
Chaque écran de détail = placeholder avec titre pour l'instant.
FAB "+" sur les onglets Biens et Contacts.
L'app doit se lancer avec : cd app && npx expo start
L'auth doit fonctionner avec un compte créé sur PocketBase.
Mets à jour AGENTS.md quand c'est terminé.
---
## PROMPT 2 — Pipeline + Fiche Bien + Calculateur
## Lancer SEULEMENT après que le Prompt 1 tourne
Lis .cursorrules et AGENTS.md.
L'auth et la navigation fonctionnent. Je construis le cœur de l'app.
HOOK app/hooks/useEtapes.ts :
- fetchEtapes() : étapes du user triées par ordre
- initEtapesDefaut() : crée les 9 étapes si l'user n'en a pas encore
HOOK app/hooks/useBiens.ts :
- fetchBiens(filters?) : avec expand etape
- fetchBienDetail(id) : avec expand etape, visites, notes
- createBien(data), updateBien(id, data), deleteBien(id)
- moveBienToEtape(bienId, etapeId)
ONGLET BIENS app/app/(tabs)/biens.tsx :
Switch Kanban / Liste :
MODE KANBAN : ScrollView horizontal, une colonne par étape
Header colonne : nom + couleur + nombre de biens
Card bien : titre, ville, surface, prix achat formaté, badge priorité
Long press → bottom sheet : changer étape | supprimer
MODE LISTE : FlatList triable, barre de recherche
FAB "+" → /bien/nouveau
FORMULAIRE app/app/bien/nouveau.tsx :
3 étapes avec barre de progression :
1. type_bien, adresse, ville, code_postal
2. surface_habitable, nb_pieces, prix estimé, source, is_off_market
3. Résumé + Créer → PocketBase → redirect /bien/[id]
FICHE BIEN app/app/bien/[id].tsx :
Sections : Header | Infos | Finances | Visites | Notes | Documents
Auto-save notes debounce 500ms.
HOOK app/hooks/useAnalyse.ts :
- fetchAnalyse(bienId), saveAnalyse(bienId, data)
- calculateResults(data) : toutes les formules de .cursorrules
CALCULATEUR app/app/calculateur/[bienId].tsx :
Recalcul temps réel. Sections : Acquisition | Travaux | Portage | Revente
Résultats colorés : vert >15% | orange 8-15% | rouge <8%
Scénarios -10%/réaliste/+10%. Bouton Enregistrer.
Mets à jour AGENTS.md.
---
## PROMPT 3 — Contacts + Visites IA + Agenda + Dashboard
## Lancer SEULEMENT après que le Prompt 2 tourne
Lis .cursorrules et AGENTS.md.
Pipeline, fiche bien et calculateur fonctionnent.
CONTACTS app/app/(tabs)/contacts.tsx :
SectionList par catégorie, recherche live, appel direct Linking.openURL tel:
Fiche contact : coordonnées, biens associés, notes
VISITES app/app/(tabs)/visites.tsx :
Écran visite app/app/visite/[id].tsx avec 3 tabs :
Tab 1 Check-liste : 4 états par item (OK/Attention/Problème/Non vérifié)
Tab 2 Notes : zone texte + bouton photo
Tab 3 Estimation : sliders travaux, avis global, score 1-10
Bouton "Générer rapport IA" pb.send('/api/generate-rapport') affiche markdown
Hook serveur pocketbase/pb_hooks/generate_rapport.pb.js :
routerAdd("POST", "/api/generate-rapport", (c) => {
const info = $apis.requestInfo(c);
if (!info.authRecord) return c.json(401, {error: "Non autorisé"});
const { notes_brutes, checklist_reponses, bien_info } = info.data;
const response = $http.send({
url: "https://api.anthropic.com/v1/messages",
method: "POST",
headers: {
"x-api-key": $os.getenv("ANTHROPIC_API_KEY"),
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 1500,
messages: [{ role: "user", content: "Génère un compte-rendu de visite professionnel en français. Bien: " + JSON.stringify(bien_info) + " Notes: " + notes_brutes + " Checklist: " + JSON.stringify(checklist_reponses) }]
}),
timeout: 30
});
const result = JSON.parse(response.raw);
return c.json(200, { rapport: result.content[0].text });
}, $apis.requireRecordAuth());
AGENDA app/app/(tabs)/agenda.tsx :
Vue Aujourd'hui : En retard (rouge) + Aujourd'hui + Cette semaine
Card tâche : checkbox, titre, badge bien, swipe snooze/supprimer
Création : bottom sheet
DASHBOARD app/app/(tabs)/index.tsx :
Alertes urgentes | KPIs | Mini pipeline | Derniers biens | Tâches du jour
Mets à jour AGENTS.md : tous modules terminés.
---
## PROMPT DEBUG
Lis .cursorrules.
Erreur dans [MODULE] :
ERREUR : [message exact]
FICHIER : [nom]
Stack : Expo + PocketBase v0.23+. Diagnostique et corrige.
## PROMPT UI
Lis .cursorrules.
L'écran [NOM] fonctionne. Améliore l'UI pour usage pro en extérieur.
Couleurs : #1D4ED8 | #16A34A | #D97706 | #DC2626

116
Makefile
View File

@ -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é"

View File

@ -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
View File

@ -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/`.

View File

@ -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"
}
]
]
}
}

View File

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

View File

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

View File

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

View File

@ -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 lappareil.'}
{app.runtimeMode === 'cloud' && 'Supabase — synchronisation cloud.'}
{app.runtimeMode === 'none' && 'Non initialisé.'}
</Text>
{app.user ? (
<Text style={styles.p}>
Compte : {app.user.email ?? app.user.id}
</Text>
) : null}
<Text style={[styles.h2, { marginTop: 24 }]}>Projet Supabase</Text>
<Text style={styles.p}>
URL et clé « anon » (Settings API). Exécutez aussi la migration SQL du
dépôt sur votre projet.
</Text>
<LabeledField
label="URL du projet"
autoCapitalize="none"
value={url}
onChangeText={setUrl}
placeholder="https://xxxx.supabase.co"
/>
<LabeledField
label="Clé anon (public)"
autoCapitalize="none"
value={key}
onChangeText={setKey}
placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
/>
{msg ? <Text style={styles.msg}>{msg}</Text> : null}
<PrimaryButton
title="Enregistrer et passer en mode cloud"
loading={loading}
onPress={async () => {
setMsg(null);
if (!url.trim() || !key.trim()) {
setMsg('Renseignez URL et clé.');
return;
}
setLoading(true);
try {
await app.saveCloudConfig({
supabaseUrl: url.trim(),
supabaseAnonKey: key.trim(),
});
setMsg('Configuration enregistrée. Connectez-vous ou créez un compte.');
router.push('/auth/login');
} catch {
setMsg('Erreur lors de lenregistrement.');
} finally {
setLoading(false);
}
}}
/>
<PrimaryButton
title="Activer le mode hors-ligne"
variant="ghost"
containerStyle={{ marginTop: 12 }}
onPress={async () => {
await app.enterLocalMode();
setMsg('Mode hors-ligne activé.');
router.replace('/(tabs)');
}}
/>
<PrimaryButton
title="Déconnexion / quitter la session"
variant="ghost"
containerStyle={{ marginTop: 24 }}
onPress={async () => {
await app.signOut();
router.replace('/');
}}
/>
</ScrollView>
);
}
const styles = StyleSheet.create({
h2: { color: colors.text, fontSize: 18, fontWeight: '700', marginBottom: 8 },
p: { color: colors.textMuted, lineHeight: 20, marginBottom: 8 },
msg: { color: colors.flash, marginBottom: 12, lineHeight: 20 },
});

6
app/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli

View File

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

37
app/app.config.js Normal file
View File

@ -0,0 +1,37 @@
/**
* Charge les variables d'environnement depuis `app/.env*` puis `../.env*`
* (le repo a souvent `.env.local` à la racine `mdb/`, pas dans `mdb/app/`).
*/
const fs = require('fs');
const path = require('path');
function loadEnvFiles() {
const dirs = [__dirname, path.join(__dirname, '..')];
const names = ['.env.local', '.env'];
for (const dir of dirs) {
for (const name of names) {
const full = path.join(dir, name);
if (!fs.existsSync(full)) continue;
const raw = fs.readFileSync(full, 'utf8');
for (const line of raw.split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const i = t.indexOf('=');
if (i <= 0) continue;
const key = t.slice(0, i).trim();
let val = t.slice(i + 1).trim();
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
if (process.env[key] === undefined) process.env[key] = val;
}
}
}
}
loadEnvFiles();
module.exports = require('./app.json');

View File

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

View File

@ -0,0 +1,50 @@
import { Ionicons } from '@expo/vector-icons';
import { Tabs } from 'expo-router';
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#1D4ED8',
headerStyle: { backgroundColor: '#f8fafc' },
headerTitleStyle: { fontWeight: '700', color: '#0f172a' },
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Dashboard',
tabBarIcon: ({ color, size }) => <Ionicons name="home-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="biens"
options={{
title: 'Biens',
tabBarIcon: ({ color, size }) => <Ionicons name="business-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="visites"
options={{
title: 'Visites',
tabBarIcon: ({ color, size }) => <Ionicons name="calendar-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="contacts"
options={{
title: 'Contacts',
tabBarIcon: ({ color, size }) => <Ionicons name="people-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="agenda"
options={{
title: 'Agenda',
tabBarIcon: ({ color, size }) => <Ionicons name="list-outline" size={size} color={color} />,
}}
/>
</Tabs>
);
}

243
app/app/(tabs)/agenda.tsx Normal file
View File

@ -0,0 +1,243 @@
import { useMemo, useState } from 'react';
import { Stack } from 'expo-router';
import {
ActivityIndicator,
Alert,
Modal,
Pressable,
ScrollView,
SectionList,
Text,
TextInput,
View,
} from 'react-native';
import { useBiens } from '@/hooks/useBiens';
import { useTachesList } from '@/hooks/useTaches';
import type { TacheExpanded } from '@/types/collections';
import {
addDays,
formatPbDateOnly,
parsePbDateOnly,
partitionTachesForAgenda,
} from '@/utils/agendaDates';
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
function bienLabel(t: TacheExpanded): string | null {
const b = t.expand?.bien;
if (!b) return null;
return b.titre?.trim() || b.ville || 'Bien';
}
export default function AgendaTab() {
const { taches, isLoading, error, createTache, updateTache, deleteTache, isCreatePending } =
useTachesList();
const { biens } = useBiens();
const [modalOpen, setModalOpen] = useState(false);
const [newTitre, setNewTitre] = useState('');
const [newDate, setNewDate] = useState(() => formatPbDateOnly(new Date()));
const [newBienId, setNewBienId] = useState<string | undefined>(undefined);
const sections = useMemo(() => {
const part = partitionTachesForAgenda(taches);
const out: { title: string; data: TacheExpanded[]; tint?: 'red' }[] = [];
if (part.overdue.length) out.push({ title: 'En retard', data: part.overdue, tint: 'red' });
if (part.today.length) out.push({ title: "Aujourd'hui", data: part.today });
if (part.week.length) out.push({ title: 'Cette semaine', data: part.week });
if (part.nodate.length) out.push({ title: 'Sans date', data: part.nodate });
return out;
}, [taches]);
const openCreate = () => {
setNewTitre('');
setNewDate(formatPbDateOnly(new Date()));
setNewBienId(undefined);
setModalOpen(true);
};
const submitCreate = async () => {
const titre = newTitre.trim();
if (!titre) {
Alert.alert('Titre requis');
return;
}
await createTache({
titre,
date_echeance: newDate.trim() || undefined,
bien: newBienId,
});
setModalOpen(false);
};
const toggleDone = async (t: TacheExpanded) => {
const next = t.statut === 'fait' ? 'a_faire' : 'fait';
await updateTache({ id: t.id, patch: { statut: next } });
};
const snoozeOneDay = async (t: TacheExpanded) => {
const base = parsePbDateOnly(t.date_echeance) ?? new Date();
const next = addDays(base, 1);
await updateTache({ id: t.id, patch: { date_echeance: formatPbDateOnly(next) } });
};
const confirmDelete = (t: TacheExpanded) => {
Alert.alert('Supprimer la tâche ?', t.titre, [
{ text: 'Annuler', style: 'cancel' },
{
text: 'Supprimer',
style: 'destructive',
onPress: () => void deleteTache(t.id),
},
]);
};
return (
<>
<Stack.Screen options={{ title: 'Agenda', headerShown: true }} />
<View className="flex-1 bg-slate-50">
{error ? (
<Text className="p-4 text-red-700">{formatPocketBaseError(error)}</Text>
) : null}
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#1D4ED8" />
</View>
) : (
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, paddingBottom: 96 }}
renderSectionHeader={({ section }) => (
<Text
className={`pb-2 pt-3 text-xs font-semibold uppercase ${section.tint === 'red' ? 'text-red-600' : 'text-slate-500'}`}
>
{section.title}
</Text>
)}
renderItem={({ item: t }) => {
const done = t.statut === 'fait';
const badge = bienLabel(t);
return (
<View className="mb-2 rounded-xl border border-slate-200 bg-white p-3">
<View className="flex-row items-start gap-3">
<Pressable
onPress={() => void toggleDone(t)}
className={`mt-0.5 h-6 w-6 items-center justify-center rounded border ${done ? 'border-green-600 bg-green-600' : 'border-slate-300 bg-white'}`}
>
{done ? <Text className="text-xs font-bold text-white"></Text> : null}
</Pressable>
<View className="min-w-0 flex-1">
<Text
className={`font-semibold text-slate-900 ${done ? 'text-slate-400 line-through' : ''}`}
>
{t.titre}
</Text>
{badge ? (
<Text className="mt-1 text-xs font-medium text-blue-700">{badge}</Text>
) : null}
{t.date_echeance ? (
<Text className="mt-1 text-xs text-slate-500">{t.date_echeance}</Text>
) : null}
</View>
</View>
<View className="mt-3 flex-row flex-wrap gap-2">
<Pressable
onPress={() => void snoozeOneDay(t)}
className="rounded-lg bg-slate-100 px-3 py-2"
>
<Text className="text-sm font-medium text-slate-800">Snooze +1 j</Text>
</Pressable>
<Pressable
onPress={() => confirmDelete(t)}
className="rounded-lg bg-red-50 px-3 py-2"
>
<Text className="text-sm font-medium text-red-700">Supprimer</Text>
</Pressable>
</View>
</View>
);
}}
ListEmptyComponent={
<Text className="py-8 text-center text-slate-600">Aucune tâche à afficher.</Text>
}
/>
)}
<Pressable
onPress={openCreate}
className="absolute bottom-6 right-5 rounded-full bg-blue-700 px-4 py-3"
>
<Text className="font-semibold text-white">+ Tâche</Text>
</Pressable>
<Modal visible={modalOpen} animationType="slide" transparent>
<View className="flex-1 justify-end bg-black/40">
<View className="max-h-[85%] rounded-t-2xl bg-white p-4">
<Text className="text-lg font-bold text-slate-900">Nouvelle tâche</Text>
<Text className="mt-3 text-sm text-slate-500">Titre</Text>
<TextInput
className="mt-1 rounded-xl border border-slate-200 px-3 py-2 text-base text-slate-900"
value={newTitre}
onChangeText={setNewTitre}
placeholder="Appeler le notaire…"
placeholderTextColor="#94a3b8"
/>
<Text className="mt-3 text-sm text-slate-500">Échéance (AAAA-MM-JJ)</Text>
<TextInput
className="mt-1 rounded-xl border border-slate-200 px-3 py-2 text-base text-slate-900"
value={newDate}
onChangeText={setNewDate}
placeholder="2026-04-29"
placeholderTextColor="#94a3b8"
/>
<Text className="mt-4 text-sm text-slate-500">Bien (optionnel)</Text>
<ScrollView horizontal className="mt-2" keyboardShouldPersistTaps="handled">
<Pressable
onPress={() => setNewBienId(undefined)}
className={`mr-2 rounded-full px-3 py-2 ${newBienId == null ? 'bg-slate-800' : 'bg-slate-100'}`}
>
<Text className={`text-sm ${newBienId == null ? 'text-white' : 'text-slate-800'}`}>
Aucun
</Text>
</Pressable>
{biens.map((b) => (
<Pressable
key={b.id}
onPress={() => setNewBienId(b.id)}
className={`mr-2 max-w-[200px] rounded-full px-3 py-2 ${newBienId === b.id ? 'bg-blue-700' : 'bg-slate-100'}`}
>
<Text
numberOfLines={1}
className={`text-sm ${newBienId === b.id ? 'text-white' : 'text-slate-800'}`}
>
{b.titre?.trim() || b.ville || b.id}
</Text>
</Pressable>
))}
</ScrollView>
<View className="mt-6 flex-row gap-3">
<Pressable
onPress={() => setModalOpen(false)}
className="flex-1 items-center rounded-xl border border-slate-200 py-3"
>
<Text className="font-semibold text-slate-800">Annuler</Text>
</Pressable>
<Pressable
onPress={() => void submitCreate()}
disabled={isCreatePending}
className="flex-1 items-center rounded-xl bg-blue-700 py-3"
>
{isCreatePending ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="font-semibold text-white">Créer</Text>
)}
</Pressable>
</View>
</View>
</View>
</Modal>
</View>
</>
);
}

129
app/app/(tabs)/biens.tsx Normal file
View File

@ -0,0 +1,129 @@
import { Link, Stack } from 'expo-router';
import { useEffect, useMemo, useRef } from 'react';
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
import { useBiens, type BienExpanded } from '@/hooks/useBiens';
import { useEtapes } from '@/hooks/useEtapes';
import { formatEUR } from '@/utils/format';
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
export default function BiensScreen() {
const { biens, prixByBien, isLoading, error } = useBiens();
const {
etapes,
isLoading: etapesLoading,
error: etapesError,
initEtapesDefaut,
initError: etapesInitMutationError,
} = useEtapes();
const initOnce = useRef(false);
useEffect(() => {
if (etapesLoading || etapes.length > 0 || initOnce.current) return;
initOnce.current = true;
void initEtapesDefaut().catch(() => {
initOnce.current = false;
});
}, [etapesLoading, etapes.length, initEtapesDefaut]);
const grouped = useMemo(() => {
const m = new Map<string, BienExpanded[]>();
const none = '__none__';
for (const e of etapes) m.set(e.id, []);
m.set(none, []);
for (const b of biens) {
const k = b.etape && m.has(b.etape) ? b.etape : none;
if (!m.has(k)) m.set(k, []);
m.get(k)!.push(b);
}
return { m, none };
}, [biens, etapes]);
const banner =
error != null
? formatPocketBaseError(error)
: etapesError != null
? formatPocketBaseError(etapesError)
: etapesInitMutationError != null
? formatPocketBaseError(etapesInitMutationError)
: null;
return (
<>
<Stack.Screen options={{ title: 'Biens', headerShown: true }} />
<View className="flex-1 bg-slate-50">
{banner ? (
<View className="border-b border-red-200 bg-red-50 px-3 py-2">
<Text className="text-sm text-red-900">{banner}</Text>
</View>
) : null}
{isLoading || etapesLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#1D4ED8" />
</View>
) : (
<ScrollView horizontal className="flex-1" contentContainerStyle={{ padding: 12, paddingBottom: 96 }}>
{etapes.map((e) => {
const list = grouped.m.get(e.id) ?? [];
return (
<View key={e.id} className="mr-3 w-56 rounded-xl border border-slate-200 bg-white p-2">
<View className="mb-2 flex-row items-center justify-between border-b border-slate-100 pb-2">
<Text className="flex-1 font-bold text-slate-900" numberOfLines={2}>
{e.nom}
</Text>
<Text className="text-xs text-slate-500">{list.length}</Text>
</View>
<Text className="mb-2 text-xs text-slate-500">{list.length} bien(s)</Text>
<ScrollView nestedScrollEnabled>
{list.map((b) => (
<Link key={b.id} href={`/bien/${b.id}`} asChild>
<Pressable className="mb-2 rounded-lg border border-slate-100 bg-slate-50 p-2">
<Text className="font-medium text-slate-900" numberOfLines={2}>
{b.titre ?? 'Sans titre'}
</Text>
<Text className="text-xs text-slate-500" numberOfLines={1}>
{[b.code_postal, b.ville].filter(Boolean).join(' ') || '—'}
</Text>
{prixByBien.has(b.id) ? (
<Text className="mt-1 text-xs font-semibold text-slate-700">
{formatEUR(prixByBien.get(b.id))}
</Text>
) : null}
</Pressable>
</Link>
))}
</ScrollView>
</View>
);
})}
<View className="mr-3 w-56 rounded-xl border border-dashed border-slate-300 bg-slate-100/80 p-2">
<Text className="mb-2 font-bold text-slate-700">Sans étape</Text>
<Text className="mb-2 text-xs text-slate-500">
{(grouped.m.get(grouped.none) ?? []).length} bien(s)
</Text>
<ScrollView nestedScrollEnabled>
{(grouped.m.get(grouped.none) ?? []).map((b) => (
<Link key={b.id} href={`/bien/${b.id}`} asChild>
<Pressable className="mb-2 rounded-lg border border-slate-200 bg-white p-2">
<Text className="font-medium text-slate-900" numberOfLines={2}>
{b.titre ?? 'Sans titre'}
</Text>
</Pressable>
</Link>
))}
</ScrollView>
</View>
</ScrollView>
)}
<Link href="/bien/nouveau" asChild>
<Pressable
className="absolute bottom-6 right-5 h-14 w-14 items-center justify-center rounded-full bg-blue-700 shadow-md"
style={{ elevation: 6 }}
>
<Text className="text-3xl leading-8 font-light text-white">+</Text>
</Pressable>
</Link>
</View>
</>
);
}

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

@ -0,0 +1,112 @@
import { useMemo, useState } from 'react';
import { Link, Stack } from 'expo-router';
import {
ActivityIndicator,
Linking,
Pressable,
SectionList,
Text,
TextInput,
View,
} from 'react-native';
import { labelContactCategorie } from '@/constants/contactCategories';
import { useContactsList } from '@/hooks/useContacts';
import type { ContactRecord } from '@/types/collections';
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
function openTel(raw?: string | null) {
if (!raw?.trim()) return;
const n = raw.replace(/\s/g, '');
void Linking.openURL(`tel:${n}`);
}
export default function ContactsTab() {
const [search, setSearch] = useState('');
const q = useContactsList();
const sections = useMemo(() => {
const list = q.data ?? [];
const s = search.trim().toLowerCase();
const filtered =
s.length === 0
? list
: list.filter((c) =>
[c.nom, c.prenom, c.societe, c.email, c.telephone, c.telephone_2]
.some((f) => f?.toLowerCase().includes(s)),
);
const byCat = new Map<string, ContactRecord[]>();
for (const c of filtered) {
const k = c.categorie || 'autre';
if (!byCat.has(k)) byCat.set(k, []);
byCat.get(k)!.push(c);
}
return [...byCat.entries()]
.map(([key, data]) => ({
title: labelContactCategorie(key),
data: [...data].sort((a, b) =>
`${a.prenom ?? ''} ${a.nom}`.localeCompare(`${b.prenom ?? ''} ${b.nom}`, 'fr'),
),
}))
.sort((a, b) => a.title.localeCompare(b.title, 'fr'));
}, [q.data, search]);
return (
<>
<Stack.Screen options={{ title: 'Contacts', headerShown: true }} />
<View className="flex-1 bg-slate-50">
{q.error ? (
<Text className="p-4 text-red-700">{formatPocketBaseError(q.error)}</Text>
) : null}
<TextInput
className="mx-3 mt-2 rounded-xl border border-slate-200 bg-white px-3 py-2 text-base text-slate-900"
placeholder="Rechercher…"
placeholderTextColor="#94a3b8"
value={search}
onChangeText={setSearch}
/>
{q.isPending ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#1D4ED8" />
</View>
) : (
<SectionList
className="flex-1 px-3 pt-2"
sections={sections}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: 88 }}
renderSectionHeader={({ section: { title } }) => (
<Text className="pb-1 pt-3 text-xs font-semibold uppercase text-slate-500">{title}</Text>
)}
renderItem={({ item: c }) => (
<View className="mb-2 rounded-xl border border-slate-200 bg-white px-3 py-3">
<Link href={`/contact/${c.id}`} asChild>
<Pressable>
<Text className="font-semibold text-slate-900">
{c.prenom ? `${c.prenom} ` : ''}
{c.nom}
</Text>
{c.societe ? <Text className="text-sm text-slate-500">{c.societe}</Text> : null}
</Pressable>
</Link>
{c.telephone ? (
<Pressable onPress={() => openTel(c.telephone)} className="mt-2 self-start">
<Text className="text-sm text-blue-700">{c.telephone}</Text>
</Pressable>
) : null}
</View>
)}
ListEmptyComponent={
<Text className="py-6 text-center text-slate-600">Aucun contact.</Text>
}
/>
)}
<Link href="/contact/nouveau" asChild>
<Pressable className="absolute bottom-6 right-5 rounded-full bg-blue-700 px-4 py-3">
<Text className="font-semibold text-white">+ Contact</Text>
</Pressable>
</Link>
</View>
</>
);
}

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

@ -0,0 +1,193 @@
import { useMemo } from 'react';
import { Link, Stack } from 'expo-router';
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
import { useBiens } from '@/hooks/useBiens';
import { useEtapes } from '@/hooks/useEtapes';
import { useTachesList } from '@/hooks/useTaches';
import { TYPES_BIENS } from '@/constants/metier';
import type { BienExpanded } from '@/hooks/useBiens';
import {
isTaskActive,
parsePbDateOnly,
partitionTachesForAgenda,
startOfLocalDay,
} from '@/utils/agendaDates';
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
function bienTitre(b: BienExpanded): string {
return b.titre?.trim() || `${b.ville ?? ''} · ${TYPES_BIENS[b.type_bien ?? 'autre'] ?? 'Bien'}`.trim();
}
export default function DashboardScreen() {
const { biens, isLoading: biensLoading, error: biensError } = useBiens();
const { etapes, isLoading: etapesLoading } = useEtapes();
const { taches, isLoading: tachesLoading, error: tachesError } = useTachesList();
const loading = biensLoading || etapesLoading || tachesLoading;
const actifs = useMemo(
() => biens.filter((b) => !b.statut || b.statut === 'actif'),
[biens],
);
const urgent = useMemo(() => {
const start = startOfLocalDay(new Date());
return taches.filter((t) => {
if (!isTaskActive(t.statut)) return false;
if (t.is_urgent) return true;
const d = parsePbDateOnly(t.date_echeance);
return d != null && d < start;
});
}, [taches]);
const part = useMemo(() => partitionTachesForAgenda(taches), [taches]);
const etapeCounts = useMemo(() => {
const m = new Map<string, number>();
for (const b of biens) {
const k = b.etape ?? '';
m.set(k, (m.get(k) ?? 0) + 1);
}
return m;
}, [biens]);
const derniers = useMemo(() => [...biens].slice(0, 5), [biens]);
return (
<>
<Stack.Screen options={{ title: 'Dashboard', headerShown: true }} />
<ScrollView className="flex-1 bg-slate-50 p-4" contentContainerStyle={{ paddingBottom: 32 }}>
{biensError ? (
<Text className="mb-2 text-red-700">{formatPocketBaseError(biensError)}</Text>
) : null}
{tachesError ? (
<Text className="mb-2 text-red-700">{formatPocketBaseError(tachesError)}</Text>
) : null}
{loading ? (
<View className="py-8">
<ActivityIndicator color="#1D4ED8" />
</View>
) : null}
<Text className="mb-2 text-lg font-bold text-slate-900">Alertes urgentes</Text>
{urgent.length === 0 ? (
<Text className="mb-6 text-slate-600">Aucune alerte.</Text>
) : (
<View className="mb-6 gap-2">
{urgent.slice(0, 6).map((t) => (
<Link key={t.id} href="/(tabs)/agenda" asChild>
<Pressable className="rounded-xl border border-red-200 bg-red-50 px-3 py-2">
<Text className="font-medium text-red-900">{t.titre}</Text>
{t.date_echeance ? (
<Text className="text-xs text-red-700">{t.date_echeance}</Text>
) : null}
</Pressable>
</Link>
))}
</View>
)}
<Text className="mb-2 text-lg font-bold text-slate-900">Indicateurs</Text>
<View className="mb-6 flex-row flex-wrap gap-3">
<View className="min-w-[100px] flex-1 rounded-xl border border-slate-200 bg-white p-3">
<Text className="text-2xl font-bold text-slate-900">{biens.length}</Text>
<Text className="text-xs text-slate-500">Biens</Text>
</View>
<View className="min-w-[100px] flex-1 rounded-xl border border-slate-200 bg-white p-3">
<Text className="text-2xl font-bold text-slate-900">{actifs.length}</Text>
<Text className="text-xs text-slate-500">Actifs</Text>
</View>
<View className="min-w-[100px] flex-1 rounded-xl border border-slate-200 bg-white p-3">
<Text className="text-2xl font-bold text-slate-900">
{taches.filter(isTaskActive).length}
</Text>
<Text className="text-xs text-slate-500">Tâches ouvertes</Text>
</View>
</View>
<Text className="mb-2 text-lg font-bold text-slate-900">Pipeline</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="mb-6">
<View className="flex-row gap-2 pb-1">
{etapes.map((e) => {
const n = etapeCounts.get(e.id) ?? 0;
return (
<View
key={e.id}
className="min-w-[120px] rounded-xl border border-slate-200 bg-white px-3 py-2"
>
<View className="mb-1 h-1 rounded-full" style={{ backgroundColor: e.couleur }} />
<Text numberOfLines={2} className="text-xs font-semibold text-slate-800">
{e.nom}
</Text>
<Text className="mt-1 text-lg font-bold text-slate-900">{n}</Text>
</View>
);
})}
{(etapeCounts.get('') ?? 0) > 0 ? (
<View className="min-w-[120px] rounded-xl border border-dashed border-slate-300 bg-white px-3 py-2">
<Text className="text-xs font-semibold text-slate-600">Sans étape</Text>
<Text className="mt-1 text-lg font-bold text-slate-900">
{etapeCounts.get('') ?? 0}
</Text>
</View>
) : null}
</View>
</ScrollView>
<View className="mb-2 flex-row items-center justify-between">
<Text className="text-lg font-bold text-slate-900">Derniers biens</Text>
<Link href="/(tabs)/biens" className="text-sm font-semibold text-blue-700">
Voir tout
</Link>
</View>
<View className="mb-6 gap-2">
{derniers.length === 0 ? (
<Text className="text-slate-600">Aucun bien.</Text>
) : (
derniers.map((b) => (
<Link key={b.id} href={`/bien/${b.id}`} asChild>
<Pressable className="rounded-xl border border-slate-200 bg-white px-3 py-3">
<Text className="font-semibold text-slate-900">{bienTitre(b)}</Text>
<Text className="text-xs text-slate-500">
{b.expand?.etape?.nom ?? '—'} · {b.ville ?? ''}
</Text>
</Pressable>
</Link>
))
)}
</View>
<View className="mb-2 flex-row items-center justify-between">
<Text className="text-lg font-bold text-slate-900">Tâches du jour</Text>
<Link href="/(tabs)/agenda" className="text-sm font-semibold text-blue-700">
Agenda
</Link>
</View>
<View className="gap-2">
{part.today.length === 0 ? (
<Text className="text-slate-600">Rien de prévu aujourdhui.</Text>
) : (
part.today.map((t) => (
<View key={t.id} className="rounded-xl border border-slate-200 bg-white px-3 py-2">
<Text className="font-medium text-slate-900">{t.titre}</Text>
</View>
))
)}
</View>
<Text className="mb-2 mt-8 text-sm font-semibold uppercase text-slate-500">Raccourcis</Text>
<Link href="/bien/nouveau" className="mb-2 text-base font-semibold text-blue-700">
Nouveau bien
</Link>
<Link href="/(tabs)/contacts" className="mb-2 text-base font-semibold text-blue-700">
Contacts
</Link>
<Link href="/(tabs)/visites" className="mb-2 text-base font-semibold text-blue-700">
Visites
</Link>
</ScrollView>
</>
);
}

View File

@ -0,0 +1,45 @@
import { Stack, useRouter } from 'expo-router';
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
import { AVIS_VISITE } from '@/constants/metier';
import { useVisitesList } from '@/hooks/useVisites';
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
export default function VisitesTab() {
const router = useRouter();
const q = useVisitesList();
return (
<>
<Stack.Screen options={{ title: 'Visites', headerShown: true }} />
<View className="flex-1 bg-slate-50">
{q.error ? (
<Text className="p-4 text-red-700">{formatPocketBaseError(q.error)}</Text>
) : null}
{q.isPending ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#1D4ED8" />
</View>
) : (
<ScrollView className="flex-1 p-3">
{q.data?.length === 0 ? (
<Text className="text-slate-600">Aucune visite.</Text>
) : null}
{q.data?.map((v) => (
<Pressable
key={v.id}
className="mb-2 rounded-xl border border-slate-200 bg-white p-3"
onPress={() => router.push(`/visite/${v.id}`)}
>
<Text className="font-semibold text-slate-900">{v.date_visite?.slice(0, 10) ?? '—'}</Text>
<Text className="text-sm text-slate-600">
{v.avis_global ? AVIS_VISITE[v.avis_global]?.label ?? v.avis_global : '—'}
</Text>
</Pressable>
))}
</ScrollView>
)}
</View>
</>
);
}

18
app/app/_layout.tsx Normal file
View File

@ -0,0 +1,18 @@
import '../global.css';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Stack } from 'expo-router';
import { AuthProvider } from '@/context/AuthContext';
const queryClient = new QueryClient();
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Stack screenOptions={{ headerShown: false }} />
</AuthProvider>
</QueryClientProvider>
);
}

13
app/app/auth/_layout.tsx Normal file
View File

@ -0,0 +1,13 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerShown: true,
headerStyle: { backgroundColor: '#f8fafc' },
headerTitleStyle: { fontWeight: '700', color: '#0f172a' },
}}
/>
);
}

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

@ -0,0 +1,71 @@
import { Link, Stack, useRouter } from 'expo-router';
import { useState } from 'react';
import { Pressable, Text, TextInput, View } from 'react-native';
import { useAuth } from '@/context/AuthContext';
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
export default function LoginScreen() {
const router = useRouter();
const { login } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const onSubmit = async () => {
setErr(null);
setBusy(true);
try {
await login(email, password);
router.replace('/(tabs)');
} catch (e) {
setErr(formatPocketBaseError(e));
} finally {
setBusy(false);
}
};
return (
<>
<Stack.Screen options={{ title: 'Connexion' }} />
<View className="flex-1 justify-center bg-slate-50 px-6">
{err ? (
<View className="mb-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2">
<Text className="text-sm text-red-900">{err}</Text>
</View>
) : null}
<Text className="mb-1 text-sm text-slate-600">Email</Text>
<TextInput
className="mb-4 rounded-xl border border-slate-200 bg-white px-3 py-3 text-base text-slate-900"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
placeholderTextColor="#94a3b8"
/>
<Text className="mb-1 text-sm text-slate-600">Mot de passe</Text>
<TextInput
className="mb-6 rounded-xl border border-slate-200 bg-white px-3 py-3 text-base text-slate-900"
secureTextEntry
value={password}
onChangeText={setPassword}
placeholderTextColor="#94a3b8"
/>
<Pressable
className="mb-4 rounded-xl py-3"
style={{ backgroundColor: busy ? '#94a3b8' : '#1D4ED8' }}
onPress={onSubmit}
disabled={busy}
>
<Text className="text-center font-semibold text-white">Se connecter</Text>
</Pressable>
<Link href="/auth/register" asChild>
<Pressable>
<Text className="text-center text-blue-700">Créer un compte</Text>
</Pressable>
</Link>
</View>
</>
);
}

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

@ -0,0 +1,79 @@
import { Link, Stack, useRouter } from 'expo-router';
import { useState } from 'react';
import { Pressable, Text, TextInput, View } from 'react-native';
import { useAuth } from '@/context/AuthContext';
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
export default function RegisterScreen() {
const router = useRouter();
const { register } = useAuth();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const onSubmit = async () => {
setErr(null);
setBusy(true);
try {
await register({ name, email, password });
router.replace('/(tabs)');
} catch (e) {
setErr(formatPocketBaseError(e));
} finally {
setBusy(false);
}
};
return (
<>
<Stack.Screen options={{ title: 'Inscription' }} />
<View className="flex-1 justify-center bg-slate-50 px-6">
{err ? (
<View className="mb-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2">
<Text className="text-sm text-red-900">{err}</Text>
</View>
) : null}
<Text className="mb-1 text-sm text-slate-600">Nom</Text>
<TextInput
className="mb-3 rounded-xl border border-slate-200 bg-white px-3 py-3 text-base text-slate-900"
value={name}
onChangeText={setName}
placeholderTextColor="#94a3b8"
/>
<Text className="mb-1 text-sm text-slate-600">Email</Text>
<TextInput
className="mb-3 rounded-xl border border-slate-200 bg-white px-3 py-3 text-base text-slate-900"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
placeholderTextColor="#94a3b8"
/>
<Text className="mb-1 text-sm text-slate-600">Mot de passe</Text>
<TextInput
className="mb-6 rounded-xl border border-slate-200 bg-white px-3 py-3 text-base text-slate-900"
secureTextEntry
value={password}
onChangeText={setPassword}
placeholderTextColor="#94a3b8"
/>
<Pressable
className="mb-4 rounded-xl py-3"
style={{ backgroundColor: busy ? '#94a3b8' : '#1D4ED8' }}
onPress={onSubmit}
disabled={busy}
>
<Text className="text-center font-semibold text-white">S&apos;inscrire</Text>
</Pressable>
<Link href="/auth/login" asChild>
<Pressable>
<Text className="text-center text-blue-700">Déjà un compte ? Connexion</Text>
</Pressable>
</Link>
</View>
</>
);
}

234
app/app/bien/[id].tsx Normal file
View File

@ -0,0 +1,234 @@
import { Link, Stack, useLocalSearchParams, useRouter } from 'expo-router';
import type { ReactNode } from 'react';
import {
ActivityIndicator,
FlatList,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import { AVIS_VISITE, TYPES_BIENS } from '@/constants/metier';
import { useBienDetail } from '@/hooks/useBiens';
import { useNoteLibre } from '@/hooks/useNoteLibre';
import { calculateResults, type AnalyseFormInput } from '@/hooks/useAnalyse';
import { formatEUR } from '@/utils/format';
function routeParamId(raw: string | string[] | undefined): string | undefined {
if (raw == null) return undefined;
return Array.isArray(raw) ? raw[0] : raw;
}
export default function BienDetailScreen() {
const { id: rawId } = useLocalSearchParams<{ id?: string | string[] }>();
const id = routeParamId(rawId);
const router = useRouter();
const { bundle, isLoading, error } = useBienDetail(id);
const { draft, setDraft, hydrated } = useNoteLibre(id, bundle?.notes);
if (!id) {
return (
<>
<Stack.Screen options={{ title: 'Bien', headerShown: true }} />
<View className="flex-1 items-center justify-center bg-slate-50 p-6">
<Text className="text-slate-600">Identifiant manquant.</Text>
</View>
</>
);
}
if (isLoading) {
return (
<>
<Stack.Screen options={{ title: 'Chargement…', headerShown: true }} />
<View className="flex-1 items-center justify-center bg-slate-50">
<ActivityIndicator size="large" color="#1D4ED8" />
</View>
</>
);
}
if (error || !bundle) {
return (
<>
<Stack.Screen options={{ title: 'Erreur', headerShown: true }} />
<View className="flex-1 items-center justify-center bg-slate-50 p-6">
<Text className="text-center text-slate-600">
{error instanceof Error ? error.message : 'Impossible de charger ce bien.'}
</Text>
<Pressable
className="mt-4 rounded-xl px-4 py-2"
style={{ backgroundColor: '#1D4ED8' }}
onPress={() => router.replace('/(tabs)/biens')}
>
<Text className="font-semibold text-white">Vers la liste des biens</Text>
</Pressable>
</View>
</>
);
}
const { bien, visites, notes, documents, analyse } = bundle;
const etape = bien.expand?.etape;
const analyseInput: AnalyseFormInput = {
prix_achat: analyse?.prix_achat,
type_bien_fiscal: analyse?.type_bien_fiscal,
frais_notaire: analyse?.frais_notaire,
frais_agence_achat: analyse?.frais_agence_achat,
budget_travaux: analyse?.budget_travaux,
reserve_imprevus_pct: analyse?.reserve_imprevus_pct,
duree_portage_mois: analyse?.duree_portage_mois,
taux_credit: analyse?.taux_credit,
taxe_fonciere_annuelle: analyse?.taxe_fonciere_annuelle,
charges_copropriete_mensuelle: analyse?.charges_copropriete_mensuelle,
prix_revente_cible: analyse?.prix_revente_cible,
frais_agence_vente_pct: analyse?.frais_agence_vente_pct,
taux_impot: analyse?.taux_impot,
};
const calc = calculateResults(analyseInput);
return (
<>
<Stack.Screen options={{ title: bien.titre ?? 'Bien', headerShown: true }} />
<ScrollView className="flex-1 bg-slate-50" contentContainerStyle={{ paddingBottom: 48 }}>
<Section title="En-tête">
{etape ? (
<View
className="self-start rounded-full px-3 py-1"
style={{ backgroundColor: `${etape.couleur ?? '#64748B'}33` }}
>
<Text className="text-sm font-semibold text-slate-900">{etape.nom}</Text>
</View>
) : (
<Text className="text-slate-600">Aucune étape assignée.</Text>
)}
<Text className="mt-2 text-2xl font-bold text-slate-900">{bien.titre ?? 'Sans titre'}</Text>
<Text className="mt-1 text-slate-600">
{[bien.adresse, bien.code_postal, bien.ville].filter(Boolean).join(' · ') || '—'}
</Text>
<Link href={`/calculateur/${bien.id}`} asChild>
<Pressable className="mt-4 self-start rounded-xl px-4 py-2" style={{ backgroundColor: '#1D4ED8' }}>
<Text className="font-semibold text-white">Ouvrir le calculateur</Text>
</Pressable>
</Link>
</Section>
<Section title="Infos">
<InfoLine label="Type" value={bien.type_bien ? TYPES_BIENS[bien.type_bien] ?? bien.type_bien : '—'} />
<InfoLine
label="Surface habitable"
value={bien.surface_habitable != null ? `${bien.surface_habitable}` : '—'}
/>
<InfoLine label="Pièces" value={bien.nb_pieces != null ? String(bien.nb_pieces) : '—'} />
<InfoLine label="Source" value={bien.source ?? '—'} />
<InfoLine label="Off-market" value={bien.is_off_market ? 'Oui' : 'Non'} />
<InfoLine label="Priorité" value={bien.priorite != null ? String(bien.priorite) : '—'} />
</Section>
<Section title="Finances">
{!analyse ? (
<Text className="text-slate-600">Aucune analyse enregistrée. Utilisez le calculateur.</Text>
) : (
<>
<InfoLine label="Prix d'achat" value={formatEUR(analyse.prix_achat)} />
<InfoLine label="Frais notaire (calc.)" value={formatEUR(calc.frais_notaire)} />
<InfoLine label="Travaux (total)" value={formatEUR(calc.travaux_total)} />
<InfoLine label="Portage (total)" value={formatEUR(calc.frais_portage_total)} />
<InfoLine label="Prix de revient" value={formatEUR(calc.prix_revient)} />
<InfoLine label="Prix revente cible" value={formatEUR(analyse.prix_revente_cible)} />
<InfoLine label="Marge brute" value={formatEUR(calc.marge_brute)} />
<InfoLine label="Marge nette" value={formatEUR(calc.marge_nette)} />
</>
)}
</Section>
<Section title="Visites">
{visites.length === 0 ? (
<Text className="text-slate-600">Aucune visite.</Text>
) : (
<FlatList
data={visites}
keyExtractor={(v) => v.id}
scrollEnabled={false}
renderItem={({ item }) => (
<Pressable
className="mb-2 rounded-xl border border-slate-200 bg-white p-3"
onPress={() => router.push(`/visite/${item.id}`)}
>
<Text className="font-semibold text-slate-900">{item.date_visite?.slice(0, 10) ?? '—'}</Text>
<Text className="text-sm text-slate-600">
{item.avis_global ? AVIS_VISITE[item.avis_global]?.label ?? item.avis_global : '—'}
</Text>
</Pressable>
)}
/>
)}
</Section>
<Section title="Notes">
<Text className="mb-2 text-xs text-slate-500">
Note libre (sauvegarde automatique après 500 ms sans frappe).
</Text>
{!hydrated ? (
<ActivityIndicator color="#1D4ED8" />
) : (
<TextInput
className="min-h-[120px] rounded-xl border border-slate-200 bg-white p-3 text-base text-slate-900"
multiline
textAlignVertical="top"
placeholder="Écrivez vos notes…"
placeholderTextColor="#94a3b8"
value={draft}
onChangeText={setDraft}
/>
)}
{notes.some((n) => n.type_note && n.type_note !== 'libre') ? (
<Text className="mt-3 text-xs font-semibold uppercase text-slate-500">Autres notes</Text>
) : null}
{notes
.filter((n) => n.type_note && n.type_note !== 'libre')
.map((n) => (
<View key={n.id} className="mt-2 rounded-lg border border-slate-100 bg-white p-2">
<Text className="text-xs text-slate-400">{n.updated?.slice(0, 16) ?? ''}</Text>
<Text className="text-sm text-slate-800">{n.contenu}</Text>
</View>
))}
</Section>
<Section title="Documents">
{documents.length === 0 ? (
<Text className="text-slate-600">Aucun document.</Text>
) : (
documents.map((d) => (
<View key={d.id} className="mb-2 rounded-xl border border-slate-200 bg-white px-3 py-2">
<Text className="font-medium text-slate-900">{d.nom}</Text>
{d.type_document ? <Text className="text-xs text-slate-500">{d.type_document}</Text> : null}
</View>
))
)}
</Section>
</ScrollView>
</>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<View className="mb-4 border-b border-slate-200 px-4 pb-4 pt-2">
<Text className="mb-3 text-lg font-bold text-slate-900">{title}</Text>
{children}
</View>
);
}
function InfoLine({ label, value }: { label: string; value: string }) {
return (
<View className="mb-2 flex-row justify-between">
<Text className="text-sm text-slate-500">{label}</Text>
<Text className="max-w-[55%] text-right text-sm font-medium text-slate-900">{value}</Text>
</View>
);
}

418
app/app/bien/nouveau.tsx Normal file
View File

@ -0,0 +1,418 @@
import { Stack, useRouter } from 'expo-router';
import { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Modal,
Pressable,
ScrollView,
Switch,
Text,
TextInput,
View,
} from 'react-native';
import { TYPES_BIENS } from '@/constants/metier';
import { useBiens } from '@/hooks/useBiens';
import { useEtapes } from '@/hooks/useEtapes';
import type { BienSource, BienType } from '@/types/collections';
import { getCurrentUserId } from '@/services/pocketbase';
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
const SOURCES: BienSource[] = [
'particulier',
'agence',
'notaire',
'tribunal',
'succession',
'reseau',
'autre',
];
const SOURCE_LABELS: Record<BienSource, string> = {
particulier: 'Particulier',
agence: 'Agence',
notaire: 'Notaire',
tribunal: 'Tribunal',
succession: 'Succession',
reseau: 'Réseau',
autre: 'Autre',
};
function parseNum(raw: string): number | undefined {
const n = Number(raw.replace(',', '.').trim());
return Number.isFinite(n) ? n : undefined;
}
function ErrorBanner({ message }: { message: string }) {
return (
<View className="mb-4 rounded-xl border border-red-200 bg-red-50 px-3 py-3">
<Text className="text-sm leading-5 text-red-900">{message}</Text>
</View>
);
}
export default function BienNouveauScreen() {
const router = useRouter();
const uid = getCurrentUserId();
const initOnce = useRef(false);
const createInFlight = useRef(false);
const {
etapes,
isLoading: etapesLoading,
initEtapesDefaut,
error: etapesQueryError,
initError: etapesInitMutationError,
} = useEtapes();
const { createBien } = useBiens({});
const [step, setStep] = useState(1);
const [stepHint, setStepHint] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [initPipelineMsg, setInitPipelineMsg] = useState<string | null>(null);
const [typeBien, setTypeBien] = useState<BienType>('appartement');
const [pickerTypeOpen, setPickerTypeOpen] = useState(false);
const [adresse, setAdresse] = useState('');
const [ville, setVille] = useState('');
const [codePostal, setCodePostal] = useState('');
const [surface, setSurface] = useState('');
const [nbPieces, setNbPieces] = useState('');
const [prixEstime, setPrixEstime] = useState('');
const [source, setSource] = useState<BienSource>('particulier');
const [pickerSourceOpen, setPickerSourceOpen] = useState(false);
const [offMarket, setOffMarket] = useState(false);
const [priorite, setPriorite] = useState('2');
const [noteProjet, setNoteProjet] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
setStepHint(null);
}, [step]);
useEffect(() => {
if (step !== 3) {
setCreateError(null);
}
}, [step]);
useEffect(() => {
if (etapesLoading || etapes.length > 0 || initOnce.current) return;
initOnce.current = true;
void initEtapesDefaut()
.then(() => {
setInitPipelineMsg(null);
})
.catch((e: unknown) => {
initOnce.current = false;
setInitPipelineMsg(formatPocketBaseError(e));
});
}, [etapesLoading, etapes.length, initEtapesDefaut]);
const firstEtapeId = etapes[0]?.id;
const canNext1 = ville.trim().length > 0 && codePostal.trim().length > 0;
const canNext2 =
parseNum(surface) != null &&
parseNum(nbPieces) != null &&
parseNum(prixEstime) != null &&
parseNum(prixEstime)! > 0;
const pipelineBanner =
etapesQueryError != null
? formatPocketBaseError(etapesQueryError)
: etapesInitMutationError != null
? formatPocketBaseError(etapesInitMutationError)
: initPipelineMsg;
const goNext1 = () => {
if (canNext1) {
setStep(2);
return;
}
setStepHint('Renseignez la ville et le code postal pour continuer.');
};
const goNext2 = () => {
if (canNext2) {
setStep(3);
return;
}
setStepHint('Indiquez une surface, un nombre de pièces et un prix dachat estimé (> 0).');
};
const onCreate = async () => {
if (!uid) {
setCreateError('Vous devez être connecté.');
return;
}
if (createInFlight.current) return;
createInFlight.current = true;
setCreateError(null);
setSubmitting(true);
try {
const titre =
`${TYPES_BIENS[typeBien] ?? typeBien}${ville.trim()}`.trim() || `Bien — ${ville.trim()}`;
const id = await createBien({
bien: {
user: uid,
...(firstEtapeId ? { etape: firstEtapeId } : {}),
type_bien: typeBien,
adresse: adresse.trim() || undefined,
ville: ville.trim(),
code_postal: codePostal.trim(),
titre,
surface_habitable: parseNum(surface),
nb_pieces: parseNum(nbPieces),
source,
is_off_market: offMarket,
priorite: parseNum(priorite) ?? 2,
statut: 'actif',
description: noteProjet.trim() || undefined,
},
prixEstime: parseNum(prixEstime),
});
router.replace(`/bien/${id}`);
} catch (e: unknown) {
setCreateError(formatPocketBaseError(e));
} finally {
createInFlight.current = false;
setSubmitting(false);
}
};
if (!uid) {
return (
<>
<Stack.Screen options={{ title: 'Nouveau bien', headerShown: true }} />
<View className="flex-1 items-center justify-center bg-slate-50 p-6">
<Text className="text-center text-slate-600">Connectez-vous pour créer un bien.</Text>
</View>
</>
);
}
return (
<>
<Stack.Screen options={{ title: 'Nouveau bien', headerShown: true }} />
<ScrollView
className="flex-1 bg-slate-50"
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 64 }}
>
{pipelineBanner ? <ErrorBanner message={pipelineBanner} /> : null}
<View className="mb-6 flex-row gap-2">
{[1, 2, 3].map((s) => (
<View
key={s}
className="h-2 flex-1 rounded-full"
style={{ backgroundColor: step >= s ? '#1D4ED8' : '#E2E8F0' }}
/>
))}
</View>
<Text className="mb-1 text-xs font-semibold uppercase text-slate-500">Étape {step} / 3</Text>
{stepHint ? <ErrorBanner message={stepHint} /> : null}
{step === 3 && createError ? <ErrorBanner message={createError} /> : null}
{!firstEtapeId && !etapesLoading ? (
<View className="mb-4 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2">
<Text className="text-sm text-amber-900">
Aucune étape pipeline disponible. Le bien sera créé sans étape ; vous pourrez lassigner plus tard.
</Text>
</View>
) : null}
{step === 1 ? (
<View>
<Text className="mb-2 text-lg font-bold text-slate-900">Localisation</Text>
<Text className="mb-1 text-sm text-slate-600">Type de bien</Text>
<Pressable
className="mb-4 rounded-xl border border-slate-200 bg-white px-3 py-3"
onPress={() => setPickerTypeOpen(true)}
>
<Text className="text-base text-slate-900">{TYPES_BIENS[typeBien]}</Text>
</Pressable>
<Field label="Adresse" value={adresse} onChangeText={setAdresse} />
<Field label="Ville *" value={ville} onChangeText={setVille} />
<Field label="Code postal *" value={codePostal} onChangeText={setCodePostal} />
<NavButtons showPrev={false} onNext={goNext1} />
</View>
) : null}
{step === 2 ? (
<View>
<Text className="mb-2 text-lg font-bold text-slate-900">Caractéristiques</Text>
<Field label="Surface habitable (m²) *" value={surface} onChangeText={setSurface} keyboard="numeric" />
<Field label="Nombre de pièces *" value={nbPieces} onChangeText={setNbPieces} keyboard="numeric" />
<Field label="Prix d'achat estimé (€) *" value={prixEstime} onChangeText={setPrixEstime} keyboard="numeric" />
<Text className="mb-1 mt-2 text-sm text-slate-600">Source</Text>
<Pressable
className="mb-4 rounded-xl border border-slate-200 bg-white px-3 py-3"
onPress={() => setPickerSourceOpen(true)}
>
<Text className="text-base text-slate-900">{SOURCE_LABELS[source]}</Text>
</Pressable>
<View className="mb-4 flex-row items-center justify-between rounded-xl border border-slate-200 bg-white px-3 py-3">
<Text className="text-base text-slate-800">Off-market</Text>
<Switch value={offMarket} onValueChange={setOffMarket} />
</View>
<Field label="Priorité (15)" value={priorite} onChangeText={setPriorite} keyboard="numeric" />
<Text className="mb-1 mt-2 text-sm text-slate-600">Note (optionnel)</Text>
<TextInput
className="mb-4 min-h-[100px] rounded-xl border border-slate-200 bg-white px-3 py-3 text-base text-slate-900"
placeholder="Contexte, contact, remarques…"
placeholderTextColor="#94a3b8"
multiline
textAlignVertical="top"
value={noteProjet}
onChangeText={setNoteProjet}
/>
<NavButtons onPrev={() => setStep(1)} onNext={goNext2} />
</View>
) : null}
{step === 3 ? (
<View>
<Text className="mb-2 text-lg font-bold text-slate-900">Résumé</Text>
<SummaryRow label="Type" value={TYPES_BIENS[typeBien]} />
<SummaryRow label="Adresse" value={[adresse, codePostal, ville].filter(Boolean).join(', ') || '—'} />
<SummaryRow label="Surface" value={surface ? `${surface}` : '—'} />
<SummaryRow label="Pièces" value={nbPieces || '—'} />
<SummaryRow label="Prix estimé" value={prixEstime ? `${prixEstime}` : '—'} />
<SummaryRow label="Source" value={SOURCE_LABELS[source]} />
<SummaryRow label="Off-market" value={offMarket ? 'Oui' : 'Non'} />
<SummaryRow label="Priorité" value={priorite} />
<SummaryRow
label="Note"
value={
noteProjet.trim()
? noteProjet.length > 80
? `${noteProjet.slice(0, 80)}`
: noteProjet
: '—'
}
/>
<SummaryRow label="Étape" value={firstEtapeId ? etapes[0]?.nom ?? '—' : 'Non assignée'} />
<NavButtons
onPrev={() => setStep(2)}
onNext={onCreate}
nextLabel={submitting ? 'Création…' : 'Créer'}
nextDisabled={submitting || etapesLoading}
/>
{etapesLoading ? <ActivityIndicator className="mt-4" color="#1D4ED8" /> : null}
</View>
) : null}
</ScrollView>
<Modal visible={pickerTypeOpen} transparent animationType="fade">
<Pressable className="flex-1 justify-end bg-black/40" onPress={() => setPickerTypeOpen(false)}>
<View className="rounded-t-2xl bg-white p-4">
<Text className="mb-3 text-lg font-bold">Type de bien</Text>
<ScrollView style={{ maxHeight: 360 }}>
{(Object.keys(TYPES_BIENS) as BienType[]).map((k) => (
<Pressable
key={k}
className="border-b border-slate-100 py-3"
onPress={() => {
setTypeBien(k);
setPickerTypeOpen(false);
}}
>
<Text className="text-base text-slate-900">{TYPES_BIENS[k]}</Text>
</Pressable>
))}
</ScrollView>
</View>
</Pressable>
</Modal>
<Modal visible={pickerSourceOpen} transparent animationType="fade">
<Pressable className="flex-1 justify-end bg-black/40" onPress={() => setPickerSourceOpen(false)}>
<View className="rounded-t-2xl bg-white p-4">
<Text className="mb-3 text-lg font-bold">Source</Text>
{SOURCES.map((k) => (
<Pressable
key={k}
className="border-b border-slate-100 py-3"
onPress={() => {
setSource(k);
setPickerSourceOpen(false);
}}
>
<Text className="text-base text-slate-900">{SOURCE_LABELS[k]}</Text>
</Pressable>
))}
</View>
</Pressable>
</Modal>
</>
);
}
function Field({
label,
value,
onChangeText,
keyboard,
}: {
label: string;
value: string;
onChangeText: (t: string) => void;
keyboard?: 'numeric';
}) {
return (
<View className="mb-3">
<Text className="mb-1 text-sm text-slate-600">{label}</Text>
<TextInput
className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-base text-slate-900"
value={value}
onChangeText={onChangeText}
keyboardType={keyboard === 'numeric' ? 'decimal-pad' : 'default'}
placeholderTextColor="#94a3b8"
/>
</View>
);
}
function SummaryRow({ label, value }: { label: string; value: string }) {
return (
<View className="mb-2 flex-row justify-between border-b border-slate-100 py-2">
<Text className="text-sm text-slate-500">{label}</Text>
<Text className="max-w-[60%] text-right text-sm font-medium text-slate-900">{value}</Text>
</View>
);
}
function NavButtons({
showPrev = true,
onPrev,
onNext,
nextLabel = 'Suivant',
nextDisabled,
}: {
showPrev?: boolean;
onPrev?: () => void;
onNext: () => void;
nextLabel?: string;
nextDisabled?: boolean;
}) {
return (
<View className="mt-6 flex-row justify-between gap-3">
{showPrev ? (
<Pressable className="flex-1 rounded-xl border border-slate-300 py-3" onPress={onPrev}>
<Text className="text-center font-semibold text-slate-800">Retour</Text>
</Pressable>
) : (
<View className="flex-1" />
)}
<Pressable
className="flex-1 rounded-xl py-3"
style={{ backgroundColor: nextDisabled ? '#94a3b8' : '#1D4ED8' }}
onPress={onNext}
disabled={nextDisabled}
>
<Text className="text-center font-semibold text-white">{nextLabel}</Text>
</Pressable>
</View>
);
}

View File

@ -0,0 +1,164 @@
import { Stack, useLocalSearchParams } from 'expo-router';
import { useEffect, useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import { useAnalyse, rendementColor } from '@/hooks/useAnalyse';
import { formatEUR } from '@/utils/format';
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
function routeParamId(raw: string | string[] | undefined): string | undefined {
if (raw == null) return undefined;
return Array.isArray(raw) ? raw[0] : raw;
}
export default function CalculateurScreen() {
const { bienId: raw } = useLocalSearchParams<{ bienId?: string | string[] }>();
const bienId = routeParamId(raw);
const { analyse, isLoading, saveAnalyse, isSaving, calculateResults: calcFn } = useAnalyse(bienId);
const [prixAchat, setPrixAchat] = useState('');
const [typeFiscal, setTypeFiscal] = useState<'ancien' | 'neuf'>('ancien');
const [budgetTravaux, setBudgetTravaux] = useState('');
const [prixRevente, setPrixRevente] = useState('');
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
if (!analyse) return;
setPrixAchat(analyse.prix_achat != null ? String(analyse.prix_achat) : '');
setTypeFiscal(analyse.type_bien_fiscal ?? 'ancien');
setBudgetTravaux(analyse.budget_travaux != null ? String(analyse.budget_travaux) : '');
setPrixRevente(analyse.prix_revente_cible != null ? String(analyse.prix_revente_cible) : '');
}, [analyse]);
const parsed = {
prix_achat: Number(prixAchat.replace(',', '.')) || 0,
type_bien_fiscal: typeFiscal,
budget_travaux: Number(budgetTravaux.replace(',', '.')) || 0,
prix_revente_cible: Number(prixRevente.replace(',', '.')) || 0,
};
const calc = calcFn(parsed);
const onSave = async () => {
setErr(null);
try {
await saveAnalyse({
prix_achat: parsed.prix_achat,
type_bien_fiscal: typeFiscal,
budget_travaux: parsed.budget_travaux,
prix_revente_cible: parsed.prix_revente_cible,
});
} catch (e) {
setErr(formatPocketBaseError(e));
}
};
if (!bienId) {
return (
<>
<Stack.Screen options={{ title: 'Calculateur', headerShown: true }} />
<View className="flex-1 items-center justify-center p-6">
<Text>Bien manquant.</Text>
</View>
</>
);
}
return (
<>
<Stack.Screen options={{ title: 'Calculateur', headerShown: true }} />
<KeyboardAvoidingView
className="flex-1 bg-slate-50"
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#1D4ED8" />
</View>
) : (
<ScrollView className="flex-1 p-4" contentContainerStyle={{ paddingBottom: 40 }}>
{err ? (
<Text className="mb-2 text-red-700">{err}</Text>
) : null}
<Text className="mb-1 text-sm text-slate-600">Prix d&apos;achat ()</Text>
<TextInput
className="mb-3 rounded-xl border border-slate-200 bg-white px-3 py-3"
keyboardType="decimal-pad"
value={prixAchat}
onChangeText={setPrixAchat}
placeholderTextColor="#94a3b8"
/>
<Text className="mb-1 text-sm text-slate-600">Type fiscal</Text>
<View className="mb-3 flex-row gap-2">
<Pressable
className="flex-1 rounded-xl border px-3 py-2"
style={{
borderColor: typeFiscal === 'ancien' ? '#1D4ED8' : '#e2e8f0',
backgroundColor: typeFiscal === 'ancien' ? '#eff6ff' : '#fff',
}}
onPress={() => setTypeFiscal('ancien')}
>
<Text className="text-center font-medium">Ancien</Text>
</Pressable>
<Pressable
className="flex-1 rounded-xl border px-3 py-2"
style={{
borderColor: typeFiscal === 'neuf' ? '#1D4ED8' : '#e2e8f0',
backgroundColor: typeFiscal === 'neuf' ? '#eff6ff' : '#fff',
}}
onPress={() => setTypeFiscal('neuf')}
>
<Text className="text-center font-medium">Neuf</Text>
</Pressable>
</View>
<Text className="mb-1 text-sm text-slate-600">Budget travaux ()</Text>
<TextInput
className="mb-3 rounded-xl border border-slate-200 bg-white px-3 py-3"
keyboardType="decimal-pad"
value={budgetTravaux}
onChangeText={setBudgetTravaux}
placeholderTextColor="#94a3b8"
/>
<Text className="mb-1 text-sm text-slate-600">Prix revente cible ()</Text>
<TextInput
className="mb-4 rounded-xl border border-slate-200 bg-white px-3 py-3"
keyboardType="decimal-pad"
value={prixRevente}
onChangeText={setPrixRevente}
placeholderTextColor="#94a3b8"
/>
<View className="mb-4 rounded-xl border border-slate-200 bg-white p-3">
<Text className="text-sm font-semibold text-slate-800">Aperçu</Text>
<Text className="mt-1 text-slate-700">Frais notaire (estim.) : {formatEUR(calc.frais_notaire)}</Text>
<Text className="text-slate-700">Prix de revient : {formatEUR(calc.prix_revient)}</Text>
<Text className="text-slate-700">Marge nette : {formatEUR(calc.marge_nette)}</Text>
<Text style={{ color: rendementColor(calc.rendement_net_pct) }} className="mt-1 font-bold">
Rendement net / revient : {calc.rendement_net_pct.toFixed(1)} %
</Text>
</View>
<Pressable
className="rounded-xl py-3"
style={{ backgroundColor: isSaving ? '#94a3b8' : '#1D4ED8' }}
onPress={onSave}
disabled={isSaving}
>
<Text className="text-center font-semibold text-white">
{isSaving ? 'Enregistrement…' : 'Enregistrer'}
</Text>
</Pressable>
</ScrollView>
)}
</KeyboardAvoidingView>
</>
);
}

164
app/app/contact/[id].tsx Normal file
View File

@ -0,0 +1,164 @@
import { Link, Stack, useLocalSearchParams } from 'expo-router';
import {
ActivityIndicator,
Linking,
Pressable,
ScrollView,
Text,
View,
} from 'react-native';
import { labelContactCategorie } from '@/constants/contactCategories';
import { useContactBiens, useContactDetail } from '@/hooks/useContacts';
import { getCurrentUserId } from '@/services/pocketbase';
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
function routeParamId(raw: string | string[] | undefined): string | undefined {
if (raw == null) return undefined;
return Array.isArray(raw) ? raw[0] : raw;
}
function openTel(raw?: string | null) {
if (!raw?.trim()) return;
void Linking.openURL(`tel:${raw.replace(/\s/g, '')}`);
}
function openMail(raw?: string | null) {
if (!raw?.trim()) return;
void Linking.openURL(`mailto:${raw.trim()}`);
}
export default function ContactDetailScreen() {
const { id: rawId } = useLocalSearchParams<{ id?: string | string[] }>();
const id = routeParamId(rawId);
const uid = getCurrentUserId();
const q = useContactDetail(id);
const biensQ = useContactBiens(id);
if (!id) {
return (
<>
<Stack.Screen options={{ title: 'Contact', headerShown: true }} />
<View className="flex-1 items-center justify-center p-6">
<Text>Identifiant manquant.</Text>
</View>
</>
);
}
if (q.isPending) {
return (
<>
<Stack.Screen options={{ title: '…', headerShown: true }} />
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#1D4ED8" />
</View>
</>
);
}
if (q.error || !q.data) {
return (
<>
<Stack.Screen options={{ title: 'Erreur', headerShown: true }} />
<View className="flex-1 items-center justify-center p-6">
<Text className="text-center text-red-700">
{q.error ? formatPocketBaseError(q.error) : 'Introuvable.'}
</Text>
</View>
</>
);
}
const c = q.data;
if (uid && c.user !== uid) {
return (
<>
<Stack.Screen options={{ title: 'Contact', headerShown: true }} />
<View className="flex-1 items-center justify-center p-6">
<Text>Accès refusé.</Text>
</View>
</>
);
}
return (
<>
<Stack.Screen options={{ title: c.nom, headerShown: true }} />
<ScrollView className="flex-1 bg-slate-50 p-4">
<Text className="text-xl font-bold text-slate-900">
{c.prenom ? `${c.prenom} ` : ''}
{c.nom}
</Text>
{c.societe ? <Text className="mt-1 text-slate-600">{c.societe}</Text> : null}
<Text className="mt-3 text-sm text-slate-500">Catégorie</Text>
<Text className="text-base text-slate-900">{labelContactCategorie(c.categorie)}</Text>
{c.email ? (
<>
<Text className="mt-3 text-sm text-slate-500">Email</Text>
<Pressable onPress={() => openMail(c.email)}>
<Text className="text-base text-blue-700">{c.email}</Text>
</Pressable>
</>
) : null}
{c.telephone ? (
<>
<Text className="mt-3 text-sm text-slate-500">Téléphone</Text>
<Pressable onPress={() => openTel(c.telephone)}>
<Text className="text-base text-blue-700">{c.telephone}</Text>
</Pressable>
</>
) : null}
{c.telephone_2 ? (
<>
<Text className="mt-3 text-sm text-slate-500">Téléphone 2</Text>
<Pressable onPress={() => openTel(c.telephone_2)}>
<Text className="text-base text-blue-700">{c.telephone_2}</Text>
</Pressable>
</>
) : null}
{(c.ville || c.zone_intervention) ? (
<>
<Text className="mt-3 text-sm text-slate-500">Localisation</Text>
<Text className="text-base text-slate-900">
{[c.ville, c.zone_intervention].filter(Boolean).join(' · ')}
</Text>
</>
) : null}
{c.notes ? (
<>
<Text className="mt-5 text-lg font-bold text-slate-900">Notes</Text>
<Text className="mt-1 text-slate-800">{c.notes}</Text>
</>
) : null}
<Text className="mt-6 text-lg font-bold text-slate-900">Biens associés</Text>
{biensQ.isPending ? (
<ActivityIndicator className="mt-2" color="#1D4ED8" />
) : biensQ.error ? (
<Text className="mt-2 text-red-700">{formatPocketBaseError(biensQ.error)}</Text>
) : (biensQ.data?.length ?? 0) === 0 ? (
<Text className="mt-2 text-slate-600">Aucun bien lié (source contact).</Text>
) : (
<View className="mt-2 gap-2">
{biensQ.data!.map((b) => (
<Link key={b.id} href={`/bien/${b.id}`} asChild>
<Pressable className="rounded-xl border border-slate-200 bg-white px-3 py-3">
<Text className="font-semibold text-slate-900">
{b.titre?.trim() || `${b.ville ?? ''} (${b.type_bien ?? 'bien'})`.trim()}
</Text>
<Text className="text-sm text-slate-500">
{[b.adresse, b.code_postal, b.ville].filter(Boolean).join(', ')}
</Text>
</Pressable>
</Link>
))}
</View>
)}
</ScrollView>
</>
);
}

View File

@ -0,0 +1,87 @@
import { Stack, useRouter } from 'expo-router';
import { useState } from 'react';
import { Pressable, Text, TextInput, View } from 'react-native';
import { getCurrentUserId, pb } from '@/services/pocketbase';
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
export default function ContactNouveauScreen() {
const router = useRouter();
const uid = getCurrentUserId();
const [nom, setNom] = useState('');
const [prenom, setPrenom] = useState('');
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const onSave = async () => {
if (!uid) {
setErr('Connectez-vous.');
return;
}
if (!nom.trim()) {
setErr('Le nom est obligatoire.');
return;
}
setErr(null);
setBusy(true);
try {
const c = await pb.collection('contacts').create({
user: uid,
nom: nom.trim(),
prenom: prenom.trim() || undefined,
categorie: 'autre',
});
router.replace(`/contact/${c.id}`);
} catch (e) {
setErr(formatPocketBaseError(e));
} finally {
setBusy(false);
}
};
if (!uid) {
return (
<>
<Stack.Screen options={{ title: 'Nouveau contact', headerShown: true }} />
<View className="flex-1 items-center justify-center p-6">
<Text className="text-slate-600">Connexion requise.</Text>
</View>
</>
);
}
return (
<>
<Stack.Screen options={{ title: 'Nouveau contact', headerShown: true }} />
<View className="flex-1 bg-slate-50 p-4">
{err ? (
<View className="mb-3 rounded-xl border border-red-200 bg-red-50 px-3 py-2">
<Text className="text-sm text-red-900">{err}</Text>
</View>
) : null}
<Text className="mb-1 text-sm text-slate-600">Nom *</Text>
<TextInput
className="mb-3 rounded-xl border border-slate-200 bg-white px-3 py-3 text-base"
value={nom}
onChangeText={setNom}
placeholderTextColor="#94a3b8"
/>
<Text className="mb-1 text-sm text-slate-600">Prénom</Text>
<TextInput
className="mb-6 rounded-xl border border-slate-200 bg-white px-3 py-3 text-base"
value={prenom}
onChangeText={setPrenom}
placeholderTextColor="#94a3b8"
/>
<Pressable
className="rounded-xl py-3"
style={{ backgroundColor: busy ? '#94a3b8' : '#1D4ED8' }}
onPress={onSave}
disabled={busy}
>
<Text className="text-center font-semibold text-white">Enregistrer</Text>
</Pressable>
</View>
</>
);
}

View File

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

387
app/app/visite/[id].tsx Normal file
View File

@ -0,0 +1,387 @@
import * as ImagePicker from 'expo-image-picker';
import { useQueryClient } from '@tanstack/react-query';
import { Stack, useLocalSearchParams } from 'expo-router';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import { AVIS_VISITE } from '@/constants/metier';
import {
CHECKLIST_ETATS,
CHECKLIST_ITEMS,
type ChecklistEtat,
} from '@/constants/visiteChecklist';
import {
appendVisitePhoto,
requestGenerateRapport,
useVisiteDetail,
useVisiteUpdate,
} from '@/hooks/useVisites';
import { getCurrentUserId } from '@/services/pocketbase';
import { formatPocketBaseError } from '@/utils/pocketbaseErrors';
function routeParamId(raw: string | string[] | undefined): string | undefined {
if (raw == null) return undefined;
return Array.isArray(raw) ? raw[0] : raw;
}
function mergeChecklist(raw?: Record<string, string> | null): Record<string, ChecklistEtat> {
const out: Record<string, ChecklistEtat> = {};
for (const { id } of CHECKLIST_ITEMS) {
const v = raw?.[id];
out[id] =
v === 'ok' || v === 'attention' || v === 'probleme' || v === 'non' ? v : 'non';
}
return out;
}
type TabKey = 0 | 1 | 2;
export default function VisiteDetailScreen() {
const { id: rawId } = useLocalSearchParams<{ id?: string | string[] }>();
const id = routeParamId(rawId);
const uid = getCurrentUserId();
const queryClient = useQueryClient();
const q = useVisiteDetail(id);
const updateVisite = useVisiteUpdate();
const [tab, setTab] = useState<TabKey>(0);
const [notesLocal, setNotesLocal] = useState('');
const [minLocal, setMinLocal] = useState('');
const [maxLocal, setMaxLocal] = useState('');
const [rapportLocal, setRapportLocal] = useState<string | null>(null);
const [iaPending, setIaPending] = useState(false);
const [photoPending, setPhotoPending] = useState(false);
const [saveNotesPending, setSaveNotesPending] = useState(false);
const v = q.data;
useEffect(() => {
if (!v) return;
setNotesLocal(v.notes_brutes ?? '');
setMinLocal(
v.estimation_travaux_min != null && !Number.isNaN(v.estimation_travaux_min)
? String(v.estimation_travaux_min)
: '',
);
setMaxLocal(
v.estimation_travaux_max != null && !Number.isNaN(v.estimation_travaux_max)
? String(v.estimation_travaux_max)
: '',
);
setRapportLocal(v.rapport_genere ?? null);
}, [v?.id, v?.notes_brutes, v?.estimation_travaux_min, v?.estimation_travaux_max, v?.rapport_genere]);
const checklist = useMemo(() => mergeChecklist(v?.checklist_reponses), [v?.checklist_reponses]);
const setChecklistItem = useCallback(
async (itemId: string, etat: ChecklistEtat) => {
if (!id || !v) return;
const next = { ...checklist, [itemId]: etat };
await updateVisite(id, { checklist_reponses: next });
},
[checklist, id, updateVisite, v],
);
const saveNotes = useCallback(async () => {
if (!id) return;
setSaveNotesPending(true);
try {
await updateVisite(id, { notes_brutes: notesLocal });
} finally {
setSaveNotesPending(false);
}
}, [id, notesLocal, updateVisite]);
const saveEstimation = useCallback(async () => {
if (!id) return;
const minN = minLocal.trim() === '' ? undefined : Number(minLocal.replace(',', '.'));
const maxN = maxLocal.trim() === '' ? undefined : Number(maxLocal.replace(',', '.'));
await updateVisite(id, {
estimation_travaux_min: minN != null && !Number.isNaN(minN) ? minN : undefined,
estimation_travaux_max: maxN != null && !Number.isNaN(maxN) ? maxN : undefined,
});
}, [id, maxLocal, minLocal, updateVisite]);
const setAvis = useCallback(
async (avis: string) => {
if (!id) return;
await updateVisite(id, { avis_global: avis });
},
[id, updateVisite],
);
const setScore = useCallback(
async (score: number) => {
if (!id) return;
await updateVisite(id, { score_opportunite: score });
},
[id, updateVisite],
);
const pickPhoto = useCallback(async () => {
if (!id) return;
setPhotoPending(true);
try {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) return;
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.85,
});
if (result.canceled || !result.assets[0]?.uri) return;
await appendVisitePhoto(id, result.assets[0].uri);
void queryClient.invalidateQueries({ queryKey: ['visite_detail', id] });
} finally {
setPhotoPending(false);
}
}, [id, queryClient]);
const generateRapport = useCallback(async () => {
if (!id || !v) return;
const bien = v.expand?.bien;
const bien_info: Record<string, unknown> = {
titre: bien?.titre,
ville: bien?.ville,
type_bien: bien?.type_bien,
adresse: bien?.adresse,
code_postal: bien?.code_postal,
};
setIaPending(true);
try {
const rapport = await requestGenerateRapport({
notes_brutes: notesLocal,
checklist_reponses: checklist as Record<string, string>,
bien_info,
});
setRapportLocal(rapport);
await updateVisite(id, { rapport_genere: rapport });
} catch (e) {
setRapportLocal(`Erreur: ${e instanceof Error ? e.message : String(e)}`);
} finally {
setIaPending(false);
}
}, [checklist, id, notesLocal, updateVisite, v]);
if (!id) {
return (
<>
<Stack.Screen options={{ title: 'Visite', headerShown: true }} />
<View className="flex-1 items-center justify-center p-6">
<Text>Identifiant manquant.</Text>
</View>
</>
);
}
if (q.isPending) {
return (
<>
<Stack.Screen options={{ title: '…', headerShown: true }} />
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#1D4ED8" />
</View>
</>
);
}
if (q.error || !v) {
return (
<>
<Stack.Screen options={{ title: 'Erreur', headerShown: true }} />
<View className="flex-1 items-center justify-center p-6">
<Text className="text-center text-red-700">
{q.error ? formatPocketBaseError(q.error) : 'Introuvable.'}
</Text>
</View>
</>
);
}
if (uid && v.user !== uid) {
return (
<>
<Stack.Screen options={{ title: 'Visite', headerShown: true }} />
<View className="flex-1 items-center justify-center p-6">
<Text>Accès refusé.</Text>
</View>
</>
);
}
const titre = v.date_visite?.slice(0, 10) ?? 'Visite';
const photoCount = Array.isArray(v.photos) ? v.photos.length : v.photos ? 1 : 0;
return (
<>
<Stack.Screen options={{ title: titre, headerShown: true }} />
<View className="flex-1 bg-slate-50">
<View className="flex-row border-b border-slate-200 bg-white px-1">
{(['Check-liste', 'Notes', 'Estimation'] as const).map((label, i) => (
<Pressable
key={label}
onPress={() => setTab(i as TabKey)}
className={`flex-1 py-3 ${tab === i ? 'border-b-2 border-blue-700' : ''}`}
>
<Text
className={`text-center text-sm font-semibold ${tab === i ? 'text-blue-800' : 'text-slate-600'}`}
>
{label}
</Text>
</Pressable>
))}
</View>
<ScrollView className="flex-1 px-3 pt-3" contentContainerStyle={{ paddingBottom: 32 }}>
{tab === 0 ? (
<View>
{CHECKLIST_ITEMS.map((item) => (
<View key={item.id} className="mb-4 rounded-xl border border-slate-200 bg-white p-3">
<Text className="font-medium text-slate-900">{item.label}</Text>
<View className="mt-2 flex-row flex-wrap gap-2">
{CHECKLIST_ETATS.map((e) => (
<Pressable
key={e.id}
onPress={() => void setChecklistItem(item.id, e.id)}
className={`rounded-lg px-3 py-2 ${checklist[item.id] === e.id ? 'bg-slate-800' : 'bg-slate-100'}`}
>
<Text
className={`text-sm font-medium ${checklist[item.id] === e.id ? 'text-white' : 'text-slate-700'}`}
>
{e.label}
</Text>
</Pressable>
))}
</View>
</View>
))}
</View>
) : null}
{tab === 1 ? (
<View>
<Text className="mb-1 text-sm text-slate-500">Notes de visite</Text>
<TextInput
className="min-h-[140px] rounded-xl border border-slate-200 bg-white p-3 text-base text-slate-900"
multiline
textAlignVertical="top"
value={notesLocal}
onChangeText={setNotesLocal}
placeholder="Observations, impressions…"
placeholderTextColor="#94a3b8"
/>
<Pressable
onPress={() => void saveNotes()}
disabled={saveNotesPending}
className="mt-3 items-center rounded-xl bg-blue-700 py-3"
>
{saveNotesPending ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="font-semibold text-white">Enregistrer les notes</Text>
)}
</Pressable>
<Text className="mt-6 text-sm text-slate-500">Photos ({photoCount})</Text>
<Pressable
onPress={() => void pickPhoto()}
disabled={photoPending}
className="mt-2 items-center rounded-xl border border-slate-300 bg-white py-3"
>
{photoPending ? (
<ActivityIndicator color="#1D4ED8" />
) : (
<Text className="font-semibold text-slate-800">Ajouter une photo</Text>
)}
</Pressable>
</View>
) : null}
{tab === 2 ? (
<View>
<Text className="text-sm text-slate-500">Budget travaux ()</Text>
<View className="mt-2 flex-row gap-2">
<TextInput
className="flex-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-base text-slate-900"
placeholder="Min"
keyboardType="decimal-pad"
value={minLocal}
onChangeText={setMinLocal}
placeholderTextColor="#94a3b8"
/>
<TextInput
className="flex-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-base text-slate-900"
placeholder="Max"
keyboardType="decimal-pad"
value={maxLocal}
onChangeText={setMaxLocal}
placeholderTextColor="#94a3b8"
/>
</View>
<Pressable
onPress={() => void saveEstimation()}
className="mt-2 items-center rounded-xl bg-slate-800 py-2"
>
<Text className="font-semibold text-white">Enregistrer le budget</Text>
</Pressable>
<Text className="mt-6 text-sm text-slate-500">Avis global</Text>
<View className="mt-2 flex-row flex-wrap gap-2">
{Object.entries(AVIS_VISITE).map(([key, { label }]) => (
<Pressable
key={key}
onPress={() => void setAvis(key)}
className={`rounded-lg px-3 py-2 ${v.avis_global === key ? 'bg-violet-700' : 'bg-slate-100'}`}
>
<Text
className={`text-sm font-medium ${v.avis_global === key ? 'text-white' : 'text-slate-800'}`}
>
{label}
</Text>
</Pressable>
))}
</View>
<Text className="mt-6 text-sm text-slate-500">Score opportunité (110)</Text>
<View className="mt-2 flex-row flex-wrap gap-2">
{Array.from({ length: 10 }, (_, i) => i + 1).map((n) => (
<Pressable
key={n}
onPress={() => void setScore(n)}
className={`h-10 w-10 items-center justify-center rounded-full ${v.score_opportunite === n ? 'bg-amber-500' : 'bg-slate-200'}`}
>
<Text className="font-bold text-slate-900">{n}</Text>
</Pressable>
))}
</View>
<Pressable
onPress={() => void generateRapport()}
disabled={iaPending}
className="mt-8 items-center rounded-xl bg-indigo-700 py-3"
>
{iaPending ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="font-semibold text-white">Générer rapport IA</Text>
)}
</Pressable>
</View>
) : null}
{rapportLocal ? (
<View className="mt-6 rounded-xl border border-slate-200 bg-white p-3">
<Text className="mb-2 font-bold text-slate-900">Rapport</Text>
<Text className="font-mono text-sm leading-6 text-slate-800">{rapportLocal}</Text>
</View>
) : null}
</ScrollView>
</View>
</>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

BIN
app/assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@ -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 dabord Supabase dans Réglages, puis revenez ici.
</Text>
<PrimaryButton
title="Ouvrir Réglages"
onPress={() => router.replace('/(tabs)/reglages')}
/>
</View>
);
}
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: colors.bg }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerStyle={{
padding: 20,
paddingTop: insets.top + 12,
paddingBottom: insets.bottom + 24,
}}
>
<LabeledField
label="E-mail"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<LabeledField
label="Mot de passe"
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{err ? <Text style={styles.err}>{err}</Text> : null}
<PrimaryButton
title="Connexion"
loading={loading}
onPress={async () => {
setErr(null);
setLoading(true);
const r = await app.signIn(email.trim(), password);
setLoading(false);
if (r.error) {
setErr(r.error);
return;
}
router.replace('/(tabs)');
}}
/>
<PrimaryButton
title="Créer un compte"
variant="ghost"
onPress={() => router.push('/auth/register')}
containerStyle={{ marginTop: 12 }}
/>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
box: { flex: 1, padding: 20, backgroundColor: colors.bg, gap: 12 },
err: { color: colors.danger, marginBottom: 12 },
});

View File

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

View File

@ -0,0 +1,20 @@
export const CONTACT_CATEGORIE_LABELS: Record<string, string> = {
notaire: 'Notaire',
agent_immo: 'Agent immobilier',
artisan_gros_oeuvre: 'Artisan gros œuvre',
artisan_second_oeuvre: 'Artisan second œuvre',
artisan_finitions: 'Artisan finitions',
banquier: 'Banquier',
courtier: 'Courtier',
diagnostiqueur: 'Diagnostiqueur',
geometre: 'Géomètre',
avocat: 'Avocat',
comptable: 'Comptable',
vendeur: 'Vendeur',
acheteur: 'Acheteur',
autre: 'Autre',
};
export function labelContactCategorie(key: string): string {
return CONTACT_CATEGORIE_LABELS[key] ?? key;
}

19
app/constants/metier.ts Normal file
View File

@ -0,0 +1,19 @@
import type { BienType } from '@/types/collections';
export const TYPES_BIENS: Record<BienType, string> = {
appartement: 'Appartement',
maison: 'Maison',
immeuble: 'Immeuble',
terrain: 'Terrain',
local_commercial: 'Local commercial',
parking: 'Parking',
cave: 'Cave',
autre: 'Autre',
};
export const AVIS_VISITE: Record<string, { label: string }> = {
coup_de_coeur: { label: 'Coup de cœur' },
interessant: { label: 'Intéressant' },
neutre: { label: 'Neutre' },
a_eviter: { label: 'À éviter' },
};

View File

@ -0,0 +1,23 @@
export type ChecklistEtat = 'ok' | 'attention' | 'probleme' | 'non';
export const CHECKLIST_ETATS: { id: ChecklistEtat; label: string; color: string }[] = [
{ id: 'ok', label: 'OK', color: '#16A34A' },
{ id: 'attention', label: 'Attention', color: '#CA8A04' },
{ id: 'probleme', label: 'Problème', color: '#DC2626' },
{ id: 'non', label: 'Non vérifié', color: '#64748B' },
];
export const CHECKLIST_ITEMS: { id: string; label: string }[] = [
{ id: 'facade', label: 'Façade / extérieur' },
{ id: 'toiture', label: 'Toiture / couverture' },
{ id: 'humidite', label: 'Humidité / traces' },
{ id: 'menuiseries', label: 'Menuiseries' },
{ id: 'chauffage', label: 'Chauffage / ECS' },
{ id: 'electricite', label: 'Électricité' },
{ id: 'plomberie', label: 'Plomberie / évacuations' },
{ id: 'copropriete', label: 'Parties communes / copro' },
{ id: 'bruit', label: 'Nuisances (bruit, odeurs)' },
{ id: 'stationnement', label: 'Stationnement / accès' },
];
export const GENERATE_RAPPORT_PATH = '/api/mdb/generate-rapport';

View File

@ -0,0 +1,82 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import type { UserRecord } from '@/types/collections';
import { hydratePocketBaseAuth, isAuthenticated, pb } from '@/services/pocketbase';
type AuthContextValue = {
user: UserRecord | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (params: { email: string; password: string; name: string }) => Promise<void>;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserRecord | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
void (async () => {
await hydratePocketBaseAuth();
if (cancelled) return;
setUser((pb.authStore.record as UserRecord | null) ?? null);
setLoading(false);
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const unsub = pb.authStore.onChange(() => {
setUser((pb.authStore.record as UserRecord | null) ?? null);
});
return () => unsub();
}, []);
const login = useCallback(async (email: string, password: string) => {
await pb.collection('users').authWithPassword(email.trim(), password);
setUser((pb.authStore.record as UserRecord | null) ?? null);
}, []);
const register = useCallback(async (params: { email: string; password: string; name: string }) => {
await pb.collection('users').create({
email: params.email.trim(),
password: params.password,
passwordConfirm: params.password,
name: params.name.trim(),
emailVisibility: true,
});
await pb.collection('users').authWithPassword(params.email.trim(), params.password);
setUser((pb.authStore.record as UserRecord | null) ?? null);
}, []);
const logout = useCallback(() => {
pb.authStore.clear();
setUser(null);
}, []);
const value = useMemo(
() => ({
user,
loading,
login,
register,
logout,
}),
[user, loading, login, register, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
export { isAuthenticated };

View File

@ -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 longlet Feu pour
activer le teaser investisseur.
</Text>
) : (
<>
<Text style={styles.cardTitle}>Investisseurs (top 5 match)</Text>
{matches.length === 0 ? (
<Text style={styles.muted}>Aucun match ajustez critères ou dossier.</Text>
) : (
matches.map((m) => (
<View key={m.id} style={styles.matchRow}>
<Text style={styles.matchName}>{m.display_name}</Text>
{m.email ? (
<Text style={styles.muted}>{m.email}</Text>
) : null}
</View>
))
)}
<PrimaryButton
title="Générer & partager le teaser PDF"
containerStyle={{ marginTop: 16 }}
onPress={() =>
void shareTeaserPdf(
dossier,
juge.result,
matches.map((m) => m.display_name),
)
}
/>
</>
)}
</ScrollView>
) : null}
</View>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<View style={styles.row}>
<Text style={styles.rowLabel}>{label}</Text>
<Text style={styles.rowValue}>{value}</Text>
</View>
);
}
function FinancesEditor({
dossier,
onSave,
}: {
dossier: DossierRow;
onSave: (patch: Partial<DossierRow>) => void;
}) {
const insets = useSafeAreaInsets();
const [title, setTitle] = useState(dossier.title);
const [address, setAddress] = useState(dossier.address_line ?? '');
const [city, setCity] = useState(dossier.city ?? '');
const [postal, setPostal] = useState(dossier.postal_code ?? '');
const [surface, setSurface] = useState(String(dossier.surface_m2 ?? ''));
const [purchase, setPurchase] = useState(String(dossier.purchase_price_target ?? ''));
const [resale, setResale] = useState(String(dossier.resale_price_estimate ?? ''));
const [dvf, setDvf] = useState(String(dossier.dvf_reference_price_m2 ?? ''));
const [works, setWorks] = useState(String(dossier.works_estimate_total ?? ''));
const [miscA, setMiscA] = useState(String(dossier.misc_acquisition_cost ?? ''));
const [miscS, setMiscS] = useState(String(dossier.misc_sale_cost ?? ''));
const [carryM, setCarryM] = useState(String(dossier.carrying_months ?? 6));
const [carryR, setCarryR] = useState(String(dossier.carrying_annual_rate ?? 0.05));
const [dpe, setDpe] = useState(dossier.dpe_class ?? '');
const [pluZone, setPluZone] = useState(dossier.plu_zone_code ?? '');
const [pluNotes, setPluNotes] = useState(dossier.plu_notes ?? '');
const [parcelDiv, setParcelDiv] = useState(dossier.parcel_subdivision_candidate);
const [deficitFoncier, setDeficitFoncier] = useState(
dossier.deficit_foncier_candidate,
);
useEffect(() => {
setTitle(dossier.title);
setAddress(dossier.address_line ?? '');
setCity(dossier.city ?? '');
setPostal(dossier.postal_code ?? '');
setSurface(String(dossier.surface_m2 ?? ''));
setPurchase(String(dossier.purchase_price_target ?? ''));
setResale(String(dossier.resale_price_estimate ?? ''));
setDvf(String(dossier.dvf_reference_price_m2 ?? ''));
setWorks(String(dossier.works_estimate_total ?? ''));
setMiscA(String(dossier.misc_acquisition_cost ?? ''));
setMiscS(String(dossier.misc_sale_cost ?? ''));
setCarryM(String(dossier.carrying_months ?? 6));
setCarryR(String(dossier.carrying_annual_rate ?? 0.05));
setDpe(dossier.dpe_class ?? '');
setPluZone(dossier.plu_zone_code ?? '');
setPluNotes(dossier.plu_notes ?? '');
setParcelDiv(dossier.parcel_subdivision_candidate);
setDeficitFoncier(dossier.deficit_foncier_candidate);
}, [
dossier.id,
dossier.updated_at,
dossier.title,
dossier.address_line,
dossier.city,
dossier.postal_code,
dossier.surface_m2,
dossier.purchase_price_target,
dossier.resale_price_estimate,
dossier.dvf_reference_price_m2,
dossier.works_estimate_total,
dossier.misc_acquisition_cost,
dossier.misc_sale_cost,
dossier.carrying_months,
dossier.carrying_annual_rate,
dossier.dpe_class,
dossier.plu_zone_code,
dossier.plu_notes,
dossier.parcel_subdivision_candidate,
dossier.deficit_foncier_candidate,
]);
const parseNum = (s: string) => Number(s.replace(',', '.').replace(/\s/g, ''));
return (
<ScrollView
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 24,
}}
>
<LabeledField label="Titre du dossier" value={title} onChangeText={setTitle} />
<LabeledField label="Adresse" value={address} onChangeText={setAddress} />
<LabeledField label="Ville" value={city} onChangeText={setCity} />
<LabeledField label="Code postal" value={postal} onChangeText={setPostal} />
<LabeledField
label="Surface (m²)"
keyboardType="decimal-pad"
value={surface}
onChangeText={setSurface}
/>
<LabeledField
label="Prix d'achat cible (€)"
keyboardType="number-pad"
value={purchase}
onChangeText={setPurchase}
/>
<LabeledField
label="Prix de revente estimé (€)"
keyboardType="number-pad"
value={resale}
onChangeText={setResale}
/>
<LabeledField
label="DVF réf. (€/m²)"
keyboardType="decimal-pad"
value={dvf}
onChangeText={setDvf}
/>
<LabeledField
label="Travaux estimés hors checklist (€)"
keyboardType="number-pad"
value={works}
onChangeText={setWorks}
/>
<LabeledField
label="Frais d'achat divers (€)"
keyboardType="number-pad"
value={miscA}
onChangeText={setMiscA}
/>
<LabeledField
label="Frais de vente divers (€)"
keyboardType="number-pad"
value={miscS}
onChangeText={setMiscS}
/>
<LabeledField
label="Portage (mois)"
keyboardType="number-pad"
value={carryM}
onChangeText={setCarryM}
/>
<LabeledField
label="Taux portage annuel (ex: 0.055)"
keyboardType="decimal-pad"
value={carryR}
onChangeText={setCarryR}
/>
<LabeledField
label="DPE (AG)"
autoCapitalize="characters"
maxLength={1}
value={dpe}
onChangeText={(t) => setDpe(t.toUpperCase())}
/>
<Text style={styles.sectionLabel}>Urbanisme & stratégie</Text>
<LabeledField
label="Zone PLU (libellé ou code)"
value={pluZone}
onChangeText={setPluZone}
/>
<LabeledField
label="Notes urbanisme (servitude, COS…)"
value={pluNotes}
onChangeText={setPluNotes}
multiline
/>
<View style={styles.switchRow}>
<Text style={styles.switchLabel}>Piste division parcellaire</Text>
<Switch value={parcelDiv} onValueChange={setParcelDiv} />
</View>
<View style={styles.switchRow}>
<Text style={styles.switchLabel}>Piste déficit foncier (passoire)</Text>
<Switch value={deficitFoncier} onValueChange={setDeficitFoncier} />
</View>
<PrimaryButton
title="Enregistrer les finances"
onPress={() => {
onSave({
title: title.trim(),
address_line: address.trim() || null,
city: city.trim() || null,
postal_code: postal.trim() || null,
surface_m2: parseNum(surface) || null,
purchase_price_target: parseNum(purchase) || null,
resale_price_estimate: parseNum(resale) || null,
dvf_reference_price_m2: parseNum(dvf) || null,
works_estimate_total: parseNum(works) || null,
misc_acquisition_cost: parseNum(miscA) || null,
misc_sale_cost: parseNum(miscS) || null,
carrying_months: Math.round(parseNum(carryM) || 6),
carrying_annual_rate: parseNum(carryR) || 0.05,
dpe_class: dpe || null,
plu_zone_code: pluZone.trim() || null,
plu_notes: pluNotes.trim() || null,
parcel_subdivision_candidate: parcelDiv,
deficit_foncier_candidate: deficitFoncier,
});
}}
/>
</ScrollView>
);
}
function VisiteTab({
definitions,
findings,
onToggle,
checklistEUR,
maxPurchase,
}: {
definitions: VisitFindingDefinitionRow[];
findings: DossierVisitFindingRow[];
onToggle: (code: string, checked: boolean) => void;
checklistEUR: number;
maxPurchase: number;
}) {
const insets = useSafeAreaInsets();
const rows = definitions.map((def) => {
const f = findings.find((x) => x.finding_code === def.code);
return { def, f };
});
return (
<ScrollView
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 24,
}}
>
<View style={styles.card}>
<Text style={styles.cardTitle}>Anti-erreur visite</Text>
<Text style={styles.muted}>
Cochez les points noirs : lapp ajoute les travaux associés et recalcule
le prix dachat max pour rester à {(MIN_NET_MARGIN_PCT * 100).toFixed(0)}{' '}
% de marge nette.
</Text>
<Text style={styles.highlight}>
Travaux checklist : {checklistEUR.toLocaleString('fr-FR')}
</Text>
<Text style={styles.highlight}>
Prix dachat max (cible marge) :{' '}
{maxPurchase.toLocaleString('fr-FR')}
</Text>
</View>
{rows.map(({ def, f }) => {
const checked = f?.checked ?? false;
return (
<View key={def.code} style={styles.visitRow}>
<View style={{ flex: 1, paddingRight: 12 }}>
<Text style={styles.visitLabel}>{def.label}</Text>
<Text style={styles.muted}>
+{(f?.works_delta_override_eur ?? def.default_works_delta_eur).toLocaleString('fr-FR')}{' '}
si coché
</Text>
</View>
<Switch
value={checked}
onValueChange={(v) => onToggle(def.code, v)}
trackColor={{ true: colors.accent, false: colors.border }}
/>
</View>
);
})}
</ScrollView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: colors.bg },
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
muted: { color: colors.textMuted, lineHeight: 20 },
tabBar: { maxHeight: 52, borderBottomWidth: 1, borderBottomColor: colors.border },
tabBarInner: { paddingHorizontal: 12, paddingVertical: 10, gap: 8, alignItems: 'center' },
tabChip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 999,
backgroundColor: colors.bgCard,
marginRight: 8,
borderWidth: 1,
borderColor: colors.border,
},
tabChipOn: { borderColor: colors.accent, backgroundColor: '#15233d' },
tabText: { color: colors.textMuted, fontWeight: '600' },
tabTextOn: { color: colors.text },
hero: { borderRadius: 16, padding: 20, marginBottom: 16 },
heroLabel: { color: colors.textMuted, fontSize: 12, textTransform: 'uppercase' },
heroScore: { fontSize: 44, fontWeight: '800', color: colors.text, marginVertical: 8 },
heroSub: { color: colors.textMuted, marginTop: 4 },
card: {
backgroundColor: colors.bgCard,
borderRadius: 14,
padding: 16,
borderWidth: 1,
borderColor: colors.border,
},
cardTitle: { color: colors.text, fontSize: 18, fontWeight: '700', marginBottom: 12 },
row: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 10,
gap: 12,
},
rowLabel: { color: colors.textMuted, flex: 1 },
rowValue: { color: colors.text, fontWeight: '600' },
highlight: { color: colors.flash, marginTop: 8, fontWeight: '600' },
visitRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
visitLabel: { color: colors.text, fontWeight: '600' },
matchRow: {
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
matchName: { color: colors.text, fontWeight: '700', fontSize: 16 },
sectionLabel: {
color: colors.textMuted,
fontSize: 12,
textTransform: 'uppercase',
letterSpacing: 0.06,
marginTop: 8,
marginBottom: 6,
},
switchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 14,
paddingVertical: 4,
},
switchLabel: { color: colors.text, flex: 1, paddingRight: 12 },
});

166
app/hooks/useAnalyse.ts Normal file
View File

@ -0,0 +1,166 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getCurrentUserId, pb } from '@/services/pocketbase';
import type { AnalyseFinanciereRecord, TypeBienFiscal } from '@/types/collections';
import { roundMoney } from '@/utils/format';
export type AnalyseFormInput = {
prix_achat?: number;
type_bien_fiscal?: TypeBienFiscal;
frais_notaire?: number;
frais_agence_achat?: number;
budget_travaux?: number;
reserve_imprevus_pct?: number;
duree_portage_mois?: number;
taux_credit?: number;
taxe_fonciere_annuelle?: number;
charges_copropriete_mensuelle?: number;
prix_revente_cible?: number;
frais_agence_vente_pct?: number;
taux_impot?: number;
};
export type AnalyseCalculated = {
frais_notaire: number;
travaux_total: number;
frais_portage_total: number;
prix_revient: number;
marge_brute: number;
marge_nette: number;
marge_brute_pct: number;
marge_nette_pct: number;
rendement_net_pct: number;
};
export function calculateResults(data: AnalyseFormInput): AnalyseCalculated {
const prixAchat = data.prix_achat ?? 0;
const typeFiscal = data.type_bien_fiscal ?? 'ancien';
const fraisNotaireAuto = prixAchat * (typeFiscal === 'neuf' ? 0.02 : 0.075);
const fraisNotaire = data.frais_notaire ?? fraisNotaireAuto;
const budgetTravaux = data.budget_travaux ?? 0;
const reservePct = data.reserve_imprevus_pct ?? 0;
const travauxTotal = budgetTravaux * (1 + reservePct / 100);
const tauxCredit = data.taux_credit ?? 0;
const taxeFon = data.taxe_fonciere_annuelle ?? 0;
const charges = data.charges_copropriete_mensuelle ?? 0;
const dureeMois = data.duree_portage_mois ?? 0;
const mensualiteCredit = (prixAchat * (tauxCredit / 100)) / 12;
const mensualiteFoncier = taxeFon / 12;
const fraisPortageTotal = (mensualiteCredit + mensualiteFoncier + charges) * dureeMois;
const fraisAgenceAchat = data.frais_agence_achat ?? 0;
const prixRevient = prixAchat + fraisNotaire + fraisAgenceAchat + travauxTotal + fraisPortageTotal;
const prixRevente = data.prix_revente_cible ?? 0;
const margeBrute = prixRevente - prixRevient;
const fraisAgenceVentePct = data.frais_agence_vente_pct ?? 0;
const tauxImpot = data.taux_impot ?? 0;
const fraisAgenceVente = prixRevente * (fraisAgenceVentePct / 100);
const impotSurMarge = margeBrute * (tauxImpot / 100);
const margeNette = margeBrute - fraisAgenceVente - impotSurMarge;
const margeBrutePct = prixRevient > 0 ? (margeBrute / prixRevient) * 100 : 0;
const margeNettePct = prixRevente > 0 ? (margeNette / prixRevente) * 100 : 0;
const rendementNetPct = prixRevient > 0 ? (margeNette / prixRevient) * 100 : 0;
return {
frais_notaire: roundMoney(fraisNotaire),
travaux_total: roundMoney(travauxTotal),
frais_portage_total: roundMoney(fraisPortageTotal),
prix_revient: roundMoney(prixRevient),
marge_brute: roundMoney(margeBrute),
marge_nette: roundMoney(margeNette),
marge_brute_pct: roundMoney(margeBrutePct),
marge_nette_pct: roundMoney(margeNettePct),
rendement_net_pct: roundMoney(rendementNetPct),
};
}
export function rendementColor(rendementNetPct: number): string {
if (rendementNetPct > 15) return '#16A34A';
if (rendementNetPct >= 8) return '#EA580C';
return '#DC2626';
}
function formToRecord(form: AnalyseFormInput, calc: AnalyseCalculated): Record<string, unknown> {
return {
prix_achat: form.prix_achat,
type_bien_fiscal: form.type_bien_fiscal,
frais_notaire: calc.frais_notaire,
frais_agence_achat: form.frais_agence_achat,
budget_travaux: form.budget_travaux,
reserve_imprevus_pct: form.reserve_imprevus_pct,
duree_portage_mois: form.duree_portage_mois,
taux_credit: form.taux_credit,
taxe_fonciere_annuelle: form.taxe_fonciere_annuelle,
charges_copropriete_mensuelle: form.charges_copropriete_mensuelle,
prix_revente_cible: form.prix_revente_cible,
frais_agence_vente_pct: form.frais_agence_vente_pct,
taux_impot: form.taux_impot,
marge_brute: calc.marge_brute,
marge_brute_pct: calc.marge_brute_pct,
marge_nette: calc.marge_nette,
marge_nette_pct: calc.marge_nette_pct,
};
}
export function useAnalyse(bienId: string | undefined) {
const uid = getCurrentUserId();
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['analyse_financiere', bienId, uid],
queryFn: async (): Promise<AnalyseFinanciereRecord | null> => {
if (!bienId || !uid) return null;
const res = await pb.collection('analyses_financieres').getList<AnalyseFinanciereRecord>(1, 1, {
filter: `bien="${bienId}" && user="${uid}"`,
sort: '-id',
});
return res.items[0] ?? null;
},
enabled: Boolean(bienId && uid),
});
const saveMutation = useMutation({
mutationFn: async (data: AnalyseFormInput & { notes?: string }) => {
if (!bienId || !uid) throw new Error('Données manquantes');
const res = await pb.collection('analyses_financieres').getList<AnalyseFinanciereRecord>(1, 1, {
filter: `bien="${bienId}" && user="${uid}"`,
sort: '-id',
});
const existing = res.items[0];
const calc = calculateResults(data);
const payload = {
...formToRecord(data, calc),
notes: data.notes,
};
if (existing) {
return pb.collection('analyses_financieres').update<AnalyseFinanciereRecord>(existing.id, payload);
}
return pb.collection('analyses_financieres').create<AnalyseFinanciereRecord>({
user: uid,
bien: bienId,
...payload,
});
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['analyse_financiere', bienId, uid] });
void queryClient.invalidateQueries({ queryKey: ['bien_detail', bienId] });
void queryClient.invalidateQueries({ queryKey: ['biens', uid] });
},
});
return {
analyse: query.data ?? null,
isLoading: query.isPending,
error: query.error,
refetch: query.refetch,
fetchAnalyse: query.refetch,
saveAnalyse: saveMutation.mutateAsync,
isSaving: saveMutation.isPending,
calculateResults,
};
}

204
app/hooks/useBiens.ts Normal file
View File

@ -0,0 +1,204 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ClientResponseError } from 'pocketbase';
import { getCurrentUserId, pb } from '@/services/pocketbase';
import type {
AnalyseFinanciereRecord,
BienCreate,
BienRecord,
BienUpdate,
ContactRecord,
DocumentRecord,
EtapePipelineRecord,
NoteRecord,
VisiteRecord,
} from '@/types/collections';
export type BiensFilters = {
search?: string;
};
export type BienExpanded = BienRecord & {
expand?: {
etape?: EtapePipelineRecord;
source_contact?: ContactRecord;
};
};
export type BienDetailBundle = {
bien: BienExpanded;
visites: VisiteRecord[];
notes: NoteRecord[];
documents: DocumentRecord[];
analyse: AnalyseFinanciereRecord | null;
};
async function fetchPrixMapForUser(uid: string): Promise<Map<string, number>> {
const analyses = await pb.collection('analyses_financieres').getFullList<AnalyseFinanciereRecord>({
filter: `user="${uid}"`,
});
const map = new Map<string, number>();
for (const a of analyses) {
if (a.prix_achat != null && a.bien) {
map.set(a.bien, a.prix_achat);
}
}
return map;
}
export async function fetchBienDetail(bienId: string): Promise<BienDetailBundle> {
const uid = getCurrentUserId();
if (!uid) throw new Error('Utilisateur non connecté');
let bien: BienExpanded;
try {
bien = await pb.collection('biens').getOne<BienExpanded>(bienId, {
expand: 'etape,source_contact',
});
} catch (e) {
if (e instanceof ClientResponseError && (e.status === 404 || e.status === 400)) {
throw new Error(
"Ce bien n'existe pas ou a été supprimé (vérifie l'admin PocketBase). Retourne à la liste des biens.",
);
}
throw e;
}
if (bien.user !== uid) {
throw new Error('Accès refusé');
}
const [visites, notes, documents, analyses] = await Promise.all([
pb.collection('visites').getFullList<VisiteRecord>({
filter: `bien="${bienId}"`,
sort: '-date_visite',
}),
pb.collection('notes_biens').getFullList<NoteRecord>({
filter: `bien="${bienId}"`,
sort: '-id',
}),
pb.collection('documents_biens').getFullList<DocumentRecord>({
filter: `bien="${bienId}"`,
sort: '-id',
}),
pb.collection('analyses_financieres').getList<AnalyseFinanciereRecord>(1, 1, {
filter: `bien="${bienId}" && user="${uid}"`,
sort: '-id',
}),
]);
const analyse = analyses.items[0] ?? null;
const byUpdatedDesc = (a: { updated?: string }, b: { updated?: string }) =>
(b.updated ?? '').localeCompare(a.updated ?? '');
return {
bien,
visites,
notes: [...notes].sort(byUpdatedDesc),
documents: [...documents].sort(byUpdatedDesc),
analyse,
};
}
export function useBiens(filters?: BiensFilters) {
const uid = getCurrentUserId();
const queryClient = useQueryClient();
const search = filters?.search?.trim() ?? '';
const query = useQuery({
queryKey: ['biens', uid, search],
queryFn: async () => {
if (!uid) return { biens: [] as BienExpanded[], prixByBien: new Map<string, number>() };
const parts = [`user="${uid}"`];
if (search.length > 0) {
const esc = search.replace(/"/g, '\\"');
parts.push(`(titre ~ "${esc}" || ville ~ "${esc}" || adresse ~ "${esc}" || code_postal ~ "${esc}")`);
}
const filter = parts.join(' && ');
const [biens, prixByBien] = await Promise.all([
pb.collection('biens').getFullList<BienExpanded>({
filter,
sort: '-id',
expand: 'etape,source_contact',
}),
fetchPrixMapForUser(uid),
]);
return { biens, prixByBien };
},
enabled: Boolean(uid),
});
const invalidateBiens = () => {
void queryClient.invalidateQueries({ queryKey: ['biens'] });
};
const createBien = useMutation({
mutationFn: async (payload: { bien: BienCreate; prixEstime?: number }) => {
if (!uid) throw new Error('Utilisateur non connecté');
const created = await pb.collection('biens').create<BienRecord>(payload.bien);
if (
payload.prixEstime != null &&
!Number.isNaN(payload.prixEstime) &&
payload.prixEstime > 0
) {
await pb.collection('analyses_financieres').create({
user: uid,
bien: created.id,
prix_achat: payload.prixEstime,
type_bien_fiscal: 'ancien',
});
}
return created.id;
},
onSuccess: invalidateBiens,
});
const updateBien = useMutation({
mutationFn: async ({ id, data }: { id: string; data: BienUpdate }) => {
return pb.collection('biens').update<BienRecord>(id, data);
},
onSuccess: (_, variables) => {
invalidateBiens();
void queryClient.invalidateQueries({ queryKey: ['bien_detail', variables.id] });
},
});
const deleteBien = useMutation({
mutationFn: async (id: string) => pb.collection('biens').delete(id),
onSuccess: invalidateBiens,
});
const moveBienToEtape = useMutation({
mutationFn: async ({ bienId, etapeId }: { bienId: string; etapeId: string }) => {
return pb.collection('biens').update(bienId, { etape: etapeId });
},
onSuccess: (_, variables) => {
invalidateBiens();
void queryClient.invalidateQueries({ queryKey: ['bien_detail', variables.bienId] });
},
});
return {
biens: query.data?.biens ?? [],
prixByBien: query.data?.prixByBien ?? new Map<string, number>(),
isLoading: query.isPending,
error: query.error,
refetch: query.refetch,
fetchBiens: query.refetch,
createBien: createBien.mutateAsync,
updateBien: updateBien.mutateAsync,
deleteBien: deleteBien.mutateAsync,
moveBienToEtape: moveBienToEtape.mutateAsync,
};
}
export function useBienDetail(bienId: string | undefined) {
const query = useQuery({
queryKey: ['bien_detail', bienId],
queryFn: () => fetchBienDetail(bienId!),
enabled: Boolean(bienId),
});
return {
bundle: query.data ?? null,
isLoading: query.isPending,
error: query.error,
refetch: query.refetch,
fetchBienDetail: query.refetch,
};
}

50
app/hooks/useContacts.ts Normal file
View File

@ -0,0 +1,50 @@
import { useQuery } from '@tanstack/react-query';
import { getCurrentUserId, pb } from '@/services/pocketbase';
import type { BienRecord, ContactRecord } from '@/types/collections';
export function useContactDetail(id: string | undefined) {
return useQuery({
queryKey: ['contact', id],
queryFn: async () => {
if (!id) throw new Error('id');
return pb.collection('contacts').getOne<ContactRecord>(id);
},
enabled: Boolean(id),
});
}
export function useContactsList() {
const uid = getCurrentUserId();
return useQuery({
queryKey: ['contacts_list', uid],
queryFn: async () => {
if (!uid) return [] as ContactRecord[];
const list = await pb.collection('contacts').getFullList<ContactRecord>({
filter: `user="${uid}"`,
sort: '-id',
});
return [...list].sort((a, b) => {
const an = `${a.prenom ?? ''} ${a.nom}`.trim().toLowerCase();
const bn = `${b.prenom ?? ''} ${b.nom}`.trim().toLowerCase();
return an.localeCompare(bn, 'fr');
});
},
enabled: Boolean(uid),
});
}
export function useContactBiens(contactId: string | undefined) {
const uid = getCurrentUserId();
return useQuery({
queryKey: ['contact_biens', uid, contactId],
queryFn: async () => {
if (!uid || !contactId) return [] as BienRecord[];
return pb.collection('biens').getFullList<BienRecord>({
filter: `user="${uid}" && source_contact="${contactId}"`,
sort: '-id',
});
},
enabled: Boolean(uid && contactId),
});
}

67
app/hooks/useEtapes.ts Normal file
View File

@ -0,0 +1,67 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getCurrentUserId, pb } from '@/services/pocketbase';
import type { EtapePipelineRecord } from '@/types/collections';
const DEFAULT_ETAPES: { nom: string; ordre: number; couleur: string; is_terminal?: boolean }[] = [
{ nom: 'Prospection', ordre: 1, couleur: '#64748B' },
{ nom: 'Contact établi', ordre: 2, couleur: '#0EA5E9' },
{ nom: 'Visite', ordre: 3, couleur: '#8B5CF6' },
{ nom: 'Analyse', ordre: 4, couleur: '#F59E0B' },
{ nom: 'Offre', ordre: 5, couleur: '#EC4899' },
{ nom: 'Compromis', ordre: 6, couleur: '#10B981' },
{ nom: 'Acte / acquisition', ordre: 7, couleur: '#16A34A', is_terminal: true },
];
export function useEtapes() {
const uid = getCurrentUserId();
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['etapes_pipeline', uid],
queryFn: async () => {
if (!uid) return [] as EtapePipelineRecord[];
const list = await pb.collection('etapes_pipeline').getFullList<EtapePipelineRecord>({
filter: `user="${uid}"`,
sort: 'ordre',
});
return list;
},
enabled: Boolean(uid),
});
const initMutation = useMutation({
mutationFn: async () => {
if (!uid) throw new Error('Non connecté');
const existing = await pb.collection('etapes_pipeline').getFullList<EtapePipelineRecord>({
filter: `user="${uid}"`,
});
if (existing.length > 0) return existing;
for (const e of DEFAULT_ETAPES) {
await pb.collection('etapes_pipeline').create({
user: uid,
nom: e.nom,
ordre: e.ordre,
couleur: e.couleur,
is_terminal: e.is_terminal ?? false,
});
}
return pb.collection('etapes_pipeline').getFullList<EtapePipelineRecord>({
filter: `user="${uid}"`,
sort: 'ordre',
});
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['etapes_pipeline'] });
},
});
return {
etapes: query.data ?? [],
isLoading: query.isPending,
error: query.error,
initEtapesDefaut: initMutation.mutateAsync,
initError: initMutation.error,
isInitPending: initMutation.isPending,
};
}

79
app/hooks/useNoteLibre.ts Normal file
View File

@ -0,0 +1,79 @@
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { getCurrentUserId, pb } from '@/services/pocketbase';
import type { NoteRecord } from '@/types/collections';
/**
* Note libre : hydrate depuis le bundle `useBienDetail` (évite un 2e GET sur notes_biens).
*/
export function useNoteLibre(bienId: string | undefined, notesFromBundle: NoteRecord[] | undefined) {
const uid = getCurrentUserId();
const queryClient = useQueryClient();
const [draft, setDraftState] = useState('');
const [hydrated, setHydrated] = useState(false);
const noteIdRef = useRef<string | null>(null);
const userEdited = useRef(false);
const prevBienId = useRef<string | undefined>(undefined);
useEffect(() => {
if (prevBienId.current !== bienId) {
prevBienId.current = bienId;
userEdited.current = false;
}
}, [bienId]);
useEffect(() => {
if (!bienId || !uid) {
setHydrated(false);
return;
}
if (notesFromBundle === undefined) {
setHydrated(false);
return;
}
const libre =
notesFromBundle.find((r) => {
const t = r.type_note as string | undefined;
return t == null || t === '' || t === 'libre';
}) ?? null;
noteIdRef.current = libre?.id ?? null;
if (!userEdited.current) {
setDraftState(libre?.contenu ?? '');
}
setHydrated(true);
}, [bienId, uid, notesFromBundle]);
useEffect(() => {
if (!bienId || !uid || !hydrated || !userEdited.current) return;
const t = setTimeout(() => {
void (async () => {
try {
if (!draft.trim()) return;
if (noteIdRef.current) {
await pb.collection('notes_biens').update(noteIdRef.current, { contenu: draft });
} else {
const c = await pb.collection('notes_biens').create<NoteRecord>({
user: uid,
bien: bienId,
contenu: draft,
type_note: 'libre',
});
noteIdRef.current = c.id;
}
void queryClient.invalidateQueries({ queryKey: ['bien_detail', bienId] });
} catch {
/* ignore autosave */
}
})();
}, 500);
return () => clearTimeout(t);
}, [draft, bienId, uid, hydrated, queryClient]);
const setDraft = (text: string) => {
userEdited.current = true;
setDraftState(text);
};
return { draft, setDraft, hydrated };
}

78
app/hooks/useTaches.ts Normal file
View File

@ -0,0 +1,78 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getCurrentUserId, pb } from '@/services/pocketbase';
import type { TacheExpanded, TacheRecord } from '@/types/collections';
export type TacheCreateInput = {
titre: string;
description?: string;
date_echeance?: string;
bien?: string;
type_tache?: string;
priorite?: number;
is_urgent?: boolean;
statut?: string;
};
export function useTachesList() {
const uid = getCurrentUserId();
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['taches_list', uid],
queryFn: async () => {
if (!uid) return [] as TacheExpanded[];
return pb.collection('taches').getFullList<TacheExpanded>({
filter: `user="${uid}"`,
sort: '-id',
expand: 'bien',
});
},
enabled: Boolean(uid),
});
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['taches_list', uid] });
};
const createTache = useMutation({
mutationFn: async (input: TacheCreateInput) => {
if (!uid) throw new Error('Utilisateur non connecté');
return pb.collection('taches').create<TacheRecord>({
user: uid,
titre: input.titre,
description: input.description,
date_echeance: input.date_echeance,
bien: input.bien || undefined,
type_tache: input.type_tache ?? 'autre',
priorite: input.priorite ?? 2,
is_urgent: input.is_urgent ?? false,
statut: input.statut ?? 'a_faire',
});
},
onSuccess: invalidate,
});
const updateTache = useMutation({
mutationFn: async ({ id, patch }: { id: string; patch: Partial<TacheRecord> }) => {
return pb.collection('taches').update<TacheRecord>(id, patch);
},
onSuccess: invalidate,
});
const deleteTache = useMutation({
mutationFn: async (id: string) => pb.collection('taches').delete(id),
onSuccess: invalidate,
});
return {
taches: query.data ?? [],
isLoading: query.isPending,
error: query.error,
refetch: query.refetch,
createTache: createTache.mutateAsync,
updateTache: updateTache.mutateAsync,
deleteTache: deleteTache.mutateAsync,
isCreatePending: createTache.isPending,
};
}

91
app/hooks/useVisites.ts Normal file
View File

@ -0,0 +1,91 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { GENERATE_RAPPORT_PATH } from '@/constants/visiteChecklist';
import { getCurrentUserId, pb } from '@/services/pocketbase';
import type { BienRecord, VisiteRecord } from '@/types/collections';
export type VisiteExpanded = VisiteRecord & {
expand?: { bien?: BienRecord };
};
export function useVisitesList() {
const uid = getCurrentUserId();
return useQuery({
queryKey: ['visites_list', uid],
queryFn: async () => {
if (!uid) return [] as VisiteRecord[];
return pb.collection('visites').getFullList<VisiteRecord>({
filter: `user="${uid}"`,
sort: '-date_visite',
});
},
enabled: Boolean(uid),
});
}
export function useVisiteDetail(id: string | undefined) {
return useQuery({
queryKey: ['visite_detail', id],
queryFn: async () => {
if (!id) throw new Error('id');
return pb.collection('visites').getOne<VisiteExpanded>(id, { expand: 'bien' });
},
enabled: Boolean(id),
});
}
export type VisitePatch = Partial<
Pick<
VisiteRecord,
| 'notes_brutes'
| 'checklist_reponses'
| 'estimation_travaux_min'
| 'estimation_travaux_max'
| 'avis_global'
| 'score_opportunite'
| 'rapport_genere'
>
>;
export function useVisiteUpdate() {
const queryClient = useQueryClient();
const uid = getCurrentUserId();
return async (visiteId: string, patch: VisitePatch) => {
const updated = await pb.collection('visites').update<VisiteRecord>(visiteId, patch);
void queryClient.invalidateQueries({ queryKey: ['visite_detail', visiteId] });
void queryClient.invalidateQueries({ queryKey: ['visites_list', uid] });
return updated;
};
}
export type GenerateRapportInput = {
notes_brutes: string;
checklist_reponses: Record<string, string>;
bien_info: Record<string, unknown>;
};
export async function requestGenerateRapport(body: GenerateRapportInput): Promise<string> {
const res = await pb.send<{ rapport?: string; message?: string }>(GENERATE_RAPPORT_PATH, {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
});
if (res && typeof res === 'object' && 'rapport' in res && typeof res.rapport === 'string') {
return res.rapport;
}
const msg =
res && typeof res === 'object' && 'message' in res && typeof res.message === 'string'
? res.message
: 'Réponse serveur inattendue';
throw new Error(msg);
}
export async function appendVisitePhoto(visiteId: string, localUri: string): Promise<VisiteRecord> {
const form = new FormData();
form.append('photos', {
uri: localUri,
name: 'photo.jpg',
type: 'image/jpeg',
} as unknown as Blob);
return pb.collection('visites').update<VisiteRecord>(visiteId, form);
}

View File

@ -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 lappareil)"
onPress={() => {
void app.enterLocalMode().then(() => router.replace('/(tabs)'));
}}
containerStyle={styles.btn}
/>
<PrimaryButton
title="Se connecter (Supabase)"
variant="ghost"
onPress={() => router.push('/auth/login')}
containerStyle={styles.btn}
/>
<PrimaryButton
title="Configurer Supabase"
variant="ghost"
onPress={() => router.push('/(tabs)/reglages')}
containerStyle={styles.btn}
/>
<Text style={styles.hint}>
Le mode hors-ligne fonctionne sans compte. Pour synchroniser plusieurs
appareils, renseignez votre projet Supabase dans Réglages puis
connectez-vous.
</Text>
</ScrollView>
);
}
const styles = StyleSheet.create({
center: {
flex: 1,
backgroundColor: colors.bg,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
muted: { color: colors.textMuted },
scroll: { paddingHorizontal: 22, backgroundColor: colors.bg },
brand: {
fontSize: 34,
fontWeight: '800',
color: colors.text,
marginBottom: 8,
},
tagline: {
fontSize: 16,
color: colors.textMuted,
lineHeight: 24,
marginBottom: 28,
},
btn: { marginBottom: 12, width: '100%' },
hint: {
marginTop: 20,
fontSize: 13,
color: colors.textMuted,
lineHeight: 20,
},
});

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,10 @@
{ {
"name": "mb-app", "name": "app",
"main": "expo-router/entry",
"version": "1.0.0", "version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": { "scripts": {
"postinstall": "node scripts/ensure-assets.js",
"start": "expo start", "start": "expo start",
"android": "expo start --android", "android": "expo start --android",
"ios": "expo start --ios", "ios": "expo start --ios",
@ -12,41 +14,36 @@
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"@supabase/supabase-js": "^2.105.1", "@tanstack/react-query": "^5.90.0",
"@tanstack/react-query": "^5.100.9", "expo": "~54.0.0",
"expo": "~54.0.33",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-document-picker": "~14.0.8", "expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.22",
"expo-font": "~14.0.11", "expo-font": "~14.0.11",
"expo-image-picker": "~17.0.11", "expo-haptics": "~15.0.7",
"expo-image-picker": "~17.0.8",
"expo-linking": "~8.0.11", "expo-linking": "~8.0.11",
"expo-notifications": "~0.32.17", "expo-router": "~6.0.0",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13", "expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.8",
"expo-web-browser": "~15.0.10", "expo-web-browser": "~15.0.10",
"nativewind": "^4.2.3", "nativewind": "^4.1.23",
"pocketbase": "^0.26.0",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.4",
"react-native-gesture-handler": "~2.28.0", "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-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-url-polyfill": "^3.0.0",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.17",
"zustand": "^5.0.12" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.26.0",
"@types/react": "~19.1.0", "@types/react": "~19.1.0",
"prettier-plugin-tailwindcss": "^0.8.0", "babel-preset-expo": "~54.0.0",
"react-test-renderer": "19.1.0",
"typescript": "~5.9.2" "typescript": "~5.9.2"
}, }
"private": true
} }

View File

@ -0,0 +1,26 @@
/**
* Postinstall : vérifie que les icônes Expo existent (déjà dans le repo).
*/
const fs = require('fs');
const path = require('path');
const required = [
'assets/images/icon.png',
'assets/images/splash-icon.png',
'assets/images/adaptive-icon.png',
'assets/images/favicon.png',
];
const root = path.join(__dirname, '..');
let ok = true;
for (const rel of required) {
const p = path.join(root, rel);
if (!fs.existsSync(p)) {
console.warn('[ensure-assets] missing:', rel);
ok = false;
}
}
if (!ok) {
console.warn('[ensure-assets] add missing images under assets/images/');
}
process.exit(0);

View File

@ -0,0 +1,86 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import PocketBase, { type AuthRecord } from 'pocketbase';
const PB_AUTH_KEY = 'mdb_pb_auth';
function resolvePocketBaseUrl(): string {
const fromEnv = process.env.EXPO_PUBLIC_PB_URL?.replace(/\/$/, '').trim() ?? '';
if (fromEnv.startsWith('http://') || fromEnv.startsWith('https://')) {
return fromEnv;
}
const fallback = 'http://localhost:8090';
if (typeof __DEV__ !== 'undefined' && __DEV__) {
console.warn(
'[mdb] EXPO_PUBLIC_PB_URL absent ou invalide — utilisation de',
fallback,
'(placez EXPO_PUBLIC_PB_URL dans app/.env.local ou mdb/.env.local ; redémarrez Expo)',
);
}
return fallback;
}
const baseUrl = resolvePocketBaseUrl();
export const pb = new PocketBase(baseUrl);
pb.autoCancellation(false);
type StoredAuth = {
token: string;
record: AuthRecord;
};
let persistListenerRegistered = false;
function registerAuthPersistence(): void {
if (persistListenerRegistered) return;
persistListenerRegistered = true;
pb.authStore.onChange(() => {
void (async () => {
try {
if (!pb.authStore.isValid || !pb.authStore.token || !pb.authStore.record) {
await AsyncStorage.removeItem(PB_AUTH_KEY);
return;
}
const payload: StoredAuth = {
token: pb.authStore.token,
record: pb.authStore.record,
};
await AsyncStorage.setItem(PB_AUTH_KEY, JSON.stringify(payload));
} catch {
await AsyncStorage.removeItem(PB_AUTH_KEY);
}
})();
}, true);
}
export async function hydratePocketBaseAuth(): Promise<void> {
registerAuthPersistence();
const raw = await AsyncStorage.getItem(PB_AUTH_KEY);
if (!raw) return;
try {
const { token, record } = JSON.parse(raw) as StoredAuth;
if (!token || !record) {
await AsyncStorage.removeItem(PB_AUTH_KEY);
return;
}
pb.authStore.save(token, record);
try {
await pb.collection('users').authRefresh();
} catch {
pb.authStore.clear();
await AsyncStorage.removeItem(PB_AUTH_KEY);
}
} catch {
await AsyncStorage.removeItem(PB_AUTH_KEY);
}
}
export function getCurrentUserId(): string | undefined {
return pb.authStore.record?.id;
}
export function isAuthenticated(): boolean {
return pb.authStore.isValid;
}

6
app/tailwind.config.js Normal file
View File

@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./app/**/*.{js,jsx,ts,tsx}'],
presets: [require('nativewind/preset')],
theme: { extend: {} },
};

10
app/tsconfig.json Normal file
View File

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

167
app/types/collections.ts Normal file
View File

@ -0,0 +1,167 @@
import type { RecordModel } from 'pocketbase';
export type UserRecord = RecordModel & {
email: string;
name?: string;
};
export type BienType =
| 'appartement'
| 'maison'
| 'immeuble'
| 'terrain'
| 'local_commercial'
| 'parking'
| 'cave'
| 'autre';
export type BienSource =
| 'particulier'
| 'agence'
| 'notaire'
| 'tribunal'
| 'succession'
| 'reseau'
| 'autre';
export type TypeBienFiscal = 'ancien' | 'neuf';
export type BienRecord = RecordModel & {
user: string;
etape?: string;
source_contact?: string;
titre?: string;
type_bien?: BienType;
adresse?: string;
code_postal?: string;
ville?: string;
latitude?: number;
longitude?: number;
surface_habitable?: number;
surface_totale?: number;
nb_pieces?: number;
nb_chambres?: number;
annee_construction?: number;
dpe_lettre?: string;
dpe_valeur?: number;
source?: BienSource;
url_annonce?: string;
statut?: string;
priorite?: number;
is_off_market?: boolean;
date_premiere_visite?: string;
date_offre?: string;
date_compromis?: string;
date_acte?: string;
description?: string;
points_forts?: string;
points_faibles?: string;
photo_principale?: string;
};
export type BienCreate = Partial<Omit<BienRecord, 'id' | 'created' | 'updated' | 'collectionId' | 'collectionName'>> & {
user: string;
ville: string;
code_postal: string;
type_bien: BienType;
};
export type BienUpdate = Partial<Omit<BienRecord, 'id' | 'user' | 'created' | 'updated' | 'collectionId' | 'collectionName'>>;
export type EtapePipelineRecord = RecordModel & {
user: string;
nom: string;
ordre: number;
couleur?: string;
is_terminal?: boolean;
};
export type ContactRecord = RecordModel & {
user: string;
nom: string;
prenom?: string;
societe?: string;
categorie: string;
specialite?: string;
email?: string;
telephone?: string;
telephone_2?: string;
ville?: string;
zone_intervention?: string;
note?: number;
recommande?: boolean;
taux_horaire?: number;
notes?: string;
is_favori?: boolean;
};
export type AnalyseFinanciereRecord = RecordModel & {
user: string;
bien: string;
prix_achat?: number;
type_bien_fiscal?: TypeBienFiscal;
frais_notaire?: number;
frais_agence_achat?: number;
budget_travaux?: number;
reserve_imprevus_pct?: number;
duree_portage_mois?: number;
taux_credit?: number;
taxe_fonciere_annuelle?: number;
charges_copropriete_mensuelle?: number;
prix_revente_cible?: number;
frais_agence_vente_pct?: number;
taux_impot?: number;
marge_brute?: number;
marge_brute_pct?: number;
marge_nette?: number;
marge_nette_pct?: number;
notes?: string;
};
export type VisiteRecord = RecordModel & {
user: string;
bien: string;
date_visite: string;
duree_minutes?: number;
type_visite?: string;
avis_global?: string;
notes_brutes?: string;
rapport_genere?: string;
checklist_reponses?: Record<string, string>;
estimation_travaux_min?: number;
estimation_travaux_max?: number;
score_opportunite?: number;
photos?: string[];
};
export type TacheRecord = RecordModel & {
user: string;
bien?: string;
contact?: string;
titre: string;
description?: string;
type_tache?: string;
priorite?: number;
statut?: string;
date_echeance?: string;
date_rappel?: string;
is_urgent?: boolean;
};
export type TacheExpanded = TacheRecord & {
expand?: { bien?: BienRecord };
};
export type NoteRecord = RecordModel & {
user: string;
bien: string;
contenu: string;
type_note?: string;
};
export type DocumentRecord = RecordModel & {
user: string;
bien: string;
nom: string;
type_document?: string;
};

72
app/utils/agendaDates.ts Normal file
View File

@ -0,0 +1,72 @@
import type { TacheRecord } from '@/types/collections';
/** Parse PocketBase `date` (YYYY-MM-DD) en date locale minuit. */
export function parsePbDateOnly(raw?: string | null): Date | null {
if (!raw) return null;
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(raw.trim());
if (!m) return null;
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
}
export function startOfLocalDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
export function addDays(d: Date, n: number): Date {
const x = new Date(d);
x.setDate(x.getDate() + n);
return x;
}
export function formatPbDateOnly(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function isTaskActive(statut?: string): boolean {
return statut !== 'fait' && statut !== 'annule';
}
export type AgendaPartition = {
overdue: TacheRecord[];
today: TacheRecord[];
week: TacheRecord[];
nodate: TacheRecord[];
};
/** Tâches actives : en retard, aujourdhui, dans les 7 jours (excl. aujourdhui), sans date. */
export function partitionTachesForAgenda(taches: TacheRecord[]): AgendaPartition {
const now = new Date();
const startToday = startOfLocalDay(now);
const startTomorrow = addDays(startToday, 1);
const endWeek = addDays(startToday, 7);
const overdue: TacheRecord[] = [];
const today: TacheRecord[] = [];
const week: TacheRecord[] = [];
const nodate: TacheRecord[] = [];
for (const t of taches) {
if (!isTaskActive(t.statut)) continue;
const d = parsePbDateOnly(t.date_echeance);
if (!d) {
nodate.push(t);
continue;
}
if (d < startToday) overdue.push(t);
else if (d < startTomorrow) today.push(t);
else if (d < endWeek) week.push(t);
}
const byDue = (a: TacheRecord, b: TacheRecord) => {
const da = parsePbDateOnly(a.date_echeance)?.getTime() ?? 0;
const db = parsePbDateOnly(b.date_echeance)?.getTime() ?? 0;
return da - db;
};
overdue.sort(byDue);
today.sort(byDue);
week.sort(byDue);
return { overdue, today, week, nodate };
}

8
app/utils/format.ts Normal file
View File

@ -0,0 +1,8 @@
export function formatEUR(value: number | null | undefined): string {
if (value == null || Number.isNaN(value)) return '—';
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value);
}
export function roundMoney(n: number): number {
return Math.round(n * 100) / 100;
}

View File

@ -0,0 +1,12 @@
import { ClientResponseError } from 'pocketbase';
export function formatPocketBaseError(e: unknown): string {
if (e instanceof ClientResponseError) {
const msg = e.response?.message;
if (typeof msg === 'string') return msg;
if (Array.isArray(msg)) return msg.join(', ');
if (e.message) return e.message;
}
if (e instanceof Error) return e.message;
return 'Une erreur est survenue.';
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

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

View File

@ -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

View File

@ -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

View 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

View 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

View File

@ -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
View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
}
}`;

View File

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

View File

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

View File

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

View File

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

View File

@ -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&apos;inscrire
</Button>
<View className="mt-8 items-center">
<Link href="/auth/login" asChild>
<Pressable className="py-2">
<Text className="font-semibold text-primary">Retour à la connexion</Text>
</Pressable>
</Link>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

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

View File

@ -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);
}
}}
/>
);
}

View File

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

View File

@ -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} />;
}

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